Доработки звонков и чатов: typing, UI и стабильность
This commit is contained in:
@@ -81,22 +81,29 @@ class IncomingCallActivity : ComponentActivity() {
|
||||
LaunchedEffect(callState.phase) {
|
||||
callLog("phase changed: ${callState.phase}")
|
||||
if (callState.phase == CallPhase.INCOMING) wasIncoming = true
|
||||
// Закрываем только если звонок реально начался и потом завершился
|
||||
// Закрываем только когда звонок завершился
|
||||
if (callState.phase == CallPhase.IDLE && wasIncoming) {
|
||||
callLog("IDLE after INCOMING → 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)
|
||||
val displayState = if (callState.phase == CallPhase.IDLE) {
|
||||
// Показываем INCOMING в 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...")
|
||||
} else callState
|
||||
} else {
|
||||
callState
|
||||
}
|
||||
|
||||
RosettaAndroidTheme(darkTheme = true) {
|
||||
CallOverlay(
|
||||
@@ -108,16 +115,10 @@ class IncomingCallActivity : ComponentActivity() {
|
||||
if (callState.phase == CallPhase.INCOMING) {
|
||||
val result = CallManager.acceptIncomingCall()
|
||||
callLog("acceptIncomingCall result=$result")
|
||||
if (result == CallActionResult.STARTED) {
|
||||
openMainActivity()
|
||||
finish()
|
||||
}
|
||||
// Остаёмся на IncomingCallActivity — она покажет CONNECTING → ACTIVE
|
||||
} else {
|
||||
callLog("onAccept: phase not INCOMING yet, waiting...")
|
||||
// WebSocket ещё не доставил CALL — открываем MainActivity,
|
||||
// она подождёт и примет звонок
|
||||
openMainActivity()
|
||||
finish()
|
||||
callLog("onAccept: phase=${callState.phase}, trying accept anyway")
|
||||
CallManager.acceptIncomingCall()
|
||||
}
|
||||
},
|
||||
onDecline = {
|
||||
|
||||
@@ -46,19 +46,24 @@ object DraftManager {
|
||||
fun saveDraft(opponentKey: String, text: String) {
|
||||
if (currentAccount.isEmpty()) return
|
||||
|
||||
val trimmed = text.trim()
|
||||
val currentDrafts = _drafts.value.toMutableMap()
|
||||
val hasContent = text.any { !it.isWhitespace() }
|
||||
val existing = _drafts.value[opponentKey]
|
||||
|
||||
if (trimmed.isEmpty()) {
|
||||
if (!hasContent) {
|
||||
if (existing == null) return
|
||||
val currentDrafts = _drafts.value.toMutableMap()
|
||||
// Удаляем черновик если текст пустой
|
||||
currentDrafts.remove(opponentKey)
|
||||
prefs?.edit()?.remove(prefKey(opponentKey))?.apply()
|
||||
_drafts.value = currentDrafts
|
||||
} else {
|
||||
currentDrafts[opponentKey] = trimmed
|
||||
prefs?.edit()?.putString(prefKey(opponentKey), trimmed)?.apply()
|
||||
// Ничего не делаем, если текст не изменился — это частый путь при больших вставках.
|
||||
if (existing == text) return
|
||||
val currentDrafts = _drafts.value.toMutableMap()
|
||||
currentDrafts[opponentKey] = text
|
||||
prefs?.edit()?.putString(prefKey(opponentKey), text)?.apply()
|
||||
_drafts.value = currentDrafts
|
||||
}
|
||||
|
||||
_drafts.value = currentDrafts
|
||||
}
|
||||
|
||||
/** Получить черновик для диалога */
|
||||
|
||||
@@ -48,13 +48,18 @@ class CallForegroundService : Service() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val action = intent?.action ?: ACTION_SYNC
|
||||
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) {
|
||||
ACTION_STOP -> {
|
||||
notifLog("ACTION_STOP → stopSelf")
|
||||
safeStopForeground()
|
||||
return START_NOT_STICKY
|
||||
if (phaseNow == CallPhase.IDLE) {
|
||||
notifLog("ACTION_STOP → stopSelf")
|
||||
safeStopForeground()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
// Может прилететь поздний STOP от прошлой сессии, не глушим живой звонок.
|
||||
notifLog("ACTION_STOP ignored: phase=$phaseNow")
|
||||
}
|
||||
ACTION_END -> {
|
||||
notifLog("ACTION_END → endCall")
|
||||
|
||||
@@ -96,6 +96,7 @@ object CallManager {
|
||||
private const val PROTOCOL_LOG_TAIL_LINES = 180
|
||||
private const val MAX_LOG_PREFIX = 180
|
||||
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 secureRandom = SecureRandom()
|
||||
@@ -125,6 +126,7 @@ object CallManager {
|
||||
private var protocolStateJob: Job? = null
|
||||
private var disconnectResetJob: Job? = null
|
||||
private var incomingRingTimeoutJob: Job? = null
|
||||
private var connectingTimeoutJob: Job? = null
|
||||
|
||||
private var signalWaiter: ((Packet) -> Unit)? = null
|
||||
private var webRtcWaiter: ((Packet) -> Unit)? = null
|
||||
@@ -146,6 +148,7 @@ object CallManager {
|
||||
private var lastLocalOfferFingerprint: String = ""
|
||||
private var e2eeRebindJob: Job? = null
|
||||
|
||||
@Volatile private var resetting = false
|
||||
private var iceServers: List<PeerConnection.IceServer> = emptyList()
|
||||
|
||||
fun initialize(context: Context) {
|
||||
@@ -173,7 +176,26 @@ object CallManager {
|
||||
ProtocolManager.requestIceServers()
|
||||
}
|
||||
ProtocolState.DISCONNECTED -> {
|
||||
resetSession(reason = "Disconnected", notifyPeer = false)
|
||||
// Не сбрасываем звонок при переподключении 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)
|
||||
} else {
|
||||
breadcrumb("DISCONNECTED in IDLE — skip reset (no active session)")
|
||||
}
|
||||
} else {
|
||||
breadcrumb("DISCONNECTED but phase=$phase — keeping call alive")
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
@@ -288,6 +310,7 @@ object CallManager {
|
||||
statusText = "Connecting..."
|
||||
)
|
||||
}
|
||||
armConnectingTimeout("acceptIncomingCall")
|
||||
|
||||
// Отправляем KEY_EXCHANGE — если WebSocket не подключен, ждём и ретраим
|
||||
scope.launch {
|
||||
@@ -489,6 +512,7 @@ object CallManager {
|
||||
statusText = "Connecting..."
|
||||
)
|
||||
}
|
||||
armConnectingTimeout("signal:create_room")
|
||||
ensurePeerConnectionAndOffer()
|
||||
}
|
||||
SignalType.ACTIVE_CALL -> Unit
|
||||
@@ -549,6 +573,7 @@ object CallManager {
|
||||
createRoomSent = true
|
||||
}
|
||||
updateState { it.copy(phase = CallPhase.CONNECTING) }
|
||||
armConnectingTimeout("key_exchange:caller")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -565,6 +590,7 @@ object CallManager {
|
||||
setupE2EE(sharedKey)
|
||||
breadcrumb("KE: CALLEE — E2EE ready, waiting for CREATE_ROOM")
|
||||
updateState { it.copy(keyCast = sharedKey, phase = CallPhase.CONNECTING) }
|
||||
armConnectingTimeout("key_exchange:callee")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -844,6 +870,7 @@ object CallManager {
|
||||
}
|
||||
|
||||
private fun onCallConnected() {
|
||||
disarmConnectingTimeout("connected")
|
||||
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CONNECTED) }
|
||||
val keyFp = sharedKeyBytes?.fingerprintHex(8) ?: "null"
|
||||
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) {
|
||||
updateState {
|
||||
it.copy(
|
||||
@@ -920,6 +972,8 @@ object CallManager {
|
||||
}
|
||||
|
||||
private fun resetSession(reason: String?, notifyPeer: Boolean) {
|
||||
resetting = true
|
||||
disarmConnectingTimeout("resetSession")
|
||||
breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}")
|
||||
breadcrumbState("resetSession")
|
||||
val snapshot = _state.value
|
||||
@@ -932,6 +986,15 @@ object CallManager {
|
||||
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
|
||||
if (wasActive) {
|
||||
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.END_CALL) }
|
||||
@@ -956,22 +1019,15 @@ object CallManager {
|
||||
lastPeerSharedPublicHex = ""
|
||||
lastRemoteOfferFingerprint = ""
|
||||
lastLocalOfferFingerprint = ""
|
||||
e2eeRebindJob?.cancel()
|
||||
e2eeRebindJob = null
|
||||
localPrivateKey = null
|
||||
localPublicKey = null
|
||||
callSessionId = ""
|
||||
callStartedAtMs = 0L
|
||||
durationJob?.cancel()
|
||||
durationJob = null
|
||||
disconnectResetJob?.cancel()
|
||||
disconnectResetJob = null
|
||||
incomingRingTimeoutJob?.cancel()
|
||||
incomingRingTimeoutJob = null
|
||||
setSpeakerphone(false)
|
||||
// Останавливаем ForegroundService ДО сброса state — иначе "Unknown" мелькает
|
||||
// Останавливаем ForegroundService и сбрасываем state
|
||||
appContext?.let { CallForegroundService.stop(it) }
|
||||
_state.value = CallUiState()
|
||||
resetting = false
|
||||
}
|
||||
|
||||
private fun resetRtcObjects() {
|
||||
@@ -1440,11 +1496,11 @@ object CallManager {
|
||||
}
|
||||
|
||||
private fun updateState(reducer: (CallUiState) -> CallUiState) {
|
||||
if (resetting) return // Не синхронизируем во время resetSession — иначе "Unknown" мелькает
|
||||
val old = _state.value
|
||||
_state.update(reducer)
|
||||
val newState = _state.value
|
||||
// Синхронизируем ForegroundService при смене фазы или имени
|
||||
// Не синхронизируем при IDLE — resetSession уже вызывает CallForegroundService.stop()
|
||||
if (newState.phase != CallPhase.IDLE &&
|
||||
(newState.phase != old.phase || newState.displayName != old.displayName)) {
|
||||
appContext?.let { ctx ->
|
||||
|
||||
@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import okhttp3.*
|
||||
import okio.ByteString
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
@@ -36,6 +37,8 @@ class Protocol(
|
||||
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
|
||||
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_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) {
|
||||
@@ -43,6 +46,123 @@ class Protocol(
|
||||
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()
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
@@ -487,11 +607,8 @@ class Protocol(
|
||||
val stream = packet.send()
|
||||
val data = stream.getStream()
|
||||
|
||||
log("📤 Sending packet: ${packet.getPacketId()} (${data.size} bytes)")
|
||||
|
||||
// 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 ""}")
|
||||
log("➡️ CLIENT -> SERVER ${describePacket(packet, data.size)}")
|
||||
log(" TX_HEX: ${hexPreview(data)}")
|
||||
|
||||
val socket = webSocket
|
||||
if (socket == null) {
|
||||
@@ -504,16 +621,17 @@ class Protocol(
|
||||
try {
|
||||
val sent = socket.send(ByteString.of(*data))
|
||||
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)
|
||||
return
|
||||
}
|
||||
log("✅ Packet ${packet.getPacketId()} sent successfully")
|
||||
log("✅ TX delivered id=0x${packet.getPacketId().toString(16).uppercase(Locale.ROOT)}")
|
||||
} 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()
|
||||
// Как в Архиве - возвращаем пакет в очередь при ошибке отправки
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -530,9 +648,8 @@ class Protocol(
|
||||
|
||||
private fun handleMessage(data: ByteArray) {
|
||||
try {
|
||||
// Debug: log first 50 bytes as hex
|
||||
val hexDump = data.take(50).joinToString(" ") { String.format("%02X", it.toInt() and 0xFF) }
|
||||
log("📥 Received ${data.size} bytes: $hexDump${if (data.size > 50) "..." else ""}")
|
||||
log("⬅️ SERVER -> CLIENT rawBytes=${data.size}")
|
||||
log(" RX_HEX: ${hexPreview(data)}")
|
||||
|
||||
val stream = Stream(data)
|
||||
if (stream.getRemainingBits() < MIN_PACKET_ID_BITS) {
|
||||
@@ -558,9 +675,15 @@ class Protocol(
|
||||
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
|
||||
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 ->
|
||||
try {
|
||||
|
||||
@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.File
|
||||
import java.security.SecureRandom
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -33,6 +34,9 @@ object ProtocolManager {
|
||||
private const val DEBUG_LOG_FLUSH_DELAY_MS = 60L
|
||||
private const val HEARTBEAT_OK_LOG_MIN_INTERVAL_MS = 5_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_WEB_RTC = 0x1B
|
||||
private const val PACKET_ICE_SERVERS = 0x1C
|
||||
@@ -61,6 +65,7 @@ object ProtocolManager {
|
||||
val debugLogs: StateFlow<List<String>> = _debugLogs.asStateFlow()
|
||||
private val debugLogsBuffer = ArrayDeque<String>(MAX_DEBUG_LOGS)
|
||||
private val debugLogsLock = Any()
|
||||
private val protocolTraceLock = Any()
|
||||
@Volatile private var debugFlushJob: Job? = null
|
||||
private val debugFlushPending = AtomicBoolean(false)
|
||||
@Volatile private var lastHeartbeatOkLogAtMs: Long = 0L
|
||||
@@ -69,6 +74,10 @@ object ProtocolManager {
|
||||
// Typing status
|
||||
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
|
||||
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 typingUsersByDialog = mutableMapOf<String, MutableSet<String>>()
|
||||
private val typingTimeoutJobs = ConcurrentHashMap<String, Job>()
|
||||
@@ -134,7 +143,6 @@ object ProtocolManager {
|
||||
}
|
||||
|
||||
fun addLog(message: String) {
|
||||
if (!uiLogsEnabled) return
|
||||
var normalizedMessage = message
|
||||
val isHeartbeatOk = normalizedMessage.startsWith("💓 Heartbeat OK")
|
||||
if (isHeartbeatOk) {
|
||||
@@ -152,6 +160,8 @@ object ProtocolManager {
|
||||
val timestamp =
|
||||
java.text.SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
|
||||
val line = "[$timestamp] $normalizedMessage"
|
||||
persistProtocolTraceLine(line)
|
||||
if (!uiLogsEnabled) return
|
||||
synchronized(debugLogsLock) {
|
||||
if (debugLogsBuffer.size >= MAX_DEBUG_LOGS) {
|
||||
debugLogsBuffer.removeFirst()
|
||||
@@ -161,6 +171,24 @@ object ProtocolManager {
|
||||
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) {
|
||||
uiLogsEnabled = enabled
|
||||
MessageLogger.setEnabled(enabled)
|
||||
@@ -656,6 +684,20 @@ object ProtocolManager {
|
||||
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) {
|
||||
val normalizedDialogKey =
|
||||
if (isGroupDialogKey(dialogKey)) normalizeGroupDialogKey(dialogKey) else dialogKey.trim()
|
||||
@@ -666,6 +708,8 @@ object ProtocolManager {
|
||||
val users = typingUsersByDialog.getOrPut(normalizedDialogKey) { mutableSetOf() }
|
||||
users.add(normalizedFrom)
|
||||
_typingUsers.value = typingUsersByDialog.keys.toSet()
|
||||
_typingUsersByDialogSnapshot.value =
|
||||
typingUsersByDialog.mapValues { entry -> entry.value.toSet() }
|
||||
}
|
||||
|
||||
val timeoutKey = makeTypingTimeoutKey(normalizedDialogKey, normalizedFrom)
|
||||
@@ -680,6 +724,8 @@ object ProtocolManager {
|
||||
typingUsersByDialog.remove(normalizedDialogKey)
|
||||
}
|
||||
_typingUsers.value = typingUsersByDialog.keys.toSet()
|
||||
_typingUsersByDialogSnapshot.value =
|
||||
typingUsersByDialog.mapValues { entry -> entry.value.toSet() }
|
||||
}
|
||||
typingTimeoutJobs.remove(timeoutKey)
|
||||
}
|
||||
@@ -691,6 +737,7 @@ object ProtocolManager {
|
||||
synchronized(typingStateLock) {
|
||||
typingUsersByDialog.clear()
|
||||
_typingUsers.value = emptySet()
|
||||
_typingUsersByDialogSnapshot.value = emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1328,6 +1375,10 @@ object ProtocolManager {
|
||||
sharedPublic: String = "",
|
||||
roomId: String = ""
|
||||
) {
|
||||
addLog(
|
||||
"📡 CALL TX type=$signalType src=${shortKeyForLog(src)} dst=${shortKeyForLog(dst)} " +
|
||||
"sharedLen=${sharedPublic.length} room=${shortKeyForLog(roomId, 12)}"
|
||||
)
|
||||
send(
|
||||
PacketSignalPeer().apply {
|
||||
this.signalType = signalType
|
||||
@@ -1345,6 +1396,11 @@ object ProtocolManager {
|
||||
fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) {
|
||||
val pk = try { getProtocol().getPublicKey().orEmpty() } catch (_: Exception) { "" }
|
||||
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(
|
||||
PacketWebRTC().apply {
|
||||
this.signalType = signalType
|
||||
@@ -1359,6 +1415,7 @@ object ProtocolManager {
|
||||
* Request ICE servers from server (0x1C).
|
||||
*/
|
||||
fun requestIceServers() {
|
||||
addLog("📡 ICE TX request")
|
||||
send(PacketIceServers())
|
||||
}
|
||||
|
||||
@@ -1368,7 +1425,13 @@ object ProtocolManager {
|
||||
*/
|
||||
fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit {
|
||||
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)
|
||||
return wrapper
|
||||
@@ -1384,7 +1447,14 @@ object ProtocolManager {
|
||||
*/
|
||||
fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit {
|
||||
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)
|
||||
return wrapper
|
||||
@@ -1400,7 +1470,11 @@ object ProtocolManager {
|
||||
*/
|
||||
fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit {
|
||||
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)
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -126,6 +126,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
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 DIRECT_MESSAGE_STACK_TIME_DIFF_MS = 60_000L
|
||||
private const val MESSAGE_SKELETON_VISIBILITY_DELAY_MS = 180L
|
||||
@@ -729,8 +730,8 @@ fun ChatDetailScreen(
|
||||
remember(user.publicKey, currentUserPublicKey) {
|
||||
"${currentUserPublicKey.trim()}::${user.publicKey.trim()}"
|
||||
}
|
||||
var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) {
|
||||
mutableStateOf<Set<String>>(emptySet())
|
||||
var groupAdminKeys by remember(groupMembersCacheKey) {
|
||||
mutableStateOf(groupAdminKeysCache[groupMembersCacheKey] ?: emptySet())
|
||||
}
|
||||
var groupMembersCount by remember(groupMembersCacheKey) {
|
||||
mutableStateOf<Int?>(groupMembersCountCache[groupMembersCacheKey])
|
||||
@@ -756,12 +757,15 @@ fun ChatDetailScreen(
|
||||
|
||||
val cachedMembersCount = groupMembersCountCache[groupMembersCacheKey]
|
||||
groupMembersCount = cachedMembersCount
|
||||
val cachedAdminKeys = groupAdminKeysCache[groupMembersCacheKey]
|
||||
if (!cachedAdminKeys.isNullOrEmpty()) {
|
||||
groupAdminKeys = cachedAdminKeys
|
||||
}
|
||||
|
||||
val members = withContext(Dispatchers.IO) {
|
||||
groupRepository.requestGroupMembers(user.publicKey)
|
||||
}
|
||||
if (members == null) {
|
||||
groupAdminKeys = emptySet()
|
||||
mentionCandidates = emptyList()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
@@ -777,6 +781,9 @@ fun ChatDetailScreen(
|
||||
val adminKey = normalizedMembers.firstOrNull().orEmpty()
|
||||
groupAdminKeys =
|
||||
if (adminKey.isBlank()) emptySet() else setOf(adminKey)
|
||||
if (groupAdminKeys.isNotEmpty()) {
|
||||
groupAdminKeysCache[groupMembersCacheKey] = groupAdminKeys
|
||||
}
|
||||
|
||||
mentionCandidates =
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -822,6 +829,8 @@ fun ChatDetailScreen(
|
||||
val messages by viewModel.messages.collectAsState()
|
||||
val inputText by viewModel.inputText.collectAsState()
|
||||
val isTyping by viewModel.opponentTyping.collectAsState()
|
||||
val typingDisplayName by viewModel.typingDisplayName.collectAsState()
|
||||
val typingDisplayPublicKey by viewModel.typingDisplayPublicKey.collectAsState()
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val isLoadingMore by viewModel.isLoadingMore.collectAsState()
|
||||
val rawIsOnline by viewModel.opponentOnline.collectAsState()
|
||||
@@ -1344,9 +1353,6 @@ fun ChatDetailScreen(
|
||||
|
||||
// Динамический subtitle: typing > online > offline
|
||||
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 groupMembersSubtitle =
|
||||
if (groupMembersSubtitleCount == null) {
|
||||
@@ -2075,7 +2081,7 @@ fun ChatDetailScreen(
|
||||
if (!isSavedMessages &&
|
||||
!isGroupChat &&
|
||||
(chatHeaderVerified >
|
||||
0 || isRosettaOfficial)
|
||||
0 || isSystemAccount)
|
||||
) {
|
||||
Spacer(
|
||||
modifier =
|
||||
@@ -2109,7 +2115,11 @@ fun ChatDetailScreen(
|
||||
if (isTyping) {
|
||||
TypingIndicator(
|
||||
isDarkTheme =
|
||||
isDarkTheme
|
||||
isDarkTheme,
|
||||
typingDisplayName =
|
||||
if (isGroupChat) typingDisplayName else "",
|
||||
typingSenderPublicKey =
|
||||
if (isGroupChat) typingDisplayPublicKey else ""
|
||||
)
|
||||
} else if (isGroupChat &&
|
||||
groupMembersCount == null
|
||||
|
||||
@@ -43,6 +43,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private const val DECRYPT_CHUNK_SIZE = 15 // Расшифровываем по 15 сообщений за раз
|
||||
private const val DECRYPT_PARALLELISM = 4 // Параллельная расшифровка
|
||||
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 chatMessageAscComparator =
|
||||
compareBy<ChatMessage>({ it.timestamp.time }, { it.id })
|
||||
@@ -167,7 +168,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val _opponentTyping = MutableStateFlow(false)
|
||||
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 typingNameResolveJob: kotlinx.coroutines.Job? = null
|
||||
@Volatile private var typingSenderPublicKey: String? = null
|
||||
@Volatile private var typingUsersCount: Int = 1
|
||||
|
||||
// 🟢 Онлайн статус собеседника
|
||||
private val _opponentOnline = MutableStateFlow(false)
|
||||
@@ -218,6 +226,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// Job для отмены загрузки при смене диалога
|
||||
private var loadingJob: Job? = null
|
||||
private var draftSaveJob: Job? = null
|
||||
|
||||
// 🔥 Throttling для typing индикатора
|
||||
private var lastTypingSentTime = 0L
|
||||
@@ -359,7 +368,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
if (shouldShowTyping) {
|
||||
showTypingIndicator()
|
||||
if (isGroupDialogKey(currentDialog)) {
|
||||
val typingUsers = ProtocolManager.getTypingUsersForDialog(currentDialog).toMutableSet()
|
||||
typingUsers.add(fromPublicKey)
|
||||
showTypingIndicator(
|
||||
senderPublicKey = fromPublicKey,
|
||||
typingUsersCount = typingUsers.size
|
||||
)
|
||||
} else {
|
||||
showTypingIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -730,7 +748,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
_opponentOnline.value = false
|
||||
_opponentTyping.value = false
|
||||
_typingDisplayName.value = ""
|
||||
_typingDisplayPublicKey.value = ""
|
||||
typingSenderPublicKey = null
|
||||
typingUsersCount = 1
|
||||
typingTimeoutJob?.cancel()
|
||||
typingNameResolveJob?.cancel()
|
||||
currentOffset = 0
|
||||
hasMoreMessages = true
|
||||
isLoadingMessages = false
|
||||
@@ -1370,6 +1393,52 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
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 {
|
||||
val normalizedPublicKey = publicKey.trim()
|
||||
if (normalizedPublicKey.isBlank()) return ""
|
||||
@@ -1436,6 +1505,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
groupSenderNameCache[normalizedPublicKey] = name
|
||||
withContext(Dispatchers.Main) {
|
||||
if (_opponentTyping.value &&
|
||||
typingSenderPublicKey.equals(normalizedPublicKey, ignoreCase = true)
|
||||
) {
|
||||
_typingDisplayName.value =
|
||||
buildGroupTypingDisplayName(name, typingUsersCount)
|
||||
}
|
||||
_messages.update { current ->
|
||||
current.map { message ->
|
||||
if (message.senderPublicKey.trim() == normalizedPublicKey &&
|
||||
@@ -1500,7 +1575,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private fun isLikelyCallAttachmentPreview(preview: String): Boolean {
|
||||
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()
|
||||
if (tail.toIntOrNull() != null) return true
|
||||
@@ -2286,11 +2363,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
/** Обновить текст ввода */
|
||||
fun updateInputText(text: String) {
|
||||
if (_inputText.value == text) return
|
||||
_inputText.value = text
|
||||
// 📝 Сохраняем черновик при каждом изменении текста (draft, как в Telegram)
|
||||
opponentKey?.let { key ->
|
||||
com.rosetta.messenger.data.DraftManager.saveDraft(key, text)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
// Отменяем предыдущий таймер, чтобы избежать race condition
|
||||
typingTimeoutJob?.cancel()
|
||||
@@ -5090,6 +5218,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
kotlinx.coroutines.delay(3000)
|
||||
_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() {
|
||||
super.onCleared()
|
||||
isCleared = true
|
||||
typingTimeoutJob?.cancel()
|
||||
typingNameResolveJob?.cancel()
|
||||
draftSaveJob?.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) }
|
||||
}
|
||||
|
||||
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_TEXT_START = 72.dp
|
||||
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
|
||||
@@ -435,6 +450,7 @@ fun ChatsListScreen(
|
||||
|
||||
// <20>🔥 Пользователи, которые сейчас печатают
|
||||
val typingUsers by ProtocolManager.typingUsers.collectAsState()
|
||||
val typingUsersByDialogSnapshot by ProtocolManager.typingUsersByDialogSnapshot.collectAsState()
|
||||
|
||||
// Load dialogs when account is available
|
||||
LaunchedEffect(accountPublicKey, accountPrivateKey) {
|
||||
@@ -807,24 +823,10 @@ fun ChatsListScreen(
|
||||
bottom = 12.dp
|
||||
)
|
||||
) {
|
||||
val isRosettaOfficial =
|
||||
accountName.equals(
|
||||
"Rosetta",
|
||||
ignoreCase = true
|
||||
) ||
|
||||
accountUsername.equals(
|
||||
"rosetta",
|
||||
ignoreCase = true
|
||||
)
|
||||
val isFreddyOfficial =
|
||||
accountName.equals(
|
||||
"freddy",
|
||||
ignoreCase = true
|
||||
) ||
|
||||
accountUsername.equals(
|
||||
"freddy",
|
||||
ignoreCase = true
|
||||
)
|
||||
val isOfficialByKey =
|
||||
MessageRepository.isSystemAccount(
|
||||
accountPublicKey
|
||||
)
|
||||
// Avatar row with theme toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -934,7 +936,7 @@ fun ChatsListScreen(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
if (accountVerified > 0 || isRosettaOfficial || isFreddyOfficial) {
|
||||
if (accountVerified > 0 || isOfficialByKey) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
VerifiedBadge(
|
||||
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 =
|
||||
selectedChatKeys
|
||||
.contains(
|
||||
@@ -2518,6 +2589,10 @@ fun ChatsListScreen(
|
||||
isDarkTheme,
|
||||
isTyping =
|
||||
isTyping,
|
||||
typingDisplayName =
|
||||
typingDisplayName,
|
||||
typingSenderPublicKey =
|
||||
typingSenderPublicKey,
|
||||
isBlocked =
|
||||
isBlocked,
|
||||
isSavedMessages =
|
||||
@@ -3627,6 +3702,8 @@ fun SwipeableDialogItem(
|
||||
dialog: DialogUiModel,
|
||||
isDarkTheme: Boolean,
|
||||
isTyping: Boolean = false,
|
||||
typingDisplayName: String = "",
|
||||
typingSenderPublicKey: String = "",
|
||||
isBlocked: Boolean = false,
|
||||
isGroupChat: Boolean = false,
|
||||
isSavedMessages: Boolean = false,
|
||||
@@ -4034,6 +4111,8 @@ fun SwipeableDialogItem(
|
||||
dialog = dialog,
|
||||
isDarkTheme = isDarkTheme,
|
||||
isTyping = isTyping,
|
||||
typingDisplayName = typingDisplayName,
|
||||
typingSenderPublicKey = typingSenderPublicKey,
|
||||
isPinned = isPinned,
|
||||
isBlocked = isBlocked,
|
||||
isMuted = isMuted,
|
||||
@@ -4051,6 +4130,8 @@ fun DialogItemContent(
|
||||
dialog: DialogUiModel,
|
||||
isDarkTheme: Boolean,
|
||||
isTyping: Boolean = false,
|
||||
typingDisplayName: String = "",
|
||||
typingSenderPublicKey: String = "",
|
||||
isPinned: Boolean = false,
|
||||
isBlocked: Boolean = false,
|
||||
isMuted: Boolean = false,
|
||||
@@ -4245,13 +4326,8 @@ fun DialogItemContent(
|
||||
modifier = Modifier.size(15.dp)
|
||||
)
|
||||
}
|
||||
val isRosettaOfficial = dialog.opponentTitle.equals("Rosetta", ignoreCase = true) ||
|
||||
dialog.opponentUsername.equals("rosetta", ignoreCase = true) ||
|
||||
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) {
|
||||
val isOfficialByKey = MessageRepository.isSystemAccount(dialog.opponentKey)
|
||||
if (dialog.verified > 0 || isOfficialByKey) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
VerifiedBadge(
|
||||
verified = if (dialog.verified > 0) dialog.verified else 1,
|
||||
@@ -4458,7 +4534,11 @@ fun DialogItemContent(
|
||||
label = "chatSubtitle"
|
||||
) { showTyping ->
|
||||
if (showTyping) {
|
||||
TypingIndicatorSmall()
|
||||
TypingIndicatorSmall(
|
||||
isDarkTheme = isDarkTheme,
|
||||
typingDisplayName = typingDisplayName,
|
||||
typingSenderPublicKey = typingSenderPublicKey
|
||||
)
|
||||
} else if (!dialog.draftText.isNullOrEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
@@ -4492,7 +4572,8 @@ fun DialogItemContent(
|
||||
dialog.lastMessageAttachmentType ==
|
||||
"Call" -> "Call"
|
||||
dialog.lastMessageAttachmentType ==
|
||||
"Forwarded" -> "Forwarded message"
|
||||
"Forwarded" ->
|
||||
"Forwarded message"
|
||||
dialog.lastMessage.isEmpty() ->
|
||||
"No messages"
|
||||
else -> dialog.lastMessage
|
||||
@@ -4712,8 +4793,21 @@ fun DialogItemContent(
|
||||
* with sequential wave animation (scale + vertical offset + opacity).
|
||||
*/
|
||||
@Composable
|
||||
fun TypingIndicatorSmall() {
|
||||
fun TypingIndicatorSmall(
|
||||
isDarkTheme: Boolean,
|
||||
typingDisplayName: String = "",
|
||||
typingSenderPublicKey: String = ""
|
||||
) {
|
||||
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")
|
||||
|
||||
// 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) {
|
||||
Text(
|
||||
text = "typing",
|
||||
fontSize = 14.sp,
|
||||
color = typingColor,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.heightIn(min = 18.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (normalizedDisplayName.isBlank()) {
|
||||
AppleEmojiText(
|
||||
text = "typing",
|
||||
fontSize = 14.sp,
|
||||
color = typingColor,
|
||||
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))
|
||||
|
||||
// Fixed-size canvas — big enough for bounce, never changes layout
|
||||
@@ -4750,7 +4871,7 @@ fun TypingIndicatorSmall() {
|
||||
val dotRadius = 1.5.dp.toPx()
|
||||
val dotSpacing = 2.5.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) {
|
||||
val p = dotProgresses[i].value
|
||||
val bounce = kotlin.math.sin(p * Math.PI).toFloat()
|
||||
|
||||
@@ -553,11 +553,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
val inferredCall = isLikelyCallAttachmentJson(lastMessageAttachments)
|
||||
return when (attachmentType) {
|
||||
0 -> if (inferredCall) "Call" else "Photo" // AttachmentType.IMAGE = 0
|
||||
1 -> {
|
||||
// AttachmentType.MESSAGES = 1 (Reply/Forward).
|
||||
// Если текст пустой — показываем "Forwarded" как в desktop.
|
||||
if (decryptedLastMessage.isNotEmpty()) null else "Forwarded"
|
||||
}
|
||||
1 -> if (decryptedLastMessage.isNotEmpty()) null else "Forwarded" // AttachmentType.MESSAGES = 1 (Reply/Forward)
|
||||
2 -> "File" // AttachmentType.FILE = 2
|
||||
3 -> "Avatar" // AttachmentType.AVATAR = 3
|
||||
4 -> "Call" // AttachmentType.CALL = 4
|
||||
@@ -589,7 +585,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
if (typeValue == 4) return true
|
||||
|
||||
val preview = first.optString("preview", "").trim()
|
||||
if (preview.isEmpty()) return true
|
||||
if (preview.isEmpty()) return false
|
||||
val tail = preview.substringAfterLast("::", preview).trim()
|
||||
if (tail.toIntOrNull() != null) return true
|
||||
|
||||
|
||||
@@ -178,12 +178,8 @@ fun CallOverlay(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false)
|
||||
)
|
||||
val isRosettaOfficial = state.peerTitle.equals("Rosetta", ignoreCase = true) ||
|
||||
state.peerUsername.equals("rosetta", ignoreCase = true) ||
|
||||
MessageRepository.isSystemAccount(state.peerPublicKey)
|
||||
val isFreddyVerified = state.peerUsername.equals("freddy", ignoreCase = true) ||
|
||||
state.peerTitle.equals("freddy", ignoreCase = true)
|
||||
if (isRosettaOfficial || isFreddyVerified) {
|
||||
val isOfficialByKey = MessageRepository.isSystemAccount(state.peerPublicKey)
|
||||
if (isOfficialByKey) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
VerifiedBadge(
|
||||
verified = 1,
|
||||
|
||||
@@ -238,12 +238,8 @@ private fun CallHistoryRowItem(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false)
|
||||
)
|
||||
val isRosettaOfficial = item.peerTitle.equals("Rosetta", ignoreCase = true) ||
|
||||
item.peerUsername.equals("rosetta", ignoreCase = true) ||
|
||||
MessageRepository.isSystemAccount(item.peerKey)
|
||||
val isFreddyVerified = item.peerUsername.equals("freddy", ignoreCase = true) ||
|
||||
item.peerTitle.equals("freddy", ignoreCase = true)
|
||||
if (item.peerVerified > 0 || isRosettaOfficial || isFreddyVerified) {
|
||||
val isOfficialByKey = MessageRepository.isSystemAccount(item.peerKey)
|
||||
if (item.peerVerified > 0 || isOfficialByKey) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
VerifiedBadge(
|
||||
verified = if (item.peerVerified > 0) item.peerVerified else 1,
|
||||
|
||||
@@ -228,9 +228,22 @@ fun DateHeader(
|
||||
* with sequential wave animation (scale + vertical offset + opacity).
|
||||
*/
|
||||
@Composable
|
||||
fun TypingIndicator(isDarkTheme: Boolean) {
|
||||
val typingColor = if (isDarkTheme) Color(0xFF54A9EB) else Color.White.copy(alpha = 0.7f)
|
||||
fun TypingIndicator(
|
||||
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 normalizedDisplayName = remember(typingDisplayName) { typingDisplayName.trim() }
|
||||
|
||||
// Each dot animates through a 0→1→0 cycle, staggered by 150 ms
|
||||
val dotProgresses = List(3) { index ->
|
||||
@@ -253,7 +266,30 @@ fun TypingIndicator(isDarkTheme: Boolean) {
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
// 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)
|
||||
|
||||
// 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
|
||||
animateFloatAsState(
|
||||
targetValue = if (isSelected) 0.85f else 1f,
|
||||
@@ -785,8 +815,6 @@ fun MessageBubble(
|
||||
.then(bubbleWidthModifier)
|
||||
.graphicsLayer {
|
||||
this.alpha = selectionAlpha
|
||||
this.scaleX = selectionScale
|
||||
this.scaleY = selectionScale
|
||||
}
|
||||
.combinedClickable(
|
||||
indication = null,
|
||||
|
||||
@@ -78,6 +78,8 @@ data class MentionCandidate(
|
||||
val publicKey: String
|
||||
)
|
||||
|
||||
private const val LARGE_INPUT_ANALYSIS_THRESHOLD = 2000
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun MessageInputBar(
|
||||
@@ -232,7 +234,11 @@ fun MessageInputBar(
|
||||
}
|
||||
val mentionPattern = remember { Regex("@([\\w\\d_]*)$") }
|
||||
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 =
|
||||
remember(mentionMatch) {
|
||||
@@ -246,9 +252,10 @@ fun MessageInputBar(
|
||||
value,
|
||||
mentionCandidates,
|
||||
mentionQuery,
|
||||
shouldShowMentionSuggestions
|
||||
shouldShowMentionSuggestions,
|
||||
skipHeavyInputAnalysis
|
||||
) {
|
||||
if (!shouldShowMentionSuggestions) {
|
||||
if (!shouldShowMentionSuggestions || skipHeavyInputAnalysis) {
|
||||
emptyList()
|
||||
} else {
|
||||
val mentionedInText =
|
||||
@@ -274,8 +281,12 @@ fun MessageInputBar(
|
||||
}
|
||||
|
||||
val emojiWordMatch =
|
||||
remember(value, selectionStart, selectionEnd) {
|
||||
EmojiKeywordSuggester.findWordAtCursor(value, selectionStart, selectionEnd)
|
||||
remember(value, selectionStart, selectionEnd, skipHeavyInputAnalysis) {
|
||||
if (skipHeavyInputAnalysis) {
|
||||
null
|
||||
} else {
|
||||
EmojiKeywordSuggester.findWordAtCursor(value, selectionStart, selectionEnd)
|
||||
}
|
||||
}
|
||||
|
||||
val emojiSuggestions =
|
||||
|
||||
@@ -592,10 +592,10 @@ fun AppleEmojiText(
|
||||
null
|
||||
}
|
||||
)
|
||||
setTextWithEmojisIfNeeded(text)
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
view.setTextWithEmojis(text)
|
||||
view.setTextColor(color.toArgb())
|
||||
view.setTypeface(view.typeface, typefaceStyle)
|
||||
// 🔥 Обновляем maxLines и ellipsize
|
||||
@@ -625,6 +625,7 @@ fun AppleEmojiText(
|
||||
null
|
||||
}
|
||||
)
|
||||
view.setTextWithEmojisIfNeeded(text)
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
@@ -644,6 +645,7 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
) : android.widget.TextView(context, attrs, defStyleAttr) {
|
||||
|
||||
companion object {
|
||||
private const val LARGE_TEXT_RENDER_THRESHOLD = 4000
|
||||
private val EMOJI_PATTERN = AppleEmojiEditTextView.EMOJI_PATTERN
|
||||
// 🔥 Паттерн для :emoji_XXXX: формата (из React Native)
|
||||
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 mentionClickCallback: ((String) -> 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
|
||||
var onLongClickCallback: (() -> Unit)? = null
|
||||
@@ -799,6 +805,17 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
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 изображения
|
||||
val spannable = SpannableStringBuilder(text)
|
||||
|
||||
@@ -877,18 +894,34 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
if (mentionsEnabled) {
|
||||
if (processMentions) {
|
||||
addMentionHighlights(spannable)
|
||||
}
|
||||
|
||||
// 🔥 5. Добавляем кликабельные ссылки после обработки эмодзи
|
||||
if (linksEnabled) {
|
||||
if (processLinks) {
|
||||
addClickableLinks(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
|
||||
*/
|
||||
@@ -1037,4 +1070,14 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
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 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 - просто по теме: белый в тёмной, чёрный в светлой
|
||||
@@ -2182,7 +2176,7 @@ private fun CollapsingOtherProfileHeader(
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
if (verified > 0 || isRosettaOfficial || isFreddyOfficial) {
|
||||
if (verified > 0 || isSystemAccount) {
|
||||
Box(
|
||||
modifier =
|
||||
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.BiometricAvailability
|
||||
import com.rosetta.messenger.biometric.BiometricPreferences
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
|
||||
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 onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress)
|
||||
val rosettaBadgeBlue = Color(0xFF1DA1F2)
|
||||
val isRosettaOfficial =
|
||||
name.equals("Rosetta", ignoreCase = true) ||
|
||||
username.equals("rosetta", ignoreCase = true)
|
||||
val isFreddyOfficial =
|
||||
name.equals("freddy", ignoreCase = true) ||
|
||||
username.equals("freddy", ignoreCase = true)
|
||||
val isOfficialByKey = MessageRepository.isSystemAccount(publicKey)
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) {
|
||||
// Expansion fraction — computed early so gradient can fade during expansion
|
||||
@@ -1416,7 +1412,7 @@ private fun CollapsingProfileHeader(
|
||||
modifier = Modifier.widthIn(max = 220.dp).alignByBaseline(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
if (verified > 0 || isRosettaOfficial || isFreddyOfficial) {
|
||||
if (verified > 0 || isOfficialByKey) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.padding(start = 4.dp)
|
||||
|
||||
Reference in New Issue
Block a user