Compare commits

...

2 Commits

Author SHA1 Message Date
7d4b9a8fc4 Релиз 1.4.5: стабилизация звонков, фиксы UI
All checks were successful
Android Kernel Build / build (push) Successful in 19m24s
- Звонок не сбрасывается при переподключении WebSocket
- Убрано мелькание "Unknown" при завершении (флаг resetting)
- Фикс placeholderColor в ChatDetailScreen (release build)
- ReleaseNotes.kt обновлён с детальным описанием всех изменений
2026-04-04 15:52:54 +05:00
6886a6cef1 Доработки звонков и чатов: typing, UI и стабильность 2026-04-04 15:17:47 +05:00
21 changed files with 914 additions and 367 deletions

View File

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

View File

@@ -81,22 +81,29 @@ class IncomingCallActivity : ComponentActivity() {
LaunchedEffect(callState.phase) {
callLog("phase changed: ${callState.phase}")
if (callState.phase == CallPhase.INCOMING) wasIncoming = true
// Закрываем только если звонок реально начался и потом завершился
// Закрываем только когда звонок завершился
if (callState.phase == CallPhase.IDLE && wasIncoming) {
callLog("IDLE after INCOMING → finish()")
finish()
} else if (callState.phase == CallPhase.CONNECTING ||
callState.phase == CallPhase.ACTIVE) {
callLog("${callState.phase} → openMainActivity + finish")
openMainActivity()
finish()
}
// НЕ закрываемся при CONNECTING/ACTIVE — остаёмся на экране звонка
// IncomingCallActivity показывает полный CallOverlay, не нужно переходить в MainActivity
}
// Показываем INCOMING даже если CallManager ещё в IDLE (push раньше WebSocket)
val displayState = if (callState.phase == CallPhase.IDLE) {
// Показываем INCOMING в IDLE только до первого реального входящего состояния.
// Иначе после Decline/END на мгновение мелькает "Unknown".
val shouldShowProvisionalIncoming =
callState.phase == CallPhase.IDLE &&
!wasIncoming &&
(callState.peerPublicKey.isNotBlank() ||
callState.peerTitle.isNotBlank() ||
callState.peerUsername.isNotBlank())
val displayState = if (shouldShowProvisionalIncoming) {
callState.copy(phase = CallPhase.INCOMING, statusText = "Incoming call...")
} else callState
} else {
callState
}
RosettaAndroidTheme(darkTheme = true) {
CallOverlay(
@@ -108,16 +115,10 @@ class IncomingCallActivity : ComponentActivity() {
if (callState.phase == CallPhase.INCOMING) {
val result = CallManager.acceptIncomingCall()
callLog("acceptIncomingCall result=$result")
if (result == CallActionResult.STARTED) {
openMainActivity()
finish()
}
// Остаёмся на IncomingCallActivity — она покажет CONNECTING → ACTIVE
} else {
callLog("onAccept: phase not INCOMING yet, waiting...")
// WebSocket ещё не доставил CALL — открываем MainActivity,
// она подождёт и примет звонок
openMainActivity()
finish()
callLog("onAccept: phase=${callState.phase}, trying accept anyway")
CallManager.acceptIncomingCall()
}
},
onDecline = {

View File

@@ -1122,18 +1122,13 @@ fun MainScreen(
accountName = accountName,
accountUsername = accountUsername,
accountVerified = accountVerified,
accountPhone = accountPhone,
accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey,
privateKeyHash = privateKeyHash,
onToggleTheme = onToggleTheme,
onProfileClick = { pushScreen(Screen.Profile) },
onNewGroupClick = {
pushScreen(Screen.GroupSetup)
},
onContactsClick = {
// TODO: Navigate to contacts
},
onCallsClick = {
// TODO: Navigate to calls
},
@@ -1152,9 +1147,6 @@ fun MainScreen(
)
},
onSettingsClick = { pushScreen(Screen.Profile) },
onInviteFriendsClick = {
// TODO: Share invite link
},
onSearchClick = { pushScreen(Screen.Search) },
onRequestsClick = { pushScreen(Screen.Requests) },
onNewChat = {
@@ -1166,7 +1158,6 @@ fun MainScreen(
onStartCall = { user ->
startCallWithPermission(user)
},
backgroundBlurColorId = backgroundBlurColorId,
pinnedChats = pinnedChats,
onTogglePin = { opponentKey ->
mainScreenScope.launch { prefsManager.togglePinChat(opponentKey) }

View File

@@ -46,19 +46,24 @@ object DraftManager {
fun saveDraft(opponentKey: String, text: String) {
if (currentAccount.isEmpty()) return
val trimmed = text.trim()
val currentDrafts = _drafts.value.toMutableMap()
val hasContent = text.any { !it.isWhitespace() }
val existing = _drafts.value[opponentKey]
if (trimmed.isEmpty()) {
if (!hasContent) {
if (existing == null) return
val currentDrafts = _drafts.value.toMutableMap()
// Удаляем черновик если текст пустой
currentDrafts.remove(opponentKey)
prefs?.edit()?.remove(prefKey(opponentKey))?.apply()
_drafts.value = currentDrafts
} else {
currentDrafts[opponentKey] = trimmed
prefs?.edit()?.putString(prefKey(opponentKey), trimmed)?.apply()
// Ничего не делаем, если текст не изменился — это частый путь при больших вставках.
if (existing == text) return
val currentDrafts = _drafts.value.toMutableMap()
currentDrafts[opponentKey] = text
prefs?.edit()?.putString(prefKey(opponentKey), text)?.apply()
_drafts.value = currentDrafts
}
_drafts.value = currentDrafts
}
/** Получить черновик для диалога */

View File

@@ -18,15 +18,31 @@ object ReleaseNotes {
Update v$VERSION_PLACEHOLDER
Звонки
- Обновлён протокол WebRTC: publicKey и deviceId в каждом пакете
- Полноэкранный входящий звонок на экране блокировки
- Фикс бесконечного "Exchanging keys" при принятии звонка
- Фикс краша ForegroundService при исходящем звонке
- Кастомный WebRTC с E2EE теперь работает в CI-сборках
- Полноэкранный входящий звонок (IncomingCallActivity) поверх экрана блокировки с кнопками Принять/Отклонить
- Обновлён протокол WebRTC: publicKey и deviceId в каждом пакете (совместимость с новым сервером)
- Звонок больше не сбрасывается при переподключении WebSocket
- Исправлен бесконечный статус "Exchanging keys" — KEY_EXCHANGE отправляется с ретраем до 6 сек
- Автоматическая привязка аккаунта при принятии звонка из push-уведомления
- Исправлен краш ForegroundService при исходящем звонке (safeStopForeground)
- Убрано мелькание "Unknown" при завершении звонка
- Кнопка Decline теперь работает во всех фазах звонка
- Баннер активного звонка теперь отображается внутри диалога
- Дедупликация push + WebSocket сигналов (без мерцания уведомлений)
- Защита от фантомных звонков при принятии на другом устройстве
- Корректное освобождение PeerConnection (dispose) при завершении звонка
- Кастомный WebRTC AAR с E2EE добавлен в репозиторий для CI-сборок
- Диагностические логи звонков и уведомлений в rosettadev1
Уведомления
- Аватарки и имена в уведомлениях
- Настройка отключения аватарок в уведомлениях
- Аватарки и имена пользователей в уведомлениях о сообщениях и звонках
- Настройка включения/выключения аватарок в уведомлениях (Notifications → Avatars in Notifications)
- Сохранение FCM токена в rosettadev1 для диагностики
- Поддержка tokenType и deviceId в push-подписке
Интерфейс
- Ограничение масштаба шрифта до 1.3x — вёрстка не ломается на телефонах с огромным текстом
- Новые обои: Light 1-3 для светлой темы, Dark 1-3 для тёмной темы
- Убраны старые обои, исправлено растяжение превью обоев
""".trimIndent()
fun getNotice(version: String): String =

View File

@@ -48,13 +48,18 @@ class CallForegroundService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val action = intent?.action ?: ACTION_SYNC
CallManager.initialize(applicationContext)
notifLog("onStartCommand action=$action phase=${CallManager.state.value.phase}")
val phaseNow = CallManager.state.value.phase
notifLog("onStartCommand action=$action phase=$phaseNow")
when (action) {
ACTION_STOP -> {
notifLog("ACTION_STOP → stopSelf")
safeStopForeground()
return START_NOT_STICKY
if (phaseNow == CallPhase.IDLE) {
notifLog("ACTION_STOP → stopSelf")
safeStopForeground()
return START_NOT_STICKY
}
// Может прилететь поздний STOP от прошлой сессии, не глушим живой звонок.
notifLog("ACTION_STOP ignored: phase=$phaseNow")
}
ACTION_END -> {
notifLog("ACTION_END → endCall")

View File

@@ -96,6 +96,7 @@ object CallManager {
private const val PROTOCOL_LOG_TAIL_LINES = 180
private const val MAX_LOG_PREFIX = 180
private const val INCOMING_RING_TIMEOUT_MS = 45_000L
private const val CONNECTING_TIMEOUT_MS = 30_000L
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val secureRandom = SecureRandom()
@@ -125,6 +126,7 @@ object CallManager {
private var protocolStateJob: Job? = null
private var disconnectResetJob: Job? = null
private var incomingRingTimeoutJob: Job? = null
private var connectingTimeoutJob: Job? = null
private var signalWaiter: ((Packet) -> Unit)? = null
private var webRtcWaiter: ((Packet) -> Unit)? = null
@@ -146,6 +148,7 @@ object CallManager {
private var lastLocalOfferFingerprint: String = ""
private var e2eeRebindJob: Job? = null
@Volatile private var resetting = false
private var iceServers: List<PeerConnection.IceServer> = emptyList()
fun initialize(context: Context) {
@@ -173,7 +176,26 @@ object CallManager {
ProtocolManager.requestIceServers()
}
ProtocolState.DISCONNECTED -> {
resetSession(reason = "Disconnected", notifyPeer = false)
// Не сбрасываем звонок при переподключении WebSocket —
// push мог разбудить процесс и вызвать reconnect,
// а звонок уже в INCOMING/CONNECTING
val phase = _state.value.phase
if (phase == CallPhase.IDLE) {
val hasResidualSession =
callSessionId.isNotBlank() ||
roomId.isNotBlank() ||
role != null ||
_state.value.peerPublicKey.isNotBlank() ||
sharedKeyBytes != null ||
peerConnection != null
if (hasResidualSession) {
resetSession(reason = "Disconnected", notifyPeer = false)
} else {
breadcrumb("DISCONNECTED in IDLE — skip reset (no active session)")
}
} else {
breadcrumb("DISCONNECTED but phase=$phase — keeping call alive")
}
}
else -> Unit
}
@@ -288,6 +310,7 @@ object CallManager {
statusText = "Connecting..."
)
}
armConnectingTimeout("acceptIncomingCall")
// Отправляем KEY_EXCHANGE — если WebSocket не подключен, ждём и ретраим
scope.launch {
@@ -489,6 +512,7 @@ object CallManager {
statusText = "Connecting..."
)
}
armConnectingTimeout("signal:create_room")
ensurePeerConnectionAndOffer()
}
SignalType.ACTIVE_CALL -> Unit
@@ -549,6 +573,7 @@ object CallManager {
createRoomSent = true
}
updateState { it.copy(phase = CallPhase.CONNECTING) }
armConnectingTimeout("key_exchange:caller")
return
}
@@ -565,6 +590,7 @@ object CallManager {
setupE2EE(sharedKey)
breadcrumb("KE: CALLEE — E2EE ready, waiting for CREATE_ROOM")
updateState { it.copy(keyCast = sharedKey, phase = CallPhase.CONNECTING) }
armConnectingTimeout("key_exchange:callee")
}
}
@@ -844,6 +870,7 @@ object CallManager {
}
private fun onCallConnected() {
disarmConnectingTimeout("connected")
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CONNECTED) }
val keyFp = sharedKeyBytes?.fingerprintHex(8) ?: "null"
breadcrumb("CONNECTED: e2eeAvail=$e2eeAvailable keyFp=$keyFp sEnc=${senderEncryptors.size} rDec=${receiverDecryptors.size} nativeLoaded=${XChaCha20E2EE.nativeLoaded}")
@@ -862,6 +889,31 @@ object CallManager {
}
}
private fun armConnectingTimeout(origin: String) {
connectingTimeoutJob?.cancel()
connectingTimeoutJob =
scope.launch {
delay(CONNECTING_TIMEOUT_MS)
val snapshot = _state.value
if (snapshot.phase != CallPhase.CONNECTING) return@launch
breadcrumb(
"CONNECTING TIMEOUT origin=$origin role=$role room=${roomId.take(12)} " +
"keyExSent=$keyExchangeSent createRoomSent=$createRoomSent offerSent=$offerSent " +
"remoteDesc=$remoteDescriptionSet peer=${snapshot.peerPublicKey.take(8)}"
)
resetSession(reason = "Connecting timeout", notifyPeer = false)
}
breadcrumb("CONNECTING watchdog armed origin=$origin timeoutMs=$CONNECTING_TIMEOUT_MS")
}
private fun disarmConnectingTimeout(origin: String) {
if (connectingTimeoutJob != null) {
connectingTimeoutJob?.cancel()
connectingTimeoutJob = null
breadcrumb("CONNECTING watchdog disarmed origin=$origin")
}
}
private fun setPeer(publicKey: String, title: String, username: String) {
updateState {
it.copy(
@@ -920,6 +972,8 @@ object CallManager {
}
private fun resetSession(reason: String?, notifyPeer: Boolean) {
resetting = true
disarmConnectingTimeout("resetSession")
breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}")
breadcrumbState("resetSession")
val snapshot = _state.value
@@ -932,6 +986,15 @@ object CallManager {
dst = peerToNotify
)
}
// Отменяем все jobs ПЕРВЫМИ — чтобы они не вызвали updateState с пустым state
durationJob?.cancel()
durationJob = null
e2eeRebindJob?.cancel()
e2eeRebindJob = null
disconnectResetJob?.cancel()
disconnectResetJob = null
incomingRingTimeoutJob?.cancel()
incomingRingTimeoutJob = null
// Play end call sound, then stop all
if (wasActive) {
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.END_CALL) }
@@ -956,22 +1019,15 @@ object CallManager {
lastPeerSharedPublicHex = ""
lastRemoteOfferFingerprint = ""
lastLocalOfferFingerprint = ""
e2eeRebindJob?.cancel()
e2eeRebindJob = null
localPrivateKey = null
localPublicKey = null
callSessionId = ""
callStartedAtMs = 0L
durationJob?.cancel()
durationJob = null
disconnectResetJob?.cancel()
disconnectResetJob = null
incomingRingTimeoutJob?.cancel()
incomingRingTimeoutJob = null
setSpeakerphone(false)
// Останавливаем ForegroundService ДО сброса state — иначе "Unknown" мелькает
// Останавливаем ForegroundService и сбрасываем state
appContext?.let { CallForegroundService.stop(it) }
_state.value = CallUiState()
resetting = false
}
private fun resetRtcObjects() {
@@ -1440,11 +1496,11 @@ object CallManager {
}
private fun updateState(reducer: (CallUiState) -> CallUiState) {
if (resetting) return // Не синхронизируем во время resetSession — иначе "Unknown" мелькает
val old = _state.value
_state.update(reducer)
val newState = _state.value
// Синхронизируем ForegroundService при смене фазы или имени
// Не синхронизируем при IDLE — resetSession уже вызывает CallForegroundService.stop()
if (newState.phase != CallPhase.IDLE &&
(newState.phase != old.phase || newState.displayName != old.displayName)) {
appContext?.let { ctx ->

View File

@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.*
import okio.ByteString
import java.util.Locale
import java.util.concurrent.TimeUnit
/**
@@ -36,12 +37,131 @@ class Protocol(
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
private const val HEARTBEAT_OK_LOG_THROTTLE_MS = 30_000L
private const val HEX_PREVIEW_BYTES = 64
private const val TEXT_PREVIEW_CHARS = 80
}
private fun log(message: String) {
// TEMPORARY: Enable logging for debugging PacketUserInfo
logger(message)
}
private fun packetName(packetId: Int): String =
when (packetId) {
0x00 -> "HANDSHAKE"
0x01 -> "USER_INFO"
0x02 -> "RESULT"
0x03 -> "SEARCH"
0x04 -> "ONLINE_SUBSCRIBE"
0x05 -> "ONLINE_STATE"
0x06 -> "MESSAGE"
0x07 -> "READ"
0x08 -> "DELIVERY"
0x09 -> "DEVICE_NEW"
0x0A -> "REQUEST_UPDATE"
0x0B -> "TYPING"
0x0F -> "REQUEST_TRANSPORT"
0x10 -> "PUSH_NOTIFICATION"
0x11 -> "GROUP_CREATE"
0x12 -> "GROUP_INFO"
0x13 -> "GROUP_INVITE_INFO"
0x14 -> "GROUP_JOIN"
0x15 -> "GROUP_LEAVE"
0x16 -> "GROUP_BAN"
0x17 -> "DEVICE_LIST"
0x18 -> "DEVICE_RESOLVE"
0x19 -> "SYNC"
0x1A -> "SIGNAL_PEER"
0x1B -> "WEBRTC"
0x1C -> "ICE_SERVERS"
else -> "UNKNOWN"
}
private fun shortKey(value: String, visible: Int = 8): String {
val raw = value.trim()
if (raw.isBlank()) return "<empty>"
if (raw.length <= visible) return raw
return "${raw.take(visible)}"
}
private fun shortText(value: String, limit: Int = TEXT_PREVIEW_CHARS): String {
val normalized = value.replace('\n', ' ').replace('\r', ' ').trim()
if (normalized.isBlank()) return "<empty>"
return if (normalized.length <= limit) normalized else "${normalized.take(limit)}"
}
private fun hexPreview(bytes: ByteArray, limit: Int = HEX_PREVIEW_BYTES): String {
return bytes
.take(limit)
.joinToString(" ") { b -> String.format(Locale.US, "%02X", b.toInt() and 0xFF) } +
if (bytes.size > limit) "" else ""
}
private fun packetSummary(packet: Packet): String {
return when (packet) {
is PacketHandshake ->
"state=${packet.handshakeState} proto=${packet.protocolVersion} hb=${packet.heartbeatInterval}s " +
"pub=${shortKey(packet.publicKey, 12)} privLen=${packet.privateKey.length} " +
"deviceId=${shortKey(packet.device.deviceId, 12)} device='${shortText(packet.device.deviceName, 40)}' " +
"os='${shortText(packet.device.deviceOs, 32)}'"
is PacketResult ->
"code=${packet.resultCode}"
is PacketSearch ->
"query='${shortText(packet.search, 48)}' users=${packet.users.size} privLen=${packet.privateKey.length}"
is PacketOnlineSubscribe ->
"keys=${packet.publicKeys.size} first=${packet.publicKeys.firstOrNull()?.let { shortKey(it) } ?: "<none>"} " +
"privLen=${packet.privateKey.length}"
is PacketOnlineState ->
"entries=${packet.publicKeysState.size} first=${packet.publicKeysState.firstOrNull()?.let { "${shortKey(it.publicKey)}:${it.state}" } ?: "<none>"}"
is PacketMessage ->
"id=${shortKey(packet.messageId, 12)} from=${shortKey(packet.fromPublicKey)} to=${shortKey(packet.toPublicKey)} " +
"ts=${packet.timestamp} contentLen=${packet.content.length} chachaLen=${packet.chachaKey.length} aesLen=${packet.aesChachaKey.length} " +
"att=${packet.attachments.size}" +
if (packet.attachments.isNotEmpty()) {
" attTypes=${packet.attachments.joinToString(",") { it.type.name }}"
} else {
""
}
is PacketRead ->
"from=${shortKey(packet.fromPublicKey)} to=${shortKey(packet.toPublicKey)} privLen=${packet.privateKey.length}"
is PacketDelivery ->
"id=${shortKey(packet.messageId, 12)} to=${shortKey(packet.toPublicKey)}"
is PacketTyping ->
"from=${shortKey(packet.fromPublicKey)} to=${shortKey(packet.toPublicKey)} privLen=${packet.privateKey.length}"
is PacketPushNotification ->
"action=${packet.action} tokenType=${packet.tokenType} tokenLen=${packet.notificationsToken.length} " +
"deviceId=${shortKey(packet.deviceId, 12)}"
is PacketSync ->
"status=${packet.status} ts=${packet.timestamp}"
is PacketSignalPeer ->
"type=${packet.signalType} src=${shortKey(packet.src)} dst=${shortKey(packet.dst)} " +
"sharedLen=${packet.sharedPublic.length} room=${shortKey(packet.roomId, 12)}"
is PacketWebRTC ->
"type=${packet.signalType} sdpLen=${packet.sdpOrCandidate.length} " +
"pk=${shortKey(packet.publicKey)} device=${shortKey(packet.deviceId, 12)} " +
"preview='${shortText(packet.sdpOrCandidate, 64)}'"
is PacketIceServers ->
"count=${packet.iceServers.size} firstUrl='${packet.iceServers.firstOrNull()?.url?.let { shortText(it, 40) } ?: "<none>"}'"
is PacketRequestTransport ->
"requestTransport"
is PacketDeviceResolve ->
"deviceId=${shortKey(packet.deviceId, 12)} solution=${packet.solution}"
is PacketDeviceList ->
"devices=${packet.devices.size}"
is PacketDeviceNew ->
"ip='${shortText(packet.ipAddress, 32)}' deviceId=${shortKey(packet.device.deviceId, 12)} " +
"device='${shortText(packet.device.deviceName, 32)}' os='${shortText(packet.device.deviceOs, 24)}'"
is PacketUserInfo ->
"username='${shortText(packet.username, 20)}' title='${shortText(packet.title, 24)}' privLen=${packet.privateKey.length}"
else ->
packet::class.java.simpleName
}
}
private fun describePacket(packet: Packet, bytes: Int): String {
val packetId = packet.getPacketId()
return "id=0x${packetId.toString(16).uppercase(Locale.ROOT)}(${packetId}) name=${packetName(packetId)} bytes=$bytes ${packetSummary(packet)}"
}
private val client = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS)
@@ -486,12 +606,9 @@ class Protocol(
private fun sendPacketDirect(packet: Packet) {
val stream = packet.send()
val data = stream.getStream()
log("📤 Sending packet: ${packet.getPacketId()} (${data.size} bytes)")
// Debug: log first 50 bytes as hex
val hexDump = data.take(50).joinToString(" ") { String.format("%02X", it) }
log(" Hex: $hexDump${if (data.size > 50) "..." else ""}")
log("➡️ CLIENT -> SERVER ${describePacket(packet, data.size)}")
log(" TX_HEX: ${hexPreview(data)}")
val socket = webSocket
if (socket == null) {
@@ -504,16 +621,17 @@ class Protocol(
try {
val sent = socket.send(ByteString.of(*data))
if (!sent) {
log("WebSocket rejected packet ${packet.getPacketId()}, re-queueing")
log("TX rejected by WebSocket for id=0x${packet.getPacketId().toString(16).uppercase(Locale.ROOT)}")
log("📦 TX re-queue packet id=0x${packet.getPacketId().toString(16).uppercase(Locale.ROOT)}")
packetQueue.add(packet)
return
}
log("Packet ${packet.getPacketId()} sent successfully")
log("TX delivered id=0x${packet.getPacketId().toString(16).uppercase(Locale.ROOT)}")
} catch (e: Exception) {
log("Exception sending packet ${packet.getPacketId()}: ${e.message}")
log("TX exception id=0x${packet.getPacketId().toString(16).uppercase(Locale.ROOT)} err=${e.message}")
e.printStackTrace()
// Как в Архиве - возвращаем пакет в очередь при ошибке отправки
log("📦 Re-queueing packet ${packet.getPacketId()} due to send error")
log("📦 TX re-queue packet id=0x${packet.getPacketId().toString(16).uppercase(Locale.ROOT)} due to send error")
packetQueue.add(packet)
}
}
@@ -530,9 +648,8 @@ class Protocol(
private fun handleMessage(data: ByteArray) {
try {
// Debug: log first 50 bytes as hex
val hexDump = data.take(50).joinToString(" ") { String.format("%02X", it.toInt() and 0xFF) }
log("📥 Received ${data.size} bytes: $hexDump${if (data.size > 50) "..." else ""}")
log("⬅️ SERVER -> CLIENT rawBytes=${data.size}")
log(" RX_HEX: ${hexPreview(data)}")
val stream = Stream(data)
if (stream.getRemainingBits() < MIN_PACKET_ID_BITS) {
@@ -558,9 +675,15 @@ class Protocol(
return
}
log("⬅️ SERVER -> CLIENT ${describePacket(packet, data.size)}")
val remainingBits = stream.getRemainingBits()
if (remainingBits > 0) {
log("⚠️ RX parser leftover bits for packet id=0x${packetId.toString(16).uppercase(Locale.ROOT)}: $remainingBits")
}
// Notify waiters
val waitersCount = packetWaiters[packetId]?.size ?: 0
log("📥 Notifying $waitersCount waiter(s) for packet $packetId")
log("📥 RX dispatch packet id=0x${packetId.toString(16).uppercase(Locale.ROOT)} waiters=$waitersCount")
packetWaiters[packetId]?.forEach { callback ->
try {

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
import java.security.SecureRandom
import java.util.*
import java.util.concurrent.ConcurrentHashMap
@@ -33,6 +34,9 @@ object ProtocolManager {
private const val DEBUG_LOG_FLUSH_DELAY_MS = 60L
private const val HEARTBEAT_OK_LOG_MIN_INTERVAL_MS = 5_000L
private const val TYPING_INDICATOR_TIMEOUT_MS = 3_000L
private const val PROTOCOL_TRACE_FILE_NAME = "protocol_wire_log.txt"
private const val PROTOCOL_TRACE_MAX_BYTES = 2_000_000L
private const val PROTOCOL_TRACE_KEEP_BYTES = 1_200_000
private const val PACKET_SIGNAL_PEER = 0x1A
private const val PACKET_WEB_RTC = 0x1B
private const val PACKET_ICE_SERVERS = 0x1C
@@ -61,6 +65,7 @@ object ProtocolManager {
val debugLogs: StateFlow<List<String>> = _debugLogs.asStateFlow()
private val debugLogsBuffer = ArrayDeque<String>(MAX_DEBUG_LOGS)
private val debugLogsLock = Any()
private val protocolTraceLock = Any()
@Volatile private var debugFlushJob: Job? = null
private val debugFlushPending = AtomicBoolean(false)
@Volatile private var lastHeartbeatOkLogAtMs: Long = 0L
@@ -69,6 +74,10 @@ object ProtocolManager {
// Typing status
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow()
private val _typingUsersByDialogSnapshot =
MutableStateFlow<Map<String, Set<String>>>(emptyMap())
val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>> =
_typingUsersByDialogSnapshot.asStateFlow()
private val typingStateLock = Any()
private val typingUsersByDialog = mutableMapOf<String, MutableSet<String>>()
private val typingTimeoutJobs = ConcurrentHashMap<String, Job>()
@@ -134,7 +143,6 @@ object ProtocolManager {
}
fun addLog(message: String) {
if (!uiLogsEnabled) return
var normalizedMessage = message
val isHeartbeatOk = normalizedMessage.startsWith("💓 Heartbeat OK")
if (isHeartbeatOk) {
@@ -152,6 +160,8 @@ object ProtocolManager {
val timestamp =
java.text.SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
val line = "[$timestamp] $normalizedMessage"
persistProtocolTraceLine(line)
if (!uiLogsEnabled) return
synchronized(debugLogsLock) {
if (debugLogsBuffer.size >= MAX_DEBUG_LOGS) {
debugLogsBuffer.removeFirst()
@@ -161,6 +171,24 @@ object ProtocolManager {
flushDebugLogsThrottled()
}
private fun persistProtocolTraceLine(line: String) {
val context = appContext ?: return
runCatching {
val dir = File(context.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs()
val traceFile = File(dir, PROTOCOL_TRACE_FILE_NAME)
synchronized(protocolTraceLock) {
if (traceFile.exists() && traceFile.length() > PROTOCOL_TRACE_MAX_BYTES) {
val tail = runCatching {
traceFile.readText(Charsets.UTF_8).takeLast(PROTOCOL_TRACE_KEEP_BYTES)
}.getOrDefault("")
traceFile.writeText("=== PROTOCOL TRACE ROTATED ===\n$tail\n", Charsets.UTF_8)
}
traceFile.appendText("$line\n", Charsets.UTF_8)
}
}
}
fun enableUILogs(enabled: Boolean) {
uiLogsEnabled = enabled
MessageLogger.setEnabled(enabled)
@@ -656,6 +684,20 @@ object ProtocolManager {
return "${dialogKey.lowercase(Locale.ROOT)}|${fromPublicKey.lowercase(Locale.ROOT)}"
}
fun getTypingUsersForDialog(dialogKey: String): Set<String> {
val normalizedDialogKey =
if (isGroupDialogKey(dialogKey)) {
normalizeGroupDialogKey(dialogKey)
} else {
dialogKey.trim()
}
if (normalizedDialogKey.isBlank()) return emptySet()
synchronized(typingStateLock) {
return typingUsersByDialog[normalizedDialogKey]?.toSet() ?: emptySet()
}
}
private fun rememberTypingEvent(dialogKey: String, fromPublicKey: String) {
val normalizedDialogKey =
if (isGroupDialogKey(dialogKey)) normalizeGroupDialogKey(dialogKey) else dialogKey.trim()
@@ -666,6 +708,8 @@ object ProtocolManager {
val users = typingUsersByDialog.getOrPut(normalizedDialogKey) { mutableSetOf() }
users.add(normalizedFrom)
_typingUsers.value = typingUsersByDialog.keys.toSet()
_typingUsersByDialogSnapshot.value =
typingUsersByDialog.mapValues { entry -> entry.value.toSet() }
}
val timeoutKey = makeTypingTimeoutKey(normalizedDialogKey, normalizedFrom)
@@ -680,6 +724,8 @@ object ProtocolManager {
typingUsersByDialog.remove(normalizedDialogKey)
}
_typingUsers.value = typingUsersByDialog.keys.toSet()
_typingUsersByDialogSnapshot.value =
typingUsersByDialog.mapValues { entry -> entry.value.toSet() }
}
typingTimeoutJobs.remove(timeoutKey)
}
@@ -691,6 +737,7 @@ object ProtocolManager {
synchronized(typingStateLock) {
typingUsersByDialog.clear()
_typingUsers.value = emptySet()
_typingUsersByDialogSnapshot.value = emptyMap()
}
}
@@ -1328,6 +1375,10 @@ object ProtocolManager {
sharedPublic: String = "",
roomId: String = ""
) {
addLog(
"📡 CALL TX type=$signalType src=${shortKeyForLog(src)} dst=${shortKeyForLog(dst)} " +
"sharedLen=${sharedPublic.length} room=${shortKeyForLog(roomId, 12)}"
)
send(
PacketSignalPeer().apply {
this.signalType = signalType
@@ -1345,6 +1396,11 @@ object ProtocolManager {
fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) {
val pk = try { getProtocol().getPublicKey().orEmpty() } catch (_: Exception) { "" }
val did = appContext?.let { getOrCreateDeviceId(it) } ?: ""
addLog(
"📡 WEBRTC TX type=$signalType sdpLen=${sdpOrCandidate.length} " +
"pk=${shortKeyForLog(pk)} did=${shortKeyForLog(did, 12)} " +
"preview='${shortTextForLog(sdpOrCandidate, 56)}'"
)
send(
PacketWebRTC().apply {
this.signalType = signalType
@@ -1359,6 +1415,7 @@ object ProtocolManager {
* Request ICE servers from server (0x1C).
*/
fun requestIceServers() {
addLog("📡 ICE TX request")
send(PacketIceServers())
}
@@ -1368,7 +1425,13 @@ object ProtocolManager {
*/
fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit {
val wrapper: (Packet) -> Unit = { packet ->
(packet as? PacketSignalPeer)?.let(callback)
(packet as? PacketSignalPeer)?.let {
addLog(
"📡 CALL RX type=${it.signalType} src=${shortKeyForLog(it.src)} dst=${shortKeyForLog(it.dst)} " +
"sharedLen=${it.sharedPublic.length} room=${shortKeyForLog(it.roomId, 12)}"
)
callback(it)
}
}
waitPacket(PACKET_SIGNAL_PEER, wrapper)
return wrapper
@@ -1384,7 +1447,14 @@ object ProtocolManager {
*/
fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit {
val wrapper: (Packet) -> Unit = { packet ->
(packet as? PacketWebRTC)?.let(callback)
(packet as? PacketWebRTC)?.let {
addLog(
"📡 WEBRTC RX type=${it.signalType} sdpLen=${it.sdpOrCandidate.length} " +
"pk=${shortKeyForLog(it.publicKey)} did=${shortKeyForLog(it.deviceId, 12)} " +
"preview='${shortTextForLog(it.sdpOrCandidate, 56)}'"
)
callback(it)
}
}
waitPacket(PACKET_WEB_RTC, wrapper)
return wrapper
@@ -1400,7 +1470,11 @@ object ProtocolManager {
*/
fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit {
val wrapper: (Packet) -> Unit = { packet ->
(packet as? PacketIceServers)?.let(callback)
(packet as? PacketIceServers)?.let {
val firstUrl = it.iceServers.firstOrNull()?.url.orEmpty()
addLog("📡 ICE RX count=${it.iceServers.size} first='${shortTextForLog(firstUrl, 56)}'")
callback(it)
}
}
waitPacket(PACKET_ICE_SERVERS, wrapper)
return wrapper
@@ -1467,6 +1541,18 @@ object ProtocolManager {
}
}
}
private fun shortKeyForLog(value: String, visible: Int = 8): String {
val trimmed = value.trim()
if (trimmed.isBlank()) return "<empty>"
return if (trimmed.length <= visible) trimmed else "${trimmed.take(visible)}"
}
private fun shortTextForLog(value: String, limit: Int = 80): String {
val normalized = value.replace('\n', ' ').replace('\r', ' ').trim()
if (normalized.isBlank()) return "<empty>"
return if (normalized.length <= limit) normalized else "${normalized.take(limit)}"
}
/**
* Disconnect and clear

View File

@@ -126,6 +126,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private val groupMembersCountCache = java.util.concurrent.ConcurrentHashMap<String, Int>()
private val groupAdminKeysCache = java.util.concurrent.ConcurrentHashMap<String, Set<String>>()
private const val GROUP_MESSAGE_STACK_TIME_DIFF_MS = 5 * 60_000L
private const val DIRECT_MESSAGE_STACK_TIME_DIFF_MS = 60_000L
private const val MESSAGE_SKELETON_VISIBILITY_DELAY_MS = 180L
@@ -367,9 +368,6 @@ fun ChatDetailScreen(
// 🎨 Window reference для управления статус баром
val window = remember { (view.context as? Activity)?.window }
// 🔥 Focus state for input
val inputFocusRequester = remember { FocusRequester() }
// 🔥 Emoji picker state
var showEmojiPicker by remember { mutableStateOf(false) }
@@ -538,7 +536,7 @@ fun ChatDetailScreen(
} else {
val isOverlayControllingSystemBars = showMediaPicker
if (!isOverlayControllingSystemBars && window != null && view != null) {
if (!isOverlayControllingSystemBars && window != null) {
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
window.statusBarColor = android.graphics.Color.TRANSPARENT
ic.isAppearanceLightStatusBars = false
@@ -556,7 +554,7 @@ fun ChatDetailScreen(
DisposableEffect(Unit) {
onDispose {
// Восстановить белые иконки статус-бара для chat list header
if (window != null && view != null) {
if (window != null) {
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
window.statusBarColor = android.graphics.Color.TRANSPARENT
ic.isAppearanceLightStatusBars = false
@@ -570,9 +568,6 @@ fun ChatDetailScreen(
}
}
// 📷 Camera: URI для сохранения фото
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
// 📷 Состояние для flow камеры: фото → редактор с caption → отправка
var pendingCameraPhotoUri by remember {
mutableStateOf<Uri?>(null)
@@ -647,47 +642,6 @@ fun ChatDetailScreen(
onDispose { onImageViewerChanged(false) }
}
// <20>📷 Camera launcher
val cameraLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture()
) { success ->
if (success && cameraImageUri != null) {
// Очищаем фокус чтобы клавиатура не появилась
keyboardController?.hide()
focusManager.clearFocus()
// Открываем редактор вместо прямой отправки
pendingCameraPhotoUri = cameraImageUri
}
}
// <20> Gallery-as-file launcher (sends images as compressed files, not as photos)
val galleryAsFileLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri ->
if (uri != null) {
scope.launch {
val fileName = MediaUtils.getFileName(context, uri)
val fileSize = MediaUtils.getFileSize(context, uri)
if (fileSize > MediaUtils.MAX_FILE_SIZE_MB * 1024 * 1024) {
android.widget.Toast.makeText(
context,
"File too large (max ${MediaUtils.MAX_FILE_SIZE_MB} MB)",
android.widget.Toast.LENGTH_LONG
).show()
return@launch
}
val base64 = MediaUtils.uriToBase64File(context, uri)
if (base64 != null) {
viewModel.sendFileMessage(base64, fileName, fileSize)
}
}
}
}
// <20>📄 File picker launcher
val filePickerLauncher =
rememberLauncherForActivityResult(
@@ -729,8 +683,8 @@ fun ChatDetailScreen(
remember(user.publicKey, currentUserPublicKey) {
"${currentUserPublicKey.trim()}::${user.publicKey.trim()}"
}
var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) {
mutableStateOf<Set<String>>(emptySet())
var groupAdminKeys by remember(groupMembersCacheKey) {
mutableStateOf(groupAdminKeysCache[groupMembersCacheKey] ?: emptySet())
}
var groupMembersCount by remember(groupMembersCacheKey) {
mutableStateOf<Int?>(groupMembersCountCache[groupMembersCacheKey])
@@ -756,12 +710,15 @@ fun ChatDetailScreen(
val cachedMembersCount = groupMembersCountCache[groupMembersCacheKey]
groupMembersCount = cachedMembersCount
val cachedAdminKeys = groupAdminKeysCache[groupMembersCacheKey]
if (!cachedAdminKeys.isNullOrEmpty()) {
groupAdminKeys = cachedAdminKeys
}
val members = withContext(Dispatchers.IO) {
groupRepository.requestGroupMembers(user.publicKey)
}
if (members == null) {
groupAdminKeys = emptySet()
mentionCandidates = emptyList()
return@LaunchedEffect
}
@@ -777,6 +734,9 @@ fun ChatDetailScreen(
val adminKey = normalizedMembers.firstOrNull().orEmpty()
groupAdminKeys =
if (adminKey.isBlank()) emptySet() else setOf(adminKey)
if (groupAdminKeys.isNotEmpty()) {
groupAdminKeysCache[groupMembersCacheKey] = groupAdminKeys
}
mentionCandidates =
withContext(Dispatchers.IO) {
@@ -820,8 +780,9 @@ fun ChatDetailScreen(
// Подключаем к ViewModel
val messages by viewModel.messages.collectAsState()
val inputText by viewModel.inputText.collectAsState()
val isTyping by viewModel.opponentTyping.collectAsState()
val typingDisplayName by viewModel.typingDisplayName.collectAsState()
val typingDisplayPublicKey by viewModel.typingDisplayPublicKey.collectAsState()
@Suppress("UNUSED_VARIABLE")
val isLoadingMore by viewModel.isLoadingMore.collectAsState()
val rawIsOnline by viewModel.opponentOnline.collectAsState()
@@ -1344,9 +1305,6 @@ fun ChatDetailScreen(
// Динамический subtitle: typing > online > offline
val isSystemAccount = MessageRepository.isSystemAccount(user.publicKey)
val isRosettaOfficial = user.title.equals("Rosetta", ignoreCase = true) ||
user.username.equals("rosetta", ignoreCase = true) ||
isSystemAccount
val groupMembersSubtitleCount = groupMembersCount
val groupMembersSubtitle =
if (groupMembersSubtitleCount == null) {
@@ -2075,7 +2033,7 @@ fun ChatDetailScreen(
if (!isSavedMessages &&
!isGroupChat &&
(chatHeaderVerified >
0 || isRosettaOfficial)
0 || isSystemAccount)
) {
Spacer(
modifier =
@@ -2109,7 +2067,11 @@ fun ChatDetailScreen(
if (isTyping) {
TypingIndicator(
isDarkTheme =
isDarkTheme
isDarkTheme,
typingDisplayName =
if (isGroupChat) typingDisplayName else "",
typingSenderPublicKey =
if (isGroupChat) typingDisplayPublicKey else ""
)
} else if (isGroupChat &&
groupMembersCount == null
@@ -2694,66 +2656,40 @@ fun ChatDetailScreen(
} else if (!isSystemAccount) {
// INPUT BAR
Column {
MessageInputBar(
value = inputText,
onValueChange = {
viewModel
.updateInputText(
it
)
if (it.isNotEmpty() &&
!isSavedMessages
) {
viewModel
.sendTypingIndicator()
}
},
ChatInputBarSection(
viewModel = viewModel,
isSavedMessages = isSavedMessages,
onSend = {
isSendingMessage =
true
viewModel
.sendMessage()
isSendingMessage = true
viewModel.sendMessage()
scope.launch {
delay(100)
listState
.animateScrollToItem(
0
)
listState.animateScrollToItem(0)
delay(300)
isSendingMessage =
false
isSendingMessage = false
}
},
isDarkTheme = isDarkTheme,
backgroundColor =
backgroundColor,
backgroundColor = backgroundColor,
textColor = textColor,
placeholderColor =
secondaryTextColor,
secondaryTextColor =
secondaryTextColor,
replyMessages =
replyMessages,
isForwardMode =
isForwardMode,
replyMessages = replyMessages,
isForwardMode = isForwardMode,
onCloseReply = {
viewModel
.clearReplyMessages()
viewModel.clearReplyMessages()
},
onShowForwardOptions = { panelMessages ->
if (panelMessages.isEmpty()) {
return@MessageInputBar
return@ChatInputBarSection
}
val forwardMessages =
panelMessages.map { msg ->
ForwardManager.ForwardMessage(
messageId =
msg.messageId,
messageId = msg.messageId,
text = msg.text,
timestamp =
msg.timestamp,
isOutgoing =
msg.isOutgoing,
timestamp = msg.timestamp,
isOutgoing = msg.isOutgoing,
senderPublicKey =
msg.publicKey.ifEmpty {
if (msg.isOutgoing) currentUserPublicKey
@@ -2783,43 +2719,28 @@ fun ChatDetailScreen(
},
chatTitle = chatTitle,
isBlocked = isBlocked,
showEmojiPicker =
showEmojiPicker,
showEmojiPicker = showEmojiPicker,
onToggleEmojiPicker = {
showEmojiPicker = it
},
focusRequester =
inputFocusRequester,
coordinator = coordinator,
displayReplyMessages =
displayReplyMessages,
onReplyClick =
scrollToMessage,
onReplyClick = scrollToMessage,
onAttachClick = {
// Telegram-style:
// галерея
// открывается
// ПОВЕРХ клавиатуры
// НЕ скрываем
// клавиатуру!
showMediaPicker =
true
// галерея открывается поверх клавиатуры.
showMediaPicker = true
},
myPublicKey =
viewModel
.myPublicKey
?: "",
opponentPublicKey =
user.publicKey,
myPrivateKey =
currentUserPrivateKey,
viewModel.myPublicKey ?: "",
opponentPublicKey = user.publicKey,
myPrivateKey = currentUserPrivateKey,
isGroupChat = isGroupChat,
mentionCandidates = mentionCandidates,
avatarRepository = avatarRepository,
inputFocusTrigger =
inputFocusTrigger,
suppressKeyboard =
showInAppCamera,
inputFocusTrigger = inputFocusTrigger,
suppressKeyboard = showInAppCamera,
hasNativeNavigationBar =
hasNativeNavigationBar
)
@@ -4301,6 +4222,76 @@ fun ChatDetailScreen(
} // Закрытие outer Box
}
@Composable
private fun ChatInputBarSection(
viewModel: ChatViewModel,
isSavedMessages: Boolean,
onSend: () -> Unit,
isDarkTheme: Boolean,
backgroundColor: Color,
textColor: Color,
secondaryTextColor: Color,
replyMessages: List<ChatViewModel.ReplyMessage>,
isForwardMode: Boolean,
onCloseReply: () -> Unit,
onShowForwardOptions: (List<ChatViewModel.ReplyMessage>) -> Unit,
chatTitle: String,
isBlocked: Boolean,
showEmojiPicker: Boolean,
onToggleEmojiPicker: (Boolean) -> Unit,
coordinator: KeyboardTransitionCoordinator,
displayReplyMessages: List<ChatViewModel.ReplyMessage>,
onReplyClick: (String) -> Unit,
onAttachClick: () -> Unit,
myPublicKey: String,
opponentPublicKey: String,
myPrivateKey: String,
isGroupChat: Boolean,
mentionCandidates: List<MentionCandidate>,
avatarRepository: AvatarRepository?,
inputFocusTrigger: Int,
suppressKeyboard: Boolean,
hasNativeNavigationBar: Boolean
) {
val inputText by viewModel.inputText.collectAsState()
MessageInputBar(
value = inputText,
onValueChange = {
viewModel.updateInputText(it)
if (it.isNotEmpty() && !isSavedMessages) {
viewModel.sendTypingIndicator()
}
},
onSend = onSend,
isDarkTheme = isDarkTheme,
backgroundColor = backgroundColor,
textColor = textColor,
secondaryTextColor = secondaryTextColor,
replyMessages = replyMessages,
isForwardMode = isForwardMode,
onCloseReply = onCloseReply,
onShowForwardOptions = onShowForwardOptions,
chatTitle = chatTitle,
isBlocked = isBlocked,
showEmojiPicker = showEmojiPicker,
onToggleEmojiPicker = onToggleEmojiPicker,
coordinator = coordinator,
displayReplyMessages = displayReplyMessages,
onReplyClick = onReplyClick,
onAttachClick = onAttachClick,
myPublicKey = myPublicKey,
opponentPublicKey = opponentPublicKey,
myPrivateKey = myPrivateKey,
isGroupChat = isGroupChat,
mentionCandidates = mentionCandidates,
avatarRepository = avatarRepository,
inputFocusTrigger = inputFocusTrigger,
suppressKeyboard = suppressKeyboard,
hasNativeNavigationBar = hasNativeNavigationBar
)
}
@Composable
private fun GroupMembersSubtitleSkeleton() {
val transition = rememberInfiniteTransition(label = "groupMembersSkeleton")

View File

@@ -43,6 +43,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private const val DECRYPT_CHUNK_SIZE = 15 // Расшифровываем по 15 сообщений за раз
private const val DECRYPT_PARALLELISM = 4 // Параллельная расшифровка
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
private const val DRAFT_SAVE_DEBOUNCE_MS = 250L
private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val chatMessageAscComparator =
compareBy<ChatMessage>({ it.timestamp.time }, { it.id })
@@ -167,7 +168,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val _opponentTyping = MutableStateFlow(false)
val opponentTyping: StateFlow<Boolean> = _opponentTyping.asStateFlow()
private val _typingDisplayName = MutableStateFlow("")
val typingDisplayName: StateFlow<String> = _typingDisplayName.asStateFlow()
private val _typingDisplayPublicKey = MutableStateFlow("")
val typingDisplayPublicKey: StateFlow<String> = _typingDisplayPublicKey.asStateFlow()
private var typingTimeoutJob: kotlinx.coroutines.Job? = null
private var typingNameResolveJob: kotlinx.coroutines.Job? = null
@Volatile private var typingSenderPublicKey: String? = null
@Volatile private var typingUsersCount: Int = 1
// 🟢 Онлайн статус собеседника
private val _opponentOnline = MutableStateFlow(false)
@@ -218,6 +226,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Job для отмены загрузки при смене диалога
private var loadingJob: Job? = null
private var draftSaveJob: Job? = null
// 🔥 Throttling для typing индикатора
private var lastTypingSentTime = 0L
@@ -359,7 +368,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
if (shouldShowTyping) {
showTypingIndicator()
if (isGroupDialogKey(currentDialog)) {
val typingUsers = ProtocolManager.getTypingUsersForDialog(currentDialog).toMutableSet()
typingUsers.add(fromPublicKey)
showTypingIndicator(
senderPublicKey = fromPublicKey,
typingUsersCount = typingUsers.size
)
} else {
showTypingIndicator()
}
}
}
@@ -730,7 +748,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
_opponentOnline.value = false
_opponentTyping.value = false
_typingDisplayName.value = ""
_typingDisplayPublicKey.value = ""
typingSenderPublicKey = null
typingUsersCount = 1
typingTimeoutJob?.cancel()
typingNameResolveJob?.cancel()
currentOffset = 0
hasMoreMessages = true
isLoadingMessages = false
@@ -1370,6 +1393,52 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
return "${trimmed.take(6)}...${trimmed.takeLast(4)}"
}
private fun buildGroupTypingDisplayName(baseName: String, participantsCount: Int): String {
val normalizedName = baseName.trim()
if (normalizedName.isBlank()) return ""
val extraParticipants = (participantsCount - 1).coerceAtLeast(0)
return if (extraParticipants > 0) {
"$normalizedName and $extraParticipants"
} else {
normalizedName
}
}
private fun resolveKnownGroupSenderName(publicKey: String): String {
val normalizedPublicKey = publicKey.trim()
if (normalizedPublicKey.isBlank()) return ""
groupSenderNameCache[normalizedPublicKey]?.let { cached ->
if (isUsableSenderName(cached, normalizedPublicKey)) return cached
}
val nameFromMessages =
_messages.value.asSequence().mapNotNull { message ->
if (message.senderPublicKey.trim()
.equals(normalizedPublicKey, ignoreCase = true)
) {
message.senderName.trim()
} else {
null
}
}.firstOrNull { isUsableSenderName(it, normalizedPublicKey) }
if (!nameFromMessages.isNullOrBlank()) {
groupSenderNameCache[normalizedPublicKey] = nameFromMessages
return nameFromMessages
}
val cachedInfo = ProtocolManager.getCachedUserInfo(normalizedPublicKey)
val protocolName =
cachedInfo?.title?.trim().orEmpty().ifBlank { cachedInfo?.username?.trim().orEmpty() }
if (isUsableSenderName(protocolName, normalizedPublicKey)) {
groupSenderNameCache[normalizedPublicKey] = protocolName
return protocolName
}
return ""
}
private suspend fun resolveGroupSenderName(publicKey: String): String {
val normalizedPublicKey = publicKey.trim()
if (normalizedPublicKey.isBlank()) return ""
@@ -1436,6 +1505,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
groupSenderNameCache[normalizedPublicKey] = name
withContext(Dispatchers.Main) {
if (_opponentTyping.value &&
typingSenderPublicKey.equals(normalizedPublicKey, ignoreCase = true)
) {
_typingDisplayName.value =
buildGroupTypingDisplayName(name, typingUsersCount)
}
_messages.update { current ->
current.map { message ->
if (message.senderPublicKey.trim() == normalizedPublicKey &&
@@ -1500,7 +1575,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private fun isLikelyCallAttachmentPreview(preview: String): Boolean {
val normalized = preview.trim()
if (normalized.isEmpty()) return true
// Empty preview must NOT be treated as call, otherwise we get false-positive
// "empty call" attachments in regular/group messages.
if (normalized.isEmpty()) return false
val tail = normalized.substringAfterLast("::", normalized).trim()
if (tail.toIntOrNull() != null) return true
@@ -2286,11 +2363,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/** Обновить текст ввода */
fun updateInputText(text: String) {
if (_inputText.value == text) return
_inputText.value = text
// 📝 Сохраняем черновик при каждом изменении текста (draft, как в Telegram)
opponentKey?.let { key ->
com.rosetta.messenger.data.DraftManager.saveDraft(key, text)
}
val key = opponentKey ?: return
draftSaveJob?.cancel()
draftSaveJob =
viewModelScope.launch(Dispatchers.Default) {
delay(DRAFT_SAVE_DEBOUNCE_MS)
// Если за время debounce текст изменился — сохраняем только свежую версию.
if (_inputText.value != text) return@launch
com.rosetta.messenger.data.DraftManager.saveDraft(key, text)
}
}
/**
@@ -5082,7 +5166,51 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
}
private fun showTypingIndicator() {
private fun showTypingIndicator(senderPublicKey: String? = null, typingUsersCount: Int = 1) {
val currentDialog = opponentKey?.trim().orEmpty()
val normalizedSender = senderPublicKey?.trim().orEmpty()
val isGroupTyping = isGroupDialogKey(currentDialog) && normalizedSender.isNotBlank()
val normalizedTypingUsersCount = typingUsersCount.coerceAtLeast(1)
if (isGroupTyping) {
typingSenderPublicKey = normalizedSender
_typingDisplayPublicKey.value = normalizedSender
this.typingUsersCount = normalizedTypingUsersCount
val knownName = resolveKnownGroupSenderName(normalizedSender)
val initialName =
if (isUsableSenderName(knownName, normalizedSender)) {
knownName
} else {
shortPublicKey(normalizedSender)
}
_typingDisplayName.value =
buildGroupTypingDisplayName(initialName, normalizedTypingUsersCount)
requestGroupSenderNameIfNeeded(normalizedSender)
typingNameResolveJob?.cancel()
typingNameResolveJob =
viewModelScope.launch(Dispatchers.IO) {
val resolvedName = resolveGroupSenderName(normalizedSender)
withContext(Dispatchers.Main.immediate) {
if (_opponentTyping.value &&
typingSenderPublicKey.equals(normalizedSender, ignoreCase = true)
) {
_typingDisplayName.value =
buildGroupTypingDisplayName(
resolvedName,
this@ChatViewModel.typingUsersCount
)
}
}
}
} else {
typingSenderPublicKey = null
_typingDisplayName.value = ""
_typingDisplayPublicKey.value = ""
this.typingUsersCount = 1
}
_opponentTyping.value = true
// Отменяем предыдущий таймер, чтобы избежать race condition
typingTimeoutJob?.cancel()
@@ -5090,6 +5218,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
viewModelScope.launch(Dispatchers.Default) {
kotlinx.coroutines.delay(3000)
_opponentTyping.value = false
_typingDisplayName.value = ""
_typingDisplayPublicKey.value = ""
typingSenderPublicKey = null
this@ChatViewModel.typingUsersCount = 1
}
}
@@ -5333,6 +5465,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
override fun onCleared() {
super.onCleared()
isCleared = true
typingTimeoutJob?.cancel()
typingNameResolveJob?.cancel()
draftSaveJob?.cancel()
pinnedCollectionJob?.cancel()
// 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов

View File

@@ -222,6 +222,21 @@ private fun isTypingForDialog(dialogKey: String, typingDialogs: Set<String>): Bo
return typingDialogs.any { it.equals(dialogKey.trim(), ignoreCase = true) }
}
private fun shortPublicKey(value: String): String {
val trimmed = value.trim()
if (trimmed.length <= 12) return trimmed
return "${trimmed.take(6)}...${trimmed.takeLast(4)}"
}
private fun resolveTypingDisplayName(publicKey: String): String {
val normalized = publicKey.trim()
if (normalized.isBlank()) return ""
val cached = ProtocolManager.getCachedUserInfo(normalized)
val resolvedName =
cached?.title?.trim().orEmpty().ifBlank { cached?.username?.trim().orEmpty() }
return if (resolvedName.isNotBlank()) resolvedName else shortPublicKey(normalized)
}
private val TELEGRAM_DIALOG_AVATAR_START = 10.dp
private val TELEGRAM_DIALOG_TEXT_START = 72.dp
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
@@ -249,24 +264,19 @@ fun ChatsListScreen(
accountName: String,
accountUsername: String,
accountVerified: Int = 0,
accountPhone: String,
accountPublicKey: String,
accountPrivateKey: String = "",
privateKeyHash: String = "",
onToggleTheme: () -> Unit,
onProfileClick: () -> Unit,
onNewGroupClick: () -> Unit,
onContactsClick: () -> Unit,
onCallsClick: () -> Unit,
onSavedMessagesClick: () -> Unit,
onSettingsClick: () -> Unit,
onInviteFriendsClick: () -> Unit,
onSearchClick: () -> Unit,
onRequestsClick: () -> Unit = {},
onNewChat: () -> Unit,
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
onStartCall: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
backgroundBlurColorId: String = "avatar",
pinnedChats: Set<String> = emptySet(),
onTogglePin: (String) -> Unit = {},
chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
@@ -435,6 +445,7 @@ fun ChatsListScreen(
// <20>🔥 Пользователи, которые сейчас печатают
val typingUsers by ProtocolManager.typingUsers.collectAsState()
val typingUsersByDialogSnapshot by ProtocolManager.typingUsersByDialogSnapshot.collectAsState()
// Load dialogs when account is available
LaunchedEffect(accountPublicKey, accountPrivateKey) {
@@ -446,7 +457,6 @@ fun ChatsListScreen(
// 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих
// сообщений
val initStart = System.currentTimeMillis()
ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey)
android.util.Log.d(
"ChatsListScreen",
@@ -553,10 +563,6 @@ fun ChatsListScreen(
allAccounts = accounts.sortedByDescending { it.publicKey == accountPublicKey }
}
// 🔥 Используем rememberSaveable чтобы сохранить состояние при навигации
// Header сразу visible = true, без анимации при возврате из чата
var visible by rememberSaveable { mutableStateOf(true) }
// Confirmation dialogs state
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
var dialogToLeave by remember { mutableStateOf<DialogUiModel?>(null) }
@@ -778,13 +784,6 @@ fun ChatsListScreen(
// ═══════════════════════════════════════════════════════════
// 🎨 DRAWER HEADER
// ═══════════════════════════════════════════════════════════
val avatarColors =
getAvatarColor(
accountPublicKey,
isDarkTheme
)
val headerColor = avatarColors.backgroundColor
// Header: цвет шапки сайдбара
Box(modifier = Modifier.fillMaxWidth()) {
Box(
@@ -807,24 +806,10 @@ fun ChatsListScreen(
bottom = 12.dp
)
) {
val isRosettaOfficial =
accountName.equals(
"Rosetta",
ignoreCase = true
) ||
accountUsername.equals(
"rosetta",
ignoreCase = true
)
val isFreddyOfficial =
accountName.equals(
"freddy",
ignoreCase = true
) ||
accountUsername.equals(
"freddy",
ignoreCase = true
)
val isOfficialByKey =
MessageRepository.isSystemAccount(
accountPublicKey
)
// Avatar row with theme toggle
Row(
modifier = Modifier.fillMaxWidth(),
@@ -934,7 +919,7 @@ fun ChatsListScreen(
fontWeight = FontWeight.Bold,
color = Color.White
)
if (accountVerified > 0 || isRosettaOfficial || isFreddyOfficial) {
if (accountVerified > 0 || isOfficialByKey) {
Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge(
verified = if (accountVerified > 0) accountVerified else 1,
@@ -2481,6 +2466,75 @@ fun ChatsListScreen(
)
}
}
val typingGroupUsers by
remember(
dialog.opponentKey,
typingUsersByDialogSnapshot
) {
derivedStateOf {
if (!isGroupDialog) {
emptySet()
} else {
val normalizedDialogKey =
normalizeGroupDialogKey(
dialog.opponentKey
)
typingUsersByDialogSnapshot[normalizedDialogKey]
?: emptySet()
}
}
}
val typingSenderPublicKey by
remember(
isGroupDialog,
typingGroupUsers
) {
derivedStateOf {
if (!isGroupDialog) {
""
} else {
typingGroupUsers.firstOrNull().orEmpty()
}
}
}
val typingDisplayName by
remember(
isGroupDialog,
typingSenderPublicKey,
typingGroupUsers
.size
) {
derivedStateOf {
if (!isGroupDialog ||
typingSenderPublicKey.isBlank()
) {
""
} else {
val baseName =
resolveTypingDisplayName(
typingSenderPublicKey
)
if (baseName.isBlank()) {
""
} else {
val extraCount =
(typingGroupUsers
.size -
1)
.coerceAtLeast(
0
)
if (extraCount >
0
) {
"$baseName and $extraCount"
} else {
baseName
}
}
}
}
}
val isSelectedDialog =
selectedChatKeys
.contains(
@@ -2518,6 +2572,10 @@ fun ChatsListScreen(
isDarkTheme,
isTyping =
isTyping,
typingDisplayName =
typingDisplayName,
typingSenderPublicKey =
typingSenderPublicKey,
isBlocked =
isBlocked,
isSavedMessages =
@@ -3387,9 +3445,6 @@ fun ChatItem(
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
val avatarColors = getAvatarColor(chat.publicKey, isDarkTheme)
val avatarText = getAvatarText(chat.publicKey)
Column {
Row(
modifier =
@@ -3627,6 +3682,8 @@ fun SwipeableDialogItem(
dialog: DialogUiModel,
isDarkTheme: Boolean,
isTyping: Boolean = false,
typingDisplayName: String = "",
typingSenderPublicKey: String = "",
isBlocked: Boolean = false,
isGroupChat: Boolean = false,
isSavedMessages: Boolean = false,
@@ -3862,13 +3919,11 @@ fun SwipeableDialogItem(
velocityTracker.resetTracking()
var totalDragX = 0f
var totalDragY = 0f
var passedSlop = false
var claimed = false
// Phase 1: Determine gesture type (tap / long-press / drag)
// Wait up to longPressTimeout; if no up or slop → long press
var gestureType = "unknown"
var fingerIsUp = false
val result = withTimeoutOrNull(longPressTimeoutMs) {
while (true) {
@@ -3905,19 +3960,17 @@ fun SwipeableDialogItem(
// Timeout → check if finger lifted during the race window
if (result == null) {
// Grace period: check if up event arrived just as timeout fired
val graceResult = withTimeoutOrNull(32L) {
withTimeoutOrNull(32L) {
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id }
if (change == null) {
gestureType = "cancelled"
fingerIsUp = true
return@withTimeoutOrNull Unit
}
if (change.changedToUpIgnoreConsumed()) {
change.consume()
gestureType = "tap"
fingerIsUp = true
return@withTimeoutOrNull Unit
}
// Still moving/holding — it's a real long press
@@ -3957,13 +4010,11 @@ fun SwipeableDialogItem(
when {
// Horizontal left swipe — reveal action buttons
currentSwipeEnabled && dominated && totalDragX < 0 -> {
passedSlop = true
claimed = true
currentOnSwipeStarted()
}
// Horizontal right swipe with buttons open — close them
dominated && totalDragX > 0 && offsetX != 0f -> {
passedSlop = true
claimed = true
}
// Right swipe with buttons closed — let drawer handle
@@ -4034,6 +4085,8 @@ fun SwipeableDialogItem(
dialog = dialog,
isDarkTheme = isDarkTheme,
isTyping = isTyping,
typingDisplayName = typingDisplayName,
typingSenderPublicKey = typingSenderPublicKey,
isPinned = isPinned,
isBlocked = isBlocked,
isMuted = isMuted,
@@ -4051,6 +4104,8 @@ fun DialogItemContent(
dialog: DialogUiModel,
isDarkTheme: Boolean,
isTyping: Boolean = false,
typingDisplayName: String = "",
typingSenderPublicKey: String = "",
isPinned: Boolean = false,
isBlocked: Boolean = false,
isMuted: Boolean = false,
@@ -4063,10 +4118,6 @@ fun DialogItemContent(
val secondaryTextColor =
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) }
val avatarColors =
remember(dialog.opponentKey, isDarkTheme) {
getAvatarColor(dialog.opponentKey, isDarkTheme)
}
val isGroupDialog = remember(dialog.opponentKey) { isGroupDialogKey(dialog.opponentKey) }
// 📁 Для Saved Messages показываем специальное имя
@@ -4101,38 +4152,6 @@ fun DialogItemContent(
}
}
// 📁 Для Saved Messages показываем иконку закладки
// 🔥 Как в Архиве: инициалы из title или username или DELETED
val initials =
remember(
dialog.opponentTitle,
dialog.opponentUsername,
dialog.opponentKey,
dialog.isSavedMessages
) {
if (dialog.isSavedMessages) {
"" // Для Saved Messages - пустая строка, будет использоваться
// иконка
} else if (dialog.opponentTitle.isNotEmpty() &&
dialog.opponentTitle != dialog.opponentKey &&
dialog.opponentTitle != dialog.opponentKey.take(7) &&
dialog.opponentTitle != dialog.opponentKey.take(8)
) {
// Используем title для инициалов
dialog.opponentTitle
.split(" ")
.take(2)
.mapNotNull { it.firstOrNull()?.uppercase() }
.joinToString("")
.ifEmpty { dialog.opponentTitle.take(2).uppercase() }
} else if (dialog.opponentUsername.isNotEmpty()) {
// Если только username - берем первые 2 символа
dialog.opponentUsername.take(2).uppercase()
} else {
dialog.opponentKey.take(2).uppercase()
}
}
Row(
modifier =
Modifier.fillMaxWidth()
@@ -4245,13 +4264,8 @@ fun DialogItemContent(
modifier = Modifier.size(15.dp)
)
}
val isRosettaOfficial = dialog.opponentTitle.equals("Rosetta", ignoreCase = true) ||
dialog.opponentUsername.equals("rosetta", ignoreCase = true) ||
MessageRepository.isSystemAccount(dialog.opponentKey)
val isFreddyVerified = dialog.opponentUsername.equals("freddy", ignoreCase = true) ||
dialog.opponentTitle.equals("freddy", ignoreCase = true) ||
displayName.equals("freddy", ignoreCase = true)
if (dialog.verified > 0 || isRosettaOfficial || isFreddyVerified) {
val isOfficialByKey = MessageRepository.isSystemAccount(dialog.opponentKey)
if (dialog.verified > 0 || isOfficialByKey) {
Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge(
verified = if (dialog.verified > 0) dialog.verified else 1,
@@ -4458,7 +4472,11 @@ fun DialogItemContent(
label = "chatSubtitle"
) { showTyping ->
if (showTyping) {
TypingIndicatorSmall()
TypingIndicatorSmall(
isDarkTheme = isDarkTheme,
typingDisplayName = typingDisplayName,
typingSenderPublicKey = typingSenderPublicKey
)
} else if (!dialog.draftText.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
@@ -4492,7 +4510,8 @@ fun DialogItemContent(
dialog.lastMessageAttachmentType ==
"Call" -> "Call"
dialog.lastMessageAttachmentType ==
"Forwarded" -> "Forwarded message"
"Forwarded" ->
"Forwarded message"
dialog.lastMessage.isEmpty() ->
"No messages"
else -> dialog.lastMessage
@@ -4712,8 +4731,21 @@ fun DialogItemContent(
* with sequential wave animation (scale + vertical offset + opacity).
*/
@Composable
fun TypingIndicatorSmall() {
fun TypingIndicatorSmall(
isDarkTheme: Boolean,
typingDisplayName: String = "",
typingSenderPublicKey: String = ""
) {
val typingColor = PrimaryBlue
val senderTypingColor =
remember(typingSenderPublicKey, isDarkTheme) {
if (typingSenderPublicKey.isBlank()) {
typingColor
} else {
getAvatarColor(typingSenderPublicKey, isDarkTheme).textColor
}
}
val normalizedDisplayName = remember(typingDisplayName) { typingDisplayName.trim() }
val infiniteTransition = rememberInfiniteTransition(label = "typing")
// Each dot animates 0→1→0 in a 1200 ms cycle, staggered by 150 ms
@@ -4736,13 +4768,40 @@ fun TypingIndicatorSmall() {
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "typing",
fontSize = 14.sp,
color = typingColor,
fontWeight = FontWeight.Medium
)
Row(
modifier = Modifier.heightIn(min = 18.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (normalizedDisplayName.isBlank()) {
AppleEmojiText(
text = "typing",
fontSize = 14.sp,
color = typingColor,
fontWeight = FontWeight.Medium,
enableLinks = false,
minHeightMultiplier = 1f
)
} else {
AppleEmojiText(
text = "$normalizedDisplayName ",
fontSize = 14.sp,
color = senderTypingColor,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
modifier = Modifier.widthIn(max = 180.dp),
enableLinks = false,
minHeightMultiplier = 1f
)
AppleEmojiText(
text = "typing",
fontSize = 14.sp,
color = typingColor,
fontWeight = FontWeight.Medium,
enableLinks = false,
minHeightMultiplier = 1f
)
}
Spacer(modifier = Modifier.width(2.dp))
// Fixed-size canvas — big enough for bounce, never changes layout
@@ -4750,7 +4809,7 @@ fun TypingIndicatorSmall() {
val dotRadius = 1.5.dp.toPx()
val dotSpacing = 2.5.dp.toPx()
val maxBounce = 2.dp.toPx()
val centerY = size.height / 2f + 1.dp.toPx()
val centerY = size.height / 2f
for (i in 0..2) {
val p = dotProgresses[i].value
val bounce = kotlin.math.sin(p * Math.PI).toFloat()
@@ -4882,7 +4941,6 @@ private fun RequestsRouteContent(
RequestsScreen(
requests = requests,
isDarkTheme = isDarkTheme,
onBack = onBack,
onRequestClick = onRequestClick,
avatarRepository = avatarRepository,
blockedUsers = blockedUsers,
@@ -5017,7 +5075,6 @@ fun RequestsSection(
fun RequestsScreen(
requests: List<DialogUiModel>,
isDarkTheme: Boolean,
onBack: () -> Unit,
onRequestClick: (DialogUiModel) -> Unit,
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
blockedUsers: Set<String> = emptySet(),

View File

@@ -553,11 +553,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
val inferredCall = isLikelyCallAttachmentJson(lastMessageAttachments)
return when (attachmentType) {
0 -> if (inferredCall) "Call" else "Photo" // AttachmentType.IMAGE = 0
1 -> {
// AttachmentType.MESSAGES = 1 (Reply/Forward).
// Если текст пустой — показываем "Forwarded" как в desktop.
if (decryptedLastMessage.isNotEmpty()) null else "Forwarded"
}
1 -> if (decryptedLastMessage.isNotEmpty()) null else "Forwarded" // AttachmentType.MESSAGES = 1 (Reply/Forward)
2 -> "File" // AttachmentType.FILE = 2
3 -> "Avatar" // AttachmentType.AVATAR = 3
4 -> "Call" // AttachmentType.CALL = 4
@@ -589,7 +585,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
if (typeValue == 4) return true
val preview = first.optString("preview", "").trim()
if (preview.isEmpty()) return true
if (preview.isEmpty()) return false
val tail = preview.substringAfterLast("::", preview).trim()
if (tail.toIntOrNull() != null) return true

View File

@@ -87,7 +87,6 @@ fun RequestsListScreen(
RequestsScreen(
requests = requests,
isDarkTheme = isDarkTheme,
onBack = onBack,
onRequestClick = { request ->
onUserSelect(chatsViewModel.dialogToSearchUser(request))
},

View File

@@ -178,12 +178,8 @@ fun CallOverlay(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
val isRosettaOfficial = state.peerTitle.equals("Rosetta", ignoreCase = true) ||
state.peerUsername.equals("rosetta", ignoreCase = true) ||
MessageRepository.isSystemAccount(state.peerPublicKey)
val isFreddyVerified = state.peerUsername.equals("freddy", ignoreCase = true) ||
state.peerTitle.equals("freddy", ignoreCase = true)
if (isRosettaOfficial || isFreddyVerified) {
val isOfficialByKey = MessageRepository.isSystemAccount(state.peerPublicKey)
if (isOfficialByKey) {
Spacer(modifier = Modifier.width(6.dp))
VerifiedBadge(
verified = 1,

View File

@@ -238,12 +238,8 @@ private fun CallHistoryRowItem(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
val isRosettaOfficial = item.peerTitle.equals("Rosetta", ignoreCase = true) ||
item.peerUsername.equals("rosetta", ignoreCase = true) ||
MessageRepository.isSystemAccount(item.peerKey)
val isFreddyVerified = item.peerUsername.equals("freddy", ignoreCase = true) ||
item.peerTitle.equals("freddy", ignoreCase = true)
if (item.peerVerified > 0 || isRosettaOfficial || isFreddyVerified) {
val isOfficialByKey = MessageRepository.isSystemAccount(item.peerKey)
if (item.peerVerified > 0 || isOfficialByKey) {
Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge(
verified = if (item.peerVerified > 0) item.peerVerified else 1,

View File

@@ -228,9 +228,22 @@ fun DateHeader(
* with sequential wave animation (scale + vertical offset + opacity).
*/
@Composable
fun TypingIndicator(isDarkTheme: Boolean) {
val typingColor = if (isDarkTheme) Color(0xFF54A9EB) else Color.White.copy(alpha = 0.7f)
fun TypingIndicator(
isDarkTheme: Boolean,
typingDisplayName: String = "",
typingSenderPublicKey: String = ""
) {
val typingColor = Color(0xFF54A9EB)
val senderTypingColor =
remember(typingSenderPublicKey, isDarkTheme) {
if (typingSenderPublicKey.isBlank()) {
typingColor
} else {
groupSenderLabelColor(typingSenderPublicKey, isDarkTheme)
}
}
val infiniteTransition = rememberInfiniteTransition(label = "typing")
val normalizedDisplayName = remember(typingDisplayName) { typingDisplayName.trim() }
// Each dot animates through a 0→1→0 cycle, staggered by 150 ms
val dotProgresses = List(3) { index ->
@@ -253,7 +266,30 @@ fun TypingIndicator(isDarkTheme: Boolean) {
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = "typing", fontSize = 13.sp, color = typingColor)
if (normalizedDisplayName.isBlank()) {
Text(
text = "typing",
fontSize = 13.sp,
color = typingColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
} else {
Text(
text = "$normalizedDisplayName ",
fontSize = 13.sp,
color = senderTypingColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "typing",
fontSize = 13.sp,
color = typingColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.width(2.dp))
// Fixed-size canvas — big enough for bounce, never changes layout
@@ -333,12 +369,6 @@ fun MessageBubble(
val swipeProgress = (kotlin.math.abs(animatedOffset) / swipeThreshold).coerceIn(0f, 1f)
// Selection animations
val selectionScale by
animateFloatAsState(
targetValue = if (isSelected) 0.95f else 1f,
animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f),
label = "selectionScale"
)
val selectionAlpha by
animateFloatAsState(
targetValue = if (isSelected) 0.85f else 1f,
@@ -785,8 +815,6 @@ fun MessageBubble(
.then(bubbleWidthModifier)
.graphicsLayer {
this.alpha = selectionAlpha
this.scaleX = selectionScale
this.scaleY = selectionScale
}
.combinedClickable(
indication = null,

View File

@@ -23,7 +23,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
@@ -56,6 +55,7 @@ import com.rosetta.messenger.ui.chats.components.*
import com.rosetta.messenger.ui.chats.utils.*
import com.rosetta.messenger.ui.chats.ChatViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import java.util.Locale
@@ -78,6 +78,8 @@ data class MentionCandidate(
val publicKey: String
)
private const val LARGE_INPUT_ANALYSIS_THRESHOLD = 2000
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun MessageInputBar(
@@ -87,7 +89,6 @@ fun MessageInputBar(
isDarkTheme: Boolean,
backgroundColor: Color,
textColor: Color,
placeholderColor: Color,
secondaryTextColor: Color,
replyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
isForwardMode: Boolean = false,
@@ -97,7 +98,6 @@ fun MessageInputBar(
isBlocked: Boolean = false,
showEmojiPicker: Boolean = false,
onToggleEmojiPicker: (Boolean) -> Unit = {},
focusRequester: FocusRequester? = null,
coordinator: KeyboardTransitionCoordinator,
displayReplyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
onReplyClick: (String) -> Unit = {},
@@ -187,7 +187,9 @@ fun MessageInputBar(
// Update coordinator through snapshotFlow (no recomposition)
LaunchedEffect(Unit) {
snapshotFlow { with(density) { imeInsets.getBottom(density).toDp() } }.collect { currentImeHeight ->
snapshotFlow { with(density) { imeInsets.getBottom(density).toDp() } }
.distinctUntilChanged()
.collect { currentImeHeight ->
val now = System.currentTimeMillis()
val heightChanged = kotlin.math.abs((currentImeHeight - lastStableKeyboardHeight).value) > 5f
if (heightChanged && currentImeHeight.value > 0) {
@@ -200,9 +202,13 @@ fun MessageInputBar(
isKeyboardVisible = currentImeHeight > 50.dp
coordinator.updateKeyboardHeight(currentImeHeight)
if (currentImeHeight > 100.dp) {
// Update "stable" height only after IME animation settles,
// otherwise low-end devices get many unnecessary recompositions.
if (!isKeyboardAnimating && currentImeHeight > 100.dp) {
coordinator.syncHeights()
lastStableKeyboardHeight = currentImeHeight
if (kotlin.math.abs((currentImeHeight - lastStableKeyboardHeight).value) > 2f) {
lastStableKeyboardHeight = currentImeHeight
}
}
}
}
@@ -232,7 +238,11 @@ fun MessageInputBar(
}
val mentionPattern = remember { Regex("@([\\w\\d_]*)$") }
val mentionedPattern = remember { Regex("@([\\w\\d_]{1,})") }
val mentionMatch = remember(value, isGroupChat) { if (isGroupChat) mentionPattern.find(value) else null }
val skipHeavyInputAnalysis = remember(value) { value.length > LARGE_INPUT_ANALYSIS_THRESHOLD }
val mentionMatch =
remember(value, isGroupChat, skipHeavyInputAnalysis) {
if (isGroupChat && !skipHeavyInputAnalysis) mentionPattern.find(value) else null
}
val mentionQuery =
remember(mentionMatch) {
@@ -246,9 +256,10 @@ fun MessageInputBar(
value,
mentionCandidates,
mentionQuery,
shouldShowMentionSuggestions
shouldShowMentionSuggestions,
skipHeavyInputAnalysis
) {
if (!shouldShowMentionSuggestions) {
if (!shouldShowMentionSuggestions || skipHeavyInputAnalysis) {
emptyList()
} else {
val mentionedInText =
@@ -274,8 +285,12 @@ fun MessageInputBar(
}
val emojiWordMatch =
remember(value, selectionStart, selectionEnd) {
EmojiKeywordSuggester.findWordAtCursor(value, selectionStart, selectionEnd)
remember(value, selectionStart, selectionEnd, skipHeavyInputAnalysis) {
if (skipHeavyInputAnalysis) {
null
} else {
EmojiKeywordSuggester.findWordAtCursor(value, selectionStart, selectionEnd)
}
}
val emojiSuggestions =

View File

@@ -357,6 +357,15 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
try {
val textStr = editable.toString()
val hasEmojiHints = containsEmojiHints(textStr)
if (!hasEmojiHints) {
// Fast path for plain text: skip heavy grapheme/asset pipeline.
// Also drop stale spans if user removed emoji content.
editable.getSpans(0, editable.length, ImageSpan::class.java).forEach {
editable.removeSpan(it)
}
return
}
val cursorPosition = selectionStart
// 🔥 Собираем все позиции эмодзи (и Unicode, и :emoji_code:)
@@ -430,6 +439,15 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
}
}
private fun containsEmojiHints(text: String): Boolean {
if (text.isEmpty()) return false
if (text.indexOf(":emoji_") >= 0) return true
for (ch in text) {
if (Character.isSurrogate(ch) || ch == '\u200D' || ch == '\uFE0F') return true
}
return false
}
private fun loadFromAssets(unified: String): Bitmap? {
return try {
val inputStream = getContext().assets.open("emoji/$unified.png")
@@ -592,10 +610,10 @@ fun AppleEmojiText(
null
}
)
setTextWithEmojisIfNeeded(text)
}
},
update = { view ->
view.setTextWithEmojis(text)
view.setTextColor(color.toArgb())
view.setTypeface(view.typeface, typefaceStyle)
// 🔥 Обновляем maxLines и ellipsize
@@ -625,6 +643,7 @@ fun AppleEmojiText(
null
}
)
view.setTextWithEmojisIfNeeded(text)
},
modifier = modifier
)
@@ -644,6 +663,7 @@ class AppleEmojiTextView @JvmOverloads constructor(
) : android.widget.TextView(context, attrs, defStyleAttr) {
companion object {
private const val LARGE_TEXT_RENDER_THRESHOLD = 4000
private val EMOJI_PATTERN = AppleEmojiEditTextView.EMOJI_PATTERN
// 🔥 Паттерн для :emoji_XXXX: формата (из React Native)
private val EMOJI_CODE_PATTERN = Pattern.compile(":emoji_([a-fA-F0-9_-]+):")
@@ -668,6 +688,10 @@ class AppleEmojiTextView @JvmOverloads constructor(
private var mentionsEnabled: Boolean = false
private var mentionClickCallback: ((String) -> Unit)? = null
private var clickableSpanPressStartCallback: (() -> Unit)? = null
private var lastRenderedText: String? = null
private var lastRenderedLinksEnabled: Boolean = false
private var lastRenderedMentionsEnabled: Boolean = false
private var lastRenderedMentionClickable: Boolean = false
// 🔥 Long press callback для selection в MessageBubble
var onLongClickCallback: (() -> Unit)? = null
@@ -799,6 +823,17 @@ class AppleEmojiTextView @JvmOverloads constructor(
}
fun setTextWithEmojis(text: String) {
val isLargeText = text.length > LARGE_TEXT_RENDER_THRESHOLD
val processMentions = mentionsEnabled && !isLargeText
val processLinks = linksEnabled && !isLargeText
val processEmoji = !isLargeText || containsEmojiHints(text)
// Для длинных логов (без emoji/links/mentions) не запускаем дорогой regex/span пайплайн.
if (!processEmoji && !processMentions && !processLinks) {
setText(text)
return
}
// 🔥 Сначала заменяем :emoji_XXXX: на PNG изображения
val spannable = SpannableStringBuilder(text)
@@ -877,17 +912,33 @@ class AppleEmojiTextView @JvmOverloads constructor(
}
}
if (mentionsEnabled) {
if (processMentions) {
addMentionHighlights(spannable)
}
// 🔥 5. Добавляем кликабельные ссылки после обработки эмодзи
if (linksEnabled) {
if (processLinks) {
addClickableLinks(spannable)
}
setText(spannable)
}
fun setTextWithEmojisIfNeeded(text: String) {
val mentionClickable = mentionsEnabled && mentionClickCallback != null
if (lastRenderedText == text &&
lastRenderedLinksEnabled == linksEnabled &&
lastRenderedMentionsEnabled == mentionsEnabled &&
lastRenderedMentionClickable == mentionClickable
) {
return
}
setTextWithEmojis(text)
lastRenderedText = text
lastRenderedLinksEnabled = linksEnabled
lastRenderedMentionsEnabled = mentionsEnabled
lastRenderedMentionClickable = mentionClickable
}
/**
* 🔥 Добавляет кликабельные ссылки (URL, email, телефоны) в spannable
@@ -1037,4 +1088,14 @@ class AppleEmojiTextView @JvmOverloads constructor(
null
}
}
private fun containsEmojiHints(text: String): Boolean {
if (text.indexOf(":emoji_") >= 0) return true
for (ch in text) {
if (Character.isSurrogate(ch) || ch == '\u200D' || ch == '\uFE0F') {
return true
}
}
return false
}
}

View File

@@ -1915,12 +1915,6 @@ private fun CollapsingOtherProfileHeader(
val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress)
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress)
val isRosettaOfficial =
name.equals("Rosetta", ignoreCase = true) ||
username.equals("rosetta", ignoreCase = true)
val isFreddyOfficial =
name.equals("freddy", ignoreCase = true) ||
username.equals("freddy", ignoreCase = true)
// ═══════════════════════════════════════════════════════════
// 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой
@@ -2182,7 +2176,7 @@ private fun CollapsingOtherProfileHeader(
textAlign = TextAlign.Center
)
if (verified > 0 || isRosettaOfficial || isFreddyOfficial) {
if (verified > 0 || isSystemAccount) {
Box(
modifier =
Modifier.padding(start = 4.dp)

View File

@@ -59,6 +59,7 @@ import androidx.palette.graphics.Palette as AndroidPalette
import com.rosetta.messenger.biometric.BiometricAuthManager
import com.rosetta.messenger.biometric.BiometricAvailability
import com.rosetta.messenger.biometric.BiometricPreferences
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
import com.rosetta.messenger.ui.components.VerifiedBadge
@@ -1146,12 +1147,7 @@ private fun CollapsingProfileHeader(
val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress)
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress)
val rosettaBadgeBlue = Color(0xFF1DA1F2)
val isRosettaOfficial =
name.equals("Rosetta", ignoreCase = true) ||
username.equals("rosetta", ignoreCase = true)
val isFreddyOfficial =
name.equals("freddy", ignoreCase = true) ||
username.equals("freddy", ignoreCase = true)
val isOfficialByKey = MessageRepository.isSystemAccount(publicKey)
Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) {
// Expansion fraction — computed early so gradient can fade during expansion
@@ -1416,7 +1412,7 @@ private fun CollapsingProfileHeader(
modifier = Modifier.widthIn(max = 220.dp).alignByBaseline(),
textAlign = TextAlign.Center
)
if (verified > 0 || isRosettaOfficial || isFreddyOfficial) {
if (verified > 0 || isOfficialByKey) {
Box(
modifier =
Modifier.padding(start = 4.dp)