Чат/звонки/коннект: Telegram-like UX и ряд фиксов

This commit is contained in:
2026-04-15 02:29:08 +05:00
parent 4396611355
commit 060d0cbd12
15 changed files with 767 additions and 238 deletions

View File

@@ -56,6 +56,7 @@ import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.auth.AccountInfo
import com.rosetta.messenger.ui.auth.AuthFlow
import com.rosetta.messenger.ui.auth.DeviceConfirmScreen
import com.rosetta.messenger.ui.auth.startAuthHandshakeFast
import com.rosetta.messenger.ui.chats.ChatDetailScreen
import com.rosetta.messenger.ui.chats.ChatsListScreen
import com.rosetta.messenger.ui.chats.VoiceTopMiniPlayer
@@ -91,6 +92,7 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class MainActivity : FragmentActivity() {
@@ -302,16 +304,57 @@ class MainActivity : FragmentActivity() {
startInCreateMode = startCreateAccountFlow,
onAuthComplete = { account ->
startCreateAccountFlow = false
currentAccount = account
cacheSessionAccount(account)
val normalizedAccount =
account?.let {
val normalizedName =
resolveAccountDisplayName(
it.publicKey,
it.name,
null
)
if (it.name == normalizedName) it
else it.copy(name = normalizedName)
}
currentAccount = normalizedAccount
cacheSessionAccount(normalizedAccount)
hasExistingAccount = true
// Save as last logged account
account?.let {
normalizedAccount?.let {
accountManager.setLastLoggedPublicKey(it.publicKey)
}
// Первый запуск после регистрации:
// дополнительно перезапускаем auth/connect, чтобы не оставаться
// в "залипшем CONNECTING" до ручного рестарта приложения.
normalizedAccount?.let { authAccount ->
startAuthHandshakeFast(
authAccount.publicKey,
authAccount.privateKeyHash
)
scope.launch {
repeat(3) { attempt ->
if (ProtocolManager.isAuthenticated()) return@launch
delay(2000L * (attempt + 1))
if (ProtocolManager.isAuthenticated()) return@launch
ProtocolManager.reconnectNowIfNeeded(
"post_auth_complete_retry_${attempt + 1}"
)
startAuthHandshakeFast(
authAccount.publicKey,
authAccount.privateKeyHash
)
}
}
}
// Reload accounts list
scope.launch {
normalizedAccount?.let {
// Синхронно помечаем текущий аккаунт активным в DataStore.
runCatching {
accountManager.setCurrentAccount(it.publicKey)
}
}
val accounts = accountManager.getAllAccounts()
accountInfoList = accounts.map { it.toAccountInfo() }
}
@@ -1492,9 +1535,18 @@ fun MainScreen(
}
}.collectAsState(initial = 0)
var chatSelectionActive by remember { mutableStateOf(false) }
val chatClearSelectionRef = remember { mutableStateOf<() -> Unit>({}) }
SwipeBackContainer(
isVisible = selectedUser != null,
onBack = { popChatAndChildren() },
onInterceptSwipeBack = {
if (chatSelectionActive) {
chatClearSelectionRef.value()
true
} else false
},
isDarkTheme = isDarkTheme,
layer = 1,
swipeEnabled = !isChatSwipeLocked,
@@ -1539,7 +1591,9 @@ fun MainScreen(
avatarRepository = avatarRepository,
onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked },
isCallActive = callUiState.isVisible,
onOpenCallOverlay = { isCallOverlayExpanded = true }
onOpenCallOverlay = { isCallOverlayExpanded = true },
onSelectionModeChange = { chatSelectionActive = it },
registerClearSelection = { fn -> chatClearSelectionRef.value = fn }
)
}
}

View File

@@ -598,6 +598,12 @@ class MessageRepository private constructor(private val context: Context) {
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
dialogDao.updateDialogFromMessages(account, toPublicKey)
// Notify listeners (ChatViewModel) that a new message was persisted
// so the chat UI reloads from DB. Without this, messages produced by
// non-input flows (e.g. CallManager's missed-call attachment) only
// appear after the user re-enters the chat.
_newMessageEvents.tryEmit(dialogKey)
// 📁 Для saved messages - гарантируем создание/обновление dialog
if (isSavedMessages) {
val existing = dialogDao.getDialog(account, account)

View File

@@ -95,7 +95,11 @@ object CallManager {
private const val TAIL_LINES = 300
private const val PROTOCOL_LOG_TAIL_LINES = 180
private const val MAX_LOG_PREFIX = 180
private const val INCOMING_RING_TIMEOUT_MS = 45_000L
// Backend's CallManager.java uses RINGING_TIMEOUT = 30s. Local timeouts are
// slightly larger so the server's RINGING_TIMEOUT signal takes precedence when
// the network is healthy; local jobs are a fallback when the signal is lost.
private const val INCOMING_RING_TIMEOUT_MS = 35_000L
private const val OUTGOING_RING_TIMEOUT_MS = 35_000L
private const val CONNECTING_TIMEOUT_MS = 30_000L
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -127,6 +131,7 @@ object CallManager {
private var protocolStateJob: Job? = null
private var disconnectResetJob: Job? = null
private var incomingRingTimeoutJob: Job? = null
private var outgoingRingTimeoutJob: Job? = null
private var connectingTimeoutJob: Job? = null
private var signalWaiter: ((Packet) -> Unit)? = null
@@ -290,6 +295,18 @@ object CallManager {
)
breadcrumbState("startOutgoingCall")
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) }
// Local fallback for caller: if RINGING_TIMEOUT signal from the server is lost,
// stop ringing after the same window the server uses (~30s + small buffer).
outgoingRingTimeoutJob?.cancel()
outgoingRingTimeoutJob = scope.launch {
delay(OUTGOING_RING_TIMEOUT_MS)
val snap = _state.value
if (snap.phase == CallPhase.OUTGOING && snap.peerPublicKey == targetKey) {
breadcrumb("startOutgoingCall: local ring timeout (${OUTGOING_RING_TIMEOUT_MS}ms) → reset")
resetSession(reason = "No answer", notifyPeer = true)
}
}
return CallActionResult.STARTED
}
@@ -551,6 +568,9 @@ object CallManager {
breadcrumb("SIG: ACCEPT ignored — role=$role")
return
}
// Callee answered before timeout — cancel outgoing ring timer
outgoingRingTimeoutJob?.cancel()
outgoingRingTimeoutJob = null
if (localPrivateKey == null || localPublicKey == null) {
breadcrumb("SIG: ACCEPT — generating local session keys")
generateSessionKeys()
@@ -1033,9 +1053,14 @@ object CallManager {
preview = durationSec.toString()
)
// Capture role synchronously before the coroutine launches, because
// resetSession() sets role = null right after calling this function —
// otherwise the async check below would fall through to the callee branch.
val capturedRole = role
scope.launch {
runCatching {
if (role == CallRole.CALLER) {
if (capturedRole == CallRole.CALLER) {
// CALLER: send call attachment as a message (peer will receive it)
MessageRepository.getInstance(context).sendMessage(
toPublicKey = peerPublicKey,
@@ -1082,6 +1107,8 @@ object CallManager {
disconnectResetJob = null
incomingRingTimeoutJob?.cancel()
incomingRingTimeoutJob = null
outgoingRingTimeoutJob?.cancel()
outgoingRingTimeoutJob = null
// Play end call sound, then stop all
if (wasActive) {
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.END_CALL) }

View File

@@ -35,6 +35,7 @@ class Protocol(
private const val TAG = "RosettaProtocol"
private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве)
private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds
private const val CONNECTING_STUCK_TIMEOUT_MS = 15_000L
private const val MIN_PACKET_ID_BITS = 18 // Stream.readInt16() = 2 * readInt8() (9 bits each)
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
@@ -182,6 +183,7 @@ class Protocol(
private var lastSuccessfulConnection = 0L
private var reconnectJob: Job? = null // Для отмены запланированных переподключений
private var isConnecting = false // Флаг для защиты от одновременных подключений
private var connectingSinceMs = 0L
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -385,6 +387,7 @@ class Protocol(
*/
fun connect() {
val currentState = _state.value
val now = System.currentTimeMillis()
log("🔌 CONNECT CALLED: currentState=$currentState, reconnectAttempts=$reconnectAttempts, isConnecting=$isConnecting")
// КРИТИЧНО: Если уже подключены и аутентифицированы - не переподключаемся!
@@ -403,10 +406,20 @@ class Protocol(
return
}
// КРИТИЧНО: проверяем флаг isConnecting, а не только состояние
// КРИТИЧНО: проверяем флаг isConnecting, а не только состояние.
// Дополнительно защищаемся от "залипшего CONNECTING", который ранее снимался только рестартом приложения.
if (isConnecting || currentState == ProtocolState.CONNECTING) {
log("⚠️ Already connecting, skipping... (preventing duplicate connect)")
return
val elapsed = if (connectingSinceMs > 0L) now - connectingSinceMs else 0L
if (elapsed in 1 until CONNECTING_STUCK_TIMEOUT_MS) {
log("⚠️ Already connecting, skipping... (elapsed=${elapsed}ms)")
return
}
log("🧯 CONNECTING STUCK detected (elapsed=${elapsed}ms) -> forcing reconnect reset")
isConnecting = false
connectingSinceMs = 0L
runCatching { webSocket?.cancel() }
webSocket = null
setState(ProtocolState.DISCONNECTED, "Reset stuck CONNECTING (${elapsed}ms)")
}
val networkReady = isNetworkAvailable?.invoke() ?: true
@@ -424,6 +437,7 @@ class Protocol(
// Устанавливаем флаг ПЕРЕД любыми операциями
isConnecting = true
connectingSinceMs = now
reconnectAttempts++
log("📊 RECONNECT ATTEMPT #$reconnectAttempts")
@@ -455,6 +469,7 @@ class Protocol(
// Сбрасываем флаг подключения
isConnecting = false
connectingSinceMs = 0L
setState(ProtocolState.CONNECTED, "WebSocket onOpen callback")
// Flush queue as soon as socket is open.
@@ -500,6 +515,7 @@ class Protocol(
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
log("❌ WebSocket CLOSED: code=$code reason='$reason' state=${_state.value} manuallyClosed=$isManuallyClosed")
isConnecting = false // Сбрасываем флаг
connectingSinceMs = 0L
handleDisconnect()
}
@@ -511,6 +527,7 @@ class Protocol(
log(" Reconnect attempts: $reconnectAttempts")
t.printStackTrace()
isConnecting = false // Сбрасываем флаг
connectingSinceMs = 0L
_lastError.value = t.message
handleDisconnect()
}
@@ -801,6 +818,7 @@ class Protocol(
log("🔌 Manual disconnect requested")
isManuallyClosed = true
isConnecting = false // Сбрасываем флаг
connectingSinceMs = 0L
reconnectJob?.cancel() // Отменяем запланированные переподключения
reconnectJob = null
handshakeJob?.cancel()
@@ -823,6 +841,7 @@ class Protocol(
fun reconnectNowIfNeeded(reason: String = "foreground") {
val currentState = _state.value
val hasCredentials = !lastPublicKey.isNullOrBlank() && !lastPrivateHash.isNullOrBlank()
val now = System.currentTimeMillis()
log(
"⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, reason=$reason"
@@ -830,12 +849,22 @@ class Protocol(
if (!hasCredentials) return
if (
if (currentState == ProtocolState.CONNECTING && isConnecting) {
val elapsed = if (connectingSinceMs > 0L) now - connectingSinceMs else 0L
if (elapsed in 1 until CONNECTING_STUCK_TIMEOUT_MS) {
return
}
log("🧯 FAST RECONNECT: stuck CONNECTING (${elapsed}ms) -> reset and reconnect")
isConnecting = false
connectingSinceMs = 0L
runCatching { webSocket?.cancel() }
webSocket = null
setState(ProtocolState.DISCONNECTED, "Fast reconnect reset stuck CONNECTING")
} else if (
currentState == ProtocolState.AUTHENTICATED ||
currentState == ProtocolState.HANDSHAKING ||
currentState == ProtocolState.DEVICE_VERIFICATION_REQUIRED ||
currentState == ProtocolState.CONNECTED ||
(currentState == ProtocolState.CONNECTING && isConnecting)
currentState == ProtocolState.CONNECTED
) {
return
}

View File

@@ -325,7 +325,9 @@ fun ChatDetailScreen(
avatarRepository: AvatarRepository? = null,
onImageViewerChanged: (Boolean) -> Unit = {},
isCallActive: Boolean = false,
onOpenCallOverlay: () -> Unit = {}
onOpenCallOverlay: () -> Unit = {},
onSelectionModeChange: (Boolean) -> Unit = {},
registerClearSelection: (() -> Unit) -> Unit = {}
) {
val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")
val context = LocalContext.current
@@ -390,6 +392,14 @@ fun ChatDetailScreen(
// 🔥 MESSAGE SELECTION STATE - для Reply/Forward
var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) }
val isSelectionMode = selectedMessages.isNotEmpty()
// Notify parent about selection mode changes so it can intercept swipe-back
LaunchedEffect(isSelectionMode) { onSelectionModeChange(isSelectionMode) }
// Register selection-clear callback so parent can cancel selection on swipe-back
DisposableEffect(Unit) {
registerClearSelection { selectedMessages = emptySet() }
onDispose { registerClearSelection {} }
}
// После long press AndroidView текста может прислать tap на отпускание.
// В этом окне игнорируем tap по этому же сообщению, чтобы selection не снимался.
var longPressSuppressedMessageId by remember { mutableStateOf<String?>(null) }
@@ -1360,10 +1370,10 @@ fun ChatDetailScreen(
// 🔥 Обработка системной кнопки назад
BackHandler {
if (isInChatSearchMode) {
closeInChatSearch()
} else {
handleBackWithInputPriority()
when {
isSelectionMode -> selectedMessages = emptySet()
isInChatSearchMode -> closeInChatSearch()
else -> handleBackWithInputPriority()
}
}
@@ -2326,11 +2336,21 @@ fun ChatDetailScreen(
}
// Voice mini player — shown right under the chat header when audio is playing
val playingVoiceAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
if (!playingVoiceAttachmentId.isNullOrBlank()) {
val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState()
val voicePlaybackSpeed by VoicePlaybackCoordinator.playbackSpeed.collectAsState()
val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState()
val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.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()
AnimatedVisibility(
visible = !playingVoiceAttachmentId.isNullOrBlank(),
enter = expandVertically(
animationSpec = tween(220, easing = androidx.compose.animation.core.FastOutSlowInEasing),
expandFrom = Alignment.Top
) + fadeIn(animationSpec = tween(220)),
exit = shrinkVertically(
animationSpec = tween(260, easing = androidx.compose.animation.core.FastOutSlowInEasing),
shrinkTowards = Alignment.Top
) + fadeOut(animationSpec = tween(180))
) {
val sender = playingVoiceSenderLabel.trim().ifBlank { "Voice" }
val time = playingVoiceTimeLabel.trim()
val voiceTitle = if (time.isBlank()) sender else "$sender at $time"

View File

@@ -5882,6 +5882,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
return
}
// ⚡ Оптимизация: не отправляем typing indicator если собеседник офлайн
// (для групп продолжаем отправлять — кто-то из участников может быть в сети)
if (!isGroupDialogKey(opponent) && !_opponentOnline.value) {
return
}
val privateKey =
myPrivateKey
?: run {

View File

@@ -66,6 +66,7 @@ import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.data.isPlaceholderAccountName
import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.CallPhase
@@ -254,6 +255,15 @@ private fun resolveTypingDisplayName(publicKey: String): String {
return if (resolvedName.isNotBlank()) resolvedName else shortPublicKey(normalized)
}
private fun rosettaDev1Log(context: Context, tag: String, message: String) {
runCatching {
val dir = java.io.File(context.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs()
val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
java.io.File(dir, "rosettadev1.txt").appendText("$ts [$tag] $message\n")
}
}
private val TELEGRAM_DIALOG_AVATAR_START = 10.dp
private val TELEGRAM_DIALOG_TEXT_START = 72.dp
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
@@ -314,9 +324,6 @@ fun ChatsListScreen(
val view = androidx.compose.ui.platform.LocalView.current
val context = androidx.compose.ui.platform.LocalContext.current
val hasNativeNavigationBar = remember(context) {
com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context)
}
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
@@ -491,22 +498,37 @@ fun ChatsListScreen(
val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState()
val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.collectAsState()
// Load dialogs when account is available
// Load dialogs as soon as public key is available.
// Private key may appear a bit later right after fresh registration on some devices.
LaunchedEffect(accountPublicKey, accountPrivateKey) {
if (accountPublicKey.isNotEmpty() && accountPrivateKey.isNotEmpty()) {
val launchStart = System.currentTimeMillis()
chatsViewModel.setAccount(accountPublicKey, accountPrivateKey)
// Устанавливаем аккаунт для RecentSearchesManager
RecentSearchesManager.setAccount(accountPublicKey)
val normalizedPublicKey = accountPublicKey.trim()
if (normalizedPublicKey.isEmpty()) return@LaunchedEffect
// 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих
// сообщений
ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey)
android.util.Log.d(
"ChatsListScreen",
"✅ Total LaunchedEffect: ${System.currentTimeMillis() - launchStart}ms"
)
val normalizedPrivateKey = accountPrivateKey.trim()
val launchStart = System.currentTimeMillis()
chatsViewModel.setAccount(normalizedPublicKey, normalizedPrivateKey)
// Устанавливаем аккаунт для RecentSearchesManager
RecentSearchesManager.setAccount(normalizedPublicKey)
// 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих
// сообщений только когда приватный ключ уже доступен.
if (normalizedPrivateKey.isNotEmpty()) {
ProtocolManager.initializeAccount(normalizedPublicKey, normalizedPrivateKey)
}
android.util.Log.d(
"ChatsListScreen",
"✅ Account init effect: pubReady=true privReady=${normalizedPrivateKey.isNotEmpty()} " +
"in ${System.currentTimeMillis() - launchStart}ms"
)
rosettaDev1Log(
context = context,
tag = "ChatsListScreen",
message =
"Account init effect pub=${shortPublicKey(normalizedPublicKey)} " +
"privReady=${normalizedPrivateKey.isNotEmpty()}"
)
}
// Status dialog state
@@ -604,9 +626,44 @@ fun ChatsListScreen(
LaunchedEffect(accountPublicKey) {
val accountManager = AccountManager(context)
val accounts = accountManager.getAllAccounts()
allAccounts = accounts.sortedByDescending { it.publicKey == accountPublicKey }
val preferredPublicKey =
accountPublicKey.trim().ifBlank {
accountManager.getLastLoggedPublicKey().orEmpty()
}
allAccounts = accounts.sortedByDescending { it.publicKey == preferredPublicKey }
}
val effectiveCurrentPublicKey =
remember(accountPublicKey, allAccounts) {
accountPublicKey.trim().ifBlank { allAccounts.firstOrNull()?.publicKey.orEmpty() }
}
val currentSidebarAccount =
remember(allAccounts, effectiveCurrentPublicKey) {
allAccounts.firstOrNull {
it.publicKey.equals(effectiveCurrentPublicKey, ignoreCase = true)
} ?: allAccounts.firstOrNull()
}
val sidebarAccountUsername =
remember(accountUsername, currentSidebarAccount) {
accountUsername.ifBlank { currentSidebarAccount?.username.orEmpty() }
}
val sidebarAccountName =
remember(accountName, sidebarAccountUsername, currentSidebarAccount, effectiveCurrentPublicKey) {
val preferredName =
when {
accountName.isNotBlank() &&
!isPlaceholderAccountName(accountName) -> accountName
!currentSidebarAccount?.name.isNullOrBlank() ->
currentSidebarAccount?.name.orEmpty()
else -> accountName
}
resolveAccountDisplayName(
effectiveCurrentPublicKey,
preferredName,
sidebarAccountUsername
)
}
// Confirmation dialogs state
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
var dialogToLeave by remember { mutableStateOf<DialogUiModel?>(null) }
@@ -625,7 +682,7 @@ fun ChatsListScreen(
val hapticFeedback = LocalHapticFeedback.current
var showSelectionMenu by remember { mutableStateOf(false) }
val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
val mutedChats by preferencesManager.mutedChatsForAccount(accountPublicKey)
val mutedChats by preferencesManager.mutedChatsForAccount(effectiveCurrentPublicKey)
.collectAsState(initial = emptySet())
// Back: drawer → закрыть, selection → сбросить
@@ -656,6 +713,31 @@ fun ChatsListScreen(
val topLevelRequestsCount = topLevelChatsState.requestsCount
val visibleTopLevelRequestsCount = if (syncInProgress) 0 else topLevelRequestsCount
// Anti-stuck guard:
// если соединение уже AUTHENTICATED и синхронизация завершена,
// loading не должен висеть бесконечно.
LaunchedEffect(accountPublicKey, protocolState, syncInProgress, topLevelIsLoading) {
val normalizedPublicKey = accountPublicKey.trim()
if (normalizedPublicKey.isBlank()) return@LaunchedEffect
if (!topLevelIsLoading) return@LaunchedEffect
if (protocolState != ProtocolState.AUTHENTICATED || syncInProgress) return@LaunchedEffect
delay(1200)
if (
topLevelIsLoading &&
protocolState == ProtocolState.AUTHENTICATED &&
!syncInProgress
) {
rosettaDev1Log(
context = context,
tag = "ChatsListScreen",
message =
"loading guard fired pub=${shortPublicKey(normalizedPublicKey)}"
)
chatsViewModel.forceStopLoading("ui_guard_authenticated_no_sync")
}
}
// Dev console dialog - commented out for now
/*
if (showDevConsole) {
@@ -806,10 +888,6 @@ fun ChatsListScreen(
Modifier.fillMaxSize()
.onSizeChanged { rootSize = it }
.background(backgroundColor)
.then(
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
else Modifier
)
) {
ModalNavigationDrawer(
drawerState = drawerState,
@@ -892,16 +970,16 @@ fun ChatsListScreen(
) {
AvatarImage(
publicKey =
accountPublicKey,
effectiveCurrentPublicKey,
avatarRepository =
avatarRepository,
size = 72.dp,
isDarkTheme =
isDarkTheme,
displayName =
accountName
sidebarAccountName
.ifEmpty {
accountUsername
sidebarAccountUsername
}
)
}
@@ -953,13 +1031,13 @@ fun ChatsListScreen(
) {
Column(modifier = Modifier.weight(1f)) {
// Display name
if (accountName.isNotEmpty()) {
if (sidebarAccountName.isNotEmpty()) {
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = accountName,
text = sidebarAccountName,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = Color.White
@@ -978,10 +1056,10 @@ fun ChatsListScreen(
}
// Username
if (accountUsername.isNotEmpty()) {
if (sidebarAccountUsername.isNotEmpty()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "@$accountUsername",
text = "@$sidebarAccountUsername",
fontSize = 13.sp,
color = Color.White.copy(alpha = 0.7f)
)
@@ -1022,7 +1100,9 @@ fun ChatsListScreen(
Column(modifier = Modifier.fillMaxWidth()) {
// All accounts list (max 5 like Telegram sidebar behavior)
allAccounts.take(5).forEach { account ->
val isCurrentAccount = account.publicKey == accountPublicKey
val isCurrentAccount =
account.publicKey ==
effectiveCurrentPublicKey
val displayName =
resolveAccountDisplayName(
account.publicKey,
@@ -1310,9 +1390,12 @@ fun ChatsListScreen(
Column(
modifier =
Modifier.fillMaxWidth()
.then(
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
else Modifier
.windowInsetsPadding(
WindowInsets
.navigationBars
.only(
WindowInsetsSides.Bottom
)
)
) {
// Telegram-style update banner
@@ -2842,7 +2925,17 @@ fun ChatsListScreen(
onOpenCall = onOpenCallOverlay
)
}
if (showVoiceMiniPlayer) {
AnimatedVisibility(
visible = showVoiceMiniPlayer,
enter = expandVertically(
animationSpec = tween(220, easing = FastOutSlowInEasing),
expandFrom = Alignment.Top
) + fadeIn(animationSpec = tween(220)),
exit = shrinkVertically(
animationSpec = tween(260, easing = FastOutSlowInEasing),
shrinkTowards = Alignment.Top
) + fadeOut(animationSpec = tween(180))
) {
VoiceTopMiniPlayer(
title = voiceMiniPlayerTitle,
isDarkTheme = isDarkTheme,

View File

@@ -14,11 +14,14 @@ import com.rosetta.messenger.network.PacketOnlineSubscribe
import com.rosetta.messenger.network.PacketSearch
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -92,6 +95,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// Job для отмены подписок при смене аккаунта
private var accountSubscriptionsJob: Job? = null
private var loadingFailSafeJob: Job? = null
// Список диалогов с расшифрованными сообщениями
private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList())
@@ -132,9 +136,11 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
ChatsUiState()
)
// Загрузка (🔥 true по умолчанию — skeleton на первом кадре, чтобы не мигало empty→skeleton→empty)
private val _isLoading = MutableStateFlow(true)
// Загрузка
// Важно: false по умолчанию, чтобы исключить "вечный skeleton", если setAccount не был вызван.
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val loadingFailSafeTimeoutMs = 4500L
private val TAG = "ChatsListVM"
private val groupInviteRegex = Regex("^#group:[A-Za-z0-9+/=:]+$")
@@ -146,6 +152,16 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
return normalized.startsWith("#group:") || normalized.startsWith("group:")
}
private fun rosettaDev1Log(msg: String) {
runCatching {
val app = getApplication<Application>()
val dir = java.io.File(app.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs()
val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
java.io.File(dir, "rosettadev1.txt").appendText("$ts [ChatsListVM] $msg\n")
}
}
private data class GroupLastSenderInfo(
val senderPrefix: String,
val senderKey: String
@@ -345,15 +361,39 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
/** Установить текущий аккаунт и загрузить диалоги */
fun setAccount(publicKey: String, privateKey: String) {
val setAccountStart = System.currentTimeMillis()
if (currentAccount == publicKey) {
val resolvedPrivateKey =
when {
privateKey.isNotBlank() -> privateKey
currentAccount == publicKey -> currentPrivateKey.orEmpty()
else -> ""
}
if (currentAccount == publicKey && currentPrivateKey == resolvedPrivateKey) {
// 🔥 Сбрасываем skeleton если он ещё показан (при повторном заходе)
if (_isLoading.value) _isLoading.value = false
if (_isLoading.value) {
_isLoading.value = false
}
loadingFailSafeJob?.cancel()
return
}
// 🔥 Показываем skeleton пока данные грузятся
_isLoading.value = true
loadingFailSafeJob?.cancel()
loadingFailSafeJob =
viewModelScope.launch {
delay(loadingFailSafeTimeoutMs)
if (_isLoading.value) {
_isLoading.value = false
android.util.Log.w(
TAG,
"Fail-safe: forced isLoading=false after ${loadingFailSafeTimeoutMs}ms for account=${publicKey.take(8)}..."
)
rosettaDev1Log(
"Fail-safe isLoading=false account=${publicKey.take(8)} timeoutMs=$loadingFailSafeTimeoutMs"
)
}
}
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
requestedUserInfoKeys.clear()
@@ -369,7 +409,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
accountSubscriptionsJob?.cancel()
currentAccount = publicKey
currentPrivateKey = privateKey
currentPrivateKey = resolvedPrivateKey
// <20> Устанавливаем аккаунт для DraftManager (загрузит черновики из SharedPreferences)
DraftManager.setAccount(publicKey)
@@ -380,7 +420,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
_requestsCount.value = 0
// 🚀 Pre-warm PBKDF2 ключ — чтобы к моменту дешифровки кэш был горячий
viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(privateKey) }
if (resolvedPrivateKey.isNotEmpty()) {
viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(resolvedPrivateKey) }
}
// Запускаем все подписки в одном родительском Job для отмены при смене аккаунта
accountSubscriptionsJob = viewModelScope.launch {
@@ -410,7 +452,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} else {
mapDialogListIncremental(
dialogsList = dialogsList,
privateKey = privateKey,
privateKey = resolvedPrivateKey,
cache = dialogsUiCache,
isRequestsFlow = false
)
@@ -418,10 +460,19 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
.filterNotNull()
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.catch { e ->
android.util.Log.e(TAG, "Dialogs flow failed in setAccount()", e)
rosettaDev1Log("Dialogs flow failed: ${e.message}")
if (_isLoading.value) _isLoading.value = false
emit(emptyList())
}
.collect { decryptedDialogs ->
_dialogs.value = decryptedDialogs
// 🚀 Убираем skeleton после первой загрузки
if (_isLoading.value) _isLoading.value = false
if (_isLoading.value) {
_isLoading.value = false
loadingFailSafeJob?.cancel()
}
// 🟢 Подписываемся на онлайн-статусы всех собеседников
// 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный
@@ -430,7 +481,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
decryptedDialogs.filter { !it.isSavedMessages }.map {
it.opponentKey
}
subscribeToOnlineStatuses(opponentsToSubscribe, privateKey)
subscribeToOnlineStatuses(opponentsToSubscribe, resolvedPrivateKey)
}
}
@@ -450,7 +501,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} else {
mapDialogListIncremental(
dialogsList = requestsList,
privateKey = privateKey,
privateKey = resolvedPrivateKey,
cache = requestsUiCache,
isRequestsFlow = true
)
@@ -498,6 +549,24 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
} // end accountSubscriptionsJob
accountSubscriptionsJob?.invokeOnCompletion { cause ->
if (cause != null && _isLoading.value) {
_isLoading.value = false
loadingFailSafeJob?.cancel()
android.util.Log.e(TAG, "accountSubscriptionsJob completed with error", cause)
rosettaDev1Log("accountSubscriptionsJob error: ${cause.message}")
}
}
}
fun forceStopLoading(reason: String) {
if (_isLoading.value) {
_isLoading.value = false
loadingFailSafeJob?.cancel()
android.util.Log.w(TAG, "forceStopLoading: $reason")
rosettaDev1Log("forceStopLoading: $reason")
}
}
/**
@@ -506,6 +575,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
*/
private fun subscribeToOnlineStatuses(opponentKeys: List<String>, privateKey: String) {
if (opponentKeys.isEmpty()) return
if (privateKey.isBlank()) return
// 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи!
val newKeys =

View File

@@ -1169,30 +1169,46 @@ fun GroupInfoScreen(
)
}
if (groupDescription.isNotBlank()) {
Spacer(modifier = Modifier.height(10.dp))
AppleEmojiText(
text = groupDescription,
color = Color.White.copy(alpha = 0.7f),
fontSize = 12.sp,
maxLines = 2,
overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = false
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
if (groupDescription.isNotBlank()) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = sectionColor
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
) {
AppleEmojiText(
text = groupDescription,
color = primaryText,
fontSize = 16.sp,
maxLines = 8,
overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = true
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Description",
color = Color(0xFF8E8E93),
fontSize = 13.sp
)
}
}
Divider(
color = borderColor,
thickness = 0.5.dp,
modifier = Modifier.fillMaxWidth()
)
}
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
color = sectionColor,
shape = RoundedCornerShape(12.dp)
modifier = Modifier.fillMaxWidth(),
color = sectionColor
) {
Column {
// Add Members
// Add Members — flat Telegram style, edge-to-edge, white text
Row(
modifier = Modifier
.fillMaxWidth()
@@ -1200,27 +1216,28 @@ fun GroupInfoScreen(
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(28.dp))
Text(
text = "Add Members",
color = primaryText,
fontSize = 16.sp,
modifier = Modifier.weight(1f)
)
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(groupMenuTrailingIconSize)
)
}
Divider(
color = borderColor,
thickness = 0.5.dp,
modifier = Modifier.padding(start = 16.dp)
modifier = Modifier.fillMaxWidth()
)
// Encryption Key
// Encryption Key — flat Telegram style, edge-to-edge, white text
Row(
modifier = Modifier
.fillMaxWidth()
@@ -1228,6 +1245,13 @@ fun GroupInfoScreen(
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Lock,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(28.dp))
Text(
text = "Encryption Key",
color = primaryText,

View File

@@ -14,6 +14,7 @@ import android.util.LruCache
import android.webkit.MimeTypeMap
import android.widget.VideoView
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.StartOffset
@@ -42,6 +43,8 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
@@ -96,6 +99,7 @@ import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.Locale
import kotlin.math.min
import kotlin.math.PI
import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.content.FileProvider
@@ -2027,6 +2031,76 @@ private fun formatVoiceDuration(seconds: Int): String {
return "$minutes:$rem"
}
private fun formatVoicePlaybackSpeedLabel(speed: Float): String {
val normalized = kotlin.math.round(speed * 10f) / 10f
return if (kotlin.math.abs(normalized - normalized.toInt().toFloat()) < 0.01f) {
"${normalized.toInt()}x"
} else {
"${normalized}x"
}
}
@Composable
private fun VoicePlaybackButtonBlob(
level: Float,
isOutgoing: Boolean,
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
val clampedLevel = level.coerceIn(0f, 1f)
val animatedLevel by animateFloatAsState(
targetValue = clampedLevel,
animationSpec = tween(durationMillis = 140),
label = "voice_blob_level"
)
val transition = rememberInfiniteTransition(label = "voice_blob_motion")
val pulse by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1420, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "voice_blob_pulse"
)
val blobColor =
if (isOutgoing) {
Color.White
} else if (isDarkTheme) {
Color(0xFF5DB8FF)
} else {
PrimaryBlue
}
Canvas(modifier = modifier) {
val center = Offset(x = size.width * 0.5f, y = size.height * 0.5f)
val buttonRadius = 20.dp.toPx() // Play button is 40dp.
val amp = animatedLevel.coerceIn(0f, 1f)
// Telegram-like: soft concentric glow, centered, no geometry distortion.
val r1 = buttonRadius + 4.2.dp.toPx() + amp * 4.0.dp.toPx() + pulse * 1.6.dp.toPx()
val r2 = buttonRadius + 2.6.dp.toPx() + amp * 2.9.dp.toPx() + pulse * 0.9.dp.toPx()
val r3 = buttonRadius + 1.3.dp.toPx() + amp * 1.8.dp.toPx() + pulse * 0.5.dp.toPx()
drawCircle(
color = blobColor.copy(alpha = (0.14f + amp * 0.08f).coerceAtMost(0.24f)),
radius = r1,
center = center
)
drawCircle(
color = blobColor.copy(alpha = (0.11f + amp * 0.06f).coerceAtMost(0.18f)),
radius = r2,
center = center
)
drawCircle(
color = blobColor.copy(alpha = (0.08f + amp * 0.05f).coerceAtMost(0.14f)),
radius = r3,
center = center
)
}
}
private fun formatDesktopCallDuration(durationSec: Int): String {
val minutes = durationSec / 60
val seconds = durationSec % 60
@@ -2178,6 +2252,9 @@ private fun VoiceAttachment(
val playbackIsPlaying by
(if (isActiveTrack) VoicePlaybackCoordinator.isPlaying else flowOf(false))
.collectAsState(initial = false)
val playbackSpeed by
(if (isActiveTrack) VoicePlaybackCoordinator.playbackSpeed else flowOf(1f))
.collectAsState(initial = 1f)
val isPlaying = isActiveTrack && playbackIsPlaying
val (previewDurationSecRaw, previewWavesRaw) =
@@ -2224,6 +2301,16 @@ private fun VoiceAttachment(
} else {
0f
}
val liveWaveLevel =
remember(isPlaying, progress, waves) {
if (!isPlaying || waves.isEmpty()) {
0f
} else {
val maxIndex = waves.lastIndex.coerceAtLeast(0)
val sampleIndex = (progress * maxIndex.toFloat()).toInt().coerceIn(0, maxIndex)
waves[sampleIndex].coerceIn(0f, 1f)
}
}
val timeText =
if (isActiveTrack && playbackDurationMs > 0) {
val leftSec = ((playbackDurationMs - playbackPositionMs).coerceAtLeast(0) / 1000)
@@ -2389,12 +2476,29 @@ private fun VoiceAttachment(
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier =
Modifier.size(40.dp)
.clip(CircleShape)
.background(actionBackground),
modifier = Modifier.size(40.dp),
contentAlignment = Alignment.Center
) {
if (downloadStatus == DownloadStatus.DOWNLOADED && isPlaying) {
VoicePlaybackButtonBlob(
level = liveWaveLevel,
isOutgoing = isOutgoing,
isDarkTheme = isDarkTheme,
modifier =
Modifier.requiredSize(64.dp).graphicsLayer {
// Blob lives strictly behind the button; keep button geometry untouched.
alpha = 0.96f
compositingStrategy = CompositingStrategy.Offscreen
}
)
}
Box(
modifier =
Modifier.size(40.dp)
.clip(CircleShape)
.background(actionBackground),
contentAlignment = Alignment.Center
) {
if (downloadStatus == DownloadStatus.DOWNLOADING ||
downloadStatus == DownloadStatus.DECRYPTING
) {
@@ -2432,6 +2536,7 @@ private fun VoiceAttachment(
}
}
}
}
}
Spacer(modifier = Modifier.width(10.dp))
@@ -2484,6 +2589,38 @@ private fun VoiceAttachment(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
if (isActiveTrack) {
val speedChipBackground =
if (isOutgoing) {
Color.White.copy(alpha = 0.2f)
} else if (isDarkTheme) {
Color(0xFF31435A)
} else {
Color(0xFFDCEBFD)
}
val speedChipTextColor = if (isOutgoing) Color.White else PrimaryBlue
Box(
modifier =
Modifier.clip(RoundedCornerShape(10.dp))
.background(speedChipBackground)
.clickable(
interactionSource =
remember { MutableInteractionSource() },
indication = null
) {
VoicePlaybackCoordinator.cycleSpeed()
}
.padding(horizontal = 6.dp, vertical = 2.dp),
contentAlignment = Alignment.Center
) {
Text(
text = formatVoicePlaybackSpeedLabel(playbackSpeed),
fontSize = 10.sp,
fontWeight = FontWeight.SemiBold,
color = speedChipTextColor
)
}
}
Text(
text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(),
fontSize = 11.sp,

View File

@@ -735,6 +735,8 @@ fun MessageBubble(
message.attachments.all {
it.type == AttachmentType.CALL
}
val hasVoiceAttachment =
message.attachments.any { it.type == AttachmentType.VOICE }
val isStandaloneGroupInvite =
message.attachments.isEmpty() &&
@@ -874,6 +876,21 @@ fun MessageBubble(
if (isCallMessage) {
// Звонки без фонового пузырька — у них свой контейнер внутри CallAttachment
Modifier
} else if (hasVoiceAttachment) {
// Для voice не клипуем содержимое пузыря:
// playback-blob может выходить за границы, как в Telegram.
Modifier.background(
color =
if (isSafeSystemMessage) {
if (isDarkTheme)
Color(0xFF2A2A2D)
else Color(0xFFF0F0F4)
} else {
bubbleColor
},
shape = bubbleShape
)
.padding(bubblePadding)
} else {
Modifier.clip(bubbleShape)
.then(
@@ -1094,7 +1111,8 @@ fun MessageBubble(
info = info,
touchX = touchX,
touchY = touchY,
view = textViewRef
view = textViewRef,
isOwnMessage = message.isOutgoing
)
}
} else null,
@@ -1209,7 +1227,8 @@ fun MessageBubble(
info = info,
touchX = touchX,
touchY = touchY,
view = textViewRef
view = textViewRef,
isOwnMessage = message.isOutgoing
)
}
} else null
@@ -1322,7 +1341,8 @@ fun MessageBubble(
info = info,
touchX = touchX,
touchY = touchY,
view = textViewRef
view = textViewRef,
isOwnMessage = message.isOutgoing
)
}
} else null,

View File

@@ -31,8 +31,10 @@ import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
@@ -62,6 +64,10 @@ class TextSelectionHelper {
private set
var selectedMessageId by mutableStateOf<String?>(null)
private set
// True when the selected message is the user's own (blue bubble) — used to pick
// white handles against the blue background instead of the default blue handles.
var isOwnMessage by mutableStateOf(false)
private set
var layoutInfo by mutableStateOf<LayoutInfo?>(null)
private set
var isActive by mutableStateOf(false)
@@ -99,8 +105,10 @@ class TextSelectionHelper {
info: LayoutInfo,
touchX: Int,
touchY: Int,
view: View?
view: View?,
isOwnMessage: Boolean = false
) {
this.isOwnMessage = isOwnMessage
val layout = info.layout
val localX = touchX - info.windowX
val localY = touchY - info.windowY
@@ -398,6 +406,10 @@ fun TextSelectionOverlay(
val handleSizePx = with(density) { HandleSize.toPx() }
val handleInsetPx = with(density) { HandleInset.toPx() }
val highlightCornerPx = with(density) { HighlightCorner.toPx() }
// Read isOwnMessage at composition level so Canvas properly invalidates on change.
// On own (blue) bubbles use the light-blue typing color — reads better than pure white.
val handleColor = if (helper.isOwnMessage) Color(0xFF54A9EB) else HandleColor
val highlightColor = if (helper.isOwnMessage) Color(0xFF54A9EB).copy(alpha = 0.45f) else HighlightColor
// Block predictive back gesture completely during text selection.
// BackHandler alone doesn't prevent the swipe animation on Android 13+
@@ -515,9 +527,16 @@ fun TextSelectionOverlay(
val padH = 3.dp.toPx()
val padV = 2.dp.toPx()
// Build a single unified Path from all per-line rects, then fill once.
// This avoids double-alpha artifacts where adjacent lines' padding overlaps.
val highlightPath = Path()
for (line in startLine..endLine) {
val lineTop = layout.getLineTop(line).toFloat() + offsetY - padV
val lineBottom = layout.getLineBottom(line).toFloat() + offsetY + padV
// Only pad the outer edges (top of first line, bottom of last line).
// Inner edges meet at lineBottom == nextLineTop so the union fills fully.
val topPad = if (line == startLine) padV else 0f
val bottomPad = if (line == endLine) padV else 0f
val lineTop = layout.getLineTop(line).toFloat() + offsetY - topPad
val lineBottom = layout.getLineBottom(line).toFloat() + offsetY + bottomPad
val left = if (line == startLine) {
layout.getPrimaryHorizontal(startOffset) + offsetX - padH
} else {
@@ -528,13 +547,14 @@ fun TextSelectionOverlay(
} else {
layout.getLineRight(line) + offsetX + padH
}
drawRoundRect(
color = HighlightColor,
topLeft = Offset(left, lineTop),
size = Size(right - left, lineBottom - lineTop),
cornerRadius = CornerRadius(highlightCornerPx)
highlightPath.addRoundRect(
RoundRect(
rect = Rect(left, lineTop, right, lineBottom),
cornerRadius = CornerRadius(highlightCornerPx)
)
)
}
drawPath(path = highlightPath, color = highlightColor)
val startHx = layout.getPrimaryHorizontal(startOffset) + offsetX
val startHy = layout.getLineBottom(startLine).toFloat() + offsetY
@@ -546,35 +566,35 @@ fun TextSelectionOverlay(
helper.endHandleX = endHx
helper.endHandleY = endHy
drawStartHandle(startHx, startHy, handleSizePx)
drawEndHandle(endHx, endHy, handleSizePx)
drawStartHandle(startHx, startHy, handleSizePx, handleColor)
drawEndHandle(endHx, endHy, handleSizePx, handleColor)
}
}
}
private fun DrawScope.drawStartHandle(x: Float, y: Float, size: Float) {
private fun DrawScope.drawStartHandle(x: Float, y: Float, size: Float, color: Color) {
val half = size / 2f
drawCircle(
color = HandleColor,
color = color,
radius = half,
center = Offset(x, y + half)
)
drawRect(
color = HandleColor,
color = color,
topLeft = Offset(x, y),
size = Size(half, half)
)
}
private fun DrawScope.drawEndHandle(x: Float, y: Float, size: Float) {
private fun DrawScope.drawEndHandle(x: Float, y: Float, size: Float, color: Color) {
val half = size / 2f
drawCircle(
color = HandleColor,
color = color,
radius = half,
center = Offset(x, y + half)
)
drawRect(
color = HandleColor,
color = color,
topLeft = Offset(x - half, y),
size = Size(half, half)
)

View File

@@ -8,6 +8,12 @@ import android.os.Build
import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import com.airbnb.lottie.LottieProperty
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.rememberLottieDynamicProperties
import com.airbnb.lottie.compose.rememberLottieDynamicProperty
import com.airbnb.lottie.compose.rememberLottieComposition
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
@@ -42,6 +48,7 @@ import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.toArgb
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
import androidx.compose.ui.input.pointer.pointerInput
@@ -72,6 +79,7 @@ import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.R
import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.components.AppleEmojiTextField
import com.rosetta.messenger.ui.components.AvatarImage
@@ -225,130 +233,83 @@ private fun TelegramVoiceDeleteIndicator(
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
val density = LocalDensity.current
val progress = cancelProgress.coerceIn(0f, 1f)
val appear = FastOutSlowInEasing.transform(progress)
val openPhase = FastOutSlowInEasing.transform((progress / 0.45f).coerceIn(0f, 1f))
val closePhase = FastOutSlowInEasing.transform(((progress - 0.55f) / 0.45f).coerceIn(0f, 1f))
val lidAngle = -26f * openPhase * (1f - closePhase)
val dotFlight = FastOutSlowInEasing.transform((progress / 0.82f).coerceIn(0f, 1f))
// Ensure red dot is clearly visible first; trash appears with a delayed phase.
val trashStart = 0.28f
val reveal = ((progress - trashStart) / (1f - trashStart)).coerceIn(0f, 1f)
val lottieProgress = FastOutSlowInEasing.transform(reveal)
val lottieAlpha = FastOutSlowInEasing.transform(reveal)
val composition by rememberLottieComposition(
LottieCompositionSpec.RawRes(R.raw.chat_audio_record_delete_2)
)
val dangerColor = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D)
val dotStartX = with(density) { (-8).dp.toPx() }
val dotEndX = with(density) { 0.dp.toPx() }
val dotEndY = with(density) { 6.dp.toPx() }
val dotX = lerpFloat(dotStartX, dotEndX, dotFlight)
val dotY = dotEndY * dotFlight * dotFlight
val dotScale = (1f - 0.72f * dotFlight).coerceAtLeast(0f)
val dotAlpha = (1f - dotFlight).coerceIn(0f, 1f)
val neutralColor = if (isDarkTheme) Color(0xFF8EA2B4) else Color(0xFF8FA2B3)
val panelBlendColor = if (isDarkTheme) Color(0xFF1A2A3A) else Color(0xFFE8F2FD)
val dynamicProperties = rememberLottieDynamicProperties(
rememberLottieDynamicProperty(
property = LottieProperty.COLOR,
value = dangerColor.toArgb(),
keyPath = arrayOf("Cup Red", "**")
),
rememberLottieDynamicProperty(
property = LottieProperty.COLOR,
value = dangerColor.toArgb(),
keyPath = arrayOf("Box Red", "**")
),
rememberLottieDynamicProperty(
property = LottieProperty.COLOR,
value = neutralColor.toArgb(),
keyPath = arrayOf("Cup Grey", "**")
),
rememberLottieDynamicProperty(
property = LottieProperty.COLOR,
value = neutralColor.toArgb(),
keyPath = arrayOf("Box Grey", "**")
),
rememberLottieDynamicProperty(
property = LottieProperty.COLOR,
value = panelBlendColor.toArgb(),
keyPath = arrayOf("Line 1", "**")
),
rememberLottieDynamicProperty(
property = LottieProperty.COLOR,
value = panelBlendColor.toArgb(),
keyPath = arrayOf("Line 2", "**")
),
rememberLottieDynamicProperty(
property = LottieProperty.COLOR,
value = panelBlendColor.toArgb(),
keyPath = arrayOf("Line 3", "**")
)
)
Box(
modifier = modifier.size(28.dp),
contentAlignment = Alignment.Center
) {
// Single recording dot (no duplicate red indicators).
RecordBlinkDot(
isDarkTheme = isDarkTheme,
modifier = Modifier.graphicsLayer {
alpha = 1f - appear
scaleX = 1f - 0.14f * appear
scaleY = 1f - 0.14f * appear
alpha = 1f - lottieAlpha
scaleX = 1f - 0.12f * lottieAlpha
scaleY = 1f - 0.12f * lottieAlpha
}
)
Canvas(
modifier = Modifier
.matchParentSize()
.graphicsLayer {
alpha = appear
scaleX = 0.84f + 0.16f * appear
scaleY = 0.84f + 0.16f * appear
}
) {
val stroke = 1.7.dp.toPx()
val cx = size.width / 2f
val bodyW = size.width * 0.36f
val bodyH = size.height * 0.34f
val bodyLeft = cx - bodyW / 2f
val bodyTop = size.height * 0.45f
val bodyRadius = bodyW * 0.16f
val bodyRight = bodyLeft + bodyW
drawRoundRect(
color = dangerColor,
topLeft = Offset(bodyLeft, bodyTop),
size = androidx.compose.ui.geometry.Size(bodyW, bodyH),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(bodyRadius),
style = androidx.compose.ui.graphics.drawscope.Stroke(
width = stroke,
cap = androidx.compose.ui.graphics.StrokeCap.Round,
join = androidx.compose.ui.graphics.StrokeJoin.Round
)
if (composition != null) {
LottieAnimation(
composition = composition,
progress = { lottieProgress },
dynamicProperties = dynamicProperties,
modifier = Modifier
.matchParentSize()
.graphicsLayer {
alpha = lottieAlpha
scaleX = 0.92f + 0.08f * lottieAlpha
scaleY = 0.92f + 0.08f * lottieAlpha
}
)
val slatYStart = bodyTop + bodyH * 0.18f
val slatYEnd = bodyTop + bodyH * 0.82f
drawLine(
color = dangerColor,
start = Offset(cx - bodyW * 0.18f, slatYStart),
end = Offset(cx - bodyW * 0.18f, slatYEnd),
strokeWidth = stroke * 0.85f,
cap = androidx.compose.ui.graphics.StrokeCap.Round
)
drawLine(
color = dangerColor,
start = Offset(cx + bodyW * 0.18f, slatYStart),
end = Offset(cx + bodyW * 0.18f, slatYEnd),
strokeWidth = stroke * 0.85f,
cap = androidx.compose.ui.graphics.StrokeCap.Round
)
val rimY = bodyTop - 2.4.dp.toPx()
drawLine(
color = dangerColor,
start = Offset(bodyLeft - bodyW * 0.09f, rimY),
end = Offset(bodyRight + bodyW * 0.09f, rimY),
strokeWidth = stroke,
cap = androidx.compose.ui.graphics.StrokeCap.Round
)
val lidY = rimY - 1.4.dp.toPx()
val lidLeft = bodyLeft - bodyW * 0.05f
val lidRight = bodyRight + bodyW * 0.05f
val lidPivot = Offset(bodyLeft + bodyW * 0.22f, lidY)
rotate(
degrees = lidAngle,
pivot = lidPivot
) {
drawLine(
color = dangerColor,
start = Offset(lidLeft, lidY),
end = Offset(lidRight, lidY),
strokeWidth = stroke,
cap = androidx.compose.ui.graphics.StrokeCap.Round
)
drawLine(
color = dangerColor,
start = Offset(cx - bodyW * 0.1f, lidY - 2.dp.toPx()),
end = Offset(cx + bodyW * 0.1f, lidY - 2.dp.toPx()),
strokeWidth = stroke,
cap = androidx.compose.ui.graphics.StrokeCap.Round
)
}
}
Box(
modifier = Modifier
.size(10.dp)
.graphicsLayer {
translationX = dotX
translationY = dotY
alpha = if (progress > 0f) dotAlpha else 0f
scaleX = dotScale
scaleY = dotScale
}
.clip(CircleShape)
.background(dangerColor)
)
}
}
@@ -2348,12 +2309,13 @@ fun MessageInputBar(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = 8.dp, vertical = 8.dp)
.padding(horizontal = 12.dp, vertical = 8.dp)
.zIndex(2f)
.onGloballyPositioned { coordinates ->
recordingInputRowHeightPx = coordinates.size.height
recordingInputRowY = coordinates.positionInWindow().y
}
},
contentAlignment = Alignment.BottomStart
) {
val isLockedOrPaused = recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED
// iOS parity (VoiceRecordingOverlay.applyCurrentTransforms):
@@ -2371,7 +2333,7 @@ fun MessageInputBar(
targetState = isLockedOrPaused,
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.height(40.dp)
.padding(end = recordingActionInset), // keep panel under large circle (Telegram-like overlap)
transitionSpec = {
fadeIn(tween(200)) togetherWith fadeOut(tween(200))
@@ -2431,6 +2393,16 @@ fun MessageInputBar(
(dragCancelProgress * 0.85f).coerceIn(0f, 1f)
)
)
val collapseToTrash =
FastOutSlowInEasing.transform(
((leftDeleteProgress - 0.14f) / 0.86f).coerceIn(0f, 1f)
)
val collapseShiftPx =
with(density) { (-58).dp.toPx() * collapseToTrash }
val collapseScale = 1f - 0.14f * collapseToTrash
val collapseAlpha = 1f - 0.55f * collapseToTrash
val timerToTrashShiftPx = collapseShiftPx * 0.35f
val timerSpacerDp = lerpFloat(10f, 2f, collapseToTrash).dp
Row(
modifier = Modifier
.fillMaxSize()
@@ -2465,12 +2437,18 @@ fun MessageInputBar(
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.graphicsLayer {
alpha = recordUiAlpha * (1f - leftDeleteProgress * 0.22f)
translationX = with(density) { recordUiShift.toPx() }
alpha =
recordUiAlpha *
(1f - leftDeleteProgress * 0.22f) *
collapseAlpha
translationX =
with(density) { recordUiShift.toPx() } + timerToTrashShiftPx
scaleX = collapseScale
scaleY = collapseScale
}
)
Spacer(modifier = Modifier.width(10.dp))
Spacer(modifier = Modifier.width(timerSpacerDp))
// Slide to cancel
SlideToCancel(
@@ -2479,8 +2457,14 @@ fun MessageInputBar(
modifier = Modifier
.weight(1f)
.graphicsLayer {
alpha = recordUiAlpha * (1f - leftDeleteProgress)
translationX = with(density) { recordUiShift.toPx() }
alpha =
recordUiAlpha *
(1f - leftDeleteProgress) *
collapseAlpha
translationX =
with(density) { recordUiShift.toPx() } + collapseShiftPx
scaleX = collapseScale
scaleY = collapseScale
}
)
}
@@ -2614,12 +2598,20 @@ fun MessageInputBar(
}
}
}
val gestureCaptureOnly = isRecordingPanelComposed && keepMicGestureCapture
if (!isRecordingPanelComposed || keepMicGestureCapture) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = 12.dp, vertical = 8.dp)
.then(
if (gestureCaptureOnly) {
Modifier.requiredHeight(0.dp)
} else {
Modifier
.heightIn(min = 48.dp)
.padding(horizontal = 12.dp, vertical = 8.dp)
}
)
.zIndex(1f)
.graphicsLayer {
// Keep gesture layer alive during hold, but never show base input under recording panel.
@@ -2908,9 +2900,11 @@ fun MessageInputBar(
didCancelHaptic = true
view.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP)
}
if (rawDx <= -cancelDragThresholdPx) {
// Trigger cancel immediately while finger is still down,
// using the same threshold as release-cancel behavior.
if (rawDx <= -releaseCancelThresholdPx) {
if (INPUT_JUMP_LOG_ENABLED) inputJumpLog(
"gesture CANCEL dx=${rawDx.toInt()} threshold=${cancelDragThresholdPx.toInt()} mode=$recordMode"
"gesture CANCEL dx=${rawDx.toInt()} threshold=${releaseCancelThresholdPx.toInt()} mode=$recordMode"
)
cancelVoiceRecordingWithAnimation("slide-cancel")
finished = true

View File

@@ -139,6 +139,8 @@ fun SwipeBackContainer(
propagateBackgroundProgress: Boolean = true,
deferToChildren: Boolean = false,
enterAnimation: SwipeBackEnterAnimation = SwipeBackEnterAnimation.Fade,
// Return true to cancel the swipe — screen bounces back and onBack is NOT called.
onInterceptSwipeBack: () -> Boolean = { false },
content: @Composable () -> Unit
) {
// 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time.
@@ -523,6 +525,32 @@ fun SwipeBackContainer(
)
if (shouldComplete) {
// Intercept: if owner handled back locally (e.g. clear
// message selection), bounce back without exiting.
if (onInterceptSwipeBack()) {
dismissKeyboard()
offsetAnimatable.animateTo(
targetValue = 0f,
animationSpec =
tween(
durationMillis =
ANIMATION_DURATION_EXIT,
easing =
TelegramEasing
),
block = {
updateSharedSwipeProgress(
progress =
value /
screenWidthPx,
active = true
)
}
)
dragOffset = 0f
clearSharedSwipeProgressIfOwner()
return@launch
}
offsetAnimatable.animateTo(
targetValue = screenWidthPx,
animationSpec =

File diff suppressed because one or more lines are too long