diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 217fdc3..3da3783 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -16,10 +16,15 @@ import androidx.activity.compose.BackHandler import androidx.compose.animation.* import androidx.compose.animation.core.tween 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.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.* import androidx.compose.ui.Modifier 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.settings.BackupScreen 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.ProfileScreen import com.rosetta.messenger.ui.settings.SafetyScreen @@ -203,6 +209,8 @@ class MainActivity : FragmentActivity() { var currentAccount by remember { mutableStateOf(getCachedSessionAccount()) } var accountInfoList by remember { mutableStateOf>(emptyList()) } var startCreateAccountFlow by remember { mutableStateOf(false) } + var preservedMainNavStack by remember { mutableStateOf>(emptyList()) } + var preservedMainNavAccountKey by remember { mutableStateOf("") } // Check for existing accounts and build AccountInfo list LaunchedEffect(Unit) { @@ -303,6 +311,8 @@ class MainActivity : FragmentActivity() { }, onLogout = { startCreateAccountFlow = false + preservedMainNavStack = emptyList() + preservedMainNavAccountKey = "" // Set currentAccount to null immediately to prevent UI // lag currentAccount = null @@ -316,8 +326,27 @@ class MainActivity : FragmentActivity() { ) } "main" -> { + val activeAccountKey = currentAccount?.publicKey.orEmpty() MainScreen( 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, themeMode = themeMode, onToggleTheme = { @@ -331,6 +360,8 @@ class MainActivity : FragmentActivity() { }, onLogout = { startCreateAccountFlow = false + preservedMainNavStack = emptyList() + preservedMainNavAccountKey = "" // Set currentAccount to null immediately to prevent UI // lag currentAccount = null @@ -343,6 +374,8 @@ class MainActivity : FragmentActivity() { }, onDeleteAccount = { startCreateAccountFlow = false + preservedMainNavStack = emptyList() + preservedMainNavAccountKey = "" val publicKey = currentAccount?.publicKey ?: return@MainScreen scope.launch { try { @@ -405,6 +438,8 @@ class MainActivity : FragmentActivity() { hasExistingAccount = accounts.isNotEmpty() // 9. If current account is deleted, return to main login screen if (currentAccount?.publicKey == targetPublicKey) { + preservedMainNavStack = emptyList() + preservedMainNavAccountKey = "" currentAccount = null clearCachedSessionAccount() } @@ -415,6 +450,8 @@ class MainActivity : FragmentActivity() { }, onSwitchAccount = { targetPublicKey -> startCreateAccountFlow = false + preservedMainNavStack = emptyList() + preservedMainNavAccountKey = "" // Save target account before leaving main screen so Unlock // screen preselects the account the user tapped. accountManager.setLastLoggedPublicKey(targetPublicKey) @@ -429,6 +466,8 @@ class MainActivity : FragmentActivity() { }, onAddAccount = { startCreateAccountFlow = true + preservedMainNavStack = emptyList() + preservedMainNavAccountKey = "" currentAccount = null clearCachedSessionAccount() scope.launch { @@ -442,6 +481,8 @@ class MainActivity : FragmentActivity() { DeviceConfirmScreen( isDarkTheme = isDarkTheme, onExit = { + preservedMainNavStack = emptyList() + preservedMainNavAccountKey = "" currentAccount = null clearCachedSessionAccount() scope.launch { @@ -614,6 +655,7 @@ private fun EncryptedAccount.toAccountInfo(): AccountInfo { sealed class Screen { data object Profile : Screen() data object ProfileFromChat : Screen() + data object Notifications : Screen() data object Requests : Screen() data object Search : Screen() data object GroupSetup : Screen() @@ -634,6 +676,8 @@ sealed class Screen { @Composable fun MainScreen( account: DecryptedAccount? = null, + initialNavStack: List = emptyList(), + onNavStackChanged: (List) -> Unit = {}, isDarkTheme: Boolean = true, themeMode: String = "dark", onToggleTheme: () -> Unit = {}, @@ -941,7 +985,8 @@ fun MainScreen( // navigation change. This eliminates the massive recomposition // that happened when ANY boolean flag changed. // ═══════════════════════════════════════════════════════════ - var navStack by remember { mutableStateOf>(emptyList()) } + var navStack by remember(accountPublicKey) { mutableStateOf(initialNavStack) } + LaunchedEffect(navStack) { onNavStackChanged(navStack) } // Derived visibility — only triggers recomposition when THIS screen changes val isProfileVisible by remember { derivedStateOf { navStack.any { it is Screen.Profile } } } @@ -949,6 +994,9 @@ fun MainScreen( derivedStateOf { navStack.any { it is Screen.ProfileFromChat } } } 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 isGroupSetupVisible by remember { derivedStateOf { navStack.any { it is Screen.GroupSetup } } } val chatDetailScreen by remember { @@ -980,6 +1028,9 @@ fun MainScreen( val isAppearanceVisible by remember { 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 fun pushScreen(screen: Screen) { @@ -1027,7 +1078,8 @@ fun MainScreen( navStack = navStack.filterNot { it is Screen.Profile || - it is Screen.Theme || + it is Screen.Theme || + it is Screen.Notifications || it is Screen.Safety || it is Screen.Backup || it is Screen.Logs || @@ -1036,6 +1088,21 @@ fun MainScreen( 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() { navStack = navStack.filterNot { @@ -1043,6 +1110,13 @@ fun MainScreen( } } + LaunchedEffect(isProfileVisible, isProfileFromChatVisible) { + if (!isProfileVisible && !isProfileFromChatVisible) { + profileHasUnsavedChanges = false + showDiscardProfileChangesDialog = false + } + } + // ProfileViewModel для логов val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel() @@ -1196,9 +1270,10 @@ fun MainScreen( // ═══════════════════════════════════════════════════════════ SwipeBackContainer( isVisible = isProfileVisible, - onBack = { popProfileAndChildren() }, + onBack = { requestProfileBack(fromChat = false) }, isDarkTheme = isDarkTheme, layer = 1, + swipeEnabled = !profileHasUnsavedChanges, propagateBackgroundProgress = false ) { // Экран профиля @@ -1209,13 +1284,15 @@ fun MainScreen( accountVerified = accountVerified, accountPublicKey = accountPublicKey, accountPrivateKeyHash = privateKeyHash, - onBack = { popProfileAndChildren() }, + onBack = { requestProfileBack(fromChat = false) }, + onHasChangesChanged = { profileHasUnsavedChanges = it }, onSaveProfile = { name, username -> accountName = name accountUsername = username mainScreenScope.launch { onAccountInfoUpdated() } }, onLogout = onLogout, + onNavigateToNotifications = { pushScreen(Screen.Notifications) }, onNavigateToTheme = { pushScreen(Screen.Theme) }, onNavigateToAppearance = { pushScreen(Screen.Appearance) }, onNavigateToSafety = { pushScreen(Screen.Safety) }, @@ -1229,6 +1306,18 @@ fun MainScreen( } // 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( isVisible = isSafetyVisible, onBack = { navStack = navStack.filterNot { it is Screen.Safety } }, @@ -1420,9 +1509,10 @@ fun MainScreen( SwipeBackContainer( isVisible = isProfileFromChatVisible, - onBack = { navStack = navStack.filterNot { it is Screen.ProfileFromChat } }, + onBack = { requestProfileBack(fromChat = true) }, isDarkTheme = isDarkTheme, layer = 1, + swipeEnabled = !profileHasUnsavedChanges, propagateBackgroundProgress = false ) { ProfileScreen( @@ -1432,13 +1522,15 @@ fun MainScreen( accountVerified = accountVerified, accountPublicKey = accountPublicKey, accountPrivateKeyHash = privateKeyHash, - onBack = { navStack = navStack.filterNot { it is Screen.ProfileFromChat } }, + onBack = { requestProfileBack(fromChat = true) }, + onHasChangesChanged = { profileHasUnsavedChanges = it }, onSaveProfile = { name, username -> accountName = name accountUsername = username mainScreenScope.launch { onAccountInfoUpdated() } }, onLogout = onLogout, + onNavigateToNotifications = { pushScreen(Screen.Notifications) }, onNavigateToTheme = { pushScreen(Screen.Theme) }, onNavigateToAppearance = { pushScreen(Screen.Appearance) }, onNavigateToSafety = { pushScreen(Screen.Safety) }, @@ -1690,6 +1782,21 @@ fun MainScreen( ) } + if (isCallScreenVisible) { + // Блокируем любой ввод по нижележащим экранам, пока открыт полноэкранный CallOverlay. + // Иначе тапы могут "пробивать" в чат (иконка звонка, kebab, input и т.д.). + val blockerInteraction = remember { MutableInteractionSource() } + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = blockerInteraction, + indication = null, + onClick = {} + ) + ) + } + CallOverlay( state = callUiState, isDarkTheme = isDarkTheme, @@ -1706,5 +1813,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) + ) + } + } + ) + } } } diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 69e19cd..0c76272 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -448,6 +448,18 @@ class MessageRepository private constructor(private val context: Context) { 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> { val dialogKey = getDialogKey(opponentKey) @@ -711,6 +723,13 @@ class MessageRepository private constructor(private val context: Context) { val isOwnMessage = packet.fromPublicKey == account 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)) { @@ -911,7 +930,7 @@ class MessageRepository private constructor(private val context: Context) { fromPublicKey = packet.fromPublicKey, toPublicKey = packet.toPublicKey, content = packet.content, - timestamp = packet.timestamp, + timestamp = normalizedPacketTimestamp, chachaKey = storedChachaKey, read = 0, fromMe = if (isOwnMessage) 1 else 0, diff --git a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt index 43edcb1..a191bb3 100644 --- a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt @@ -238,7 +238,7 @@ interface MessageDao { """ SELECT * FROM messages WHERE account = :account AND dialog_key = :dialogKey - ORDER BY timestamp DESC, message_id DESC + ORDER BY timestamp DESC, id DESC LIMIT :limit OFFSET :offset """ ) @@ -260,7 +260,7 @@ interface MessageDao { WHERE account = :account AND from_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 """ ) @@ -286,7 +286,7 @@ interface MessageDao { """ SELECT * FROM messages 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> @@ -319,7 +319,7 @@ interface MessageDao { """ SELECT * FROM messages WHERE account = :account AND dialog_key = :dialogKey - ORDER BY timestamp DESC, message_id DESC + ORDER BY timestamp DESC, id DESC LIMIT :limit """ ) @@ -378,7 +378,7 @@ interface MessageDao { AND dialog_key = :dialogKey AND from_public_key = :fromPublicKey AND timestamp BETWEEN :timestampFrom AND :timestampTo - ORDER BY timestamp ASC, message_id ASC + ORDER BY timestamp ASC, id ASC LIMIT 1 """ ) @@ -462,7 +462,7 @@ interface MessageDao { WHERE account = :account AND ((from_public_key = :opponent AND to_public_key = :account) 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? @@ -477,7 +477,7 @@ interface MessageDao { WHERE account = :account AND ((from_public_key = :opponent AND to_public_key = :account) 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? @@ -492,7 +492,7 @@ interface MessageDao { WHERE account = :account AND ((from_public_key = :opponent AND to_public_key = :account) 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? @@ -629,7 +629,7 @@ interface MessageDao { END WHERE m.account = :account 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 """ ) @@ -978,7 +978,7 @@ interface DialogDao { """ SELECT * FROM messages 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? diff --git a/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt b/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt index 866f0ff..181e999 100644 --- a/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt +++ b/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt @@ -332,7 +332,7 @@ abstract class RosettaDatabase : RoomDatabase() { THEN dialogs.account || ':' || dialogs.opponent_key ELSE dialogs.opponent_key || ':' || dialogs.account END - ORDER BY m.timestamp DESC, m.message_id DESC + ORDER BY m.timestamp DESC, m.id DESC LIMIT 1 ), '' diff --git a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt index 8791d71..a3e4122 100644 --- a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt @@ -19,7 +19,6 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.coroutineContext /** @@ -44,6 +43,7 @@ object TransportManager { private val _uploading = MutableStateFlow>(emptyList()) val uploading: StateFlow> = _uploading.asStateFlow() + private val activeUploadCalls = ConcurrentHashMap() private val _downloading = MutableStateFlow>(emptyList()) val downloading: StateFlow> = _downloading.asStateFlow() @@ -133,6 +133,14 @@ object TransportManager { _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 = suspendCancellableCoroutine { cont -> val call = client.newCall(request) @@ -224,13 +232,31 @@ object TransportManager { .post(requestBody) .build() - val response = suspendCoroutine { cont -> - client.newCall(request).enqueue(object : Callback { + val response = suspendCancellableCoroutine { cont -> + 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) { - cont.resumeWithException(e) + activeUploadCalls.remove(id, call) + if (call.isCanceled()) { + cont.cancel(CancellationException("Upload cancelled")) + } else { + cont.resumeWithException(e) + } } override fun onResponse(call: Call, response: Response) { + activeUploadCalls.remove(id, call) + if (cont.isCancelled) { + response.close() + return + } cont.resume(response) } }) @@ -253,12 +279,16 @@ object TransportManager { tag } + } catch (e: CancellationException) { + ProtocolManager.addLog("🛑 Upload cancelled: id=${id.take(8)}") + throw e } catch (e: Exception) { ProtocolManager.addLog( "❌ Upload failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}" ) throw e } finally { + activeUploadCalls.remove(id)?.cancel() // Удаляем из списка загрузок _uploading.value = _uploading.value.filter { it.id != id } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt index 6f91b02..4cc58fb 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt @@ -98,7 +98,7 @@ fun SelectAccountScreen( DisposableEffect(isDarkTheme) { val window = (view.context as android.app.Activity).window val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) - insetsController.isAppearanceLightStatusBars = !isDarkTheme + insetsController.isAppearanceLightStatusBars = false onDispose { } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 7b6d209..9af4799 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -3306,6 +3306,14 @@ fun ChatDetailScreen( message.id ) }, + onCancelPhotoUpload = { + attachmentId -> + viewModel + .cancelOutgoingImageUpload( + message.id, + attachmentId + ) + }, onImageClick = { attachmentId, bounds diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index f7cf91f..7da3eaf 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -45,10 +45,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM private const val DRAFT_SAVE_DEBOUNCE_MS = 250L private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private val chatMessageAscComparator = - compareBy({ it.timestamp.time }, { it.id }) + // Keep sort stable for equal timestamps: tie order comes from existing list/insertion order. + private val chatMessageAscComparator = compareBy { it.timestamp.time } private val chatMessageDescComparator = - compareByDescending { it.timestamp.time }.thenByDescending { it.id } + compareByDescending { it.timestamp.time } private fun sortMessagesAsc(messages: List): List = messages.sortedWith(chatMessageAscComparator) @@ -227,6 +227,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Job для отмены загрузки при смене диалога private var loadingJob: Job? = null private var draftSaveJob: Job? = null + private val outgoingImageUploadJobs = ConcurrentHashMap() // 🔥 Throttling для typing индикатора private var lastTypingSentTime = 0L @@ -2579,6 +2580,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) { val account = myPublicKey ?: return @@ -3514,7 +3528,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 2. 🔄 В фоне, независимо от жизненного цикла экрана: // сохраняем optimistic в БД -> конвертируем -> загружаем -> отправляем пакет. - backgroundUploadScope.launch { + val uploadJob = backgroundUploadScope.launch { try { logPhotoPipeline(messageId, "persist optimistic message in DB") val optimisticAttachmentsJson = @@ -3554,6 +3568,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { opponentPublicKey = recipient ) logPhotoPipeline(messageId, "optimistic dialog updated") + } catch (e: CancellationException) { + throw e } catch (_: Exception) { logPhotoPipeline(messageId, "optimistic DB save skipped (non-fatal)") } @@ -3603,6 +3619,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { privateKey = privateKey ) logPhotoPipeline(messageId, "pipeline completed") + } catch (e: CancellationException) { + logPhotoPipeline(messageId, "pipeline cancelled by user") + throw e } catch (e: Exception) { logPhotoPipelineError(messageId, "prepare+convert", e) if (!isCleared) { @@ -3612,6 +3631,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } } + outgoingImageUploadJobs[messageId] = uploadJob + uploadJob.invokeOnCompletion { outgoingImageUploadJobs.remove(messageId) } } /** 🔄 Обновляет optimistic сообщение с реальными данными изображения */ @@ -3655,6 +3676,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { sender: String, privateKey: String ) { + var packetSentToProtocol = false try { val context = getApplication() val pipelineStartedAt = System.currentTimeMillis() @@ -3746,6 +3768,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Отправляем пакет if (!isSavedMessages) { ProtocolManager.send(packet) + packetSentToProtocol = true logPhotoPipeline(messageId, "packet sent to protocol") } else { logPhotoPipeline(messageId, "saved-messages mode: packet send skipped") @@ -3811,9 +3834,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { messageId, "dialog updated; totalElapsed=${System.currentTimeMillis() - pipelineStartedAt}ms" ) + } catch (e: CancellationException) { + logPhotoPipeline(messageId, "internal-send cancelled") + throw e } catch (e: Exception) { logPhotoPipelineError(messageId, "internal-send", e) - withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) } + 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) } + } } } @@ -5469,6 +5503,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { typingNameResolveJob?.cancel() draftSaveJob?.cancel() pinnedCollectionJob?.cancel() + outgoingImageUploadJobs.values.forEach { it.cancel() } + outgoingImageUploadJobs.clear() // 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов ProtocolManager.unwaitPacket(0x0B, typingPacketHandler) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 969050a..cb680f0 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -3464,7 +3464,8 @@ fun ChatItem( isDarkTheme = isDarkTheme, showOnlineIndicator = true, isOnline = chat.isOnline, - displayName = chat.name // 🔥 Для инициалов + displayName = chat.name, // 🔥 Для инициалов + enableBlurPrewarm = true ) Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP)) @@ -4208,7 +4209,8 @@ fun DialogItemContent( avatarRepository = avatarRepository, size = TELEGRAM_DIALOG_AVATAR_SIZE, isDarkTheme = isDarkTheme, - displayName = avatarDisplayName + displayName = avatarDisplayName, + enableBlurPrewarm = true ) } @@ -4736,7 +4738,7 @@ fun TypingIndicatorSmall( typingDisplayName: String = "", typingSenderPublicKey: String = "" ) { - val typingColor = PrimaryBlue + val typingColor = if (isDarkTheme) PrimaryBlue else Color(0xFF34C759) val senderTypingColor = remember(typingSenderPublicKey, isDarkTheme) { if (typingSenderPublicKey.isBlank()) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt index 1ffd84f..20bb7d0 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt @@ -150,6 +150,8 @@ fun GroupSetupScreen( val primaryTextColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = Color(0xFF8E8E93) 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 = if (isDarkTheme) sectionColor else Color(0xFF8CC9F6) val groupAvatarCameraIconColor = @@ -745,8 +747,8 @@ fun GroupSetupScreen( isLoading = false } }, - containerColor = if (actionEnabled) accentColor else accentColor.copy(alpha = 0.42f), - contentColor = Color.White, + containerColor = if (actionEnabled) accentColor else disabledActionColor, + contentColor = if (actionEnabled) Color.White else disabledActionContentColor, shape = CircleShape, modifier = run { // Берём максимум из всех позиций — при переключении keyboard↔emoji @@ -762,7 +764,7 @@ fun GroupSetupScreen( ) { if (isLoading && step == GroupSetupStep.DESCRIPTION) { CircularProgressIndicator( - color = Color.White, + color = if (actionEnabled) Color.White else disabledActionContentColor, strokeWidth = 2.dp, modifier = Modifier.size(22.dp) ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt index ced4e60..fc04dae 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt @@ -109,7 +109,7 @@ fun SearchScreen( DisposableEffect(isDarkTheme) { val window = (view.context as android.app.Activity).window val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) - insetsController.isAppearanceLightStatusBars = !isDarkTheme + insetsController.isAppearanceLightStatusBars = false window.statusBarColor = android.graphics.Color.TRANSPARENT onDispose { // Restore white status bar icons for chat list header diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt index 67a03cb..ca7e8f3 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt @@ -765,7 +765,7 @@ fun ChatAttachAlert( // as the popup overlay, so top area and content overlay always match. if (fullScreen) { window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt() - insetsController?.isAppearanceLightStatusBars = !dark + insetsController?.isAppearanceLightStatusBars = false } else { insetsController?.isAppearanceLightStatusBars = false var lastAppliedAlpha = -1 diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index cf23ea4..c751393 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -86,6 +86,7 @@ import kotlin.math.min private const val TAG = "AttachmentComponents" private const val MAX_BITMAP_DECODE_DIMENSION = 4096 private val whitespaceRegex = "\\s+".toRegex() +private val LocalOnCancelImageUpload = staticCompositionLocalOf<(String) -> Unit> { {} } private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:") @@ -463,6 +464,7 @@ fun MessageAttachments( showTail: Boolean = true, // Показывать хвостик пузырька isSelectionMode: Boolean = false, // Блокировать клик на фото в selection mode onLongClick: () -> Unit = {}, // Long press на фото — запускает selection mode + onCancelUpload: (attachmentId: String) -> Unit = {}, onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }, modifier: Modifier = Modifier ) { @@ -478,21 +480,23 @@ fun MessageAttachments( Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { // 🖼️ Коллаж для изображений (если больше 1) if (imageAttachments.isNotEmpty()) { - ImageCollage( - attachments = imageAttachments, - chachaKey = chachaKey, - privateKey = privateKey, - senderPublicKey = senderPublicKey, - isOutgoing = isOutgoing, - isDarkTheme = isDarkTheme, - timestamp = timestamp, - messageStatus = messageStatus, - hasCaption = hasCaption, - showTail = showTail, - isSelectionMode = isSelectionMode, - onLongClick = onLongClick, - onImageClick = onImageClick - ) + CompositionLocalProvider(LocalOnCancelImageUpload provides onCancelUpload) { + ImageCollage( + attachments = imageAttachments, + chachaKey = chachaKey, + privateKey = privateKey, + senderPublicKey = senderPublicKey, + isOutgoing = isOutgoing, + isDarkTheme = isDarkTheme, + timestamp = timestamp, + messageStatus = messageStatus, + hasCaption = hasCaption, + showTail = showTail, + isSelectionMode = isSelectionMode, + onLongClick = onLongClick, + onImageClick = onImageClick + ) + } } // Остальные attachments по отдельности @@ -926,6 +930,11 @@ fun ImageAttachment( var blurhashBitmap by remember(attachment.id) { mutableStateOf(null) } var downloadProgress by remember(attachment.id) { mutableStateOf(0f) } 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 downloadTag = getDownloadTag(attachment) @@ -1470,26 +1479,75 @@ fun ImageAttachment( } } - // ✈️ Telegram-style: Loader при отправке фото (самолётик/кружок) - // Показываем когда сообщение отправляется И это исходящее сообщение - if (isOutgoing && messageStatus == MessageStatus.SENDING && downloadStatus == DownloadStatus.DOWNLOADED) { + // Desktop-style upload state: Encrypting... (0%) / progress ring (>0%) + if (isOutgoing && + messageStatus == MessageStatus.SENDING && + downloadStatus == DownloadStatus.DOWNLOADED && + (isUploadInProgress || attachment.localUri.isNotEmpty()) + ) { Box( modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f)), contentAlignment = Alignment.Center ) { - // Круглый индикатор отправки - Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(Color.Black.copy(alpha = 0.5f)), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - modifier = Modifier.size(28.dp), - color = Color.White, - strokeWidth = 2.5.dp - ) + if (uploadProgress > 0) { + val cappedProgress = uploadProgress.coerceIn(1, 95) / 100f + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.5f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + enabled = !isSelectionMode + ) { + onCancelImageUpload(attachment.id) + }, + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + progress = cappedProgress, + modifier = Modifier.size(36.dp), + color = Color.White, + 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 + ) + } } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 02442cb..9231e17 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -233,7 +233,8 @@ fun TypingIndicator( typingDisplayName: String = "", typingSenderPublicKey: String = "" ) { - val typingColor = Color(0xFF54A9EB) + val typingColor = + if (isDarkTheme) Color(0xFF54A9EB) else Color.White.copy(alpha = 0.7f) val senderTypingColor = remember(typingSenderPublicKey, isDarkTheme) { if (typingSenderPublicKey.isBlank()) { @@ -345,6 +346,7 @@ fun MessageBubble( onReplyClick: (String) -> Unit = {}, onRetry: () -> Unit = {}, onDelete: () -> Unit = {}, + onCancelPhotoUpload: (attachmentId: String) -> Unit = {}, onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }, onAvatarClick: (senderPublicKey: String) -> Unit = {}, onForwardedSenderClick: (senderPublicKey: String) -> Unit = {}, @@ -984,6 +986,7 @@ fun MessageBubble( // пузырька isSelectionMode = isSelectionMode, onLongClick = onLongClick, + onCancelUpload = onCancelPhotoUpload, // В selection mode блокируем открытие фото onImageClick = if (isSelectionMode) { _, _ -> } else onImageClick ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt index 1b2674b..79ab820 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt @@ -631,7 +631,7 @@ fun MediaPickerBottomSheet( // Telegram-like natural dim: status-bar tint follows picker scrim alpha. if (fullScreen) { window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt() - insetsController?.isAppearanceLightStatusBars = !dark + insetsController?.isAppearanceLightStatusBars = false } else { insetsController?.isAppearanceLightStatusBars = false var lastAppliedAlpha = -1 diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt index 7617a55..14ea0a8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp @@ -76,8 +77,10 @@ fun AvatarImage( showOnlineIndicator: Boolean = false, isOnline: Boolean = false, 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 isSystemUpdatesAccount = publicKey == MessageRepository.SYSTEM_UPDATES_PUBLIC_KEY @@ -117,6 +120,18 @@ fun AvatarImage( bitmap = null } } + + 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( modifier = Modifier diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt b/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt index d67581f..cdad6b8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt @@ -7,6 +7,7 @@ import android.graphics.Canvas import android.graphics.Matrix import android.graphics.Paint import android.graphics.Shader +import android.util.LruCache import android.os.Build import android.renderscript.Allocation import android.renderscript.Element @@ -38,6 +39,51 @@ import com.rosetta.messenger.utils.AvatarFileManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlin.math.abs +import java.util.Collections + +private object BlurredAvatarMemoryCache { + private val cache = object : LruCache(18 * 1024) { + override fun sizeOf(key: String, value: Bitmap): Int = (value.byteCount / 1024).coerceAtLeast(1) + } + private val latestByPublicKey = object : LruCache(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 = 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 ) { val context = LocalContext.current + val hasColorOverlay = overlayColors != null && overlayColors.isNotEmpty() val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState() ?: remember { mutableStateOf(emptyList()) } @@ -62,11 +109,24 @@ fun BoxScope.BlurredAvatarBackground( val avatarKey = remember(avatars) { avatars.firstOrNull()?.timestamp ?: 0L } + val blurCacheKey = remember(publicKey, avatarKey) { "$publicKey:$avatarKey" } - var originalBitmap by remember { mutableStateOf(null) } - var blurredBitmap by remember { mutableStateOf(null) } + var originalBitmap by remember(publicKey) { mutableStateOf(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 newOriginal = withContext(Dispatchers.IO) { // Keep system account blur source identical to the visible avatar drawable. @@ -81,46 +141,36 @@ fun BoxScope.BlurredAvatarBackground( } if (newOriginal != null) { originalBitmap = newOriginal - blurredBitmap = withContext(Dispatchers.Default) { - gaussianBlur(context, newOriginal, radius = 20f, passes = 2) + val blurred = withContext(Dispatchers.Default) { + gaussianBlur(context, newOriginal, radius = blurRadius, passes = 2) } - } else { + blurredBitmap = blurred + BlurredAvatarMemoryCache.put(blurCacheKey, blurred) + } else if (blurredBitmap == null) { originalBitmap = null - blurredBitmap = null } } // ═══════════════════════════════════════════════════════════ // Режим с overlay-цветом — blur + пастельный overlay // ═══════════════════════════════════════════════════════════ - if (overlayColors != null && overlayColors.isNotEmpty()) { - // LAYER 1: Blurred avatar (яркий, видный) - if (blurredBitmap != null) { - Image( - 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) { + if (hasColorOverlay) { + val overlayPalette = overlayColors.orEmpty() + // LAYER 1: Pure color/gradient background (no avatar blur behind avatar). + val overlayMod = if (overlayPalette.size == 1) { Modifier.matchParentSize() - .background(overlayColors[0].copy(alpha = colorAlpha)) + .background(overlayPalette[0]) } else { Modifier.matchParentSize() .background( Brush.linearGradient( - colors = overlayColors.map { it.copy(alpha = colorAlpha) } + colors = overlayPalette ) ) } Box(modifier = overlayMod) - // LAYER 3: Нижний градиент для читаемости текста + // LAYER 2: Нижний градиент для читаемости текста Box( modifier = Modifier .matchParentSize() @@ -141,13 +191,14 @@ fun BoxScope.BlurredAvatarBackground( // Стандартный режим (нет overlay-цвета) — blur аватарки // Telegram-style: яркий видный блюр + мягкое затемнение // ═══════════════════════════════════════════════════════════ - if (blurredBitmap != null) { + val displayBitmap = blurredBitmap ?: originalBitmap + if (displayBitmap != null) { // LAYER 1: Размытая аватарка — яркая и видная Image( - bitmap = blurredBitmap!!.asImageBitmap(), + bitmap = displayBitmap.asImageBitmap(), contentDescription = null, modifier = Modifier.matchParentSize() - .graphicsLayer { this.alpha = 0.9f }, + .graphicsLayer { this.alpha = if (blurredBitmap != null) 0.9f else 0.72f }, contentScale = ContentScale.Crop ) // LAYER 2: Лёгкое тонирование цветом аватара diff --git a/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt index 281b285..78f5e65 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt @@ -159,7 +159,7 @@ fun OnboardingScreen( if (shouldUpdateStatusBar && !view.isInEditMode) { val window = (view.context as android.app.Activity).window val insetsController = WindowCompat.getInsetsController(window, view) - insetsController.isAppearanceLightStatusBars = !isDarkTheme + insetsController.isAppearanceLightStatusBars = false window.statusBarColor = android.graphics.Color.TRANSPARENT // Navigation bar: показываем только если есть нативные кнопки diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt index 86d4dee..0b1f410 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt @@ -87,7 +87,7 @@ fun AppearanceScreen( SideEffect { val window = (view.context as android.app.Activity).window val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) - insetsController.isAppearanceLightStatusBars = !isDarkTheme + insetsController.isAppearanceLightStatusBars = false window.statusBarColor = android.graphics.Color.TRANSPARENT } } @@ -333,6 +333,7 @@ private fun ProfileBlurPreview( ) { val option = BackgroundBlurPresets.findById(selectedId) val overlayColors = BackgroundBlurPresets.getOverlayColors(selectedId) + val shouldUseAvatarBlur = overlayColors.isNullOrEmpty() && selectedId != "none" val avatarColors = getAvatarColor(publicKey, isDarkTheme) // Загрузка аватарки @@ -344,7 +345,7 @@ private fun ProfileBlurPreview( var blurredBitmap by remember { mutableStateOf(null) } val blurContext = LocalContext.current - LaunchedEffect(avatarKey) { + LaunchedEffect(avatarKey, shouldUseAvatarBlur) { val current = avatars if (current.isNotEmpty()) { val decoded = withContext(Dispatchers.IO) { @@ -352,9 +353,14 @@ private fun ProfileBlurPreview( } if (decoded != null) { avatarBitmap = decoded - blurredBitmap = withContext(Dispatchers.Default) { - appearanceGaussianBlur(blurContext, decoded, radius = 20f, passes = 2) - } + blurredBitmap = + if (shouldUseAvatarBlur) { + withContext(Dispatchers.Default) { + appearanceGaussianBlur(blurContext, decoded, radius = 20f, passes = 2) + } + } else { + null + } } } else { avatarBitmap = null @@ -375,9 +381,8 @@ private fun ProfileBlurPreview( // ═══════════════════════════════════════════════════ // LAYER 1: Blurred avatar background (идентично BlurredAvatarBackground) // ═══════════════════════════════════════════════════ - if (blurredBitmap != null) { - // overlay-режим: 0.85f, стандартный: 0.9f (как в BlurredAvatarBackground) - val blurImgAlpha = if (overlayColors != null && overlayColors.isNotEmpty()) 0.85f else 0.9f + if (shouldUseAvatarBlur && blurredBitmap != null) { + val blurImgAlpha = 0.9f Image( bitmap = blurredBitmap!!.asImageBitmap(), contentDescription = null, @@ -402,20 +407,18 @@ private fun ProfileBlurPreview( val overlayMod = if (overlayColors.size == 1) { Modifier .fillMaxSize() - .background(overlayColors[0].copy(alpha = if (blurredBitmap != null) 0.4f else 0.85f)) + .background(overlayColors[0]) } else { Modifier .fillMaxSize() .background( Brush.linearGradient( - colors = overlayColors.map { - it.copy(alpha = if (blurredBitmap != null) 0.4f else 0.85f) - } + colors = overlayColors ) ) } Box(modifier = overlayMod) - } else if (blurredBitmap != null) { + } else if (shouldUseAvatarBlur && blurredBitmap != null) { // Стандартный тинт (идентичен BlurredAvatarBackground) Box( modifier = Modifier @@ -782,4 +785,3 @@ private fun appearanceGaussianBlur(context: Context, source: Bitmap, radius: Flo rs.destroy() return Bitmap.createBitmap(current, pad, pad, w, h) } - diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/BiometricEnableScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/BiometricEnableScreen.kt index ca55f62..da1ceaa 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/BiometricEnableScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/BiometricEnableScreen.kt @@ -87,7 +87,7 @@ fun BiometricEnableScreen( SideEffect { val window = (view.context as android.app.Activity).window val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) - insetsController.isAppearanceLightStatusBars = !isDarkTheme + insetsController.isAppearanceLightStatusBars = false window.statusBarColor = android.graphics.Color.TRANSPARENT } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/NotificationsScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/NotificationsScreen.kt new file mode 100644 index 0000000..fbf5c47 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/NotificationsScreen.kt @@ -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) + ) + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileLogsScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileLogsScreen.kt index fe8c2b1..6c4a1bf 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileLogsScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileLogsScreen.kt @@ -32,7 +32,7 @@ fun ProfileLogsScreen( SideEffect { val window = (view.context as android.app.Activity).window val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) - insetsController.isAppearanceLightStatusBars = !isDarkTheme + insetsController.isAppearanceLightStatusBars = false window.statusBarColor = android.graphics.Color.TRANSPARENT } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index fa6910c..88de147 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -275,8 +275,10 @@ fun ProfileScreen( accountPublicKey: String, accountPrivateKeyHash: String, onBack: () -> Unit, + onHasChangesChanged: (Boolean) -> Unit = {}, onSaveProfile: (name: String, username: String) -> Unit, onLogout: () -> Unit, + onNavigateToNotifications: () -> Unit = {}, onNavigateToTheme: () -> Unit = {}, onNavigateToAppearance: () -> Unit = {}, onNavigateToSafety: () -> Unit = {}, @@ -402,7 +404,7 @@ fun ProfileScreen( // State for editing - Update when account data changes var editedName by remember(accountName) { mutableStateOf(accountName) } 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 usernameTouched 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 viewModel.updateLocalProfile(accountPublicKey, editedName, editedUsername) - hasChanges = false serverNameError = null serverUsernameError = null serverGeneralError = null @@ -743,9 +744,14 @@ fun ProfileScreen( } } - // Update hasChanges when fields change - LaunchedEffect(editedName, editedUsername) { - hasChanges = editedName != accountName || editedUsername != accountUsername + SideEffect { + onHasChangesChanged(hasChanges) + } + + DisposableEffect(Unit) { + onDispose { + onHasChangesChanged(false) + } } // Handle back button press - navigate to chat list instead of closing app @@ -838,49 +844,19 @@ fun ProfileScreen( 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 // ═════════════════════════════════════════════════════════════ TelegramSectionTitle(title = "Settings", isDarkTheme = isDarkTheme) + TelegramSettingsItem( + icon = TelegramIcons.Notifications, + title = "Notifications", + onClick = onNavigateToNotifications, + isDarkTheme = isDarkTheme, + showDivider = true + ) + TelegramSettingsItem( icon = TelegramIcons.Theme, title = "Theme", diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt index 1e46910..1a79615 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt @@ -78,7 +78,7 @@ fun ThemeScreen( SideEffect { val window = (view.context as android.app.Activity).window val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) - insetsController.isAppearanceLightStatusBars = !isDarkTheme + insetsController.isAppearanceLightStatusBars = false window.statusBarColor = android.graphics.Color.TRANSPARENT } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/UpdatesScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/UpdatesScreen.kt index a382377..9a00de8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/UpdatesScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/UpdatesScreen.kt @@ -35,7 +35,7 @@ fun UpdatesScreen( SideEffect { val window = (view.context as android.app.Activity).window val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) - insetsController.isAppearanceLightStatusBars = !isDarkTheme + insetsController.isAppearanceLightStatusBars = false window.statusBarColor = android.graphics.Color.TRANSPARENT } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/utils/SystemBarsStyleUtils.kt b/app/src/main/java/com/rosetta/messenger/ui/utils/SystemBarsStyleUtils.kt index 1b05fe4..61b419e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/utils/SystemBarsStyleUtils.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/utils/SystemBarsStyleUtils.kt @@ -40,7 +40,7 @@ object SystemBarsStyleUtils { if (window == null || view == null) return val insetsController = WindowCompat.getInsetsController(window, view) window.statusBarColor = Color.TRANSPARENT - insetsController.isAppearanceLightStatusBars = !isDarkTheme + insetsController.isAppearanceLightStatusBars = false } fun restoreNavigationBar(window: Window?, view: View?, context: Context, state: SystemBarsState?) {