diff --git a/app/src/main/java/app/rosette/android/ui/keyboard/AnimatedKeyboardTransition.kt b/app/src/main/java/app/rosette/android/ui/keyboard/AnimatedKeyboardTransition.kt index fa28bd4..8ef167c 100644 --- a/app/src/main/java/app/rosette/android/ui/keyboard/AnimatedKeyboardTransition.kt +++ b/app/src/main/java/app/rosette/android/ui/keyboard/AnimatedKeyboardTransition.kt @@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect + import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -15,7 +15,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp -import kotlinx.coroutines.delay + /** * 🚀 Telegram-style: Fixed Height Box + Fade In/Out @@ -109,20 +109,4 @@ fun AnimatedKeyboardTransition( content() } } -} - -/** - * Алиас для обратной совместимости - */ -@Composable -fun SimpleAnimatedKeyboardTransition( - coordinator: KeyboardTransitionCoordinator, - showEmojiPicker: Boolean, - content: @Composable () -> Unit -) { - AnimatedKeyboardTransition( - coordinator = coordinator, - showEmojiPicker = showEmojiPicker, - content = content - ) } \ No newline at end of file diff --git a/app/src/main/java/app/rosette/android/ui/keyboard/KeyboardTransitionCoordinator.kt b/app/src/main/java/app/rosette/android/ui/keyboard/KeyboardTransitionCoordinator.kt index a0a987f..c3f0220 100644 --- a/app/src/main/java/app/rosette/android/ui/keyboard/KeyboardTransitionCoordinator.kt +++ b/app/src/main/java/app/rosette/android/ui/keyboard/KeyboardTransitionCoordinator.kt @@ -4,7 +4,7 @@ import android.os.Handler import android.os.Looper import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf + import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -46,9 +46,6 @@ class KeyboardTransitionCoordinator { var currentState by mutableStateOf(TransitionState.IDLE) private set - var transitionProgress by mutableFloatStateOf(0f) - private set - // ============ Высоты ============ var keyboardHeight by mutableStateOf(0.dp) @@ -68,9 +65,6 @@ class KeyboardTransitionCoordinator { // Используется для отключения imePadding пока Box виден var isEmojiBoxVisible by mutableStateOf(false) - // 🔥 Коллбэк для показа emoji (сохраняем для вызова после закрытия клавиатуры) - private var pendingShowEmojiCallback: (() -> Unit)? = null - // 📊 Для умного логирования (не каждый фрейм) private var lastLogTime = 0L private var lastLoggedHeight = -1f @@ -108,8 +102,6 @@ class KeyboardTransitionCoordinator { currentState = TransitionState.IDLE isTransitioning = false - // Очищаем pending callback - больше не нужен - pendingShowEmojiCallback = null } // ============ Главный метод: Emoji → Keyboard ============ @@ -119,11 +111,6 @@ class KeyboardTransitionCoordinator { * плавно скрыть emoji. */ fun requestShowKeyboard(showKeyboard: () -> Unit, hideEmoji: () -> Unit) { - // 🔥 Отменяем pending emoji callback если он есть (предотвращаем конфликт) - if (pendingShowEmojiCallback != null) { - pendingShowEmojiCallback = null - } - currentState = TransitionState.EMOJI_TO_KEYBOARD isTransitioning = true @@ -260,13 +247,6 @@ class KeyboardTransitionCoordinator { // 🔥 УБРАН pending callback - теперь emoji показывается сразу в requestShowEmoji() } - /** Обновить высоту emoji панели. */ - fun updateEmojiHeight(height: Dp) { - if (height > 0.dp && height != emojiHeight) { - emojiHeight = height - } - } - /** * Синхронизировать высоты (emoji = keyboard). * @@ -292,35 +272,6 @@ class KeyboardTransitionCoordinator { } } - /** - * Получить текущую высоту для резервирования места. Telegram паттерн: всегда резервировать - * максимум из двух. - */ - fun getReservedHeight(): Dp { - return when { - isKeyboardVisible -> keyboardHeight - isEmojiVisible -> emojiHeight - isTransitioning -> maxOf(keyboardHeight, emojiHeight) - else -> 0.dp - } - } - - /** Проверка, можно ли начать новый переход. */ - fun canStartTransition(): Boolean { - return !isTransitioning - } - - /** Сброс состояния (для отладки). */ - fun reset() { - currentState = TransitionState.IDLE - isTransitioning = false - isKeyboardVisible = false - isEmojiVisible = false - transitionProgress = 0f - } - - /** Логирование текущего состояния. */ - fun logState() {} } /** Composable для создания и запоминания coordinator'а. */ 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 2a883c6..c0824ac 100644 --- a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt @@ -4,7 +4,9 @@ import android.content.Context import android.media.AudioManager import android.util.Log import com.rosetta.messenger.data.MessageRepository +import java.security.MessageDigest import java.security.SecureRandom +import java.util.IdentityHashMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -87,6 +89,12 @@ object CallManager { private const val TAG = "CallManager" private const val LOCAL_AUDIO_TRACK_ID = "rosetta_audio_track" private const val LOCAL_MEDIA_STREAM_ID = "rosetta_audio_stream" + private const val BREADCRUMB_FILE_NAME = "e2ee_breadcrumb.txt" + private const val DIAG_FILE_NAME = "e2ee_diag.txt" + private const val NATIVE_CRASH_FILE_NAME = "native_crash.txt" + private const val TAIL_LINES = 300 + private const val PROTOCOL_LOG_TAIL_LINES = 180 + private const val MAX_LOG_PREFIX = 180 private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val secureRandom = SecureRandom() @@ -103,6 +111,11 @@ object CallManager { private var roomId: String = "" private var offerSent = false private var remoteDescriptionSet = false + private var callSessionId: String = "" + private var callStartedAtMs: Long = 0L + private var keyExchangeSent = false + private var createRoomSent = false + private var lastPeerSharedPublicHex: String = "" private var localPrivateKey: ByteArray? = null private var localPublicKey: ByteArray? = null @@ -124,8 +137,12 @@ object CallManager { // E2EE (XChaCha20 — compatible with Desktop) private var sharedKeyBytes: ByteArray? = null - private var senderEncryptor: XChaCha20E2EE.Encryptor? = null - private var receiverDecryptor: XChaCha20E2EE.Decryptor? = null + private val senderEncryptors = IdentityHashMap() + private val receiverDecryptors = IdentityHashMap() + private var pendingAudioSenderForE2ee: RtpSender? = null + private var lastRemoteOfferFingerprint: String = "" + private var lastLocalOfferFingerprint: String = "" + private var e2eeRebindJob: Job? = null private var iceServers: List = emptyList() @@ -176,7 +193,9 @@ object CallManager { if (!ProtocolManager.isAuthenticated()) return CallActionResult.NOT_AUTHENTICATED resetSession(reason = null, notifyPeer = false) + beginCallSession("outgoing:${targetKey.take(8)}") role = CallRole.CALLER + generateSessionKeys() setPeer(targetKey, user.title, user.username) updateState { it.copy( @@ -190,6 +209,7 @@ object CallManager { src = ownPublicKey, dst = targetKey ) + breadcrumbState("startOutgoingCall") appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) } return CallActionResult.STARTED } @@ -210,6 +230,7 @@ object CallManager { dst = snapshot.peerPublicKey, sharedPublic = localPublic.toHex() ) + keyExchangeSent = true updateState { it.copy( @@ -217,6 +238,7 @@ object CallManager { statusText = "Exchanging keys..." ) } + breadcrumbState("acceptIncomingCall") return CallActionResult.STARTED } @@ -308,6 +330,7 @@ object CallManager { } val incomingPeer = packet.src.trim() if (incomingPeer.isBlank()) return + beginCallSession("incoming:${incomingPeer.take(8)}") breadcrumb("SIG: CALL from ${incomingPeer.take(8)}… → INCOMING") role = CallRole.CALLEE resetRtcObjects() @@ -359,30 +382,45 @@ object CallManager { breadcrumb("KE: ABORT — sharedPublic blank") return } + val duplicatePeerKey = lastPeerSharedPublicHex.equals(peerPublicHex, ignoreCase = true) + if (duplicatePeerKey && sharedKeyBytes != null) { + breadcrumb("KE: duplicate peer key ignored") + return + } breadcrumb("KE: role=$role peerPub=${peerPublicHex.take(16)}…") + lastPeerSharedPublicHex = peerPublicHex if (role == CallRole.CALLER) { - generateSessionKeys() + if (localPrivateKey == null || localPublicKey == null) { + breadcrumb("KE: CALLER — generating session keys (were null)") + generateSessionKeys() + } val sharedKey = computeSharedSecretHex(peerPublicHex) if (sharedKey == null) { breadcrumb("KE: CALLER — computeSharedSecret FAILED") return } setupE2EE(sharedKey) - breadcrumb("KE: CALLER — E2EE ready, sending KEY_EXCHANGE + CREATE_ROOM") + breadcrumb("KE: CALLER — E2EE ready, sending missing signaling packets") updateState { it.copy(keyCast = sharedKey, statusText = "Creating room...") } val localPublic = localPublicKey ?: return - ProtocolManager.sendCallSignal( - signalType = SignalType.KEY_EXCHANGE, - src = ownPublicKey, - dst = peerKey, - sharedPublic = localPublic.toHex() - ) - ProtocolManager.sendCallSignal( - signalType = SignalType.CREATE_ROOM, - src = ownPublicKey, - dst = peerKey - ) + if (!keyExchangeSent) { + ProtocolManager.sendCallSignal( + signalType = SignalType.KEY_EXCHANGE, + src = ownPublicKey, + dst = peerKey, + sharedPublic = localPublic.toHex() + ) + keyExchangeSent = true + } + if (!createRoomSent) { + ProtocolManager.sendCallSignal( + signalType = SignalType.CREATE_ROOM, + src = ownPublicKey, + dst = peerKey + ) + createRoomSent = true + } updateState { it.copy(phase = CallPhase.CONNECTING) } return } @@ -406,6 +444,7 @@ object CallManager { private suspend fun handleWebRtcPacket(packet: PacketWebRTC) { webRtcSignalMutex.withLock { val phase = _state.value.phase + breadcrumb("RTC: packet=${packet.signalType} payloadLen=${packet.sdpOrCandidate.length} phase=$phase") if (phase != CallPhase.CONNECTING && phase != CallPhase.ACTIVE) { breadcrumb("RTC: IGNORED ${packet.signalType} — phase=$phase") return@withLock @@ -435,6 +474,7 @@ object CallManager { pc.setRemoteDescriptionAwait(answer) remoteDescriptionSet = true flushBufferedRemoteCandidates() + attachReceiverE2EEFromPeerConnection() breadcrumb("RTC: ANSWER applied OK, state=${pc.signalingState()}") } catch (e: Exception) { breadcrumb("RTC: ANSWER FAILED — ${e.message}") @@ -457,12 +497,23 @@ object CallManager { breadcrumb("RTC: OFFER packet with type=${remoteOffer.type} ignored") return@withLock } + val offerFingerprint = remoteOffer.description.shortFingerprintHex(10) + val phaseNow = _state.value.phase + if (offerFingerprint == lastLocalOfferFingerprint) { + breadcrumb("RTC: OFFER loopback ignored fp=$offerFingerprint") + return@withLock + } + if (phaseNow == CallPhase.ACTIVE && offerFingerprint == lastRemoteOfferFingerprint) { + breadcrumb("RTC: OFFER duplicate in ACTIVE ignored fp=$offerFingerprint") + return@withLock + } breadcrumb("RTC: OFFER received (offerSent=$offerSent state=${pc.signalingState()})") try { pc.setRemoteDescriptionAwait(remoteOffer) remoteDescriptionSet = true flushBufferedRemoteCandidates() + attachReceiverE2EEFromPeerConnection() val stateAfterRemote = pc.signalingState() if (stateAfterRemote != PeerConnection.SignalingState.HAVE_REMOTE_OFFER && @@ -478,6 +529,8 @@ object CallManager { signalType = WebRTCSignalType.ANSWER, sdpOrCandidate = serializeSessionDescription(answer) ) + attachReceiverE2EEFromPeerConnection() + lastRemoteOfferFingerprint = offerFingerprint breadcrumb("RTC: OFFER handled → ANSWER sent") } catch (e: Exception) { breadcrumb("RTC: OFFER FAILED — ${e.message}") @@ -529,6 +582,7 @@ object CallManager { if (audioSource == null) { audioSource = factory.createAudioSource(MediaConstraints()) } + var senderToAttach: RtpSender? = null if (localAudioTrack == null) { localAudioTrack = factory.createAudioTrack(LOCAL_AUDIO_TRACK_ID, audioSource) localAudioTrack?.setEnabled(!_state.value.isMuted) @@ -538,13 +592,27 @@ object CallManager { listOf(LOCAL_MEDIA_STREAM_ID) ) val transceiver = pc.addTransceiver(localAudioTrack, txInit) - breadcrumb("PC: audio transceiver added, attaching E2EE…") - attachSenderE2EE(transceiver?.sender) + senderToAttach = transceiver?.sender + pendingAudioSenderForE2ee = senderToAttach + breadcrumb("PC: audio transceiver added (E2EE attach deferred)") + } else { + senderToAttach = + runCatching { + pc.senders.firstOrNull { sender -> + sender.track()?.kind() == "audio" + } + }.getOrNull() + if (senderToAttach != null) { + pendingAudioSenderForE2ee = senderToAttach + } } + attachSenderE2EE(pendingAudioSenderForE2ee ?: senderToAttach) try { val offer = pc.createOfferAwait() pc.setLocalDescriptionAwait(offer) + lastLocalOfferFingerprint = offer.description.shortFingerprintHex(10) + breadcrumb("RTC: local OFFER fp=$lastLocalOfferFingerprint") ProtocolManager.sendWebRtcSignal( signalType = WebRTCSignalType.OFFER, sdpOrCandidate = serializeSessionDescription(offer) @@ -599,10 +667,12 @@ object CallManager { override fun onAddTrack(receiver: RtpReceiver?, mediaStreams: Array?) = Unit override fun onTrack(transceiver: RtpTransceiver?) { breadcrumb("PC: onTrack → attachReceiverE2EE") - attachReceiverE2EE(transceiver) + attachReceiverE2EE(transceiver?.receiver) + attachReceiverE2EEFromPeerConnection() } override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) { breadcrumb("PC: connState=$newState") + breadcrumbState("onConnectionChange:$newState") when (newState) { PeerConnection.PeerConnectionState.CONNECTED -> { disconnectResetJob?.cancel() @@ -721,6 +791,7 @@ object CallManager { private fun resetSession(reason: String?, notifyPeer: Boolean) { breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}") + breadcrumbState("resetSession") val snapshot = _state.value val wasActive = snapshot.phase != CallPhase.IDLE val peerToNotify = snapshot.peerPublicKey @@ -747,8 +818,17 @@ object CallManager { roomId = "" offerSent = false remoteDescriptionSet = false + keyExchangeSent = false + createRoomSent = false + lastPeerSharedPublicHex = "" + lastRemoteOfferFingerprint = "" + lastLocalOfferFingerprint = "" + e2eeRebindJob?.cancel() + e2eeRebindJob = null localPrivateKey = null localPublicKey = null + callSessionId = "" + callStartedAtMs = 0L durationJob?.cancel() durationJob = null disconnectResetJob?.cancel() @@ -792,6 +872,7 @@ object CallManager { return } sharedKeyBytes = keyBytes.copyOf(32) + breadcrumb("KE: shared key fp=${sharedKeyBytes?.fingerprintHex(8)}") // Open native diagnostics file for frame-level logging try { val dir = java.io.File(appContext!!.filesDir, "crash_reports") @@ -799,40 +880,198 @@ object CallManager { val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath XChaCha20E2EE.nativeOpenDiagFile(diagPath) } catch (_: Throwable) {} + // If sender track already exists, bind encryptor now. + val existingSender = + pendingAudioSenderForE2ee + ?: runCatching { + peerConnection?.senders?.firstOrNull { sender -> sender.track()?.kind() == "audio" } + }.getOrNull() + if (existingSender != null) { + attachSenderE2EE(existingSender) + } + attachReceiverE2EEFromPeerConnection() + startE2EERebindLoopIfNeeded() Log.i(TAG, "E2EE key ready (XChaCha20)") } + private fun startE2EERebindLoopIfNeeded() { + if (e2eeRebindJob?.isActive == true) return + e2eeRebindJob = + scope.launch { + while (true) { + delay(1500L) + if (!e2eeAvailable || sharedKeyBytes == null) continue + val phaseNow = _state.value.phase + if (phaseNow != CallPhase.CONNECTING && phaseNow != CallPhase.ACTIVE) continue + val pc = peerConnection ?: continue + val sender = + runCatching { + pc.senders.firstOrNull { it.track()?.kind() == "audio" } + }.getOrNull() + if (sender != null) { + attachSenderE2EE(sender) + } + attachReceiverE2EEFromPeerConnection() + } + } + } + + private fun attachReceiverE2EEFromPeerConnection() { + val pc = peerConnection ?: return + runCatching { + var fromReceivers = 0 + var fromTransceivers = 0 + pc.receivers.forEach { receiver -> + if (isAudioReceiver(receiver)) { + attachReceiverE2EE(receiver) + fromReceivers++ + } + } + pc.transceivers.forEach { transceiver -> + val receiver = transceiver.receiver ?: return@forEach + if (isAudioReceiver(receiver)) { + attachReceiverE2EE(receiver) + fromTransceivers++ + } + } + breadcrumb("E2EE: scan receivers attached recv=$fromReceivers tx=$fromTransceivers totalMap=${receiverDecryptors.size}") + }.onFailure { + breadcrumb("E2EE: attachReceiverE2EEFromPeerConnection failed: ${it.message}") + } + } + /** Write a breadcrumb to crash_reports/e2ee_breadcrumb.txt — survives SIGSEGV */ private fun breadcrumb(step: String) { try { - val dir = java.io.File(appContext!!.filesDir, "crash_reports") + val dir = ensureCrashReportsDir() ?: return if (!dir.exists()) dir.mkdirs() - val f = java.io.File(dir, "e2ee_breadcrumb.txt") + val f = java.io.File(dir, BREADCRUMB_FILE_NAME) // Reset file at start of key exchange if (step.startsWith("KE:") && step.contains("agreement")) { f.writeText("") } - f.appendText("${System.currentTimeMillis()} $step\n") + val sidPrefix = if (callSessionId.isNotBlank()) "[sid=$callSessionId] " else "" + f.appendText("${System.currentTimeMillis()} $sidPrefix$step\n") } catch (_: Throwable) {} } /** Save a full crash report to crash_reports/ */ private fun saveCrashReport(title: String, error: Throwable) { try { - val dir = java.io.File(appContext!!.filesDir, "crash_reports") + val dir = ensureCrashReportsDir() ?: return if (!dir.exists()) dir.mkdirs() val ts = java.text.SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", java.util.Locale.getDefault()).format(java.util.Date()) val f = java.io.File(dir, "crash_e2ee_$ts.txt") val sw = java.io.StringWriter() error.printStackTrace(java.io.PrintWriter(sw)) - f.writeText("=== E2EE CRASH REPORT ===\n$title\n\nType: ${error.javaClass.name}\nMessage: ${error.message}\n\n$sw") + val breadcrumbTail = readFileTail(java.io.File(dir, BREADCRUMB_FILE_NAME), TAIL_LINES) + val diagTail = readFileTail(java.io.File(dir, DIAG_FILE_NAME), TAIL_LINES) + val nativeCrash = readFileTail(java.io.File(dir, NATIVE_CRASH_FILE_NAME), TAIL_LINES) + val protocolTail = + ProtocolManager.debugLogs.value + .takeLast(PROTOCOL_LOG_TAIL_LINES) + .joinToString("\n") + f.writeText( + buildString { + appendLine("=== E2EE CRASH REPORT ===") + appendLine(title) + appendLine() + appendLine("Time: $ts") + appendLine("Type: ${error.javaClass.name}") + appendLine("Message: ${error.message}") + appendLine() + appendLine("--- CALL SNAPSHOT ---") + appendLine(buildStateSnapshot()) + appendLine() + appendLine("--- STACKTRACE ---") + appendLine(sw.toString()) + appendLine() + appendLine("--- NATIVE CRASH (tail) ---") + appendLine(nativeCrash) + appendLine() + appendLine("--- E2EE DIAG (tail) ---") + appendLine(diagTail) + appendLine() + appendLine("--- E2EE BREADCRUMB (tail) ---") + appendLine(breadcrumbTail) + appendLine() + appendLine("--- PROTOCOL LOGS (tail) ---") + appendLine(if (protocolTail.isBlank()) "" else protocolTail) + } + ) } catch (_: Throwable) {} } + private fun beginCallSession(seed: String) { + val bytes = ByteArray(4) + secureRandom.nextBytes(bytes) + val random = bytes.joinToString("") { "%02x".format(it) } + callSessionId = "${seed.take(8)}-$random" + callStartedAtMs = System.currentTimeMillis() + breadcrumb("SESSION: begin seed=$seed") + } + + private fun ensureCrashReportsDir(): java.io.File? { + val context = appContext ?: return null + return java.io.File(context.filesDir, "crash_reports").apply { if (!exists()) mkdirs() } + } + + private fun readFileTail(file: java.io.File, maxLines: Int): String { + if (!file.exists()) return "" + return runCatching { + val lines = file.readLines() + val tail = if (lines.size <= maxLines) lines else lines.takeLast(maxLines) + if (tail.isEmpty()) "" else tail.joinToString("\n") + }.getOrElse { "" } + } + + private fun buildStateSnapshot(): String { + val st = _state.value + val now = System.currentTimeMillis() + val age = if (callStartedAtMs > 0L) now - callStartedAtMs else -1L + val pc = peerConnection + val pcSig = runCatching { pc?.signalingState() }.getOrNull() + val pcIce = runCatching { pc?.iceConnectionState() }.getOrNull() + val pcConn = runCatching { pc?.connectionState() }.getOrNull() + val pcLocal = runCatching { pc?.localDescription?.type?.canonicalForm() }.getOrDefault("-") + val pcRemote = runCatching { pc?.remoteDescription?.type?.canonicalForm() }.getOrDefault("-") + val senders = runCatching { pc?.senders?.size ?: 0 }.getOrDefault(-1) + val receivers = runCatching { pc?.receivers?.size ?: 0 }.getOrDefault(-1) + return buildString { + append("sid=").append(if (callSessionId.isBlank()) "" else callSessionId) + append(" ageMs=").append(age) + append(" phase=").append(st.phase) + append(" role=").append(role) + append(" peer=").append(st.peerPublicKey.take(12)) + append(" room=").append(roomId.take(16)) + append(" offerSent=").append(offerSent) + append(" remoteDescSet=").append(remoteDescriptionSet) + append(" e2eeAvail=").append(e2eeAvailable) + append(" keyBytes=").append(sharedKeyBytes?.size ?: 0) + append(" pc(sig=").append(pcSig) + append(",ice=").append(pcIce) + append(",conn=").append(pcConn) + append(",local=").append(pcLocal) + append(",remote=").append(pcRemote) + append(",senders=").append(senders) + append(",receivers=").append(receivers) + append(")") + } + } + + private fun breadcrumbState(marker: String) { + breadcrumb("STATE[$marker] ${buildStateSnapshot()}") + } + private fun attachSenderE2EE(sender: RtpSender?) { if (!e2eeAvailable) return val key = sharedKeyBytes ?: return if (sender == null) return + val existing = senderEncryptors[sender] + if (existing != null) { + runCatching { sender.setFrameEncryptor(existing) } + return + } try { breadcrumb("1. encryptor: nativeLoaded=${XChaCha20E2EE.nativeLoaded}") @@ -847,7 +1086,8 @@ object CallManager { breadcrumb("4. calling sender.setFrameEncryptor…") sender.setFrameEncryptor(enc) breadcrumb("5. setFrameEncryptor OK!") - senderEncryptor = enc + senderEncryptors[sender] = enc + pendingAudioSenderForE2ee = null } catch (e: Throwable) { saveCrashReport("attachSenderE2EE failed", e) Log.e(TAG, "E2EE: sender encryptor failed", e) @@ -855,10 +1095,20 @@ object CallManager { } } - private fun attachReceiverE2EE(transceiver: RtpTransceiver?) { + private fun isAudioReceiver(receiver: RtpReceiver?): Boolean { + if (receiver == null) return false + return runCatching { receiver.track()?.kind() == "audio" }.getOrDefault(false) + } + + private fun attachReceiverE2EE(receiver: RtpReceiver?) { if (!e2eeAvailable) return val key = sharedKeyBytes ?: return - val receiver = transceiver?.receiver ?: return + if (receiver == null) return + val existing = receiverDecryptors[receiver] + if (existing != null) { + runCatching { receiver.setFrameDecryptor(existing) } + return + } try { breadcrumb("6. decryptor: creating…") @@ -873,7 +1123,7 @@ object CallManager { breadcrumb("9. calling receiver.setFrameDecryptor…") receiver.setFrameDecryptor(dec) breadcrumb("10. setFrameDecryptor OK!") - receiverDecryptor = dec + receiverDecryptors[receiver] = dec } catch (e: Throwable) { saveCrashReport("attachReceiverE2EE failed", e) Log.e(TAG, "E2EE: receiver decryptor failed", e) @@ -885,10 +1135,15 @@ object CallManager { // Release our ref. WebRTC holds its own ref via scoped_refptr. // After our Release: WebRTC ref remains. On peerConnection.close() // WebRTC releases its ref → ref=0 → native object deleted. - runCatching { senderEncryptor?.dispose() } - runCatching { receiverDecryptor?.dispose() } - senderEncryptor = null - receiverDecryptor = null + senderEncryptors.values.forEach { enc -> + runCatching { enc.dispose() } + } + receiverDecryptors.values.forEach { dec -> + runCatching { dec.dispose() } + } + senderEncryptors.clear() + receiverDecryptors.clear() + pendingAudioSenderForE2ee = null sharedKeyBytes?.let { it.fill(0) } sharedKeyBytes = null runCatching { XChaCha20E2EE.nativeCloseDiagFile() } @@ -896,11 +1151,12 @@ object CallManager { private fun generateSessionKeys() { val privateKey = ByteArray(32) - secureRandom.nextBytes(privateKey) + X25519.generatePrivateKey(secureRandom, privateKey) val publicKey = ByteArray(32) X25519.generatePublicKey(privateKey, 0, publicKey, 0) localPrivateKey = privateKey localPublicKey = publicKey + breadcrumb("KE: local keypair pub=${publicKey.shortHex()} privFp=${privateKey.fingerprintHex(6)}") } private fun computeSharedSecretHex(peerPublicHex: String): String? { @@ -908,17 +1164,17 @@ object CallManager { val peerPublic = peerPublicHex.hexToBytes() ?: return null if (peerPublic.size != 32) return null val rawDh = ByteArray(32) - breadcrumb("KE: X25519 agreement…") + breadcrumb("KE: X25519 agreement with peerPub=${peerPublic.shortHex()}…") val ok = X25519.calculateAgreement(privateKey, 0, peerPublic, 0, rawDh, 0) if (!ok) { breadcrumb("KE: X25519 FAILED") return null } - breadcrumb("KE: X25519 OK, calling HSalsa20…") + breadcrumb("KE: X25519 OK rawDhFp=${rawDh.fingerprintHex(8)}, calling HSalsa20…") return try { val naclShared = XChaCha20E2EE.hsalsa20(rawDh) rawDh.fill(0) - breadcrumb("KE: HSalsa20 OK, key ready") + breadcrumb("KE: HSalsa20 OK keyFp=${naclShared.fingerprintHex(8)}") naclShared.toHex() } catch (e: Throwable) { saveCrashReport("HSalsa20 failed", e) @@ -943,6 +1199,12 @@ object CallManager { val type = SessionDescription.Type.fromCanonicalForm(json.getString("type")) val sdp = json.getString("sdp") SessionDescription(type, sdp) + }.onFailure { error -> + val preview = raw.replace('\n', ' ').replace('\r', ' ') + breadcrumb( + "RTC: parseSessionDescription FAILED len=${raw.length} " + + "preview=${preview.take(MAX_LOG_PREFIX)} err=${error.message}" + ) }.getOrNull() } @@ -961,6 +1223,12 @@ object CallManager { val sdpMid = if (json.has("sdpMid") && !json.isNull("sdpMid")) json.getString("sdpMid") else null val sdpMLineIndex = json.optInt("sdpMLineIndex", 0) IceCandidate(sdpMid, sdpMLineIndex, candidate) + }.onFailure { error -> + val preview = raw.replace('\n', ' ').replace('\r', ' ') + breadcrumb( + "RTC: parseIceCandidate FAILED len=${raw.length} " + + "preview=${preview.take(MAX_LOG_PREFIX)} err=${error.message}" + ) }.getOrNull() } @@ -976,6 +1244,16 @@ object CallManager { } private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + private fun ByteArray.shortHex(bytes: Int = 6): String = + take(bytes.coerceAtMost(size)).joinToString("") { "%02x".format(it) } + private fun ByteArray.fingerprintHex(bytes: Int = 8): String { + val digest = MessageDigest.getInstance("SHA-256").digest(this) + return digest.take(bytes.coerceAtMost(digest.size)).joinToString("") { "%02x".format(it) } + } + private fun String.shortFingerprintHex(bytes: Int = 8): String { + val digest = MessageDigest.getInstance("SHA-256").digest(toByteArray(Charsets.UTF_8)) + return digest.take(bytes.coerceAtMost(digest.size)).joinToString("") { "%02x".format(it) } + } private fun String.hexToBytes(): ByteArray? { val clean = trim().lowercase() diff --git a/app/src/main/java/com/rosetta/messenger/network/PacketPushToken.kt b/app/src/main/java/com/rosetta/messenger/network/PacketPushToken.kt deleted file mode 100644 index 45e0243..0000000 --- a/app/src/main/java/com/rosetta/messenger/network/PacketPushToken.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.rosetta.messenger.network - -/** - * Push Token packet (ID: 0x0A) - DEPRECATED - * Старый формат, заменен на PacketPushNotification (0x10) - */ -class PacketPushToken : Packet() { - var privateKey: String = "" - var publicKey: String = "" - var pushToken: String = "" - var platform: String = "android" // "android" или "ios" - - override fun getPacketId(): Int = 0x0A - - override fun receive(stream: Stream) { - privateKey = stream.readString() - publicKey = stream.readString() - pushToken = stream.readString() - platform = stream.readString() - } - - override fun send(): Stream { - val stream = Stream() - stream.writeInt16(getPacketId()) - stream.writeString(privateKey) - stream.writeString(publicKey) - stream.writeString(pushToken) - stream.writeString(platform) - return stream - } -} diff --git a/app/src/main/java/com/rosetta/messenger/providers/AuthState.kt b/app/src/main/java/com/rosetta/messenger/providers/AuthState.kt deleted file mode 100644 index 61449cd..0000000 --- a/app/src/main/java/com/rosetta/messenger/providers/AuthState.kt +++ /dev/null @@ -1,274 +0,0 @@ -package com.rosetta.messenger.providers - -import android.content.Context -import androidx.compose.runtime.* -import com.rosetta.messenger.crypto.CryptoManager -import com.rosetta.messenger.database.DatabaseService -import com.rosetta.messenger.database.DecryptedAccountData -import com.rosetta.messenger.network.ProtocolManager -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** - * Auth state management - matches React Native architecture - */ -sealed class AuthStatus { - object Loading : AuthStatus() - object Unauthenticated : AuthStatus() - data class Authenticated(val account: DecryptedAccountData) : AuthStatus() - data class Locked(val publicKey: String) : AuthStatus() -} - -data class AuthStateData( - val status: AuthStatus = AuthStatus.Loading, - val hasExistingAccounts: Boolean = false, - val availableAccounts: List = emptyList() -) - -class AuthStateManager( - private val context: Context, - private val scope: CoroutineScope -) { - private val databaseService = DatabaseService.getInstance(context) - - private val _state = MutableStateFlow(AuthStateData()) - val state: StateFlow = _state.asStateFlow() - - private var currentDecryptedAccount: DecryptedAccountData? = null - - // 🚀 ОПТИМИЗАЦИЯ: Кэш списка аккаунтов для UI - private var accountsCache: List? = null - private var lastAccountsLoadTime = 0L - private val accountsCacheTTL = 5000L // 5 секунд - - companion object { - private const val TAG = "AuthStateManager" - } - - init { - scope.launch { - loadAccounts() - checkAuthStatus() - } - } - - private suspend fun loadAccounts() = withContext(Dispatchers.IO) { - try { - // 🚀 ОПТИМИЗАЦИЯ: Используем кэш если он свежий - val currentTime = System.currentTimeMillis() - if (accountsCache != null && (currentTime - lastAccountsLoadTime) < accountsCacheTTL) { - _state.update { it.copy( - hasExistingAccounts = accountsCache!!.isNotEmpty(), - availableAccounts = accountsCache!! - )} - return@withContext - } - - val accounts = databaseService.getAllEncryptedAccounts() - val hasAccounts = accounts.isNotEmpty() - val accountKeys = accounts.map { it.publicKey } - - // Обновляем кэш - accountsCache = accountKeys - lastAccountsLoadTime = currentTime - - _state.update { it.copy( - hasExistingAccounts = hasAccounts, - availableAccounts = accountKeys -)} - } catch (e: Exception) { - } - } - - private suspend fun checkAuthStatus() { - try { - val hasAccounts = databaseService.hasAccounts() - if (!hasAccounts) { - _state.update { it.copy( - status = AuthStatus.Unauthenticated - )} - } else { - _state.update { it.copy( - status = AuthStatus.Unauthenticated - )} - } - } catch (e: Exception) { - _state.update { it.copy( - status = AuthStatus.Unauthenticated - )} - } - } - - /** - * Create new account from seed phrase - * Matches createAccountFromSeedPhrase from React Native - * 🚀 ОПТИМИЗАЦИЯ: Dispatchers.Default для CPU-интенсивной криптографии - */ - suspend fun createAccount( - seedPhrase: List, - password: String - ): Result = withContext(Dispatchers.Default) { - try { - // Step 1: Generate key pair from seed phrase (using BIP39) - val keyPair = CryptoManager.generateKeyPairFromSeed(seedPhrase) - - // Step 2: Generate private key hash for protocol - val privateKeyHash = CryptoManager.generatePrivateKeyHash(keyPair.privateKey) - - // Step 3: Encrypt private key with password - val encryptedPrivateKey = CryptoManager.encryptWithPassword( - keyPair.privateKey, password - ) - - // Step 4: Encrypt seed phrase with password - val encryptedSeedPhrase = CryptoManager.encryptWithPassword( - seedPhrase.joinToString(" "), password - ) - - // Step 5: Save to database - val saved = withContext(Dispatchers.IO) { - databaseService.saveEncryptedAccount( - publicKey = keyPair.publicKey, - privateKeyEncrypted = encryptedPrivateKey, - seedPhraseEncrypted = encryptedSeedPhrase - ) - } - - if (!saved) { - return@withContext Result.failure(Exception("Failed to save account to database")) - } - - // Step 6: Create decrypted account object - val decryptedAccount = DecryptedAccountData( - publicKey = keyPair.publicKey, - privateKey = keyPair.privateKey, - privateKeyHash = privateKeyHash, - seedPhrase = seedPhrase - ) - - // Step 7: Update state and reload accounts - currentDecryptedAccount = decryptedAccount - _state.update { it.copy( - status = AuthStatus.Authenticated(decryptedAccount) - )} - - loadAccounts() - - // Initialize MessageRepository BEFORE connecting/authenticating - // so incoming messages from server are stored under the correct account - ProtocolManager.initializeAccount(keyPair.publicKey, keyPair.privateKey) - - // Step 8: Connect and authenticate with protocol - ProtocolManager.connect() - ProtocolManager.authenticate(keyPair.publicKey, privateKeyHash) - ProtocolManager.reconnectNowIfNeeded("auth_state_create") - - Result.success(decryptedAccount) - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Unlock account with password - * Matches loginWithPassword from React Native - */ - suspend fun unlock( - publicKey: String, - password: String - ): Result = withContext(Dispatchers.Default) { - try { - // Decrypt account from database - val decryptedAccount = withContext(Dispatchers.IO) { - databaseService.decryptAccount(publicKey, password) - } - - if (decryptedAccount == null) { - return@withContext Result.failure(Exception("Invalid password or account not found")) - } - - // Update last used timestamp - withContext(Dispatchers.IO) { - databaseService.updateLastUsed(publicKey) - } - - // Update state - currentDecryptedAccount = decryptedAccount - _state.update { it.copy( - status = AuthStatus.Authenticated(decryptedAccount) - )} - - // Initialize MessageRepository BEFORE connecting/authenticating - // so incoming messages from server are stored under the correct account - ProtocolManager.initializeAccount(decryptedAccount.publicKey, decryptedAccount.privateKey) - - // Connect and authenticate with protocol - ProtocolManager.connect() - ProtocolManager.authenticate(decryptedAccount.publicKey, decryptedAccount.privateKeyHash) - ProtocolManager.reconnectNowIfNeeded("auth_state_unlock") - - Result.success(decryptedAccount) - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Logout - clears decrypted account from memory - */ - fun logout() { - currentDecryptedAccount = null - _state.update { it.copy( - status = AuthStatus.Unauthenticated - )} - } - - /** - * Delete account from database - */ - suspend fun deleteAccount(publicKey: String): Result = withContext(Dispatchers.IO) { - try { - val success = databaseService.deleteAccount(publicKey) - if (!success) { - return@withContext Result.failure(Exception("Failed to delete account")) - } - - // If deleting current account, logout - if (currentDecryptedAccount?.publicKey == publicKey) { - withContext(Dispatchers.Main) { - logout() - } - } - - loadAccounts() - Result.success(Unit) - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Get current decrypted account (if authenticated) - */ - fun getCurrentAccount(): DecryptedAccountData? = currentDecryptedAccount -} - -@Composable -fun rememberAuthState(context: Context): AuthStateManager { - val scope = rememberCoroutineScope() - return remember(context) { - AuthStateManager(context, scope) - } -} - -@Composable -fun ProvideAuthState( - authState: AuthStateManager, - content: @Composable (AuthStateData) -> Unit -) { - val state by authState.state.collectAsState() - content(state) -} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt.bak b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt.bak deleted file mode 100644 index da6c493..0000000 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt.bak +++ /dev/null @@ -1,2469 +0,0 @@ -package com.rosetta.messenger.ui.chats - -import android.content.Context -import androidx.compose.animation.* -import androidx.compose.animation.core.* -import androidx.compose.foundation.* -import androidx.compose.foundation.gestures.detectHorizontalDragGestures -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.outlined.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.airbnb.lottie.compose.* -import com.rosetta.messenger.R -import com.rosetta.messenger.data.RecentSearchesManager -import com.rosetta.messenger.network.ProtocolManager -import com.rosetta.messenger.network.ProtocolState -import com.rosetta.messenger.ui.components.AppleEmojiText -import com.rosetta.messenger.ui.onboarding.PrimaryBlue -import java.text.SimpleDateFormat -import java.util.* -import kotlinx.coroutines.launch - -@Immutable -data class Chat( - val id: String, - val name: String, - val lastMessage: String, - val lastMessageTime: Date, - val unreadCount: Int = 0, - val isOnline: Boolean = false, - val publicKey: String, - val isSavedMessages: Boolean = false, - val isPinned: Boolean = false -) - -// Avatar colors matching React Native app (Mantine inspired) -// Light theme colors (background lighter, text darker) -private val avatarColorsLight = - listOf( - Color(0xFF1971c2) to Color(0xFFd0ebff), // blue - Color(0xFF0c8599) to Color(0xFFc5f6fa), // cyan - Color(0xFF9c36b5) to Color(0xFFeebefa), // grape - Color(0xFF2f9e44) to Color(0xFFd3f9d8), // green - Color(0xFF4263eb) to Color(0xFFdbe4ff), // indigo - Color(0xFF5c940d) to Color(0xFFe9fac8), // lime - Color(0xFFd9480f) to Color(0xFFffe8cc), // orange - Color(0xFFc2255c) to Color(0xFFffdeeb), // pink - Color(0xFFe03131) to Color(0xFFffe0e0), // red - Color(0xFF099268) to Color(0xFFc3fae8), // teal - Color(0xFF6741d9) to Color(0xFFe5dbff) // violet - ) - -// Dark theme colors (background darker, text lighter) -private val avatarColorsDark = - listOf( - Color(0xFF7dd3fc) to Color(0xFF2d3548), // blue - Color(0xFF67e8f9) to Color(0xFF2d4248), // cyan - Color(0xFFd8b4fe) to Color(0xFF39334c), // grape - Color(0xFF86efac) to Color(0xFF2d3f32), // green - Color(0xFFa5b4fc) to Color(0xFF333448), // indigo - Color(0xFFbef264) to Color(0xFF383f2d), // lime - Color(0xFFfdba74) to Color(0xFF483529), // orange - Color(0xFFf9a8d4) to Color(0xFF482d3d), // pink - Color(0xFFfca5a5) to Color(0xFF482d2d), // red - Color(0xFF5eead4) to Color(0xFF2d4340), // teal - Color(0xFFc4b5fd) to Color(0xFF3a334c) // violet - ) - -// Cache для цветов аватаров -data class AvatarColors(val textColor: Color, val backgroundColor: Color) - -private val avatarColorCache = mutableMapOf() - -fun getAvatarColor(name: String, isDarkTheme: Boolean): AvatarColors { - val cacheKey = "${name}_${if (isDarkTheme) "dark" else "light"}" - return avatarColorCache.getOrPut(cacheKey) { - val colors = if (isDarkTheme) avatarColorsDark else avatarColorsLight - val index = - name.hashCode().mod(colors.size).let { - if (it < 0) it + colors.size else it - } - val (textColor, bgColor) = colors[index] - AvatarColors(textColor, bgColor) - } -} - -// Cache для инициалов -private val initialsCache = mutableMapOf() - -fun getInitials(name: String): String { - return initialsCache.getOrPut(name) { - val words = name.trim().split(Regex("\\s+")).filter { it.isNotEmpty() } - when { - words.isEmpty() -> "??" - words.size == 1 -> words[0].take(2).uppercase() - else -> "${words[0].first()}${words[1].first()}".uppercase() - } - } -} - -// Get avatar text from public key (first 2 chars) -fun getAvatarText(publicKey: String): String { - return publicKey.take(2).uppercase() -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ChatsListScreen( - isDarkTheme: Boolean, - accountName: String, - accountPhone: String, - accountPublicKey: String, - accountPrivateKey: String = "", - privateKeyHash: String = "", - onToggleTheme: () -> Unit, - onProfileClick: () -> Unit, - onNewGroupClick: () -> Unit, - onContactsClick: () -> Unit, - onCallsClick: () -> Unit, - onSavedMessagesClick: () -> Unit, - onSettingsClick: () -> Unit, - onInviteFriendsClick: () -> Unit, - onSearchClick: () -> Unit, - onNewChat: () -> Unit, - onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {}, - chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(), - onLogout: () -> Unit -) { - // Theme transition state - var hasInitialized by remember { mutableStateOf(false) } - - LaunchedEffect(Unit) { hasInitialized = true } - - val view = androidx.compose.ui.platform.LocalView.current - val context = androidx.compose.ui.platform.LocalContext.current - val focusManager = androidx.compose.ui.platform.LocalFocusManager.current - val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) - val scope = rememberCoroutineScope() - - // 🔥 ВСЕГДА закрываем клавиатуру при появлении ChatsListScreen - // Используем DisposableEffect чтобы срабатывало при каждом появлении экрана - DisposableEffect(Unit) { - // Закрываем клавиатуру сразу - val imm = - context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as - android.view.inputmethod.InputMethodManager - imm.hideSoftInputFromWindow(view.windowToken, 0) - focusManager.clearFocus() - - onDispose {} - } - - // Дополнительно закрываем клавиатуру с небольшой задержкой (на случай если она появляется - // после) - LaunchedEffect(Unit) { - kotlinx.coroutines.delay(100) - val imm = - context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as - android.view.inputmethod.InputMethodManager - imm.hideSoftInputFromWindow(view.windowToken, 0) - focusManager.clearFocus() - } - - // Update status bar and completely hide navigation bar - LaunchedEffect(isDarkTheme) { - if (!view.isInEditMode) { - val window = (view.context as android.app.Activity).window - val insetsController = - androidx.core.view.WindowCompat.getInsetsController(window, view) - - // Status bar - insetsController.isAppearanceLightStatusBars = !isDarkTheme - window.statusBarColor = android.graphics.Color.TRANSPARENT - - // Completely hide navigation bar - insetsController.hide( - androidx.core.view.WindowInsetsCompat.Type.navigationBars() - ) - insetsController.systemBarsBehavior = - androidx.core.view.WindowInsetsControllerCompat - .BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - } - } - - // Colors - instant change, no animation - 🔥 КЭШИРУЕМ для производительности - val backgroundColor = - remember(isDarkTheme) { if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) } - val drawerBackgroundColor = - remember(isDarkTheme) { if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) } - val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black } - val secondaryTextColor = - remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E8E) else Color(0xFF8E8E93) } - - // Protocol connection state - val protocolState by ProtocolManager.state.collectAsState() - - // 🔥 Пользователи, которые сейчас печатают - val typingUsers by ProtocolManager.typingUsers.collectAsState() - - // Load dialogs when account is available - LaunchedEffect(accountPublicKey, accountPrivateKey) { - if (accountPublicKey.isNotEmpty() && accountPrivateKey.isNotEmpty()) { - chatsViewModel.setAccount(accountPublicKey, accountPrivateKey) - // Устанавливаем аккаунт для RecentSearchesManager - RecentSearchesManager.setAccount(accountPublicKey) - // 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих - // сообщений - ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey) - } - } - - // Status dialog state - var showStatusDialog by remember { mutableStateOf(false) } - val debugLogs by ProtocolManager.debugLogs.collectAsState() - - // 📱 FCM токен диалог - var showFcmDialog by remember { mutableStateOf(false) } - - // 📬 Requests screen state - var showRequestsScreen by remember { mutableStateOf(false) } - - // 🔥 Используем rememberSaveable чтобы сохранить состояние при навигации - // Header сразу visible = true, без анимации при возврате из чата - var visible by rememberSaveable { mutableStateOf(true) } - - // Confirmation dialogs state - var dialogToDelete by remember { mutableStateOf(null) } - var dialogToBlock by remember { mutableStateOf(null) } - var dialogToUnblock by remember { mutableStateOf(null) } - - // Trigger для обновления статуса блокировки - var blocklistUpdateTrigger by remember { mutableStateOf(0) } - - // Dev console dialog - commented out for now - /* - if (showDevConsole) { - AlertDialog( - onDismissRequest = { showDevConsole = false }, - title = { Text("Dev Console", fontWeight = FontWeight.Bold) }, - text = { Text("Dev console temporarily disabled") }, - confirmButton = { - Button(onClick = { showDevConsole = false }) { - Text("Close") - } - }, - containerColor = if (isDarkTheme) Color(0xFF212121) else Color.White - ) - } - */ - - // Status dialog - if (showStatusDialog) { - AlertDialog( - onDismissRequest = { showStatusDialog = false }, - title = { - Text( - "Connection Status", - fontWeight = FontWeight.Bold, - color = textColor - ) - }, - text = { - Column(modifier = Modifier.fillMaxWidth()) { - // Status indicator - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier.fillMaxWidth() - .padding(bottom = 12.dp) - ) { - Box( - modifier = - Modifier.size(12.dp) - .clip(CircleShape) - .background( - when (protocolState - ) { - ProtocolState - .AUTHENTICATED -> - Color( - 0xFF4CAF50 - ) - ProtocolState - .CONNECTING, - ProtocolState - .CONNECTED, - ProtocolState - .HANDSHAKING -> - Color( - 0xFFFFC107 - ) - ProtocolState - .DISCONNECTED -> - Color( - 0xFFF44336 - ) - } - ) - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = - when (protocolState) { - ProtocolState - .DISCONNECTED -> - "Disconnected" - ProtocolState.CONNECTING -> - "Connecting..." - ProtocolState.CONNECTED -> - "Connected" - ProtocolState.HANDSHAKING -> - "Authenticating..." - ProtocolState - .AUTHENTICATED -> - "Authenticated" - }, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = textColor - ) - } - } - }, - confirmButton = { - Button( - onClick = { showStatusDialog = false }, - colors = - ButtonDefaults.buttonColors( - containerColor = PrimaryBlue - ) - ) { Text("Close", color = Color.White) } - }, - containerColor = if (isDarkTheme) Color(0xFF212121) else Color.White - ) - } - - // Simple background - Box(modifier = Modifier.fillMaxSize().background(backgroundColor).navigationBarsPadding()) { - ModalNavigationDrawer( - drawerState = drawerState, - drawerContent = { - ModalDrawerSheet( - drawerContainerColor = Color.Transparent, - windowInsets = - WindowInsets( - 0 - ), // 🎨 Убираем системные отступы - drawer идет до - // верха - modifier = Modifier.width(300.dp) - ) { - Column( - modifier = - Modifier.fillMaxSize() - .background(drawerBackgroundColor) - ) { - // ═══════════════════════════════════════════════════════════ - // 🎨 DRAWER HEADER - Avatar and status - // ═══════════════════════════════════════════════════════════ - val headerColor = - if (isDarkTheme) { - Color(0xFF2C5282) - } else { - Color(0xFF4A90D9) - } - - Box( - modifier = - Modifier.fillMaxWidth() - .background( - color = headerColor - ) - .statusBarsPadding() // 🎨 - // Контент начинается - // после status bar - .padding( - top = 16.dp, - start = 20.dp, - end = 20.dp, - bottom = 20.dp - ) - ) { - Column { - // Avatar with border - val avatarColors = - getAvatarColor( - accountPublicKey, - isDarkTheme - ) - Box( - modifier = - Modifier.size(72.dp) - .clip( - CircleShape - ) - .background( - Color.White - .copy( - alpha = - 0.2f - ) - ) - .padding( - 3.dp - ) - .clip( - CircleShape - ) - .background( - avatarColors - .backgroundColor - ), - contentAlignment = - Alignment.Center - ) { - Text( - text = - getAvatarText( - accountPublicKey - ), - fontSize = 26.sp, - fontWeight = - FontWeight - .Bold, - color = - avatarColors - .textColor - ) - } - - Spacer( - modifier = - Modifier.height( - 14.dp - ) - ) - - // Public key (username style) - - // clickable для копирования - val truncatedKey = - if (accountPublicKey - .length > 16 - ) { - "${accountPublicKey.take(8)}...${accountPublicKey.takeLast(6)}" - } else accountPublicKey - - val context = - androidx.compose.ui.platform - .LocalContext - .current - var showCopiedToast by remember { - mutableStateOf(false) - } - - // Плавная замена текста - - // ускоренная анимация - AnimatedContent( - targetState = - showCopiedToast, - transitionSpec = { - fadeIn( - animationSpec = - tween( - 150 - ) - ) togetherWith - fadeOut( - animationSpec = - tween( - 150 - ) - ) - }, - label = "copiedAnimation" - ) { isCopied -> - Text( - text = - if (isCopied - ) - "Copied!" - else - truncatedKey, - fontSize = 16.sp, - fontWeight = - FontWeight - .SemiBold, - color = Color.White, - fontStyle = - if (isCopied - ) - androidx.compose - .ui - .text - .font - .FontStyle - .Italic - else - androidx.compose - .ui - .text - .font - .FontStyle - .Normal, - modifier = - Modifier - .clickable { - if (!showCopiedToast - ) { - // Копируем публичный ключ - val clipboard = - context.getSystemService( - android.content - .Context - .CLIPBOARD_SERVICE - ) as - android.content.ClipboardManager - val clip = - android.content - .ClipData - .newPlainText( - "Public Key", - accountPublicKey - ) - clipboard - .setPrimaryClip( - clip - ) - showCopiedToast = - true - } - } - ) - } - - // Автоматически возвращаем обратно - // через 1.5 секунды - if (showCopiedToast) { - LaunchedEffect(Unit) { - kotlinx.coroutines - .delay(1500) - showCopiedToast = - false - } - } - - Spacer( - modifier = - Modifier.height( - 6.dp - ) - ) - - // Username display - if (accountName.isNotEmpty()) { - Text( - text = - "@$accountName", - fontSize = 13.sp, - color = - Color.White - .copy( - alpha = - 0.85f - ) - ) - } - } - } - - // ═══════════════════════════════════════════════════════════ - // 📱 MENU ITEMS - // ═══════════════════════════════════════════════════════════ - Column( - modifier = - Modifier.fillMaxWidth() - .weight(1f) - .verticalScroll( - rememberScrollState() - ) - .padding(vertical = 8.dp) - ) { - val menuIconColor = - textColor.copy(alpha = 0.6f) - - // 👤 Profile Section - DrawerMenuItemEnhanced( - icon = Icons.Outlined.Person, - text = "My Profile", - iconColor = menuIconColor, - textColor = textColor, - onClick = { - scope.launch { - drawerState.close() - } - onProfileClick() - } - ) - - // 📖 Saved Messages - DrawerMenuItemEnhanced( - icon = Icons.Outlined.Bookmark, - text = "Saved Messages", - iconColor = menuIconColor, - textColor = textColor, - onClick = { - scope.launch { - drawerState.close() - // Ждём завершения - // анимации закрытия - // drawer - kotlinx.coroutines - .delay(250) - onSavedMessagesClick() - } - } - ) - - DrawerDivider(isDarkTheme) - - // 👥 Contacts - DrawerMenuItemEnhanced( - icon = Icons.Outlined.Contacts, - text = "Contacts", - iconColor = menuIconColor, - textColor = textColor, - onClick = { - scope.launch { - drawerState.close() - } - onContactsClick() - } - ) - - // 📞 Calls - DrawerMenuItemEnhanced( - icon = Icons.Outlined.Call, - text = "Calls", - iconColor = menuIconColor, - textColor = textColor, - onClick = { - scope.launch { - drawerState.close() - } - onCallsClick() - } - ) - - // ➕ Invite Friends - DrawerMenuItemEnhanced( - icon = Icons.Outlined.PersonAdd, - text = "Invite Friends", - iconColor = menuIconColor, - textColor = textColor, - onClick = { - scope.launch { - drawerState.close() - } - onInviteFriendsClick() - } - ) - - DrawerDivider(isDarkTheme) - - // 🔔 FCM Logs - DrawerMenuItemEnhanced( - icon = Icons.Outlined.Notifications, - text = "FCM Token Logs", - iconColor = menuIconColor, - textColor = textColor, - onClick = { - scope.launch { - drawerState.close() - } - showFcmDialog = true - } - ) - - // ⚙️ Settings - DrawerMenuItemEnhanced( - icon = Icons.Outlined.Settings, - text = "Settings", - iconColor = menuIconColor, - textColor = textColor, - onClick = { - scope.launch { - drawerState.close() - } - onSettingsClick() - } - ) - - // 🌓 Theme Toggle - DrawerMenuItemEnhanced( - icon = - if (isDarkTheme) - Icons.Outlined - .LightMode - else - Icons.Outlined - .DarkMode, - text = - if (isDarkTheme) - "Light Mode" - else "Dark Mode", - iconColor = menuIconColor, - textColor = textColor, - onClick = { onToggleTheme() } - ) - - // ❓ Help - DrawerMenuItemEnhanced( - icon = Icons.Outlined.HelpOutline, - text = "Help & FAQ", - iconColor = menuIconColor, - textColor = textColor, - onClick = { - scope.launch { - drawerState.close() - } - // TODO: Add help screen - // navigation - } - ) - } - - // ═══════════════════════════════════════════════════════════ - // 🚪 FOOTER - Logout & Version - // ═══════════════════════════════════════════════════════════ - Column(modifier = Modifier.fillMaxWidth()) { - Divider( - color = - if (isDarkTheme) - Color(0xFF2A2A2A) - else Color(0xFFE8E8E8), - thickness = 0.5.dp - ) - - // Logout - DrawerMenuItemEnhanced( - icon = Icons.Outlined.Logout, - text = "Log Out", - iconColor = Color(0xFFFF4444), - textColor = Color(0xFFFF4444), - onClick = { - scope.launch { - drawerState.close() - kotlinx.coroutines - .delay(150) - onLogout() - } - } - ) - - // Version info - Box( - modifier = - Modifier.fillMaxWidth() - .padding( - horizontal = - 20.dp, - vertical = - 12.dp - ), - contentAlignment = - Alignment.CenterStart - ) { - Text( - text = "Rosetta v1.0.0", - fontSize = 12.sp, - color = - if (isDarkTheme) - Color( - 0xFF666666 - ) - else - Color( - 0xFF999999 - ) - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - } - } - } - } - ) { - Scaffold( - topBar = { - AnimatedVisibility( - visible = visible, - enter = - fadeIn(tween(300)) + - expandVertically( - animationSpec = - tween( - 300, - easing = - FastOutSlowInEasing - ) - ), - exit = - fadeOut(tween(200)) + - shrinkVertically( - animationSpec = tween(200) - ) - ) { - key(isDarkTheme, showRequestsScreen) { - TopAppBar( - navigationIcon = { - if (showRequestsScreen) { - // Back button for - // Requests - IconButton( - onClick = { - showRequestsScreen = - false - } - ) { - Icon( - Icons.Default - .ArrowBack, - contentDescription = - "Back", - tint = - PrimaryBlue - ) - } - } else { - // Menu button for - // main screen - IconButton( - onClick = { - scope - .launch { - drawerState - .open() - } - } - ) { - Icon( - Icons.Default - .Menu, - contentDescription = - "Menu", - tint = - textColor - .copy( - alpha = - 0.6f - ) - ) - } - } - }, - title = { - if (showRequestsScreen) { - // Requests title - Text( - "Requests", - fontWeight = - FontWeight - .Bold, - fontSize = - 20.sp, - color = - textColor - ) - } else { - // Rosetta title - // with status - Row( - verticalAlignment = - Alignment - .CenterVertically - ) { - Text( - "Rosetta", - fontWeight = - FontWeight - .Bold, - fontSize = - 20.sp, - color = - textColor - ) - Spacer( - modifier = - Modifier.width( - 8.dp - ) - ) - Box( - modifier = - Modifier.size( - 10.dp - ) - .clip( - CircleShape - ) - .background( - when (protocolState - ) { - ProtocolState - .AUTHENTICATED -> - Color( - 0xFF4CAF50 - ) - ProtocolState - .CONNECTING, - ProtocolState - .CONNECTED, - ProtocolState - .HANDSHAKING -> - Color( - 0xFFFFC107 - ) - ProtocolState - .DISCONNECTED -> - Color( - 0xFFF44336 - ) - } - ) - .clickable { - showStatusDialog = - true - } - ) - } - } - }, - actions = { - // Search only on main - // screen - if (!showRequestsScreen) { - IconButton( - onClick = { - if (protocolState == - ProtocolState - .AUTHENTICATED - ) { - onSearchClick() - } - }, - enabled = - protocolState == - ProtocolState - .AUTHENTICATED - ) { - Icon( - Icons.Default - .Search, - contentDescription = - "Search", - tint = - if (protocolState == - ProtocolState - .AUTHENTICATED - ) - textColor - .copy( - alpha = - 0.6f - ) - else - textColor - .copy( - alpha = - 0.5f - ) - ) - } - } - }, - colors = - TopAppBarDefaults - .topAppBarColors( - containerColor = - backgroundColor, - scrolledContainerColor = - backgroundColor, - navigationIconContentColor = - textColor, - titleContentColor = - textColor, - actionIconContentColor = - textColor - ) - ) - } - } - }, - floatingActionButton = { - AnimatedVisibility( - visible = false, // Hidden for now - enter = - fadeIn(tween(500, delayMillis = 300)) + - scaleIn( - initialScale = 0.5f, - animationSpec = - tween( - 500, - delayMillis = - 300 - ) - ) - ) { - FloatingActionButton( - onClick = onNewChat, - containerColor = PrimaryBlue, - contentColor = Color.White, - shape = CircleShape - ) { - Icon( - Icons.Default.Edit, - contentDescription = "New Chat" - ) - } - } - }, - containerColor = backgroundColor - ) { paddingValues -> - // Main content - Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { - // � Используем комбинированное состояние для атомарного - // обновления - // Это предотвращает "дергание" UI когда dialogs и requests - // обновляются - // независимо - val chatsState by chatsViewModel.chatsState.collectAsState() - val requests = chatsState.requests - val requestsCount = chatsState.requestsCount - - // 🔥 ИСПРАВЛЕНИЕ МЕРЦАНИЯ: Запоминаем, что контент УЖЕ был - // показан - // Это предотвращает показ EmptyState при временных пустых - // обновлениях - var hasShownContent by rememberSaveable { - mutableStateOf(false) - } - if (chatsState.hasContent) { - hasShownContent = true - } - - // 🎯 Показываем Empty State только если контент НИКОГДА не - // показывался - val shouldShowEmptyState = - chatsState.isEmpty && !hasShownContent - - // 🎬 Animated content transition between main list and - // requests - AnimatedContent( - targetState = showRequestsScreen, - transitionSpec = { - fadeIn( - animationSpec = tween(200) - ) togetherWith - fadeOut(animationSpec = tween(150)) - }, - label = "RequestsTransition" - ) { isRequestsScreen -> - if (isRequestsScreen) { - // 📬 Show Requests Screen - RequestsScreen( - requests = requests, - isDarkTheme = isDarkTheme, - onBack = { - showRequestsScreen = false - }, - onRequestClick = { request -> - showRequestsScreen = false - val user = - chatsViewModel - .dialogToSearchUser( - request - ) - onUserSelect(user) - } - ) - } else if (shouldShowEmptyState) { - // 🔥 Empty state - показываем только если - // контент НЕ был показан ранее - EmptyChatsState( - isDarkTheme = isDarkTheme, - modifier = Modifier.fillMaxSize() - ) - } else { - // Show dialogs list - val dividerColor = - if (isDarkTheme) Color(0xFF3A3A3A) - else Color(0xFFE8E8E8) - // 🔥 Берем dialogs из chatsState для - // консистентности - val currentDialogs = chatsState.dialogs - - LazyColumn( - modifier = Modifier.fillMaxSize() - ) { - // � FCM Logs Section - val fcmLogs = - com.rosetta.messenger - .MainActivity - .fcmLogs - if (fcmLogs.isNotEmpty()) { - item( - key = - "fcm_logs_section" - ) { - FcmLogsSection( - logs = - fcmLogs, - isDarkTheme = - isDarkTheme, - onClearLogs = { - com.rosetta - .messenger - .MainActivity - .clearFcmLogs() - } - ) - Divider( - color = - dividerColor, - thickness = - 0.5.dp - ) - } - } - - // �📬 Requests Section - if (requestsCount > 0) { - item( - key = - "requests_section" - ) { - RequestsSection( - count = - requestsCount, - isDarkTheme = - isDarkTheme, - onClick = { - showRequestsScreen = - true - } - ) - Divider( - color = - dividerColor, - thickness = - 0.5.dp - ) - } - } - - items( - currentDialogs, - key = { it.opponentKey } - ) { dialog -> - val isSavedMessages = - dialog.opponentKey == - accountPublicKey - // Check if user is blocked - var isBlocked by remember { - mutableStateOf( - false - ) - } - LaunchedEffect( - dialog.opponentKey, - blocklistUpdateTrigger - ) { - isBlocked = - chatsViewModel - .isUserBlocked( - dialog.opponentKey - ) - } - - Column { - SwipeableDialogItem( - dialog = - dialog, - isDarkTheme = - isDarkTheme, - isTyping = - typingUsers - .contains( - dialog.opponentKey - ), - isBlocked = - isBlocked, - isSavedMessages = - isSavedMessages, - onClick = { - val user = - chatsViewModel - .dialogToSearchUser( - dialog - ) - onUserSelect( - user - ) - }, - onDelete = { - dialogToDelete = - dialog - }, - onBlock = { - dialogToBlock = - dialog - }, - onUnblock = { - dialogToUnblock = - dialog - } - ) - - // 🔥 СЕПАРАТОР - - // линия разделения - // между диалогами - Divider( - modifier = - Modifier.padding( - start = - 84.dp - ), - color = - dividerColor, - thickness = - 0.5.dp - ) - } - } - } - } - } // Close AnimatedContent - - // Console button removed - } - } - } // Close ModalNavigationDrawer - - // 🔥 Confirmation Dialogs - - // Delete Dialog Confirmation - dialogToDelete?.let { dialog -> - AlertDialog( - onDismissRequest = { dialogToDelete = null }, - containerColor = - if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, - title = { - Text( - "Delete Chat", - fontWeight = FontWeight.Bold, - color = textColor - ) - }, - text = { - Text( - "Are you sure you want to delete this chat? This action cannot be undone.", - color = secondaryTextColor - ) - }, - confirmButton = { - TextButton( - onClick = { - val opponentKey = dialog.opponentKey - dialogToDelete = null - scope.launch { - chatsViewModel.deleteDialog( - opponentKey - ) - } - } - ) { Text("Delete", color = Color(0xFFFF3B30)) } - }, - dismissButton = { - TextButton(onClick = { dialogToDelete = null }) { - Text("Cancel", color = PrimaryBlue) - } - } - ) - } - - // Block Dialog Confirmation - dialogToBlock?.let { dialog -> - AlertDialog( - onDismissRequest = { dialogToBlock = null }, - containerColor = - if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, - title = { - Text( - "Block ${dialog.opponentTitle.ifEmpty { "User" }}", - fontWeight = FontWeight.Bold, - color = textColor - ) - }, - text = { - Text( - "Are you sure you want to block this user? They won't be able to send you messages.", - color = secondaryTextColor - ) - }, - confirmButton = { - TextButton( - onClick = { - val opponentKey = dialog.opponentKey - dialogToBlock = null - scope.launch { - chatsViewModel.blockUser( - opponentKey - ) - blocklistUpdateTrigger++ - } - } - ) { Text("Block", color = Color(0xFFFF3B30)) } - }, - dismissButton = { - TextButton(onClick = { dialogToBlock = null }) { - Text("Cancel", color = PrimaryBlue) - } - } - ) - } - - // Unblock Dialog Confirmation - dialogToUnblock?.let { dialog -> - AlertDialog( - onDismissRequest = { dialogToUnblock = null }, - containerColor = - if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, - title = { - Text( - "Unblock ${dialog.opponentTitle.ifEmpty { "User" }}", - fontWeight = FontWeight.Bold, - color = textColor - ) - }, - text = { - Text( - "Are you sure you want to unblock this user? They will be able to send you messages again.", - color = secondaryTextColor - ) - }, - confirmButton = { - TextButton( - onClick = { - val opponentKey = dialog.opponentKey - dialogToUnblock = null - scope.launch { - chatsViewModel.unblockUser( - opponentKey - ) - blocklistUpdateTrigger++ - } - } - ) { Text("Unblock", color = PrimaryBlue) } - }, - dismissButton = { - TextButton(onClick = { dialogToUnblock = null }) { - Text("Cancel", color = Color(0xFF8E8E93)) - } - } - ) - } - } // Close Box -} - -@Composable -private fun EmptyChatsState(isDarkTheme: Boolean, modifier: Modifier = Modifier) { - val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - - // Lottie animation - val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.letter)) - val progress by animateLottieCompositionAsState(composition = composition, iterations = 1) - - Column( - modifier = modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - // Lottie animation - LottieAnimation( - composition = composition, - progress = { progress }, - modifier = Modifier.size(150.dp) - ) - - Spacer(modifier = Modifier.height(24.dp)) - - Text( - text = "No conversations yet", - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, - color = secondaryTextColor, - textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = "Start a new conversation to get started", - fontSize = 15.sp, - color = secondaryTextColor, - textAlign = TextAlign.Center - ) - } -} - -// Chat item for list -@Composable -fun ChatItem(chat: Chat, isDarkTheme: Boolean, onClick: () -> Unit) { - val textColor = if (isDarkTheme) Color.White else Color.Black - val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) - - val avatarColors = getAvatarColor(chat.publicKey, isDarkTheme) - val avatarText = getAvatarText(chat.publicKey) - - Column { - Row( - modifier = - Modifier.fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Avatar - Box( - modifier = - Modifier.size(56.dp) - .clip(CircleShape) - .background(avatarColors.backgroundColor), - contentAlignment = Alignment.Center - ) { - Text( - text = avatarText, - fontSize = 20.sp, - fontWeight = FontWeight.SemiBold, - color = avatarColors.textColor - ) - - // Online indicator - if (chat.isOnline) { - Box( - modifier = - Modifier.align(Alignment.BottomEnd) - .offset(x = 2.dp, y = 2.dp) - .size(16.dp) - .clip(CircleShape) - .background( - if (isDarkTheme) - Color(0xFF1A1A1A) - else Color.White - ) - .padding(2.dp) - .clip(CircleShape) - .background(Color(0xFF4CAF50)) - ) - } - } - - Spacer(modifier = Modifier.width(12.dp)) - - Column(modifier = Modifier.weight(1f)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = chat.name, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - - Row(verticalAlignment = Alignment.CenterVertically) { - // Read status - Icon( - Icons.Default.DoneAll, - contentDescription = null, - tint = PrimaryBlue, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = formatTime(chat.lastMessageTime), - fontSize = 13.sp, - color = secondaryTextColor - ) - } - } - - Spacer(modifier = Modifier.height(4.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - // 🔥 Используем AppleEmojiText для отображения эмодзи - AppleEmojiText( - text = chat.lastMessage, - fontSize = 14.sp, - color = secondaryTextColor, - maxLines = 1, - overflow = android.text.TextUtils.TruncateAt.END, - modifier = Modifier.weight(1f) - ) - - Row(verticalAlignment = Alignment.CenterVertically) { - // Pin icon - if (chat.isPinned) { - Icon( - Icons.Default.PushPin, - contentDescription = "Pinned", - tint = - secondaryTextColor.copy( - alpha = 0.6f - ), - modifier = - Modifier.size(16.dp) - .padding(end = 4.dp) - ) - } - - // Unread badge - if (chat.unreadCount > 0) { - Box( - modifier = - Modifier.clip(CircleShape) - .background( - PrimaryBlue - ) - .padding( - horizontal = - 8.dp, - vertical = - 2.dp - ), - contentAlignment = Alignment.Center - ) { - Text( - text = - if (chat.unreadCount > - 99 - ) - "99+" - else - chat.unreadCount - .toString(), - fontSize = 12.sp, - fontWeight = - FontWeight.SemiBold, - color = Color.White - ) - } - } - } - } - } - } - - Divider( - modifier = Modifier.padding(start = 84.dp), - color = dividerColor, - thickness = 0.5.dp - ) - } -} - -// Cache для SimpleDateFormat - создание дорогостоящее -private val timeFormatCache = - java.lang.ThreadLocal.withInitial { SimpleDateFormat("HH:mm", Locale.getDefault()) } -private val weekFormatCache = - java.lang.ThreadLocal.withInitial { SimpleDateFormat("EEE", Locale.getDefault()) } -private val monthFormatCache = - java.lang.ThreadLocal.withInitial { SimpleDateFormat("MMM d", Locale.getDefault()) } -private val yearFormatCache = - java.lang.ThreadLocal.withInitial { SimpleDateFormat("dd.MM.yy", Locale.getDefault()) } - -private fun formatTime(date: Date): String { - val now = Calendar.getInstance() - val messageTime = Calendar.getInstance().apply { time = date } - - return when { - now.get(Calendar.DATE) == messageTime.get(Calendar.DATE) -> { - timeFormatCache.get()?.format(date) ?: "" - } - now.get(Calendar.DATE) - messageTime.get(Calendar.DATE) == 1 -> { - "Yesterday" - } - now.get(Calendar.WEEK_OF_YEAR) == messageTime.get(Calendar.WEEK_OF_YEAR) -> { - weekFormatCache.get()?.format(date) ?: "" - } - now.get(Calendar.YEAR) == messageTime.get(Calendar.YEAR) -> { - monthFormatCache.get()?.format(date) ?: "" - } - else -> { - yearFormatCache.get()?.format(date) ?: "" - } - } -} - -/** Элемент меню в боковом drawer */ -@Composable -fun DrawerMenuItem( - icon: androidx.compose.ui.graphics.vector.ImageVector, - text: String, - isDarkTheme: Boolean, - isDestructive: Boolean = false, - onClick: () -> Unit -) { - val textColor = - if (isDestructive) { - Color(0xFFFF3B30) - } else { - if (isDarkTheme) Color.White else Color.Black - } - - val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) - - Row( - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .clip(RoundedCornerShape(12.dp)) - .background(backgroundColor) - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = textColor.copy(alpha = 0.6f), - modifier = Modifier.size(24.dp) - ) - - Spacer(modifier = Modifier.width(16.dp)) - - Text( - text = text, - fontSize = 16.sp, - color = textColor, - fontWeight = FontWeight.Medium - ) - } -} - -/** - * 🔥 Swipeable wrapper для DialogItem с кнопками Block и Delete Свайп влево показывает действия - * (как в React Native версии) - */ -@Composable -fun SwipeableDialogItem( - dialog: DialogUiModel, - isDarkTheme: Boolean, - isTyping: Boolean = false, - isBlocked: Boolean = false, - isSavedMessages: Boolean = false, - onClick: () -> Unit, - onDelete: () -> Unit = {}, - onBlock: () -> Unit = {}, - onUnblock: () -> Unit = {} -) { - val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) - var offsetX by remember { mutableStateOf(0f) } - val swipeWidthDp = if (isSavedMessages) 80.dp else 160.dp - val density = androidx.compose.ui.platform.LocalDensity.current - val swipeWidthPx = with(density) { swipeWidthDp.toPx() } - - // Фиксированная высота элемента (как в DialogItem) - val itemHeight = 80.dp - - // Анимация возврата - val animatedOffsetX by - animateFloatAsState( - targetValue = offsetX, - animationSpec = spring(dampingRatio = 0.7f, stiffness = 400f), - label = "swipeOffset" - ) - - Box(modifier = Modifier.fillMaxWidth().height(itemHeight).clipToBounds()) { - // 1. КНОПКИ - позиционированы справа, всегда видны при свайпе - Row( - modifier = - Modifier.align(Alignment.CenterEnd) - .height(itemHeight) - .width(swipeWidthDp) - ) { - // Кнопка Block/Unblock (только если не Saved Messages) - if (!isSavedMessages) { - Box( - modifier = - Modifier.width(80.dp) - .fillMaxHeight() - .background( - if (isBlocked) Color(0xFF4CAF50) - else Color(0xFFFF6B6B) - ) - .clickable { - if (isBlocked) onUnblock() - else onBlock() - offsetX = 0f - }, - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - imageVector = - if (isBlocked) - Icons.Default.LockOpen - else Icons.Default.Block, - contentDescription = - if (isBlocked) "Unblock" - else "Block", - tint = Color.White, - modifier = Modifier.size(22.dp) - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = - if (isBlocked) "Unblock" - else "Block", - color = Color.White, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold - ) - } - } - } - - // Кнопка Delete - Box( - modifier = - Modifier.width(80.dp) - .fillMaxHeight() - .background(PrimaryBlue) - .clickable { - // Закрываем свайп мгновенно перед удалением - offsetX = 0f - onDelete() - }, - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = "Delete", - tint = Color.White, - modifier = Modifier.size(22.dp) - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Delete", - color = Color.White, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold - ) - } - } - } - - // 2. КОНТЕНТ - поверх кнопок, сдвигается при свайпе - Column( - modifier = - Modifier.fillMaxSize() - .offset { IntOffset(animatedOffsetX.toInt(), 0) } - .background(backgroundColor) - .pointerInput(Unit) { - detectHorizontalDragGestures( - onDragEnd = { - // Если свайпнули больше чем на - // половину - фиксируем - if (kotlin.math.abs(offsetX) > - swipeWidthPx / 2 - ) { - offsetX = -swipeWidthPx - } else { - offsetX = 0f - } - }, - onDragCancel = { offsetX = 0f }, - onHorizontalDrag = { _, dragAmount -> - // Только свайп влево (отрицательное - // значение) - val newOffset = offsetX + dragAmount - offsetX = - newOffset.coerceIn( - -swipeWidthPx, - 0f - ) - } - ) - } - ) { - DialogItemContent( - dialog = dialog, - isDarkTheme = isDarkTheme, - isTyping = isTyping, - onClick = onClick - ) - - // Сепаратор внутри контента - val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) - Divider( - modifier = Modifier.padding(start = 84.dp), - color = dividerColor, - thickness = 0.5.dp - ) - } - } -} - -/** Элемент диалога из базы данных - ОПТИМИЗИРОВАННЫЙ (без сепаратора для SwipeableDialogItem) */ -@Composable -fun DialogItemContent( - dialog: DialogUiModel, - isDarkTheme: Boolean, - isTyping: Boolean = false, - onClick: () -> Unit -) { - // 🔥 ОПТИМИЗАЦИЯ: Кешируем цвета и строки - val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black } - val secondaryTextColor = - remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) } - - val avatarColors = - remember(dialog.opponentKey, isDarkTheme) { - getAvatarColor(dialog.opponentKey, isDarkTheme) - } - - // 📁 Для Saved Messages показываем специальное имя - // 🔥 Как в Архиве: title > username > "DELETED" - val displayName = - remember( - dialog.opponentTitle, - dialog.opponentUsername, - dialog.opponentKey, - dialog.isSavedMessages - ) { - if (dialog.isSavedMessages) { - "Saved Messages" - } else if (dialog.opponentTitle.isNotEmpty() && - dialog.opponentTitle != dialog.opponentKey && - dialog.opponentTitle != dialog.opponentKey.take(7) && - dialog.opponentTitle != dialog.opponentKey.take(8) - ) { - // 🔥 Показываем title как основное имя (как в десктопной версии) - // Обрезаем до 15 символов как в Архиве - if (dialog.opponentTitle.length > 15) { - dialog.opponentTitle.take(15) + "..." - } else { - dialog.opponentTitle - } - } else if (dialog.opponentUsername.isNotEmpty()) { - // Username только если нет title - "@${dialog.opponentUsername}" - } else { - // 🔥 Как в Архиве - если нет информации, показываем часть ключа - dialog.opponentKey.take(7) - } - } - - // 📁 Для Saved Messages показываем иконку закладки - // 🔥 Как в Архиве: инициалы из title или username или DELETED - val initials = - remember( - dialog.opponentTitle, - dialog.opponentUsername, - dialog.opponentKey, - dialog.isSavedMessages - ) { - if (dialog.isSavedMessages) { - "" // Для Saved Messages - пустая строка, будет использоваться - // иконка - } else if (dialog.opponentTitle.isNotEmpty() && - dialog.opponentTitle != dialog.opponentKey && - dialog.opponentTitle != dialog.opponentKey.take(7) && - dialog.opponentTitle != dialog.opponentKey.take(8) - ) { - // Используем title для инициалов - dialog.opponentTitle - .split(" ") - .take(2) - .mapNotNull { it.firstOrNull()?.uppercase() } - .joinToString("") - .ifEmpty { dialog.opponentTitle.take(2).uppercase() } - } else if (dialog.opponentUsername.isNotEmpty()) { - // Если только username - берем первые 2 символа - dialog.opponentUsername.take(2).uppercase() - } else { - dialog.opponentKey.take(2).uppercase() - } - } - - Row( - modifier = - Modifier.fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Avatar container with online indicator - Box(modifier = Modifier.size(56.dp)) { - // Avatar - Box( - modifier = - Modifier.fillMaxSize() - .clip(CircleShape) - .background( - if (dialog.isSavedMessages) PrimaryBlue - else avatarColors.backgroundColor - ), - contentAlignment = Alignment.Center - ) { - if (dialog.isSavedMessages) { - Icon( - Icons.Default.Bookmark, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(24.dp) - ) - } else { - Text( - text = initials, - color = avatarColors.textColor, - fontWeight = FontWeight.SemiBold, - fontSize = 18.sp - ) - } - } - - // Online indicator - зелёный кружок с белой обводкой - if (dialog.isOnline == 1) { - Box( - modifier = - Modifier.size(18.dp) - .align(Alignment.BottomEnd) - .offset(x = (-2).dp, y = (-2).dp) - .clip(CircleShape) - .background( - if (isDarkTheme) Color(0xFF1C1C1E) - else Color.White - ) - .padding(3.dp) - .clip(CircleShape) - .background( - Color(0xFF34C759) - ) // iOS зелёный цвет - ) - } - } - - Spacer(modifier = Modifier.width(12.dp)) - - // Name and last message - Column(modifier = Modifier.weight(1f)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = displayName, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End - ) { - // 📁 Для Saved Messages ВСЕГДА показываем синие двойные - // галочки (прочитано) - if (dialog.isSavedMessages) { - Icon( - imageVector = Icons.Default.DoneAll, - contentDescription = null, - tint = PrimaryBlue, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - } else if (dialog.lastMessageFromMe == 1) { - // Показываем статус только для исходящих сообщений - // (кроме Saved Messages) - // 🔥 ПРАВИЛЬНАЯ ЛОГИКА (синхронизировано с - // ChatViewModel): - // - lastMessageDelivered == 3 → две синие галочки - // (прочитано собеседником) - // - lastMessageDelivered == 1 → одна галочка - // (доставлено) - // - lastMessageDelivered == 0 → часики - // (отправляется) - // - lastMessageDelivered == 2 → ошибка - when (dialog.lastMessageDelivered) { - 2 -> { - // ERROR - показываем иконку ошибки - Icon( - imageVector = - Icons.Outlined - .ErrorOutline, - contentDescription = - "Sending failed", - tint = - Color( - 0xFFFF3B30 - ), // iOS красный - modifier = - Modifier.size(16.dp) - ) - Spacer( - modifier = - Modifier.width(4.dp) - ) - } - 3 -> { - // READ (delivered=3) - две синие - // галочки - Icon( - imageVector = - Icons.Default - .DoneAll, - contentDescription = null, - tint = PrimaryBlue, - modifier = - Modifier.size(16.dp) - ) - Spacer( - modifier = - Modifier.width(4.dp) - ) - } - else -> { - // DELIVERED (1) или SENDING (0) - - // одна серая галочка - Icon( - imageVector = - Icons.Default.Done, - contentDescription = null, - tint = - secondaryTextColor - .copy( - alpha = - 0.6f - ), - modifier = - Modifier.size(16.dp) - ) - Spacer( - modifier = - Modifier.width(4.dp) - ) - } - } - } - - Text( - text = - formatTime( - Date(dialog.lastMessageTimestamp) - ), - fontSize = 13.sp, - color = - if (dialog.unreadCount > 0) PrimaryBlue - else secondaryTextColor - ) - } - } - - Spacer(modifier = Modifier.height(4.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - // 🔥 Показываем typing индикатор или последнее сообщение - if (isTyping) { - TypingIndicatorSmall() - } else { - // 🔥 Используем AppleEmojiText для отображения эмодзи - // Если есть непрочитанные - текст темнее - AppleEmojiText( - text = dialog.lastMessage.ifEmpty { "No messages" }, - fontSize = 14.sp, - color = - if (dialog.unreadCount > 0) - textColor.copy(alpha = 0.85f) - else secondaryTextColor, - fontWeight = - if (dialog.unreadCount > 0) - FontWeight.Medium - else FontWeight.Normal, - maxLines = 1, - overflow = android.text.TextUtils.TruncateAt.END, - modifier = Modifier.weight(1f) - ) - } - - // Unread badge - if (dialog.unreadCount > 0) { - Spacer(modifier = Modifier.width(8.dp)) - val unreadText = - when { - dialog.unreadCount > 999 -> "999+" - dialog.unreadCount > 99 -> "99+" - else -> dialog.unreadCount.toString() - } - Box( - modifier = - Modifier.height(22.dp) - .widthIn(min = 22.dp) - .clip(RoundedCornerShape(11.dp)) - .background(PrimaryBlue) - .padding(horizontal = 6.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = unreadText, - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - color = Color.White, - maxLines = 1 - ) - } - } - } - } - } -} - -/** - * 🔥 Компактный индикатор typing для списка чатов Голубой текст "typing" с анимированными точками - */ -@Composable -fun TypingIndicatorSmall() { - val infiniteTransition = rememberInfiniteTransition(label = "typing") - val typingColor = PrimaryBlue - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(1.dp) - ) { - Text( - text = "typing", - fontSize = 14.sp, - color = typingColor, - fontWeight = FontWeight.Medium - ) - - // 3 анимированные точки - repeat(3) { index -> - val offsetY by - infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = -3f, - animationSpec = - infiniteRepeatable( - animation = - tween( - durationMillis = 500, - delayMillis = index * 120, - easing = FastOutSlowInEasing - ), - repeatMode = RepeatMode.Reverse - ), - label = "dot$index" - ) - - Text( - text = ".", - fontSize = 14.sp, - color = typingColor, - fontWeight = FontWeight.Medium, - modifier = Modifier.offset(y = offsetY.dp) - ) - } - } -} - -/** 📬 Секция Requests - кнопка для перехода к списку запросов */ -@Composable -fun RequestsSection(count: Int, isDarkTheme: Boolean, onClick: () -> Unit) { - val textColor = if (isDarkTheme) Color(0xFF4DABF7) else Color(0xFF228BE6) - val arrowColor = if (isDarkTheme) Color(0xFFC9C9C9) else Color(0xFF228BE6) - - Row( - modifier = - Modifier.fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 14.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Requests +$count", - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = textColor - ) - - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = "Open requests", - tint = arrowColor.copy(alpha = 0.6f), - modifier = Modifier.size(24.dp) - ) - } -} - -/** 📬 Экран со списком Requests (без хедера - хедер в основном TopAppBar) */ -@Composable -fun RequestsScreen( - requests: List, - isDarkTheme: Boolean, - onBack: () -> Unit, - onRequestClick: (DialogUiModel) -> Unit -) { - val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) - val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) - - Column(modifier = Modifier.fillMaxSize().background(backgroundColor)) { - if (requests.isEmpty()) { - // Empty state - Box( - modifier = Modifier.fillMaxSize().padding(32.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "No requests", - fontSize = 16.sp, - color = - if (isDarkTheme) Color(0xFF8E8E8E) - else Color(0xFF8E8E93), - textAlign = TextAlign.Center - ) - } - } else { - // Requests list - LazyColumn(modifier = Modifier.fillMaxSize()) { - items(requests, key = { it.opponentKey }) { request -> - DialogItemContent( - dialog = request, - isDarkTheme = isDarkTheme, - isTyping = false, - onClick = { onRequestClick(request) } - ) - - Divider( - modifier = Modifier.padding(start = 84.dp), - color = dividerColor, - thickness = 0.5.dp - ) - } - } - } - } -} - -/** 🎨 Enhanced Drawer Menu Item - красивый пункт меню с hover эффектом */ -@Composable -fun DrawerMenuItemEnhanced( - icon: androidx.compose.ui.graphics.vector.ImageVector, - text: String, - iconColor: Color, - textColor: Color, - badge: String? = null, - onClick: () -> Unit -) { - Row( - modifier = - Modifier.fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 20.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = iconColor, - modifier = Modifier.size(24.dp) - ) - - Spacer(modifier = Modifier.width(20.dp)) - - Text( - text = text, - fontSize = 16.sp, - fontWeight = FontWeight.Normal, - color = textColor, - modifier = Modifier.weight(1f) - ) - - badge?.let { - Box( - modifier = - Modifier.background( - color = Color(0xFF4A90D9), - shape = RoundedCornerShape(10.dp) - ) - .padding(horizontal = 8.dp, vertical = 2.dp) - ) { - Text( - text = it, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - color = Color.White - ) - } - } - } -} - -/** 📏 Drawer Divider - разделитель между секциями */ -@Composable -fun DrawerDivider(isDarkTheme: Boolean) { - Spacer(modifier = Modifier.height(8.dp)) - Divider( - modifier = Modifier.padding(horizontal = 20.dp), - color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFEEEEEE), - thickness = 0.5.dp - ) - Spacer(modifier = Modifier.height(8.dp)) -} - -/** 🔔 FCM Logs Section - отображение логов отправки FCM токена */ -@Composable -fun FcmLogsSection(logs: List, isDarkTheme: Boolean, onClearLogs: () -> Unit) { - val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF8F9FA) - val textColor = if (isDarkTheme) Color(0xFFE0E0E0) else Color(0xFF1A1A1A) - val accentColor = if (isDarkTheme) Color(0xFF4A90D9) else Color(0xFF2979FF) - - var expanded by remember { mutableStateOf(true) } - - Column(modifier = Modifier.fillMaxWidth().background(backgroundColor)) { - // Header - Row( - modifier = - Modifier.fillMaxWidth() - .clickable { expanded = !expanded } - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = - if (expanded) Icons.Default.KeyboardArrowDown - else Icons.Default.KeyboardArrowRight, - contentDescription = null, - tint = accentColor, - modifier = Modifier.size(24.dp) - ) - - Spacer(modifier = Modifier.width(12.dp)) - - Text( - text = "🔔 FCM Token Status", - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - color = textColor, - modifier = Modifier.weight(1f) - ) - - // Clear button - if (logs.isNotEmpty()) { - TextButton( - onClick = onClearLogs, - contentPadding = - PaddingValues(horizontal = 8.dp, vertical = 4.dp) - ) { Text(text = "Clear", fontSize = 13.sp, color = accentColor) } - } - } - - // Logs content - AnimatedVisibility( - visible = expanded, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - Column( - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - .background( - color = - if (isDarkTheme) Color(0xFF1A1A1A) - else Color.White, - shape = RoundedCornerShape(8.dp) - ) - .padding(12.dp) - ) { - if (logs.isEmpty()) { - Text( - text = "No FCM logs yet...", - fontSize = 14.sp, - color = - if (isDarkTheme) Color(0xFF8E8E8E) - else Color(0xFF999999), - fontFamily = FontFamily.Monospace - ) - } else { - logs.take(10).forEach { log -> - Text( - text = log, - fontSize = 12.sp, - color = textColor, - fontFamily = FontFamily.Monospace, - lineHeight = 18.sp, - modifier = Modifier.padding(vertical = 2.dp) - ) - } - - if (logs.size > 10) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "... и ещё ${logs.size - 10} логов", - fontSize = 11.sp, - color = - if (isDarkTheme) Color(0xFF8E8E8E) - else Color(0xFF999999), - fontStyle = - androidx.compose.ui.text.font - .FontStyle.Italic - ) - } - } - } - } - - Spacer(modifier = Modifier.height(8.dp)) - } -} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/DebugLogsBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/DebugLogsBottomSheet.kt deleted file mode 100644 index 596c50a..0000000 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/DebugLogsBottomSheet.kt +++ /dev/null @@ -1,258 +0,0 @@ -package com.rosetta.messenger.ui.chats.components - -import android.view.HapticFeedbackConstants -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.rosetta.messenger.ui.onboarding.PrimaryBlue -import compose.icons.TablerIcons -import compose.icons.tablericons.Bug -import com.rosetta.messenger.ui.icons.TelegramIcons -import kotlinx.coroutines.launch - -/** - * 🐛 BottomSheet для отображения debug логов протокола - * - * Показывает логи отправки/получения сообщений для дебага. - * Использует ProtocolManager.debugLogs как источник данных. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DebugLogsBottomSheet( - logs: List, - isDarkTheme: Boolean, - onDismiss: () -> Unit, - onClearLogs: () -> Unit -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) - val scope = rememberCoroutineScope() - val view = LocalView.current - val listState = rememberLazyListState() - - // Colors - val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White - val textColor = if (isDarkTheme) Color.White else Color.Black - val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) - - // Haptic feedback при открытии - LaunchedEffect(Unit) { - view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - } - - // Авто-скролл вниз при новых логах - LaunchedEffect(logs.size) { - if (logs.isNotEmpty()) { - listState.animateScrollToItem(logs.size - 1) - } - } - - // Плавное затемнение статус бара - DisposableEffect(Unit) { - if (!view.isInEditMode) { - val window = (view.context as? android.app.Activity)?.window - val originalStatusBarColor = window?.statusBarColor ?: 0 - val scrimColor = android.graphics.Color.argb(153, 0, 0, 0) - - val fadeInAnimator = android.animation.ValueAnimator.ofArgb(originalStatusBarColor, scrimColor).apply { - duration = 200 - addUpdateListener { animator -> - window?.statusBarColor = animator.animatedValue as Int - } - } - fadeInAnimator.start() - - onDispose { - val fadeOutAnimator = android.animation.ValueAnimator.ofArgb(scrimColor, originalStatusBarColor).apply { - duration = 150 - addUpdateListener { animator -> - window?.statusBarColor = animator.animatedValue as Int - } - } - fadeOutAnimator.start() - } - } else { - onDispose { } - } - } - - fun dismissWithAnimation() { - scope.launch { - sheetState.hide() - onDismiss() - } - } - - ModalBottomSheet( - onDismissRequest = { dismissWithAnimation() }, - sheetState = sheetState, - containerColor = backgroundColor, - scrimColor = Color.Black.copy(alpha = 0.6f), - dragHandle = { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(8.dp)) - Box( - modifier = Modifier - .width(36.dp) - .height(5.dp) - .clip(RoundedCornerShape(2.5.dp)) - .background(if (isDarkTheme) Color(0xFF4A4A4A) else Color(0xFFD1D1D6)) - ) - Spacer(modifier = Modifier.height(16.dp)) - } - }, - shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), - modifier = Modifier.statusBarsPadding() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - ) { - // Header - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - // Иконка и заголовок - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - TablerIcons.Bug, - contentDescription = null, - tint = PrimaryBlue, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(12.dp)) - Column { - Text( - text = "Debug Logs", - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - color = textColor - ) - Text( - text = "${logs.size} log entries", - fontSize = 14.sp, - color = secondaryTextColor - ) - } - } - - // Кнопки - Row { - IconButton(onClick = onClearLogs) { - Icon( - painter = TelegramIcons.Delete, - contentDescription = "Clear logs", - tint = secondaryTextColor.copy(alpha = 0.6f), - modifier = Modifier.size(22.dp) - ) - } - IconButton(onClick = { dismissWithAnimation() }) { - Icon( - Icons.Default.Close, - contentDescription = "Close", - tint = secondaryTextColor.copy(alpha = 0.6f), - modifier = Modifier.size(22.dp) - ) - } - } - } - - Spacer(modifier = Modifier.height(12.dp)) - Divider(color = dividerColor, thickness = 0.5.dp) - - // Контент - if (logs.isEmpty()) { - // Empty state - Box( - modifier = Modifier - .fillMaxWidth() - .height(200.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "No logs yet.\nLogs will appear here during messaging.", - fontSize = 14.sp, - color = secondaryTextColor, - textAlign = TextAlign.Center - ) - } - } else { - // Список логов - LazyColumn( - state = listState, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 300.dp, max = 500.dp) - .padding(horizontal = 8.dp, vertical = 8.dp) - ) { - items(logs) { log -> - DebugLogItem(log = log, isDarkTheme = isDarkTheme) - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - } - } -} - -/** - * Элемент лога с цветовой кодировкой - */ -@Composable -private fun DebugLogItem( - log: String, - isDarkTheme: Boolean -) { - val textColor = if (isDarkTheme) Color.White else Color.Black - val successColor = Color(0xFF34C759) - val errorColor = Color(0xFFFF3B30) - val purpleColor = Color(0xFFAF52DE) - val heartbeatColor = Color(0xFFFF9500) - val messageColor = PrimaryBlue - - // Определяем цвет по содержимому лога - val logColor = when { - log.contains("✅") || log.contains("SUCCESS") -> successColor - log.contains("❌") || log.contains("ERROR") || log.contains("FAILED") -> errorColor - log.contains("🔄") || log.contains("STATE") -> purpleColor - log.contains("💓") || log.contains("💔") -> heartbeatColor - log.contains("📥") || log.contains("📤") || log.contains("📨") -> messageColor - else -> textColor.copy(alpha = 0.85f) - } - - Text( - text = log, - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = logColor, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 2.dp, horizontal = 8.dp) - ) -} diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt index aea5123..eefa41b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt @@ -3,7 +3,7 @@ package com.rosetta.messenger.ui.components.metaball import android.graphics.ColorMatrixColorFilter import android.graphics.Path import android.graphics.RectF -import android.util.Log + import android.graphics.RenderEffect import android.graphics.Shader import android.os.Build @@ -11,13 +11,10 @@ import android.view.Gravity import androidx.annotation.RequiresApi import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement + import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row + import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -25,13 +22,11 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults -import androidx.compose.material3.Text + import androidx.compose.ui.graphics.RectangleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect + import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -48,10 +43,10 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.text.font.FontWeight + import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp + import kotlin.math.abs import kotlin.math.max import kotlin.math.min @@ -410,17 +405,8 @@ fun ProfileMetaballOverlay( NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view) } - // Only log in explicit debug mode to keep production scroll clean. - val debugLogsEnabled = MetaballDebug.forceMode != null || MetaballDebug.forceNoNotch - LaunchedEffect(debugLogsEnabled, notchInfo, screenWidthPx, statusBarHeightPx, headerHeightPx) { - if (debugLogsEnabled) { - Log.d("ProfileMetaball", "NotchInfo: gravity=${notchInfo?.gravity}, isCircle=${notchInfo?.isLikelyCircle}, bounds=${notchInfo?.bounds}, raw=${notchInfo?.rawPath}") - Log.d("ProfileMetaball", "Screen: width=${screenWidthPx}px, statusBar=${statusBarHeightPx}px, header=${headerHeightPx}px") - } - } - val hasCenteredNotch = remember(notchInfo, screenWidthPx) { - !MetaballDebug.forceNoNotch && isCenteredTopCutout(notchInfo, screenWidthPx) + isCenteredTopCutout(notchInfo, screenWidthPx) } // Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView) @@ -900,7 +886,7 @@ fun ProfileMetaballOverlayCpu( NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view) } val hasRealNotch = remember(notchInfo, screenWidthPx) { - !MetaballDebug.forceNoNotch && isCenteredTopCutout(notchInfo, screenWidthPx) + isCenteredTopCutout(notchInfo, screenWidthPx) } val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() } @@ -1162,153 +1148,6 @@ fun ProfileMetaballOverlayCpu( } } -/** - * DEBUG: Temporary toggle to force a specific rendering path. - * Set forceMode to test different paths on your device: - * - null: auto-detect (default production behavior) - * - "gpu": force GPU path (requires API 31+) - * - "cpu": force CPU bitmap path - * - "compat": force compat/noop path - * - * Set forceNoNotch = true to simulate no-notch device (black bar fallback). - * - * TODO: Remove before release! - */ -object MetaballDebug { - var forceMode: String? = null // "gpu", "cpu", "compat", or null - var forceNoNotch: Boolean = false // true = pretend no notch exists -} - -/** - * DEBUG: Floating panel with buttons to switch metaball rendering path. - * Place inside a Box (e.g. profile header) — it aligns to bottom-center. - * TODO: Remove before release! - */ -@Composable -fun MetaballDebugPanel(modifier: Modifier = Modifier) { - var currentMode by remember { mutableStateOf(MetaballDebug.forceMode) } - var noNotch by remember { mutableStateOf(MetaballDebug.forceNoNotch) } - - val context = LocalContext.current - val perfClass = remember { DevicePerformanceClass.get(context) } - - Column( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 12.dp) - .background( - ComposeColor.Black.copy(alpha = 0.75f), - RoundedCornerShape(12.dp) - ) - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - // Title - Text( - text = "Metaball Debug | API ${Build.VERSION.SDK_INT} | $perfClass", - color = ComposeColor.White, - fontSize = 11.sp, - fontWeight = FontWeight.Bold - ) - - // Mode buttons row - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.fillMaxWidth() - ) { - val modes = listOf(null to "Auto", "gpu" to "GPU", "cpu" to "CPU", "compat" to "Compat") - modes.forEach { (mode, label) -> - val isSelected = currentMode == mode - Box( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(8.dp)) - .background( - if (isSelected) ComposeColor(0xFF4CAF50) else ComposeColor.White.copy(alpha = 0.15f) - ) - .border( - width = 1.dp, - color = if (isSelected) ComposeColor(0xFF4CAF50) else ComposeColor.White.copy(alpha = 0.3f), - shape = RoundedCornerShape(8.dp) - ) - .clickable { - MetaballDebug.forceMode = mode - currentMode = mode - } - .padding(vertical = 8.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = label, - color = ComposeColor.White, - fontSize = 12.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal - ) - } - } - } - - // No-notch toggle - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = "Force no-notch (black bar)", - color = ComposeColor.White, - fontSize = 12.sp - ) - Switch( - checked = noNotch, - onCheckedChange = { - MetaballDebug.forceNoNotch = it - noNotch = it - }, - colors = SwitchDefaults.colors( - checkedThumbColor = ComposeColor(0xFF4CAF50), - checkedTrackColor = ComposeColor(0xFF4CAF50).copy(alpha = 0.5f) - ) - ) - } - - // Current active path info - val activePath = when (currentMode) { - "gpu" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) "GPU (forced)" else "GPU needs API 31!" - "cpu" -> "CPU (forced)" - "compat" -> "Compat (forced)" - else -> when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> "GPU (auto)" - else -> "CPU (auto)" - } - } - Text( - text = "Active: $activePath" + if (noNotch) " + no-notch" else "", - color = ComposeColor(0xFF4CAF50), - fontSize = 11.sp, - fontWeight = FontWeight.Medium - ) - - // Notch detection info - val view = LocalView.current - val notchRes = remember { NotchInfoUtils.getInfo(context) } - val notchCutout = remember(view) { NotchInfoUtils.getInfoFromCutout(view) } - val notchSource = when { - notchRes != null -> "resource" - notchCutout != null -> "DisplayCutout" - else -> "NONE" - } - val activeNotch = notchRes ?: notchCutout - Text( - text = "Notch: $notchSource" + - if (activeNotch != null) " | ${activeNotch.bounds.width().toInt()}x${activeNotch.bounds.height().toInt()}" + - " circle=${activeNotch.isLikelyCircle}" else " (black bar fallback!)", - color = if (activeNotch != null) ComposeColor(0xFF4CAF50) else ComposeColor(0xFFFF5722), - fontSize = 10.sp - ) - } -} - /** * Auto-selecting wrapper — 3-tier architecture matching Telegram's ProfileGooeyView: * 1. GPU path (Android 12+, average+ performance): RenderEffect blur + ColorMatrixColorFilter @@ -1329,36 +1168,9 @@ fun ProfileMetaballEffect( val context = LocalContext.current val performanceClass = remember { DevicePerformanceClass.get(context) } - // Debug: log which path is selected - val selectedPath = when (MetaballDebug.forceMode) { - "gpu" -> "GPU (forced)" - "cpu" -> "CPU (forced)" - "compat" -> "Compat (forced)" - else -> when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> "GPU (auto)" - else -> "CPU (auto)" - } - } - val debugLogsEnabled = MetaballDebug.forceMode != null || MetaballDebug.forceNoNotch - LaunchedEffect(selectedPath, debugLogsEnabled, performanceClass) { - if (debugLogsEnabled) { - Log.d("MetaballDebug", "Rendering path: $selectedPath, forceNoNotch: ${MetaballDebug.forceNoNotch}, perf: $performanceClass, API: ${Build.VERSION.SDK_INT}") - } - } - // Resolve actual mode - val useGpu = when (MetaballDebug.forceMode) { - "gpu" -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S // still need API 31 - "cpu" -> false - "compat" -> false - else -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S - } - val useCpu = when (MetaballDebug.forceMode) { - "gpu" -> false - "cpu" -> true - "compat" -> false - else -> !useGpu - } + val useGpu = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val useCpu = !useGpu when { useGpu -> {