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 при параллельной
// расшифровке
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)
init {
@@ -298,17 +302,21 @@ object CryptoManager {
* 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений
*/
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)
val cacheKey = "$password:$encryptedData"
decryptionCache[cacheKey]?.let {
return it
if (cacheKey != null) {
decryptionCache[cacheKey]?.let {
return it
}
}
return try {
val result = decryptWithPasswordInternal(encryptedData, password)
// 🚀 Сохраняем в кэш (lock-free)
if (result != null) {
if (cacheKey != null && result != null && result.length <= MAX_CACHEABLE_DECRYPTED_CHARS) {
// Ограничиваем размер кэша
if (decryptionCache.size >= DECRYPTION_CACHE_SIZE) {
// Удаляем ~10% самых старых записей

View File

@@ -856,7 +856,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
isOutgoing = fm.isOutgoing,
publicKey = fm.senderPublicKey,
senderName = fm.senderName,
attachments = fm.attachments
attachments = fm.attachments,
chachaKeyPlainHex = fm.chachaKeyPlain
)
}
_isForwardMode.value = true
@@ -2160,7 +2161,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
forwardedList.add(ReplyData(
messageId = fwdMessageId,
senderName = senderDisplayName,
text = fwdText,
text = resolveReplyPreviewText(fwdText, fwdAttachments),
isFromMe = fwdIsFromMe,
isForwarded = true,
forwardedFromName = senderDisplayName,
@@ -2346,7 +2347,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
ReplyData(
messageId = realMessageId,
senderName = resolvedSenderName,
text = replyText,
text = resolveReplyPreviewText(replyText, originalAttachments),
isFromMe = isReplyFromMe,
isForwarded = isForwarded,
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 отправителя для
* правильного отображения цитаты
@@ -2515,16 +2528,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
msg.senderPublicKey.trim().ifEmpty {
if (msg.isOutgoing) sender else opponent
}
val resolvedAttachments =
msg.attachments
.filter { it.type != AttachmentType.MESSAGES }
ReplyMessage(
messageId = msg.id,
text = msg.text,
text = resolveReplyPreviewText(msg.text, resolvedAttachments),
timestamp = msg.timestamp.time,
isOutgoing = msg.isOutgoing,
publicKey = resolvedPublicKey,
senderName = msg.senderName,
attachments =
msg.attachments
.filter { it.type != AttachmentType.MESSAGES },
attachments = resolvedAttachments,
chachaKeyPlainHex = msg.chachaKeyPlainHex
)
}
@@ -2542,16 +2556,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
msg.senderPublicKey.trim().ifEmpty {
if (msg.isOutgoing) sender else opponent
}
val resolvedAttachments =
msg.attachments
.filter { it.type != AttachmentType.MESSAGES }
ReplyMessage(
messageId = msg.id,
text = msg.text,
text = resolveReplyPreviewText(msg.text, resolvedAttachments),
timestamp = msg.timestamp.time,
isOutgoing = msg.isOutgoing,
publicKey = resolvedPublicKey,
senderName = msg.senderName,
attachments =
msg.attachments
.filter { it.type != AttachmentType.MESSAGES }
attachments = resolvedAttachments,
chachaKeyPlainHex = msg.chachaKeyPlainHex
)
}
_isForwardMode.value = true
@@ -2942,7 +2958,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
ReplyData(
messageId = firstReply.messageId,
senderName = firstReplySenderName,
text = firstReply.text,
text = resolveReplyPreviewText(firstReply.text, replyAttachments),
isFromMe = firstReply.isOutgoing,
isForwarded = isForward,
forwardedFromName =
@@ -2972,7 +2988,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
ReplyData(
messageId = msg.messageId,
senderName = senderDisplayName,
text = msg.text,
text = resolveReplyPreviewText(msg.text, resolvedAttachments),
isFromMe = msg.isOutgoing,
isForwarded = true,
forwardedFromName = senderDisplayName,
@@ -3143,6 +3159,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (isForwardToSend) {
put("forwarded", true)
put("senderName", msg.senderName)
if (msg.chachaKeyPlainHex.isNotEmpty()) {
put("chacha_key_plain", msg.chachaKeyPlainHex)
}
}
}
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.components.AnimatedDotsText
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.AvatarImage
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) }
}
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 {
val trimmed = value.trim()
if (trimmed.length <= 12) return trimmed
@@ -467,6 +480,12 @@ fun ChatsListScreen(
// <20>🔥 Пользователи, которые сейчас печатают
val typingUsers by ProtocolManager.typingUsers.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
LaunchedEffect(accountPublicKey, accountPrivateKey) {
@@ -2130,6 +2149,50 @@ fun ChatsListScreen(
callUiState.phase != CallPhase.INCOMING
}
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 для
// консистентности
// 📌 Порядок по времени готовится в ViewModel.
@@ -2332,9 +2395,7 @@ fun ChatsListScreen(
Modifier.fillMaxSize()
.padding(
top =
if (showStickyCallBanner)
callBannerHeight
else 0.dp
stickyTopInset
)
.then(
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 =
selectedChatKeys
.contains(
@@ -2613,6 +2686,8 @@ fun ChatsListScreen(
typingDisplayName,
typingSenderPublicKey =
typingSenderPublicKey,
isVoicePlaybackActive =
isVoicePlaybackActive,
isBlocked =
isBlocked,
isSavedMessages =
@@ -2746,14 +2821,41 @@ fun ChatsListScreen(
}
}
}
if (showStickyCallBanner) {
CallTopBanner(
state = callUiState,
isSticky = true,
isDarkTheme = isDarkTheme,
avatarRepository = avatarRepository,
onOpenCall = onOpenCallOverlay
)
if (showStickyCallBanner || showVoiceMiniPlayer) {
Column(
modifier =
Modifier.fillMaxWidth()
.align(
Alignment.TopCenter
)
) {
if (showStickyCallBanner) {
CallTopBanner(
state = callUiState,
isSticky = true,
isDarkTheme = isDarkTheme,
avatarRepository = avatarRepository,
onOpenCall = onOpenCallOverlay
)
}
if (showVoiceMiniPlayer) {
VoiceTopMiniPlayer(
title = voiceMiniPlayerTitle,
isDarkTheme = isDarkTheme,
isPlaying = isVoicePlaybackRunning,
speed = voicePlaybackSpeed,
onTogglePlay = {
VoicePlaybackCoordinator.toggleCurrentPlayback()
},
onCycleSpeed = {
VoicePlaybackCoordinator.cycleSpeed()
},
onClose = {
VoicePlaybackCoordinator.stop()
}
)
}
}
}
}
}
@@ -3722,6 +3824,7 @@ fun SwipeableDialogItem(
isTyping: Boolean = false,
typingDisplayName: String = "",
typingSenderPublicKey: String = "",
isVoicePlaybackActive: Boolean = false,
isBlocked: Boolean = false,
isGroupChat: Boolean = false,
isSavedMessages: Boolean = false,
@@ -4125,6 +4228,7 @@ fun SwipeableDialogItem(
isTyping = isTyping,
typingDisplayName = typingDisplayName,
typingSenderPublicKey = typingSenderPublicKey,
isVoicePlaybackActive = isVoicePlaybackActive,
isPinned = isPinned,
isBlocked = isBlocked,
isMuted = isMuted,
@@ -4144,6 +4248,7 @@ fun DialogItemContent(
isTyping: Boolean = false,
typingDisplayName: String = "",
typingSenderPublicKey: String = "",
isVoicePlaybackActive: Boolean = false,
isPinned: Boolean = false,
isBlocked: Boolean = false,
isMuted: Boolean = false,
@@ -4278,12 +4383,12 @@ fun DialogItemContent(
// Name and last message
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().heightIn(min = 22.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.weight(1f),
modifier = Modifier.weight(1f).heightIn(min = 22.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppleEmojiText(
@@ -4293,7 +4398,8 @@ fun DialogItemContent(
color = textColor,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = false
enableLinks = false,
minHeightMultiplier = 1f
)
if (isGroupDialog) {
Spacer(modifier = Modifier.width(5.dp))
@@ -4301,7 +4407,7 @@ fun DialogItemContent(
imageVector = TablerIcons.Users,
contentDescription = null,
tint = secondaryTextColor.copy(alpha = 0.9f),
modifier = Modifier.size(15.dp)
modifier = Modifier.size(14.dp)
)
}
val isOfficialByKey = MessageRepository.isSystemAccount(dialog.opponentKey)
@@ -4310,7 +4416,7 @@ fun DialogItemContent(
VerifiedBadge(
verified = if (dialog.verified > 0) dialog.verified else 1,
size = 16,
modifier = Modifier.offset(y = (-2).dp),
modifier = Modifier,
isDarkTheme = isDarkTheme,
badgeTint = PrimaryBlue
)
@@ -4337,6 +4443,7 @@ fun DialogItemContent(
}
Row(
modifier = Modifier.heightIn(min = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
@@ -4467,7 +4574,7 @@ fun DialogItemContent(
0.6f
),
modifier =
Modifier.size(14.dp)
Modifier.size(16.dp)
)
Spacer(
modifier =
@@ -4487,9 +4594,11 @@ fun DialogItemContent(
Text(
text = formattedTime,
fontSize = 13.sp,
lineHeight = 13.sp,
color =
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),
contentAlignment = Alignment.CenterStart
) {
val subtitleMode =
remember(
isVoicePlaybackActive,
isTyping,
dialog.draftText
) {
when {
isVoicePlaybackActive -> "voice"
isTyping -> "typing"
!dialog.draftText.isNullOrEmpty() -> "draft"
else -> "message"
}
}
Crossfade(
targetState = isTyping,
targetState = subtitleMode,
animationSpec = tween(150),
label = "chatSubtitle"
) { showTyping ->
if (showTyping) {
) { mode ->
if (mode == "voice") {
VoicePlaybackIndicatorSmall(
isDarkTheme = isDarkTheme
)
} else if (mode == "typing") {
TypingIndicatorSmall(
isDarkTheme = isDarkTheme,
typingDisplayName = typingDisplayName,
typingSenderPublicKey = typingSenderPublicKey
)
} else if (!dialog.draftText.isNullOrEmpty()) {
} else if (mode == "draft") {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Draft: ",
@@ -4527,7 +4653,7 @@ fun DialogItemContent(
maxLines = 1
)
AppleEmojiText(
text = dialog.draftText,
text = dialog.draftText.orEmpty(),
modifier = Modifier.weight(1f),
fontSize = 14.sp,
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
private fun SwipeBackContainer(
onBack: () -> Unit,
@@ -5467,7 +5748,7 @@ fun DrawerMenuItemEnhanced(
Text(
text = text,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
fontWeight = FontWeight.Medium,
color = textColor,
modifier = Modifier.weight(1f)
)
@@ -5527,7 +5808,7 @@ fun DrawerMenuItemEnhanced(
Text(
text = text,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
fontWeight = FontWeight.Medium,
color = textColor,
modifier = Modifier.weight(1f)
)
@@ -5561,7 +5842,7 @@ fun DrawerMenuItemEnhanced(
fun DrawerDivider(isDarkTheme: Boolean) {
Spacer(modifier = Modifier.height(8.dp))
Divider(
modifier = Modifier.padding(horizontal = 20.dp),
modifier = Modifier.fillMaxWidth(),
color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFC8C8CC),
thickness = 0.5.dp
)

View File

@@ -573,23 +573,23 @@ private fun ChatsTabContent(
}
}
// ─── Recent header (always show with Clear All) ───
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(top = 14.dp, bottom = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Recent",
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = PrimaryBlue
)
if (recentUsers.isNotEmpty()) {
// ─── Recent header (only when there are recents) ───
if (recentUsers.isNotEmpty()) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(top = 14.dp, bottom = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Recent",
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = PrimaryBlue
)
Text(
"Clear All",
fontSize = 13.sp,

View File

@@ -7,6 +7,7 @@ import android.graphics.Matrix
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.net.Uri
import android.os.Build
import android.os.SystemClock
import android.util.Base64
import android.util.LruCache
@@ -91,6 +92,8 @@ import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream
import java.io.File
import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.Locale
import kotlin.math.min
import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.content.FileProvider
@@ -153,25 +156,48 @@ private fun decodeVoicePayload(data: String): ByteArray? {
return decodeHexPayload(data) ?: decodeBase64Payload(data)
}
private object VoicePlaybackCoordinator {
object VoicePlaybackCoordinator {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val speedSteps = listOf(1f, 1.5f, 2f)
private var player: MediaPlayer? = null
private var currentAttachmentId: String? = null
private var progressJob: Job? = null
private val _playingAttachmentId = MutableStateFlow<String?>(null)
val playingAttachmentId: StateFlow<String?> = _playingAttachmentId.asStateFlow()
private val _playingDialogKey = MutableStateFlow<String?>(null)
val playingDialogKey: StateFlow<String?> = _playingDialogKey.asStateFlow()
private val _positionMs = MutableStateFlow(0)
val positionMs: StateFlow<Int> = _positionMs.asStateFlow()
private val _durationMs = MutableStateFlow(0)
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()) {
onError("Voice file is missing")
return
}
if (currentAttachmentId == attachmentId && player?.isPlaying == true) {
stop()
if (currentAttachmentId == attachmentId && player != null) {
if (_isPlaying.value) {
pause()
} else {
resume(onError = onError)
}
return
}
@@ -187,22 +213,18 @@ private object VoicePlaybackCoordinator {
mediaPlayer.setDataSource(sourceFile.absolutePath)
mediaPlayer.setOnCompletionListener { stop() }
mediaPlayer.prepare()
applyPlaybackSpeed(mediaPlayer)
mediaPlayer.start()
player = mediaPlayer
currentAttachmentId = attachmentId
_playingAttachmentId.value = attachmentId
_playingDialogKey.value = dialogKey.trim().ifBlank { null }
_playingSenderLabel.value = senderLabel.trim()
_playingTimeLabel.value = playedAtLabel.trim()
_durationMs.value = mediaPlayer.duration.coerceAtLeast(0)
_positionMs.value = mediaPlayer.currentPosition.coerceAtLeast(0)
progressJob?.cancel()
progressJob =
scope.launch {
while (isActive && currentAttachmentId == attachmentId) {
val active = player
if (active == null || !active.isPlaying) break
_positionMs.value = active.currentPosition.coerceAtLeast(0)
delay(120)
}
}
_isPlaying.value = true
startProgressUpdates(attachmentId)
} catch (e: Exception) {
runCatching { mediaPlayer.release() }
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() {
val active = player
player = null
@@ -217,8 +313,12 @@ private object VoicePlaybackCoordinator {
progressJob?.cancel()
progressJob = null
_playingAttachmentId.value = null
_playingDialogKey.value = null
_playingSenderLabel.value = ""
_playingTimeLabel.value = ""
_positionMs.value = 0
_durationMs.value = 0
_isPlaying.value = false
if (active != null) {
runCatching {
if (active.isPlaying) active.stop()
@@ -593,6 +693,7 @@ fun MessageAttachments(
isOutgoing: Boolean,
isDarkTheme: Boolean,
senderPublicKey: String,
senderDisplayName: String = "",
dialogPublicKey: String = "",
isGroupChat: Boolean = false,
timestamp: java.util.Date,
@@ -683,6 +784,8 @@ fun MessageAttachments(
chachaKeyPlainHex = chachaKeyPlainHex,
privateKey = privateKey,
senderPublicKey = senderPublicKey,
senderDisplayName = senderDisplayName,
dialogPublicKey = dialogPublicKey,
isOutgoing = isOutgoing,
isDarkTheme = isDarkTheme,
timestamp = timestamp,
@@ -2036,6 +2139,8 @@ private fun VoiceAttachment(
chachaKeyPlainHex: String,
privateKey: String,
senderPublicKey: String,
senderDisplayName: String,
dialogPublicKey: String,
isOutgoing: Boolean,
isDarkTheme: Boolean,
timestamp: java.util.Date,
@@ -2043,10 +2148,12 @@ private fun VoiceAttachment(
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val playingAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
val activeAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
val playbackPositionMs by VoicePlaybackCoordinator.positionMs.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) =
remember(attachment.preview) { parseVoicePreview(attachment.preview) }
@@ -2078,21 +2185,37 @@ private fun VoiceAttachment(
val effectiveDurationSec =
remember(isPlaying, playbackDurationMs, previewDurationSec) {
val fromPlayer = (playbackDurationMs / 1000).coerceAtLeast(0)
if (isPlaying && fromPlayer > 0) fromPlayer else previewDurationSec
if (isActiveTrack && fromPlayer > 0) fromPlayer else previewDurationSec
}
val progress =
if (isPlaying && playbackDurationMs > 0) {
if (isActiveTrack && playbackDurationMs > 0) {
(playbackPositionMs.toFloat() / playbackDurationMs.toFloat()).coerceIn(0f, 1f)
} else {
0f
}
val timeText =
if (isPlaying && playbackDurationMs > 0) {
if (isActiveTrack && playbackDurationMs > 0) {
val leftSec = ((playbackDurationMs - playbackPositionMs).coerceAtLeast(0) / 1000)
"-${formatVoiceDuration(leftSec)}"
formatVoiceDuration(leftSec)
} else {
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) {
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@{
if (attachment.transportTag.isBlank()) {
downloadStatus = DownloadStatus.ERROR
@@ -2172,9 +2287,15 @@ private fun VoiceAttachment(
if (file == null || !file.exists()) {
if (payload.isNotBlank()) {
val prepared = ensureVoiceAudioFile(context, attachment.id, payload)
if (prepared != null) {
audioFilePath = prepared.absolutePath
VoicePlaybackCoordinator.toggle(attachment.id, prepared) { message ->
if (prepared != null) {
audioFilePath = prepared.absolutePath
VoicePlaybackCoordinator.toggle(
attachmentId = attachment.id,
sourceFile = prepared,
dialogKey = dialogPublicKey,
senderLabel = playbackSenderLabel,
playedAtLabel = playbackTimeLabel
) { message ->
downloadStatus = DownloadStatus.ERROR
errorText = message
}
@@ -2186,7 +2307,13 @@ private fun VoiceAttachment(
triggerDownload()
}
} else {
VoicePlaybackCoordinator.toggle(attachment.id, file) { message ->
VoicePlaybackCoordinator.toggle(
attachmentId = attachment.id,
sourceFile = file,
dialogKey = dialogPublicKey,
senderLabel = playbackSenderLabel,
playedAtLabel = playbackTimeLabel
) { message ->
downloadStatus = DownloadStatus.ERROR
errorText = message
}

View File

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

View File

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

View File

@@ -92,16 +92,8 @@ fun MyQrCodeScreen(
val scope = rememberCoroutineScope()
var selectedThemeIndex by remember { mutableIntStateOf(if (isDarkTheme) 0 else 3) }
// Auto-switch to matching theme group when app theme changes
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)
}
}
// Local dark/light state — independent from the global app theme
var localIsDark by remember { mutableStateOf(isDarkTheme) }
val theme = qrThemes[selectedThemeIndex]
@@ -272,7 +264,7 @@ fun MyQrCodeScreen(
Surface(
modifier = Modifier.fillMaxWidth(),
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
) {
Column(
@@ -299,16 +291,17 @@ fun MyQrCodeScreen(
) {
IconButton(onClick = onBack) {
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))
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))
var themeButtonPos by remember { mutableStateOf(Offset.Zero) }
IconButton(
onClick = {
// Snapshot → toggle theme → circular reveal
// Snapshot → toggle LOCAL theme → circular reveal
// Does NOT toggle the global app theme
val now = System.currentTimeMillis()
if (!revealActive && rootSize.width > 0 && now - lastRevealTime >= revealCooldownMs) {
lastRevealTime = now
@@ -319,11 +312,10 @@ fun MyQrCodeScreen(
revealActive = true
revealCenter = themeButtonPos
revealSnapshot = snapshot.asImageBitmap()
// Switch to matching wallpaper in new theme
val posInGroup = if (isDarkTheme) selectedThemeIndex else selectedThemeIndex - 3
val newIndex = if (isDarkTheme) (posInGroup + 3).coerceIn(3, 5) else posInGroup.coerceIn(0, 2)
val posInGroup = if (localIsDark) selectedThemeIndex else selectedThemeIndex - 3
val newIndex = if (localIsDark) (posInGroup + 3).coerceIn(3, 5) else posInGroup.coerceIn(0, 2)
selectedThemeIndex = newIndex
onToggleTheme()
localIsDark = !localIsDark
scope.launch {
try {
revealRadius.snapTo(0f)
@@ -337,11 +329,8 @@ fun MyQrCodeScreen(
revealActive = false
}
}
} else {
// drawToBitmap failed — skip
}
}
// else: cooldown active — ignore tap
},
modifier = Modifier.onGloballyPositioned { coords ->
val pos = coords.positionInRoot()
@@ -350,9 +339,9 @@ fun MyQrCodeScreen(
}
) {
Icon(
imageVector = if (isDarkTheme) TablerIcons.Sun else TablerIcons.MoonStars,
imageVector = if (localIsDark) TablerIcons.Sun else TablerIcons.MoonStars,
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))
// Wallpaper selector — show current theme's wallpapers
val currentThemes = qrThemes.filter { it.isDark == isDarkTheme }
val currentThemes = qrThemes.filter { it.isDark == localIsDark }
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
@@ -394,7 +383,7 @@ fun MyQrCodeScreen(
modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
}
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))
}
}

View File

@@ -43,10 +43,10 @@ data class AppIconOption(
)
private val iconOptions = listOf(
AppIconOption("default", "Rosetta", "Original icon", ".MainActivityDefault", R.drawable.ic_launcher_foreground, Color(0xFF1B1B1B)),
AppIconOption("calculator", "Calculator", "Disguise as calculator", ".MainActivityCalculator", R.drawable.ic_calc_foreground, Color(0xFF795548)),
AppIconOption("weather", "Weather", "Disguise as weather app", ".MainActivityWeather", R.drawable.ic_weather_foreground, Color(0xFF42A5F5)),
AppIconOption("notes", "Notes", "Disguise as notes app", ".MainActivityNotes", R.drawable.ic_notes_foreground, Color(0xFFFFC107))
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.White),
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.White)
)
@Composable
@@ -173,9 +173,8 @@ fun AppIconScreen(
.background(option.previewBg),
contentAlignment = Alignment.Center
) {
// Default icon has 15% inset built-in — show full size
val iconSize = if (option.id == "default") 52.dp else 36.dp
val scaleType = if (option.id == "default")
val imgSize = if (option.id == "default") 52.dp else 44.dp
val imgScale = if (option.id == "default")
android.widget.ImageView.ScaleType.CENTER_CROP
else
android.widget.ImageView.ScaleType.FIT_CENTER
@@ -183,10 +182,10 @@ fun AppIconScreen(
factory = { ctx ->
android.widget.ImageView(ctx).apply {
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.foundation.Image
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.shape.CircleShape
import androidx.compose.runtime.*
@@ -64,7 +66,11 @@ fun SplashScreen(
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor),
.background(backgroundColor)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { },
contentAlignment = Alignment.Center
) {
// 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"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#795548"/>
<solid android:color="#FFFFFF"/>
</shape>

View File

@@ -1,38 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Calculator — Google Calculator style: simple, bold, white on color -->
<group android:translateX="30" android:translateY="24">
<!-- = 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>
<?xml version="1.0" encoding="utf-8"?>
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:inset="20%">
<bitmap
android:src="@drawable/ic_calc_downloaded"
android:gravity="fill"/>
</inset>

View File

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

View File

@@ -1,52 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Notes — Google Keep style: white card with colored pin/accent -->
<group android:translateX="26" android:translateY="20">
<!-- 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>
<?xml version="1.0" encoding="utf-8"?>
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:inset="20%">
<bitmap
android:src="@drawable/ic_notes_downloaded"
android:gravity="fill"/>
</inset>

View File

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

View File

@@ -1,32 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Weather — sun with cloud, Material You style, centered in safe zone -->
<group android:translateX="20" android:translateY="20">
<!-- 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>
<?xml version="1.0" encoding="utf-8"?>
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:inset="20%">
<bitmap
android:src="@drawable/ic_weather_downloaded"
android:gravity="fill"/>
</inset>