android: save all pending changes

This commit is contained in:
2026-03-26 21:37:31 +05:00
parent 0af4e6587e
commit 9cca071bd8
8 changed files with 327 additions and 3334 deletions

View File

@@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -15,7 +15,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
/** /**
* 🚀 Telegram-style: Fixed Height Box + Fade In/Out * 🚀 Telegram-style: Fixed Height Box + Fade In/Out
@@ -110,19 +110,3 @@ fun AnimatedKeyboardTransition(
} }
} }
} }
/**
* Алиас для обратной совместимости
*/
@Composable
fun SimpleAnimatedKeyboardTransition(
coordinator: KeyboardTransitionCoordinator,
showEmojiPicker: Boolean,
content: @Composable () -> Unit
) {
AnimatedKeyboardTransition(
coordinator = coordinator,
showEmojiPicker = showEmojiPicker,
content = content
)
}

View File

@@ -4,7 +4,7 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -46,9 +46,6 @@ class KeyboardTransitionCoordinator {
var currentState by mutableStateOf(TransitionState.IDLE) var currentState by mutableStateOf(TransitionState.IDLE)
private set private set
var transitionProgress by mutableFloatStateOf(0f)
private set
// ============ Высоты ============ // ============ Высоты ============
var keyboardHeight by mutableStateOf(0.dp) var keyboardHeight by mutableStateOf(0.dp)
@@ -68,9 +65,6 @@ class KeyboardTransitionCoordinator {
// Используется для отключения imePadding пока Box виден // Используется для отключения imePadding пока Box виден
var isEmojiBoxVisible by mutableStateOf(false) var isEmojiBoxVisible by mutableStateOf(false)
// 🔥 Коллбэк для показа emoji (сохраняем для вызова после закрытия клавиатуры)
private var pendingShowEmojiCallback: (() -> Unit)? = null
// 📊 Для умного логирования (не каждый фрейм) // 📊 Для умного логирования (не каждый фрейм)
private var lastLogTime = 0L private var lastLogTime = 0L
private var lastLoggedHeight = -1f private var lastLoggedHeight = -1f
@@ -108,8 +102,6 @@ class KeyboardTransitionCoordinator {
currentState = TransitionState.IDLE currentState = TransitionState.IDLE
isTransitioning = false isTransitioning = false
// Очищаем pending callback - больше не нужен
pendingShowEmojiCallback = null
} }
// ============ Главный метод: Emoji → Keyboard ============ // ============ Главный метод: Emoji → Keyboard ============
@@ -119,11 +111,6 @@ class KeyboardTransitionCoordinator {
* плавно скрыть emoji. * плавно скрыть emoji.
*/ */
fun requestShowKeyboard(showKeyboard: () -> Unit, hideEmoji: () -> Unit) { fun requestShowKeyboard(showKeyboard: () -> Unit, hideEmoji: () -> Unit) {
// 🔥 Отменяем pending emoji callback если он есть (предотвращаем конфликт)
if (pendingShowEmojiCallback != null) {
pendingShowEmojiCallback = null
}
currentState = TransitionState.EMOJI_TO_KEYBOARD currentState = TransitionState.EMOJI_TO_KEYBOARD
isTransitioning = true isTransitioning = true
@@ -260,13 +247,6 @@ class KeyboardTransitionCoordinator {
// 🔥 УБРАН pending callback - теперь emoji показывается сразу в requestShowEmoji() // 🔥 УБРАН pending callback - теперь emoji показывается сразу в requestShowEmoji()
} }
/** Обновить высоту emoji панели. */
fun updateEmojiHeight(height: Dp) {
if (height > 0.dp && height != emojiHeight) {
emojiHeight = height
}
}
/** /**
* Синхронизировать высоты (emoji = keyboard). * Синхронизировать высоты (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'а. */ /** Composable для создания и запоминания coordinator'а. */

View File

@@ -4,7 +4,9 @@ import android.content.Context
import android.media.AudioManager import android.media.AudioManager
import android.util.Log import android.util.Log
import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.data.MessageRepository
import java.security.MessageDigest
import java.security.SecureRandom import java.security.SecureRandom
import java.util.IdentityHashMap
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -87,6 +89,12 @@ object CallManager {
private const val TAG = "CallManager" private const val TAG = "CallManager"
private const val LOCAL_AUDIO_TRACK_ID = "rosetta_audio_track" private const val LOCAL_AUDIO_TRACK_ID = "rosetta_audio_track"
private const val LOCAL_MEDIA_STREAM_ID = "rosetta_audio_stream" 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 scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val secureRandom = SecureRandom() private val secureRandom = SecureRandom()
@@ -103,6 +111,11 @@ object CallManager {
private var roomId: String = "" private var roomId: String = ""
private var offerSent = false private var offerSent = false
private var remoteDescriptionSet = 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 localPrivateKey: ByteArray? = null
private var localPublicKey: ByteArray? = null private var localPublicKey: ByteArray? = null
@@ -124,8 +137,12 @@ object CallManager {
// E2EE (XChaCha20 — compatible with Desktop) // E2EE (XChaCha20 — compatible with Desktop)
private var sharedKeyBytes: ByteArray? = null private var sharedKeyBytes: ByteArray? = null
private var senderEncryptor: XChaCha20E2EE.Encryptor? = null private val senderEncryptors = IdentityHashMap<RtpSender, XChaCha20E2EE.Encryptor>()
private var receiverDecryptor: XChaCha20E2EE.Decryptor? = null private val receiverDecryptors = IdentityHashMap<RtpReceiver, XChaCha20E2EE.Decryptor>()
private var pendingAudioSenderForE2ee: RtpSender? = null
private var lastRemoteOfferFingerprint: String = ""
private var lastLocalOfferFingerprint: String = ""
private var e2eeRebindJob: Job? = null
private var iceServers: List<PeerConnection.IceServer> = emptyList() private var iceServers: List<PeerConnection.IceServer> = emptyList()
@@ -176,7 +193,9 @@ object CallManager {
if (!ProtocolManager.isAuthenticated()) return CallActionResult.NOT_AUTHENTICATED if (!ProtocolManager.isAuthenticated()) return CallActionResult.NOT_AUTHENTICATED
resetSession(reason = null, notifyPeer = false) resetSession(reason = null, notifyPeer = false)
beginCallSession("outgoing:${targetKey.take(8)}")
role = CallRole.CALLER role = CallRole.CALLER
generateSessionKeys()
setPeer(targetKey, user.title, user.username) setPeer(targetKey, user.title, user.username)
updateState { updateState {
it.copy( it.copy(
@@ -190,6 +209,7 @@ object CallManager {
src = ownPublicKey, src = ownPublicKey,
dst = targetKey dst = targetKey
) )
breadcrumbState("startOutgoingCall")
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) } appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) }
return CallActionResult.STARTED return CallActionResult.STARTED
} }
@@ -210,6 +230,7 @@ object CallManager {
dst = snapshot.peerPublicKey, dst = snapshot.peerPublicKey,
sharedPublic = localPublic.toHex() sharedPublic = localPublic.toHex()
) )
keyExchangeSent = true
updateState { updateState {
it.copy( it.copy(
@@ -217,6 +238,7 @@ object CallManager {
statusText = "Exchanging keys..." statusText = "Exchanging keys..."
) )
} }
breadcrumbState("acceptIncomingCall")
return CallActionResult.STARTED return CallActionResult.STARTED
} }
@@ -308,6 +330,7 @@ object CallManager {
} }
val incomingPeer = packet.src.trim() val incomingPeer = packet.src.trim()
if (incomingPeer.isBlank()) return if (incomingPeer.isBlank()) return
beginCallSession("incoming:${incomingPeer.take(8)}")
breadcrumb("SIG: CALL from ${incomingPeer.take(8)}… → INCOMING") breadcrumb("SIG: CALL from ${incomingPeer.take(8)}… → INCOMING")
role = CallRole.CALLEE role = CallRole.CALLEE
resetRtcObjects() resetRtcObjects()
@@ -359,30 +382,45 @@ object CallManager {
breadcrumb("KE: ABORT — sharedPublic blank") breadcrumb("KE: ABORT — sharedPublic blank")
return 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)}") breadcrumb("KE: role=$role peerPub=${peerPublicHex.take(16)}")
lastPeerSharedPublicHex = peerPublicHex
if (role == CallRole.CALLER) { if (role == CallRole.CALLER) {
if (localPrivateKey == null || localPublicKey == null) {
breadcrumb("KE: CALLER — generating session keys (were null)")
generateSessionKeys() generateSessionKeys()
}
val sharedKey = computeSharedSecretHex(peerPublicHex) val sharedKey = computeSharedSecretHex(peerPublicHex)
if (sharedKey == null) { if (sharedKey == null) {
breadcrumb("KE: CALLER — computeSharedSecret FAILED") breadcrumb("KE: CALLER — computeSharedSecret FAILED")
return return
} }
setupE2EE(sharedKey) 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...") } updateState { it.copy(keyCast = sharedKey, statusText = "Creating room...") }
val localPublic = localPublicKey ?: return val localPublic = localPublicKey ?: return
if (!keyExchangeSent) {
ProtocolManager.sendCallSignal( ProtocolManager.sendCallSignal(
signalType = SignalType.KEY_EXCHANGE, signalType = SignalType.KEY_EXCHANGE,
src = ownPublicKey, src = ownPublicKey,
dst = peerKey, dst = peerKey,
sharedPublic = localPublic.toHex() sharedPublic = localPublic.toHex()
) )
keyExchangeSent = true
}
if (!createRoomSent) {
ProtocolManager.sendCallSignal( ProtocolManager.sendCallSignal(
signalType = SignalType.CREATE_ROOM, signalType = SignalType.CREATE_ROOM,
src = ownPublicKey, src = ownPublicKey,
dst = peerKey dst = peerKey
) )
createRoomSent = true
}
updateState { it.copy(phase = CallPhase.CONNECTING) } updateState { it.copy(phase = CallPhase.CONNECTING) }
return return
} }
@@ -406,6 +444,7 @@ object CallManager {
private suspend fun handleWebRtcPacket(packet: PacketWebRTC) { private suspend fun handleWebRtcPacket(packet: PacketWebRTC) {
webRtcSignalMutex.withLock { webRtcSignalMutex.withLock {
val phase = _state.value.phase val phase = _state.value.phase
breadcrumb("RTC: packet=${packet.signalType} payloadLen=${packet.sdpOrCandidate.length} phase=$phase")
if (phase != CallPhase.CONNECTING && phase != CallPhase.ACTIVE) { if (phase != CallPhase.CONNECTING && phase != CallPhase.ACTIVE) {
breadcrumb("RTC: IGNORED ${packet.signalType} — phase=$phase") breadcrumb("RTC: IGNORED ${packet.signalType} — phase=$phase")
return@withLock return@withLock
@@ -435,6 +474,7 @@ object CallManager {
pc.setRemoteDescriptionAwait(answer) pc.setRemoteDescriptionAwait(answer)
remoteDescriptionSet = true remoteDescriptionSet = true
flushBufferedRemoteCandidates() flushBufferedRemoteCandidates()
attachReceiverE2EEFromPeerConnection()
breadcrumb("RTC: ANSWER applied OK, state=${pc.signalingState()}") breadcrumb("RTC: ANSWER applied OK, state=${pc.signalingState()}")
} catch (e: Exception) { } catch (e: Exception) {
breadcrumb("RTC: ANSWER FAILED — ${e.message}") breadcrumb("RTC: ANSWER FAILED — ${e.message}")
@@ -457,12 +497,23 @@ object CallManager {
breadcrumb("RTC: OFFER packet with type=${remoteOffer.type} ignored") breadcrumb("RTC: OFFER packet with type=${remoteOffer.type} ignored")
return@withLock 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()})") breadcrumb("RTC: OFFER received (offerSent=$offerSent state=${pc.signalingState()})")
try { try {
pc.setRemoteDescriptionAwait(remoteOffer) pc.setRemoteDescriptionAwait(remoteOffer)
remoteDescriptionSet = true remoteDescriptionSet = true
flushBufferedRemoteCandidates() flushBufferedRemoteCandidates()
attachReceiverE2EEFromPeerConnection()
val stateAfterRemote = pc.signalingState() val stateAfterRemote = pc.signalingState()
if (stateAfterRemote != PeerConnection.SignalingState.HAVE_REMOTE_OFFER && if (stateAfterRemote != PeerConnection.SignalingState.HAVE_REMOTE_OFFER &&
@@ -478,6 +529,8 @@ object CallManager {
signalType = WebRTCSignalType.ANSWER, signalType = WebRTCSignalType.ANSWER,
sdpOrCandidate = serializeSessionDescription(answer) sdpOrCandidate = serializeSessionDescription(answer)
) )
attachReceiverE2EEFromPeerConnection()
lastRemoteOfferFingerprint = offerFingerprint
breadcrumb("RTC: OFFER handled → ANSWER sent") breadcrumb("RTC: OFFER handled → ANSWER sent")
} catch (e: Exception) { } catch (e: Exception) {
breadcrumb("RTC: OFFER FAILED — ${e.message}") breadcrumb("RTC: OFFER FAILED — ${e.message}")
@@ -529,6 +582,7 @@ object CallManager {
if (audioSource == null) { if (audioSource == null) {
audioSource = factory.createAudioSource(MediaConstraints()) audioSource = factory.createAudioSource(MediaConstraints())
} }
var senderToAttach: RtpSender? = null
if (localAudioTrack == null) { if (localAudioTrack == null) {
localAudioTrack = factory.createAudioTrack(LOCAL_AUDIO_TRACK_ID, audioSource) localAudioTrack = factory.createAudioTrack(LOCAL_AUDIO_TRACK_ID, audioSource)
localAudioTrack?.setEnabled(!_state.value.isMuted) localAudioTrack?.setEnabled(!_state.value.isMuted)
@@ -538,13 +592,27 @@ object CallManager {
listOf(LOCAL_MEDIA_STREAM_ID) listOf(LOCAL_MEDIA_STREAM_ID)
) )
val transceiver = pc.addTransceiver(localAudioTrack, txInit) val transceiver = pc.addTransceiver(localAudioTrack, txInit)
breadcrumb("PC: audio transceiver added, attaching E2EE…") senderToAttach = transceiver?.sender
attachSenderE2EE(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 { try {
val offer = pc.createOfferAwait() val offer = pc.createOfferAwait()
pc.setLocalDescriptionAwait(offer) pc.setLocalDescriptionAwait(offer)
lastLocalOfferFingerprint = offer.description.shortFingerprintHex(10)
breadcrumb("RTC: local OFFER fp=$lastLocalOfferFingerprint")
ProtocolManager.sendWebRtcSignal( ProtocolManager.sendWebRtcSignal(
signalType = WebRTCSignalType.OFFER, signalType = WebRTCSignalType.OFFER,
sdpOrCandidate = serializeSessionDescription(offer) sdpOrCandidate = serializeSessionDescription(offer)
@@ -599,10 +667,12 @@ object CallManager {
override fun onAddTrack(receiver: RtpReceiver?, mediaStreams: Array<out org.webrtc.MediaStream>?) = Unit override fun onAddTrack(receiver: RtpReceiver?, mediaStreams: Array<out org.webrtc.MediaStream>?) = Unit
override fun onTrack(transceiver: RtpTransceiver?) { override fun onTrack(transceiver: RtpTransceiver?) {
breadcrumb("PC: onTrack → attachReceiverE2EE") breadcrumb("PC: onTrack → attachReceiverE2EE")
attachReceiverE2EE(transceiver) attachReceiverE2EE(transceiver?.receiver)
attachReceiverE2EEFromPeerConnection()
} }
override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) { override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) {
breadcrumb("PC: connState=$newState") breadcrumb("PC: connState=$newState")
breadcrumbState("onConnectionChange:$newState")
when (newState) { when (newState) {
PeerConnection.PeerConnectionState.CONNECTED -> { PeerConnection.PeerConnectionState.CONNECTED -> {
disconnectResetJob?.cancel() disconnectResetJob?.cancel()
@@ -721,6 +791,7 @@ object CallManager {
private fun resetSession(reason: String?, notifyPeer: Boolean) { private fun resetSession(reason: String?, notifyPeer: Boolean) {
breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}") breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}")
breadcrumbState("resetSession")
val snapshot = _state.value val snapshot = _state.value
val wasActive = snapshot.phase != CallPhase.IDLE val wasActive = snapshot.phase != CallPhase.IDLE
val peerToNotify = snapshot.peerPublicKey val peerToNotify = snapshot.peerPublicKey
@@ -747,8 +818,17 @@ object CallManager {
roomId = "" roomId = ""
offerSent = false offerSent = false
remoteDescriptionSet = false remoteDescriptionSet = false
keyExchangeSent = false
createRoomSent = false
lastPeerSharedPublicHex = ""
lastRemoteOfferFingerprint = ""
lastLocalOfferFingerprint = ""
e2eeRebindJob?.cancel()
e2eeRebindJob = null
localPrivateKey = null localPrivateKey = null
localPublicKey = null localPublicKey = null
callSessionId = ""
callStartedAtMs = 0L
durationJob?.cancel() durationJob?.cancel()
durationJob = null durationJob = null
disconnectResetJob?.cancel() disconnectResetJob?.cancel()
@@ -792,6 +872,7 @@ object CallManager {
return return
} }
sharedKeyBytes = keyBytes.copyOf(32) sharedKeyBytes = keyBytes.copyOf(32)
breadcrumb("KE: shared key fp=${sharedKeyBytes?.fingerprintHex(8)}")
// Open native diagnostics file for frame-level logging // Open native diagnostics file for frame-level logging
try { try {
val dir = java.io.File(appContext!!.filesDir, "crash_reports") 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 val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath
XChaCha20E2EE.nativeOpenDiagFile(diagPath) XChaCha20E2EE.nativeOpenDiagFile(diagPath)
} catch (_: Throwable) {} } 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)") 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 */ /** Write a breadcrumb to crash_reports/e2ee_breadcrumb.txt — survives SIGSEGV */
private fun breadcrumb(step: String) { private fun breadcrumb(step: String) {
try { try {
val dir = java.io.File(appContext!!.filesDir, "crash_reports") val dir = ensureCrashReportsDir() ?: return
if (!dir.exists()) dir.mkdirs() 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 // Reset file at start of key exchange
if (step.startsWith("KE:") && step.contains("agreement")) { if (step.startsWith("KE:") && step.contains("agreement")) {
f.writeText("") 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) {} } catch (_: Throwable) {}
} }
/** Save a full crash report to crash_reports/ */ /** Save a full crash report to crash_reports/ */
private fun saveCrashReport(title: String, error: Throwable) { private fun saveCrashReport(title: String, error: Throwable) {
try { try {
val dir = java.io.File(appContext!!.filesDir, "crash_reports") val dir = ensureCrashReportsDir() ?: return
if (!dir.exists()) dir.mkdirs() 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 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 f = java.io.File(dir, "crash_e2ee_$ts.txt")
val sw = java.io.StringWriter() val sw = java.io.StringWriter()
error.printStackTrace(java.io.PrintWriter(sw)) 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()) "<empty>" else protocolTail)
}
)
} catch (_: Throwable) {} } 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 "<missing: ${file.name}>"
return runCatching {
val lines = file.readLines()
val tail = if (lines.size <= maxLines) lines else lines.takeLast(maxLines)
if (tail.isEmpty()) "<empty: ${file.name}>" else tail.joinToString("\n")
}.getOrElse { "<read-failed: ${file.name}: ${it.message}>" }
}
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()) "<none>" 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?) { private fun attachSenderE2EE(sender: RtpSender?) {
if (!e2eeAvailable) return if (!e2eeAvailable) return
val key = sharedKeyBytes ?: return val key = sharedKeyBytes ?: return
if (sender == null) return if (sender == null) return
val existing = senderEncryptors[sender]
if (existing != null) {
runCatching { sender.setFrameEncryptor(existing) }
return
}
try { try {
breadcrumb("1. encryptor: nativeLoaded=${XChaCha20E2EE.nativeLoaded}") breadcrumb("1. encryptor: nativeLoaded=${XChaCha20E2EE.nativeLoaded}")
@@ -847,7 +1086,8 @@ object CallManager {
breadcrumb("4. calling sender.setFrameEncryptor…") breadcrumb("4. calling sender.setFrameEncryptor…")
sender.setFrameEncryptor(enc) sender.setFrameEncryptor(enc)
breadcrumb("5. setFrameEncryptor OK!") breadcrumb("5. setFrameEncryptor OK!")
senderEncryptor = enc senderEncryptors[sender] = enc
pendingAudioSenderForE2ee = null
} catch (e: Throwable) { } catch (e: Throwable) {
saveCrashReport("attachSenderE2EE failed", e) saveCrashReport("attachSenderE2EE failed", e)
Log.e(TAG, "E2EE: sender encryptor 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 if (!e2eeAvailable) return
val key = sharedKeyBytes ?: 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 { try {
breadcrumb("6. decryptor: creating…") breadcrumb("6. decryptor: creating…")
@@ -873,7 +1123,7 @@ object CallManager {
breadcrumb("9. calling receiver.setFrameDecryptor…") breadcrumb("9. calling receiver.setFrameDecryptor…")
receiver.setFrameDecryptor(dec) receiver.setFrameDecryptor(dec)
breadcrumb("10. setFrameDecryptor OK!") breadcrumb("10. setFrameDecryptor OK!")
receiverDecryptor = dec receiverDecryptors[receiver] = dec
} catch (e: Throwable) { } catch (e: Throwable) {
saveCrashReport("attachReceiverE2EE failed", e) saveCrashReport("attachReceiverE2EE failed", e)
Log.e(TAG, "E2EE: receiver decryptor 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. // Release our ref. WebRTC holds its own ref via scoped_refptr.
// After our Release: WebRTC ref remains. On peerConnection.close() // After our Release: WebRTC ref remains. On peerConnection.close()
// WebRTC releases its ref → ref=0 → native object deleted. // WebRTC releases its ref → ref=0 → native object deleted.
runCatching { senderEncryptor?.dispose() } senderEncryptors.values.forEach { enc ->
runCatching { receiverDecryptor?.dispose() } runCatching { enc.dispose() }
senderEncryptor = null }
receiverDecryptor = null receiverDecryptors.values.forEach { dec ->
runCatching { dec.dispose() }
}
senderEncryptors.clear()
receiverDecryptors.clear()
pendingAudioSenderForE2ee = null
sharedKeyBytes?.let { it.fill(0) } sharedKeyBytes?.let { it.fill(0) }
sharedKeyBytes = null sharedKeyBytes = null
runCatching { XChaCha20E2EE.nativeCloseDiagFile() } runCatching { XChaCha20E2EE.nativeCloseDiagFile() }
@@ -896,11 +1151,12 @@ object CallManager {
private fun generateSessionKeys() { private fun generateSessionKeys() {
val privateKey = ByteArray(32) val privateKey = ByteArray(32)
secureRandom.nextBytes(privateKey) X25519.generatePrivateKey(secureRandom, privateKey)
val publicKey = ByteArray(32) val publicKey = ByteArray(32)
X25519.generatePublicKey(privateKey, 0, publicKey, 0) X25519.generatePublicKey(privateKey, 0, publicKey, 0)
localPrivateKey = privateKey localPrivateKey = privateKey
localPublicKey = publicKey localPublicKey = publicKey
breadcrumb("KE: local keypair pub=${publicKey.shortHex()} privFp=${privateKey.fingerprintHex(6)}")
} }
private fun computeSharedSecretHex(peerPublicHex: String): String? { private fun computeSharedSecretHex(peerPublicHex: String): String? {
@@ -908,17 +1164,17 @@ object CallManager {
val peerPublic = peerPublicHex.hexToBytes() ?: return null val peerPublic = peerPublicHex.hexToBytes() ?: return null
if (peerPublic.size != 32) return null if (peerPublic.size != 32) return null
val rawDh = ByteArray(32) 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) val ok = X25519.calculateAgreement(privateKey, 0, peerPublic, 0, rawDh, 0)
if (!ok) { if (!ok) {
breadcrumb("KE: X25519 FAILED") breadcrumb("KE: X25519 FAILED")
return null return null
} }
breadcrumb("KE: X25519 OK, calling HSalsa20…") breadcrumb("KE: X25519 OK rawDhFp=${rawDh.fingerprintHex(8)}, calling HSalsa20…")
return try { return try {
val naclShared = XChaCha20E2EE.hsalsa20(rawDh) val naclShared = XChaCha20E2EE.hsalsa20(rawDh)
rawDh.fill(0) rawDh.fill(0)
breadcrumb("KE: HSalsa20 OK, key ready") breadcrumb("KE: HSalsa20 OK keyFp=${naclShared.fingerprintHex(8)}")
naclShared.toHex() naclShared.toHex()
} catch (e: Throwable) { } catch (e: Throwable) {
saveCrashReport("HSalsa20 failed", e) saveCrashReport("HSalsa20 failed", e)
@@ -943,6 +1199,12 @@ object CallManager {
val type = SessionDescription.Type.fromCanonicalForm(json.getString("type")) val type = SessionDescription.Type.fromCanonicalForm(json.getString("type"))
val sdp = json.getString("sdp") val sdp = json.getString("sdp")
SessionDescription(type, 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() }.getOrNull()
} }
@@ -961,6 +1223,12 @@ object CallManager {
val sdpMid = if (json.has("sdpMid") && !json.isNull("sdpMid")) json.getString("sdpMid") else null val sdpMid = if (json.has("sdpMid") && !json.isNull("sdpMid")) json.getString("sdpMid") else null
val sdpMLineIndex = json.optInt("sdpMLineIndex", 0) val sdpMLineIndex = json.optInt("sdpMLineIndex", 0)
IceCandidate(sdpMid, sdpMLineIndex, candidate) 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() }.getOrNull()
} }
@@ -976,6 +1244,16 @@ object CallManager {
} }
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } 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? { private fun String.hexToBytes(): ByteArray? {
val clean = trim().lowercase() val clean = trim().lowercase()

View File

@@ -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
}
}

View File

@@ -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<String> = 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<AuthStateData> = _state.asStateFlow()
private var currentDecryptedAccount: DecryptedAccountData? = null
// 🚀 ОПТИМИЗАЦИЯ: Кэш списка аккаунтов для UI
private var accountsCache: List<String>? = 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<String>,
password: String
): Result<DecryptedAccountData> = 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<DecryptedAccountData> = 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<Unit> = 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)
}

View File

@@ -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<String>,
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)
)
}

View File

@@ -3,7 +3,7 @@ package com.rosetta.messenger.ui.components.metaball
import android.graphics.ColorMatrixColorFilter import android.graphics.ColorMatrixColorFilter
import android.graphics.Path import android.graphics.Path
import android.graphics.RectF import android.graphics.RectF
import android.util.Log
import android.graphics.RenderEffect import android.graphics.RenderEffect
import android.graphics.Shader import android.graphics.Shader
import android.os.Build import android.os.Build
@@ -11,13 +11,10 @@ import android.view.Gravity
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background 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.Box
import androidx.compose.foundation.layout.BoxScope 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape 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.ui.graphics.RectangleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView 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.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@@ -410,17 +405,8 @@ fun ProfileMetaballOverlay(
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view) 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) { 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) // Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView)
@@ -900,7 +886,7 @@ fun ProfileMetaballOverlayCpu(
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view) NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view)
} }
val hasRealNotch = remember(notchInfo, screenWidthPx) { val hasRealNotch = remember(notchInfo, screenWidthPx) {
!MetaballDebug.forceNoNotch && isCenteredTopCutout(notchInfo, screenWidthPx) isCenteredTopCutout(notchInfo, screenWidthPx)
} }
val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() } 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: * Auto-selecting wrapper — 3-tier architecture matching Telegram's ProfileGooeyView:
* 1. GPU path (Android 12+, average+ performance): RenderEffect blur + ColorMatrixColorFilter * 1. GPU path (Android 12+, average+ performance): RenderEffect blur + ColorMatrixColorFilter
@@ -1329,36 +1168,9 @@ fun ProfileMetaballEffect(
val context = LocalContext.current val context = LocalContext.current
val performanceClass = remember { DevicePerformanceClass.get(context) } 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 // Resolve actual mode
val useGpu = when (MetaballDebug.forceMode) { val useGpu = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
"gpu" -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S // still need API 31 val useCpu = !useGpu
"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
}
when { when {
useGpu -> { useGpu -> {