diff --git a/app/src/main/java/com/rosetta/messenger/IncomingCallActivity.kt b/app/src/main/java/com/rosetta/messenger/IncomingCallActivity.kt index 0783647..d88e9f3 100644 --- a/app/src/main/java/com/rosetta/messenger/IncomingCallActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/IncomingCallActivity.kt @@ -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 = { diff --git a/app/src/main/java/com/rosetta/messenger/data/DraftManager.kt b/app/src/main/java/com/rosetta/messenger/data/DraftManager.kt index b4d58af..c7fb721 100644 --- a/app/src/main/java/com/rosetta/messenger/data/DraftManager.kt +++ b/app/src/main/java/com/rosetta/messenger/data/DraftManager.kt @@ -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 } /** Получить черновик для диалога */ diff --git a/app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt b/app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt index eb2d0f7..c949134 100644 --- a/app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt +++ b/app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt @@ -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") diff --git a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt index f7bafab..2a21a2e 100644 --- a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt @@ -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 = 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 -> diff --git a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt index 9605f41..9be7f7c 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt @@ -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 "" + 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 "" + 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) } ?: ""} " + + "privLen=${packet.privateKey.length}" + is PacketOnlineState -> + "entries=${packet.publicKeysState.size} first=${packet.publicKeysState.firstOrNull()?.let { "${shortKey(it.publicKey)}:${it.state}" } ?: ""}" + 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) } ?: ""}'" + 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 { diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index 7e2de9f..6053284 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -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> = _debugLogs.asStateFlow() private val debugLogsBuffer = ArrayDeque(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>(emptySet()) val typingUsers: StateFlow> = _typingUsers.asStateFlow() + private val _typingUsersByDialogSnapshot = + MutableStateFlow>>(emptyMap()) + val typingUsersByDialogSnapshot: StateFlow>> = + _typingUsersByDialogSnapshot.asStateFlow() private val typingStateLock = Any() private val typingUsersByDialog = mutableMapOf>() private val typingTimeoutJobs = ConcurrentHashMap() @@ -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 { + 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 "" + 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 "" + return if (normalized.length <= limit) normalized else "${normalized.take(limit)}…" + } /** * Disconnect and clear diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index dd32e27..6985719 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -126,6 +126,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext private val groupMembersCountCache = java.util.concurrent.ConcurrentHashMap() +private val groupAdminKeysCache = java.util.concurrent.ConcurrentHashMap>() 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 @@ -729,8 +730,8 @@ fun ChatDetailScreen( remember(user.publicKey, currentUserPublicKey) { "${currentUserPublicKey.trim()}::${user.publicKey.trim()}" } - var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) { - mutableStateOf>(emptySet()) + var groupAdminKeys by remember(groupMembersCacheKey) { + mutableStateOf(groupAdminKeysCache[groupMembersCacheKey] ?: emptySet()) } var groupMembersCount by remember(groupMembersCacheKey) { mutableStateOf(groupMembersCountCache[groupMembersCacheKey]) @@ -756,12 +757,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 +781,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) { @@ -822,6 +829,8 @@ fun ChatDetailScreen( 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 +1353,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 +2081,7 @@ fun ChatDetailScreen( if (!isSavedMessages && !isGroupChat && (chatHeaderVerified > - 0 || isRosettaOfficial) + 0 || isSystemAccount) ) { Spacer( modifier = @@ -2109,7 +2115,11 @@ fun ChatDetailScreen( if (isTyping) { TypingIndicator( isDarkTheme = - isDarkTheme + isDarkTheme, + typingDisplayName = + if (isGroupChat) typingDisplayName else "", + typingSenderPublicKey = + if (isGroupChat) typingDisplayPublicKey else "" ) } else if (isGroupChat && groupMembersCount == null diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 25aea72..f7cf91f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -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({ it.timestamp.time }, { it.id }) @@ -167,7 +168,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private val _opponentTyping = MutableStateFlow(false) val opponentTyping: StateFlow = _opponentTyping.asStateFlow() + private val _typingDisplayName = MutableStateFlow("") + val typingDisplayName: StateFlow = _typingDisplayName.asStateFlow() + private val _typingDisplayPublicKey = MutableStateFlow("") + val typingDisplayPublicKey: StateFlow = _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() // 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 641a721..99c707c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -222,6 +222,21 @@ private fun isTypingForDialog(dialogKey: String, typingDialogs: Set): 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 @@ -435,6 +450,7 @@ fun ChatsListScreen( // �🔥 Пользователи, которые сейчас печатают val typingUsers by ProtocolManager.typingUsers.collectAsState() + val typingUsersByDialogSnapshot by ProtocolManager.typingUsersByDialogSnapshot.collectAsState() // Load dialogs when account is available LaunchedEffect(accountPublicKey, accountPrivateKey) { @@ -807,24 +823,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 +936,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 +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 = selectedChatKeys .contains( @@ -2518,6 +2589,10 @@ fun ChatsListScreen( isDarkTheme, isTyping = isTyping, + typingDisplayName = + typingDisplayName, + typingSenderPublicKey = + typingSenderPublicKey, isBlocked = isBlocked, isSavedMessages = @@ -3627,6 +3702,8 @@ fun SwipeableDialogItem( dialog: DialogUiModel, isDarkTheme: Boolean, isTyping: Boolean = false, + typingDisplayName: String = "", + typingSenderPublicKey: String = "", isBlocked: Boolean = false, isGroupChat: Boolean = false, isSavedMessages: Boolean = false, @@ -4034,6 +4111,8 @@ fun SwipeableDialogItem( dialog = dialog, isDarkTheme = isDarkTheme, isTyping = isTyping, + typingDisplayName = typingDisplayName, + typingSenderPublicKey = typingSenderPublicKey, isPinned = isPinned, isBlocked = isBlocked, isMuted = isMuted, @@ -4051,6 +4130,8 @@ fun DialogItemContent( dialog: DialogUiModel, isDarkTheme: Boolean, isTyping: Boolean = false, + typingDisplayName: String = "", + typingSenderPublicKey: String = "", isPinned: Boolean = false, isBlocked: Boolean = false, isMuted: Boolean = false, @@ -4245,13 +4326,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 +4534,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 +4572,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 +4793,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 +4830,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 +4871,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() diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index 3b7bd4a..c222dba 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt index 027cb30..4dd54b8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt @@ -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, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallsHistoryScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallsHistoryScreen.kt index 8ab92a0..ec77c66 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallsHistoryScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallsHistoryScreen.kt @@ -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, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index a00be03..02442cb 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -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, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index 09efa68..11a9b22 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt @@ -78,6 +78,8 @@ data class MentionCandidate( val publicKey: String ) +private const val LARGE_INPUT_ANALYSIS_THRESHOLD = 2000 + @OptIn(ExperimentalComposeUiApi::class) @Composable fun MessageInputBar( @@ -232,7 +234,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 +252,10 @@ fun MessageInputBar( value, mentionCandidates, mentionQuery, - shouldShowMentionSuggestions + shouldShowMentionSuggestions, + skipHeavyInputAnalysis ) { - if (!shouldShowMentionSuggestions) { + if (!shouldShowMentionSuggestions || skipHeavyInputAnalysis) { emptyList() } else { val mentionedInText = @@ -274,8 +281,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 = diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt index 103720a..a0cf4d3 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt @@ -592,10 +592,10 @@ fun AppleEmojiText( null } ) + setTextWithEmojisIfNeeded(text) } }, update = { view -> - view.setTextWithEmojis(text) view.setTextColor(color.toArgb()) view.setTypeface(view.typeface, typefaceStyle) // 🔥 Обновляем maxLines и ellipsize @@ -625,6 +625,7 @@ fun AppleEmojiText( null } ) + view.setTextWithEmojisIfNeeded(text) }, modifier = modifier ) @@ -644,6 +645,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 +670,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 +805,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 +894,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 +1070,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 + } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index 8eb45c6..f141c79 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -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) diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index ebe2a15..fa6910c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -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)