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.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
)
}

View File

@@ -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'а. */

View File

@@ -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<RtpSender, XChaCha20E2EE.Encryptor>()
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()
@@ -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<out org.webrtc.MediaStream>?) = 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()) "<empty>" 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 "<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?) {
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()

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.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 -> {