android: save all pending changes
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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'а. */
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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 -> {
|
||||
|
||||
Reference in New Issue
Block a user