fix: Большое количество изменений

This commit is contained in:
2026-04-14 04:19:34 +05:00
parent cb920b490d
commit ce7f913de7
20 changed files with 1241 additions and 533 deletions

View File

@@ -45,6 +45,10 @@ object CryptoManager {
// ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной // ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной
// расшифровке // расшифровке
private const val DECRYPTION_CACHE_SIZE = 2000 private const val DECRYPTION_CACHE_SIZE = 2000
// Не кэшируем большие payload (вложения), чтобы избежать OOM на конкатенации cache key
// и хранения гигантских plaintext в памяти.
private const val MAX_CACHEABLE_ENCRYPTED_CHARS = 64 * 1024
private const val MAX_CACHEABLE_DECRYPTED_CHARS = 64 * 1024
private val decryptionCache = ConcurrentHashMap<String, String>(DECRYPTION_CACHE_SIZE, 0.75f, 4) private val decryptionCache = ConcurrentHashMap<String, String>(DECRYPTION_CACHE_SIZE, 0.75f, 4)
init { init {
@@ -298,17 +302,21 @@ object CryptoManager {
* 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений * 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений
*/ */
fun decryptWithPassword(encryptedData: String, password: String): String? { fun decryptWithPassword(encryptedData: String, password: String): String? {
val useCache = encryptedData.length <= MAX_CACHEABLE_ENCRYPTED_CHARS
val cacheKey = if (useCache) "$password:$encryptedData" else null
// 🚀 ОПТИМИЗАЦИЯ: Lock-free проверка кэша (ConcurrentHashMap) // 🚀 ОПТИМИЗАЦИЯ: Lock-free проверка кэша (ConcurrentHashMap)
val cacheKey = "$password:$encryptedData" if (cacheKey != null) {
decryptionCache[cacheKey]?.let { decryptionCache[cacheKey]?.let {
return it return it
} }
}
return try { return try {
val result = decryptWithPasswordInternal(encryptedData, password) val result = decryptWithPasswordInternal(encryptedData, password)
// 🚀 Сохраняем в кэш (lock-free) // 🚀 Сохраняем в кэш (lock-free)
if (result != null) { if (cacheKey != null && result != null && result.length <= MAX_CACHEABLE_DECRYPTED_CHARS) {
// Ограничиваем размер кэша // Ограничиваем размер кэша
if (decryptionCache.size >= DECRYPTION_CACHE_SIZE) { if (decryptionCache.size >= DECRYPTION_CACHE_SIZE) {
// Удаляем ~10% самых старых записей // Удаляем ~10% самых старых записей

View File

@@ -856,7 +856,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
isOutgoing = fm.isOutgoing, isOutgoing = fm.isOutgoing,
publicKey = fm.senderPublicKey, publicKey = fm.senderPublicKey,
senderName = fm.senderName, senderName = fm.senderName,
attachments = fm.attachments attachments = fm.attachments,
chachaKeyPlainHex = fm.chachaKeyPlain
) )
} }
_isForwardMode.value = true _isForwardMode.value = true
@@ -2160,7 +2161,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
forwardedList.add(ReplyData( forwardedList.add(ReplyData(
messageId = fwdMessageId, messageId = fwdMessageId,
senderName = senderDisplayName, senderName = senderDisplayName,
text = fwdText, text = resolveReplyPreviewText(fwdText, fwdAttachments),
isFromMe = fwdIsFromMe, isFromMe = fwdIsFromMe,
isForwarded = true, isForwarded = true,
forwardedFromName = senderDisplayName, forwardedFromName = senderDisplayName,
@@ -2346,7 +2347,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
ReplyData( ReplyData(
messageId = realMessageId, messageId = realMessageId,
senderName = resolvedSenderName, senderName = resolvedSenderName,
text = replyText, text = resolveReplyPreviewText(replyText, originalAttachments),
isFromMe = isReplyFromMe, isFromMe = isReplyFromMe,
isForwarded = isForwarded, isForwarded = isForwarded,
forwardedFromName = forwardFromDisplay, forwardedFromName = forwardFromDisplay,
@@ -2501,6 +2502,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
private fun resolveReplyPreviewText(
text: String,
attachments: List<MessageAttachment>
): String {
if (text.isNotBlank()) return text
return when {
attachments.any { it.type == AttachmentType.VOICE } -> "Voice Message"
attachments.any { it.type == AttachmentType.VIDEO_CIRCLE } -> "Video Message"
else -> text
}
}
/** /**
* 🔥 Установить сообщения для Reply (как в React Native) Сохраняем publicKey отправителя для * 🔥 Установить сообщения для Reply (как в React Native) Сохраняем publicKey отправителя для
* правильного отображения цитаты * правильного отображения цитаты
@@ -2515,16 +2528,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
msg.senderPublicKey.trim().ifEmpty { msg.senderPublicKey.trim().ifEmpty {
if (msg.isOutgoing) sender else opponent if (msg.isOutgoing) sender else opponent
} }
val resolvedAttachments =
msg.attachments
.filter { it.type != AttachmentType.MESSAGES }
ReplyMessage( ReplyMessage(
messageId = msg.id, messageId = msg.id,
text = msg.text, text = resolveReplyPreviewText(msg.text, resolvedAttachments),
timestamp = msg.timestamp.time, timestamp = msg.timestamp.time,
isOutgoing = msg.isOutgoing, isOutgoing = msg.isOutgoing,
publicKey = resolvedPublicKey, publicKey = resolvedPublicKey,
senderName = msg.senderName, senderName = msg.senderName,
attachments = attachments = resolvedAttachments,
msg.attachments
.filter { it.type != AttachmentType.MESSAGES },
chachaKeyPlainHex = msg.chachaKeyPlainHex chachaKeyPlainHex = msg.chachaKeyPlainHex
) )
} }
@@ -2542,16 +2556,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
msg.senderPublicKey.trim().ifEmpty { msg.senderPublicKey.trim().ifEmpty {
if (msg.isOutgoing) sender else opponent if (msg.isOutgoing) sender else opponent
} }
val resolvedAttachments =
msg.attachments
.filter { it.type != AttachmentType.MESSAGES }
ReplyMessage( ReplyMessage(
messageId = msg.id, messageId = msg.id,
text = msg.text, text = resolveReplyPreviewText(msg.text, resolvedAttachments),
timestamp = msg.timestamp.time, timestamp = msg.timestamp.time,
isOutgoing = msg.isOutgoing, isOutgoing = msg.isOutgoing,
publicKey = resolvedPublicKey, publicKey = resolvedPublicKey,
senderName = msg.senderName, senderName = msg.senderName,
attachments = attachments = resolvedAttachments,
msg.attachments chachaKeyPlainHex = msg.chachaKeyPlainHex
.filter { it.type != AttachmentType.MESSAGES }
) )
} }
_isForwardMode.value = true _isForwardMode.value = true
@@ -2942,7 +2958,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
ReplyData( ReplyData(
messageId = firstReply.messageId, messageId = firstReply.messageId,
senderName = firstReplySenderName, senderName = firstReplySenderName,
text = firstReply.text, text = resolveReplyPreviewText(firstReply.text, replyAttachments),
isFromMe = firstReply.isOutgoing, isFromMe = firstReply.isOutgoing,
isForwarded = isForward, isForwarded = isForward,
forwardedFromName = forwardedFromName =
@@ -2972,7 +2988,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
ReplyData( ReplyData(
messageId = msg.messageId, messageId = msg.messageId,
senderName = senderDisplayName, senderName = senderDisplayName,
text = msg.text, text = resolveReplyPreviewText(msg.text, resolvedAttachments),
isFromMe = msg.isOutgoing, isFromMe = msg.isOutgoing,
isForwarded = true, isForwarded = true,
forwardedFromName = senderDisplayName, forwardedFromName = senderDisplayName,
@@ -3143,6 +3159,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (isForwardToSend) { if (isForwardToSend) {
put("forwarded", true) put("forwarded", true)
put("senderName", msg.senderName) put("senderName", msg.senderName)
if (msg.chachaKeyPlainHex.isNotEmpty()) {
put("chacha_key_plain", msg.chachaKeyPlainHex)
}
} }
} }
replyJsonArray.put(replyJson) replyJsonArray.put(replyJson)

View File

@@ -74,6 +74,7 @@ import com.rosetta.messenger.ui.chats.calls.CallsHistoryScreen
import com.rosetta.messenger.ui.chats.calls.CallTopBanner import com.rosetta.messenger.ui.chats.calls.CallTopBanner
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner
import com.rosetta.messenger.ui.chats.components.VoicePlaybackCoordinator
import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.components.VerifiedBadge
@@ -222,6 +223,18 @@ private fun isTypingForDialog(dialogKey: String, typingDialogs: Set<String>): Bo
return typingDialogs.any { it.equals(dialogKey.trim(), ignoreCase = true) } return typingDialogs.any { it.equals(dialogKey.trim(), ignoreCase = true) }
} }
private fun isVoicePlayingForDialog(dialogKey: String, playingDialogKey: String?): Boolean {
val active = playingDialogKey?.trim().orEmpty()
if (active.isEmpty()) return false
if (isGroupDialogKey(dialogKey) || isGroupDialogKey(active)) {
return normalizeGroupDialogKey(dialogKey).equals(
normalizeGroupDialogKey(active),
ignoreCase = true
)
}
return dialogKey.trim().equals(active, ignoreCase = true)
}
private fun shortPublicKey(value: String): String { private fun shortPublicKey(value: String): String {
val trimmed = value.trim() val trimmed = value.trim()
if (trimmed.length <= 12) return trimmed if (trimmed.length <= 12) return trimmed
@@ -467,6 +480,12 @@ fun ChatsListScreen(
// <20>🔥 Пользователи, которые сейчас печатают // <20>🔥 Пользователи, которые сейчас печатают
val typingUsers by ProtocolManager.typingUsers.collectAsState() val typingUsers by ProtocolManager.typingUsers.collectAsState()
val typingUsersByDialogSnapshot by ProtocolManager.typingUsersByDialogSnapshot.collectAsState() val typingUsersByDialogSnapshot by ProtocolManager.typingUsersByDialogSnapshot.collectAsState()
val playingVoiceAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
val playingVoiceDialogKey by VoicePlaybackCoordinator.playingDialogKey.collectAsState()
val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState()
val voicePlaybackSpeed by VoicePlaybackCoordinator.playbackSpeed.collectAsState()
val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState()
val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.collectAsState()
// Load dialogs when account is available // Load dialogs when account is available
LaunchedEffect(accountPublicKey, accountPrivateKey) { LaunchedEffect(accountPublicKey, accountPrivateKey) {
@@ -2130,6 +2149,50 @@ fun ChatsListScreen(
callUiState.phase != CallPhase.INCOMING callUiState.phase != CallPhase.INCOMING
} }
val callBannerHeight = 40.dp val callBannerHeight = 40.dp
val showVoiceMiniPlayer =
remember(
showRequestsScreen,
showDownloadsScreen,
showCallsScreen,
playingVoiceAttachmentId
) {
!showRequestsScreen &&
!showDownloadsScreen &&
!showCallsScreen &&
!playingVoiceAttachmentId.isNullOrBlank()
}
val voiceBannerHeight = 36.dp
val stickyTopInset =
remember(
showStickyCallBanner,
showVoiceMiniPlayer
) {
var topInset = 0.dp
if (showStickyCallBanner) {
topInset += callBannerHeight
}
if (showVoiceMiniPlayer) {
topInset += voiceBannerHeight
}
topInset
}
val voiceMiniPlayerTitle =
remember(
playingVoiceSenderLabel,
playingVoiceTimeLabel
) {
val sender =
playingVoiceSenderLabel
.trim()
.ifBlank {
"Voice"
}
val time =
playingVoiceTimeLabel
.trim()
if (time.isBlank()) sender
else "$sender at $time"
}
// 🔥 Берем dialogs из chatsState для // 🔥 Берем dialogs из chatsState для
// консистентности // консистентности
// 📌 Порядок по времени готовится в ViewModel. // 📌 Порядок по времени готовится в ViewModel.
@@ -2332,9 +2395,7 @@ fun ChatsListScreen(
Modifier.fillMaxSize() Modifier.fillMaxSize()
.padding( .padding(
top = top =
if (showStickyCallBanner) stickyTopInset
callBannerHeight
else 0.dp
) )
.then( .then(
if (requestsCount > 0) Modifier.nestedScroll(requestsNestedScroll) if (requestsCount > 0) Modifier.nestedScroll(requestsNestedScroll)
@@ -2572,6 +2633,18 @@ fun ChatsListScreen(
} }
} }
} }
val isVoicePlaybackActive by
remember(
dialog.opponentKey,
playingVoiceDialogKey
) {
derivedStateOf {
isVoicePlayingForDialog(
dialog.opponentKey,
playingVoiceDialogKey
)
}
}
val isSelectedDialog = val isSelectedDialog =
selectedChatKeys selectedChatKeys
.contains( .contains(
@@ -2613,6 +2686,8 @@ fun ChatsListScreen(
typingDisplayName, typingDisplayName,
typingSenderPublicKey = typingSenderPublicKey =
typingSenderPublicKey, typingSenderPublicKey,
isVoicePlaybackActive =
isVoicePlaybackActive,
isBlocked = isBlocked =
isBlocked, isBlocked,
isSavedMessages = isSavedMessages =
@@ -2746,6 +2821,14 @@ fun ChatsListScreen(
} }
} }
} }
if (showStickyCallBanner || showVoiceMiniPlayer) {
Column(
modifier =
Modifier.fillMaxWidth()
.align(
Alignment.TopCenter
)
) {
if (showStickyCallBanner) { if (showStickyCallBanner) {
CallTopBanner( CallTopBanner(
state = callUiState, state = callUiState,
@@ -2755,6 +2838,25 @@ fun ChatsListScreen(
onOpenCall = onOpenCallOverlay onOpenCall = onOpenCallOverlay
) )
} }
if (showVoiceMiniPlayer) {
VoiceTopMiniPlayer(
title = voiceMiniPlayerTitle,
isDarkTheme = isDarkTheme,
isPlaying = isVoicePlaybackRunning,
speed = voicePlaybackSpeed,
onTogglePlay = {
VoicePlaybackCoordinator.toggleCurrentPlayback()
},
onCycleSpeed = {
VoicePlaybackCoordinator.cycleSpeed()
},
onClose = {
VoicePlaybackCoordinator.stop()
}
)
}
}
}
} }
} }
} // Close Requests AnimatedContent } // Close Requests AnimatedContent
@@ -3722,6 +3824,7 @@ fun SwipeableDialogItem(
isTyping: Boolean = false, isTyping: Boolean = false,
typingDisplayName: String = "", typingDisplayName: String = "",
typingSenderPublicKey: String = "", typingSenderPublicKey: String = "",
isVoicePlaybackActive: Boolean = false,
isBlocked: Boolean = false, isBlocked: Boolean = false,
isGroupChat: Boolean = false, isGroupChat: Boolean = false,
isSavedMessages: Boolean = false, isSavedMessages: Boolean = false,
@@ -4125,6 +4228,7 @@ fun SwipeableDialogItem(
isTyping = isTyping, isTyping = isTyping,
typingDisplayName = typingDisplayName, typingDisplayName = typingDisplayName,
typingSenderPublicKey = typingSenderPublicKey, typingSenderPublicKey = typingSenderPublicKey,
isVoicePlaybackActive = isVoicePlaybackActive,
isPinned = isPinned, isPinned = isPinned,
isBlocked = isBlocked, isBlocked = isBlocked,
isMuted = isMuted, isMuted = isMuted,
@@ -4144,6 +4248,7 @@ fun DialogItemContent(
isTyping: Boolean = false, isTyping: Boolean = false,
typingDisplayName: String = "", typingDisplayName: String = "",
typingSenderPublicKey: String = "", typingSenderPublicKey: String = "",
isVoicePlaybackActive: Boolean = false,
isPinned: Boolean = false, isPinned: Boolean = false,
isBlocked: Boolean = false, isBlocked: Boolean = false,
isMuted: Boolean = false, isMuted: Boolean = false,
@@ -4278,12 +4383,12 @@ fun DialogItemContent(
// Name and last message // Name and last message
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth().heightIn(min = 22.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Row( Row(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f).heightIn(min = 22.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
AppleEmojiText( AppleEmojiText(
@@ -4293,7 +4398,8 @@ fun DialogItemContent(
color = textColor, color = textColor,
maxLines = 1, maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END, overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = false enableLinks = false,
minHeightMultiplier = 1f
) )
if (isGroupDialog) { if (isGroupDialog) {
Spacer(modifier = Modifier.width(5.dp)) Spacer(modifier = Modifier.width(5.dp))
@@ -4301,7 +4407,7 @@ fun DialogItemContent(
imageVector = TablerIcons.Users, imageVector = TablerIcons.Users,
contentDescription = null, contentDescription = null,
tint = secondaryTextColor.copy(alpha = 0.9f), tint = secondaryTextColor.copy(alpha = 0.9f),
modifier = Modifier.size(15.dp) modifier = Modifier.size(14.dp)
) )
} }
val isOfficialByKey = MessageRepository.isSystemAccount(dialog.opponentKey) val isOfficialByKey = MessageRepository.isSystemAccount(dialog.opponentKey)
@@ -4310,7 +4416,7 @@ fun DialogItemContent(
VerifiedBadge( VerifiedBadge(
verified = if (dialog.verified > 0) dialog.verified else 1, verified = if (dialog.verified > 0) dialog.verified else 1,
size = 16, size = 16,
modifier = Modifier.offset(y = (-2).dp), modifier = Modifier,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
badgeTint = PrimaryBlue badgeTint = PrimaryBlue
) )
@@ -4337,6 +4443,7 @@ fun DialogItemContent(
} }
Row( Row(
modifier = Modifier.heightIn(min = 16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End horizontalArrangement = Arrangement.End
) { ) {
@@ -4467,7 +4574,7 @@ fun DialogItemContent(
0.6f 0.6f
), ),
modifier = modifier =
Modifier.size(14.dp) Modifier.size(16.dp)
) )
Spacer( Spacer(
modifier = modifier =
@@ -4487,9 +4594,11 @@ fun DialogItemContent(
Text( Text(
text = formattedTime, text = formattedTime,
fontSize = 13.sp, fontSize = 13.sp,
lineHeight = 13.sp,
color = color =
if (visibleUnreadCount > 0) PrimaryBlue if (visibleUnreadCount > 0) PrimaryBlue
else secondaryTextColor else secondaryTextColor,
modifier = Modifier.align(Alignment.CenterVertically)
) )
} }
} }
@@ -4506,18 +4615,35 @@ fun DialogItemContent(
modifier = Modifier.weight(1f).heightIn(min = 20.dp), modifier = Modifier.weight(1f).heightIn(min = 20.dp),
contentAlignment = Alignment.CenterStart contentAlignment = Alignment.CenterStart
) { ) {
val subtitleMode =
remember(
isVoicePlaybackActive,
isTyping,
dialog.draftText
) {
when {
isVoicePlaybackActive -> "voice"
isTyping -> "typing"
!dialog.draftText.isNullOrEmpty() -> "draft"
else -> "message"
}
}
Crossfade( Crossfade(
targetState = isTyping, targetState = subtitleMode,
animationSpec = tween(150), animationSpec = tween(150),
label = "chatSubtitle" label = "chatSubtitle"
) { showTyping -> ) { mode ->
if (showTyping) { if (mode == "voice") {
VoicePlaybackIndicatorSmall(
isDarkTheme = isDarkTheme
)
} else if (mode == "typing") {
TypingIndicatorSmall( TypingIndicatorSmall(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
typingDisplayName = typingDisplayName, typingDisplayName = typingDisplayName,
typingSenderPublicKey = typingSenderPublicKey typingSenderPublicKey = typingSenderPublicKey
) )
} else if (!dialog.draftText.isNullOrEmpty()) { } else if (mode == "draft") {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = "Draft: ", text = "Draft: ",
@@ -4527,7 +4653,7 @@ fun DialogItemContent(
maxLines = 1 maxLines = 1
) )
AppleEmojiText( AppleEmojiText(
text = dialog.draftText, text = dialog.draftText.orEmpty(),
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
fontSize = 14.sp, fontSize = 14.sp,
color = secondaryTextColor, color = secondaryTextColor,
@@ -4868,6 +4994,161 @@ fun TypingIndicatorSmall(
} }
} }
@Composable
private fun VoicePlaybackIndicatorSmall(
isDarkTheme: Boolean
) {
val accentColor = if (isDarkTheme) PrimaryBlue else Color(0xFF2481CC)
val transition = rememberInfiniteTransition(label = "voicePlaybackIndicator")
val levels = List(3) { index ->
transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 900
0f at 0
1f at 280
0.2f at 580
0f at 900
},
repeatMode = RepeatMode.Restart,
initialStartOffset = StartOffset(index * 130)
),
label = "voiceBar$index"
).value
}
Row(
modifier = Modifier.heightIn(min = 18.dp),
verticalAlignment = Alignment.CenterVertically
) {
Canvas(modifier = Modifier.size(width = 14.dp, height = 12.dp)) {
val barWidth = 2.dp.toPx()
val gap = 2.dp.toPx()
val baseY = size.height
repeat(3) { index ->
val x = index * (barWidth + gap)
val progress = levels[index].coerceIn(0f, 1f)
val minH = 3.dp.toPx()
val maxH = 10.dp.toPx()
val height = minH + (maxH - minH) * progress
drawRoundRect(
color = accentColor.copy(alpha = 0.6f + progress * 0.4f),
topLeft = Offset(x, baseY - height),
size = androidx.compose.ui.geometry.Size(barWidth, height),
cornerRadius =
androidx.compose.ui.geometry.CornerRadius(
x = barWidth,
y = barWidth
)
)
}
}
Spacer(modifier = Modifier.width(5.dp))
AppleEmojiText(
text = "Listening",
fontSize = 14.sp,
color = accentColor,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = false,
minHeightMultiplier = 1f
)
}
}
private fun formatVoiceSpeedLabel(speed: Float): String {
val normalized = (speed * 10f).roundToInt() / 10f
return if (kotlin.math.abs(normalized - normalized.toInt().toFloat()) < 0.01f) {
"${normalized.toInt()}x"
} else {
"${normalized}x"
}
}
@Composable
private fun VoiceTopMiniPlayer(
title: String,
isDarkTheme: Boolean,
isPlaying: Boolean,
speed: Float,
onTogglePlay: () -> Unit,
onCycleSpeed: () -> Unit,
onClose: () -> Unit
) {
val containerColor = if (isDarkTheme) Color(0xFF203446) else Color(0xFFEAF4FF)
val accentColor = if (isDarkTheme) Color(0xFF58AAFF) else Color(0xFF2481CC)
val textColor = if (isDarkTheme) Color(0xFFF3F8FF) else Color(0xFF183047)
val secondaryColor = if (isDarkTheme) Color(0xFF9EB6CC) else Color(0xFF4F6F8A)
Row(
modifier =
Modifier.fillMaxWidth()
.height(36.dp)
.background(containerColor)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = onTogglePlay,
modifier = Modifier.size(28.dp)
) {
Icon(
imageVector =
if (isPlaying) TablerIcons.PlayerPause
else TablerIcons.PlayerPlay,
contentDescription = if (isPlaying) "Pause voice" else "Play voice",
tint = accentColor,
modifier = Modifier.size(18.dp)
)
}
AppleEmojiText(
text = title,
fontSize = 14.sp,
color = textColor,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
modifier = Modifier.weight(1f),
enableLinks = false,
minHeightMultiplier = 1f
)
Box(
modifier =
Modifier.clip(RoundedCornerShape(8.dp))
.border(1.dp, accentColor.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
.clickable { onCycleSpeed() }
.padding(horizontal = 8.dp, vertical = 2.dp),
contentAlignment = Alignment.Center
) {
Text(
text = formatVoiceSpeedLabel(speed),
color = accentColor,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.width(6.dp))
IconButton(
onClick = onClose,
modifier = Modifier.size(28.dp)
) {
Icon(
imageVector = TablerIcons.X,
contentDescription = "Close voice",
tint = secondaryColor,
modifier = Modifier.size(18.dp)
)
}
}
}
@Composable @Composable
private fun SwipeBackContainer( private fun SwipeBackContainer(
onBack: () -> Unit, onBack: () -> Unit,
@@ -5467,7 +5748,7 @@ fun DrawerMenuItemEnhanced(
Text( Text(
text = text, text = text,
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Medium,
color = textColor, color = textColor,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
@@ -5527,7 +5808,7 @@ fun DrawerMenuItemEnhanced(
Text( Text(
text = text, text = text,
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Medium,
color = textColor, color = textColor,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
@@ -5561,7 +5842,7 @@ fun DrawerMenuItemEnhanced(
fun DrawerDivider(isDarkTheme: Boolean) { fun DrawerDivider(isDarkTheme: Boolean) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Divider( Divider(
modifier = Modifier.padding(horizontal = 20.dp), modifier = Modifier.fillMaxWidth(),
color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFC8C8CC), color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFC8C8CC),
thickness = 0.5.dp thickness = 0.5.dp
) )

View File

@@ -573,7 +573,8 @@ private fun ChatsTabContent(
} }
} }
// ─── Recent header (always show with Clear All) ─── // ─── Recent header (only when there are recents) ───
if (recentUsers.isNotEmpty()) {
item { item {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -589,7 +590,6 @@ private fun ChatsTabContent(
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = PrimaryBlue color = PrimaryBlue
) )
if (recentUsers.isNotEmpty()) {
Text( Text(
"Clear All", "Clear All",
fontSize = 13.sp, fontSize = 13.sp,

View File

@@ -7,6 +7,7 @@ import android.graphics.Matrix
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.MediaPlayer import android.media.MediaPlayer
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.SystemClock import android.os.SystemClock
import android.util.Base64 import android.util.Base64
import android.util.LruCache import android.util.LruCache
@@ -91,6 +92,8 @@ import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.Locale
import kotlin.math.min import kotlin.math.min
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
@@ -153,25 +156,48 @@ private fun decodeVoicePayload(data: String): ByteArray? {
return decodeHexPayload(data) ?: decodeBase64Payload(data) return decodeHexPayload(data) ?: decodeBase64Payload(data)
} }
private object VoicePlaybackCoordinator { object VoicePlaybackCoordinator {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val speedSteps = listOf(1f, 1.5f, 2f)
private var player: MediaPlayer? = null private var player: MediaPlayer? = null
private var currentAttachmentId: String? = null private var currentAttachmentId: String? = null
private var progressJob: Job? = null private var progressJob: Job? = null
private val _playingAttachmentId = MutableStateFlow<String?>(null) private val _playingAttachmentId = MutableStateFlow<String?>(null)
val playingAttachmentId: StateFlow<String?> = _playingAttachmentId.asStateFlow() val playingAttachmentId: StateFlow<String?> = _playingAttachmentId.asStateFlow()
private val _playingDialogKey = MutableStateFlow<String?>(null)
val playingDialogKey: StateFlow<String?> = _playingDialogKey.asStateFlow()
private val _positionMs = MutableStateFlow(0) private val _positionMs = MutableStateFlow(0)
val positionMs: StateFlow<Int> = _positionMs.asStateFlow() val positionMs: StateFlow<Int> = _positionMs.asStateFlow()
private val _durationMs = MutableStateFlow(0) private val _durationMs = MutableStateFlow(0)
val durationMs: StateFlow<Int> = _durationMs.asStateFlow() val durationMs: StateFlow<Int> = _durationMs.asStateFlow()
private val _isPlaying = MutableStateFlow(false)
val isPlaying: StateFlow<Boolean> = _isPlaying.asStateFlow()
private val _playbackSpeed = MutableStateFlow(1f)
val playbackSpeed: StateFlow<Float> = _playbackSpeed.asStateFlow()
private val _playingSenderLabel = MutableStateFlow("")
val playingSenderLabel: StateFlow<String> = _playingSenderLabel.asStateFlow()
private val _playingTimeLabel = MutableStateFlow("")
val playingTimeLabel: StateFlow<String> = _playingTimeLabel.asStateFlow()
fun toggle(attachmentId: String, sourceFile: File, onError: (String) -> Unit = {}) { fun toggle(
attachmentId: String,
sourceFile: File,
dialogKey: String = "",
senderLabel: String = "",
playedAtLabel: String = "",
onError: (String) -> Unit = {}
) {
if (!sourceFile.exists()) { if (!sourceFile.exists()) {
onError("Voice file is missing") onError("Voice file is missing")
return return
} }
if (currentAttachmentId == attachmentId && player?.isPlaying == true) {
stop() if (currentAttachmentId == attachmentId && player != null) {
if (_isPlaying.value) {
pause()
} else {
resume(onError = onError)
}
return return
} }
@@ -187,22 +213,18 @@ private object VoicePlaybackCoordinator {
mediaPlayer.setDataSource(sourceFile.absolutePath) mediaPlayer.setDataSource(sourceFile.absolutePath)
mediaPlayer.setOnCompletionListener { stop() } mediaPlayer.setOnCompletionListener { stop() }
mediaPlayer.prepare() mediaPlayer.prepare()
applyPlaybackSpeed(mediaPlayer)
mediaPlayer.start() mediaPlayer.start()
player = mediaPlayer player = mediaPlayer
currentAttachmentId = attachmentId currentAttachmentId = attachmentId
_playingAttachmentId.value = attachmentId _playingAttachmentId.value = attachmentId
_playingDialogKey.value = dialogKey.trim().ifBlank { null }
_playingSenderLabel.value = senderLabel.trim()
_playingTimeLabel.value = playedAtLabel.trim()
_durationMs.value = mediaPlayer.duration.coerceAtLeast(0) _durationMs.value = mediaPlayer.duration.coerceAtLeast(0)
_positionMs.value = mediaPlayer.currentPosition.coerceAtLeast(0) _positionMs.value = mediaPlayer.currentPosition.coerceAtLeast(0)
progressJob?.cancel() _isPlaying.value = true
progressJob = startProgressUpdates(attachmentId)
scope.launch {
while (isActive && currentAttachmentId == attachmentId) {
val active = player
if (active == null || !active.isPlaying) break
_positionMs.value = active.currentPosition.coerceAtLeast(0)
delay(120)
}
}
} catch (e: Exception) { } catch (e: Exception) {
runCatching { mediaPlayer.release() } runCatching { mediaPlayer.release() }
stop() stop()
@@ -210,6 +232,80 @@ private object VoicePlaybackCoordinator {
} }
} }
fun toggleCurrentPlayback(onError: (String) -> Unit = {}) {
if (player == null || currentAttachmentId.isNullOrBlank()) return
if (_isPlaying.value) {
pause()
} else {
resume(onError = onError)
}
}
fun pause() {
val active = player ?: return
runCatching {
if (active.isPlaying) active.pause()
}
_isPlaying.value = false
progressJob?.cancel()
progressJob = null
_positionMs.value = active.currentPosition.coerceAtLeast(0)
}
fun resume(onError: (String) -> Unit = {}) {
val active = player ?: return
val attachmentId = currentAttachmentId
if (attachmentId.isNullOrBlank()) return
try {
applyPlaybackSpeed(active)
active.start()
_durationMs.value = active.duration.coerceAtLeast(0)
_positionMs.value = active.currentPosition.coerceAtLeast(0)
_isPlaying.value = true
startProgressUpdates(attachmentId)
} catch (e: Exception) {
stop()
onError(e.message ?: "Playback failed")
}
}
fun cycleSpeed() {
val current = _playbackSpeed.value
val currentIndex = speedSteps.indexOfFirst { kotlin.math.abs(it - current) < 0.01f }
val next = if (currentIndex < 0) speedSteps.first() else speedSteps[(currentIndex + 1) % speedSteps.size]
setPlaybackSpeed(next)
}
private fun setPlaybackSpeed(speed: Float) {
val normalized =
speedSteps.minByOrNull { kotlin.math.abs(it - speed) } ?: speedSteps.first()
_playbackSpeed.value = normalized
player?.let { applyPlaybackSpeed(it) }
}
private fun applyPlaybackSpeed(mediaPlayer: MediaPlayer) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
runCatching {
val current = mediaPlayer.playbackParams
mediaPlayer.playbackParams = current.setSpeed(_playbackSpeed.value)
}
}
private fun startProgressUpdates(attachmentId: String) {
progressJob?.cancel()
progressJob =
scope.launch {
while (isActive && currentAttachmentId == attachmentId) {
val active = player ?: break
_positionMs.value = active.currentPosition.coerceAtLeast(0)
_durationMs.value = active.duration.coerceAtLeast(0)
if (!active.isPlaying) break
delay(120)
}
_isPlaying.value = player?.isPlaying == true && currentAttachmentId == attachmentId
}
}
fun stop() { fun stop() {
val active = player val active = player
player = null player = null
@@ -217,8 +313,12 @@ private object VoicePlaybackCoordinator {
progressJob?.cancel() progressJob?.cancel()
progressJob = null progressJob = null
_playingAttachmentId.value = null _playingAttachmentId.value = null
_playingDialogKey.value = null
_playingSenderLabel.value = ""
_playingTimeLabel.value = ""
_positionMs.value = 0 _positionMs.value = 0
_durationMs.value = 0 _durationMs.value = 0
_isPlaying.value = false
if (active != null) { if (active != null) {
runCatching { runCatching {
if (active.isPlaying) active.stop() if (active.isPlaying) active.stop()
@@ -593,6 +693,7 @@ fun MessageAttachments(
isOutgoing: Boolean, isOutgoing: Boolean,
isDarkTheme: Boolean, isDarkTheme: Boolean,
senderPublicKey: String, senderPublicKey: String,
senderDisplayName: String = "",
dialogPublicKey: String = "", dialogPublicKey: String = "",
isGroupChat: Boolean = false, isGroupChat: Boolean = false,
timestamp: java.util.Date, timestamp: java.util.Date,
@@ -683,6 +784,8 @@ fun MessageAttachments(
chachaKeyPlainHex = chachaKeyPlainHex, chachaKeyPlainHex = chachaKeyPlainHex,
privateKey = privateKey, privateKey = privateKey,
senderPublicKey = senderPublicKey, senderPublicKey = senderPublicKey,
senderDisplayName = senderDisplayName,
dialogPublicKey = dialogPublicKey,
isOutgoing = isOutgoing, isOutgoing = isOutgoing,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
timestamp = timestamp, timestamp = timestamp,
@@ -2036,6 +2139,8 @@ private fun VoiceAttachment(
chachaKeyPlainHex: String, chachaKeyPlainHex: String,
privateKey: String, privateKey: String,
senderPublicKey: String, senderPublicKey: String,
senderDisplayName: String,
dialogPublicKey: String,
isOutgoing: Boolean, isOutgoing: Boolean,
isDarkTheme: Boolean, isDarkTheme: Boolean,
timestamp: java.util.Date, timestamp: java.util.Date,
@@ -2043,10 +2148,12 @@ private fun VoiceAttachment(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val playingAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState() val activeAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
val playbackPositionMs by VoicePlaybackCoordinator.positionMs.collectAsState() val playbackPositionMs by VoicePlaybackCoordinator.positionMs.collectAsState()
val playbackDurationMs by VoicePlaybackCoordinator.durationMs.collectAsState() val playbackDurationMs by VoicePlaybackCoordinator.durationMs.collectAsState()
val isPlaying = playingAttachmentId == attachment.id val playbackIsPlaying by VoicePlaybackCoordinator.isPlaying.collectAsState()
val isActiveTrack = activeAttachmentId == attachment.id
val isPlaying = isActiveTrack && playbackIsPlaying
val (previewDurationSecRaw, previewWavesRaw) = val (previewDurationSecRaw, previewWavesRaw) =
remember(attachment.preview) { parseVoicePreview(attachment.preview) } remember(attachment.preview) { parseVoicePreview(attachment.preview) }
@@ -2078,21 +2185,37 @@ private fun VoiceAttachment(
val effectiveDurationSec = val effectiveDurationSec =
remember(isPlaying, playbackDurationMs, previewDurationSec) { remember(isPlaying, playbackDurationMs, previewDurationSec) {
val fromPlayer = (playbackDurationMs / 1000).coerceAtLeast(0) val fromPlayer = (playbackDurationMs / 1000).coerceAtLeast(0)
if (isPlaying && fromPlayer > 0) fromPlayer else previewDurationSec if (isActiveTrack && fromPlayer > 0) fromPlayer else previewDurationSec
} }
val progress = val progress =
if (isPlaying && playbackDurationMs > 0) { if (isActiveTrack && playbackDurationMs > 0) {
(playbackPositionMs.toFloat() / playbackDurationMs.toFloat()).coerceIn(0f, 1f) (playbackPositionMs.toFloat() / playbackDurationMs.toFloat()).coerceIn(0f, 1f)
} else { } else {
0f 0f
} }
val timeText = val timeText =
if (isPlaying && playbackDurationMs > 0) { if (isActiveTrack && playbackDurationMs > 0) {
val leftSec = ((playbackDurationMs - playbackPositionMs).coerceAtLeast(0) / 1000) val leftSec = ((playbackDurationMs - playbackPositionMs).coerceAtLeast(0) / 1000)
"-${formatVoiceDuration(leftSec)}" formatVoiceDuration(leftSec)
} else { } else {
formatVoiceDuration(effectiveDurationSec) formatVoiceDuration(effectiveDurationSec)
} }
val playbackSenderLabel =
remember(isOutgoing, senderDisplayName) {
val senderName = senderDisplayName.trim()
when {
isOutgoing -> "You"
senderName.isNotBlank() -> senderName
else -> "Voice"
}
}
val playbackTimeLabel =
remember(timestamp.time) {
runCatching {
SimpleDateFormat("h:mm a", Locale.getDefault()).format(timestamp)
}
.getOrDefault("")
}
LaunchedEffect(payload, attachment.id) { LaunchedEffect(payload, attachment.id) {
if (payload.isBlank()) return@LaunchedEffect if (payload.isBlank()) return@LaunchedEffect
@@ -2112,14 +2235,6 @@ private fun VoiceAttachment(
} }
} }
DisposableEffect(attachment.id) {
onDispose {
if (playingAttachmentId == attachment.id) {
VoicePlaybackCoordinator.stop()
}
}
}
val triggerDownload: () -> Unit = download@{ val triggerDownload: () -> Unit = download@{
if (attachment.transportTag.isBlank()) { if (attachment.transportTag.isBlank()) {
downloadStatus = DownloadStatus.ERROR downloadStatus = DownloadStatus.ERROR
@@ -2174,7 +2289,13 @@ private fun VoiceAttachment(
val prepared = ensureVoiceAudioFile(context, attachment.id, payload) val prepared = ensureVoiceAudioFile(context, attachment.id, payload)
if (prepared != null) { if (prepared != null) {
audioFilePath = prepared.absolutePath audioFilePath = prepared.absolutePath
VoicePlaybackCoordinator.toggle(attachment.id, prepared) { message -> VoicePlaybackCoordinator.toggle(
attachmentId = attachment.id,
sourceFile = prepared,
dialogKey = dialogPublicKey,
senderLabel = playbackSenderLabel,
playedAtLabel = playbackTimeLabel
) { message ->
downloadStatus = DownloadStatus.ERROR downloadStatus = DownloadStatus.ERROR
errorText = message errorText = message
} }
@@ -2186,7 +2307,13 @@ private fun VoiceAttachment(
triggerDownload() triggerDownload()
} }
} else { } else {
VoicePlaybackCoordinator.toggle(attachment.id, file) { message -> VoicePlaybackCoordinator.toggle(
attachmentId = attachment.id,
sourceFile = file,
dialogKey = dialogPublicKey,
senderLabel = playbackSenderLabel,
playedAtLabel = playbackTimeLabel
) { message ->
downloadStatus = DownloadStatus.ERROR downloadStatus = DownloadStatus.ERROR
errorText = message errorText = message
} }

View File

@@ -355,6 +355,16 @@ fun MessageBubble(
onGroupInviteOpen: (SearchUser) -> Unit = {}, onGroupInviteOpen: (SearchUser) -> Unit = {},
contextMenuContent: @Composable () -> Unit = {} contextMenuContent: @Composable () -> Unit = {}
) { ) {
val isTextSelectionOnThisMessage =
remember(
textSelectionHelper?.isInSelectionMode,
textSelectionHelper?.selectedMessageId,
message.id
) {
textSelectionHelper?.isInSelectionMode == true &&
textSelectionHelper.selectedMessageId == message.id
}
// Swipe-to-reply state // Swipe-to-reply state
val hapticFeedback = LocalHapticFeedback.current val hapticFeedback = LocalHapticFeedback.current
var swipeOffset by remember { mutableStateOf(0f) } var swipeOffset by remember { mutableStateOf(0f) }
@@ -374,7 +384,7 @@ fun MessageBubble(
// Selection animations // Selection animations
val selectionAlpha by val selectionAlpha by
animateFloatAsState( animateFloatAsState(
targetValue = if (isSelected) 0.85f else 1f, targetValue = if (isSelected && !isTextSelectionOnThisMessage) 0.85f else 1f,
animationSpec = tween(150), animationSpec = tween(150),
label = "selectionAlpha" label = "selectionAlpha"
) )
@@ -558,7 +568,8 @@ fun MessageBubble(
val selectionBackgroundColor by val selectionBackgroundColor by
animateColorAsState( animateColorAsState(
targetValue = targetValue =
if (isSelected) PrimaryBlue.copy(alpha = 0.15f) if (isSelected && !isTextSelectionOnThisMessage)
PrimaryBlue.copy(alpha = 0.15f)
else Color.Transparent, else Color.Transparent,
animationSpec = tween(200), animationSpec = tween(200),
label = "selectionBg" label = "selectionBg"
@@ -1004,6 +1015,7 @@ fun MessageBubble(
isOutgoing = message.isOutgoing, isOutgoing = message.isOutgoing,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
senderPublicKey = senderPublicKey, senderPublicKey = senderPublicKey,
senderDisplayName = senderName,
dialogPublicKey = dialogPublicKey, dialogPublicKey = dialogPublicKey,
isGroupChat = isGroupChat, isGroupChat = isGroupChat,
timestamp = message.timestamp, timestamp = message.timestamp,
@@ -2381,6 +2393,8 @@ fun ReplyBubble(
) )
} else if (!hasImage) { } else if (!hasImage) {
val displayText = when { val displayText = when {
replyData.attachments.any { it.type == AttachmentType.VOICE } -> "Voice Message"
replyData.attachments.any { it.type == AttachmentType.VIDEO_CIRCLE } -> "Video Message"
replyData.attachments.any { it.type == AttachmentType.FILE } -> "File" replyData.attachments.any { it.type == AttachmentType.FILE } -> "File"
replyData.attachments.any { it.type == AttachmentType.CALL } -> "Call" replyData.attachments.any { it.type == AttachmentType.CALL } -> "Call"
else -> "..." else -> "..."

View File

@@ -23,6 +23,9 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.findViewTreeLifecycleOwner
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -160,7 +163,7 @@ fun SwipeBackContainer(
// Alpha animation for fade-in entry // Alpha animation for fade-in entry
val alphaAnimatable = remember { Animatable(0f) } val alphaAnimatable = remember { Animatable(0f) }
// Drag state - direct update without animation // Drag state
var dragOffset by remember { mutableFloatStateOf(0f) } var dragOffset by remember { mutableFloatStateOf(0f) }
var isDragging by remember { mutableStateOf(false) } var isDragging by remember { mutableStateOf(false) }
@@ -177,6 +180,7 @@ fun SwipeBackContainer(
val context = LocalContext.current val context = LocalContext.current
val view = LocalView.current val view = LocalView.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val lifecycleOwner = view.findViewTreeLifecycleOwner()
val dismissKeyboard: () -> Unit = { val dismissKeyboard: () -> Unit = {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0) imm.hideSoftInputFromWindow(view.windowToken, 0)
@@ -187,21 +191,16 @@ fun SwipeBackContainer(
focusManager.clearFocus(force = true) focusManager.clearFocus(force = true)
} }
// Current offset: use drag offset during drag, animatable otherwise + optional enter slide fun computeCurrentOffset(): Float {
val baseOffset = if (isDragging) dragOffset else offsetAnimatable.value val base = if (isDragging) dragOffset else offsetAnimatable.value
val enterOffset = val enter = if (isAnimatingIn && enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
if (isAnimatingIn && enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
enterOffsetAnimatable.value enterOffsetAnimatable.value
} else { } else 0f
0f return base + enter
} }
val currentOffset = baseOffset + enterOffset
// Current alpha: use animatable during fade animations, otherwise 1 // Current alpha: use animatable during fade animations, otherwise 1
val currentAlpha = if (isAnimatingIn || isAnimatingOut) alphaAnimatable.value else 1f val currentAlpha = if (isAnimatingIn || isAnimatingOut) alphaAnimatable.value else 1f
// Scrim alpha based on swipe progress
val scrimAlpha = (1f - (currentOffset / screenWidthPx).coerceIn(0f, 1f)) * 0.4f
val sharedOwnerId = SwipeBackSharedProgress.ownerId val sharedOwnerId = SwipeBackSharedProgress.ownerId
val sharedOwnerLayer = SwipeBackSharedProgress.ownerLayer val sharedOwnerLayer = SwipeBackSharedProgress.ownerLayer
val sharedProgress = SwipeBackSharedProgress.progress val sharedProgress = SwipeBackSharedProgress.progress
@@ -239,6 +238,21 @@ fun SwipeBackContainer(
} }
} }
fun forceResetSwipeState() {
isDragging = false
dragOffset = 0f
clearSharedSwipeProgressIfOwner()
scope.launch {
offsetAnimatable.snapTo(0f)
if (enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
enterOffsetAnimatable.snapTo(0f)
}
if (shouldShow && !isAnimatingOut) {
alphaAnimatable.snapTo(1f)
}
}
}
// Handle visibility changes // Handle visibility changes
// 🔥 FIX: try/finally ensures animation flags are ALWAYS reset even if // 🔥 FIX: try/finally ensures animation flags are ALWAYS reset even if
// LaunchedEffect is cancelled by rapid isVisible changes (fast swipes). // LaunchedEffect is cancelled by rapid isVisible changes (fast swipes).
@@ -292,10 +306,34 @@ fun SwipeBackContainer(
} }
} }
DisposableEffect(Unit) { onDispose { clearSharedSwipeProgressIfOwner() } } DisposableEffect(lifecycleOwner) {
if (lifecycleOwner == null) {
onDispose { }
} else {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) {
forceResetSwipeState()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
DisposableEffect(Unit) {
onDispose {
forceResetSwipeState()
}
}
if (!shouldShow && !isAnimatingIn && !isAnimatingOut) return if (!shouldShow && !isAnimatingIn && !isAnimatingOut) return
val currentOffset = computeCurrentOffset()
val swipeProgress = (currentOffset / screenWidthPx).coerceIn(0f, 1f)
val scrimAlpha = if (isDragging || currentOffset > 0f) 0.14f * (1f - swipeProgress) else 0f
Box( Box(
modifier = modifier =
Modifier.fillMaxSize().graphicsLayer { Modifier.fillMaxSize().graphicsLayer {
@@ -346,12 +384,14 @@ fun SwipeBackContainer(
var totalDragY = 0f var totalDragY = 0f
var passedSlop = false var passedSlop = false
var keyboardHiddenForGesture = false var keyboardHiddenForGesture = false
var resetOnFinally = true
// deferToChildren=true: pre-slop uses Main pass so children // deferToChildren=true: pre-slop uses Main pass so children
// (e.g. LazyRow) process first — if they consume, we back off. // (e.g. LazyRow) process first — if they consume, we back off.
// deferToChildren=false (default): always use Initial pass // deferToChildren=false (default): always use Initial pass
// to intercept before children (original behavior). // to intercept before children (original behavior).
// Post-claim: always Initial to block children. // Post-claim: always Initial to block children.
try {
while (true) { while (true) {
val pass = val pass =
if (startedSwipe || !deferToChildren) if (startedSwipe || !deferToChildren)
@@ -365,6 +405,7 @@ fun SwipeBackContainer(
?: break ?: break
if (change.changedToUpIgnoreConsumed()) { if (change.changedToUpIgnoreConsumed()) {
resetOnFinally = false
break break
} }
@@ -444,6 +485,13 @@ fun SwipeBackContainer(
change.consume() change.consume()
} }
} }
} finally {
// Сбрасываем только при отмене/прерывании жеста.
// При обычном UP сброс делаем позже, чтобы не было рывка.
if (resetOnFinally && isDragging) {
forceResetSwipeState()
}
}
// Handle drag end // Handle drag end
if (startedSwipe) { if (startedSwipe) {

View File

@@ -92,16 +92,8 @@ fun MyQrCodeScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var selectedThemeIndex by remember { mutableIntStateOf(if (isDarkTheme) 0 else 3) } var selectedThemeIndex by remember { mutableIntStateOf(if (isDarkTheme) 0 else 3) }
// Local dark/light state — independent from the global app theme
// Auto-switch to matching theme group when app theme changes var localIsDark by remember { mutableStateOf(isDarkTheme) }
LaunchedEffect(isDarkTheme) {
val currentTheme = qrThemes.getOrNull(selectedThemeIndex)
if (currentTheme != null && currentTheme.isDark != isDarkTheme) {
// Map to same position in the other group
val posInGroup = if (currentTheme.isDark) selectedThemeIndex else selectedThemeIndex - 3
selectedThemeIndex = if (isDarkTheme) posInGroup.coerceIn(0, 2) else (posInGroup + 3).coerceIn(3, 5)
}
}
val theme = qrThemes[selectedThemeIndex] val theme = qrThemes[selectedThemeIndex]
@@ -272,7 +264,7 @@ fun MyQrCodeScreen(
Surface( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
color = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White, color = if (localIsDark) Color(0xFF1C1C1E) else Color.White,
shadowElevation = 16.dp shadowElevation = 16.dp
) { ) {
Column( Column(
@@ -299,16 +291,17 @@ fun MyQrCodeScreen(
) { ) {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(TablerIcons.X, contentDescription = "Close", Icon(TablerIcons.X, contentDescription = "Close",
tint = if (isDarkTheme) Color.White else Color.Black) tint = if (localIsDark) Color.White else Color.Black)
} }
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Text("QR Code", fontSize = 17.sp, fontWeight = FontWeight.SemiBold, Text("QR Code", fontSize = 17.sp, fontWeight = FontWeight.SemiBold,
color = if (isDarkTheme) Color.White else Color.Black) color = if (localIsDark) Color.White else Color.Black)
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
var themeButtonPos by remember { mutableStateOf(Offset.Zero) } var themeButtonPos by remember { mutableStateOf(Offset.Zero) }
IconButton( IconButton(
onClick = { onClick = {
// Snapshot → toggle theme → circular reveal // Snapshot → toggle LOCAL theme → circular reveal
// Does NOT toggle the global app theme
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (!revealActive && rootSize.width > 0 && now - lastRevealTime >= revealCooldownMs) { if (!revealActive && rootSize.width > 0 && now - lastRevealTime >= revealCooldownMs) {
lastRevealTime = now lastRevealTime = now
@@ -319,11 +312,10 @@ fun MyQrCodeScreen(
revealActive = true revealActive = true
revealCenter = themeButtonPos revealCenter = themeButtonPos
revealSnapshot = snapshot.asImageBitmap() revealSnapshot = snapshot.asImageBitmap()
// Switch to matching wallpaper in new theme val posInGroup = if (localIsDark) selectedThemeIndex else selectedThemeIndex - 3
val posInGroup = if (isDarkTheme) selectedThemeIndex else selectedThemeIndex - 3 val newIndex = if (localIsDark) (posInGroup + 3).coerceIn(3, 5) else posInGroup.coerceIn(0, 2)
val newIndex = if (isDarkTheme) (posInGroup + 3).coerceIn(3, 5) else posInGroup.coerceIn(0, 2)
selectedThemeIndex = newIndex selectedThemeIndex = newIndex
onToggleTheme() localIsDark = !localIsDark
scope.launch { scope.launch {
try { try {
revealRadius.snapTo(0f) revealRadius.snapTo(0f)
@@ -337,11 +329,8 @@ fun MyQrCodeScreen(
revealActive = false revealActive = false
} }
} }
} else {
// drawToBitmap failed — skip
} }
} }
// else: cooldown active — ignore tap
}, },
modifier = Modifier.onGloballyPositioned { coords -> modifier = Modifier.onGloballyPositioned { coords ->
val pos = coords.positionInRoot() val pos = coords.positionInRoot()
@@ -350,9 +339,9 @@ fun MyQrCodeScreen(
} }
) { ) {
Icon( Icon(
imageVector = if (isDarkTheme) TablerIcons.Sun else TablerIcons.MoonStars, imageVector = if (localIsDark) TablerIcons.Sun else TablerIcons.MoonStars,
contentDescription = "Toggle theme", contentDescription = "Toggle theme",
tint = if (isDarkTheme) Color.White else Color.Black tint = if (localIsDark) Color.White else Color.Black
) )
} }
} }
@@ -360,7 +349,7 @@ fun MyQrCodeScreen(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Wallpaper selector — show current theme's wallpapers // Wallpaper selector — show current theme's wallpapers
val currentThemes = qrThemes.filter { it.isDark == isDarkTheme } val currentThemes = qrThemes.filter { it.isDark == localIsDark }
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
@@ -394,7 +383,7 @@ fun MyQrCodeScreen(
modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop) modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
} }
Icon(TablerIcons.Scan, contentDescription = null, Icon(TablerIcons.Scan, contentDescription = null,
tint = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.35f), tint = if (localIsDark) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.35f),
modifier = Modifier.size(22.dp)) modifier = Modifier.size(22.dp))
} }
} }

View File

@@ -43,10 +43,10 @@ data class AppIconOption(
) )
private val iconOptions = listOf( private val iconOptions = listOf(
AppIconOption("default", "Rosetta", "Original icon", ".MainActivityDefault", R.drawable.ic_launcher_foreground, Color(0xFF1B1B1B)), AppIconOption("default", "Rosetta", "Original icon", ".MainActivityDefault", R.drawable.rosetta_icon, Color(0xFF1B1B1B)),
AppIconOption("calculator", "Calculator", "Disguise as calculator", ".MainActivityCalculator", R.drawable.ic_calc_foreground, Color(0xFF795548)), AppIconOption("calculator", "Calculator", "Disguise as calculator", ".MainActivityCalculator", R.drawable.ic_calc_foreground, Color.White),
AppIconOption("weather", "Weather", "Disguise as weather app", ".MainActivityWeather", R.drawable.ic_weather_foreground, Color(0xFF42A5F5)), AppIconOption("weather", "Weather", "Disguise as weather app", ".MainActivityWeather", R.drawable.ic_weather_foreground, Color.White),
AppIconOption("notes", "Notes", "Disguise as notes app", ".MainActivityNotes", R.drawable.ic_notes_foreground, Color(0xFFFFC107)) AppIconOption("notes", "Notes", "Disguise as notes app", ".MainActivityNotes", R.drawable.ic_notes_foreground, Color.White)
) )
@Composable @Composable
@@ -173,9 +173,8 @@ fun AppIconScreen(
.background(option.previewBg), .background(option.previewBg),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// Default icon has 15% inset built-in — show full size val imgSize = if (option.id == "default") 52.dp else 44.dp
val iconSize = if (option.id == "default") 52.dp else 36.dp val imgScale = if (option.id == "default")
val scaleType = if (option.id == "default")
android.widget.ImageView.ScaleType.CENTER_CROP android.widget.ImageView.ScaleType.CENTER_CROP
else else
android.widget.ImageView.ScaleType.FIT_CENTER android.widget.ImageView.ScaleType.FIT_CENTER
@@ -183,10 +182,10 @@ fun AppIconScreen(
factory = { ctx -> factory = { ctx ->
android.widget.ImageView(ctx).apply { android.widget.ImageView(ctx).apply {
setImageResource(option.iconRes) setImageResource(option.iconRes)
this.scaleType = scaleType scaleType = imgScale
} }
}, },
modifier = Modifier.size(iconSize) modifier = Modifier.size(imgSize)
) )
} }

View File

@@ -3,6 +3,8 @@ package com.rosetta.messenger.ui.splash
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
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.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -64,7 +66,11 @@ fun SplashScreen(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(backgroundColor), .background(backgroundColor)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// Glow effect behind logo // Glow effect behind logo

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"> <shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#795548"/> <solid android:color="#FFFFFF"/>
</shape> </shape>

View File

@@ -1,38 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <?xml version="1.0" encoding="utf-8"?>
android:width="108dp" <inset xmlns:android="http://schemas.android.com/apk/res/android"
android:height="108dp" android:inset="20%">
android:viewportWidth="108" <bitmap
android:viewportHeight="108"> android:src="@drawable/ic_calc_downloaded"
<!-- Calculator — Google Calculator style: simple, bold, white on color --> android:gravity="fill"/>
<group android:translateX="30" android:translateY="24"> </inset>
<!-- = sign (equals, large, centered, bold) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M6,22h36v5H6z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M6,33h36v5H6z" />
<!-- + sign (plus, smaller, top right) -->
<path
android:fillColor="#FFFFFF"
android:alpha="0.7"
android:pathData="M34,2h4v16h-4z" />
<path
android:fillColor="#FFFFFF"
android:alpha="0.7"
android:pathData="M28,8h16v4H28z" />
<!-- ÷ sign (divide, bottom) -->
<path
android:fillColor="#FFFFFF"
android:alpha="0.7"
android:pathData="M6,50h36v4H6z" />
<path
android:fillColor="#FFFFFF"
android:alpha="0.7"
android:pathData="M22,43a3,3,0,1,1,-3,3a3,3,0,0,1,3,-3z" />
<path
android:fillColor="#FFFFFF"
android:alpha="0.7"
android:pathData="M22,57a3,3,0,1,1,-3,3a3,3,0,0,1,3,-3z" />
</group>
</vector>

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"> <shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FFC107"/> <solid android:color="#FFFFFF"/>
</shape> </shape>

View File

@@ -1,52 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <?xml version="1.0" encoding="utf-8"?>
android:width="108dp" <inset xmlns:android="http://schemas.android.com/apk/res/android"
android:height="108dp" android:inset="20%">
android:viewportWidth="108" <bitmap
android:viewportHeight="108"> android:src="@drawable/ic_notes_downloaded"
<!-- Notes — Google Keep style: white card with colored pin/accent --> android:gravity="fill"/>
<group android:translateX="26" android:translateY="20"> </inset>
<!-- Card shadow -->
<path
android:fillColor="#00000020"
android:pathData="M5,5h46c2.8,0 5,2.2 5,5v54c0,2.8 -2.2,5 -5,5H5c-2.8,0 -5,-2.2 -5,-5V10C0,7.2 2.2,5 5,5z" />
<!-- White card -->
<path
android:fillColor="#FFFFFF"
android:pathData="M5,2h46c2.8,0 5,2.2 5,5v54c0,2.8 -2.2,5 -5,5H5c-2.8,0 -5,-2.2 -5,-5V7C0,4.2 2.2,2 5,2z" />
<!-- Checkbox checked -->
<path
android:fillColor="#FFA000"
android:pathData="M6,14h6v6H6z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M7.5,17.5l1.5,1.5l3.5,-3.5"
android:strokeColor="#FFFFFF"
android:strokeWidth="1.5"/>
<!-- Text line 1 (next to checkbox) -->
<path android:fillColor="#757575" android:pathData="M16,15h32v3H16z" />
<!-- Checkbox unchecked -->
<path
android:fillColor="#00000000"
android:strokeColor="#BDBDBD"
android:strokeWidth="1.5"
android:pathData="M6,28h6v6H6z" />
<!-- Text line 2 -->
<path android:fillColor="#BDBDBD" android:pathData="M16,29h28v3H16z" />
<!-- Checkbox unchecked -->
<path
android:fillColor="#00000000"
android:strokeColor="#BDBDBD"
android:strokeWidth="1.5"
android:pathData="M6,42h6v6H6z" />
<!-- Text line 3 -->
<path android:fillColor="#BDBDBD" android:pathData="M16,43h22v3H16z" />
<!-- Checkbox unchecked -->
<path
android:fillColor="#00000000"
android:strokeColor="#BDBDBD"
android:strokeWidth="1.5"
android:pathData="M6,56h6v6H6z" />
<!-- Text line 4 -->
<path android:fillColor="#BDBDBD" android:pathData="M16,57h18v3H16z" />
</group>
</vector>

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"> <shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#42A5F5"/> <solid android:color="#FFFFFF"/>
</shape> </shape>

View File

@@ -1,32 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <?xml version="1.0" encoding="utf-8"?>
android:width="108dp" <inset xmlns:android="http://schemas.android.com/apk/res/android"
android:height="108dp" android:inset="20%">
android:viewportWidth="108" <bitmap
android:viewportHeight="108"> android:src="@drawable/ic_weather_downloaded"
<!-- Weather — sun with cloud, Material You style, centered in safe zone --> android:gravity="fill"/>
<group android:translateX="20" android:translateY="20"> </inset>
<!-- Sun glow (soft circle) -->
<path
android:fillColor="#FFF9C4"
android:pathData="M34,24a14,14,0,1,1,-14,14a14,14,0,0,1,14,-14z" />
<!-- Sun core -->
<path
android:fillColor="#FFB300"
android:pathData="M34,28a10,10,0,1,1,-10,10a10,10,0,0,1,10,-10z" />
<!-- Sun rays -->
<path
android:fillColor="#FFB300"
android:pathData="M33,12h2v8h-2zM33,48h2v8h-2zM12,37v-2h8v2zM48,37v-2h8v2z" />
<path
android:fillColor="#FFB300"
android:pathData="M18.3,18.3l1.4,1.4l5.7,5.7l-1.4,1.4l-5.7,-5.7zM42.6,42.6l1.4,1.4l5.7,5.7l-1.4,1.4l-5.7,-5.7zM18.3,49.7l5.7,-5.7l1.4,1.4l-5.7,5.7zM42.6,25.4l5.7,-5.7l1.4,1.4l-5.7,5.7z" />
<!-- Cloud (in front of sun) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M52,44c4.4,0 8,3.6 8,8s-3.6,8 -8,8H20c-5.5,0 -10,-4.5 -10,-10s4.5,-10 10,-10c0.5,0 1,0 1.5,0.1C23.2,35.2 28,32 34,32c6.4,0 11.7,4.4 13.2,10.3C49,42.1 50.5,42 52,44z" />
<!-- Cloud shadow hint -->
<path
android:fillColor="#E0E0E0"
android:pathData="M20,58h32c2.5,0 4.8,-0.8 6.6,-2H14C16,57 17.9,58 20,58z" />
</group>
</vector>