Compare commits

...

4 Commits

32 changed files with 939 additions and 388 deletions

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release // Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.4.7" val rosettaVersionName = "1.4.8"
val rosettaVersionCode = 49 // Increment on each release val rosettaVersionCode = 50 // Increment on each release
val customWebRtcAar = file("libs/libwebrtc-custom.aar") val customWebRtcAar = file("libs/libwebrtc-custom.aar")
android { android {

View File

@@ -16,10 +16,15 @@ import androidx.activity.compose.BackHandler
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -67,6 +72,7 @@ import com.rosetta.messenger.ui.crashlogs.CrashLogsScreen
import com.rosetta.messenger.ui.onboarding.OnboardingScreen import com.rosetta.messenger.ui.onboarding.OnboardingScreen
import com.rosetta.messenger.ui.settings.BackupScreen import com.rosetta.messenger.ui.settings.BackupScreen
import com.rosetta.messenger.ui.settings.BiometricEnableScreen import com.rosetta.messenger.ui.settings.BiometricEnableScreen
import com.rosetta.messenger.ui.settings.NotificationsScreen
import com.rosetta.messenger.ui.settings.OtherProfileScreen import com.rosetta.messenger.ui.settings.OtherProfileScreen
import com.rosetta.messenger.ui.settings.ProfileScreen import com.rosetta.messenger.ui.settings.ProfileScreen
import com.rosetta.messenger.ui.settings.SafetyScreen import com.rosetta.messenger.ui.settings.SafetyScreen
@@ -203,6 +209,8 @@ class MainActivity : FragmentActivity() {
var currentAccount by remember { mutableStateOf(getCachedSessionAccount()) } var currentAccount by remember { mutableStateOf(getCachedSessionAccount()) }
var accountInfoList by remember { mutableStateOf<List<AccountInfo>>(emptyList()) } var accountInfoList by remember { mutableStateOf<List<AccountInfo>>(emptyList()) }
var startCreateAccountFlow by remember { mutableStateOf(false) } var startCreateAccountFlow by remember { mutableStateOf(false) }
var preservedMainNavStack by remember { mutableStateOf<List<Screen>>(emptyList()) }
var preservedMainNavAccountKey by remember { mutableStateOf("") }
// Check for existing accounts and build AccountInfo list // Check for existing accounts and build AccountInfo list
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -303,6 +311,8 @@ class MainActivity : FragmentActivity() {
}, },
onLogout = { onLogout = {
startCreateAccountFlow = false startCreateAccountFlow = false
preservedMainNavStack = emptyList()
preservedMainNavAccountKey = ""
// Set currentAccount to null immediately to prevent UI // Set currentAccount to null immediately to prevent UI
// lag // lag
currentAccount = null currentAccount = null
@@ -316,8 +326,27 @@ class MainActivity : FragmentActivity() {
) )
} }
"main" -> { "main" -> {
val activeAccountKey = currentAccount?.publicKey.orEmpty()
MainScreen( MainScreen(
account = currentAccount, account = currentAccount,
initialNavStack =
if (
activeAccountKey.isNotBlank() &&
preservedMainNavAccountKey.equals(
activeAccountKey,
ignoreCase = true
)
) {
preservedMainNavStack
} else {
emptyList()
},
onNavStackChanged = { stack ->
if (activeAccountKey.isNotBlank()) {
preservedMainNavAccountKey = activeAccountKey
preservedMainNavStack = stack
}
},
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
themeMode = themeMode, themeMode = themeMode,
onToggleTheme = { onToggleTheme = {
@@ -331,6 +360,8 @@ class MainActivity : FragmentActivity() {
}, },
onLogout = { onLogout = {
startCreateAccountFlow = false startCreateAccountFlow = false
preservedMainNavStack = emptyList()
preservedMainNavAccountKey = ""
// Set currentAccount to null immediately to prevent UI // Set currentAccount to null immediately to prevent UI
// lag // lag
currentAccount = null currentAccount = null
@@ -343,6 +374,8 @@ class MainActivity : FragmentActivity() {
}, },
onDeleteAccount = { onDeleteAccount = {
startCreateAccountFlow = false startCreateAccountFlow = false
preservedMainNavStack = emptyList()
preservedMainNavAccountKey = ""
val publicKey = currentAccount?.publicKey ?: return@MainScreen val publicKey = currentAccount?.publicKey ?: return@MainScreen
scope.launch { scope.launch {
try { try {
@@ -405,6 +438,8 @@ class MainActivity : FragmentActivity() {
hasExistingAccount = accounts.isNotEmpty() hasExistingAccount = accounts.isNotEmpty()
// 9. If current account is deleted, return to main login screen // 9. If current account is deleted, return to main login screen
if (currentAccount?.publicKey == targetPublicKey) { if (currentAccount?.publicKey == targetPublicKey) {
preservedMainNavStack = emptyList()
preservedMainNavAccountKey = ""
currentAccount = null currentAccount = null
clearCachedSessionAccount() clearCachedSessionAccount()
} }
@@ -415,6 +450,8 @@ class MainActivity : FragmentActivity() {
}, },
onSwitchAccount = { targetPublicKey -> onSwitchAccount = { targetPublicKey ->
startCreateAccountFlow = false startCreateAccountFlow = false
preservedMainNavStack = emptyList()
preservedMainNavAccountKey = ""
// Save target account before leaving main screen so Unlock // Save target account before leaving main screen so Unlock
// screen preselects the account the user tapped. // screen preselects the account the user tapped.
accountManager.setLastLoggedPublicKey(targetPublicKey) accountManager.setLastLoggedPublicKey(targetPublicKey)
@@ -429,6 +466,8 @@ class MainActivity : FragmentActivity() {
}, },
onAddAccount = { onAddAccount = {
startCreateAccountFlow = true startCreateAccountFlow = true
preservedMainNavStack = emptyList()
preservedMainNavAccountKey = ""
currentAccount = null currentAccount = null
clearCachedSessionAccount() clearCachedSessionAccount()
scope.launch { scope.launch {
@@ -442,6 +481,8 @@ class MainActivity : FragmentActivity() {
DeviceConfirmScreen( DeviceConfirmScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onExit = { onExit = {
preservedMainNavStack = emptyList()
preservedMainNavAccountKey = ""
currentAccount = null currentAccount = null
clearCachedSessionAccount() clearCachedSessionAccount()
scope.launch { scope.launch {
@@ -614,6 +655,7 @@ private fun EncryptedAccount.toAccountInfo(): AccountInfo {
sealed class Screen { sealed class Screen {
data object Profile : Screen() data object Profile : Screen()
data object ProfileFromChat : Screen() data object ProfileFromChat : Screen()
data object Notifications : Screen()
data object Requests : Screen() data object Requests : Screen()
data object Search : Screen() data object Search : Screen()
data object GroupSetup : Screen() data object GroupSetup : Screen()
@@ -634,6 +676,8 @@ sealed class Screen {
@Composable @Composable
fun MainScreen( fun MainScreen(
account: DecryptedAccount? = null, account: DecryptedAccount? = null,
initialNavStack: List<Screen> = emptyList(),
onNavStackChanged: (List<Screen>) -> Unit = {},
isDarkTheme: Boolean = true, isDarkTheme: Boolean = true,
themeMode: String = "dark", themeMode: String = "dark",
onToggleTheme: () -> Unit = {}, onToggleTheme: () -> Unit = {},
@@ -941,7 +985,8 @@ fun MainScreen(
// navigation change. This eliminates the massive recomposition // navigation change. This eliminates the massive recomposition
// that happened when ANY boolean flag changed. // that happened when ANY boolean flag changed.
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
var navStack by remember { mutableStateOf<List<Screen>>(emptyList()) } var navStack by remember(accountPublicKey) { mutableStateOf(initialNavStack) }
LaunchedEffect(navStack) { onNavStackChanged(navStack) }
// Derived visibility — only triggers recomposition when THIS screen changes // Derived visibility — only triggers recomposition when THIS screen changes
val isProfileVisible by remember { derivedStateOf { navStack.any { it is Screen.Profile } } } val isProfileVisible by remember { derivedStateOf { navStack.any { it is Screen.Profile } } }
@@ -949,6 +994,9 @@ fun MainScreen(
derivedStateOf { navStack.any { it is Screen.ProfileFromChat } } derivedStateOf { navStack.any { it is Screen.ProfileFromChat } }
} }
val isRequestsVisible by remember { derivedStateOf { navStack.any { it is Screen.Requests } } } val isRequestsVisible by remember { derivedStateOf { navStack.any { it is Screen.Requests } } }
val isNotificationsVisible by remember {
derivedStateOf { navStack.any { it is Screen.Notifications } }
}
val isSearchVisible by remember { derivedStateOf { navStack.any { it is Screen.Search } } } val isSearchVisible by remember { derivedStateOf { navStack.any { it is Screen.Search } } }
val isGroupSetupVisible by remember { derivedStateOf { navStack.any { it is Screen.GroupSetup } } } val isGroupSetupVisible by remember { derivedStateOf { navStack.any { it is Screen.GroupSetup } } }
val chatDetailScreen by remember { val chatDetailScreen by remember {
@@ -980,6 +1028,9 @@ fun MainScreen(
val isAppearanceVisible by remember { val isAppearanceVisible by remember {
derivedStateOf { navStack.any { it is Screen.Appearance } } derivedStateOf { navStack.any { it is Screen.Appearance } }
} }
var profileHasUnsavedChanges by remember(accountPublicKey) { mutableStateOf(false) }
var showDiscardProfileChangesDialog by remember { mutableStateOf(false) }
var discardProfileChangesFromChat by remember { mutableStateOf(false) }
// Navigation helpers // Navigation helpers
fun pushScreen(screen: Screen) { fun pushScreen(screen: Screen) {
@@ -1028,6 +1079,7 @@ fun MainScreen(
navStack.filterNot { navStack.filterNot {
it is Screen.Profile || it is Screen.Profile ||
it is Screen.Theme || it is Screen.Theme ||
it is Screen.Notifications ||
it is Screen.Safety || it is Screen.Safety ||
it is Screen.Backup || it is Screen.Backup ||
it is Screen.Logs || it is Screen.Logs ||
@@ -1036,6 +1088,21 @@ fun MainScreen(
it is Screen.Appearance it is Screen.Appearance
} }
} }
fun performProfileBack(fromChat: Boolean) {
if (fromChat) {
navStack = navStack.filterNot { it is Screen.ProfileFromChat }
} else {
popProfileAndChildren()
}
}
fun requestProfileBack(fromChat: Boolean) {
if (profileHasUnsavedChanges) {
discardProfileChangesFromChat = fromChat
showDiscardProfileChangesDialog = true
} else {
performProfileBack(fromChat)
}
}
fun popChatAndChildren() { fun popChatAndChildren() {
navStack = navStack =
navStack.filterNot { navStack.filterNot {
@@ -1043,6 +1110,13 @@ fun MainScreen(
} }
} }
LaunchedEffect(isProfileVisible, isProfileFromChatVisible) {
if (!isProfileVisible && !isProfileFromChatVisible) {
profileHasUnsavedChanges = false
showDiscardProfileChangesDialog = false
}
}
// ProfileViewModel для логов // ProfileViewModel для логов
val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel = val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel =
androidx.lifecycle.viewmodel.compose.viewModel() androidx.lifecycle.viewmodel.compose.viewModel()
@@ -1196,9 +1270,10 @@ fun MainScreen(
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
SwipeBackContainer( SwipeBackContainer(
isVisible = isProfileVisible, isVisible = isProfileVisible,
onBack = { popProfileAndChildren() }, onBack = { requestProfileBack(fromChat = false) },
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
layer = 1, layer = 1,
swipeEnabled = !profileHasUnsavedChanges,
propagateBackgroundProgress = false propagateBackgroundProgress = false
) { ) {
// Экран профиля // Экран профиля
@@ -1209,13 +1284,15 @@ fun MainScreen(
accountVerified = accountVerified, accountVerified = accountVerified,
accountPublicKey = accountPublicKey, accountPublicKey = accountPublicKey,
accountPrivateKeyHash = privateKeyHash, accountPrivateKeyHash = privateKeyHash,
onBack = { popProfileAndChildren() }, onBack = { requestProfileBack(fromChat = false) },
onHasChangesChanged = { profileHasUnsavedChanges = it },
onSaveProfile = { name, username -> onSaveProfile = { name, username ->
accountName = name accountName = name
accountUsername = username accountUsername = username
mainScreenScope.launch { onAccountInfoUpdated() } mainScreenScope.launch { onAccountInfoUpdated() }
}, },
onLogout = onLogout, onLogout = onLogout,
onNavigateToNotifications = { pushScreen(Screen.Notifications) },
onNavigateToTheme = { pushScreen(Screen.Theme) }, onNavigateToTheme = { pushScreen(Screen.Theme) },
onNavigateToAppearance = { pushScreen(Screen.Appearance) }, onNavigateToAppearance = { pushScreen(Screen.Appearance) },
onNavigateToSafety = { pushScreen(Screen.Safety) }, onNavigateToSafety = { pushScreen(Screen.Safety) },
@@ -1229,6 +1306,18 @@ fun MainScreen(
} }
// Other screens with swipe back // Other screens with swipe back
SwipeBackContainer(
isVisible = isNotificationsVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Notifications } },
isDarkTheme = isDarkTheme,
layer = 2
) {
NotificationsScreen(
isDarkTheme = isDarkTheme,
onBack = { navStack = navStack.filterNot { it is Screen.Notifications } }
)
}
SwipeBackContainer( SwipeBackContainer(
isVisible = isSafetyVisible, isVisible = isSafetyVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Safety } }, onBack = { navStack = navStack.filterNot { it is Screen.Safety } },
@@ -1420,9 +1509,10 @@ fun MainScreen(
SwipeBackContainer( SwipeBackContainer(
isVisible = isProfileFromChatVisible, isVisible = isProfileFromChatVisible,
onBack = { navStack = navStack.filterNot { it is Screen.ProfileFromChat } }, onBack = { requestProfileBack(fromChat = true) },
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
layer = 1, layer = 1,
swipeEnabled = !profileHasUnsavedChanges,
propagateBackgroundProgress = false propagateBackgroundProgress = false
) { ) {
ProfileScreen( ProfileScreen(
@@ -1432,13 +1522,15 @@ fun MainScreen(
accountVerified = accountVerified, accountVerified = accountVerified,
accountPublicKey = accountPublicKey, accountPublicKey = accountPublicKey,
accountPrivateKeyHash = privateKeyHash, accountPrivateKeyHash = privateKeyHash,
onBack = { navStack = navStack.filterNot { it is Screen.ProfileFromChat } }, onBack = { requestProfileBack(fromChat = true) },
onHasChangesChanged = { profileHasUnsavedChanges = it },
onSaveProfile = { name, username -> onSaveProfile = { name, username ->
accountName = name accountName = name
accountUsername = username accountUsername = username
mainScreenScope.launch { onAccountInfoUpdated() } mainScreenScope.launch { onAccountInfoUpdated() }
}, },
onLogout = onLogout, onLogout = onLogout,
onNavigateToNotifications = { pushScreen(Screen.Notifications) },
onNavigateToTheme = { pushScreen(Screen.Theme) }, onNavigateToTheme = { pushScreen(Screen.Theme) },
onNavigateToAppearance = { pushScreen(Screen.Appearance) }, onNavigateToAppearance = { pushScreen(Screen.Appearance) },
onNavigateToSafety = { pushScreen(Screen.Safety) }, onNavigateToSafety = { pushScreen(Screen.Safety) },
@@ -1498,8 +1590,7 @@ fun MainScreen(
isVisible = isSearchVisible, isVisible = isSearchVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Search } }, onBack = { navStack = navStack.filterNot { it is Screen.Search } },
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
layer = 1, layer = 1
deferToChildren = true
) { ) {
// Экран поиска // Экран поиска
SearchScreen( SearchScreen(
@@ -1690,6 +1781,21 @@ fun MainScreen(
) )
} }
if (isCallScreenVisible) {
// Блокируем любой ввод по нижележащим экранам, пока открыт полноэкранный CallOverlay.
// Иначе тапы могут "пробивать" в чат (иконка звонка, kebab, input и т.д.).
val blockerInteraction = remember { MutableInteractionSource() }
Box(
modifier = Modifier
.fillMaxSize()
.clickable(
interactionSource = blockerInteraction,
indication = null,
onClick = {}
)
)
}
CallOverlay( CallOverlay(
state = callUiState, state = callUiState,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
@@ -1706,5 +1812,43 @@ fun MainScreen(
} }
} }
) )
if (showDiscardProfileChangesDialog) {
AlertDialog(
onDismissRequest = { showDiscardProfileChangesDialog = false },
containerColor = if (isDarkTheme) Color(0xFF1E1E20) else Color.White,
title = {
Text(
text = "Discard changes?",
color = if (isDarkTheme) Color.White else Color.Black
)
},
text = {
Text(
text = "You have unsaved profile changes. If you leave now, they will be lost.",
color = if (isDarkTheme) Color(0xFFB5B5BC) else Color(0xFF5A5A66)
)
},
confirmButton = {
TextButton(
onClick = {
showDiscardProfileChangesDialog = false
profileHasUnsavedChanges = false
performProfileBack(discardProfileChangesFromChat)
}
) {
Text("Discard", color = Color(0xFFFF3B30))
}
},
dismissButton = {
TextButton(onClick = { showDiscardProfileChangesDialog = false }) {
Text(
"Stay",
color = if (isDarkTheme) Color(0xFF5FA8FF) else Color(0xFF0D8CF4)
)
}
}
)
}
} }
} }

View File

@@ -448,6 +448,18 @@ class MessageRepository private constructor(private val context: Context) {
return if (raw < 1_000_000_000_000L) raw * 1000L else raw return if (raw < 1_000_000_000_000L) raw * 1000L else raw
} }
/**
* Normalize incoming message timestamp for chat ordering:
* 1) accept both seconds and milliseconds;
* 2) never allow a message timestamp from the future on this device.
*/
private fun normalizeIncomingPacketTimestamp(rawTimestamp: Long, receivedAtMs: Long): Long {
val normalizedRaw =
if (rawTimestamp in 1..999_999_999_999L) rawTimestamp * 1000L else rawTimestamp
if (normalizedRaw <= 0L) return receivedAtMs
return minOf(normalizedRaw, receivedAtMs)
}
/** Получить поток сообщений для диалога */ /** Получить поток сообщений для диалога */
fun getMessagesFlow(opponentKey: String): StateFlow<List<Message>> { fun getMessagesFlow(opponentKey: String): StateFlow<List<Message>> {
val dialogKey = getDialogKey(opponentKey) val dialogKey = getDialogKey(opponentKey)
@@ -711,6 +723,13 @@ class MessageRepository private constructor(private val context: Context) {
val isOwnMessage = packet.fromPublicKey == account val isOwnMessage = packet.fromPublicKey == account
val isGroupMessage = isGroupDialogKey(packet.toPublicKey) val isGroupMessage = isGroupDialogKey(packet.toPublicKey)
val normalizedPacketTimestamp =
normalizeIncomingPacketTimestamp(packet.timestamp, startTime)
if (normalizedPacketTimestamp != packet.timestamp) {
MessageLogger.debug(
"📥 TIMESTAMP normalized: raw=${packet.timestamp} -> local=$normalizedPacketTimestamp"
)
}
// 🔥 Проверяем, не заблокирован ли отправитель // 🔥 Проверяем, не заблокирован ли отправитель
if (!isOwnMessage && !isGroupDialogKey(packet.fromPublicKey)) { if (!isOwnMessage && !isGroupDialogKey(packet.fromPublicKey)) {
@@ -911,7 +930,7 @@ class MessageRepository private constructor(private val context: Context) {
fromPublicKey = packet.fromPublicKey, fromPublicKey = packet.fromPublicKey,
toPublicKey = packet.toPublicKey, toPublicKey = packet.toPublicKey,
content = packet.content, content = packet.content,
timestamp = packet.timestamp, timestamp = normalizedPacketTimestamp,
chachaKey = storedChachaKey, chachaKey = storedChachaKey,
read = 0, read = 0,
fromMe = if (isOwnMessage) 1 else 0, fromMe = if (isOwnMessage) 1 else 0,

View File

@@ -17,20 +17,19 @@ object ReleaseNotes {
val RELEASE_NOTICE = """ val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER Update v$VERSION_PLACEHOLDER
Звонки и lockscreen Синхронизация (как на Desktop)
- MainActivity больше не открывается поверх экрана блокировки: чаты не раскрываются без разблокировки устройства - Во время sync экран чатов показывает "Updating..." и скрывает шумящие промежуточные индикаторы
- Во входящем полноэкранном звонке отключено автоматическое снятие keyguard - На период синхронизации скрываются badge'ы непрочитанного и requests, чтобы список не "прыгал"
- Исправлено краткое появление "Unknown" при завершении полноэкранного звонка
- При принятии звонка из push добавлено восстановление auth из локального кеша и ускорена отправка ACCEPT
Сеть и протокол Медиа и вложения
- Добавлено ожидание активной сети перед reconnect (ConnectivityManager callback + timeout fallback) - Исправлен кейс, когда фото уже отправлено, но локально оставалось в ERROR с красным индикатором
- Разрешена pre-auth отправка call/WebRTC/ICE пакетов после открытия сокета - Для исходящих медиа стабилизирован переход статусов: после успешной отправки фиксируется SENT без ложного timeout->ERROR
- Очередь исходящих пакетов теперь сбрасывается сразу в onOpen и отправляется state-aware - Таймаут/ретрай WAITING из БД больше не портит медиа-вложения (применяется только к обычным текстовым ожиданиям)
- Для legacy/неподдерживаемых attachment добавлен desktop-style fallback:
"This attachment is no longer available because it was sent for a previous version of the app."
Стабильность UI Группы и UI
- Crash Details защищён от очень больших логов (без падений при открытии тяжёлых отчётов) - Исправлена геометрия входящих фото в группах: пузырь больше не прилипает к аватарке
- SharedMedia fast-scroll overlay стабилизирован от NaN/Infinity координат
""".trimIndent() """.trimIndent()
fun getNotice(version: String): String = fun getNotice(version: String): String =

View File

@@ -238,7 +238,7 @@ interface MessageDao {
""" """
SELECT * FROM messages SELECT * FROM messages
WHERE account = :account AND dialog_key = :dialogKey WHERE account = :account AND dialog_key = :dialogKey
ORDER BY timestamp DESC, message_id DESC ORDER BY timestamp DESC, id DESC
LIMIT :limit OFFSET :offset LIMIT :limit OFFSET :offset
""" """
) )
@@ -260,7 +260,7 @@ interface MessageDao {
WHERE account = :account WHERE account = :account
AND from_public_key = :account AND from_public_key = :account
AND to_public_key = :account AND to_public_key = :account
ORDER BY timestamp DESC, message_id DESC ORDER BY timestamp DESC, id DESC
LIMIT :limit OFFSET :offset LIMIT :limit OFFSET :offset
""" """
) )
@@ -286,7 +286,7 @@ interface MessageDao {
""" """
SELECT * FROM messages SELECT * FROM messages
WHERE account = :account AND dialog_key = :dialogKey WHERE account = :account AND dialog_key = :dialogKey
ORDER BY timestamp ASC, message_id ASC ORDER BY timestamp ASC, id ASC
""" """
) )
fun getMessagesFlow(account: String, dialogKey: String): Flow<List<MessageEntity>> fun getMessagesFlow(account: String, dialogKey: String): Flow<List<MessageEntity>>
@@ -319,7 +319,7 @@ interface MessageDao {
""" """
SELECT * FROM messages SELECT * FROM messages
WHERE account = :account AND dialog_key = :dialogKey WHERE account = :account AND dialog_key = :dialogKey
ORDER BY timestamp DESC, message_id DESC ORDER BY timestamp DESC, id DESC
LIMIT :limit LIMIT :limit
""" """
) )
@@ -378,7 +378,7 @@ interface MessageDao {
AND dialog_key = :dialogKey AND dialog_key = :dialogKey
AND from_public_key = :fromPublicKey AND from_public_key = :fromPublicKey
AND timestamp BETWEEN :timestampFrom AND :timestampTo AND timestamp BETWEEN :timestampFrom AND :timestampTo
ORDER BY timestamp ASC, message_id ASC ORDER BY timestamp ASC, id ASC
LIMIT 1 LIMIT 1
""" """
) )
@@ -462,7 +462,7 @@ interface MessageDao {
WHERE account = :account WHERE account = :account
AND ((from_public_key = :opponent AND to_public_key = :account) AND ((from_public_key = :opponent AND to_public_key = :account)
OR (from_public_key = :account AND to_public_key = :opponent)) OR (from_public_key = :account AND to_public_key = :opponent))
ORDER BY timestamp DESC, message_id DESC LIMIT 1 ORDER BY timestamp DESC, id DESC LIMIT 1
""" """
) )
suspend fun getLastMessageDebug(account: String, opponent: String): MessageEntity? suspend fun getLastMessageDebug(account: String, opponent: String): MessageEntity?
@@ -477,7 +477,7 @@ interface MessageDao {
WHERE account = :account WHERE account = :account
AND ((from_public_key = :opponent AND to_public_key = :account) AND ((from_public_key = :opponent AND to_public_key = :account)
OR (from_public_key = :account AND to_public_key = :opponent)) OR (from_public_key = :account AND to_public_key = :opponent))
ORDER BY timestamp DESC, message_id DESC LIMIT 1 ORDER BY timestamp DESC, id DESC LIMIT 1
""" """
) )
suspend fun getLastMessageStatus(account: String, opponent: String): LastMessageStatus? suspend fun getLastMessageStatus(account: String, opponent: String): LastMessageStatus?
@@ -492,7 +492,7 @@ interface MessageDao {
WHERE account = :account WHERE account = :account
AND ((from_public_key = :opponent AND to_public_key = :account) AND ((from_public_key = :opponent AND to_public_key = :account)
OR (from_public_key = :account AND to_public_key = :opponent)) OR (from_public_key = :account AND to_public_key = :opponent))
ORDER BY timestamp DESC, message_id DESC LIMIT 1 ORDER BY timestamp DESC, id DESC LIMIT 1
""" """
) )
suspend fun getLastMessageAttachments(account: String, opponent: String): String? suspend fun getLastMessageAttachments(account: String, opponent: String): String?
@@ -508,6 +508,7 @@ interface MessageDao {
WHERE account = :account WHERE account = :account
AND from_me = 1 AND from_me = 1
AND delivered = 0 AND delivered = 0
AND (attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]')
AND timestamp >= :minTimestamp AND timestamp >= :minTimestamp
ORDER BY timestamp ASC ORDER BY timestamp ASC
""" """
@@ -524,6 +525,7 @@ interface MessageDao {
WHERE account = :account WHERE account = :account
AND from_me = 1 AND from_me = 1
AND delivered = 0 AND delivered = 0
AND (attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]')
AND timestamp < :maxTimestamp AND timestamp < :maxTimestamp
""" """
) )
@@ -629,7 +631,7 @@ interface MessageDao {
END END
WHERE m.account = :account WHERE m.account = :account
AND m.primary_attachment_type = 4 AND m.primary_attachment_type = 4
ORDER BY m.timestamp DESC, m.message_id DESC ORDER BY m.timestamp DESC, m.id DESC
LIMIT :limit LIMIT :limit
""" """
) )
@@ -978,7 +980,7 @@ interface DialogDao {
""" """
SELECT * FROM messages SELECT * FROM messages
WHERE account = :account AND dialog_key = :dialogKey WHERE account = :account AND dialog_key = :dialogKey
ORDER BY timestamp DESC, message_id DESC LIMIT 1 ORDER BY timestamp DESC, id DESC LIMIT 1
""" """
) )
suspend fun getLastMessageByDialogKey(account: String, dialogKey: String): MessageEntity? suspend fun getLastMessageByDialogKey(account: String, dialogKey: String): MessageEntity?

View File

@@ -332,7 +332,7 @@ abstract class RosettaDatabase : RoomDatabase() {
THEN dialogs.account || ':' || dialogs.opponent_key THEN dialogs.account || ':' || dialogs.opponent_key
ELSE dialogs.opponent_key || ':' || dialogs.account ELSE dialogs.opponent_key || ':' || dialogs.account
END END
ORDER BY m.timestamp DESC, m.message_id DESC ORDER BY m.timestamp DESC, m.id DESC
LIMIT 1 LIMIT 1
), ),
'' ''

View File

@@ -19,7 +19,6 @@ import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
/** /**
@@ -44,6 +43,7 @@ object TransportManager {
private val _uploading = MutableStateFlow<List<TransportState>>(emptyList()) private val _uploading = MutableStateFlow<List<TransportState>>(emptyList())
val uploading: StateFlow<List<TransportState>> = _uploading.asStateFlow() val uploading: StateFlow<List<TransportState>> = _uploading.asStateFlow()
private val activeUploadCalls = ConcurrentHashMap<String, Call>()
private val _downloading = MutableStateFlow<List<TransportState>>(emptyList()) private val _downloading = MutableStateFlow<List<TransportState>>(emptyList())
val downloading: StateFlow<List<TransportState>> = _downloading.asStateFlow() val downloading: StateFlow<List<TransportState>> = _downloading.asStateFlow()
@@ -133,6 +133,14 @@ object TransportManager {
_downloading.value = _downloading.value.filter { it.id != id } _downloading.value = _downloading.value.filter { it.id != id }
} }
/**
* Принудительно отменяет активный HTTP call для upload attachment.
*/
fun cancelUpload(id: String) {
activeUploadCalls.remove(id)?.cancel()
_uploading.value = _uploading.value.filter { it.id != id }
}
private suspend fun awaitDownloadResponse(id: String, request: Request): Response = private suspend fun awaitDownloadResponse(id: String, request: Request): Response =
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
val call = client.newCall(request) val call = client.newCall(request)
@@ -224,13 +232,31 @@ object TransportManager {
.post(requestBody) .post(requestBody)
.build() .build()
val response = suspendCoroutine<Response> { cont -> val response = suspendCancellableCoroutine<Response> { cont ->
client.newCall(request).enqueue(object : Callback { val call = client.newCall(request)
activeUploadCalls[id] = call
cont.invokeOnCancellation {
activeUploadCalls.remove(id, call)
call.cancel()
}
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) { override fun onFailure(call: Call, e: IOException) {
activeUploadCalls.remove(id, call)
if (call.isCanceled()) {
cont.cancel(CancellationException("Upload cancelled"))
} else {
cont.resumeWithException(e) cont.resumeWithException(e)
} }
}
override fun onResponse(call: Call, response: Response) { override fun onResponse(call: Call, response: Response) {
activeUploadCalls.remove(id, call)
if (cont.isCancelled) {
response.close()
return
}
cont.resume(response) cont.resume(response)
} }
}) })
@@ -253,12 +279,16 @@ object TransportManager {
tag tag
} }
} catch (e: CancellationException) {
ProtocolManager.addLog("🛑 Upload cancelled: id=${id.take(8)}")
throw e
} catch (e: Exception) { } catch (e: Exception) {
ProtocolManager.addLog( ProtocolManager.addLog(
"❌ Upload failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}" "❌ Upload failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
) )
throw e throw e
} finally { } finally {
activeUploadCalls.remove(id)?.cancel()
// Удаляем из списка загрузок // Удаляем из списка загрузок
_uploading.value = _uploading.value.filter { it.id != id } _uploading.value = _uploading.value.filter { it.id != id }
} }

View File

@@ -12,7 +12,6 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -35,19 +34,18 @@ fun SeedPhraseScreen(
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val cardBackground = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5)
var seedPhrase by remember { mutableStateOf<List<String>>(emptyList()) } var seedPhrase by remember { mutableStateOf<List<String>>(emptyList()) }
var isGenerating by remember { mutableStateOf(true) }
var hasCopied by remember { mutableStateOf(false) } var hasCopied by remember { mutableStateOf(false) }
var visible by remember { mutableStateOf(false) } var visible by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
delay(100) // Генерируем фразу сразу, без задержек
seedPhrase = CryptoManager.generateSeedPhrase() seedPhrase = CryptoManager.generateSeedPhrase()
isGenerating = false // Даем микро-паузу, чтобы верстка отрисовалась, и запускаем анимацию
delay(50)
visible = true visible = true
} }
@@ -59,7 +57,7 @@ fun SeedPhraseScreen(
.navigationBarsPadding() .navigationBarsPadding()
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Simple top bar // Top bar
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -108,30 +106,13 @@ fun SeedPhraseScreen(
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
// Two column layout // Сетка со словами (без Crossfade и лоадера)
if (isGenerating) { if (seedPhrase.isNotEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = PrimaryBlue,
strokeWidth = 2.dp,
modifier = Modifier.size(40.dp)
)
}
} else {
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(500, delayMillis = 200))
) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
// Left column (words 1-6) // Левая колонка (1-6)
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
@@ -142,12 +123,12 @@ fun SeedPhraseScreen(
word = seedPhrase[i], word = seedPhrase[i],
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
visible = visible, visible = visible,
delay = 300 + (i * 50) delay = i * 60
) )
} }
} }
// Right column (words 7-12) // Правая колонка (7-12)
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
@@ -158,23 +139,21 @@ fun SeedPhraseScreen(
word = seedPhrase[i], word = seedPhrase[i],
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
visible = visible, visible = visible,
delay = 300 + (i * 50) delay = i * 60
) )
} }
} }
} }
} }
}
Spacer(modifier = Modifier.height(20.dp)) Spacer(modifier = Modifier.height(24.dp))
// Copy button // Кнопка Copy
if (!isGenerating) {
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(500, delayMillis = 600)) + scaleIn( enter = fadeIn(tween(400, delayMillis = 800)) + scaleIn(
initialScale = 0.8f, initialScale = 0.8f,
animationSpec = tween(500, delayMillis = 600) animationSpec = tween(400, delayMillis = 800, easing = LinearOutSlowInEasing)
) )
) { ) {
TextButton( TextButton(
@@ -201,33 +180,32 @@ fun SeedPhraseScreen(
) )
} }
} }
}
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
// Continue button // Кнопка Continue
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(400, delayMillis = 700)) enter = fadeIn(tween(400, delayMillis = 900)) + slideInVertically(
initialOffsetY = { 20 },
animationSpec = tween(400, delayMillis = 900)
)
) { ) {
Button( Button(
onClick = { onConfirm(seedPhrase) }, onClick = { onConfirm(seedPhrase) },
enabled = !isGenerating,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(50.dp), .height(52.dp),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = PrimaryBlue, containerColor = PrimaryBlue,
contentColor = Color.White, contentColor = Color.White
disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f),
disabledContentColor = Color.White.copy(alpha = 0.5f)
), ),
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(14.dp)
) { ) {
Text( Text(
text = "Continue", text = "Continue",
fontSize = 17.sp, fontSize = 17.sp,
fontWeight = FontWeight.Medium fontWeight = FontWeight.SemiBold
) )
} }
} }
@@ -246,21 +224,11 @@ private fun WordItem(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val numberColor = if (isDarkTheme) Color(0xFF888888) else Color(0xFF999999) val numberColor = if (isDarkTheme) Color(0xFF888888) else Color(0xFF999999)
// Beautiful solid colors that fit the theme
val wordColors = listOf( val wordColors = listOf(
Color(0xFF5E9FFF), // Soft blue Color(0xFF5E9FFF), Color(0xFFFF7EB3), Color(0xFF7B68EE),
Color(0xFFFF7EB3), // Soft pink Color(0xFF50C878), Color(0xFFFF6B6B), Color(0xFF4ECDC4),
Color(0xFF7B68EE), // Medium purple Color(0xFFFFB347), Color(0xFFBA55D3), Color(0xFF87CEEB),
Color(0xFF50C878), // Emerald green Color(0xFFDDA0DD), Color(0xFF98D8C8), Color(0xFFF7DC6F)
Color(0xFFFF6B6B), // Coral red
Color(0xFF4ECDC4), // Teal
Color(0xFFFFB347), // Pastel orange
Color(0xFFBA55D3), // Medium orchid
Color(0xFF87CEEB), // Sky blue
Color(0xFFDDA0DD), // Plum
Color(0xFF98D8C8), // Mint
Color(0xFFF7DC6F) // Soft yellow
) )
val wordColor = wordColors[(number - 1) % wordColors.size] val wordColor = wordColors[(number - 1) % wordColors.size]
@@ -271,21 +239,18 @@ private fun WordItem(
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background(bgColor) .background(bgColor)
.padding(horizontal = 16.dp, vertical = 14.dp) .padding(horizontal = 14.dp, vertical = 12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = "$number.", text = "$number.",
fontSize = 15.sp, fontSize = 13.sp,
color = numberColor, color = numberColor,
modifier = Modifier.width(28.dp) modifier = Modifier.width(26.dp)
) )
Text( Text(
text = word, text = word,
fontSize = 17.sp, fontSize = 16.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = wordColor, color = wordColor,
fontFamily = FontFamily.Monospace fontFamily = FontFamily.Monospace
@@ -303,15 +268,21 @@ private fun AnimatedWordItem(
delay: Int, delay: Int,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val overshootEasing = remember { CubicBezierEasing(0.175f, 0.885f, 0.32f, 1.275f) }
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(400, delayMillis = delay)) enter = fadeIn(animationSpec = tween(300, delayMillis = delay)) +
) { slideInVertically(
WordItem( initialOffsetY = { 30 },
number = number, animationSpec = tween(400, delayMillis = delay, easing = FastOutSlowInEasing)
word = word, ) +
isDarkTheme = isDarkTheme, scaleIn(
initialScale = 0.85f,
animationSpec = tween(400, delayMillis = delay, easing = overshootEasing)
),
modifier = modifier modifier = modifier
) ) {
WordItem(number, word, isDarkTheme)
} }
} }

View File

@@ -98,7 +98,7 @@ fun SelectAccountScreen(
DisposableEffect(isDarkTheme) { DisposableEffect(isDarkTheme) {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !isDarkTheme insetsController.isAppearanceLightStatusBars = false
onDispose { } onDispose { }
} }

View File

@@ -3306,6 +3306,14 @@ fun ChatDetailScreen(
message.id message.id
) )
}, },
onCancelPhotoUpload = {
attachmentId ->
viewModel
.cancelOutgoingImageUpload(
message.id,
attachmentId
)
},
onImageClick = { onImageClick = {
attachmentId, attachmentId,
bounds bounds

View File

@@ -45,10 +45,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
private const val DRAFT_SAVE_DEBOUNCE_MS = 250L private const val DRAFT_SAVE_DEBOUNCE_MS = 250L
private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val chatMessageAscComparator = // Keep sort stable for equal timestamps: tie order comes from existing list/insertion order.
compareBy<ChatMessage>({ it.timestamp.time }, { it.id }) private val chatMessageAscComparator = compareBy<ChatMessage> { it.timestamp.time }
private val chatMessageDescComparator = private val chatMessageDescComparator =
compareByDescending<ChatMessage> { it.timestamp.time }.thenByDescending { it.id } compareByDescending<ChatMessage> { it.timestamp.time }
private fun sortMessagesAsc(messages: List<ChatMessage>): List<ChatMessage> = private fun sortMessagesAsc(messages: List<ChatMessage>): List<ChatMessage> =
messages.sortedWith(chatMessageAscComparator) messages.sortedWith(chatMessageAscComparator)
@@ -227,6 +227,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Job для отмены загрузки при смене диалога // Job для отмены загрузки при смене диалога
private var loadingJob: Job? = null private var loadingJob: Job? = null
private var draftSaveJob: Job? = null private var draftSaveJob: Job? = null
private val outgoingImageUploadJobs = ConcurrentHashMap<String, Job>()
// 🔥 Throttling для typing индикатора // 🔥 Throttling для typing индикатора
private var lastTypingSentTime = 0L private var lastTypingSentTime = 0L
@@ -619,6 +620,50 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
updateCacheFromCurrentMessages() updateCacheFromCurrentMessages()
} }
/**
* Для исходящих media-сообщений (фото/файл/аватар) не держим "часики" после фактической отправки:
* optimistic WAITING в БД должен отображаться как SENT, если localUri уже очищен.
*/
private fun shouldTreatWaitingAsSent(entity: MessageEntity): Boolean {
if (entity.fromMe != 1 || entity.primaryAttachmentType < 0) return false
val attachments = parseAttachmentsJsonArray(entity.attachments) ?: return false
if (attachments.length() == 0) return false
var hasMediaAttachment = false
for (index in 0 until attachments.length()) {
val attachment = attachments.optJSONObject(index) ?: continue
when (parseAttachmentType(attachment)) {
AttachmentType.IMAGE,
AttachmentType.FILE,
AttachmentType.AVATAR -> {
hasMediaAttachment = true
if (attachment.optString("localUri", "").isNotBlank()) {
// Локальный URI ещё есть => загрузка/подготовка не завершена.
return false
}
}
AttachmentType.UNKNOWN -> continue
else -> return false
}
}
return hasMediaAttachment
}
private fun mapEntityDeliveryStatus(entity: MessageEntity): MessageStatus {
return when (entity.delivered) {
DeliveryStatus.WAITING.value ->
if (shouldTreatWaitingAsSent(entity)) MessageStatus.SENT
else MessageStatus.SENDING
DeliveryStatus.DELIVERED.value ->
if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
DeliveryStatus.ERROR.value -> MessageStatus.ERROR
DeliveryStatus.READ.value -> MessageStatus.READ
else -> MessageStatus.SENT
}
}
private fun shortPhotoId(value: String, limit: Int = 8): String { private fun shortPhotoId(value: String, limit: Int = 8): String {
val trimmed = value.trim() val trimmed = value.trim()
if (trimmed.isEmpty()) return "unknown" if (trimmed.isEmpty()) return "unknown"
@@ -1044,14 +1089,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
currentMessages.map { message -> currentMessages.map { message ->
val entity = entitiesById[message.id] ?: return@map message val entity = entitiesById[message.id] ?: return@map message
val dbStatus = val dbStatus = mapEntityDeliveryStatus(entity)
when (entity.delivered) {
0 -> MessageStatus.SENDING
1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
2 -> MessageStatus.ERROR
3 -> MessageStatus.READ
else -> MessageStatus.SENT
}
var updatedMessage = message var updatedMessage = message
if (updatedMessage.status != dbStatus) { if (updatedMessage.status != dbStatus) {
@@ -1370,14 +1408,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
text = displayText, text = displayText,
isOutgoing = entity.fromMe == 1, isOutgoing = entity.fromMe == 1,
timestamp = Date(entity.timestamp), timestamp = Date(entity.timestamp),
status = status = mapEntityDeliveryStatus(entity),
when (entity.delivered) {
0 -> MessageStatus.SENDING
1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
2 -> MessageStatus.ERROR
3 -> MessageStatus.READ
else -> MessageStatus.SENT
},
replyData = if (forwardedMessages.isNotEmpty()) null else replyData, replyData = if (forwardedMessages.isNotEmpty()) null else replyData,
forwardedMessages = forwardedMessages, forwardedMessages = forwardedMessages,
attachments = finalAttachments, attachments = finalAttachments,
@@ -2579,6 +2610,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
/** 🛑 Отменить исходящую отправку фото во время загрузки */
fun cancelOutgoingImageUpload(messageId: String, attachmentId: String) {
ProtocolManager.addLog(
"🛑 IMG cancel requested: msg=${messageId.take(8)}, att=${attachmentId.take(12)}"
)
outgoingImageUploadJobs.remove(messageId)?.cancel(
CancellationException("User cancelled image upload")
)
TransportManager.cancelUpload(attachmentId)
ProtocolManager.resolveOutgoingRetry(messageId)
deleteMessage(messageId)
}
/** 🔥 Удалить сообщение (для ошибки отправки) */ /** 🔥 Удалить сообщение (для ошибки отправки) */
fun deleteMessage(messageId: String) { fun deleteMessage(messageId: String) {
val account = myPublicKey ?: return val account = myPublicKey ?: return
@@ -3514,7 +3558,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 2. 🔄 В фоне, независимо от жизненного цикла экрана: // 2. 🔄 В фоне, независимо от жизненного цикла экрана:
// сохраняем optimistic в БД -> конвертируем -> загружаем -> отправляем пакет. // сохраняем optimistic в БД -> конвертируем -> загружаем -> отправляем пакет.
backgroundUploadScope.launch { val uploadJob = backgroundUploadScope.launch {
try { try {
logPhotoPipeline(messageId, "persist optimistic message in DB") logPhotoPipeline(messageId, "persist optimistic message in DB")
val optimisticAttachmentsJson = val optimisticAttachmentsJson =
@@ -3554,6 +3598,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
opponentPublicKey = recipient opponentPublicKey = recipient
) )
logPhotoPipeline(messageId, "optimistic dialog updated") logPhotoPipeline(messageId, "optimistic dialog updated")
} catch (e: CancellationException) {
throw e
} catch (_: Exception) { } catch (_: Exception) {
logPhotoPipeline(messageId, "optimistic DB save skipped (non-fatal)") logPhotoPipeline(messageId, "optimistic DB save skipped (non-fatal)")
} }
@@ -3603,6 +3649,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
privateKey = privateKey privateKey = privateKey
) )
logPhotoPipeline(messageId, "pipeline completed") logPhotoPipeline(messageId, "pipeline completed")
} catch (e: CancellationException) {
logPhotoPipeline(messageId, "pipeline cancelled by user")
throw e
} catch (e: Exception) { } catch (e: Exception) {
logPhotoPipelineError(messageId, "prepare+convert", e) logPhotoPipelineError(messageId, "prepare+convert", e)
if (!isCleared) { if (!isCleared) {
@@ -3612,6 +3661,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
} }
outgoingImageUploadJobs[messageId] = uploadJob
uploadJob.invokeOnCompletion { outgoingImageUploadJobs.remove(messageId) }
} }
/** 🔄 Обновляет optimistic сообщение с реальными данными изображения */ /** 🔄 Обновляет optimistic сообщение с реальными данными изображения */
@@ -3655,6 +3706,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
sender: String, sender: String,
privateKey: String privateKey: String
) { ) {
var packetSentToProtocol = false
try { try {
val context = getApplication<Application>() val context = getApplication<Application>()
val pipelineStartedAt = System.currentTimeMillis() val pipelineStartedAt = System.currentTimeMillis()
@@ -3746,6 +3798,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Отправляем пакет // Отправляем пакет
if (!isSavedMessages) { if (!isSavedMessages) {
ProtocolManager.send(packet) ProtocolManager.send(packet)
packetSentToProtocol = true
logPhotoPipeline(messageId, "packet sent to protocol") logPhotoPipeline(messageId, "packet sent to protocol")
} else { } else {
logPhotoPipeline(messageId, "saved-messages mode: packet send skipped") logPhotoPipeline(messageId, "saved-messages mode: packet send skipped")
@@ -3790,17 +3843,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
logPhotoPipeline(messageId, "db status+attachments updated") logPhotoPipeline(messageId, "db status+attachments updated")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (isSavedMessages) {
updateMessageStatus(messageId, MessageStatus.SENT) updateMessageStatus(messageId, MessageStatus.SENT)
}
// Также очищаем localUri в UI // Также очищаем localUri в UI
updateMessageAttachments(messageId, null) updateMessageAttachments(messageId, null)
} }
logPhotoPipeline( logPhotoPipeline(messageId, "ui status switched to SENT")
messageId,
if (isSavedMessages) "ui status switched to SENT"
else "ui status kept at SENDING until delivery ACK"
)
saveDialog( saveDialog(
lastMessage = if (caption.isNotEmpty()) caption else "photo", lastMessage = if (caption.isNotEmpty()) caption else "photo",
@@ -3811,11 +3858,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
messageId, messageId,
"dialog updated; totalElapsed=${System.currentTimeMillis() - pipelineStartedAt}ms" "dialog updated; totalElapsed=${System.currentTimeMillis() - pipelineStartedAt}ms"
) )
} catch (e: CancellationException) {
logPhotoPipeline(messageId, "internal-send cancelled")
throw e
} catch (e: Exception) { } catch (e: Exception) {
logPhotoPipelineError(messageId, "internal-send", e) logPhotoPipelineError(messageId, "internal-send", e)
if (packetSentToProtocol) {
// Packet already sent to server: local post-send failure (cache/DB/UI) must not mark message as failed.
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
logPhotoPipeline(messageId, "post-send non-fatal error: status kept as SENT")
} else {
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) } withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
} }
} }
}
/** /**
* 📸 Отправка сообщения с изображением * 📸 Отправка сообщения с изображением
@@ -3989,12 +4047,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
opponentPublicKey = recipient opponentPublicKey = recipient
) )
// Для обычных диалогов остаёмся в SENDING до PacketDelivery(messageId). // После успешной отправки пакета фиксируем SENT (без ложного timeout->ERROR).
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (isSavedMessages) {
updateMessageStatus(messageId, MessageStatus.SENT) updateMessageStatus(messageId, MessageStatus.SENT)
} }
}
saveDialog( saveDialog(
lastMessage = if (text.isNotEmpty()) text else "photo", lastMessage = if (text.isNotEmpty()) text else "photo",
@@ -4277,9 +4333,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
_messages.value.map { msg -> _messages.value.map { msg ->
if (msg.id != messageId) return@map msg if (msg.id != messageId) return@map msg
msg.copy( msg.copy(
status = status = MessageStatus.SENT,
if (isSavedMessages) MessageStatus.SENT
else MessageStatus.SENDING,
attachments = attachments =
msg.attachments.map { current -> msg.attachments.map { current ->
val final = finalAttachmentsById[current.id] val final = finalAttachmentsById[current.id]
@@ -4510,12 +4564,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
opponentPublicKey = recipient opponentPublicKey = recipient
) )
// Обновляем UI: для обычных чатов оставляем SENDING до PacketDelivery(messageId). // После успешной отправки медиа переводим в SENT.
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (isSavedMessages) {
updateMessageStatus(messageId, MessageStatus.SENT) updateMessageStatus(messageId, MessageStatus.SENT)
} }
}
saveDialog( saveDialog(
lastMessage = if (text.isNotEmpty()) text else "📷 ${images.size} photos", lastMessage = if (text.isNotEmpty()) text else "📷 ${images.size} photos",
@@ -5469,6 +5521,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
typingNameResolveJob?.cancel() typingNameResolveJob?.cancel()
draftSaveJob?.cancel() draftSaveJob?.cancel()
pinnedCollectionJob?.cancel() pinnedCollectionJob?.cancel()
outgoingImageUploadJobs.values.forEach { it.cancel() }
outgoingImageUploadJobs.clear()
// 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов // 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов
ProtocolManager.unwaitPacket(0x0B, typingPacketHandler) ProtocolManager.unwaitPacket(0x0B, typingPacketHandler)

View File

@@ -610,6 +610,7 @@ fun ChatsListScreen(
val topLevelChatsState by chatsViewModel.chatsState.collectAsState() val topLevelChatsState by chatsViewModel.chatsState.collectAsState()
val topLevelIsLoading by chatsViewModel.isLoading.collectAsState() val topLevelIsLoading by chatsViewModel.isLoading.collectAsState()
val topLevelRequestsCount = topLevelChatsState.requestsCount val topLevelRequestsCount = topLevelChatsState.requestsCount
val visibleTopLevelRequestsCount = if (syncInProgress) 0 else topLevelRequestsCount
// Dev console dialog - commented out for now // Dev console dialog - commented out for now
/* /*
@@ -1163,7 +1164,7 @@ fun ChatsListScreen(
text = "Requests", text = "Requests",
iconColor = menuIconColor, iconColor = menuIconColor,
textColor = menuTextColor, textColor = menuTextColor,
badge = if (topLevelRequestsCount > 0) topLevelRequestsCount.toString() else null, badge = if (visibleTopLevelRequestsCount > 0) visibleTopLevelRequestsCount.toString() else null,
badgeColor = accentColor, badgeColor = accentColor,
onClick = { onClick = {
scope.launch { scope.launch {
@@ -1598,7 +1599,7 @@ fun ChatsListScreen(
) )
// Badge с числом запросов // Badge с числом запросов
androidx.compose.animation.AnimatedVisibility( androidx.compose.animation.AnimatedVisibility(
visible = topLevelRequestsCount > 0, visible = visibleTopLevelRequestsCount > 0,
enter = scaleIn( enter = scaleIn(
animationSpec = spring( animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy, dampingRatio = Spring.DampingRatioMediumBouncy,
@@ -1608,10 +1609,10 @@ fun ChatsListScreen(
exit = scaleOut() + fadeOut(), exit = scaleOut() + fadeOut(),
modifier = Modifier.align(Alignment.TopEnd) modifier = Modifier.align(Alignment.TopEnd)
) { ) {
val badgeText = remember(topLevelRequestsCount) { val badgeText = remember(visibleTopLevelRequestsCount) {
when { when {
topLevelRequestsCount > 99 -> "99+" visibleTopLevelRequestsCount > 99 -> "99+"
else -> topLevelRequestsCount.toString() else -> visibleTopLevelRequestsCount.toString()
} }
} }
val badgeBg = Color.White val badgeBg = Color.White
@@ -1679,7 +1680,7 @@ fun ChatsListScreen(
) )
} else if (syncInProgress) { } else if (syncInProgress) {
AnimatedDotsText( AnimatedDotsText(
baseText = "Synchronizing", baseText = "Updating",
color = Color.White, color = Color.White,
fontSize = 20.sp, fontSize = 20.sp,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
@@ -1858,8 +1859,8 @@ fun ChatsListScreen(
// независимо // независимо
val chatsState = topLevelChatsState val chatsState = topLevelChatsState
val isLoading = topLevelIsLoading val isLoading = topLevelIsLoading
val requests = chatsState.requests val requests = if (syncInProgress) emptyList() else chatsState.requests
val requestsCount = chatsState.requestsCount val requestsCount = if (syncInProgress) 0 else chatsState.requestsCount
val showSkeleton by val showSkeleton by
produceState( produceState(
@@ -3464,7 +3465,8 @@ fun ChatItem(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
showOnlineIndicator = true, showOnlineIndicator = true,
isOnline = chat.isOnline, isOnline = chat.isOnline,
displayName = chat.name // 🔥 Для инициалов displayName = chat.name, // 🔥 Для инициалов
enableBlurPrewarm = true
) )
Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP)) Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP))
@@ -4117,6 +4119,7 @@ fun DialogItemContent(
val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black } val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black }
val secondaryTextColor = val secondaryTextColor =
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) } remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) }
val visibleUnreadCount = if (syncInProgress) 0 else dialog.unreadCount
val isGroupDialog = remember(dialog.opponentKey) { isGroupDialogKey(dialog.opponentKey) } val isGroupDialog = remember(dialog.opponentKey) { isGroupDialogKey(dialog.opponentKey) }
@@ -4208,7 +4211,8 @@ fun DialogItemContent(
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
size = TELEGRAM_DIALOG_AVATAR_SIZE, size = TELEGRAM_DIALOG_AVATAR_SIZE,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
displayName = avatarDisplayName displayName = avatarDisplayName,
enableBlurPrewarm = true
) )
} }
@@ -4448,7 +4452,7 @@ fun DialogItemContent(
text = formattedTime, text = formattedTime,
fontSize = 13.sp, fontSize = 13.sp,
color = color =
if (dialog.unreadCount > 0) PrimaryBlue if (visibleUnreadCount > 0) PrimaryBlue
else secondaryTextColor else secondaryTextColor
) )
} }
@@ -4588,7 +4592,7 @@ fun DialogItemContent(
baseDisplayText, baseDisplayText,
fontSize = 14.sp, fontSize = 14.sp,
color = color =
if (dialog.unreadCount > if (visibleUnreadCount >
0 0
) )
textColor.copy( textColor.copy(
@@ -4598,7 +4602,7 @@ fun DialogItemContent(
else else
secondaryTextColor, secondaryTextColor,
fontWeight = fontWeight =
if (dialog.unreadCount > if (visibleUnreadCount >
0 0
) )
FontWeight.Medium FontWeight.Medium
@@ -4619,11 +4623,11 @@ fun DialogItemContent(
text = baseDisplayText, text = baseDisplayText,
fontSize = 14.sp, fontSize = 14.sp,
color = color =
if (dialog.unreadCount > 0) if (visibleUnreadCount > 0)
textColor.copy(alpha = 0.85f) textColor.copy(alpha = 0.85f)
else secondaryTextColor, else secondaryTextColor,
fontWeight = fontWeight =
if (dialog.unreadCount > 0) if (visibleUnreadCount > 0)
FontWeight.Medium FontWeight.Medium
else FontWeight.Normal, else FontWeight.Normal,
maxLines = 1, maxLines = 1,
@@ -4658,15 +4662,15 @@ fun DialogItemContent(
} }
// Unread badge // Unread badge
if (dialog.unreadCount > 0) { if (visibleUnreadCount > 0) {
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
val unreadText = val unreadText =
remember(dialog.unreadCount) { remember(visibleUnreadCount) {
when { when {
dialog.unreadCount > 999 -> "999+" visibleUnreadCount > 999 -> "999+"
dialog.unreadCount > 99 -> "99+" visibleUnreadCount > 99 -> "99+"
else -> else ->
dialog.unreadCount visibleUnreadCount
.toString() .toString()
} }
} }
@@ -4736,7 +4740,7 @@ fun TypingIndicatorSmall(
typingDisplayName: String = "", typingDisplayName: String = "",
typingSenderPublicKey: String = "" typingSenderPublicKey: String = ""
) { ) {
val typingColor = PrimaryBlue val typingColor = if (isDarkTheme) PrimaryBlue else Color(0xFF34C759)
val senderTypingColor = val senderTypingColor =
remember(typingSenderPublicKey, isDarkTheme) { remember(typingSenderPublicKey, isDarkTheme) {
if (typingSenderPublicKey.isBlank()) { if (typingSenderPublicKey.isBlank()) {

View File

@@ -16,6 +16,7 @@ import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import java.util.Locale import java.util.Locale
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@@ -391,13 +392,22 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} }
// Подписываемся на обычные диалоги // Подписываемся на обычные диалоги
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
launch { launch {
dialogDao dialogDao
.getDialogsFlow(publicKey) .getDialogsFlow(publicKey)
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO .flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
.debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка) .debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка)
.map { dialogsList -> .combine(ProtocolManager.syncInProgress) { dialogsList, syncing ->
dialogsList to syncing
}
.mapLatest { (dialogsList, syncing) ->
// Desktop behavior parity:
// while sync is active we keep current chats list stable (no per-message UI churn),
// then apply one consolidated update when sync finishes.
if (syncing && _dialogs.value.isNotEmpty()) {
null
} else {
mapDialogListIncremental( mapDialogListIncremental(
dialogsList = dialogsList, dialogsList = dialogsList,
privateKey = privateKey, privateKey = privateKey,
@@ -405,6 +415,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
isRequestsFlow = false isRequestsFlow = false
) )
} }
}
.filterNotNull()
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedDialogs -> .collect { decryptedDialogs ->
_dialogs.value = decryptedDialogs _dialogs.value = decryptedDialogs
@@ -423,13 +435,19 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} }
// 📬 Подписываемся на requests (запросы от новых пользователей) // 📬 Подписываемся на requests (запросы от новых пользователей)
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
launch { launch {
dialogDao dialogDao
.getRequestsFlow(publicKey) .getRequestsFlow(publicKey)
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.debounce(100) // 🚀 Батчим быстрые обновления .debounce(100) // 🚀 Батчим быстрые обновления
.map { requestsList -> .combine(ProtocolManager.syncInProgress) { requestsList, syncing ->
requestsList to syncing
}
.mapLatest { (requestsList, syncing) ->
if (syncing) {
emptyList()
} else {
mapDialogListIncremental( mapDialogListIncremental(
dialogsList = requestsList, dialogsList = requestsList,
privateKey = privateKey, privateKey = privateKey,
@@ -437,6 +455,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
isRequestsFlow = true isRequestsFlow = true
) )
} }
}
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedRequests -> _requests.value = decryptedRequests } .collect { decryptedRequests -> _requests.value = decryptedRequests }
} }
@@ -446,6 +465,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
dialogDao dialogDao
.getRequestsCountFlow(publicKey) .getRequestsCountFlow(publicKey)
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.combine(ProtocolManager.syncInProgress) { count, syncing ->
if (syncing) 0 else count
}
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения
.collect { count -> _requestsCount.value = count } .collect { count -> _requestsCount.value = count }
} }

View File

@@ -150,6 +150,8 @@ fun GroupSetupScreen(
val primaryTextColor = if (isDarkTheme) Color.White else Color.Black val primaryTextColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = Color(0xFF8E8E93) val secondaryTextColor = Color(0xFF8E8E93)
val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6) val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
val disabledActionColor = if (isDarkTheme) Color(0xFF5A5A5E) else Color(0xFFC7C7CC)
val disabledActionContentColor = if (isDarkTheme) Color(0xFFAAAAAF) else Color(0xFF8E8E93)
val groupAvatarCameraButtonColor = val groupAvatarCameraButtonColor =
if (isDarkTheme) sectionColor else Color(0xFF8CC9F6) if (isDarkTheme) sectionColor else Color(0xFF8CC9F6)
val groupAvatarCameraIconColor = val groupAvatarCameraIconColor =
@@ -745,8 +747,8 @@ fun GroupSetupScreen(
isLoading = false isLoading = false
} }
}, },
containerColor = if (actionEnabled) accentColor else accentColor.copy(alpha = 0.42f), containerColor = if (actionEnabled) accentColor else disabledActionColor,
contentColor = Color.White, contentColor = if (actionEnabled) Color.White else disabledActionContentColor,
shape = CircleShape, shape = CircleShape,
modifier = run { modifier = run {
// Берём максимум из всех позиций — при переключении keyboard↔emoji // Берём максимум из всех позиций — при переключении keyboard↔emoji
@@ -762,7 +764,7 @@ fun GroupSetupScreen(
) { ) {
if (isLoading && step == GroupSetupStep.DESCRIPTION) { if (isLoading && step == GroupSetupStep.DESCRIPTION) {
CircularProgressIndicator( CircularProgressIndicator(
color = Color.White, color = if (actionEnabled) Color.White else disabledActionContentColor,
strokeWidth = 2.dp, strokeWidth = 2.dp,
modifier = Modifier.size(22.dp) modifier = Modifier.size(22.dp)
) )

View File

@@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
@@ -39,12 +40,12 @@ fun RequestsListScreen(
avatarRepository: AvatarRepository? = null avatarRepository: AvatarRepository? = null
) { ) {
val chatsState by chatsViewModel.chatsState.collectAsState() val chatsState by chatsViewModel.chatsState.collectAsState()
val requests = chatsState.requests val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
val requests = if (syncInProgress) emptyList() else chatsState.requests
val blockedUsers by chatsViewModel.blockedUsers.collectAsState() val blockedUsers by chatsViewModel.blockedUsers.collectAsState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val headerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4) val headerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4)
val textColor = if (isDarkTheme) Color.White else Color.Black
Scaffold( Scaffold(
topBar = { topBar = {
@@ -60,7 +61,7 @@ fun RequestsListScreen(
}, },
title = { title = {
Text( Text(
text = "Requests", text = if (syncInProgress) "Updating..." else "Requests",
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 20.sp, fontSize = 20.sp,
color = Color.White color = Color.White

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -109,7 +110,7 @@ fun SearchScreen(
DisposableEffect(isDarkTheme) { DisposableEffect(isDarkTheme) {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !isDarkTheme insetsController.isAppearanceLightStatusBars = false
window.statusBarColor = android.graphics.Color.TRANSPARENT window.statusBarColor = android.graphics.Color.TRANSPARENT
onDispose { onDispose {
// Restore white status bar icons for chat list header // Restore white status bar icons for chat list header
@@ -238,14 +239,10 @@ fun SearchScreen(
} }
} }
Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) { Box(modifier = Modifier.fillMaxSize()) {
detectHorizontalDragGestures { _, dragAmount -> val hideKbScrollConnection = remember { HideKeyboardNestedScroll(view, focusManager) }
if (dragAmount > 10f) {
hideKeyboardInstantly()
}
}
}) {
Scaffold( Scaffold(
modifier = Modifier.nestedScroll(hideKbScrollConnection),
topBar = { topBar = {
// Хедер как в Telegram: стрелка назад + поле ввода // Хедер как в Telegram: стрелка назад + поле ввода
Surface( Surface(
@@ -538,7 +535,7 @@ private fun ChatsTabContent(
if (searchQuery.isEmpty()) { if (searchQuery.isEmpty()) {
// ═══ Idle state: frequent contacts + recent searches ═══ // ═══ Idle state: frequent contacts + recent searches ═══
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize().imePadding()
) { ) {
// ─── Горизонтальный ряд частых контактов (как в Telegram) ─── // ─── Горизонтальный ряд частых контактов (как в Telegram) ───
if (visibleFrequentContacts.isNotEmpty()) { if (visibleFrequentContacts.isNotEmpty()) {
@@ -1183,7 +1180,7 @@ private fun MessagesTabContent(
} }
else -> { else -> {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize().imePadding(),
contentPadding = PaddingValues(vertical = 4.dp) contentPadding = PaddingValues(vertical = 4.dp)
) { ) {
items(results, key = { it.messageId }) { result -> items(results, key = { it.messageId }) { result ->
@@ -1757,7 +1754,7 @@ private fun DownloadsTabContent(
) )
} else { } else {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize().imePadding(),
contentPadding = PaddingValues(vertical = 4.dp) contentPadding = PaddingValues(vertical = 4.dp)
) { ) {
items(files, key = { it.name }) { downloadedFile -> items(files, key = { it.name }) { downloadedFile ->
@@ -1909,7 +1906,7 @@ private fun FilesTabContent(
} else { } else {
val dateFormat = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) } val dateFormat = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) }
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize().imePadding(),
contentPadding = PaddingValues(vertical = 4.dp) contentPadding = PaddingValues(vertical = 4.dp)
) { ) {
items(fileItems, key = { "${it.messageId}_${it.attachmentId}" }) { item -> items(fileItems, key = { "${it.messageId}_${it.attachmentId}" }) { item ->
@@ -2163,3 +2160,21 @@ private fun RecentUserItem(
} }
} }
} }
/** NestedScrollConnection который скрывает клавиатуру при любом вертикальном скролле */
private class HideKeyboardNestedScroll(
private val view: android.view.View,
private val focusManager: androidx.compose.ui.focus.FocusManager
) : androidx.compose.ui.input.nestedscroll.NestedScrollConnection {
override fun onPreScroll(
available: androidx.compose.ui.geometry.Offset,
source: androidx.compose.ui.input.nestedscroll.NestedScrollSource
): androidx.compose.ui.geometry.Offset {
if (kotlin.math.abs(available.y) > 0.5f) {
val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus()
}
return androidx.compose.ui.geometry.Offset.Zero
}
}

View File

@@ -765,7 +765,7 @@ fun ChatAttachAlert(
// as the popup overlay, so top area and content overlay always match. // as the popup overlay, so top area and content overlay always match.
if (fullScreen) { if (fullScreen) {
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt() window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
insetsController?.isAppearanceLightStatusBars = !dark insetsController?.isAppearanceLightStatusBars = false
} else { } else {
insetsController?.isAppearanceLightStatusBars = false insetsController?.isAppearanceLightStatusBars = false
var lastAppliedAlpha = -1 var lastAppliedAlpha = -1

View File

@@ -86,6 +86,7 @@ import kotlin.math.min
private const val TAG = "AttachmentComponents" private const val TAG = "AttachmentComponents"
private const val MAX_BITMAP_DECODE_DIMENSION = 4096 private const val MAX_BITMAP_DECODE_DIMENSION = 4096
private val whitespaceRegex = "\\s+".toRegex() private val whitespaceRegex = "\\s+".toRegex()
private val LocalOnCancelImageUpload = staticCompositionLocalOf<(String) -> Unit> { {} }
private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:") private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:")
@@ -144,6 +145,42 @@ private fun shortDebugHash(bytes: ByteArray): String {
} }
} }
private const val LEGACY_ATTACHMENT_ERROR_TEXT =
"This attachment is no longer available because it was sent for a previous version of the app."
@Composable
private fun LegacyAttachmentErrorCard(
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
val borderColor = if (isDarkTheme) Color(0xFF2A2F38) else Color(0xFFE3E7EF)
val backgroundColor = if (isDarkTheme) Color(0xFF1E232B) else Color(0xFFF7F9FC)
val textColor = if (isDarkTheme) Color(0xFFE9EDF5) else Color(0xFF2B3340)
Row(
modifier =
modifier.fillMaxWidth()
.border(1.dp, borderColor, RoundedCornerShape(8.dp))
.clip(RoundedCornerShape(8.dp))
.background(backgroundColor)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
tint = Color(0xFFE55A5A),
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = LEGACY_ATTACHMENT_ERROR_TEXT,
color = textColor,
fontSize = 12.sp
)
}
}
/** /**
* Анимированный текст с волнообразными точками. * Анимированный текст с волнообразными точками.
* Три точки плавно подпрыгивают каскадом с изменением прозрачности. * Три точки плавно подпрыгивают каскадом с изменением прозрачности.
@@ -463,6 +500,7 @@ fun MessageAttachments(
showTail: Boolean = true, // Показывать хвостик пузырька showTail: Boolean = true, // Показывать хвостик пузырька
isSelectionMode: Boolean = false, // Блокировать клик на фото в selection mode isSelectionMode: Boolean = false, // Блокировать клик на фото в selection mode
onLongClick: () -> Unit = {}, // Long press на фото — запускает selection mode onLongClick: () -> Unit = {}, // Long press на фото — запускает selection mode
onCancelUpload: (attachmentId: String) -> Unit = {},
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }, onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -478,6 +516,7 @@ fun MessageAttachments(
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
// 🖼️ Коллаж для изображений (если больше 1) // 🖼️ Коллаж для изображений (если больше 1)
if (imageAttachments.isNotEmpty()) { if (imageAttachments.isNotEmpty()) {
CompositionLocalProvider(LocalOnCancelImageUpload provides onCancelUpload) {
ImageCollage( ImageCollage(
attachments = imageAttachments, attachments = imageAttachments,
chachaKey = chachaKey, chachaKey = chachaKey,
@@ -494,6 +533,7 @@ fun MessageAttachments(
onImageClick = onImageClick onImageClick = onImageClick
) )
} }
}
// Остальные attachments по отдельности // Остальные attachments по отдельности
otherAttachments.forEach { attachment -> otherAttachments.forEach { attachment ->
@@ -534,7 +574,8 @@ fun MessageAttachments(
) )
} }
else -> { else -> {
/* MESSAGES обрабатываются отдельно */ // Desktop parity: unsupported/legacy attachment gets explicit compatibility card.
LegacyAttachmentErrorCard(isDarkTheme = isDarkTheme)
} }
} }
} }
@@ -926,6 +967,11 @@ fun ImageAttachment(
var blurhashBitmap by remember(attachment.id) { mutableStateOf<Bitmap?>(null) } var blurhashBitmap by remember(attachment.id) { mutableStateOf<Bitmap?>(null) }
var downloadProgress by remember(attachment.id) { mutableStateOf(0f) } var downloadProgress by remember(attachment.id) { mutableStateOf(0f) }
var errorLabel by remember(attachment.id) { mutableStateOf("Error") } var errorLabel by remember(attachment.id) { mutableStateOf("Error") }
val uploadingState by TransportManager.uploading.collectAsState()
val uploadEntry = uploadingState.firstOrNull { it.id == attachment.id }
val uploadProgress = uploadEntry?.progress ?: 0
val isUploadInProgress = uploadEntry != null
val onCancelImageUpload = LocalOnCancelImageUpload.current
val preview = getPreview(attachment) val preview = getPreview(attachment)
val downloadTag = getDownloadTag(attachment) val downloadTag = getDownloadTag(attachment)
@@ -1470,26 +1516,75 @@ fun ImageAttachment(
} }
} }
// ✈️ Telegram-style: Loader при отправке фото (самолётик/кружок) // Desktop-style upload state: Encrypting... (0%) / progress ring (>0%)
// Показываем когда сообщение отправляется И это исходящее сообщение if (isOutgoing &&
if (isOutgoing && messageStatus == MessageStatus.SENDING && downloadStatus == DownloadStatus.DOWNLOADED) { messageStatus == MessageStatus.SENDING &&
downloadStatus == DownloadStatus.DOWNLOADED &&
(isUploadInProgress || attachment.localUri.isNotEmpty())
) {
Box( Box(
modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f)), modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// Круглый индикатор отправки if (uploadProgress > 0) {
val cappedProgress = uploadProgress.coerceIn(1, 95) / 100f
Box( Box(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(44.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f)), .background(Color.Black.copy(alpha = 0.5f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled = !isSelectionMode
) {
onCancelImageUpload(attachment.id)
},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(28.dp), progress = cappedProgress,
modifier = Modifier.size(36.dp),
color = Color.White, color = Color.White,
strokeWidth = 2.5.dp strokeWidth = 2.5.dp,
trackColor = Color.White.copy(alpha = 0.25f),
strokeCap = StrokeCap.Round
) )
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Cancel upload",
tint = Color.White,
modifier = Modifier.size(14.dp)
)
}
} else {
Row(
modifier =
Modifier.clip(RoundedCornerShape(8.dp))
.background(Color.Black.copy(alpha = 0.5f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled = !isSelectionMode
) {
onCancelImageUpload(attachment.id)
}
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
color = Color.White,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(6.dp))
AnimatedDotsText(
baseText = "Encrypting",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
}
} }
} }
} }

View File

@@ -233,7 +233,8 @@ fun TypingIndicator(
typingDisplayName: String = "", typingDisplayName: String = "",
typingSenderPublicKey: String = "" typingSenderPublicKey: String = ""
) { ) {
val typingColor = Color(0xFF54A9EB) val typingColor =
if (isDarkTheme) Color(0xFF54A9EB) else Color.White.copy(alpha = 0.7f)
val senderTypingColor = val senderTypingColor =
remember(typingSenderPublicKey, isDarkTheme) { remember(typingSenderPublicKey, isDarkTheme) {
if (typingSenderPublicKey.isBlank()) { if (typingSenderPublicKey.isBlank()) {
@@ -345,6 +346,7 @@ fun MessageBubble(
onReplyClick: (String) -> Unit = {}, onReplyClick: (String) -> Unit = {},
onRetry: () -> Unit = {}, onRetry: () -> Unit = {},
onDelete: () -> Unit = {}, onDelete: () -> Unit = {},
onCancelPhotoUpload: (attachmentId: String) -> Unit = {},
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }, onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
onAvatarClick: (senderPublicKey: String) -> Unit = {}, onAvatarClick: (senderPublicKey: String) -> Unit = {},
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {}, onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
@@ -571,6 +573,7 @@ fun MessageBubble(
val telegramIncomingAvatarSize = 42.dp val telegramIncomingAvatarSize = 42.dp
val telegramIncomingAvatarLane = 48.dp val telegramIncomingAvatarLane = 48.dp
val telegramIncomingAvatarInset = 6.dp val telegramIncomingAvatarInset = 6.dp
val telegramIncomingBubbleGap = 6.dp
val shouldShowIncomingGroupAvatar = val shouldShowIncomingGroupAvatar =
showIncomingGroupAvatar showIncomingGroupAvatar
?: (isGroupChat && ?: (isGroupChat &&
@@ -811,7 +814,13 @@ fun MessageBubble(
Box( Box(
modifier = modifier =
Modifier.align(Alignment.Bottom) Modifier.align(Alignment.Bottom)
.padding(end = 12.dp) .padding(
start =
if (!message.isOutgoing && isGroupChat)
telegramIncomingBubbleGap
else 0.dp,
end = 12.dp
)
.then(bubbleWidthModifier) .then(bubbleWidthModifier)
.graphicsLayer { .graphicsLayer {
this.alpha = selectionAlpha this.alpha = selectionAlpha
@@ -984,6 +993,7 @@ fun MessageBubble(
// пузырька // пузырька
isSelectionMode = isSelectionMode, isSelectionMode = isSelectionMode,
onLongClick = onLongClick, onLongClick = onLongClick,
onCancelUpload = onCancelPhotoUpload,
// В selection mode блокируем открытие фото // В selection mode блокируем открытие фото
onImageClick = if (isSelectionMode) { _, _ -> } else onImageClick onImageClick = if (isSelectionMode) { _, _ -> } else onImageClick
) )

View File

@@ -631,7 +631,7 @@ fun MediaPickerBottomSheet(
// Telegram-like natural dim: status-bar tint follows picker scrim alpha. // Telegram-like natural dim: status-bar tint follows picker scrim alpha.
if (fullScreen) { if (fullScreen) {
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt() window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
insetsController?.isAppearanceLightStatusBars = !dark insetsController?.isAppearanceLightStatusBars = false
} else { } else {
insetsController?.isAppearanceLightStatusBars = false insetsController?.isAppearanceLightStatusBars = false
var lastAppliedAlpha = -1 var lastAppliedAlpha = -1

View File

@@ -18,6 +18,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@@ -76,8 +77,10 @@ fun AvatarImage(
showOnlineIndicator: Boolean = false, showOnlineIndicator: Boolean = false,
isOnline: Boolean = false, isOnline: Boolean = false,
shape: Shape = CircleShape, shape: Shape = CircleShape,
displayName: String? = null // 🔥 Имя для инициалов (title/username) displayName: String? = null, // 🔥 Имя для инициалов (title/username)
enableBlurPrewarm: Boolean = false
) { ) {
val context = LocalContext.current
val isSystemSafeAccount = publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY val isSystemSafeAccount = publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY
val isSystemUpdatesAccount = publicKey == MessageRepository.SYSTEM_UPDATES_PUBLIC_KEY val isSystemUpdatesAccount = publicKey == MessageRepository.SYSTEM_UPDATES_PUBLIC_KEY
@@ -118,6 +121,18 @@ fun AvatarImage(
} }
} }
LaunchedEffect(enableBlurPrewarm, publicKey, avatarKey, bitmap) {
if (!enableBlurPrewarm) return@LaunchedEffect
val source = bitmap ?: return@LaunchedEffect
prewarmBlurredAvatarFromBitmap(
context = context,
publicKey = publicKey,
avatarTimestamp = avatarKey,
sourceBitmap = source,
blurRadius = 20f
)
}
Box( Box(
modifier = Modifier modifier = Modifier
.size(size) .size(size)

View File

@@ -7,6 +7,7 @@ import android.graphics.Canvas
import android.graphics.Matrix import android.graphics.Matrix
import android.graphics.Paint import android.graphics.Paint
import android.graphics.Shader import android.graphics.Shader
import android.util.LruCache
import android.os.Build import android.os.Build
import android.renderscript.Allocation import android.renderscript.Allocation
import android.renderscript.Element import android.renderscript.Element
@@ -38,6 +39,51 @@ import com.rosetta.messenger.utils.AvatarFileManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.math.abs import kotlin.math.abs
import java.util.Collections
private object BlurredAvatarMemoryCache {
private val cache = object : LruCache<String, Bitmap>(18 * 1024) {
override fun sizeOf(key: String, value: Bitmap): Int = (value.byteCount / 1024).coerceAtLeast(1)
}
private val latestByPublicKey = object : LruCache<String, Bitmap>(12 * 1024) {
override fun sizeOf(key: String, value: Bitmap): Int = (value.byteCount / 1024).coerceAtLeast(1)
}
fun get(key: String): Bitmap? = cache.get(key)
fun getLatest(publicKey: String): Bitmap? = latestByPublicKey.get(publicKey)
fun put(key: String, bitmap: Bitmap) {
if (bitmap.isRecycled) return
cache.put(key, bitmap)
val publicKey = key.substringBeforeLast(':', "")
if (publicKey.isNotEmpty()) {
latestByPublicKey.put(publicKey, bitmap)
}
}
}
private val blurPrewarmInFlight: MutableSet<String> = Collections.synchronizedSet(mutableSetOf())
internal suspend fun prewarmBlurredAvatarFromBitmap(
context: Context,
publicKey: String,
avatarTimestamp: Long,
sourceBitmap: Bitmap,
blurRadius: Float = 20f
) {
if (sourceBitmap.isRecycled || publicKey.isBlank()) return
val cacheKey = "$publicKey:$avatarTimestamp"
if (BlurredAvatarMemoryCache.get(cacheKey) != null) return
if (!blurPrewarmInFlight.add(cacheKey)) return
try {
val blurred = withContext(Dispatchers.Default) {
gaussianBlur(context, sourceBitmap, radius = blurRadius, passes = 2)
}
BlurredAvatarMemoryCache.put(cacheKey, blurred)
} finally {
blurPrewarmInFlight.remove(cacheKey)
}
}
/** /**
* Компонент для отображения размытого фона аватарки * Компонент для отображения размытого фона аватарки
@@ -55,6 +101,7 @@ fun BoxScope.BlurredAvatarBackground(
isDarkTheme: Boolean = true isDarkTheme: Boolean = true
) { ) {
val context = LocalContext.current val context = LocalContext.current
val hasColorOverlay = overlayColors != null && overlayColors.isNotEmpty()
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState() val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) } ?: remember { mutableStateOf(emptyList()) }
@@ -62,11 +109,24 @@ fun BoxScope.BlurredAvatarBackground(
val avatarKey = remember(avatars) { val avatarKey = remember(avatars) {
avatars.firstOrNull()?.timestamp ?: 0L avatars.firstOrNull()?.timestamp ?: 0L
} }
val blurCacheKey = remember(publicKey, avatarKey) { "$publicKey:$avatarKey" }
var originalBitmap by remember { mutableStateOf<Bitmap?>(null) } var originalBitmap by remember(publicKey) { mutableStateOf<Bitmap?>(null) }
var blurredBitmap by remember { mutableStateOf<Bitmap?>(null) } var blurredBitmap by remember(publicKey) {
mutableStateOf(BlurredAvatarMemoryCache.getLatest(publicKey))
}
LaunchedEffect(publicKey, avatarKey, blurCacheKey, hasColorOverlay) {
// For custom color/gradient background we intentionally disable avatar blur layer.
if (hasColorOverlay) {
return@LaunchedEffect
}
BlurredAvatarMemoryCache.get(blurCacheKey)?.let { cached ->
blurredBitmap = cached
return@LaunchedEffect
}
LaunchedEffect(avatarKey, publicKey) {
val currentAvatars = avatars val currentAvatars = avatars
val newOriginal = withContext(Dispatchers.IO) { val newOriginal = withContext(Dispatchers.IO) {
// Keep system account blur source identical to the visible avatar drawable. // Keep system account blur source identical to the visible avatar drawable.
@@ -81,46 +141,36 @@ fun BoxScope.BlurredAvatarBackground(
} }
if (newOriginal != null) { if (newOriginal != null) {
originalBitmap = newOriginal originalBitmap = newOriginal
blurredBitmap = withContext(Dispatchers.Default) { val blurred = withContext(Dispatchers.Default) {
gaussianBlur(context, newOriginal, radius = 20f, passes = 2) gaussianBlur(context, newOriginal, radius = blurRadius, passes = 2)
} }
} else { blurredBitmap = blurred
BlurredAvatarMemoryCache.put(blurCacheKey, blurred)
} else if (blurredBitmap == null) {
originalBitmap = null originalBitmap = null
blurredBitmap = null
} }
} }
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Режим с overlay-цветом — blur + пастельный overlay // Режим с overlay-цветом — blur + пастельный overlay
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
if (overlayColors != null && overlayColors.isNotEmpty()) { if (hasColorOverlay) {
// LAYER 1: Blurred avatar (яркий, видный) val overlayPalette = overlayColors.orEmpty()
if (blurredBitmap != null) { // LAYER 1: Pure color/gradient background (no avatar blur behind avatar).
Image( val overlayMod = if (overlayPalette.size == 1) {
bitmap = blurredBitmap!!.asImageBitmap(),
contentDescription = null,
modifier = Modifier.matchParentSize()
.graphicsLayer { this.alpha = 0.85f },
contentScale = ContentScale.Crop
)
}
// LAYER 2: Цвет/градиент overlay
val colorAlpha = if (blurredBitmap != null) 0.4f else 0.85f
val overlayMod = if (overlayColors.size == 1) {
Modifier.matchParentSize() Modifier.matchParentSize()
.background(overlayColors[0].copy(alpha = colorAlpha)) .background(overlayPalette[0])
} else { } else {
Modifier.matchParentSize() Modifier.matchParentSize()
.background( .background(
Brush.linearGradient( Brush.linearGradient(
colors = overlayColors.map { it.copy(alpha = colorAlpha) } colors = overlayPalette
) )
) )
} }
Box(modifier = overlayMod) Box(modifier = overlayMod)
// LAYER 3: Нижний градиент для читаемости текста // LAYER 2: Нижний градиент для читаемости текста
Box( Box(
modifier = Modifier modifier = Modifier
.matchParentSize() .matchParentSize()
@@ -141,13 +191,14 @@ fun BoxScope.BlurredAvatarBackground(
// Стандартный режим (нет overlay-цвета) — blur аватарки // Стандартный режим (нет overlay-цвета) — blur аватарки
// Telegram-style: яркий видный блюр + мягкое затемнение // Telegram-style: яркий видный блюр + мягкое затемнение
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
if (blurredBitmap != null) { val displayBitmap = blurredBitmap ?: originalBitmap
if (displayBitmap != null) {
// LAYER 1: Размытая аватарка — яркая и видная // LAYER 1: Размытая аватарка — яркая и видная
Image( Image(
bitmap = blurredBitmap!!.asImageBitmap(), bitmap = displayBitmap.asImageBitmap(),
contentDescription = null, contentDescription = null,
modifier = Modifier.matchParentSize() modifier = Modifier.matchParentSize()
.graphicsLayer { this.alpha = 0.9f }, .graphicsLayer { this.alpha = if (blurredBitmap != null) 0.9f else 0.72f },
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
// LAYER 2: Лёгкое тонирование цветом аватара // LAYER 2: Лёгкое тонирование цветом аватара

View File

@@ -159,7 +159,7 @@ fun OnboardingScreen(
if (shouldUpdateStatusBar && !view.isInEditMode) { if (shouldUpdateStatusBar && !view.isInEditMode) {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
val insetsController = WindowCompat.getInsetsController(window, view) val insetsController = WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !isDarkTheme insetsController.isAppearanceLightStatusBars = false
window.statusBarColor = android.graphics.Color.TRANSPARENT window.statusBarColor = android.graphics.Color.TRANSPARENT
// Navigation bar: показываем только если есть нативные кнопки // Navigation bar: показываем только если есть нативные кнопки

View File

@@ -87,7 +87,7 @@ fun AppearanceScreen(
SideEffect { SideEffect {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !isDarkTheme insetsController.isAppearanceLightStatusBars = false
window.statusBarColor = android.graphics.Color.TRANSPARENT window.statusBarColor = android.graphics.Color.TRANSPARENT
} }
} }
@@ -333,6 +333,7 @@ private fun ProfileBlurPreview(
) { ) {
val option = BackgroundBlurPresets.findById(selectedId) val option = BackgroundBlurPresets.findById(selectedId)
val overlayColors = BackgroundBlurPresets.getOverlayColors(selectedId) val overlayColors = BackgroundBlurPresets.getOverlayColors(selectedId)
val shouldUseAvatarBlur = overlayColors.isNullOrEmpty() && selectedId != "none"
val avatarColors = getAvatarColor(publicKey, isDarkTheme) val avatarColors = getAvatarColor(publicKey, isDarkTheme)
// Загрузка аватарки // Загрузка аватарки
@@ -344,7 +345,7 @@ private fun ProfileBlurPreview(
var blurredBitmap by remember { mutableStateOf<Bitmap?>(null) } var blurredBitmap by remember { mutableStateOf<Bitmap?>(null) }
val blurContext = LocalContext.current val blurContext = LocalContext.current
LaunchedEffect(avatarKey) { LaunchedEffect(avatarKey, shouldUseAvatarBlur) {
val current = avatars val current = avatars
if (current.isNotEmpty()) { if (current.isNotEmpty()) {
val decoded = withContext(Dispatchers.IO) { val decoded = withContext(Dispatchers.IO) {
@@ -352,9 +353,14 @@ private fun ProfileBlurPreview(
} }
if (decoded != null) { if (decoded != null) {
avatarBitmap = decoded avatarBitmap = decoded
blurredBitmap = withContext(Dispatchers.Default) { blurredBitmap =
if (shouldUseAvatarBlur) {
withContext(Dispatchers.Default) {
appearanceGaussianBlur(blurContext, decoded, radius = 20f, passes = 2) appearanceGaussianBlur(blurContext, decoded, radius = 20f, passes = 2)
} }
} else {
null
}
} }
} else { } else {
avatarBitmap = null avatarBitmap = null
@@ -375,9 +381,8 @@ private fun ProfileBlurPreview(
// ═══════════════════════════════════════════════════ // ═══════════════════════════════════════════════════
// LAYER 1: Blurred avatar background (идентично BlurredAvatarBackground) // LAYER 1: Blurred avatar background (идентично BlurredAvatarBackground)
// ═══════════════════════════════════════════════════ // ═══════════════════════════════════════════════════
if (blurredBitmap != null) { if (shouldUseAvatarBlur && blurredBitmap != null) {
// overlay-режим: 0.85f, стандартный: 0.9f (как в BlurredAvatarBackground) val blurImgAlpha = 0.9f
val blurImgAlpha = if (overlayColors != null && overlayColors.isNotEmpty()) 0.85f else 0.9f
Image( Image(
bitmap = blurredBitmap!!.asImageBitmap(), bitmap = blurredBitmap!!.asImageBitmap(),
contentDescription = null, contentDescription = null,
@@ -402,20 +407,18 @@ private fun ProfileBlurPreview(
val overlayMod = if (overlayColors.size == 1) { val overlayMod = if (overlayColors.size == 1) {
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(overlayColors[0].copy(alpha = if (blurredBitmap != null) 0.4f else 0.85f)) .background(overlayColors[0])
} else { } else {
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background( .background(
Brush.linearGradient( Brush.linearGradient(
colors = overlayColors.map { colors = overlayColors
it.copy(alpha = if (blurredBitmap != null) 0.4f else 0.85f)
}
) )
) )
} }
Box(modifier = overlayMod) Box(modifier = overlayMod)
} else if (blurredBitmap != null) { } else if (shouldUseAvatarBlur && blurredBitmap != null) {
// Стандартный тинт (идентичен BlurredAvatarBackground) // Стандартный тинт (идентичен BlurredAvatarBackground)
Box( Box(
modifier = Modifier modifier = Modifier
@@ -782,4 +785,3 @@ private fun appearanceGaussianBlur(context: Context, source: Bitmap, radius: Flo
rs.destroy() rs.destroy()
return Bitmap.createBitmap(current, pad, pad, w, h) return Bitmap.createBitmap(current, pad, pad, w, h)
} }

View File

@@ -87,7 +87,7 @@ fun BiometricEnableScreen(
SideEffect { SideEffect {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !isDarkTheme insetsController.isAppearanceLightStatusBars = false
window.statusBarColor = android.graphics.Color.TRANSPARENT window.statusBarColor = android.graphics.Color.TRANSPARENT
} }
} }

View File

@@ -0,0 +1,131 @@
package com.rosetta.messenger.ui.settings
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.ui.icons.TelegramIcons
import compose.icons.TablerIcons
import compose.icons.tablericons.ChevronLeft
import kotlinx.coroutines.launch
import androidx.compose.ui.platform.LocalContext
@Composable
fun NotificationsScreen(
isDarkTheme: Boolean,
onBack: () -> Unit
) {
val context = LocalContext.current
val preferencesManager = remember { PreferencesManager(context) }
val notificationsEnabled by preferencesManager.notificationsEnabled.collectAsState(initial = true)
val avatarInNotifications by preferencesManager.notificationAvatarEnabled.collectAsState(initial = true)
val scope = rememberCoroutineScope()
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
BackHandler { onBack() }
Column(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
) {
androidx.compose.material3.Surface(
modifier = Modifier.fillMaxWidth(),
color = backgroundColor
) {
androidx.compose.foundation.layout.Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding())
.padding(horizontal = 4.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(
imageVector = TablerIcons.ChevronLeft,
contentDescription = "Back",
tint = textColor
)
}
Text(
text = "Notifications",
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
color = textColor,
modifier = Modifier.padding(start = 8.dp)
)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(8.dp))
TelegramSectionTitle(title = "Notifications", isDarkTheme = isDarkTheme)
TelegramToggleItem(
icon = TelegramIcons.Notifications,
title = "Push Notifications",
subtitle = "Messages and call alerts",
isEnabled = notificationsEnabled,
onToggle = {
scope.launch {
preferencesManager.setNotificationsEnabled(!notificationsEnabled)
}
},
isDarkTheme = isDarkTheme,
showDivider = true
)
TelegramToggleItem(
icon = TelegramIcons.Photos,
title = "Avatars in Notifications",
subtitle = "Show sender avatar in push alerts",
isEnabled = avatarInNotifications,
onToggle = {
scope.launch {
preferencesManager.setNotificationAvatarEnabled(!avatarInNotifications)
}
},
isDarkTheme = isDarkTheme
)
Text(
text = "Changes apply instantly.",
color = secondaryTextColor,
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
)
}
}
}

View File

@@ -32,7 +32,7 @@ fun ProfileLogsScreen(
SideEffect { SideEffect {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !isDarkTheme insetsController.isAppearanceLightStatusBars = false
window.statusBarColor = android.graphics.Color.TRANSPARENT window.statusBarColor = android.graphics.Color.TRANSPARENT
} }
} }

View File

@@ -275,8 +275,10 @@ fun ProfileScreen(
accountPublicKey: String, accountPublicKey: String,
accountPrivateKeyHash: String, accountPrivateKeyHash: String,
onBack: () -> Unit, onBack: () -> Unit,
onHasChangesChanged: (Boolean) -> Unit = {},
onSaveProfile: (name: String, username: String) -> Unit, onSaveProfile: (name: String, username: String) -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onNavigateToNotifications: () -> Unit = {},
onNavigateToTheme: () -> Unit = {}, onNavigateToTheme: () -> Unit = {},
onNavigateToAppearance: () -> Unit = {}, onNavigateToAppearance: () -> Unit = {},
onNavigateToSafety: () -> Unit = {}, onNavigateToSafety: () -> Unit = {},
@@ -402,7 +404,7 @@ fun ProfileScreen(
// State for editing - Update when account data changes // State for editing - Update when account data changes
var editedName by remember(accountName) { mutableStateOf(accountName) } var editedName by remember(accountName) { mutableStateOf(accountName) }
var editedUsername by remember(accountUsername) { mutableStateOf(accountUsername) } var editedUsername by remember(accountUsername) { mutableStateOf(accountUsername) }
var hasChanges by remember { mutableStateOf(false) } val hasChanges = editedName != accountName || editedUsername != accountUsername
var nameTouched by remember { mutableStateOf(false) } var nameTouched by remember { mutableStateOf(false) }
var usernameTouched by remember { mutableStateOf(false) } var usernameTouched by remember { mutableStateOf(false) }
var showValidationErrors by remember { mutableStateOf(false) } var showValidationErrors by remember { mutableStateOf(false) }
@@ -701,7 +703,6 @@ fun ProfileScreen(
// Following desktop version: update local data AFTER server confirms success // Following desktop version: update local data AFTER server confirms success
viewModel.updateLocalProfile(accountPublicKey, editedName, editedUsername) viewModel.updateLocalProfile(accountPublicKey, editedName, editedUsername)
hasChanges = false
serverNameError = null serverNameError = null
serverUsernameError = null serverUsernameError = null
serverGeneralError = null serverGeneralError = null
@@ -743,9 +744,14 @@ fun ProfileScreen(
} }
} }
// Update hasChanges when fields change SideEffect {
LaunchedEffect(editedName, editedUsername) { onHasChangesChanged(hasChanges)
hasChanges = editedName != accountName || editedUsername != accountUsername }
DisposableEffect(Unit) {
onDispose {
onHasChangesChanged(false)
}
} }
// Handle back button press - navigate to chat list instead of closing app // Handle back button press - navigate to chat list instead of closing app
@@ -838,49 +844,19 @@ fun ProfileScreen(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
// ═════════════════════════════════════════════════════════════
// 🔔 NOTIFICATIONS SECTION
// ═════════════════════════════════════════════════════════════
TelegramSectionTitle(title = "Notifications", isDarkTheme = isDarkTheme)
run {
val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
val notificationsEnabled by preferencesManager.notificationsEnabled.collectAsState(initial = true)
val avatarInNotifications by preferencesManager.notificationAvatarEnabled.collectAsState(initial = true)
val scope = rememberCoroutineScope()
TelegramToggleItem(
icon = TelegramIcons.Notifications,
title = "Push Notifications",
isEnabled = notificationsEnabled,
onToggle = {
scope.launch {
preferencesManager.setNotificationsEnabled(!notificationsEnabled)
}
},
isDarkTheme = isDarkTheme
)
TelegramToggleItem(
icon = TelegramIcons.Photos,
title = "Avatars in Notifications",
isEnabled = avatarInNotifications,
onToggle = {
scope.launch {
preferencesManager.setNotificationAvatarEnabled(!avatarInNotifications)
}
},
isDarkTheme = isDarkTheme
)
}
Spacer(modifier = Modifier.height(24.dp))
// ═════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════
// ⚙️ SETTINGS SECTION - Telegram style // ⚙️ SETTINGS SECTION - Telegram style
// ═════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════
TelegramSectionTitle(title = "Settings", isDarkTheme = isDarkTheme) TelegramSectionTitle(title = "Settings", isDarkTheme = isDarkTheme)
TelegramSettingsItem(
icon = TelegramIcons.Notifications,
title = "Notifications",
onClick = onNavigateToNotifications,
isDarkTheme = isDarkTheme,
showDivider = true
)
TelegramSettingsItem( TelegramSettingsItem(
icon = TelegramIcons.Theme, icon = TelegramIcons.Theme,
title = "Theme", title = "Theme",

View File

@@ -78,7 +78,7 @@ fun ThemeScreen(
SideEffect { SideEffect {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !isDarkTheme insetsController.isAppearanceLightStatusBars = false
window.statusBarColor = android.graphics.Color.TRANSPARENT window.statusBarColor = android.graphics.Color.TRANSPARENT
} }
} }

View File

@@ -35,7 +35,7 @@ fun UpdatesScreen(
SideEffect { SideEffect {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !isDarkTheme insetsController.isAppearanceLightStatusBars = false
window.statusBarColor = android.graphics.Color.TRANSPARENT window.statusBarColor = android.graphics.Color.TRANSPARENT
} }
} }

View File

@@ -40,7 +40,7 @@ object SystemBarsStyleUtils {
if (window == null || view == null) return if (window == null || view == null) return
val insetsController = WindowCompat.getInsetsController(window, view) val insetsController = WindowCompat.getInsetsController(window, view)
window.statusBarColor = Color.TRANSPARENT window.statusBarColor = Color.TRANSPARENT
insetsController.isAppearanceLightStatusBars = !isDarkTheme insetsController.isAppearanceLightStatusBars = false
} }
fun restoreNavigationBar(window: Window?, view: View?, context: Context, state: SystemBarsState?) { fun restoreNavigationBar(window: Window?, view: View?, context: Context, state: SystemBarsState?) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB