Доработки звонков и чатов: typing, UI и стабильность
This commit is contained in:
@@ -81,22 +81,29 @@ class IncomingCallActivity : ComponentActivity() {
|
|||||||
LaunchedEffect(callState.phase) {
|
LaunchedEffect(callState.phase) {
|
||||||
callLog("phase changed: ${callState.phase}")
|
callLog("phase changed: ${callState.phase}")
|
||||||
if (callState.phase == CallPhase.INCOMING) wasIncoming = true
|
if (callState.phase == CallPhase.INCOMING) wasIncoming = true
|
||||||
// Закрываем только если звонок реально начался и потом завершился
|
// Закрываем только когда звонок завершился
|
||||||
if (callState.phase == CallPhase.IDLE && wasIncoming) {
|
if (callState.phase == CallPhase.IDLE && wasIncoming) {
|
||||||
callLog("IDLE after INCOMING → finish()")
|
callLog("IDLE after INCOMING → finish()")
|
||||||
finish()
|
finish()
|
||||||
} else if (callState.phase == CallPhase.CONNECTING ||
|
|
||||||
callState.phase == CallPhase.ACTIVE) {
|
|
||||||
callLog("${callState.phase} → openMainActivity + finish")
|
|
||||||
openMainActivity()
|
|
||||||
finish()
|
|
||||||
}
|
}
|
||||||
|
// НЕ закрываемся при CONNECTING/ACTIVE — остаёмся на экране звонка
|
||||||
|
// IncomingCallActivity показывает полный CallOverlay, не нужно переходить в MainActivity
|
||||||
}
|
}
|
||||||
|
|
||||||
// Показываем INCOMING даже если CallManager ещё в IDLE (push раньше WebSocket)
|
// Показываем INCOMING в IDLE только до первого реального входящего состояния.
|
||||||
val displayState = if (callState.phase == CallPhase.IDLE) {
|
// Иначе после Decline/END на мгновение мелькает "Unknown".
|
||||||
|
val shouldShowProvisionalIncoming =
|
||||||
|
callState.phase == CallPhase.IDLE &&
|
||||||
|
!wasIncoming &&
|
||||||
|
(callState.peerPublicKey.isNotBlank() ||
|
||||||
|
callState.peerTitle.isNotBlank() ||
|
||||||
|
callState.peerUsername.isNotBlank())
|
||||||
|
|
||||||
|
val displayState = if (shouldShowProvisionalIncoming) {
|
||||||
callState.copy(phase = CallPhase.INCOMING, statusText = "Incoming call...")
|
callState.copy(phase = CallPhase.INCOMING, statusText = "Incoming call...")
|
||||||
} else callState
|
} else {
|
||||||
|
callState
|
||||||
|
}
|
||||||
|
|
||||||
RosettaAndroidTheme(darkTheme = true) {
|
RosettaAndroidTheme(darkTheme = true) {
|
||||||
CallOverlay(
|
CallOverlay(
|
||||||
@@ -108,16 +115,10 @@ class IncomingCallActivity : ComponentActivity() {
|
|||||||
if (callState.phase == CallPhase.INCOMING) {
|
if (callState.phase == CallPhase.INCOMING) {
|
||||||
val result = CallManager.acceptIncomingCall()
|
val result = CallManager.acceptIncomingCall()
|
||||||
callLog("acceptIncomingCall result=$result")
|
callLog("acceptIncomingCall result=$result")
|
||||||
if (result == CallActionResult.STARTED) {
|
// Остаёмся на IncomingCallActivity — она покажет CONNECTING → ACTIVE
|
||||||
openMainActivity()
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
callLog("onAccept: phase not INCOMING yet, waiting...")
|
callLog("onAccept: phase=${callState.phase}, trying accept anyway")
|
||||||
// WebSocket ещё не доставил CALL — открываем MainActivity,
|
CallManager.acceptIncomingCall()
|
||||||
// она подождёт и примет звонок
|
|
||||||
openMainActivity()
|
|
||||||
finish()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDecline = {
|
onDecline = {
|
||||||
|
|||||||
@@ -46,19 +46,24 @@ object DraftManager {
|
|||||||
fun saveDraft(opponentKey: String, text: String) {
|
fun saveDraft(opponentKey: String, text: String) {
|
||||||
if (currentAccount.isEmpty()) return
|
if (currentAccount.isEmpty()) return
|
||||||
|
|
||||||
val trimmed = text.trim()
|
val hasContent = text.any { !it.isWhitespace() }
|
||||||
val currentDrafts = _drafts.value.toMutableMap()
|
val existing = _drafts.value[opponentKey]
|
||||||
|
|
||||||
if (trimmed.isEmpty()) {
|
if (!hasContent) {
|
||||||
|
if (existing == null) return
|
||||||
|
val currentDrafts = _drafts.value.toMutableMap()
|
||||||
// Удаляем черновик если текст пустой
|
// Удаляем черновик если текст пустой
|
||||||
currentDrafts.remove(opponentKey)
|
currentDrafts.remove(opponentKey)
|
||||||
prefs?.edit()?.remove(prefKey(opponentKey))?.apply()
|
prefs?.edit()?.remove(prefKey(opponentKey))?.apply()
|
||||||
} else {
|
|
||||||
currentDrafts[opponentKey] = trimmed
|
|
||||||
prefs?.edit()?.putString(prefKey(opponentKey), trimmed)?.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
_drafts.value = currentDrafts
|
_drafts.value = currentDrafts
|
||||||
|
} else {
|
||||||
|
// Ничего не делаем, если текст не изменился — это частый путь при больших вставках.
|
||||||
|
if (existing == text) return
|
||||||
|
val currentDrafts = _drafts.value.toMutableMap()
|
||||||
|
currentDrafts[opponentKey] = text
|
||||||
|
prefs?.edit()?.putString(prefKey(opponentKey), text)?.apply()
|
||||||
|
_drafts.value = currentDrafts
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Получить черновик для диалога */
|
/** Получить черновик для диалога */
|
||||||
|
|||||||
@@ -48,14 +48,19 @@ class CallForegroundService : Service() {
|
|||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
val action = intent?.action ?: ACTION_SYNC
|
val action = intent?.action ?: ACTION_SYNC
|
||||||
CallManager.initialize(applicationContext)
|
CallManager.initialize(applicationContext)
|
||||||
notifLog("onStartCommand action=$action phase=${CallManager.state.value.phase}")
|
val phaseNow = CallManager.state.value.phase
|
||||||
|
notifLog("onStartCommand action=$action phase=$phaseNow")
|
||||||
|
|
||||||
when (action) {
|
when (action) {
|
||||||
ACTION_STOP -> {
|
ACTION_STOP -> {
|
||||||
|
if (phaseNow == CallPhase.IDLE) {
|
||||||
notifLog("ACTION_STOP → stopSelf")
|
notifLog("ACTION_STOP → stopSelf")
|
||||||
safeStopForeground()
|
safeStopForeground()
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
// Может прилететь поздний STOP от прошлой сессии, не глушим живой звонок.
|
||||||
|
notifLog("ACTION_STOP ignored: phase=$phaseNow")
|
||||||
|
}
|
||||||
ACTION_END -> {
|
ACTION_END -> {
|
||||||
notifLog("ACTION_END → endCall")
|
notifLog("ACTION_END → endCall")
|
||||||
CallManager.endCall()
|
CallManager.endCall()
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ object CallManager {
|
|||||||
private const val PROTOCOL_LOG_TAIL_LINES = 180
|
private const val PROTOCOL_LOG_TAIL_LINES = 180
|
||||||
private const val MAX_LOG_PREFIX = 180
|
private const val MAX_LOG_PREFIX = 180
|
||||||
private const val INCOMING_RING_TIMEOUT_MS = 45_000L
|
private const val INCOMING_RING_TIMEOUT_MS = 45_000L
|
||||||
|
private const val CONNECTING_TIMEOUT_MS = 30_000L
|
||||||
|
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
private val secureRandom = SecureRandom()
|
private val secureRandom = SecureRandom()
|
||||||
@@ -125,6 +126,7 @@ object CallManager {
|
|||||||
private var protocolStateJob: Job? = null
|
private var protocolStateJob: Job? = null
|
||||||
private var disconnectResetJob: Job? = null
|
private var disconnectResetJob: Job? = null
|
||||||
private var incomingRingTimeoutJob: Job? = null
|
private var incomingRingTimeoutJob: Job? = null
|
||||||
|
private var connectingTimeoutJob: Job? = null
|
||||||
|
|
||||||
private var signalWaiter: ((Packet) -> Unit)? = null
|
private var signalWaiter: ((Packet) -> Unit)? = null
|
||||||
private var webRtcWaiter: ((Packet) -> Unit)? = null
|
private var webRtcWaiter: ((Packet) -> Unit)? = null
|
||||||
@@ -146,6 +148,7 @@ object CallManager {
|
|||||||
private var lastLocalOfferFingerprint: String = ""
|
private var lastLocalOfferFingerprint: String = ""
|
||||||
private var e2eeRebindJob: Job? = null
|
private var e2eeRebindJob: Job? = null
|
||||||
|
|
||||||
|
@Volatile private var resetting = false
|
||||||
private var iceServers: List<PeerConnection.IceServer> = emptyList()
|
private var iceServers: List<PeerConnection.IceServer> = emptyList()
|
||||||
|
|
||||||
fun initialize(context: Context) {
|
fun initialize(context: Context) {
|
||||||
@@ -173,7 +176,26 @@ object CallManager {
|
|||||||
ProtocolManager.requestIceServers()
|
ProtocolManager.requestIceServers()
|
||||||
}
|
}
|
||||||
ProtocolState.DISCONNECTED -> {
|
ProtocolState.DISCONNECTED -> {
|
||||||
|
// Не сбрасываем звонок при переподключении WebSocket —
|
||||||
|
// push мог разбудить процесс и вызвать reconnect,
|
||||||
|
// а звонок уже в INCOMING/CONNECTING
|
||||||
|
val phase = _state.value.phase
|
||||||
|
if (phase == CallPhase.IDLE) {
|
||||||
|
val hasResidualSession =
|
||||||
|
callSessionId.isNotBlank() ||
|
||||||
|
roomId.isNotBlank() ||
|
||||||
|
role != null ||
|
||||||
|
_state.value.peerPublicKey.isNotBlank() ||
|
||||||
|
sharedKeyBytes != null ||
|
||||||
|
peerConnection != null
|
||||||
|
if (hasResidualSession) {
|
||||||
resetSession(reason = "Disconnected", notifyPeer = false)
|
resetSession(reason = "Disconnected", notifyPeer = false)
|
||||||
|
} else {
|
||||||
|
breadcrumb("DISCONNECTED in IDLE — skip reset (no active session)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
breadcrumb("DISCONNECTED but phase=$phase — keeping call alive")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
@@ -288,6 +310,7 @@ object CallManager {
|
|||||||
statusText = "Connecting..."
|
statusText = "Connecting..."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
armConnectingTimeout("acceptIncomingCall")
|
||||||
|
|
||||||
// Отправляем KEY_EXCHANGE — если WebSocket не подключен, ждём и ретраим
|
// Отправляем KEY_EXCHANGE — если WebSocket не подключен, ждём и ретраим
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -489,6 +512,7 @@ object CallManager {
|
|||||||
statusText = "Connecting..."
|
statusText = "Connecting..."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
armConnectingTimeout("signal:create_room")
|
||||||
ensurePeerConnectionAndOffer()
|
ensurePeerConnectionAndOffer()
|
||||||
}
|
}
|
||||||
SignalType.ACTIVE_CALL -> Unit
|
SignalType.ACTIVE_CALL -> Unit
|
||||||
@@ -549,6 +573,7 @@ object CallManager {
|
|||||||
createRoomSent = true
|
createRoomSent = true
|
||||||
}
|
}
|
||||||
updateState { it.copy(phase = CallPhase.CONNECTING) }
|
updateState { it.copy(phase = CallPhase.CONNECTING) }
|
||||||
|
armConnectingTimeout("key_exchange:caller")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,6 +590,7 @@ object CallManager {
|
|||||||
setupE2EE(sharedKey)
|
setupE2EE(sharedKey)
|
||||||
breadcrumb("KE: CALLEE — E2EE ready, waiting for CREATE_ROOM")
|
breadcrumb("KE: CALLEE — E2EE ready, waiting for CREATE_ROOM")
|
||||||
updateState { it.copy(keyCast = sharedKey, phase = CallPhase.CONNECTING) }
|
updateState { it.copy(keyCast = sharedKey, phase = CallPhase.CONNECTING) }
|
||||||
|
armConnectingTimeout("key_exchange:callee")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -844,6 +870,7 @@ object CallManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun onCallConnected() {
|
private fun onCallConnected() {
|
||||||
|
disarmConnectingTimeout("connected")
|
||||||
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CONNECTED) }
|
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CONNECTED) }
|
||||||
val keyFp = sharedKeyBytes?.fingerprintHex(8) ?: "null"
|
val keyFp = sharedKeyBytes?.fingerprintHex(8) ?: "null"
|
||||||
breadcrumb("CONNECTED: e2eeAvail=$e2eeAvailable keyFp=$keyFp sEnc=${senderEncryptors.size} rDec=${receiverDecryptors.size} nativeLoaded=${XChaCha20E2EE.nativeLoaded}")
|
breadcrumb("CONNECTED: e2eeAvail=$e2eeAvailable keyFp=$keyFp sEnc=${senderEncryptors.size} rDec=${receiverDecryptors.size} nativeLoaded=${XChaCha20E2EE.nativeLoaded}")
|
||||||
@@ -862,6 +889,31 @@ object CallManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun armConnectingTimeout(origin: String) {
|
||||||
|
connectingTimeoutJob?.cancel()
|
||||||
|
connectingTimeoutJob =
|
||||||
|
scope.launch {
|
||||||
|
delay(CONNECTING_TIMEOUT_MS)
|
||||||
|
val snapshot = _state.value
|
||||||
|
if (snapshot.phase != CallPhase.CONNECTING) return@launch
|
||||||
|
breadcrumb(
|
||||||
|
"CONNECTING TIMEOUT origin=$origin role=$role room=${roomId.take(12)} " +
|
||||||
|
"keyExSent=$keyExchangeSent createRoomSent=$createRoomSent offerSent=$offerSent " +
|
||||||
|
"remoteDesc=$remoteDescriptionSet peer=${snapshot.peerPublicKey.take(8)}…"
|
||||||
|
)
|
||||||
|
resetSession(reason = "Connecting timeout", notifyPeer = false)
|
||||||
|
}
|
||||||
|
breadcrumb("CONNECTING watchdog armed origin=$origin timeoutMs=$CONNECTING_TIMEOUT_MS")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disarmConnectingTimeout(origin: String) {
|
||||||
|
if (connectingTimeoutJob != null) {
|
||||||
|
connectingTimeoutJob?.cancel()
|
||||||
|
connectingTimeoutJob = null
|
||||||
|
breadcrumb("CONNECTING watchdog disarmed origin=$origin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun setPeer(publicKey: String, title: String, username: String) {
|
private fun setPeer(publicKey: String, title: String, username: String) {
|
||||||
updateState {
|
updateState {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -920,6 +972,8 @@ object CallManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun resetSession(reason: String?, notifyPeer: Boolean) {
|
private fun resetSession(reason: String?, notifyPeer: Boolean) {
|
||||||
|
resetting = true
|
||||||
|
disarmConnectingTimeout("resetSession")
|
||||||
breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}")
|
breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}")
|
||||||
breadcrumbState("resetSession")
|
breadcrumbState("resetSession")
|
||||||
val snapshot = _state.value
|
val snapshot = _state.value
|
||||||
@@ -932,6 +986,15 @@ object CallManager {
|
|||||||
dst = peerToNotify
|
dst = peerToNotify
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Отменяем все jobs ПЕРВЫМИ — чтобы они не вызвали updateState с пустым state
|
||||||
|
durationJob?.cancel()
|
||||||
|
durationJob = null
|
||||||
|
e2eeRebindJob?.cancel()
|
||||||
|
e2eeRebindJob = null
|
||||||
|
disconnectResetJob?.cancel()
|
||||||
|
disconnectResetJob = null
|
||||||
|
incomingRingTimeoutJob?.cancel()
|
||||||
|
incomingRingTimeoutJob = null
|
||||||
// Play end call sound, then stop all
|
// Play end call sound, then stop all
|
||||||
if (wasActive) {
|
if (wasActive) {
|
||||||
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.END_CALL) }
|
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.END_CALL) }
|
||||||
@@ -956,22 +1019,15 @@ object CallManager {
|
|||||||
lastPeerSharedPublicHex = ""
|
lastPeerSharedPublicHex = ""
|
||||||
lastRemoteOfferFingerprint = ""
|
lastRemoteOfferFingerprint = ""
|
||||||
lastLocalOfferFingerprint = ""
|
lastLocalOfferFingerprint = ""
|
||||||
e2eeRebindJob?.cancel()
|
|
||||||
e2eeRebindJob = null
|
|
||||||
localPrivateKey = null
|
localPrivateKey = null
|
||||||
localPublicKey = null
|
localPublicKey = null
|
||||||
callSessionId = ""
|
callSessionId = ""
|
||||||
callStartedAtMs = 0L
|
callStartedAtMs = 0L
|
||||||
durationJob?.cancel()
|
|
||||||
durationJob = null
|
|
||||||
disconnectResetJob?.cancel()
|
|
||||||
disconnectResetJob = null
|
|
||||||
incomingRingTimeoutJob?.cancel()
|
|
||||||
incomingRingTimeoutJob = null
|
|
||||||
setSpeakerphone(false)
|
setSpeakerphone(false)
|
||||||
// Останавливаем ForegroundService ДО сброса state — иначе "Unknown" мелькает
|
// Останавливаем ForegroundService и сбрасываем state
|
||||||
appContext?.let { CallForegroundService.stop(it) }
|
appContext?.let { CallForegroundService.stop(it) }
|
||||||
_state.value = CallUiState()
|
_state.value = CallUiState()
|
||||||
|
resetting = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resetRtcObjects() {
|
private fun resetRtcObjects() {
|
||||||
@@ -1440,11 +1496,11 @@ object CallManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updateState(reducer: (CallUiState) -> CallUiState) {
|
private fun updateState(reducer: (CallUiState) -> CallUiState) {
|
||||||
|
if (resetting) return // Не синхронизируем во время resetSession — иначе "Unknown" мелькает
|
||||||
val old = _state.value
|
val old = _state.value
|
||||||
_state.update(reducer)
|
_state.update(reducer)
|
||||||
val newState = _state.value
|
val newState = _state.value
|
||||||
// Синхронизируем ForegroundService при смене фазы или имени
|
// Синхронизируем ForegroundService при смене фазы или имени
|
||||||
// Не синхронизируем при IDLE — resetSession уже вызывает CallForegroundService.stop()
|
|
||||||
if (newState.phase != CallPhase.IDLE &&
|
if (newState.phase != CallPhase.IDLE &&
|
||||||
(newState.phase != old.phase || newState.displayName != old.displayName)) {
|
(newState.phase != old.phase || newState.displayName != old.displayName)) {
|
||||||
appContext?.let { ctx ->
|
appContext?.let { ctx ->
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import okhttp3.*
|
import okhttp3.*
|
||||||
import okio.ByteString
|
import okio.ByteString
|
||||||
|
import java.util.Locale
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +37,8 @@ class Protocol(
|
|||||||
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
|
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
|
||||||
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
|
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
|
||||||
private const val HEARTBEAT_OK_LOG_THROTTLE_MS = 30_000L
|
private const val HEARTBEAT_OK_LOG_THROTTLE_MS = 30_000L
|
||||||
|
private const val HEX_PREVIEW_BYTES = 64
|
||||||
|
private const val TEXT_PREVIEW_CHARS = 80
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun log(message: String) {
|
private fun log(message: String) {
|
||||||
@@ -43,6 +46,123 @@ class Protocol(
|
|||||||
logger(message)
|
logger(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun packetName(packetId: Int): String =
|
||||||
|
when (packetId) {
|
||||||
|
0x00 -> "HANDSHAKE"
|
||||||
|
0x01 -> "USER_INFO"
|
||||||
|
0x02 -> "RESULT"
|
||||||
|
0x03 -> "SEARCH"
|
||||||
|
0x04 -> "ONLINE_SUBSCRIBE"
|
||||||
|
0x05 -> "ONLINE_STATE"
|
||||||
|
0x06 -> "MESSAGE"
|
||||||
|
0x07 -> "READ"
|
||||||
|
0x08 -> "DELIVERY"
|
||||||
|
0x09 -> "DEVICE_NEW"
|
||||||
|
0x0A -> "REQUEST_UPDATE"
|
||||||
|
0x0B -> "TYPING"
|
||||||
|
0x0F -> "REQUEST_TRANSPORT"
|
||||||
|
0x10 -> "PUSH_NOTIFICATION"
|
||||||
|
0x11 -> "GROUP_CREATE"
|
||||||
|
0x12 -> "GROUP_INFO"
|
||||||
|
0x13 -> "GROUP_INVITE_INFO"
|
||||||
|
0x14 -> "GROUP_JOIN"
|
||||||
|
0x15 -> "GROUP_LEAVE"
|
||||||
|
0x16 -> "GROUP_BAN"
|
||||||
|
0x17 -> "DEVICE_LIST"
|
||||||
|
0x18 -> "DEVICE_RESOLVE"
|
||||||
|
0x19 -> "SYNC"
|
||||||
|
0x1A -> "SIGNAL_PEER"
|
||||||
|
0x1B -> "WEBRTC"
|
||||||
|
0x1C -> "ICE_SERVERS"
|
||||||
|
else -> "UNKNOWN"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shortKey(value: String, visible: Int = 8): String {
|
||||||
|
val raw = value.trim()
|
||||||
|
if (raw.isBlank()) return "<empty>"
|
||||||
|
if (raw.length <= visible) return raw
|
||||||
|
return "${raw.take(visible)}…"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shortText(value: String, limit: Int = TEXT_PREVIEW_CHARS): String {
|
||||||
|
val normalized = value.replace('\n', ' ').replace('\r', ' ').trim()
|
||||||
|
if (normalized.isBlank()) return "<empty>"
|
||||||
|
return if (normalized.length <= limit) normalized else "${normalized.take(limit)}…"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hexPreview(bytes: ByteArray, limit: Int = HEX_PREVIEW_BYTES): String {
|
||||||
|
return bytes
|
||||||
|
.take(limit)
|
||||||
|
.joinToString(" ") { b -> String.format(Locale.US, "%02X", b.toInt() and 0xFF) } +
|
||||||
|
if (bytes.size > limit) " …" else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun packetSummary(packet: Packet): String {
|
||||||
|
return when (packet) {
|
||||||
|
is PacketHandshake ->
|
||||||
|
"state=${packet.handshakeState} proto=${packet.protocolVersion} hb=${packet.heartbeatInterval}s " +
|
||||||
|
"pub=${shortKey(packet.publicKey, 12)} privLen=${packet.privateKey.length} " +
|
||||||
|
"deviceId=${shortKey(packet.device.deviceId, 12)} device='${shortText(packet.device.deviceName, 40)}' " +
|
||||||
|
"os='${shortText(packet.device.deviceOs, 32)}'"
|
||||||
|
is PacketResult ->
|
||||||
|
"code=${packet.resultCode}"
|
||||||
|
is PacketSearch ->
|
||||||
|
"query='${shortText(packet.search, 48)}' users=${packet.users.size} privLen=${packet.privateKey.length}"
|
||||||
|
is PacketOnlineSubscribe ->
|
||||||
|
"keys=${packet.publicKeys.size} first=${packet.publicKeys.firstOrNull()?.let { shortKey(it) } ?: "<none>"} " +
|
||||||
|
"privLen=${packet.privateKey.length}"
|
||||||
|
is PacketOnlineState ->
|
||||||
|
"entries=${packet.publicKeysState.size} first=${packet.publicKeysState.firstOrNull()?.let { "${shortKey(it.publicKey)}:${it.state}" } ?: "<none>"}"
|
||||||
|
is PacketMessage ->
|
||||||
|
"id=${shortKey(packet.messageId, 12)} from=${shortKey(packet.fromPublicKey)} to=${shortKey(packet.toPublicKey)} " +
|
||||||
|
"ts=${packet.timestamp} contentLen=${packet.content.length} chachaLen=${packet.chachaKey.length} aesLen=${packet.aesChachaKey.length} " +
|
||||||
|
"att=${packet.attachments.size}" +
|
||||||
|
if (packet.attachments.isNotEmpty()) {
|
||||||
|
" attTypes=${packet.attachments.joinToString(",") { it.type.name }}"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
is PacketRead ->
|
||||||
|
"from=${shortKey(packet.fromPublicKey)} to=${shortKey(packet.toPublicKey)} privLen=${packet.privateKey.length}"
|
||||||
|
is PacketDelivery ->
|
||||||
|
"id=${shortKey(packet.messageId, 12)} to=${shortKey(packet.toPublicKey)}"
|
||||||
|
is PacketTyping ->
|
||||||
|
"from=${shortKey(packet.fromPublicKey)} to=${shortKey(packet.toPublicKey)} privLen=${packet.privateKey.length}"
|
||||||
|
is PacketPushNotification ->
|
||||||
|
"action=${packet.action} tokenType=${packet.tokenType} tokenLen=${packet.notificationsToken.length} " +
|
||||||
|
"deviceId=${shortKey(packet.deviceId, 12)}"
|
||||||
|
is PacketSync ->
|
||||||
|
"status=${packet.status} ts=${packet.timestamp}"
|
||||||
|
is PacketSignalPeer ->
|
||||||
|
"type=${packet.signalType} src=${shortKey(packet.src)} dst=${shortKey(packet.dst)} " +
|
||||||
|
"sharedLen=${packet.sharedPublic.length} room=${shortKey(packet.roomId, 12)}"
|
||||||
|
is PacketWebRTC ->
|
||||||
|
"type=${packet.signalType} sdpLen=${packet.sdpOrCandidate.length} " +
|
||||||
|
"pk=${shortKey(packet.publicKey)} device=${shortKey(packet.deviceId, 12)} " +
|
||||||
|
"preview='${shortText(packet.sdpOrCandidate, 64)}'"
|
||||||
|
is PacketIceServers ->
|
||||||
|
"count=${packet.iceServers.size} firstUrl='${packet.iceServers.firstOrNull()?.url?.let { shortText(it, 40) } ?: "<none>"}'"
|
||||||
|
is PacketRequestTransport ->
|
||||||
|
"requestTransport"
|
||||||
|
is PacketDeviceResolve ->
|
||||||
|
"deviceId=${shortKey(packet.deviceId, 12)} solution=${packet.solution}"
|
||||||
|
is PacketDeviceList ->
|
||||||
|
"devices=${packet.devices.size}"
|
||||||
|
is PacketDeviceNew ->
|
||||||
|
"ip='${shortText(packet.ipAddress, 32)}' deviceId=${shortKey(packet.device.deviceId, 12)} " +
|
||||||
|
"device='${shortText(packet.device.deviceName, 32)}' os='${shortText(packet.device.deviceOs, 24)}'"
|
||||||
|
is PacketUserInfo ->
|
||||||
|
"username='${shortText(packet.username, 20)}' title='${shortText(packet.title, 24)}' privLen=${packet.privateKey.length}"
|
||||||
|
else ->
|
||||||
|
packet::class.java.simpleName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun describePacket(packet: Packet, bytes: Int): String {
|
||||||
|
val packetId = packet.getPacketId()
|
||||||
|
return "id=0x${packetId.toString(16).uppercase(Locale.ROOT)}(${packetId}) name=${packetName(packetId)} bytes=$bytes ${packetSummary(packet)}"
|
||||||
|
}
|
||||||
|
|
||||||
private val client = OkHttpClient.Builder()
|
private val client = OkHttpClient.Builder()
|
||||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||||
.connectTimeout(10, TimeUnit.SECONDS)
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
@@ -487,11 +607,8 @@ class Protocol(
|
|||||||
val stream = packet.send()
|
val stream = packet.send()
|
||||||
val data = stream.getStream()
|
val data = stream.getStream()
|
||||||
|
|
||||||
log("📤 Sending packet: ${packet.getPacketId()} (${data.size} bytes)")
|
log("➡️ CLIENT -> SERVER ${describePacket(packet, data.size)}")
|
||||||
|
log(" TX_HEX: ${hexPreview(data)}")
|
||||||
// Debug: log first 50 bytes as hex
|
|
||||||
val hexDump = data.take(50).joinToString(" ") { String.format("%02X", it) }
|
|
||||||
log(" Hex: $hexDump${if (data.size > 50) "..." else ""}")
|
|
||||||
|
|
||||||
val socket = webSocket
|
val socket = webSocket
|
||||||
if (socket == null) {
|
if (socket == null) {
|
||||||
@@ -504,16 +621,17 @@ class Protocol(
|
|||||||
try {
|
try {
|
||||||
val sent = socket.send(ByteString.of(*data))
|
val sent = socket.send(ByteString.of(*data))
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
log("❌ WebSocket rejected packet ${packet.getPacketId()}, re-queueing")
|
log("❌ TX rejected by WebSocket for id=0x${packet.getPacketId().toString(16).uppercase(Locale.ROOT)}")
|
||||||
|
log("📦 TX re-queue packet id=0x${packet.getPacketId().toString(16).uppercase(Locale.ROOT)}")
|
||||||
packetQueue.add(packet)
|
packetQueue.add(packet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log("✅ Packet ${packet.getPacketId()} sent successfully")
|
log("✅ TX delivered id=0x${packet.getPacketId().toString(16).uppercase(Locale.ROOT)}")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log("❌ Exception sending packet ${packet.getPacketId()}: ${e.message}")
|
log("❌ TX exception id=0x${packet.getPacketId().toString(16).uppercase(Locale.ROOT)} err=${e.message}")
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
// Как в Архиве - возвращаем пакет в очередь при ошибке отправки
|
// Как в Архиве - возвращаем пакет в очередь при ошибке отправки
|
||||||
log("📦 Re-queueing packet ${packet.getPacketId()} due to send error")
|
log("📦 TX re-queue packet id=0x${packet.getPacketId().toString(16).uppercase(Locale.ROOT)} due to send error")
|
||||||
packetQueue.add(packet)
|
packetQueue.add(packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -530,9 +648,8 @@ class Protocol(
|
|||||||
|
|
||||||
private fun handleMessage(data: ByteArray) {
|
private fun handleMessage(data: ByteArray) {
|
||||||
try {
|
try {
|
||||||
// Debug: log first 50 bytes as hex
|
log("⬅️ SERVER -> CLIENT rawBytes=${data.size}")
|
||||||
val hexDump = data.take(50).joinToString(" ") { String.format("%02X", it.toInt() and 0xFF) }
|
log(" RX_HEX: ${hexPreview(data)}")
|
||||||
log("📥 Received ${data.size} bytes: $hexDump${if (data.size > 50) "..." else ""}")
|
|
||||||
|
|
||||||
val stream = Stream(data)
|
val stream = Stream(data)
|
||||||
if (stream.getRemainingBits() < MIN_PACKET_ID_BITS) {
|
if (stream.getRemainingBits() < MIN_PACKET_ID_BITS) {
|
||||||
@@ -558,9 +675,15 @@ class Protocol(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log("⬅️ SERVER -> CLIENT ${describePacket(packet, data.size)}")
|
||||||
|
val remainingBits = stream.getRemainingBits()
|
||||||
|
if (remainingBits > 0) {
|
||||||
|
log("⚠️ RX parser leftover bits for packet id=0x${packetId.toString(16).uppercase(Locale.ROOT)}: $remainingBits")
|
||||||
|
}
|
||||||
|
|
||||||
// Notify waiters
|
// Notify waiters
|
||||||
val waitersCount = packetWaiters[packetId]?.size ?: 0
|
val waitersCount = packetWaiters[packetId]?.size ?: 0
|
||||||
log("📥 Notifying $waitersCount waiter(s) for packet $packetId")
|
log("📥 RX dispatch packet id=0x${packetId.toString(16).uppercase(Locale.ROOT)} waiters=$waitersCount")
|
||||||
|
|
||||||
packetWaiters[packetId]?.forEach { callback ->
|
packetWaiters[packetId]?.forEach { callback ->
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import java.io.File
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
@@ -33,6 +34,9 @@ object ProtocolManager {
|
|||||||
private const val DEBUG_LOG_FLUSH_DELAY_MS = 60L
|
private const val DEBUG_LOG_FLUSH_DELAY_MS = 60L
|
||||||
private const val HEARTBEAT_OK_LOG_MIN_INTERVAL_MS = 5_000L
|
private const val HEARTBEAT_OK_LOG_MIN_INTERVAL_MS = 5_000L
|
||||||
private const val TYPING_INDICATOR_TIMEOUT_MS = 3_000L
|
private const val TYPING_INDICATOR_TIMEOUT_MS = 3_000L
|
||||||
|
private const val PROTOCOL_TRACE_FILE_NAME = "protocol_wire_log.txt"
|
||||||
|
private const val PROTOCOL_TRACE_MAX_BYTES = 2_000_000L
|
||||||
|
private const val PROTOCOL_TRACE_KEEP_BYTES = 1_200_000
|
||||||
private const val PACKET_SIGNAL_PEER = 0x1A
|
private const val PACKET_SIGNAL_PEER = 0x1A
|
||||||
private const val PACKET_WEB_RTC = 0x1B
|
private const val PACKET_WEB_RTC = 0x1B
|
||||||
private const val PACKET_ICE_SERVERS = 0x1C
|
private const val PACKET_ICE_SERVERS = 0x1C
|
||||||
@@ -61,6 +65,7 @@ object ProtocolManager {
|
|||||||
val debugLogs: StateFlow<List<String>> = _debugLogs.asStateFlow()
|
val debugLogs: StateFlow<List<String>> = _debugLogs.asStateFlow()
|
||||||
private val debugLogsBuffer = ArrayDeque<String>(MAX_DEBUG_LOGS)
|
private val debugLogsBuffer = ArrayDeque<String>(MAX_DEBUG_LOGS)
|
||||||
private val debugLogsLock = Any()
|
private val debugLogsLock = Any()
|
||||||
|
private val protocolTraceLock = Any()
|
||||||
@Volatile private var debugFlushJob: Job? = null
|
@Volatile private var debugFlushJob: Job? = null
|
||||||
private val debugFlushPending = AtomicBoolean(false)
|
private val debugFlushPending = AtomicBoolean(false)
|
||||||
@Volatile private var lastHeartbeatOkLogAtMs: Long = 0L
|
@Volatile private var lastHeartbeatOkLogAtMs: Long = 0L
|
||||||
@@ -69,6 +74,10 @@ object ProtocolManager {
|
|||||||
// Typing status
|
// Typing status
|
||||||
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
|
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
|
||||||
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow()
|
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow()
|
||||||
|
private val _typingUsersByDialogSnapshot =
|
||||||
|
MutableStateFlow<Map<String, Set<String>>>(emptyMap())
|
||||||
|
val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>> =
|
||||||
|
_typingUsersByDialogSnapshot.asStateFlow()
|
||||||
private val typingStateLock = Any()
|
private val typingStateLock = Any()
|
||||||
private val typingUsersByDialog = mutableMapOf<String, MutableSet<String>>()
|
private val typingUsersByDialog = mutableMapOf<String, MutableSet<String>>()
|
||||||
private val typingTimeoutJobs = ConcurrentHashMap<String, Job>()
|
private val typingTimeoutJobs = ConcurrentHashMap<String, Job>()
|
||||||
@@ -134,7 +143,6 @@ object ProtocolManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun addLog(message: String) {
|
fun addLog(message: String) {
|
||||||
if (!uiLogsEnabled) return
|
|
||||||
var normalizedMessage = message
|
var normalizedMessage = message
|
||||||
val isHeartbeatOk = normalizedMessage.startsWith("💓 Heartbeat OK")
|
val isHeartbeatOk = normalizedMessage.startsWith("💓 Heartbeat OK")
|
||||||
if (isHeartbeatOk) {
|
if (isHeartbeatOk) {
|
||||||
@@ -152,6 +160,8 @@ object ProtocolManager {
|
|||||||
val timestamp =
|
val timestamp =
|
||||||
java.text.SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
|
java.text.SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
|
||||||
val line = "[$timestamp] $normalizedMessage"
|
val line = "[$timestamp] $normalizedMessage"
|
||||||
|
persistProtocolTraceLine(line)
|
||||||
|
if (!uiLogsEnabled) return
|
||||||
synchronized(debugLogsLock) {
|
synchronized(debugLogsLock) {
|
||||||
if (debugLogsBuffer.size >= MAX_DEBUG_LOGS) {
|
if (debugLogsBuffer.size >= MAX_DEBUG_LOGS) {
|
||||||
debugLogsBuffer.removeFirst()
|
debugLogsBuffer.removeFirst()
|
||||||
@@ -161,6 +171,24 @@ object ProtocolManager {
|
|||||||
flushDebugLogsThrottled()
|
flushDebugLogsThrottled()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun persistProtocolTraceLine(line: String) {
|
||||||
|
val context = appContext ?: return
|
||||||
|
runCatching {
|
||||||
|
val dir = File(context.filesDir, "crash_reports")
|
||||||
|
if (!dir.exists()) dir.mkdirs()
|
||||||
|
val traceFile = File(dir, PROTOCOL_TRACE_FILE_NAME)
|
||||||
|
synchronized(protocolTraceLock) {
|
||||||
|
if (traceFile.exists() && traceFile.length() > PROTOCOL_TRACE_MAX_BYTES) {
|
||||||
|
val tail = runCatching {
|
||||||
|
traceFile.readText(Charsets.UTF_8).takeLast(PROTOCOL_TRACE_KEEP_BYTES)
|
||||||
|
}.getOrDefault("")
|
||||||
|
traceFile.writeText("=== PROTOCOL TRACE ROTATED ===\n$tail\n", Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
traceFile.appendText("$line\n", Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun enableUILogs(enabled: Boolean) {
|
fun enableUILogs(enabled: Boolean) {
|
||||||
uiLogsEnabled = enabled
|
uiLogsEnabled = enabled
|
||||||
MessageLogger.setEnabled(enabled)
|
MessageLogger.setEnabled(enabled)
|
||||||
@@ -656,6 +684,20 @@ object ProtocolManager {
|
|||||||
return "${dialogKey.lowercase(Locale.ROOT)}|${fromPublicKey.lowercase(Locale.ROOT)}"
|
return "${dialogKey.lowercase(Locale.ROOT)}|${fromPublicKey.lowercase(Locale.ROOT)}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getTypingUsersForDialog(dialogKey: String): Set<String> {
|
||||||
|
val normalizedDialogKey =
|
||||||
|
if (isGroupDialogKey(dialogKey)) {
|
||||||
|
normalizeGroupDialogKey(dialogKey)
|
||||||
|
} else {
|
||||||
|
dialogKey.trim()
|
||||||
|
}
|
||||||
|
if (normalizedDialogKey.isBlank()) return emptySet()
|
||||||
|
|
||||||
|
synchronized(typingStateLock) {
|
||||||
|
return typingUsersByDialog[normalizedDialogKey]?.toSet() ?: emptySet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun rememberTypingEvent(dialogKey: String, fromPublicKey: String) {
|
private fun rememberTypingEvent(dialogKey: String, fromPublicKey: String) {
|
||||||
val normalizedDialogKey =
|
val normalizedDialogKey =
|
||||||
if (isGroupDialogKey(dialogKey)) normalizeGroupDialogKey(dialogKey) else dialogKey.trim()
|
if (isGroupDialogKey(dialogKey)) normalizeGroupDialogKey(dialogKey) else dialogKey.trim()
|
||||||
@@ -666,6 +708,8 @@ object ProtocolManager {
|
|||||||
val users = typingUsersByDialog.getOrPut(normalizedDialogKey) { mutableSetOf() }
|
val users = typingUsersByDialog.getOrPut(normalizedDialogKey) { mutableSetOf() }
|
||||||
users.add(normalizedFrom)
|
users.add(normalizedFrom)
|
||||||
_typingUsers.value = typingUsersByDialog.keys.toSet()
|
_typingUsers.value = typingUsersByDialog.keys.toSet()
|
||||||
|
_typingUsersByDialogSnapshot.value =
|
||||||
|
typingUsersByDialog.mapValues { entry -> entry.value.toSet() }
|
||||||
}
|
}
|
||||||
|
|
||||||
val timeoutKey = makeTypingTimeoutKey(normalizedDialogKey, normalizedFrom)
|
val timeoutKey = makeTypingTimeoutKey(normalizedDialogKey, normalizedFrom)
|
||||||
@@ -680,6 +724,8 @@ object ProtocolManager {
|
|||||||
typingUsersByDialog.remove(normalizedDialogKey)
|
typingUsersByDialog.remove(normalizedDialogKey)
|
||||||
}
|
}
|
||||||
_typingUsers.value = typingUsersByDialog.keys.toSet()
|
_typingUsers.value = typingUsersByDialog.keys.toSet()
|
||||||
|
_typingUsersByDialogSnapshot.value =
|
||||||
|
typingUsersByDialog.mapValues { entry -> entry.value.toSet() }
|
||||||
}
|
}
|
||||||
typingTimeoutJobs.remove(timeoutKey)
|
typingTimeoutJobs.remove(timeoutKey)
|
||||||
}
|
}
|
||||||
@@ -691,6 +737,7 @@ object ProtocolManager {
|
|||||||
synchronized(typingStateLock) {
|
synchronized(typingStateLock) {
|
||||||
typingUsersByDialog.clear()
|
typingUsersByDialog.clear()
|
||||||
_typingUsers.value = emptySet()
|
_typingUsers.value = emptySet()
|
||||||
|
_typingUsersByDialogSnapshot.value = emptyMap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1328,6 +1375,10 @@ object ProtocolManager {
|
|||||||
sharedPublic: String = "",
|
sharedPublic: String = "",
|
||||||
roomId: String = ""
|
roomId: String = ""
|
||||||
) {
|
) {
|
||||||
|
addLog(
|
||||||
|
"📡 CALL TX type=$signalType src=${shortKeyForLog(src)} dst=${shortKeyForLog(dst)} " +
|
||||||
|
"sharedLen=${sharedPublic.length} room=${shortKeyForLog(roomId, 12)}"
|
||||||
|
)
|
||||||
send(
|
send(
|
||||||
PacketSignalPeer().apply {
|
PacketSignalPeer().apply {
|
||||||
this.signalType = signalType
|
this.signalType = signalType
|
||||||
@@ -1345,6 +1396,11 @@ object ProtocolManager {
|
|||||||
fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) {
|
fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) {
|
||||||
val pk = try { getProtocol().getPublicKey().orEmpty() } catch (_: Exception) { "" }
|
val pk = try { getProtocol().getPublicKey().orEmpty() } catch (_: Exception) { "" }
|
||||||
val did = appContext?.let { getOrCreateDeviceId(it) } ?: ""
|
val did = appContext?.let { getOrCreateDeviceId(it) } ?: ""
|
||||||
|
addLog(
|
||||||
|
"📡 WEBRTC TX type=$signalType sdpLen=${sdpOrCandidate.length} " +
|
||||||
|
"pk=${shortKeyForLog(pk)} did=${shortKeyForLog(did, 12)} " +
|
||||||
|
"preview='${shortTextForLog(sdpOrCandidate, 56)}'"
|
||||||
|
)
|
||||||
send(
|
send(
|
||||||
PacketWebRTC().apply {
|
PacketWebRTC().apply {
|
||||||
this.signalType = signalType
|
this.signalType = signalType
|
||||||
@@ -1359,6 +1415,7 @@ object ProtocolManager {
|
|||||||
* Request ICE servers from server (0x1C).
|
* Request ICE servers from server (0x1C).
|
||||||
*/
|
*/
|
||||||
fun requestIceServers() {
|
fun requestIceServers() {
|
||||||
|
addLog("📡 ICE TX request")
|
||||||
send(PacketIceServers())
|
send(PacketIceServers())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1368,7 +1425,13 @@ object ProtocolManager {
|
|||||||
*/
|
*/
|
||||||
fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit {
|
fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit {
|
||||||
val wrapper: (Packet) -> Unit = { packet ->
|
val wrapper: (Packet) -> Unit = { packet ->
|
||||||
(packet as? PacketSignalPeer)?.let(callback)
|
(packet as? PacketSignalPeer)?.let {
|
||||||
|
addLog(
|
||||||
|
"📡 CALL RX type=${it.signalType} src=${shortKeyForLog(it.src)} dst=${shortKeyForLog(it.dst)} " +
|
||||||
|
"sharedLen=${it.sharedPublic.length} room=${shortKeyForLog(it.roomId, 12)}"
|
||||||
|
)
|
||||||
|
callback(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
waitPacket(PACKET_SIGNAL_PEER, wrapper)
|
waitPacket(PACKET_SIGNAL_PEER, wrapper)
|
||||||
return wrapper
|
return wrapper
|
||||||
@@ -1384,7 +1447,14 @@ object ProtocolManager {
|
|||||||
*/
|
*/
|
||||||
fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit {
|
fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit {
|
||||||
val wrapper: (Packet) -> Unit = { packet ->
|
val wrapper: (Packet) -> Unit = { packet ->
|
||||||
(packet as? PacketWebRTC)?.let(callback)
|
(packet as? PacketWebRTC)?.let {
|
||||||
|
addLog(
|
||||||
|
"📡 WEBRTC RX type=${it.signalType} sdpLen=${it.sdpOrCandidate.length} " +
|
||||||
|
"pk=${shortKeyForLog(it.publicKey)} did=${shortKeyForLog(it.deviceId, 12)} " +
|
||||||
|
"preview='${shortTextForLog(it.sdpOrCandidate, 56)}'"
|
||||||
|
)
|
||||||
|
callback(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
waitPacket(PACKET_WEB_RTC, wrapper)
|
waitPacket(PACKET_WEB_RTC, wrapper)
|
||||||
return wrapper
|
return wrapper
|
||||||
@@ -1400,7 +1470,11 @@ object ProtocolManager {
|
|||||||
*/
|
*/
|
||||||
fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit {
|
fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit {
|
||||||
val wrapper: (Packet) -> Unit = { packet ->
|
val wrapper: (Packet) -> Unit = { packet ->
|
||||||
(packet as? PacketIceServers)?.let(callback)
|
(packet as? PacketIceServers)?.let {
|
||||||
|
val firstUrl = it.iceServers.firstOrNull()?.url.orEmpty()
|
||||||
|
addLog("📡 ICE RX count=${it.iceServers.size} first='${shortTextForLog(firstUrl, 56)}'")
|
||||||
|
callback(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
waitPacket(PACKET_ICE_SERVERS, wrapper)
|
waitPacket(PACKET_ICE_SERVERS, wrapper)
|
||||||
return wrapper
|
return wrapper
|
||||||
@@ -1468,6 +1542,18 @@ object ProtocolManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun shortKeyForLog(value: String, visible: Int = 8): String {
|
||||||
|
val trimmed = value.trim()
|
||||||
|
if (trimmed.isBlank()) return "<empty>"
|
||||||
|
return if (trimmed.length <= visible) trimmed else "${trimmed.take(visible)}…"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shortTextForLog(value: String, limit: Int = 80): String {
|
||||||
|
val normalized = value.replace('\n', ' ').replace('\r', ' ').trim()
|
||||||
|
if (normalized.isBlank()) return "<empty>"
|
||||||
|
return if (normalized.length <= limit) normalized else "${normalized.take(limit)}…"
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disconnect and clear
|
* Disconnect and clear
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
private val groupMembersCountCache = java.util.concurrent.ConcurrentHashMap<String, Int>()
|
private val groupMembersCountCache = java.util.concurrent.ConcurrentHashMap<String, Int>()
|
||||||
|
private val groupAdminKeysCache = java.util.concurrent.ConcurrentHashMap<String, Set<String>>()
|
||||||
private const val GROUP_MESSAGE_STACK_TIME_DIFF_MS = 5 * 60_000L
|
private const val GROUP_MESSAGE_STACK_TIME_DIFF_MS = 5 * 60_000L
|
||||||
private const val DIRECT_MESSAGE_STACK_TIME_DIFF_MS = 60_000L
|
private const val DIRECT_MESSAGE_STACK_TIME_DIFF_MS = 60_000L
|
||||||
private const val MESSAGE_SKELETON_VISIBILITY_DELAY_MS = 180L
|
private const val MESSAGE_SKELETON_VISIBILITY_DELAY_MS = 180L
|
||||||
@@ -729,8 +730,8 @@ fun ChatDetailScreen(
|
|||||||
remember(user.publicKey, currentUserPublicKey) {
|
remember(user.publicKey, currentUserPublicKey) {
|
||||||
"${currentUserPublicKey.trim()}::${user.publicKey.trim()}"
|
"${currentUserPublicKey.trim()}::${user.publicKey.trim()}"
|
||||||
}
|
}
|
||||||
var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) {
|
var groupAdminKeys by remember(groupMembersCacheKey) {
|
||||||
mutableStateOf<Set<String>>(emptySet())
|
mutableStateOf(groupAdminKeysCache[groupMembersCacheKey] ?: emptySet())
|
||||||
}
|
}
|
||||||
var groupMembersCount by remember(groupMembersCacheKey) {
|
var groupMembersCount by remember(groupMembersCacheKey) {
|
||||||
mutableStateOf<Int?>(groupMembersCountCache[groupMembersCacheKey])
|
mutableStateOf<Int?>(groupMembersCountCache[groupMembersCacheKey])
|
||||||
@@ -756,12 +757,15 @@ fun ChatDetailScreen(
|
|||||||
|
|
||||||
val cachedMembersCount = groupMembersCountCache[groupMembersCacheKey]
|
val cachedMembersCount = groupMembersCountCache[groupMembersCacheKey]
|
||||||
groupMembersCount = cachedMembersCount
|
groupMembersCount = cachedMembersCount
|
||||||
|
val cachedAdminKeys = groupAdminKeysCache[groupMembersCacheKey]
|
||||||
|
if (!cachedAdminKeys.isNullOrEmpty()) {
|
||||||
|
groupAdminKeys = cachedAdminKeys
|
||||||
|
}
|
||||||
|
|
||||||
val members = withContext(Dispatchers.IO) {
|
val members = withContext(Dispatchers.IO) {
|
||||||
groupRepository.requestGroupMembers(user.publicKey)
|
groupRepository.requestGroupMembers(user.publicKey)
|
||||||
}
|
}
|
||||||
if (members == null) {
|
if (members == null) {
|
||||||
groupAdminKeys = emptySet()
|
|
||||||
mentionCandidates = emptyList()
|
mentionCandidates = emptyList()
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
@@ -777,6 +781,9 @@ fun ChatDetailScreen(
|
|||||||
val adminKey = normalizedMembers.firstOrNull().orEmpty()
|
val adminKey = normalizedMembers.firstOrNull().orEmpty()
|
||||||
groupAdminKeys =
|
groupAdminKeys =
|
||||||
if (adminKey.isBlank()) emptySet() else setOf(adminKey)
|
if (adminKey.isBlank()) emptySet() else setOf(adminKey)
|
||||||
|
if (groupAdminKeys.isNotEmpty()) {
|
||||||
|
groupAdminKeysCache[groupMembersCacheKey] = groupAdminKeys
|
||||||
|
}
|
||||||
|
|
||||||
mentionCandidates =
|
mentionCandidates =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -822,6 +829,8 @@ fun ChatDetailScreen(
|
|||||||
val messages by viewModel.messages.collectAsState()
|
val messages by viewModel.messages.collectAsState()
|
||||||
val inputText by viewModel.inputText.collectAsState()
|
val inputText by viewModel.inputText.collectAsState()
|
||||||
val isTyping by viewModel.opponentTyping.collectAsState()
|
val isTyping by viewModel.opponentTyping.collectAsState()
|
||||||
|
val typingDisplayName by viewModel.typingDisplayName.collectAsState()
|
||||||
|
val typingDisplayPublicKey by viewModel.typingDisplayPublicKey.collectAsState()
|
||||||
@Suppress("UNUSED_VARIABLE")
|
@Suppress("UNUSED_VARIABLE")
|
||||||
val isLoadingMore by viewModel.isLoadingMore.collectAsState()
|
val isLoadingMore by viewModel.isLoadingMore.collectAsState()
|
||||||
val rawIsOnline by viewModel.opponentOnline.collectAsState()
|
val rawIsOnline by viewModel.opponentOnline.collectAsState()
|
||||||
@@ -1344,9 +1353,6 @@ fun ChatDetailScreen(
|
|||||||
|
|
||||||
// Динамический subtitle: typing > online > offline
|
// Динамический subtitle: typing > online > offline
|
||||||
val isSystemAccount = MessageRepository.isSystemAccount(user.publicKey)
|
val isSystemAccount = MessageRepository.isSystemAccount(user.publicKey)
|
||||||
val isRosettaOfficial = user.title.equals("Rosetta", ignoreCase = true) ||
|
|
||||||
user.username.equals("rosetta", ignoreCase = true) ||
|
|
||||||
isSystemAccount
|
|
||||||
val groupMembersSubtitleCount = groupMembersCount
|
val groupMembersSubtitleCount = groupMembersCount
|
||||||
val groupMembersSubtitle =
|
val groupMembersSubtitle =
|
||||||
if (groupMembersSubtitleCount == null) {
|
if (groupMembersSubtitleCount == null) {
|
||||||
@@ -2075,7 +2081,7 @@ fun ChatDetailScreen(
|
|||||||
if (!isSavedMessages &&
|
if (!isSavedMessages &&
|
||||||
!isGroupChat &&
|
!isGroupChat &&
|
||||||
(chatHeaderVerified >
|
(chatHeaderVerified >
|
||||||
0 || isRosettaOfficial)
|
0 || isSystemAccount)
|
||||||
) {
|
) {
|
||||||
Spacer(
|
Spacer(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -2109,7 +2115,11 @@ fun ChatDetailScreen(
|
|||||||
if (isTyping) {
|
if (isTyping) {
|
||||||
TypingIndicator(
|
TypingIndicator(
|
||||||
isDarkTheme =
|
isDarkTheme =
|
||||||
isDarkTheme
|
isDarkTheme,
|
||||||
|
typingDisplayName =
|
||||||
|
if (isGroupChat) typingDisplayName else "",
|
||||||
|
typingSenderPublicKey =
|
||||||
|
if (isGroupChat) typingDisplayPublicKey else ""
|
||||||
)
|
)
|
||||||
} else if (isGroupChat &&
|
} else if (isGroupChat &&
|
||||||
groupMembersCount == null
|
groupMembersCount == null
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
private const val DECRYPT_CHUNK_SIZE = 15 // Расшифровываем по 15 сообщений за раз
|
private const val DECRYPT_CHUNK_SIZE = 15 // Расшифровываем по 15 сообщений за раз
|
||||||
private const val DECRYPT_PARALLELISM = 4 // Параллельная расшифровка
|
private const val DECRYPT_PARALLELISM = 4 // Параллельная расшифровка
|
||||||
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
|
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
|
||||||
|
private const val DRAFT_SAVE_DEBOUNCE_MS = 250L
|
||||||
private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private val chatMessageAscComparator =
|
private val chatMessageAscComparator =
|
||||||
compareBy<ChatMessage>({ it.timestamp.time }, { it.id })
|
compareBy<ChatMessage>({ it.timestamp.time }, { it.id })
|
||||||
@@ -167,7 +168,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
private val _opponentTyping = MutableStateFlow(false)
|
private val _opponentTyping = MutableStateFlow(false)
|
||||||
val opponentTyping: StateFlow<Boolean> = _opponentTyping.asStateFlow()
|
val opponentTyping: StateFlow<Boolean> = _opponentTyping.asStateFlow()
|
||||||
|
private val _typingDisplayName = MutableStateFlow("")
|
||||||
|
val typingDisplayName: StateFlow<String> = _typingDisplayName.asStateFlow()
|
||||||
|
private val _typingDisplayPublicKey = MutableStateFlow("")
|
||||||
|
val typingDisplayPublicKey: StateFlow<String> = _typingDisplayPublicKey.asStateFlow()
|
||||||
private var typingTimeoutJob: kotlinx.coroutines.Job? = null
|
private var typingTimeoutJob: kotlinx.coroutines.Job? = null
|
||||||
|
private var typingNameResolveJob: kotlinx.coroutines.Job? = null
|
||||||
|
@Volatile private var typingSenderPublicKey: String? = null
|
||||||
|
@Volatile private var typingUsersCount: Int = 1
|
||||||
|
|
||||||
// 🟢 Онлайн статус собеседника
|
// 🟢 Онлайн статус собеседника
|
||||||
private val _opponentOnline = MutableStateFlow(false)
|
private val _opponentOnline = MutableStateFlow(false)
|
||||||
@@ -218,6 +226,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
// Job для отмены загрузки при смене диалога
|
// Job для отмены загрузки при смене диалога
|
||||||
private var loadingJob: Job? = null
|
private var loadingJob: Job? = null
|
||||||
|
private var draftSaveJob: Job? = null
|
||||||
|
|
||||||
// 🔥 Throttling для typing индикатора
|
// 🔥 Throttling для typing индикатора
|
||||||
private var lastTypingSentTime = 0L
|
private var lastTypingSentTime = 0L
|
||||||
@@ -359,9 +368,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (shouldShowTyping) {
|
if (shouldShowTyping) {
|
||||||
|
if (isGroupDialogKey(currentDialog)) {
|
||||||
|
val typingUsers = ProtocolManager.getTypingUsersForDialog(currentDialog).toMutableSet()
|
||||||
|
typingUsers.add(fromPublicKey)
|
||||||
|
showTypingIndicator(
|
||||||
|
senderPublicKey = fromPublicKey,
|
||||||
|
typingUsersCount = typingUsers.size
|
||||||
|
)
|
||||||
|
} else {
|
||||||
showTypingIndicator()
|
showTypingIndicator()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val onlinePacketHandler: (Packet) -> Unit = { packet ->
|
private val onlinePacketHandler: (Packet) -> Unit = { packet ->
|
||||||
val onlinePacket = packet as PacketOnlineState
|
val onlinePacket = packet as PacketOnlineState
|
||||||
@@ -730,7 +748,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
_opponentOnline.value = false
|
_opponentOnline.value = false
|
||||||
_opponentTyping.value = false
|
_opponentTyping.value = false
|
||||||
|
_typingDisplayName.value = ""
|
||||||
|
_typingDisplayPublicKey.value = ""
|
||||||
|
typingSenderPublicKey = null
|
||||||
|
typingUsersCount = 1
|
||||||
typingTimeoutJob?.cancel()
|
typingTimeoutJob?.cancel()
|
||||||
|
typingNameResolveJob?.cancel()
|
||||||
currentOffset = 0
|
currentOffset = 0
|
||||||
hasMoreMessages = true
|
hasMoreMessages = true
|
||||||
isLoadingMessages = false
|
isLoadingMessages = false
|
||||||
@@ -1370,6 +1393,52 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
return "${trimmed.take(6)}...${trimmed.takeLast(4)}"
|
return "${trimmed.take(6)}...${trimmed.takeLast(4)}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun buildGroupTypingDisplayName(baseName: String, participantsCount: Int): String {
|
||||||
|
val normalizedName = baseName.trim()
|
||||||
|
if (normalizedName.isBlank()) return ""
|
||||||
|
val extraParticipants = (participantsCount - 1).coerceAtLeast(0)
|
||||||
|
return if (extraParticipants > 0) {
|
||||||
|
"$normalizedName and $extraParticipants"
|
||||||
|
} else {
|
||||||
|
normalizedName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveKnownGroupSenderName(publicKey: String): String {
|
||||||
|
val normalizedPublicKey = publicKey.trim()
|
||||||
|
if (normalizedPublicKey.isBlank()) return ""
|
||||||
|
|
||||||
|
groupSenderNameCache[normalizedPublicKey]?.let { cached ->
|
||||||
|
if (isUsableSenderName(cached, normalizedPublicKey)) return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
val nameFromMessages =
|
||||||
|
_messages.value.asSequence().mapNotNull { message ->
|
||||||
|
if (message.senderPublicKey.trim()
|
||||||
|
.equals(normalizedPublicKey, ignoreCase = true)
|
||||||
|
) {
|
||||||
|
message.senderName.trim()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.firstOrNull { isUsableSenderName(it, normalizedPublicKey) }
|
||||||
|
|
||||||
|
if (!nameFromMessages.isNullOrBlank()) {
|
||||||
|
groupSenderNameCache[normalizedPublicKey] = nameFromMessages
|
||||||
|
return nameFromMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
val cachedInfo = ProtocolManager.getCachedUserInfo(normalizedPublicKey)
|
||||||
|
val protocolName =
|
||||||
|
cachedInfo?.title?.trim().orEmpty().ifBlank { cachedInfo?.username?.trim().orEmpty() }
|
||||||
|
if (isUsableSenderName(protocolName, normalizedPublicKey)) {
|
||||||
|
groupSenderNameCache[normalizedPublicKey] = protocolName
|
||||||
|
return protocolName
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun resolveGroupSenderName(publicKey: String): String {
|
private suspend fun resolveGroupSenderName(publicKey: String): String {
|
||||||
val normalizedPublicKey = publicKey.trim()
|
val normalizedPublicKey = publicKey.trim()
|
||||||
if (normalizedPublicKey.isBlank()) return ""
|
if (normalizedPublicKey.isBlank()) return ""
|
||||||
@@ -1436,6 +1505,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
groupSenderNameCache[normalizedPublicKey] = name
|
groupSenderNameCache[normalizedPublicKey] = name
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
if (_opponentTyping.value &&
|
||||||
|
typingSenderPublicKey.equals(normalizedPublicKey, ignoreCase = true)
|
||||||
|
) {
|
||||||
|
_typingDisplayName.value =
|
||||||
|
buildGroupTypingDisplayName(name, typingUsersCount)
|
||||||
|
}
|
||||||
_messages.update { current ->
|
_messages.update { current ->
|
||||||
current.map { message ->
|
current.map { message ->
|
||||||
if (message.senderPublicKey.trim() == normalizedPublicKey &&
|
if (message.senderPublicKey.trim() == normalizedPublicKey &&
|
||||||
@@ -1500,7 +1575,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
private fun isLikelyCallAttachmentPreview(preview: String): Boolean {
|
private fun isLikelyCallAttachmentPreview(preview: String): Boolean {
|
||||||
val normalized = preview.trim()
|
val normalized = preview.trim()
|
||||||
if (normalized.isEmpty()) return true
|
// Empty preview must NOT be treated as call, otherwise we get false-positive
|
||||||
|
// "empty call" attachments in regular/group messages.
|
||||||
|
if (normalized.isEmpty()) return false
|
||||||
|
|
||||||
val tail = normalized.substringAfterLast("::", normalized).trim()
|
val tail = normalized.substringAfterLast("::", normalized).trim()
|
||||||
if (tail.toIntOrNull() != null) return true
|
if (tail.toIntOrNull() != null) return true
|
||||||
@@ -2286,9 +2363,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
/** Обновить текст ввода */
|
/** Обновить текст ввода */
|
||||||
fun updateInputText(text: String) {
|
fun updateInputText(text: String) {
|
||||||
|
if (_inputText.value == text) return
|
||||||
_inputText.value = text
|
_inputText.value = text
|
||||||
// 📝 Сохраняем черновик при каждом изменении текста (draft, как в Telegram)
|
|
||||||
opponentKey?.let { key ->
|
val key = opponentKey ?: return
|
||||||
|
draftSaveJob?.cancel()
|
||||||
|
draftSaveJob =
|
||||||
|
viewModelScope.launch(Dispatchers.Default) {
|
||||||
|
delay(DRAFT_SAVE_DEBOUNCE_MS)
|
||||||
|
// Если за время debounce текст изменился — сохраняем только свежую версию.
|
||||||
|
if (_inputText.value != text) return@launch
|
||||||
com.rosetta.messenger.data.DraftManager.saveDraft(key, text)
|
com.rosetta.messenger.data.DraftManager.saveDraft(key, text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5082,7 +5166,51 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showTypingIndicator() {
|
private fun showTypingIndicator(senderPublicKey: String? = null, typingUsersCount: Int = 1) {
|
||||||
|
val currentDialog = opponentKey?.trim().orEmpty()
|
||||||
|
val normalizedSender = senderPublicKey?.trim().orEmpty()
|
||||||
|
val isGroupTyping = isGroupDialogKey(currentDialog) && normalizedSender.isNotBlank()
|
||||||
|
val normalizedTypingUsersCount = typingUsersCount.coerceAtLeast(1)
|
||||||
|
|
||||||
|
if (isGroupTyping) {
|
||||||
|
typingSenderPublicKey = normalizedSender
|
||||||
|
_typingDisplayPublicKey.value = normalizedSender
|
||||||
|
this.typingUsersCount = normalizedTypingUsersCount
|
||||||
|
val knownName = resolveKnownGroupSenderName(normalizedSender)
|
||||||
|
val initialName =
|
||||||
|
if (isUsableSenderName(knownName, normalizedSender)) {
|
||||||
|
knownName
|
||||||
|
} else {
|
||||||
|
shortPublicKey(normalizedSender)
|
||||||
|
}
|
||||||
|
_typingDisplayName.value =
|
||||||
|
buildGroupTypingDisplayName(initialName, normalizedTypingUsersCount)
|
||||||
|
|
||||||
|
requestGroupSenderNameIfNeeded(normalizedSender)
|
||||||
|
|
||||||
|
typingNameResolveJob?.cancel()
|
||||||
|
typingNameResolveJob =
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val resolvedName = resolveGroupSenderName(normalizedSender)
|
||||||
|
withContext(Dispatchers.Main.immediate) {
|
||||||
|
if (_opponentTyping.value &&
|
||||||
|
typingSenderPublicKey.equals(normalizedSender, ignoreCase = true)
|
||||||
|
) {
|
||||||
|
_typingDisplayName.value =
|
||||||
|
buildGroupTypingDisplayName(
|
||||||
|
resolvedName,
|
||||||
|
this@ChatViewModel.typingUsersCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
typingSenderPublicKey = null
|
||||||
|
_typingDisplayName.value = ""
|
||||||
|
_typingDisplayPublicKey.value = ""
|
||||||
|
this.typingUsersCount = 1
|
||||||
|
}
|
||||||
|
|
||||||
_opponentTyping.value = true
|
_opponentTyping.value = true
|
||||||
// Отменяем предыдущий таймер, чтобы избежать race condition
|
// Отменяем предыдущий таймер, чтобы избежать race condition
|
||||||
typingTimeoutJob?.cancel()
|
typingTimeoutJob?.cancel()
|
||||||
@@ -5090,6 +5218,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
viewModelScope.launch(Dispatchers.Default) {
|
viewModelScope.launch(Dispatchers.Default) {
|
||||||
kotlinx.coroutines.delay(3000)
|
kotlinx.coroutines.delay(3000)
|
||||||
_opponentTyping.value = false
|
_opponentTyping.value = false
|
||||||
|
_typingDisplayName.value = ""
|
||||||
|
_typingDisplayPublicKey.value = ""
|
||||||
|
typingSenderPublicKey = null
|
||||||
|
this@ChatViewModel.typingUsersCount = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5333,6 +5465,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
isCleared = true
|
isCleared = true
|
||||||
|
typingTimeoutJob?.cancel()
|
||||||
|
typingNameResolveJob?.cancel()
|
||||||
|
draftSaveJob?.cancel()
|
||||||
pinnedCollectionJob?.cancel()
|
pinnedCollectionJob?.cancel()
|
||||||
|
|
||||||
// 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов
|
// 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов
|
||||||
|
|||||||
@@ -222,6 +222,21 @@ private fun isTypingForDialog(dialogKey: String, typingDialogs: Set<String>): Bo
|
|||||||
return typingDialogs.any { it.equals(dialogKey.trim(), ignoreCase = true) }
|
return typingDialogs.any { it.equals(dialogKey.trim(), ignoreCase = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun shortPublicKey(value: String): String {
|
||||||
|
val trimmed = value.trim()
|
||||||
|
if (trimmed.length <= 12) return trimmed
|
||||||
|
return "${trimmed.take(6)}...${trimmed.takeLast(4)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveTypingDisplayName(publicKey: String): String {
|
||||||
|
val normalized = publicKey.trim()
|
||||||
|
if (normalized.isBlank()) return ""
|
||||||
|
val cached = ProtocolManager.getCachedUserInfo(normalized)
|
||||||
|
val resolvedName =
|
||||||
|
cached?.title?.trim().orEmpty().ifBlank { cached?.username?.trim().orEmpty() }
|
||||||
|
return if (resolvedName.isNotBlank()) resolvedName else shortPublicKey(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
private val TELEGRAM_DIALOG_AVATAR_START = 10.dp
|
private val TELEGRAM_DIALOG_AVATAR_START = 10.dp
|
||||||
private val TELEGRAM_DIALOG_TEXT_START = 72.dp
|
private val TELEGRAM_DIALOG_TEXT_START = 72.dp
|
||||||
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
|
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
|
||||||
@@ -435,6 +450,7 @@ fun ChatsListScreen(
|
|||||||
|
|
||||||
// <20>🔥 Пользователи, которые сейчас печатают
|
// <20>🔥 Пользователи, которые сейчас печатают
|
||||||
val typingUsers by ProtocolManager.typingUsers.collectAsState()
|
val typingUsers by ProtocolManager.typingUsers.collectAsState()
|
||||||
|
val typingUsersByDialogSnapshot by ProtocolManager.typingUsersByDialogSnapshot.collectAsState()
|
||||||
|
|
||||||
// Load dialogs when account is available
|
// Load dialogs when account is available
|
||||||
LaunchedEffect(accountPublicKey, accountPrivateKey) {
|
LaunchedEffect(accountPublicKey, accountPrivateKey) {
|
||||||
@@ -807,23 +823,9 @@ fun ChatsListScreen(
|
|||||||
bottom = 12.dp
|
bottom = 12.dp
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
val isRosettaOfficial =
|
val isOfficialByKey =
|
||||||
accountName.equals(
|
MessageRepository.isSystemAccount(
|
||||||
"Rosetta",
|
accountPublicKey
|
||||||
ignoreCase = true
|
|
||||||
) ||
|
|
||||||
accountUsername.equals(
|
|
||||||
"rosetta",
|
|
||||||
ignoreCase = true
|
|
||||||
)
|
|
||||||
val isFreddyOfficial =
|
|
||||||
accountName.equals(
|
|
||||||
"freddy",
|
|
||||||
ignoreCase = true
|
|
||||||
) ||
|
|
||||||
accountUsername.equals(
|
|
||||||
"freddy",
|
|
||||||
ignoreCase = true
|
|
||||||
)
|
)
|
||||||
// Avatar row with theme toggle
|
// Avatar row with theme toggle
|
||||||
Row(
|
Row(
|
||||||
@@ -934,7 +936,7 @@ fun ChatsListScreen(
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = Color.White
|
color = Color.White
|
||||||
)
|
)
|
||||||
if (accountVerified > 0 || isRosettaOfficial || isFreddyOfficial) {
|
if (accountVerified > 0 || isOfficialByKey) {
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
VerifiedBadge(
|
VerifiedBadge(
|
||||||
verified = if (accountVerified > 0) accountVerified else 1,
|
verified = if (accountVerified > 0) accountVerified else 1,
|
||||||
@@ -2481,6 +2483,75 @@ fun ChatsListScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val typingGroupUsers by
|
||||||
|
remember(
|
||||||
|
dialog.opponentKey,
|
||||||
|
typingUsersByDialogSnapshot
|
||||||
|
) {
|
||||||
|
derivedStateOf {
|
||||||
|
if (!isGroupDialog) {
|
||||||
|
emptySet()
|
||||||
|
} else {
|
||||||
|
val normalizedDialogKey =
|
||||||
|
normalizeGroupDialogKey(
|
||||||
|
dialog.opponentKey
|
||||||
|
)
|
||||||
|
typingUsersByDialogSnapshot[normalizedDialogKey]
|
||||||
|
?: emptySet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val typingSenderPublicKey by
|
||||||
|
remember(
|
||||||
|
isGroupDialog,
|
||||||
|
typingGroupUsers
|
||||||
|
) {
|
||||||
|
derivedStateOf {
|
||||||
|
if (!isGroupDialog) {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
typingGroupUsers.firstOrNull().orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val typingDisplayName by
|
||||||
|
remember(
|
||||||
|
isGroupDialog,
|
||||||
|
typingSenderPublicKey,
|
||||||
|
typingGroupUsers
|
||||||
|
.size
|
||||||
|
) {
|
||||||
|
derivedStateOf {
|
||||||
|
if (!isGroupDialog ||
|
||||||
|
typingSenderPublicKey.isBlank()
|
||||||
|
) {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
val baseName =
|
||||||
|
resolveTypingDisplayName(
|
||||||
|
typingSenderPublicKey
|
||||||
|
)
|
||||||
|
if (baseName.isBlank()) {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
val extraCount =
|
||||||
|
(typingGroupUsers
|
||||||
|
.size -
|
||||||
|
1)
|
||||||
|
.coerceAtLeast(
|
||||||
|
0
|
||||||
|
)
|
||||||
|
if (extraCount >
|
||||||
|
0
|
||||||
|
) {
|
||||||
|
"$baseName and $extraCount"
|
||||||
|
} else {
|
||||||
|
baseName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
val isSelectedDialog =
|
val isSelectedDialog =
|
||||||
selectedChatKeys
|
selectedChatKeys
|
||||||
.contains(
|
.contains(
|
||||||
@@ -2518,6 +2589,10 @@ fun ChatsListScreen(
|
|||||||
isDarkTheme,
|
isDarkTheme,
|
||||||
isTyping =
|
isTyping =
|
||||||
isTyping,
|
isTyping,
|
||||||
|
typingDisplayName =
|
||||||
|
typingDisplayName,
|
||||||
|
typingSenderPublicKey =
|
||||||
|
typingSenderPublicKey,
|
||||||
isBlocked =
|
isBlocked =
|
||||||
isBlocked,
|
isBlocked,
|
||||||
isSavedMessages =
|
isSavedMessages =
|
||||||
@@ -3627,6 +3702,8 @@ fun SwipeableDialogItem(
|
|||||||
dialog: DialogUiModel,
|
dialog: DialogUiModel,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
isTyping: Boolean = false,
|
isTyping: Boolean = false,
|
||||||
|
typingDisplayName: String = "",
|
||||||
|
typingSenderPublicKey: String = "",
|
||||||
isBlocked: Boolean = false,
|
isBlocked: Boolean = false,
|
||||||
isGroupChat: Boolean = false,
|
isGroupChat: Boolean = false,
|
||||||
isSavedMessages: Boolean = false,
|
isSavedMessages: Boolean = false,
|
||||||
@@ -4034,6 +4111,8 @@ fun SwipeableDialogItem(
|
|||||||
dialog = dialog,
|
dialog = dialog,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
isTyping = isTyping,
|
isTyping = isTyping,
|
||||||
|
typingDisplayName = typingDisplayName,
|
||||||
|
typingSenderPublicKey = typingSenderPublicKey,
|
||||||
isPinned = isPinned,
|
isPinned = isPinned,
|
||||||
isBlocked = isBlocked,
|
isBlocked = isBlocked,
|
||||||
isMuted = isMuted,
|
isMuted = isMuted,
|
||||||
@@ -4051,6 +4130,8 @@ fun DialogItemContent(
|
|||||||
dialog: DialogUiModel,
|
dialog: DialogUiModel,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
isTyping: Boolean = false,
|
isTyping: Boolean = false,
|
||||||
|
typingDisplayName: String = "",
|
||||||
|
typingSenderPublicKey: String = "",
|
||||||
isPinned: Boolean = false,
|
isPinned: Boolean = false,
|
||||||
isBlocked: Boolean = false,
|
isBlocked: Boolean = false,
|
||||||
isMuted: Boolean = false,
|
isMuted: Boolean = false,
|
||||||
@@ -4245,13 +4326,8 @@ fun DialogItemContent(
|
|||||||
modifier = Modifier.size(15.dp)
|
modifier = Modifier.size(15.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val isRosettaOfficial = dialog.opponentTitle.equals("Rosetta", ignoreCase = true) ||
|
val isOfficialByKey = MessageRepository.isSystemAccount(dialog.opponentKey)
|
||||||
dialog.opponentUsername.equals("rosetta", ignoreCase = true) ||
|
if (dialog.verified > 0 || isOfficialByKey) {
|
||||||
MessageRepository.isSystemAccount(dialog.opponentKey)
|
|
||||||
val isFreddyVerified = dialog.opponentUsername.equals("freddy", ignoreCase = true) ||
|
|
||||||
dialog.opponentTitle.equals("freddy", ignoreCase = true) ||
|
|
||||||
displayName.equals("freddy", ignoreCase = true)
|
|
||||||
if (dialog.verified > 0 || isRosettaOfficial || isFreddyVerified) {
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
VerifiedBadge(
|
VerifiedBadge(
|
||||||
verified = if (dialog.verified > 0) dialog.verified else 1,
|
verified = if (dialog.verified > 0) dialog.verified else 1,
|
||||||
@@ -4458,7 +4534,11 @@ fun DialogItemContent(
|
|||||||
label = "chatSubtitle"
|
label = "chatSubtitle"
|
||||||
) { showTyping ->
|
) { showTyping ->
|
||||||
if (showTyping) {
|
if (showTyping) {
|
||||||
TypingIndicatorSmall()
|
TypingIndicatorSmall(
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
typingDisplayName = typingDisplayName,
|
||||||
|
typingSenderPublicKey = typingSenderPublicKey
|
||||||
|
)
|
||||||
} else if (!dialog.draftText.isNullOrEmpty()) {
|
} else if (!dialog.draftText.isNullOrEmpty()) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
@@ -4492,7 +4572,8 @@ fun DialogItemContent(
|
|||||||
dialog.lastMessageAttachmentType ==
|
dialog.lastMessageAttachmentType ==
|
||||||
"Call" -> "Call"
|
"Call" -> "Call"
|
||||||
dialog.lastMessageAttachmentType ==
|
dialog.lastMessageAttachmentType ==
|
||||||
"Forwarded" -> "Forwarded message"
|
"Forwarded" ->
|
||||||
|
"Forwarded message"
|
||||||
dialog.lastMessage.isEmpty() ->
|
dialog.lastMessage.isEmpty() ->
|
||||||
"No messages"
|
"No messages"
|
||||||
else -> dialog.lastMessage
|
else -> dialog.lastMessage
|
||||||
@@ -4712,8 +4793,21 @@ fun DialogItemContent(
|
|||||||
* with sequential wave animation (scale + vertical offset + opacity).
|
* with sequential wave animation (scale + vertical offset + opacity).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun TypingIndicatorSmall() {
|
fun TypingIndicatorSmall(
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
typingDisplayName: String = "",
|
||||||
|
typingSenderPublicKey: String = ""
|
||||||
|
) {
|
||||||
val typingColor = PrimaryBlue
|
val typingColor = PrimaryBlue
|
||||||
|
val senderTypingColor =
|
||||||
|
remember(typingSenderPublicKey, isDarkTheme) {
|
||||||
|
if (typingSenderPublicKey.isBlank()) {
|
||||||
|
typingColor
|
||||||
|
} else {
|
||||||
|
getAvatarColor(typingSenderPublicKey, isDarkTheme).textColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val normalizedDisplayName = remember(typingDisplayName) { typingDisplayName.trim() }
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "typing")
|
val infiniteTransition = rememberInfiniteTransition(label = "typing")
|
||||||
|
|
||||||
// Each dot animates 0→1→0 in a 1200 ms cycle, staggered by 150 ms
|
// Each dot animates 0→1→0 in a 1200 ms cycle, staggered by 150 ms
|
||||||
@@ -4736,13 +4830,40 @@ fun TypingIndicatorSmall() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(
|
||||||
Text(
|
modifier = Modifier.heightIn(min = 18.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (normalizedDisplayName.isBlank()) {
|
||||||
|
AppleEmojiText(
|
||||||
text = "typing",
|
text = "typing",
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = typingColor,
|
color = typingColor,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.Medium,
|
||||||
|
enableLinks = false,
|
||||||
|
minHeightMultiplier = 1f
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
AppleEmojiText(
|
||||||
|
text = "$normalizedDisplayName ",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = senderTypingColor,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = android.text.TextUtils.TruncateAt.END,
|
||||||
|
modifier = Modifier.widthIn(max = 180.dp),
|
||||||
|
enableLinks = false,
|
||||||
|
minHeightMultiplier = 1f
|
||||||
|
)
|
||||||
|
AppleEmojiText(
|
||||||
|
text = "typing",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = typingColor,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
enableLinks = false,
|
||||||
|
minHeightMultiplier = 1f
|
||||||
|
)
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.width(2.dp))
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
|
|
||||||
// Fixed-size canvas — big enough for bounce, never changes layout
|
// Fixed-size canvas — big enough for bounce, never changes layout
|
||||||
@@ -4750,7 +4871,7 @@ fun TypingIndicatorSmall() {
|
|||||||
val dotRadius = 1.5.dp.toPx()
|
val dotRadius = 1.5.dp.toPx()
|
||||||
val dotSpacing = 2.5.dp.toPx()
|
val dotSpacing = 2.5.dp.toPx()
|
||||||
val maxBounce = 2.dp.toPx()
|
val maxBounce = 2.dp.toPx()
|
||||||
val centerY = size.height / 2f + 1.dp.toPx()
|
val centerY = size.height / 2f
|
||||||
for (i in 0..2) {
|
for (i in 0..2) {
|
||||||
val p = dotProgresses[i].value
|
val p = dotProgresses[i].value
|
||||||
val bounce = kotlin.math.sin(p * Math.PI).toFloat()
|
val bounce = kotlin.math.sin(p * Math.PI).toFloat()
|
||||||
|
|||||||
@@ -553,11 +553,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
val inferredCall = isLikelyCallAttachmentJson(lastMessageAttachments)
|
val inferredCall = isLikelyCallAttachmentJson(lastMessageAttachments)
|
||||||
return when (attachmentType) {
|
return when (attachmentType) {
|
||||||
0 -> if (inferredCall) "Call" else "Photo" // AttachmentType.IMAGE = 0
|
0 -> if (inferredCall) "Call" else "Photo" // AttachmentType.IMAGE = 0
|
||||||
1 -> {
|
1 -> if (decryptedLastMessage.isNotEmpty()) null else "Forwarded" // AttachmentType.MESSAGES = 1 (Reply/Forward)
|
||||||
// AttachmentType.MESSAGES = 1 (Reply/Forward).
|
|
||||||
// Если текст пустой — показываем "Forwarded" как в desktop.
|
|
||||||
if (decryptedLastMessage.isNotEmpty()) null else "Forwarded"
|
|
||||||
}
|
|
||||||
2 -> "File" // AttachmentType.FILE = 2
|
2 -> "File" // AttachmentType.FILE = 2
|
||||||
3 -> "Avatar" // AttachmentType.AVATAR = 3
|
3 -> "Avatar" // AttachmentType.AVATAR = 3
|
||||||
4 -> "Call" // AttachmentType.CALL = 4
|
4 -> "Call" // AttachmentType.CALL = 4
|
||||||
@@ -589,7 +585,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
if (typeValue == 4) return true
|
if (typeValue == 4) return true
|
||||||
|
|
||||||
val preview = first.optString("preview", "").trim()
|
val preview = first.optString("preview", "").trim()
|
||||||
if (preview.isEmpty()) return true
|
if (preview.isEmpty()) return false
|
||||||
val tail = preview.substringAfterLast("::", preview).trim()
|
val tail = preview.substringAfterLast("::", preview).trim()
|
||||||
if (tail.toIntOrNull() != null) return true
|
if (tail.toIntOrNull() != null) return true
|
||||||
|
|
||||||
|
|||||||
@@ -178,12 +178,8 @@ fun CallOverlay(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f, fill = false)
|
modifier = Modifier.weight(1f, fill = false)
|
||||||
)
|
)
|
||||||
val isRosettaOfficial = state.peerTitle.equals("Rosetta", ignoreCase = true) ||
|
val isOfficialByKey = MessageRepository.isSystemAccount(state.peerPublicKey)
|
||||||
state.peerUsername.equals("rosetta", ignoreCase = true) ||
|
if (isOfficialByKey) {
|
||||||
MessageRepository.isSystemAccount(state.peerPublicKey)
|
|
||||||
val isFreddyVerified = state.peerUsername.equals("freddy", ignoreCase = true) ||
|
|
||||||
state.peerTitle.equals("freddy", ignoreCase = true)
|
|
||||||
if (isRosettaOfficial || isFreddyVerified) {
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
VerifiedBadge(
|
VerifiedBadge(
|
||||||
verified = 1,
|
verified = 1,
|
||||||
|
|||||||
@@ -238,12 +238,8 @@ private fun CallHistoryRowItem(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f, fill = false)
|
modifier = Modifier.weight(1f, fill = false)
|
||||||
)
|
)
|
||||||
val isRosettaOfficial = item.peerTitle.equals("Rosetta", ignoreCase = true) ||
|
val isOfficialByKey = MessageRepository.isSystemAccount(item.peerKey)
|
||||||
item.peerUsername.equals("rosetta", ignoreCase = true) ||
|
if (item.peerVerified > 0 || isOfficialByKey) {
|
||||||
MessageRepository.isSystemAccount(item.peerKey)
|
|
||||||
val isFreddyVerified = item.peerUsername.equals("freddy", ignoreCase = true) ||
|
|
||||||
item.peerTitle.equals("freddy", ignoreCase = true)
|
|
||||||
if (item.peerVerified > 0 || isRosettaOfficial || isFreddyVerified) {
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
VerifiedBadge(
|
VerifiedBadge(
|
||||||
verified = if (item.peerVerified > 0) item.peerVerified else 1,
|
verified = if (item.peerVerified > 0) item.peerVerified else 1,
|
||||||
|
|||||||
@@ -228,9 +228,22 @@ fun DateHeader(
|
|||||||
* with sequential wave animation (scale + vertical offset + opacity).
|
* with sequential wave animation (scale + vertical offset + opacity).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun TypingIndicator(isDarkTheme: Boolean) {
|
fun TypingIndicator(
|
||||||
val typingColor = if (isDarkTheme) Color(0xFF54A9EB) else Color.White.copy(alpha = 0.7f)
|
isDarkTheme: Boolean,
|
||||||
|
typingDisplayName: String = "",
|
||||||
|
typingSenderPublicKey: String = ""
|
||||||
|
) {
|
||||||
|
val typingColor = Color(0xFF54A9EB)
|
||||||
|
val senderTypingColor =
|
||||||
|
remember(typingSenderPublicKey, isDarkTheme) {
|
||||||
|
if (typingSenderPublicKey.isBlank()) {
|
||||||
|
typingColor
|
||||||
|
} else {
|
||||||
|
groupSenderLabelColor(typingSenderPublicKey, isDarkTheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "typing")
|
val infiniteTransition = rememberInfiniteTransition(label = "typing")
|
||||||
|
val normalizedDisplayName = remember(typingDisplayName) { typingDisplayName.trim() }
|
||||||
|
|
||||||
// Each dot animates through a 0→1→0 cycle, staggered by 150 ms
|
// Each dot animates through a 0→1→0 cycle, staggered by 150 ms
|
||||||
val dotProgresses = List(3) { index ->
|
val dotProgresses = List(3) { index ->
|
||||||
@@ -253,7 +266,30 @@ fun TypingIndicator(isDarkTheme: Boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(text = "typing", fontSize = 13.sp, color = typingColor)
|
if (normalizedDisplayName.isBlank()) {
|
||||||
|
Text(
|
||||||
|
text = "typing",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = typingColor,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "$normalizedDisplayName ",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = senderTypingColor,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "typing",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = typingColor,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.width(2.dp))
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
|
|
||||||
// Fixed-size canvas — big enough for bounce, never changes layout
|
// Fixed-size canvas — big enough for bounce, never changes layout
|
||||||
@@ -333,12 +369,6 @@ fun MessageBubble(
|
|||||||
val swipeProgress = (kotlin.math.abs(animatedOffset) / swipeThreshold).coerceIn(0f, 1f)
|
val swipeProgress = (kotlin.math.abs(animatedOffset) / swipeThreshold).coerceIn(0f, 1f)
|
||||||
|
|
||||||
// Selection animations
|
// Selection animations
|
||||||
val selectionScale by
|
|
||||||
animateFloatAsState(
|
|
||||||
targetValue = if (isSelected) 0.95f else 1f,
|
|
||||||
animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f),
|
|
||||||
label = "selectionScale"
|
|
||||||
)
|
|
||||||
val selectionAlpha by
|
val selectionAlpha by
|
||||||
animateFloatAsState(
|
animateFloatAsState(
|
||||||
targetValue = if (isSelected) 0.85f else 1f,
|
targetValue = if (isSelected) 0.85f else 1f,
|
||||||
@@ -785,8 +815,6 @@ fun MessageBubble(
|
|||||||
.then(bubbleWidthModifier)
|
.then(bubbleWidthModifier)
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
this.alpha = selectionAlpha
|
this.alpha = selectionAlpha
|
||||||
this.scaleX = selectionScale
|
|
||||||
this.scaleY = selectionScale
|
|
||||||
}
|
}
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
indication = null,
|
indication = null,
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ data class MentionCandidate(
|
|||||||
val publicKey: String
|
val publicKey: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private const val LARGE_INPUT_ANALYSIS_THRESHOLD = 2000
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageInputBar(
|
fun MessageInputBar(
|
||||||
@@ -232,7 +234,11 @@ fun MessageInputBar(
|
|||||||
}
|
}
|
||||||
val mentionPattern = remember { Regex("@([\\w\\d_]*)$") }
|
val mentionPattern = remember { Regex("@([\\w\\d_]*)$") }
|
||||||
val mentionedPattern = remember { Regex("@([\\w\\d_]{1,})") }
|
val mentionedPattern = remember { Regex("@([\\w\\d_]{1,})") }
|
||||||
val mentionMatch = remember(value, isGroupChat) { if (isGroupChat) mentionPattern.find(value) else null }
|
val skipHeavyInputAnalysis = remember(value) { value.length > LARGE_INPUT_ANALYSIS_THRESHOLD }
|
||||||
|
val mentionMatch =
|
||||||
|
remember(value, isGroupChat, skipHeavyInputAnalysis) {
|
||||||
|
if (isGroupChat && !skipHeavyInputAnalysis) mentionPattern.find(value) else null
|
||||||
|
}
|
||||||
|
|
||||||
val mentionQuery =
|
val mentionQuery =
|
||||||
remember(mentionMatch) {
|
remember(mentionMatch) {
|
||||||
@@ -246,9 +252,10 @@ fun MessageInputBar(
|
|||||||
value,
|
value,
|
||||||
mentionCandidates,
|
mentionCandidates,
|
||||||
mentionQuery,
|
mentionQuery,
|
||||||
shouldShowMentionSuggestions
|
shouldShowMentionSuggestions,
|
||||||
|
skipHeavyInputAnalysis
|
||||||
) {
|
) {
|
||||||
if (!shouldShowMentionSuggestions) {
|
if (!shouldShowMentionSuggestions || skipHeavyInputAnalysis) {
|
||||||
emptyList()
|
emptyList()
|
||||||
} else {
|
} else {
|
||||||
val mentionedInText =
|
val mentionedInText =
|
||||||
@@ -274,9 +281,13 @@ fun MessageInputBar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val emojiWordMatch =
|
val emojiWordMatch =
|
||||||
remember(value, selectionStart, selectionEnd) {
|
remember(value, selectionStart, selectionEnd, skipHeavyInputAnalysis) {
|
||||||
|
if (skipHeavyInputAnalysis) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
EmojiKeywordSuggester.findWordAtCursor(value, selectionStart, selectionEnd)
|
EmojiKeywordSuggester.findWordAtCursor(value, selectionStart, selectionEnd)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val emojiSuggestions =
|
val emojiSuggestions =
|
||||||
remember(emojiWordMatch) {
|
remember(emojiWordMatch) {
|
||||||
|
|||||||
@@ -592,10 +592,10 @@ fun AppleEmojiText(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
setTextWithEmojisIfNeeded(text)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update = { view ->
|
update = { view ->
|
||||||
view.setTextWithEmojis(text)
|
|
||||||
view.setTextColor(color.toArgb())
|
view.setTextColor(color.toArgb())
|
||||||
view.setTypeface(view.typeface, typefaceStyle)
|
view.setTypeface(view.typeface, typefaceStyle)
|
||||||
// 🔥 Обновляем maxLines и ellipsize
|
// 🔥 Обновляем maxLines и ellipsize
|
||||||
@@ -625,6 +625,7 @@ fun AppleEmojiText(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
view.setTextWithEmojisIfNeeded(text)
|
||||||
},
|
},
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
@@ -644,6 +645,7 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
) : android.widget.TextView(context, attrs, defStyleAttr) {
|
) : android.widget.TextView(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val LARGE_TEXT_RENDER_THRESHOLD = 4000
|
||||||
private val EMOJI_PATTERN = AppleEmojiEditTextView.EMOJI_PATTERN
|
private val EMOJI_PATTERN = AppleEmojiEditTextView.EMOJI_PATTERN
|
||||||
// 🔥 Паттерн для :emoji_XXXX: формата (из React Native)
|
// 🔥 Паттерн для :emoji_XXXX: формата (из React Native)
|
||||||
private val EMOJI_CODE_PATTERN = Pattern.compile(":emoji_([a-fA-F0-9_-]+):")
|
private val EMOJI_CODE_PATTERN = Pattern.compile(":emoji_([a-fA-F0-9_-]+):")
|
||||||
@@ -668,6 +670,10 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
private var mentionsEnabled: Boolean = false
|
private var mentionsEnabled: Boolean = false
|
||||||
private var mentionClickCallback: ((String) -> Unit)? = null
|
private var mentionClickCallback: ((String) -> Unit)? = null
|
||||||
private var clickableSpanPressStartCallback: (() -> Unit)? = null
|
private var clickableSpanPressStartCallback: (() -> Unit)? = null
|
||||||
|
private var lastRenderedText: String? = null
|
||||||
|
private var lastRenderedLinksEnabled: Boolean = false
|
||||||
|
private var lastRenderedMentionsEnabled: Boolean = false
|
||||||
|
private var lastRenderedMentionClickable: Boolean = false
|
||||||
|
|
||||||
// 🔥 Long press callback для selection в MessageBubble
|
// 🔥 Long press callback для selection в MessageBubble
|
||||||
var onLongClickCallback: (() -> Unit)? = null
|
var onLongClickCallback: (() -> Unit)? = null
|
||||||
@@ -799,6 +805,17 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setTextWithEmojis(text: String) {
|
fun setTextWithEmojis(text: String) {
|
||||||
|
val isLargeText = text.length > LARGE_TEXT_RENDER_THRESHOLD
|
||||||
|
val processMentions = mentionsEnabled && !isLargeText
|
||||||
|
val processLinks = linksEnabled && !isLargeText
|
||||||
|
val processEmoji = !isLargeText || containsEmojiHints(text)
|
||||||
|
|
||||||
|
// Для длинных логов (без emoji/links/mentions) не запускаем дорогой regex/span пайплайн.
|
||||||
|
if (!processEmoji && !processMentions && !processLinks) {
|
||||||
|
setText(text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 🔥 Сначала заменяем :emoji_XXXX: на PNG изображения
|
// 🔥 Сначала заменяем :emoji_XXXX: на PNG изображения
|
||||||
val spannable = SpannableStringBuilder(text)
|
val spannable = SpannableStringBuilder(text)
|
||||||
|
|
||||||
@@ -877,18 +894,34 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mentionsEnabled) {
|
if (processMentions) {
|
||||||
addMentionHighlights(spannable)
|
addMentionHighlights(spannable)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 5. Добавляем кликабельные ссылки после обработки эмодзи
|
// 🔥 5. Добавляем кликабельные ссылки после обработки эмодзи
|
||||||
if (linksEnabled) {
|
if (processLinks) {
|
||||||
addClickableLinks(spannable)
|
addClickableLinks(spannable)
|
||||||
}
|
}
|
||||||
|
|
||||||
setText(spannable)
|
setText(spannable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setTextWithEmojisIfNeeded(text: String) {
|
||||||
|
val mentionClickable = mentionsEnabled && mentionClickCallback != null
|
||||||
|
if (lastRenderedText == text &&
|
||||||
|
lastRenderedLinksEnabled == linksEnabled &&
|
||||||
|
lastRenderedMentionsEnabled == mentionsEnabled &&
|
||||||
|
lastRenderedMentionClickable == mentionClickable
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTextWithEmojis(text)
|
||||||
|
lastRenderedText = text
|
||||||
|
lastRenderedLinksEnabled = linksEnabled
|
||||||
|
lastRenderedMentionsEnabled = mentionsEnabled
|
||||||
|
lastRenderedMentionClickable = mentionClickable
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔥 Добавляет кликабельные ссылки (URL, email, телефоны) в spannable
|
* 🔥 Добавляет кликабельные ссылки (URL, email, телефоны) в spannable
|
||||||
*/
|
*/
|
||||||
@@ -1037,4 +1070,14 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun containsEmojiHints(text: String): Boolean {
|
||||||
|
if (text.indexOf(":emoji_") >= 0) return true
|
||||||
|
for (ch in text) {
|
||||||
|
if (Character.isSurrogate(ch) || ch == '\u200D' || ch == '\uFE0F') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1915,12 +1915,6 @@ private fun CollapsingOtherProfileHeader(
|
|||||||
|
|
||||||
val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress)
|
val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress)
|
||||||
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress)
|
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress)
|
||||||
val isRosettaOfficial =
|
|
||||||
name.equals("Rosetta", ignoreCase = true) ||
|
|
||||||
username.equals("rosetta", ignoreCase = true)
|
|
||||||
val isFreddyOfficial =
|
|
||||||
name.equals("freddy", ignoreCase = true) ||
|
|
||||||
username.equals("freddy", ignoreCase = true)
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой
|
// 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой
|
||||||
@@ -2182,7 +2176,7 @@ private fun CollapsingOtherProfileHeader(
|
|||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
|
||||||
if (verified > 0 || isRosettaOfficial || isFreddyOfficial) {
|
if (verified > 0 || isSystemAccount) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.padding(start = 4.dp)
|
Modifier.padding(start = 4.dp)
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import androidx.palette.graphics.Palette as AndroidPalette
|
|||||||
import com.rosetta.messenger.biometric.BiometricAuthManager
|
import com.rosetta.messenger.biometric.BiometricAuthManager
|
||||||
import com.rosetta.messenger.biometric.BiometricAvailability
|
import com.rosetta.messenger.biometric.BiometricAvailability
|
||||||
import com.rosetta.messenger.biometric.BiometricPreferences
|
import com.rosetta.messenger.biometric.BiometricPreferences
|
||||||
|
import com.rosetta.messenger.data.MessageRepository
|
||||||
import com.rosetta.messenger.repository.AvatarRepository
|
import com.rosetta.messenger.repository.AvatarRepository
|
||||||
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
|
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
|
||||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||||
@@ -1146,12 +1147,7 @@ private fun CollapsingProfileHeader(
|
|||||||
val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress)
|
val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress)
|
||||||
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress)
|
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress)
|
||||||
val rosettaBadgeBlue = Color(0xFF1DA1F2)
|
val rosettaBadgeBlue = Color(0xFF1DA1F2)
|
||||||
val isRosettaOfficial =
|
val isOfficialByKey = MessageRepository.isSystemAccount(publicKey)
|
||||||
name.equals("Rosetta", ignoreCase = true) ||
|
|
||||||
username.equals("rosetta", ignoreCase = true)
|
|
||||||
val isFreddyOfficial =
|
|
||||||
name.equals("freddy", ignoreCase = true) ||
|
|
||||||
username.equals("freddy", ignoreCase = true)
|
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) {
|
Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) {
|
||||||
// Expansion fraction — computed early so gradient can fade during expansion
|
// Expansion fraction — computed early so gradient can fade during expansion
|
||||||
@@ -1416,7 +1412,7 @@ private fun CollapsingProfileHeader(
|
|||||||
modifier = Modifier.widthIn(max = 220.dp).alignByBaseline(),
|
modifier = Modifier.widthIn(max = 220.dp).alignByBaseline(),
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
if (verified > 0 || isRosettaOfficial || isFreddyOfficial) {
|
if (verified > 0 || isOfficialByKey) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.padding(start = 4.dp)
|
Modifier.padding(start = 4.dp)
|
||||||
|
|||||||
Reference in New Issue
Block a user