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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -15,7 +15,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🚀 Telegram-style: Fixed Height Box + Fade In/Out
|
* 🚀 Telegram-style: Fixed Height Box + Fade In/Out
|
||||||
@@ -110,19 +110,3 @@ fun AnimatedKeyboardTransition(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Алиас для обратной совместимости
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun SimpleAnimatedKeyboardTransition(
|
|
||||||
coordinator: KeyboardTransitionCoordinator,
|
|
||||||
showEmojiPicker: Boolean,
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
AnimatedKeyboardTransition(
|
|
||||||
coordinator = coordinator,
|
|
||||||
showEmojiPicker = showEmojiPicker,
|
|
||||||
content = content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -46,9 +46,6 @@ class KeyboardTransitionCoordinator {
|
|||||||
var currentState by mutableStateOf(TransitionState.IDLE)
|
var currentState by mutableStateOf(TransitionState.IDLE)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var transitionProgress by mutableFloatStateOf(0f)
|
|
||||||
private set
|
|
||||||
|
|
||||||
// ============ Высоты ============
|
// ============ Высоты ============
|
||||||
|
|
||||||
var keyboardHeight by mutableStateOf(0.dp)
|
var keyboardHeight by mutableStateOf(0.dp)
|
||||||
@@ -68,9 +65,6 @@ class KeyboardTransitionCoordinator {
|
|||||||
// Используется для отключения imePadding пока Box виден
|
// Используется для отключения imePadding пока Box виден
|
||||||
var isEmojiBoxVisible by mutableStateOf(false)
|
var isEmojiBoxVisible by mutableStateOf(false)
|
||||||
|
|
||||||
// 🔥 Коллбэк для показа emoji (сохраняем для вызова после закрытия клавиатуры)
|
|
||||||
private var pendingShowEmojiCallback: (() -> Unit)? = null
|
|
||||||
|
|
||||||
// 📊 Для умного логирования (не каждый фрейм)
|
// 📊 Для умного логирования (не каждый фрейм)
|
||||||
private var lastLogTime = 0L
|
private var lastLogTime = 0L
|
||||||
private var lastLoggedHeight = -1f
|
private var lastLoggedHeight = -1f
|
||||||
@@ -108,8 +102,6 @@ class KeyboardTransitionCoordinator {
|
|||||||
currentState = TransitionState.IDLE
|
currentState = TransitionState.IDLE
|
||||||
isTransitioning = false
|
isTransitioning = false
|
||||||
|
|
||||||
// Очищаем pending callback - больше не нужен
|
|
||||||
pendingShowEmojiCallback = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Главный метод: Emoji → Keyboard ============
|
// ============ Главный метод: Emoji → Keyboard ============
|
||||||
@@ -119,11 +111,6 @@ class KeyboardTransitionCoordinator {
|
|||||||
* плавно скрыть emoji.
|
* плавно скрыть emoji.
|
||||||
*/
|
*/
|
||||||
fun requestShowKeyboard(showKeyboard: () -> Unit, hideEmoji: () -> Unit) {
|
fun requestShowKeyboard(showKeyboard: () -> Unit, hideEmoji: () -> Unit) {
|
||||||
// 🔥 Отменяем pending emoji callback если он есть (предотвращаем конфликт)
|
|
||||||
if (pendingShowEmojiCallback != null) {
|
|
||||||
pendingShowEmojiCallback = null
|
|
||||||
}
|
|
||||||
|
|
||||||
currentState = TransitionState.EMOJI_TO_KEYBOARD
|
currentState = TransitionState.EMOJI_TO_KEYBOARD
|
||||||
isTransitioning = true
|
isTransitioning = true
|
||||||
|
|
||||||
@@ -260,13 +247,6 @@ class KeyboardTransitionCoordinator {
|
|||||||
// 🔥 УБРАН pending callback - теперь emoji показывается сразу в requestShowEmoji()
|
// 🔥 УБРАН pending callback - теперь emoji показывается сразу в requestShowEmoji()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Обновить высоту emoji панели. */
|
|
||||||
fun updateEmojiHeight(height: Dp) {
|
|
||||||
if (height > 0.dp && height != emojiHeight) {
|
|
||||||
emojiHeight = height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Синхронизировать высоты (emoji = keyboard).
|
* Синхронизировать высоты (emoji = keyboard).
|
||||||
*
|
*
|
||||||
@@ -292,35 +272,6 @@ class KeyboardTransitionCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Получить текущую высоту для резервирования места. Telegram паттерн: всегда резервировать
|
|
||||||
* максимум из двух.
|
|
||||||
*/
|
|
||||||
fun getReservedHeight(): Dp {
|
|
||||||
return when {
|
|
||||||
isKeyboardVisible -> keyboardHeight
|
|
||||||
isEmojiVisible -> emojiHeight
|
|
||||||
isTransitioning -> maxOf(keyboardHeight, emojiHeight)
|
|
||||||
else -> 0.dp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Проверка, можно ли начать новый переход. */
|
|
||||||
fun canStartTransition(): Boolean {
|
|
||||||
return !isTransitioning
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Сброс состояния (для отладки). */
|
|
||||||
fun reset() {
|
|
||||||
currentState = TransitionState.IDLE
|
|
||||||
isTransitioning = false
|
|
||||||
isKeyboardVisible = false
|
|
||||||
isEmojiVisible = false
|
|
||||||
transitionProgress = 0f
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Логирование текущего состояния. */
|
|
||||||
fun logState() {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Composable для создания и запоминания coordinator'а. */
|
/** Composable для создания и запоминания coordinator'а. */
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import android.content.Context
|
|||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.rosetta.messenger.data.MessageRepository
|
import com.rosetta.messenger.data.MessageRepository
|
||||||
|
import java.security.MessageDigest
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
|
import java.util.IdentityHashMap
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -87,6 +89,12 @@ object CallManager {
|
|||||||
private const val TAG = "CallManager"
|
private const val TAG = "CallManager"
|
||||||
private const val LOCAL_AUDIO_TRACK_ID = "rosetta_audio_track"
|
private const val LOCAL_AUDIO_TRACK_ID = "rosetta_audio_track"
|
||||||
private const val LOCAL_MEDIA_STREAM_ID = "rosetta_audio_stream"
|
private const val LOCAL_MEDIA_STREAM_ID = "rosetta_audio_stream"
|
||||||
|
private const val BREADCRUMB_FILE_NAME = "e2ee_breadcrumb.txt"
|
||||||
|
private const val DIAG_FILE_NAME = "e2ee_diag.txt"
|
||||||
|
private const val NATIVE_CRASH_FILE_NAME = "native_crash.txt"
|
||||||
|
private const val TAIL_LINES = 300
|
||||||
|
private const val PROTOCOL_LOG_TAIL_LINES = 180
|
||||||
|
private const val MAX_LOG_PREFIX = 180
|
||||||
|
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
private val secureRandom = SecureRandom()
|
private val secureRandom = SecureRandom()
|
||||||
@@ -103,6 +111,11 @@ object CallManager {
|
|||||||
private var roomId: String = ""
|
private var roomId: String = ""
|
||||||
private var offerSent = false
|
private var offerSent = false
|
||||||
private var remoteDescriptionSet = false
|
private var remoteDescriptionSet = false
|
||||||
|
private var callSessionId: String = ""
|
||||||
|
private var callStartedAtMs: Long = 0L
|
||||||
|
private var keyExchangeSent = false
|
||||||
|
private var createRoomSent = false
|
||||||
|
private var lastPeerSharedPublicHex: String = ""
|
||||||
|
|
||||||
private var localPrivateKey: ByteArray? = null
|
private var localPrivateKey: ByteArray? = null
|
||||||
private var localPublicKey: ByteArray? = null
|
private var localPublicKey: ByteArray? = null
|
||||||
@@ -124,8 +137,12 @@ object CallManager {
|
|||||||
|
|
||||||
// E2EE (XChaCha20 — compatible with Desktop)
|
// E2EE (XChaCha20 — compatible with Desktop)
|
||||||
private var sharedKeyBytes: ByteArray? = null
|
private var sharedKeyBytes: ByteArray? = null
|
||||||
private var senderEncryptor: XChaCha20E2EE.Encryptor? = null
|
private val senderEncryptors = IdentityHashMap<RtpSender, XChaCha20E2EE.Encryptor>()
|
||||||
private var receiverDecryptor: XChaCha20E2EE.Decryptor? = null
|
private val receiverDecryptors = IdentityHashMap<RtpReceiver, XChaCha20E2EE.Decryptor>()
|
||||||
|
private var pendingAudioSenderForE2ee: RtpSender? = null
|
||||||
|
private var lastRemoteOfferFingerprint: String = ""
|
||||||
|
private var lastLocalOfferFingerprint: String = ""
|
||||||
|
private var e2eeRebindJob: Job? = null
|
||||||
|
|
||||||
private var iceServers: List<PeerConnection.IceServer> = emptyList()
|
private var iceServers: List<PeerConnection.IceServer> = emptyList()
|
||||||
|
|
||||||
@@ -176,7 +193,9 @@ object CallManager {
|
|||||||
if (!ProtocolManager.isAuthenticated()) return CallActionResult.NOT_AUTHENTICATED
|
if (!ProtocolManager.isAuthenticated()) return CallActionResult.NOT_AUTHENTICATED
|
||||||
|
|
||||||
resetSession(reason = null, notifyPeer = false)
|
resetSession(reason = null, notifyPeer = false)
|
||||||
|
beginCallSession("outgoing:${targetKey.take(8)}")
|
||||||
role = CallRole.CALLER
|
role = CallRole.CALLER
|
||||||
|
generateSessionKeys()
|
||||||
setPeer(targetKey, user.title, user.username)
|
setPeer(targetKey, user.title, user.username)
|
||||||
updateState {
|
updateState {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -190,6 +209,7 @@ object CallManager {
|
|||||||
src = ownPublicKey,
|
src = ownPublicKey,
|
||||||
dst = targetKey
|
dst = targetKey
|
||||||
)
|
)
|
||||||
|
breadcrumbState("startOutgoingCall")
|
||||||
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) }
|
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) }
|
||||||
return CallActionResult.STARTED
|
return CallActionResult.STARTED
|
||||||
}
|
}
|
||||||
@@ -210,6 +230,7 @@ object CallManager {
|
|||||||
dst = snapshot.peerPublicKey,
|
dst = snapshot.peerPublicKey,
|
||||||
sharedPublic = localPublic.toHex()
|
sharedPublic = localPublic.toHex()
|
||||||
)
|
)
|
||||||
|
keyExchangeSent = true
|
||||||
|
|
||||||
updateState {
|
updateState {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -217,6 +238,7 @@ object CallManager {
|
|||||||
statusText = "Exchanging keys..."
|
statusText = "Exchanging keys..."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
breadcrumbState("acceptIncomingCall")
|
||||||
return CallActionResult.STARTED
|
return CallActionResult.STARTED
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,6 +330,7 @@ object CallManager {
|
|||||||
}
|
}
|
||||||
val incomingPeer = packet.src.trim()
|
val incomingPeer = packet.src.trim()
|
||||||
if (incomingPeer.isBlank()) return
|
if (incomingPeer.isBlank()) return
|
||||||
|
beginCallSession("incoming:${incomingPeer.take(8)}")
|
||||||
breadcrumb("SIG: CALL from ${incomingPeer.take(8)}… → INCOMING")
|
breadcrumb("SIG: CALL from ${incomingPeer.take(8)}… → INCOMING")
|
||||||
role = CallRole.CALLEE
|
role = CallRole.CALLEE
|
||||||
resetRtcObjects()
|
resetRtcObjects()
|
||||||
@@ -359,30 +382,45 @@ object CallManager {
|
|||||||
breadcrumb("KE: ABORT — sharedPublic blank")
|
breadcrumb("KE: ABORT — sharedPublic blank")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val duplicatePeerKey = lastPeerSharedPublicHex.equals(peerPublicHex, ignoreCase = true)
|
||||||
|
if (duplicatePeerKey && sharedKeyBytes != null) {
|
||||||
|
breadcrumb("KE: duplicate peer key ignored")
|
||||||
|
return
|
||||||
|
}
|
||||||
breadcrumb("KE: role=$role peerPub=${peerPublicHex.take(16)}…")
|
breadcrumb("KE: role=$role peerPub=${peerPublicHex.take(16)}…")
|
||||||
|
lastPeerSharedPublicHex = peerPublicHex
|
||||||
|
|
||||||
if (role == CallRole.CALLER) {
|
if (role == CallRole.CALLER) {
|
||||||
generateSessionKeys()
|
if (localPrivateKey == null || localPublicKey == null) {
|
||||||
|
breadcrumb("KE: CALLER — generating session keys (were null)")
|
||||||
|
generateSessionKeys()
|
||||||
|
}
|
||||||
val sharedKey = computeSharedSecretHex(peerPublicHex)
|
val sharedKey = computeSharedSecretHex(peerPublicHex)
|
||||||
if (sharedKey == null) {
|
if (sharedKey == null) {
|
||||||
breadcrumb("KE: CALLER — computeSharedSecret FAILED")
|
breadcrumb("KE: CALLER — computeSharedSecret FAILED")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setupE2EE(sharedKey)
|
setupE2EE(sharedKey)
|
||||||
breadcrumb("KE: CALLER — E2EE ready, sending KEY_EXCHANGE + CREATE_ROOM")
|
breadcrumb("KE: CALLER — E2EE ready, sending missing signaling packets")
|
||||||
updateState { it.copy(keyCast = sharedKey, statusText = "Creating room...") }
|
updateState { it.copy(keyCast = sharedKey, statusText = "Creating room...") }
|
||||||
val localPublic = localPublicKey ?: return
|
val localPublic = localPublicKey ?: return
|
||||||
ProtocolManager.sendCallSignal(
|
if (!keyExchangeSent) {
|
||||||
signalType = SignalType.KEY_EXCHANGE,
|
ProtocolManager.sendCallSignal(
|
||||||
src = ownPublicKey,
|
signalType = SignalType.KEY_EXCHANGE,
|
||||||
dst = peerKey,
|
src = ownPublicKey,
|
||||||
sharedPublic = localPublic.toHex()
|
dst = peerKey,
|
||||||
)
|
sharedPublic = localPublic.toHex()
|
||||||
ProtocolManager.sendCallSignal(
|
)
|
||||||
signalType = SignalType.CREATE_ROOM,
|
keyExchangeSent = true
|
||||||
src = ownPublicKey,
|
}
|
||||||
dst = peerKey
|
if (!createRoomSent) {
|
||||||
)
|
ProtocolManager.sendCallSignal(
|
||||||
|
signalType = SignalType.CREATE_ROOM,
|
||||||
|
src = ownPublicKey,
|
||||||
|
dst = peerKey
|
||||||
|
)
|
||||||
|
createRoomSent = true
|
||||||
|
}
|
||||||
updateState { it.copy(phase = CallPhase.CONNECTING) }
|
updateState { it.copy(phase = CallPhase.CONNECTING) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -406,6 +444,7 @@ object CallManager {
|
|||||||
private suspend fun handleWebRtcPacket(packet: PacketWebRTC) {
|
private suspend fun handleWebRtcPacket(packet: PacketWebRTC) {
|
||||||
webRtcSignalMutex.withLock {
|
webRtcSignalMutex.withLock {
|
||||||
val phase = _state.value.phase
|
val phase = _state.value.phase
|
||||||
|
breadcrumb("RTC: packet=${packet.signalType} payloadLen=${packet.sdpOrCandidate.length} phase=$phase")
|
||||||
if (phase != CallPhase.CONNECTING && phase != CallPhase.ACTIVE) {
|
if (phase != CallPhase.CONNECTING && phase != CallPhase.ACTIVE) {
|
||||||
breadcrumb("RTC: IGNORED ${packet.signalType} — phase=$phase")
|
breadcrumb("RTC: IGNORED ${packet.signalType} — phase=$phase")
|
||||||
return@withLock
|
return@withLock
|
||||||
@@ -435,6 +474,7 @@ object CallManager {
|
|||||||
pc.setRemoteDescriptionAwait(answer)
|
pc.setRemoteDescriptionAwait(answer)
|
||||||
remoteDescriptionSet = true
|
remoteDescriptionSet = true
|
||||||
flushBufferedRemoteCandidates()
|
flushBufferedRemoteCandidates()
|
||||||
|
attachReceiverE2EEFromPeerConnection()
|
||||||
breadcrumb("RTC: ANSWER applied OK, state=${pc.signalingState()}")
|
breadcrumb("RTC: ANSWER applied OK, state=${pc.signalingState()}")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
breadcrumb("RTC: ANSWER FAILED — ${e.message}")
|
breadcrumb("RTC: ANSWER FAILED — ${e.message}")
|
||||||
@@ -457,12 +497,23 @@ object CallManager {
|
|||||||
breadcrumb("RTC: OFFER packet with type=${remoteOffer.type} ignored")
|
breadcrumb("RTC: OFFER packet with type=${remoteOffer.type} ignored")
|
||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
|
val offerFingerprint = remoteOffer.description.shortFingerprintHex(10)
|
||||||
|
val phaseNow = _state.value.phase
|
||||||
|
if (offerFingerprint == lastLocalOfferFingerprint) {
|
||||||
|
breadcrumb("RTC: OFFER loopback ignored fp=$offerFingerprint")
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
if (phaseNow == CallPhase.ACTIVE && offerFingerprint == lastRemoteOfferFingerprint) {
|
||||||
|
breadcrumb("RTC: OFFER duplicate in ACTIVE ignored fp=$offerFingerprint")
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
|
||||||
breadcrumb("RTC: OFFER received (offerSent=$offerSent state=${pc.signalingState()})")
|
breadcrumb("RTC: OFFER received (offerSent=$offerSent state=${pc.signalingState()})")
|
||||||
try {
|
try {
|
||||||
pc.setRemoteDescriptionAwait(remoteOffer)
|
pc.setRemoteDescriptionAwait(remoteOffer)
|
||||||
remoteDescriptionSet = true
|
remoteDescriptionSet = true
|
||||||
flushBufferedRemoteCandidates()
|
flushBufferedRemoteCandidates()
|
||||||
|
attachReceiverE2EEFromPeerConnection()
|
||||||
|
|
||||||
val stateAfterRemote = pc.signalingState()
|
val stateAfterRemote = pc.signalingState()
|
||||||
if (stateAfterRemote != PeerConnection.SignalingState.HAVE_REMOTE_OFFER &&
|
if (stateAfterRemote != PeerConnection.SignalingState.HAVE_REMOTE_OFFER &&
|
||||||
@@ -478,6 +529,8 @@ object CallManager {
|
|||||||
signalType = WebRTCSignalType.ANSWER,
|
signalType = WebRTCSignalType.ANSWER,
|
||||||
sdpOrCandidate = serializeSessionDescription(answer)
|
sdpOrCandidate = serializeSessionDescription(answer)
|
||||||
)
|
)
|
||||||
|
attachReceiverE2EEFromPeerConnection()
|
||||||
|
lastRemoteOfferFingerprint = offerFingerprint
|
||||||
breadcrumb("RTC: OFFER handled → ANSWER sent")
|
breadcrumb("RTC: OFFER handled → ANSWER sent")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
breadcrumb("RTC: OFFER FAILED — ${e.message}")
|
breadcrumb("RTC: OFFER FAILED — ${e.message}")
|
||||||
@@ -529,6 +582,7 @@ object CallManager {
|
|||||||
if (audioSource == null) {
|
if (audioSource == null) {
|
||||||
audioSource = factory.createAudioSource(MediaConstraints())
|
audioSource = factory.createAudioSource(MediaConstraints())
|
||||||
}
|
}
|
||||||
|
var senderToAttach: RtpSender? = null
|
||||||
if (localAudioTrack == null) {
|
if (localAudioTrack == null) {
|
||||||
localAudioTrack = factory.createAudioTrack(LOCAL_AUDIO_TRACK_ID, audioSource)
|
localAudioTrack = factory.createAudioTrack(LOCAL_AUDIO_TRACK_ID, audioSource)
|
||||||
localAudioTrack?.setEnabled(!_state.value.isMuted)
|
localAudioTrack?.setEnabled(!_state.value.isMuted)
|
||||||
@@ -538,13 +592,27 @@ object CallManager {
|
|||||||
listOf(LOCAL_MEDIA_STREAM_ID)
|
listOf(LOCAL_MEDIA_STREAM_ID)
|
||||||
)
|
)
|
||||||
val transceiver = pc.addTransceiver(localAudioTrack, txInit)
|
val transceiver = pc.addTransceiver(localAudioTrack, txInit)
|
||||||
breadcrumb("PC: audio transceiver added, attaching E2EE…")
|
senderToAttach = transceiver?.sender
|
||||||
attachSenderE2EE(transceiver?.sender)
|
pendingAudioSenderForE2ee = senderToAttach
|
||||||
|
breadcrumb("PC: audio transceiver added (E2EE attach deferred)")
|
||||||
|
} else {
|
||||||
|
senderToAttach =
|
||||||
|
runCatching {
|
||||||
|
pc.senders.firstOrNull { sender ->
|
||||||
|
sender.track()?.kind() == "audio"
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
if (senderToAttach != null) {
|
||||||
|
pendingAudioSenderForE2ee = senderToAttach
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
attachSenderE2EE(pendingAudioSenderForE2ee ?: senderToAttach)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val offer = pc.createOfferAwait()
|
val offer = pc.createOfferAwait()
|
||||||
pc.setLocalDescriptionAwait(offer)
|
pc.setLocalDescriptionAwait(offer)
|
||||||
|
lastLocalOfferFingerprint = offer.description.shortFingerprintHex(10)
|
||||||
|
breadcrumb("RTC: local OFFER fp=$lastLocalOfferFingerprint")
|
||||||
ProtocolManager.sendWebRtcSignal(
|
ProtocolManager.sendWebRtcSignal(
|
||||||
signalType = WebRTCSignalType.OFFER,
|
signalType = WebRTCSignalType.OFFER,
|
||||||
sdpOrCandidate = serializeSessionDescription(offer)
|
sdpOrCandidate = serializeSessionDescription(offer)
|
||||||
@@ -599,10 +667,12 @@ object CallManager {
|
|||||||
override fun onAddTrack(receiver: RtpReceiver?, mediaStreams: Array<out org.webrtc.MediaStream>?) = Unit
|
override fun onAddTrack(receiver: RtpReceiver?, mediaStreams: Array<out org.webrtc.MediaStream>?) = Unit
|
||||||
override fun onTrack(transceiver: RtpTransceiver?) {
|
override fun onTrack(transceiver: RtpTransceiver?) {
|
||||||
breadcrumb("PC: onTrack → attachReceiverE2EE")
|
breadcrumb("PC: onTrack → attachReceiverE2EE")
|
||||||
attachReceiverE2EE(transceiver)
|
attachReceiverE2EE(transceiver?.receiver)
|
||||||
|
attachReceiverE2EEFromPeerConnection()
|
||||||
}
|
}
|
||||||
override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) {
|
override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) {
|
||||||
breadcrumb("PC: connState=$newState")
|
breadcrumb("PC: connState=$newState")
|
||||||
|
breadcrumbState("onConnectionChange:$newState")
|
||||||
when (newState) {
|
when (newState) {
|
||||||
PeerConnection.PeerConnectionState.CONNECTED -> {
|
PeerConnection.PeerConnectionState.CONNECTED -> {
|
||||||
disconnectResetJob?.cancel()
|
disconnectResetJob?.cancel()
|
||||||
@@ -721,6 +791,7 @@ object CallManager {
|
|||||||
|
|
||||||
private fun resetSession(reason: String?, notifyPeer: Boolean) {
|
private fun resetSession(reason: String?, notifyPeer: Boolean) {
|
||||||
breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}")
|
breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}")
|
||||||
|
breadcrumbState("resetSession")
|
||||||
val snapshot = _state.value
|
val snapshot = _state.value
|
||||||
val wasActive = snapshot.phase != CallPhase.IDLE
|
val wasActive = snapshot.phase != CallPhase.IDLE
|
||||||
val peerToNotify = snapshot.peerPublicKey
|
val peerToNotify = snapshot.peerPublicKey
|
||||||
@@ -747,8 +818,17 @@ object CallManager {
|
|||||||
roomId = ""
|
roomId = ""
|
||||||
offerSent = false
|
offerSent = false
|
||||||
remoteDescriptionSet = false
|
remoteDescriptionSet = false
|
||||||
|
keyExchangeSent = false
|
||||||
|
createRoomSent = false
|
||||||
|
lastPeerSharedPublicHex = ""
|
||||||
|
lastRemoteOfferFingerprint = ""
|
||||||
|
lastLocalOfferFingerprint = ""
|
||||||
|
e2eeRebindJob?.cancel()
|
||||||
|
e2eeRebindJob = null
|
||||||
localPrivateKey = null
|
localPrivateKey = null
|
||||||
localPublicKey = null
|
localPublicKey = null
|
||||||
|
callSessionId = ""
|
||||||
|
callStartedAtMs = 0L
|
||||||
durationJob?.cancel()
|
durationJob?.cancel()
|
||||||
durationJob = null
|
durationJob = null
|
||||||
disconnectResetJob?.cancel()
|
disconnectResetJob?.cancel()
|
||||||
@@ -792,6 +872,7 @@ object CallManager {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
sharedKeyBytes = keyBytes.copyOf(32)
|
sharedKeyBytes = keyBytes.copyOf(32)
|
||||||
|
breadcrumb("KE: shared key fp=${sharedKeyBytes?.fingerprintHex(8)}")
|
||||||
// Open native diagnostics file for frame-level logging
|
// Open native diagnostics file for frame-level logging
|
||||||
try {
|
try {
|
||||||
val dir = java.io.File(appContext!!.filesDir, "crash_reports")
|
val dir = java.io.File(appContext!!.filesDir, "crash_reports")
|
||||||
@@ -799,40 +880,198 @@ object CallManager {
|
|||||||
val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath
|
val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath
|
||||||
XChaCha20E2EE.nativeOpenDiagFile(diagPath)
|
XChaCha20E2EE.nativeOpenDiagFile(diagPath)
|
||||||
} catch (_: Throwable) {}
|
} catch (_: Throwable) {}
|
||||||
|
// If sender track already exists, bind encryptor now.
|
||||||
|
val existingSender =
|
||||||
|
pendingAudioSenderForE2ee
|
||||||
|
?: runCatching {
|
||||||
|
peerConnection?.senders?.firstOrNull { sender -> sender.track()?.kind() == "audio" }
|
||||||
|
}.getOrNull()
|
||||||
|
if (existingSender != null) {
|
||||||
|
attachSenderE2EE(existingSender)
|
||||||
|
}
|
||||||
|
attachReceiverE2EEFromPeerConnection()
|
||||||
|
startE2EERebindLoopIfNeeded()
|
||||||
Log.i(TAG, "E2EE key ready (XChaCha20)")
|
Log.i(TAG, "E2EE key ready (XChaCha20)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startE2EERebindLoopIfNeeded() {
|
||||||
|
if (e2eeRebindJob?.isActive == true) return
|
||||||
|
e2eeRebindJob =
|
||||||
|
scope.launch {
|
||||||
|
while (true) {
|
||||||
|
delay(1500L)
|
||||||
|
if (!e2eeAvailable || sharedKeyBytes == null) continue
|
||||||
|
val phaseNow = _state.value.phase
|
||||||
|
if (phaseNow != CallPhase.CONNECTING && phaseNow != CallPhase.ACTIVE) continue
|
||||||
|
val pc = peerConnection ?: continue
|
||||||
|
val sender =
|
||||||
|
runCatching {
|
||||||
|
pc.senders.firstOrNull { it.track()?.kind() == "audio" }
|
||||||
|
}.getOrNull()
|
||||||
|
if (sender != null) {
|
||||||
|
attachSenderE2EE(sender)
|
||||||
|
}
|
||||||
|
attachReceiverE2EEFromPeerConnection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun attachReceiverE2EEFromPeerConnection() {
|
||||||
|
val pc = peerConnection ?: return
|
||||||
|
runCatching {
|
||||||
|
var fromReceivers = 0
|
||||||
|
var fromTransceivers = 0
|
||||||
|
pc.receivers.forEach { receiver ->
|
||||||
|
if (isAudioReceiver(receiver)) {
|
||||||
|
attachReceiverE2EE(receiver)
|
||||||
|
fromReceivers++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pc.transceivers.forEach { transceiver ->
|
||||||
|
val receiver = transceiver.receiver ?: return@forEach
|
||||||
|
if (isAudioReceiver(receiver)) {
|
||||||
|
attachReceiverE2EE(receiver)
|
||||||
|
fromTransceivers++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
breadcrumb("E2EE: scan receivers attached recv=$fromReceivers tx=$fromTransceivers totalMap=${receiverDecryptors.size}")
|
||||||
|
}.onFailure {
|
||||||
|
breadcrumb("E2EE: attachReceiverE2EEFromPeerConnection failed: ${it.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Write a breadcrumb to crash_reports/e2ee_breadcrumb.txt — survives SIGSEGV */
|
/** Write a breadcrumb to crash_reports/e2ee_breadcrumb.txt — survives SIGSEGV */
|
||||||
private fun breadcrumb(step: String) {
|
private fun breadcrumb(step: String) {
|
||||||
try {
|
try {
|
||||||
val dir = java.io.File(appContext!!.filesDir, "crash_reports")
|
val dir = ensureCrashReportsDir() ?: return
|
||||||
if (!dir.exists()) dir.mkdirs()
|
if (!dir.exists()) dir.mkdirs()
|
||||||
val f = java.io.File(dir, "e2ee_breadcrumb.txt")
|
val f = java.io.File(dir, BREADCRUMB_FILE_NAME)
|
||||||
// Reset file at start of key exchange
|
// Reset file at start of key exchange
|
||||||
if (step.startsWith("KE:") && step.contains("agreement")) {
|
if (step.startsWith("KE:") && step.contains("agreement")) {
|
||||||
f.writeText("")
|
f.writeText("")
|
||||||
}
|
}
|
||||||
f.appendText("${System.currentTimeMillis()} $step\n")
|
val sidPrefix = if (callSessionId.isNotBlank()) "[sid=$callSessionId] " else ""
|
||||||
|
f.appendText("${System.currentTimeMillis()} $sidPrefix$step\n")
|
||||||
} catch (_: Throwable) {}
|
} catch (_: Throwable) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save a full crash report to crash_reports/ */
|
/** Save a full crash report to crash_reports/ */
|
||||||
private fun saveCrashReport(title: String, error: Throwable) {
|
private fun saveCrashReport(title: String, error: Throwable) {
|
||||||
try {
|
try {
|
||||||
val dir = java.io.File(appContext!!.filesDir, "crash_reports")
|
val dir = ensureCrashReportsDir() ?: return
|
||||||
if (!dir.exists()) dir.mkdirs()
|
if (!dir.exists()) dir.mkdirs()
|
||||||
val ts = java.text.SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", java.util.Locale.getDefault()).format(java.util.Date())
|
val ts = java.text.SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", java.util.Locale.getDefault()).format(java.util.Date())
|
||||||
val f = java.io.File(dir, "crash_e2ee_$ts.txt")
|
val f = java.io.File(dir, "crash_e2ee_$ts.txt")
|
||||||
val sw = java.io.StringWriter()
|
val sw = java.io.StringWriter()
|
||||||
error.printStackTrace(java.io.PrintWriter(sw))
|
error.printStackTrace(java.io.PrintWriter(sw))
|
||||||
f.writeText("=== E2EE CRASH REPORT ===\n$title\n\nType: ${error.javaClass.name}\nMessage: ${error.message}\n\n$sw")
|
val breadcrumbTail = readFileTail(java.io.File(dir, BREADCRUMB_FILE_NAME), TAIL_LINES)
|
||||||
|
val diagTail = readFileTail(java.io.File(dir, DIAG_FILE_NAME), TAIL_LINES)
|
||||||
|
val nativeCrash = readFileTail(java.io.File(dir, NATIVE_CRASH_FILE_NAME), TAIL_LINES)
|
||||||
|
val protocolTail =
|
||||||
|
ProtocolManager.debugLogs.value
|
||||||
|
.takeLast(PROTOCOL_LOG_TAIL_LINES)
|
||||||
|
.joinToString("\n")
|
||||||
|
f.writeText(
|
||||||
|
buildString {
|
||||||
|
appendLine("=== E2EE CRASH REPORT ===")
|
||||||
|
appendLine(title)
|
||||||
|
appendLine()
|
||||||
|
appendLine("Time: $ts")
|
||||||
|
appendLine("Type: ${error.javaClass.name}")
|
||||||
|
appendLine("Message: ${error.message}")
|
||||||
|
appendLine()
|
||||||
|
appendLine("--- CALL SNAPSHOT ---")
|
||||||
|
appendLine(buildStateSnapshot())
|
||||||
|
appendLine()
|
||||||
|
appendLine("--- STACKTRACE ---")
|
||||||
|
appendLine(sw.toString())
|
||||||
|
appendLine()
|
||||||
|
appendLine("--- NATIVE CRASH (tail) ---")
|
||||||
|
appendLine(nativeCrash)
|
||||||
|
appendLine()
|
||||||
|
appendLine("--- E2EE DIAG (tail) ---")
|
||||||
|
appendLine(diagTail)
|
||||||
|
appendLine()
|
||||||
|
appendLine("--- E2EE BREADCRUMB (tail) ---")
|
||||||
|
appendLine(breadcrumbTail)
|
||||||
|
appendLine()
|
||||||
|
appendLine("--- PROTOCOL LOGS (tail) ---")
|
||||||
|
appendLine(if (protocolTail.isBlank()) "<empty>" else protocolTail)
|
||||||
|
}
|
||||||
|
)
|
||||||
} catch (_: Throwable) {}
|
} catch (_: Throwable) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun beginCallSession(seed: String) {
|
||||||
|
val bytes = ByteArray(4)
|
||||||
|
secureRandom.nextBytes(bytes)
|
||||||
|
val random = bytes.joinToString("") { "%02x".format(it) }
|
||||||
|
callSessionId = "${seed.take(8)}-$random"
|
||||||
|
callStartedAtMs = System.currentTimeMillis()
|
||||||
|
breadcrumb("SESSION: begin seed=$seed")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureCrashReportsDir(): java.io.File? {
|
||||||
|
val context = appContext ?: return null
|
||||||
|
return java.io.File(context.filesDir, "crash_reports").apply { if (!exists()) mkdirs() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readFileTail(file: java.io.File, maxLines: Int): String {
|
||||||
|
if (!file.exists()) return "<missing: ${file.name}>"
|
||||||
|
return runCatching {
|
||||||
|
val lines = file.readLines()
|
||||||
|
val tail = if (lines.size <= maxLines) lines else lines.takeLast(maxLines)
|
||||||
|
if (tail.isEmpty()) "<empty: ${file.name}>" else tail.joinToString("\n")
|
||||||
|
}.getOrElse { "<read-failed: ${file.name}: ${it.message}>" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildStateSnapshot(): String {
|
||||||
|
val st = _state.value
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val age = if (callStartedAtMs > 0L) now - callStartedAtMs else -1L
|
||||||
|
val pc = peerConnection
|
||||||
|
val pcSig = runCatching { pc?.signalingState() }.getOrNull()
|
||||||
|
val pcIce = runCatching { pc?.iceConnectionState() }.getOrNull()
|
||||||
|
val pcConn = runCatching { pc?.connectionState() }.getOrNull()
|
||||||
|
val pcLocal = runCatching { pc?.localDescription?.type?.canonicalForm() }.getOrDefault("-")
|
||||||
|
val pcRemote = runCatching { pc?.remoteDescription?.type?.canonicalForm() }.getOrDefault("-")
|
||||||
|
val senders = runCatching { pc?.senders?.size ?: 0 }.getOrDefault(-1)
|
||||||
|
val receivers = runCatching { pc?.receivers?.size ?: 0 }.getOrDefault(-1)
|
||||||
|
return buildString {
|
||||||
|
append("sid=").append(if (callSessionId.isBlank()) "<none>" else callSessionId)
|
||||||
|
append(" ageMs=").append(age)
|
||||||
|
append(" phase=").append(st.phase)
|
||||||
|
append(" role=").append(role)
|
||||||
|
append(" peer=").append(st.peerPublicKey.take(12))
|
||||||
|
append(" room=").append(roomId.take(16))
|
||||||
|
append(" offerSent=").append(offerSent)
|
||||||
|
append(" remoteDescSet=").append(remoteDescriptionSet)
|
||||||
|
append(" e2eeAvail=").append(e2eeAvailable)
|
||||||
|
append(" keyBytes=").append(sharedKeyBytes?.size ?: 0)
|
||||||
|
append(" pc(sig=").append(pcSig)
|
||||||
|
append(",ice=").append(pcIce)
|
||||||
|
append(",conn=").append(pcConn)
|
||||||
|
append(",local=").append(pcLocal)
|
||||||
|
append(",remote=").append(pcRemote)
|
||||||
|
append(",senders=").append(senders)
|
||||||
|
append(",receivers=").append(receivers)
|
||||||
|
append(")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun breadcrumbState(marker: String) {
|
||||||
|
breadcrumb("STATE[$marker] ${buildStateSnapshot()}")
|
||||||
|
}
|
||||||
|
|
||||||
private fun attachSenderE2EE(sender: RtpSender?) {
|
private fun attachSenderE2EE(sender: RtpSender?) {
|
||||||
if (!e2eeAvailable) return
|
if (!e2eeAvailable) return
|
||||||
val key = sharedKeyBytes ?: return
|
val key = sharedKeyBytes ?: return
|
||||||
if (sender == null) return
|
if (sender == null) return
|
||||||
|
val existing = senderEncryptors[sender]
|
||||||
|
if (existing != null) {
|
||||||
|
runCatching { sender.setFrameEncryptor(existing) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
breadcrumb("1. encryptor: nativeLoaded=${XChaCha20E2EE.nativeLoaded}")
|
breadcrumb("1. encryptor: nativeLoaded=${XChaCha20E2EE.nativeLoaded}")
|
||||||
@@ -847,7 +1086,8 @@ object CallManager {
|
|||||||
breadcrumb("4. calling sender.setFrameEncryptor…")
|
breadcrumb("4. calling sender.setFrameEncryptor…")
|
||||||
sender.setFrameEncryptor(enc)
|
sender.setFrameEncryptor(enc)
|
||||||
breadcrumb("5. setFrameEncryptor OK!")
|
breadcrumb("5. setFrameEncryptor OK!")
|
||||||
senderEncryptor = enc
|
senderEncryptors[sender] = enc
|
||||||
|
pendingAudioSenderForE2ee = null
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
saveCrashReport("attachSenderE2EE failed", e)
|
saveCrashReport("attachSenderE2EE failed", e)
|
||||||
Log.e(TAG, "E2EE: sender encryptor failed", e)
|
Log.e(TAG, "E2EE: sender encryptor failed", e)
|
||||||
@@ -855,10 +1095,20 @@ object CallManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun attachReceiverE2EE(transceiver: RtpTransceiver?) {
|
private fun isAudioReceiver(receiver: RtpReceiver?): Boolean {
|
||||||
|
if (receiver == null) return false
|
||||||
|
return runCatching { receiver.track()?.kind() == "audio" }.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun attachReceiverE2EE(receiver: RtpReceiver?) {
|
||||||
if (!e2eeAvailable) return
|
if (!e2eeAvailable) return
|
||||||
val key = sharedKeyBytes ?: return
|
val key = sharedKeyBytes ?: return
|
||||||
val receiver = transceiver?.receiver ?: return
|
if (receiver == null) return
|
||||||
|
val existing = receiverDecryptors[receiver]
|
||||||
|
if (existing != null) {
|
||||||
|
runCatching { receiver.setFrameDecryptor(existing) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
breadcrumb("6. decryptor: creating…")
|
breadcrumb("6. decryptor: creating…")
|
||||||
@@ -873,7 +1123,7 @@ object CallManager {
|
|||||||
breadcrumb("9. calling receiver.setFrameDecryptor…")
|
breadcrumb("9. calling receiver.setFrameDecryptor…")
|
||||||
receiver.setFrameDecryptor(dec)
|
receiver.setFrameDecryptor(dec)
|
||||||
breadcrumb("10. setFrameDecryptor OK!")
|
breadcrumb("10. setFrameDecryptor OK!")
|
||||||
receiverDecryptor = dec
|
receiverDecryptors[receiver] = dec
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
saveCrashReport("attachReceiverE2EE failed", e)
|
saveCrashReport("attachReceiverE2EE failed", e)
|
||||||
Log.e(TAG, "E2EE: receiver decryptor failed", e)
|
Log.e(TAG, "E2EE: receiver decryptor failed", e)
|
||||||
@@ -885,10 +1135,15 @@ object CallManager {
|
|||||||
// Release our ref. WebRTC holds its own ref via scoped_refptr.
|
// Release our ref. WebRTC holds its own ref via scoped_refptr.
|
||||||
// After our Release: WebRTC ref remains. On peerConnection.close()
|
// After our Release: WebRTC ref remains. On peerConnection.close()
|
||||||
// WebRTC releases its ref → ref=0 → native object deleted.
|
// WebRTC releases its ref → ref=0 → native object deleted.
|
||||||
runCatching { senderEncryptor?.dispose() }
|
senderEncryptors.values.forEach { enc ->
|
||||||
runCatching { receiverDecryptor?.dispose() }
|
runCatching { enc.dispose() }
|
||||||
senderEncryptor = null
|
}
|
||||||
receiverDecryptor = null
|
receiverDecryptors.values.forEach { dec ->
|
||||||
|
runCatching { dec.dispose() }
|
||||||
|
}
|
||||||
|
senderEncryptors.clear()
|
||||||
|
receiverDecryptors.clear()
|
||||||
|
pendingAudioSenderForE2ee = null
|
||||||
sharedKeyBytes?.let { it.fill(0) }
|
sharedKeyBytes?.let { it.fill(0) }
|
||||||
sharedKeyBytes = null
|
sharedKeyBytes = null
|
||||||
runCatching { XChaCha20E2EE.nativeCloseDiagFile() }
|
runCatching { XChaCha20E2EE.nativeCloseDiagFile() }
|
||||||
@@ -896,11 +1151,12 @@ object CallManager {
|
|||||||
|
|
||||||
private fun generateSessionKeys() {
|
private fun generateSessionKeys() {
|
||||||
val privateKey = ByteArray(32)
|
val privateKey = ByteArray(32)
|
||||||
secureRandom.nextBytes(privateKey)
|
X25519.generatePrivateKey(secureRandom, privateKey)
|
||||||
val publicKey = ByteArray(32)
|
val publicKey = ByteArray(32)
|
||||||
X25519.generatePublicKey(privateKey, 0, publicKey, 0)
|
X25519.generatePublicKey(privateKey, 0, publicKey, 0)
|
||||||
localPrivateKey = privateKey
|
localPrivateKey = privateKey
|
||||||
localPublicKey = publicKey
|
localPublicKey = publicKey
|
||||||
|
breadcrumb("KE: local keypair pub=${publicKey.shortHex()} privFp=${privateKey.fingerprintHex(6)}")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun computeSharedSecretHex(peerPublicHex: String): String? {
|
private fun computeSharedSecretHex(peerPublicHex: String): String? {
|
||||||
@@ -908,17 +1164,17 @@ object CallManager {
|
|||||||
val peerPublic = peerPublicHex.hexToBytes() ?: return null
|
val peerPublic = peerPublicHex.hexToBytes() ?: return null
|
||||||
if (peerPublic.size != 32) return null
|
if (peerPublic.size != 32) return null
|
||||||
val rawDh = ByteArray(32)
|
val rawDh = ByteArray(32)
|
||||||
breadcrumb("KE: X25519 agreement…")
|
breadcrumb("KE: X25519 agreement with peerPub=${peerPublic.shortHex()}…")
|
||||||
val ok = X25519.calculateAgreement(privateKey, 0, peerPublic, 0, rawDh, 0)
|
val ok = X25519.calculateAgreement(privateKey, 0, peerPublic, 0, rawDh, 0)
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
breadcrumb("KE: X25519 FAILED")
|
breadcrumb("KE: X25519 FAILED")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
breadcrumb("KE: X25519 OK, calling HSalsa20…")
|
breadcrumb("KE: X25519 OK rawDhFp=${rawDh.fingerprintHex(8)}, calling HSalsa20…")
|
||||||
return try {
|
return try {
|
||||||
val naclShared = XChaCha20E2EE.hsalsa20(rawDh)
|
val naclShared = XChaCha20E2EE.hsalsa20(rawDh)
|
||||||
rawDh.fill(0)
|
rawDh.fill(0)
|
||||||
breadcrumb("KE: HSalsa20 OK, key ready")
|
breadcrumb("KE: HSalsa20 OK keyFp=${naclShared.fingerprintHex(8)}")
|
||||||
naclShared.toHex()
|
naclShared.toHex()
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
saveCrashReport("HSalsa20 failed", e)
|
saveCrashReport("HSalsa20 failed", e)
|
||||||
@@ -943,6 +1199,12 @@ object CallManager {
|
|||||||
val type = SessionDescription.Type.fromCanonicalForm(json.getString("type"))
|
val type = SessionDescription.Type.fromCanonicalForm(json.getString("type"))
|
||||||
val sdp = json.getString("sdp")
|
val sdp = json.getString("sdp")
|
||||||
SessionDescription(type, sdp)
|
SessionDescription(type, sdp)
|
||||||
|
}.onFailure { error ->
|
||||||
|
val preview = raw.replace('\n', ' ').replace('\r', ' ')
|
||||||
|
breadcrumb(
|
||||||
|
"RTC: parseSessionDescription FAILED len=${raw.length} " +
|
||||||
|
"preview=${preview.take(MAX_LOG_PREFIX)} err=${error.message}"
|
||||||
|
)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -961,6 +1223,12 @@ object CallManager {
|
|||||||
val sdpMid = if (json.has("sdpMid") && !json.isNull("sdpMid")) json.getString("sdpMid") else null
|
val sdpMid = if (json.has("sdpMid") && !json.isNull("sdpMid")) json.getString("sdpMid") else null
|
||||||
val sdpMLineIndex = json.optInt("sdpMLineIndex", 0)
|
val sdpMLineIndex = json.optInt("sdpMLineIndex", 0)
|
||||||
IceCandidate(sdpMid, sdpMLineIndex, candidate)
|
IceCandidate(sdpMid, sdpMLineIndex, candidate)
|
||||||
|
}.onFailure { error ->
|
||||||
|
val preview = raw.replace('\n', ' ').replace('\r', ' ')
|
||||||
|
breadcrumb(
|
||||||
|
"RTC: parseIceCandidate FAILED len=${raw.length} " +
|
||||||
|
"preview=${preview.take(MAX_LOG_PREFIX)} err=${error.message}"
|
||||||
|
)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -976,6 +1244,16 @@ object CallManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||||
|
private fun ByteArray.shortHex(bytes: Int = 6): String =
|
||||||
|
take(bytes.coerceAtMost(size)).joinToString("") { "%02x".format(it) }
|
||||||
|
private fun ByteArray.fingerprintHex(bytes: Int = 8): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256").digest(this)
|
||||||
|
return digest.take(bytes.coerceAtMost(digest.size)).joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
private fun String.shortFingerprintHex(bytes: Int = 8): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256").digest(toByteArray(Charsets.UTF_8))
|
||||||
|
return digest.take(bytes.coerceAtMost(digest.size)).joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun String.hexToBytes(): ByteArray? {
|
private fun String.hexToBytes(): ByteArray? {
|
||||||
val clean = trim().lowercase()
|
val clean = trim().lowercase()
|
||||||
|
|||||||
@@ -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.ColorMatrixColorFilter
|
||||||
import android.graphics.Path
|
import android.graphics.Path
|
||||||
import android.graphics.RectF
|
import android.graphics.RectF
|
||||||
import android.util.Log
|
|
||||||
import android.graphics.RenderEffect
|
import android.graphics.RenderEffect
|
||||||
import android.graphics.Shader
|
import android.graphics.Shader
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -11,13 +11,10 @@ import android.view.Gravity
|
|||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -25,13 +22,11 @@ import androidx.compose.foundation.layout.offset
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.SwitchDefaults
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -48,10 +43,10 @@ import androidx.compose.ui.platform.LocalConfiguration
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
@@ -410,17 +405,8 @@ fun ProfileMetaballOverlay(
|
|||||||
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view)
|
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only log in explicit debug mode to keep production scroll clean.
|
|
||||||
val debugLogsEnabled = MetaballDebug.forceMode != null || MetaballDebug.forceNoNotch
|
|
||||||
LaunchedEffect(debugLogsEnabled, notchInfo, screenWidthPx, statusBarHeightPx, headerHeightPx) {
|
|
||||||
if (debugLogsEnabled) {
|
|
||||||
Log.d("ProfileMetaball", "NotchInfo: gravity=${notchInfo?.gravity}, isCircle=${notchInfo?.isLikelyCircle}, bounds=${notchInfo?.bounds}, raw=${notchInfo?.rawPath}")
|
|
||||||
Log.d("ProfileMetaball", "Screen: width=${screenWidthPx}px, statusBar=${statusBarHeightPx}px, header=${headerHeightPx}px")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val hasCenteredNotch = remember(notchInfo, screenWidthPx) {
|
val hasCenteredNotch = remember(notchInfo, screenWidthPx) {
|
||||||
!MetaballDebug.forceNoNotch && isCenteredTopCutout(notchInfo, screenWidthPx)
|
isCenteredTopCutout(notchInfo, screenWidthPx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView)
|
// Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView)
|
||||||
@@ -900,7 +886,7 @@ fun ProfileMetaballOverlayCpu(
|
|||||||
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view)
|
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view)
|
||||||
}
|
}
|
||||||
val hasRealNotch = remember(notchInfo, screenWidthPx) {
|
val hasRealNotch = remember(notchInfo, screenWidthPx) {
|
||||||
!MetaballDebug.forceNoNotch && isCenteredTopCutout(notchInfo, screenWidthPx)
|
isCenteredTopCutout(notchInfo, screenWidthPx)
|
||||||
}
|
}
|
||||||
val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() }
|
val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() }
|
||||||
|
|
||||||
@@ -1162,153 +1148,6 @@ fun ProfileMetaballOverlayCpu(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* DEBUG: Temporary toggle to force a specific rendering path.
|
|
||||||
* Set forceMode to test different paths on your device:
|
|
||||||
* - null: auto-detect (default production behavior)
|
|
||||||
* - "gpu": force GPU path (requires API 31+)
|
|
||||||
* - "cpu": force CPU bitmap path
|
|
||||||
* - "compat": force compat/noop path
|
|
||||||
*
|
|
||||||
* Set forceNoNotch = true to simulate no-notch device (black bar fallback).
|
|
||||||
*
|
|
||||||
* TODO: Remove before release!
|
|
||||||
*/
|
|
||||||
object MetaballDebug {
|
|
||||||
var forceMode: String? = null // "gpu", "cpu", "compat", or null
|
|
||||||
var forceNoNotch: Boolean = false // true = pretend no notch exists
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DEBUG: Floating panel with buttons to switch metaball rendering path.
|
|
||||||
* Place inside a Box (e.g. profile header) — it aligns to bottom-center.
|
|
||||||
* TODO: Remove before release!
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun MetaballDebugPanel(modifier: Modifier = Modifier) {
|
|
||||||
var currentMode by remember { mutableStateOf(MetaballDebug.forceMode) }
|
|
||||||
var noNotch by remember { mutableStateOf(MetaballDebug.forceNoNotch) }
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
val perfClass = remember { DevicePerformanceClass.get(context) }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
.background(
|
|
||||||
ComposeColor.Black.copy(alpha = 0.75f),
|
|
||||||
RoundedCornerShape(12.dp)
|
|
||||||
)
|
|
||||||
.padding(12.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
// Title
|
|
||||||
Text(
|
|
||||||
text = "Metaball Debug | API ${Build.VERSION.SDK_INT} | $perfClass",
|
|
||||||
color = ComposeColor.White,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
|
|
||||||
// Mode buttons row
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
val modes = listOf(null to "Auto", "gpu" to "GPU", "cpu" to "CPU", "compat" to "Compat")
|
|
||||||
modes.forEach { (mode, label) ->
|
|
||||||
val isSelected = currentMode == mode
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(
|
|
||||||
if (isSelected) ComposeColor(0xFF4CAF50) else ComposeColor.White.copy(alpha = 0.15f)
|
|
||||||
)
|
|
||||||
.border(
|
|
||||||
width = 1.dp,
|
|
||||||
color = if (isSelected) ComposeColor(0xFF4CAF50) else ComposeColor.White.copy(alpha = 0.3f),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
)
|
|
||||||
.clickable {
|
|
||||||
MetaballDebug.forceMode = mode
|
|
||||||
currentMode = mode
|
|
||||||
}
|
|
||||||
.padding(vertical = 8.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
color = ComposeColor.White,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No-notch toggle
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Force no-notch (black bar)",
|
|
||||||
color = ComposeColor.White,
|
|
||||||
fontSize = 12.sp
|
|
||||||
)
|
|
||||||
Switch(
|
|
||||||
checked = noNotch,
|
|
||||||
onCheckedChange = {
|
|
||||||
MetaballDebug.forceNoNotch = it
|
|
||||||
noNotch = it
|
|
||||||
},
|
|
||||||
colors = SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = ComposeColor(0xFF4CAF50),
|
|
||||||
checkedTrackColor = ComposeColor(0xFF4CAF50).copy(alpha = 0.5f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Current active path info
|
|
||||||
val activePath = when (currentMode) {
|
|
||||||
"gpu" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) "GPU (forced)" else "GPU needs API 31!"
|
|
||||||
"cpu" -> "CPU (forced)"
|
|
||||||
"compat" -> "Compat (forced)"
|
|
||||||
else -> when {
|
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> "GPU (auto)"
|
|
||||||
else -> "CPU (auto)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = "Active: $activePath" + if (noNotch) " + no-notch" else "",
|
|
||||||
color = ComposeColor(0xFF4CAF50),
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
|
|
||||||
// Notch detection info
|
|
||||||
val view = LocalView.current
|
|
||||||
val notchRes = remember { NotchInfoUtils.getInfo(context) }
|
|
||||||
val notchCutout = remember(view) { NotchInfoUtils.getInfoFromCutout(view) }
|
|
||||||
val notchSource = when {
|
|
||||||
notchRes != null -> "resource"
|
|
||||||
notchCutout != null -> "DisplayCutout"
|
|
||||||
else -> "NONE"
|
|
||||||
}
|
|
||||||
val activeNotch = notchRes ?: notchCutout
|
|
||||||
Text(
|
|
||||||
text = "Notch: $notchSource" +
|
|
||||||
if (activeNotch != null) " | ${activeNotch.bounds.width().toInt()}x${activeNotch.bounds.height().toInt()}" +
|
|
||||||
" circle=${activeNotch.isLikelyCircle}" else " (black bar fallback!)",
|
|
||||||
color = if (activeNotch != null) ComposeColor(0xFF4CAF50) else ComposeColor(0xFFFF5722),
|
|
||||||
fontSize = 10.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-selecting wrapper — 3-tier architecture matching Telegram's ProfileGooeyView:
|
* Auto-selecting wrapper — 3-tier architecture matching Telegram's ProfileGooeyView:
|
||||||
* 1. GPU path (Android 12+, average+ performance): RenderEffect blur + ColorMatrixColorFilter
|
* 1. GPU path (Android 12+, average+ performance): RenderEffect blur + ColorMatrixColorFilter
|
||||||
@@ -1329,36 +1168,9 @@ fun ProfileMetaballEffect(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val performanceClass = remember { DevicePerformanceClass.get(context) }
|
val performanceClass = remember { DevicePerformanceClass.get(context) }
|
||||||
|
|
||||||
// Debug: log which path is selected
|
|
||||||
val selectedPath = when (MetaballDebug.forceMode) {
|
|
||||||
"gpu" -> "GPU (forced)"
|
|
||||||
"cpu" -> "CPU (forced)"
|
|
||||||
"compat" -> "Compat (forced)"
|
|
||||||
else -> when {
|
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> "GPU (auto)"
|
|
||||||
else -> "CPU (auto)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val debugLogsEnabled = MetaballDebug.forceMode != null || MetaballDebug.forceNoNotch
|
|
||||||
LaunchedEffect(selectedPath, debugLogsEnabled, performanceClass) {
|
|
||||||
if (debugLogsEnabled) {
|
|
||||||
Log.d("MetaballDebug", "Rendering path: $selectedPath, forceNoNotch: ${MetaballDebug.forceNoNotch}, perf: $performanceClass, API: ${Build.VERSION.SDK_INT}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve actual mode
|
// Resolve actual mode
|
||||||
val useGpu = when (MetaballDebug.forceMode) {
|
val useGpu = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
"gpu" -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S // still need API 31
|
val useCpu = !useGpu
|
||||||
"cpu" -> false
|
|
||||||
"compat" -> false
|
|
||||||
else -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
|
||||||
}
|
|
||||||
val useCpu = when (MetaballDebug.forceMode) {
|
|
||||||
"gpu" -> false
|
|
||||||
"cpu" -> true
|
|
||||||
"compat" -> false
|
|
||||||
else -> !useGpu
|
|
||||||
}
|
|
||||||
|
|
||||||
when {
|
when {
|
||||||
useGpu -> {
|
useGpu -> {
|
||||||
|
|||||||
Reference in New Issue
Block a user