Доработки звонков и чатов: typing, UI и стабильность

This commit is contained in:
2026-04-04 15:17:47 +05:00
parent a9be1282c6
commit 6886a6cef1
17 changed files with 764 additions and 162 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import okhttp3.* import okhttp3.*
import okio.ByteString import okio.ByteString
import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
@@ -36,6 +37,8 @@ class Protocol(
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15 private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
private const val HEARTBEAT_OK_LOG_THROTTLE_MS = 30_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) { private fun log(message: String) {
@@ -43,6 +46,123 @@ class Protocol(
logger(message) 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() private val client = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS) .readTimeout(0, TimeUnit.MILLISECONDS)
.connectTimeout(10, TimeUnit.SECONDS) .connectTimeout(10, TimeUnit.SECONDS)
@@ -487,11 +607,8 @@ class Protocol(
val stream = packet.send() val stream = packet.send()
val data = stream.getStream() val data = stream.getStream()
log("📤 Sending packet: ${packet.getPacketId()} (${data.size} bytes)") log("➡️ CLIENT -> SERVER ${describePacket(packet, data.size)}")
log(" TX_HEX: ${hexPreview(data)}")
// 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 ""}")
val socket = webSocket val socket = webSocket
if (socket == null) { if (socket == null) {
@@ -504,16 +621,17 @@ class Protocol(
try { try {
val sent = socket.send(ByteString.of(*data)) val sent = socket.send(ByteString.of(*data))
if (!sent) { 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) packetQueue.add(packet)
return return
} }
log("Packet ${packet.getPacketId()} sent successfully") log("TX delivered id=0x${packet.getPacketId().toString(16).uppercase(Locale.ROOT)}")
} catch (e: Exception) { } 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() 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) packetQueue.add(packet)
} }
} }
@@ -530,9 +648,8 @@ class Protocol(
private fun handleMessage(data: ByteArray) { private fun handleMessage(data: ByteArray) {
try { try {
// Debug: log first 50 bytes as hex log("⬅️ SERVER -> CLIENT rawBytes=${data.size}")
val hexDump = data.take(50).joinToString(" ") { String.format("%02X", it.toInt() and 0xFF) } log(" RX_HEX: ${hexPreview(data)}")
log("📥 Received ${data.size} bytes: $hexDump${if (data.size > 50) "..." else ""}")
val stream = Stream(data) val stream = Stream(data)
if (stream.getRemainingBits() < MIN_PACKET_ID_BITS) { if (stream.getRemainingBits() < MIN_PACKET_ID_BITS) {
@@ -558,9 +675,15 @@ class Protocol(
return 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 // Notify waiters
val waitersCount = packetWaiters[packetId]?.size ?: 0 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 -> packetWaiters[packetId]?.forEach { callback ->
try { try {

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import java.io.File
import java.security.SecureRandom import java.security.SecureRandom
import java.util.* import java.util.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@@ -33,6 +34,9 @@ object ProtocolManager {
private const val DEBUG_LOG_FLUSH_DELAY_MS = 60L private const val DEBUG_LOG_FLUSH_DELAY_MS = 60L
private const val HEARTBEAT_OK_LOG_MIN_INTERVAL_MS = 5_000L private const val HEARTBEAT_OK_LOG_MIN_INTERVAL_MS = 5_000L
private const val TYPING_INDICATOR_TIMEOUT_MS = 3_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_SIGNAL_PEER = 0x1A
private const val PACKET_WEB_RTC = 0x1B private const val PACKET_WEB_RTC = 0x1B
private const val PACKET_ICE_SERVERS = 0x1C private const val PACKET_ICE_SERVERS = 0x1C
@@ -61,6 +65,7 @@ object ProtocolManager {
val debugLogs: StateFlow<List<String>> = _debugLogs.asStateFlow() val debugLogs: StateFlow<List<String>> = _debugLogs.asStateFlow()
private val debugLogsBuffer = ArrayDeque<String>(MAX_DEBUG_LOGS) private val debugLogsBuffer = ArrayDeque<String>(MAX_DEBUG_LOGS)
private val debugLogsLock = Any() private val debugLogsLock = Any()
private val protocolTraceLock = Any()
@Volatile private var debugFlushJob: Job? = null @Volatile private var debugFlushJob: Job? = null
private val debugFlushPending = AtomicBoolean(false) private val debugFlushPending = AtomicBoolean(false)
@Volatile private var lastHeartbeatOkLogAtMs: Long = 0L @Volatile private var lastHeartbeatOkLogAtMs: Long = 0L
@@ -69,6 +74,10 @@ object ProtocolManager {
// Typing status // Typing status
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet()) private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow() 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 typingStateLock = Any()
private val typingUsersByDialog = mutableMapOf<String, MutableSet<String>>() private val typingUsersByDialog = mutableMapOf<String, MutableSet<String>>()
private val typingTimeoutJobs = ConcurrentHashMap<String, Job>() private val typingTimeoutJobs = ConcurrentHashMap<String, Job>()
@@ -134,7 +143,6 @@ object ProtocolManager {
} }
fun addLog(message: String) { fun addLog(message: String) {
if (!uiLogsEnabled) return
var normalizedMessage = message var normalizedMessage = message
val isHeartbeatOk = normalizedMessage.startsWith("💓 Heartbeat OK") val isHeartbeatOk = normalizedMessage.startsWith("💓 Heartbeat OK")
if (isHeartbeatOk) { if (isHeartbeatOk) {
@@ -152,6 +160,8 @@ object ProtocolManager {
val timestamp = val timestamp =
java.text.SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date()) java.text.SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
val line = "[$timestamp] $normalizedMessage" val line = "[$timestamp] $normalizedMessage"
persistProtocolTraceLine(line)
if (!uiLogsEnabled) return
synchronized(debugLogsLock) { synchronized(debugLogsLock) {
if (debugLogsBuffer.size >= MAX_DEBUG_LOGS) { if (debugLogsBuffer.size >= MAX_DEBUG_LOGS) {
debugLogsBuffer.removeFirst() debugLogsBuffer.removeFirst()
@@ -161,6 +171,24 @@ object ProtocolManager {
flushDebugLogsThrottled() 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) { fun enableUILogs(enabled: Boolean) {
uiLogsEnabled = enabled uiLogsEnabled = enabled
MessageLogger.setEnabled(enabled) MessageLogger.setEnabled(enabled)
@@ -656,6 +684,20 @@ object ProtocolManager {
return "${dialogKey.lowercase(Locale.ROOT)}|${fromPublicKey.lowercase(Locale.ROOT)}" 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) { private fun rememberTypingEvent(dialogKey: String, fromPublicKey: String) {
val normalizedDialogKey = val normalizedDialogKey =
if (isGroupDialogKey(dialogKey)) normalizeGroupDialogKey(dialogKey) else dialogKey.trim() if (isGroupDialogKey(dialogKey)) normalizeGroupDialogKey(dialogKey) else dialogKey.trim()
@@ -666,6 +708,8 @@ object ProtocolManager {
val users = typingUsersByDialog.getOrPut(normalizedDialogKey) { mutableSetOf() } val users = typingUsersByDialog.getOrPut(normalizedDialogKey) { mutableSetOf() }
users.add(normalizedFrom) users.add(normalizedFrom)
_typingUsers.value = typingUsersByDialog.keys.toSet() _typingUsers.value = typingUsersByDialog.keys.toSet()
_typingUsersByDialogSnapshot.value =
typingUsersByDialog.mapValues { entry -> entry.value.toSet() }
} }
val timeoutKey = makeTypingTimeoutKey(normalizedDialogKey, normalizedFrom) val timeoutKey = makeTypingTimeoutKey(normalizedDialogKey, normalizedFrom)
@@ -680,6 +724,8 @@ object ProtocolManager {
typingUsersByDialog.remove(normalizedDialogKey) typingUsersByDialog.remove(normalizedDialogKey)
} }
_typingUsers.value = typingUsersByDialog.keys.toSet() _typingUsers.value = typingUsersByDialog.keys.toSet()
_typingUsersByDialogSnapshot.value =
typingUsersByDialog.mapValues { entry -> entry.value.toSet() }
} }
typingTimeoutJobs.remove(timeoutKey) typingTimeoutJobs.remove(timeoutKey)
} }
@@ -691,6 +737,7 @@ object ProtocolManager {
synchronized(typingStateLock) { synchronized(typingStateLock) {
typingUsersByDialog.clear() typingUsersByDialog.clear()
_typingUsers.value = emptySet() _typingUsers.value = emptySet()
_typingUsersByDialogSnapshot.value = emptyMap()
} }
} }
@@ -1328,6 +1375,10 @@ object ProtocolManager {
sharedPublic: String = "", sharedPublic: String = "",
roomId: String = "" roomId: String = ""
) { ) {
addLog(
"📡 CALL TX type=$signalType src=${shortKeyForLog(src)} dst=${shortKeyForLog(dst)} " +
"sharedLen=${sharedPublic.length} room=${shortKeyForLog(roomId, 12)}"
)
send( send(
PacketSignalPeer().apply { PacketSignalPeer().apply {
this.signalType = signalType this.signalType = signalType
@@ -1345,6 +1396,11 @@ object ProtocolManager {
fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) { fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) {
val pk = try { getProtocol().getPublicKey().orEmpty() } catch (_: Exception) { "" } val pk = try { getProtocol().getPublicKey().orEmpty() } catch (_: Exception) { "" }
val did = appContext?.let { getOrCreateDeviceId(it) } ?: "" 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( send(
PacketWebRTC().apply { PacketWebRTC().apply {
this.signalType = signalType this.signalType = signalType
@@ -1359,6 +1415,7 @@ object ProtocolManager {
* Request ICE servers from server (0x1C). * Request ICE servers from server (0x1C).
*/ */
fun requestIceServers() { fun requestIceServers() {
addLog("📡 ICE TX request")
send(PacketIceServers()) send(PacketIceServers())
} }
@@ -1368,7 +1425,13 @@ object ProtocolManager {
*/ */
fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit { fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit {
val wrapper: (Packet) -> Unit = { packet -> 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) waitPacket(PACKET_SIGNAL_PEER, wrapper)
return wrapper return wrapper
@@ -1384,7 +1447,14 @@ object ProtocolManager {
*/ */
fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit { fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit {
val wrapper: (Packet) -> Unit = { packet -> 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) waitPacket(PACKET_WEB_RTC, wrapper)
return wrapper return wrapper
@@ -1400,7 +1470,11 @@ object ProtocolManager {
*/ */
fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit { fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit {
val wrapper: (Packet) -> Unit = { packet -> 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) waitPacket(PACKET_ICE_SERVERS, wrapper)
return wrapper return wrapper
@@ -1468,6 +1542,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 * Disconnect and clear
*/ */

View File

@@ -126,6 +126,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
private val groupMembersCountCache = java.util.concurrent.ConcurrentHashMap<String, Int>() 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 GROUP_MESSAGE_STACK_TIME_DIFF_MS = 5 * 60_000L
private const val DIRECT_MESSAGE_STACK_TIME_DIFF_MS = 60_000L private const val DIRECT_MESSAGE_STACK_TIME_DIFF_MS = 60_000L
private const val MESSAGE_SKELETON_VISIBILITY_DELAY_MS = 180L private const val MESSAGE_SKELETON_VISIBILITY_DELAY_MS = 180L
@@ -729,8 +730,8 @@ fun ChatDetailScreen(
remember(user.publicKey, currentUserPublicKey) { remember(user.publicKey, currentUserPublicKey) {
"${currentUserPublicKey.trim()}::${user.publicKey.trim()}" "${currentUserPublicKey.trim()}::${user.publicKey.trim()}"
} }
var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) { var groupAdminKeys by remember(groupMembersCacheKey) {
mutableStateOf<Set<String>>(emptySet()) mutableStateOf(groupAdminKeysCache[groupMembersCacheKey] ?: emptySet())
} }
var groupMembersCount by remember(groupMembersCacheKey) { var groupMembersCount by remember(groupMembersCacheKey) {
mutableStateOf<Int?>(groupMembersCountCache[groupMembersCacheKey]) mutableStateOf<Int?>(groupMembersCountCache[groupMembersCacheKey])
@@ -756,12 +757,15 @@ fun ChatDetailScreen(
val cachedMembersCount = groupMembersCountCache[groupMembersCacheKey] val cachedMembersCount = groupMembersCountCache[groupMembersCacheKey]
groupMembersCount = cachedMembersCount groupMembersCount = cachedMembersCount
val cachedAdminKeys = groupAdminKeysCache[groupMembersCacheKey]
if (!cachedAdminKeys.isNullOrEmpty()) {
groupAdminKeys = cachedAdminKeys
}
val members = withContext(Dispatchers.IO) { val members = withContext(Dispatchers.IO) {
groupRepository.requestGroupMembers(user.publicKey) groupRepository.requestGroupMembers(user.publicKey)
} }
if (members == null) { if (members == null) {
groupAdminKeys = emptySet()
mentionCandidates = emptyList() mentionCandidates = emptyList()
return@LaunchedEffect return@LaunchedEffect
} }
@@ -777,6 +781,9 @@ fun ChatDetailScreen(
val adminKey = normalizedMembers.firstOrNull().orEmpty() val adminKey = normalizedMembers.firstOrNull().orEmpty()
groupAdminKeys = groupAdminKeys =
if (adminKey.isBlank()) emptySet() else setOf(adminKey) if (adminKey.isBlank()) emptySet() else setOf(adminKey)
if (groupAdminKeys.isNotEmpty()) {
groupAdminKeysCache[groupMembersCacheKey] = groupAdminKeys
}
mentionCandidates = mentionCandidates =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -822,6 +829,8 @@ fun ChatDetailScreen(
val messages by viewModel.messages.collectAsState() val messages by viewModel.messages.collectAsState()
val inputText by viewModel.inputText.collectAsState() val inputText by viewModel.inputText.collectAsState()
val isTyping by viewModel.opponentTyping.collectAsState() val isTyping by viewModel.opponentTyping.collectAsState()
val typingDisplayName by viewModel.typingDisplayName.collectAsState()
val typingDisplayPublicKey by viewModel.typingDisplayPublicKey.collectAsState()
@Suppress("UNUSED_VARIABLE") @Suppress("UNUSED_VARIABLE")
val isLoadingMore by viewModel.isLoadingMore.collectAsState() val isLoadingMore by viewModel.isLoadingMore.collectAsState()
val rawIsOnline by viewModel.opponentOnline.collectAsState() val rawIsOnline by viewModel.opponentOnline.collectAsState()
@@ -1344,9 +1353,6 @@ fun ChatDetailScreen(
// Динамический subtitle: typing > online > offline // Динамический subtitle: typing > online > offline
val isSystemAccount = MessageRepository.isSystemAccount(user.publicKey) 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 groupMembersSubtitleCount = groupMembersCount
val groupMembersSubtitle = val groupMembersSubtitle =
if (groupMembersSubtitleCount == null) { if (groupMembersSubtitleCount == null) {
@@ -2075,7 +2081,7 @@ fun ChatDetailScreen(
if (!isSavedMessages && if (!isSavedMessages &&
!isGroupChat && !isGroupChat &&
(chatHeaderVerified > (chatHeaderVerified >
0 || isRosettaOfficial) 0 || isSystemAccount)
) { ) {
Spacer( Spacer(
modifier = modifier =
@@ -2109,7 +2115,11 @@ fun ChatDetailScreen(
if (isTyping) { if (isTyping) {
TypingIndicator( TypingIndicator(
isDarkTheme = isDarkTheme =
isDarkTheme isDarkTheme,
typingDisplayName =
if (isGroupChat) typingDisplayName else "",
typingSenderPublicKey =
if (isGroupChat) typingDisplayPublicKey else ""
) )
} else if (isGroupChat && } else if (isGroupChat &&
groupMembersCount == null groupMembersCount == null

View File

@@ -43,6 +43,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private const val DECRYPT_CHUNK_SIZE = 15 // Расшифровываем по 15 сообщений за раз private const val DECRYPT_CHUNK_SIZE = 15 // Расшифровываем по 15 сообщений за раз
private const val DECRYPT_PARALLELISM = 4 // Параллельная расшифровка private const val DECRYPT_PARALLELISM = 4 // Параллельная расшифровка
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM 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 backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val chatMessageAscComparator = private val chatMessageAscComparator =
compareBy<ChatMessage>({ it.timestamp.time }, { it.id }) compareBy<ChatMessage>({ it.timestamp.time }, { it.id })
@@ -167,7 +168,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val _opponentTyping = MutableStateFlow(false) private val _opponentTyping = MutableStateFlow(false)
val opponentTyping: StateFlow<Boolean> = _opponentTyping.asStateFlow() 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 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) private val _opponentOnline = MutableStateFlow(false)
@@ -218,6 +226,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Job для отмены загрузки при смене диалога // Job для отмены загрузки при смене диалога
private var loadingJob: Job? = null private var loadingJob: Job? = null
private var draftSaveJob: Job? = null
// 🔥 Throttling для typing индикатора // 🔥 Throttling для typing индикатора
private var lastTypingSentTime = 0L private var lastTypingSentTime = 0L
@@ -359,9 +368,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
if (shouldShowTyping) { if (shouldShowTyping) {
if (isGroupDialogKey(currentDialog)) {
val typingUsers = ProtocolManager.getTypingUsersForDialog(currentDialog).toMutableSet()
typingUsers.add(fromPublicKey)
showTypingIndicator(
senderPublicKey = fromPublicKey,
typingUsersCount = typingUsers.size
)
} else {
showTypingIndicator() showTypingIndicator()
} }
} }
}
private val onlinePacketHandler: (Packet) -> Unit = { packet -> private val onlinePacketHandler: (Packet) -> Unit = { packet ->
val onlinePacket = packet as PacketOnlineState val onlinePacket = packet as PacketOnlineState
@@ -730,7 +748,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
_opponentOnline.value = false _opponentOnline.value = false
_opponentTyping.value = false _opponentTyping.value = false
_typingDisplayName.value = ""
_typingDisplayPublicKey.value = ""
typingSenderPublicKey = null
typingUsersCount = 1
typingTimeoutJob?.cancel() typingTimeoutJob?.cancel()
typingNameResolveJob?.cancel()
currentOffset = 0 currentOffset = 0
hasMoreMessages = true hasMoreMessages = true
isLoadingMessages = false isLoadingMessages = false
@@ -1370,6 +1393,52 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
return "${trimmed.take(6)}...${trimmed.takeLast(4)}" 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 { private suspend fun resolveGroupSenderName(publicKey: String): String {
val normalizedPublicKey = publicKey.trim() val normalizedPublicKey = publicKey.trim()
if (normalizedPublicKey.isBlank()) return "" if (normalizedPublicKey.isBlank()) return ""
@@ -1436,6 +1505,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
groupSenderNameCache[normalizedPublicKey] = name groupSenderNameCache[normalizedPublicKey] = name
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (_opponentTyping.value &&
typingSenderPublicKey.equals(normalizedPublicKey, ignoreCase = true)
) {
_typingDisplayName.value =
buildGroupTypingDisplayName(name, typingUsersCount)
}
_messages.update { current -> _messages.update { current ->
current.map { message -> current.map { message ->
if (message.senderPublicKey.trim() == normalizedPublicKey && if (message.senderPublicKey.trim() == normalizedPublicKey &&
@@ -1500,7 +1575,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private fun isLikelyCallAttachmentPreview(preview: String): Boolean { private fun isLikelyCallAttachmentPreview(preview: String): Boolean {
val normalized = preview.trim() 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() val tail = normalized.substringAfterLast("::", normalized).trim()
if (tail.toIntOrNull() != null) return true if (tail.toIntOrNull() != null) return true
@@ -2286,9 +2363,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/** Обновить текст ввода */ /** Обновить текст ввода */
fun updateInputText(text: String) { fun updateInputText(text: String) {
if (_inputText.value == text) return
_inputText.value = text _inputText.value = text
// 📝 Сохраняем черновик при каждом изменении текста (draft, как в Telegram)
opponentKey?.let { key -> 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) 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 _opponentTyping.value = true
// Отменяем предыдущий таймер, чтобы избежать race condition // Отменяем предыдущий таймер, чтобы избежать race condition
typingTimeoutJob?.cancel() typingTimeoutJob?.cancel()
@@ -5090,6 +5218,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
kotlinx.coroutines.delay(3000) kotlinx.coroutines.delay(3000)
_opponentTyping.value = false _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() { override fun onCleared() {
super.onCleared() super.onCleared()
isCleared = true isCleared = true
typingTimeoutJob?.cancel()
typingNameResolveJob?.cancel()
draftSaveJob?.cancel()
pinnedCollectionJob?.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) } 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_AVATAR_START = 10.dp
private val TELEGRAM_DIALOG_TEXT_START = 72.dp private val TELEGRAM_DIALOG_TEXT_START = 72.dp
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
@@ -435,6 +450,7 @@ fun ChatsListScreen(
// <20>🔥 Пользователи, которые сейчас печатают // <20>🔥 Пользователи, которые сейчас печатают
val typingUsers by ProtocolManager.typingUsers.collectAsState() val typingUsers by ProtocolManager.typingUsers.collectAsState()
val typingUsersByDialogSnapshot by ProtocolManager.typingUsersByDialogSnapshot.collectAsState()
// Load dialogs when account is available // Load dialogs when account is available
LaunchedEffect(accountPublicKey, accountPrivateKey) { LaunchedEffect(accountPublicKey, accountPrivateKey) {
@@ -807,23 +823,9 @@ fun ChatsListScreen(
bottom = 12.dp bottom = 12.dp
) )
) { ) {
val isRosettaOfficial = val isOfficialByKey =
accountName.equals( MessageRepository.isSystemAccount(
"Rosetta", accountPublicKey
ignoreCase = true
) ||
accountUsername.equals(
"rosetta",
ignoreCase = true
)
val isFreddyOfficial =
accountName.equals(
"freddy",
ignoreCase = true
) ||
accountUsername.equals(
"freddy",
ignoreCase = true
) )
// Avatar row with theme toggle // Avatar row with theme toggle
Row( Row(
@@ -934,7 +936,7 @@ fun ChatsListScreen(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color.White color = Color.White
) )
if (accountVerified > 0 || isRosettaOfficial || isFreddyOfficial) { if (accountVerified > 0 || isOfficialByKey) {
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge( VerifiedBadge(
verified = if (accountVerified > 0) accountVerified else 1, verified = if (accountVerified > 0) accountVerified else 1,
@@ -2481,6 +2483,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 = val isSelectedDialog =
selectedChatKeys selectedChatKeys
.contains( .contains(
@@ -2518,6 +2589,10 @@ fun ChatsListScreen(
isDarkTheme, isDarkTheme,
isTyping = isTyping =
isTyping, isTyping,
typingDisplayName =
typingDisplayName,
typingSenderPublicKey =
typingSenderPublicKey,
isBlocked = isBlocked =
isBlocked, isBlocked,
isSavedMessages = isSavedMessages =
@@ -3627,6 +3702,8 @@ fun SwipeableDialogItem(
dialog: DialogUiModel, dialog: DialogUiModel,
isDarkTheme: Boolean, isDarkTheme: Boolean,
isTyping: Boolean = false, isTyping: Boolean = false,
typingDisplayName: String = "",
typingSenderPublicKey: String = "",
isBlocked: Boolean = false, isBlocked: Boolean = false,
isGroupChat: Boolean = false, isGroupChat: Boolean = false,
isSavedMessages: Boolean = false, isSavedMessages: Boolean = false,
@@ -4034,6 +4111,8 @@ fun SwipeableDialogItem(
dialog = dialog, dialog = dialog,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
isTyping = isTyping, isTyping = isTyping,
typingDisplayName = typingDisplayName,
typingSenderPublicKey = typingSenderPublicKey,
isPinned = isPinned, isPinned = isPinned,
isBlocked = isBlocked, isBlocked = isBlocked,
isMuted = isMuted, isMuted = isMuted,
@@ -4051,6 +4130,8 @@ fun DialogItemContent(
dialog: DialogUiModel, dialog: DialogUiModel,
isDarkTheme: Boolean, isDarkTheme: Boolean,
isTyping: Boolean = false, isTyping: Boolean = false,
typingDisplayName: String = "",
typingSenderPublicKey: String = "",
isPinned: Boolean = false, isPinned: Boolean = false,
isBlocked: Boolean = false, isBlocked: Boolean = false,
isMuted: Boolean = false, isMuted: Boolean = false,
@@ -4245,13 +4326,8 @@ fun DialogItemContent(
modifier = Modifier.size(15.dp) modifier = Modifier.size(15.dp)
) )
} }
val isRosettaOfficial = dialog.opponentTitle.equals("Rosetta", ignoreCase = true) || val isOfficialByKey = MessageRepository.isSystemAccount(dialog.opponentKey)
dialog.opponentUsername.equals("rosetta", ignoreCase = true) || if (dialog.verified > 0 || isOfficialByKey) {
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) {
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge( VerifiedBadge(
verified = if (dialog.verified > 0) dialog.verified else 1, verified = if (dialog.verified > 0) dialog.verified else 1,
@@ -4458,7 +4534,11 @@ fun DialogItemContent(
label = "chatSubtitle" label = "chatSubtitle"
) { showTyping -> ) { showTyping ->
if (showTyping) { if (showTyping) {
TypingIndicatorSmall() TypingIndicatorSmall(
isDarkTheme = isDarkTheme,
typingDisplayName = typingDisplayName,
typingSenderPublicKey = typingSenderPublicKey
)
} else if (!dialog.draftText.isNullOrEmpty()) { } else if (!dialog.draftText.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
@@ -4492,7 +4572,8 @@ fun DialogItemContent(
dialog.lastMessageAttachmentType == dialog.lastMessageAttachmentType ==
"Call" -> "Call" "Call" -> "Call"
dialog.lastMessageAttachmentType == dialog.lastMessageAttachmentType ==
"Forwarded" -> "Forwarded message" "Forwarded" ->
"Forwarded message"
dialog.lastMessage.isEmpty() -> dialog.lastMessage.isEmpty() ->
"No messages" "No messages"
else -> dialog.lastMessage else -> dialog.lastMessage
@@ -4712,8 +4793,21 @@ fun DialogItemContent(
* with sequential wave animation (scale + vertical offset + opacity). * with sequential wave animation (scale + vertical offset + opacity).
*/ */
@Composable @Composable
fun TypingIndicatorSmall() { fun TypingIndicatorSmall(
isDarkTheme: Boolean,
typingDisplayName: String = "",
typingSenderPublicKey: String = ""
) {
val typingColor = PrimaryBlue 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") val infiniteTransition = rememberInfiniteTransition(label = "typing")
// Each dot animates 0→1→0 in a 1200 ms cycle, staggered by 150 ms // Each dot animates 0→1→0 in a 1200 ms cycle, staggered by 150 ms
@@ -4736,13 +4830,40 @@ fun TypingIndicatorSmall() {
) )
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(
Text( modifier = Modifier.heightIn(min = 18.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (normalizedDisplayName.isBlank()) {
AppleEmojiText(
text = "typing", text = "typing",
fontSize = 14.sp, fontSize = 14.sp,
color = typingColor, color = typingColor,
fontWeight = FontWeight.Medium 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)) Spacer(modifier = Modifier.width(2.dp))
// Fixed-size canvas — big enough for bounce, never changes layout // Fixed-size canvas — big enough for bounce, never changes layout
@@ -4750,7 +4871,7 @@ fun TypingIndicatorSmall() {
val dotRadius = 1.5.dp.toPx() val dotRadius = 1.5.dp.toPx()
val dotSpacing = 2.5.dp.toPx() val dotSpacing = 2.5.dp.toPx()
val maxBounce = 2.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) { for (i in 0..2) {
val p = dotProgresses[i].value val p = dotProgresses[i].value
val bounce = kotlin.math.sin(p * Math.PI).toFloat() val bounce = kotlin.math.sin(p * Math.PI).toFloat()

View File

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

View File

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

View File

@@ -238,12 +238,8 @@ private fun CallHistoryRowItem(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false) modifier = Modifier.weight(1f, fill = false)
) )
val isRosettaOfficial = item.peerTitle.equals("Rosetta", ignoreCase = true) || val isOfficialByKey = MessageRepository.isSystemAccount(item.peerKey)
item.peerUsername.equals("rosetta", ignoreCase = true) || if (item.peerVerified > 0 || isOfficialByKey) {
MessageRepository.isSystemAccount(item.peerKey)
val isFreddyVerified = item.peerUsername.equals("freddy", ignoreCase = true) ||
item.peerTitle.equals("freddy", ignoreCase = true)
if (item.peerVerified > 0 || isRosettaOfficial || isFreddyVerified) {
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge( VerifiedBadge(
verified = if (item.peerVerified > 0) item.peerVerified else 1, 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). * with sequential wave animation (scale + vertical offset + opacity).
*/ */
@Composable @Composable
fun TypingIndicator(isDarkTheme: Boolean) { fun TypingIndicator(
val typingColor = if (isDarkTheme) Color(0xFF54A9EB) else Color.White.copy(alpha = 0.7f) 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 infiniteTransition = rememberInfiniteTransition(label = "typing")
val normalizedDisplayName = remember(typingDisplayName) { typingDisplayName.trim() }
// Each dot animates through a 0→1→0 cycle, staggered by 150 ms // Each dot animates through a 0→1→0 cycle, staggered by 150 ms
val dotProgresses = List(3) { index -> val dotProgresses = List(3) { index ->
@@ -253,7 +266,30 @@ fun TypingIndicator(isDarkTheme: Boolean) {
} }
Row(verticalAlignment = Alignment.CenterVertically) { 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)) Spacer(modifier = Modifier.width(2.dp))
// Fixed-size canvas — big enough for bounce, never changes layout // 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) val swipeProgress = (kotlin.math.abs(animatedOffset) / swipeThreshold).coerceIn(0f, 1f)
// Selection animations // 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 val selectionAlpha by
animateFloatAsState( animateFloatAsState(
targetValue = if (isSelected) 0.85f else 1f, targetValue = if (isSelected) 0.85f else 1f,
@@ -785,8 +815,6 @@ fun MessageBubble(
.then(bubbleWidthModifier) .then(bubbleWidthModifier)
.graphicsLayer { .graphicsLayer {
this.alpha = selectionAlpha this.alpha = selectionAlpha
this.scaleX = selectionScale
this.scaleY = selectionScale
} }
.combinedClickable( .combinedClickable(
indication = null, indication = null,

View File

@@ -78,6 +78,8 @@ data class MentionCandidate(
val publicKey: String val publicKey: String
) )
private const val LARGE_INPUT_ANALYSIS_THRESHOLD = 2000
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
fun MessageInputBar( fun MessageInputBar(
@@ -232,7 +234,11 @@ fun MessageInputBar(
} }
val mentionPattern = remember { Regex("@([\\w\\d_]*)$") } val mentionPattern = remember { Regex("@([\\w\\d_]*)$") }
val mentionedPattern = remember { Regex("@([\\w\\d_]{1,})") } 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 = val mentionQuery =
remember(mentionMatch) { remember(mentionMatch) {
@@ -246,9 +252,10 @@ fun MessageInputBar(
value, value,
mentionCandidates, mentionCandidates,
mentionQuery, mentionQuery,
shouldShowMentionSuggestions shouldShowMentionSuggestions,
skipHeavyInputAnalysis
) { ) {
if (!shouldShowMentionSuggestions) { if (!shouldShowMentionSuggestions || skipHeavyInputAnalysis) {
emptyList() emptyList()
} else { } else {
val mentionedInText = val mentionedInText =
@@ -274,9 +281,13 @@ fun MessageInputBar(
} }
val emojiWordMatch = val emojiWordMatch =
remember(value, selectionStart, selectionEnd) { remember(value, selectionStart, selectionEnd, skipHeavyInputAnalysis) {
if (skipHeavyInputAnalysis) {
null
} else {
EmojiKeywordSuggester.findWordAtCursor(value, selectionStart, selectionEnd) EmojiKeywordSuggester.findWordAtCursor(value, selectionStart, selectionEnd)
} }
}
val emojiSuggestions = val emojiSuggestions =
remember(emojiWordMatch) { remember(emojiWordMatch) {

View File

@@ -592,10 +592,10 @@ fun AppleEmojiText(
null null
} }
) )
setTextWithEmojisIfNeeded(text)
} }
}, },
update = { view -> update = { view ->
view.setTextWithEmojis(text)
view.setTextColor(color.toArgb()) view.setTextColor(color.toArgb())
view.setTypeface(view.typeface, typefaceStyle) view.setTypeface(view.typeface, typefaceStyle)
// 🔥 Обновляем maxLines и ellipsize // 🔥 Обновляем maxLines и ellipsize
@@ -625,6 +625,7 @@ fun AppleEmojiText(
null null
} }
) )
view.setTextWithEmojisIfNeeded(text)
}, },
modifier = modifier modifier = modifier
) )
@@ -644,6 +645,7 @@ class AppleEmojiTextView @JvmOverloads constructor(
) : android.widget.TextView(context, attrs, defStyleAttr) { ) : android.widget.TextView(context, attrs, defStyleAttr) {
companion object { companion object {
private const val LARGE_TEXT_RENDER_THRESHOLD = 4000
private val EMOJI_PATTERN = AppleEmojiEditTextView.EMOJI_PATTERN private val EMOJI_PATTERN = AppleEmojiEditTextView.EMOJI_PATTERN
// 🔥 Паттерн для :emoji_XXXX: формата (из React Native) // 🔥 Паттерн для :emoji_XXXX: формата (из React Native)
private val EMOJI_CODE_PATTERN = Pattern.compile(":emoji_([a-fA-F0-9_-]+):") private val EMOJI_CODE_PATTERN = Pattern.compile(":emoji_([a-fA-F0-9_-]+):")
@@ -668,6 +670,10 @@ class AppleEmojiTextView @JvmOverloads constructor(
private var mentionsEnabled: Boolean = false private var mentionsEnabled: Boolean = false
private var mentionClickCallback: ((String) -> Unit)? = null private var mentionClickCallback: ((String) -> Unit)? = null
private var clickableSpanPressStartCallback: (() -> 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 // 🔥 Long press callback для selection в MessageBubble
var onLongClickCallback: (() -> Unit)? = null var onLongClickCallback: (() -> Unit)? = null
@@ -799,6 +805,17 @@ class AppleEmojiTextView @JvmOverloads constructor(
} }
fun setTextWithEmojis(text: String) { 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 изображения // 🔥 Сначала заменяем :emoji_XXXX: на PNG изображения
val spannable = SpannableStringBuilder(text) val spannable = SpannableStringBuilder(text)
@@ -877,18 +894,34 @@ class AppleEmojiTextView @JvmOverloads constructor(
} }
} }
if (mentionsEnabled) { if (processMentions) {
addMentionHighlights(spannable) addMentionHighlights(spannable)
} }
// 🔥 5. Добавляем кликабельные ссылки после обработки эмодзи // 🔥 5. Добавляем кликабельные ссылки после обработки эмодзи
if (linksEnabled) { if (processLinks) {
addClickableLinks(spannable) addClickableLinks(spannable)
} }
setText(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 * 🔥 Добавляет кликабельные ссылки (URL, email, телефоны) в spannable
*/ */
@@ -1037,4 +1070,14 @@ class AppleEmojiTextView @JvmOverloads constructor(
null 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 nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress)
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.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 - просто по теме: белый в тёмной, чёрный в светлой // 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой
@@ -2182,7 +2176,7 @@ private fun CollapsingOtherProfileHeader(
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
if (verified > 0 || isRosettaOfficial || isFreddyOfficial) { if (verified > 0 || isSystemAccount) {
Box( Box(
modifier = modifier =
Modifier.padding(start = 4.dp) 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.BiometricAuthManager
import com.rosetta.messenger.biometric.BiometricAvailability import com.rosetta.messenger.biometric.BiometricAvailability
import com.rosetta.messenger.biometric.BiometricPreferences import com.rosetta.messenger.biometric.BiometricPreferences
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.BlurredAvatarBackground import com.rosetta.messenger.ui.components.BlurredAvatarBackground
import com.rosetta.messenger.ui.components.VerifiedBadge 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 nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress)
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress) val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress)
val rosettaBadgeBlue = Color(0xFF1DA1F2) val rosettaBadgeBlue = Color(0xFF1DA1F2)
val isRosettaOfficial = val isOfficialByKey = MessageRepository.isSystemAccount(publicKey)
name.equals("Rosetta", ignoreCase = true) ||
username.equals("rosetta", ignoreCase = true)
val isFreddyOfficial =
name.equals("freddy", ignoreCase = true) ||
username.equals("freddy", ignoreCase = true)
Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) { Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) {
// Expansion fraction — computed early so gradient can fade during expansion // Expansion fraction — computed early so gradient can fade during expansion
@@ -1416,7 +1412,7 @@ private fun CollapsingProfileHeader(
modifier = Modifier.widthIn(max = 220.dp).alignByBaseline(), modifier = Modifier.widthIn(max = 220.dp).alignByBaseline(),
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
if (verified > 0 || isRosettaOfficial || isFreddyOfficial) { if (verified > 0 || isOfficialByKey) {
Box( Box(
modifier = modifier =
Modifier.padding(start = 4.dp) Modifier.padding(start = 4.dp)