Compare commits
2 Commits
a9be1282c6
...
7d4b9a8fc4
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d4b9a8fc4 | |||
| 6886a6cef1 |
@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
|||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// Rosetta versioning — bump here on each release
|
// Rosetta versioning — bump here on each release
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
val rosettaVersionName = "1.4.4"
|
val rosettaVersionName = "1.4.5"
|
||||||
val rosettaVersionCode = 46 // Increment on each release
|
val rosettaVersionCode = 47 // Increment on each release
|
||||||
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -1122,18 +1122,13 @@ fun MainScreen(
|
|||||||
accountName = accountName,
|
accountName = accountName,
|
||||||
accountUsername = accountUsername,
|
accountUsername = accountUsername,
|
||||||
accountVerified = accountVerified,
|
accountVerified = accountVerified,
|
||||||
accountPhone = accountPhone,
|
|
||||||
accountPublicKey = accountPublicKey,
|
accountPublicKey = accountPublicKey,
|
||||||
accountPrivateKey = accountPrivateKey,
|
accountPrivateKey = accountPrivateKey,
|
||||||
privateKeyHash = privateKeyHash,
|
|
||||||
onToggleTheme = onToggleTheme,
|
onToggleTheme = onToggleTheme,
|
||||||
onProfileClick = { pushScreen(Screen.Profile) },
|
onProfileClick = { pushScreen(Screen.Profile) },
|
||||||
onNewGroupClick = {
|
onNewGroupClick = {
|
||||||
pushScreen(Screen.GroupSetup)
|
pushScreen(Screen.GroupSetup)
|
||||||
},
|
},
|
||||||
onContactsClick = {
|
|
||||||
// TODO: Navigate to contacts
|
|
||||||
},
|
|
||||||
onCallsClick = {
|
onCallsClick = {
|
||||||
// TODO: Navigate to calls
|
// TODO: Navigate to calls
|
||||||
},
|
},
|
||||||
@@ -1152,9 +1147,6 @@ fun MainScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
onSettingsClick = { pushScreen(Screen.Profile) },
|
onSettingsClick = { pushScreen(Screen.Profile) },
|
||||||
onInviteFriendsClick = {
|
|
||||||
// TODO: Share invite link
|
|
||||||
},
|
|
||||||
onSearchClick = { pushScreen(Screen.Search) },
|
onSearchClick = { pushScreen(Screen.Search) },
|
||||||
onRequestsClick = { pushScreen(Screen.Requests) },
|
onRequestsClick = { pushScreen(Screen.Requests) },
|
||||||
onNewChat = {
|
onNewChat = {
|
||||||
@@ -1166,7 +1158,6 @@ fun MainScreen(
|
|||||||
onStartCall = { user ->
|
onStartCall = { user ->
|
||||||
startCallWithPermission(user)
|
startCallWithPermission(user)
|
||||||
},
|
},
|
||||||
backgroundBlurColorId = backgroundBlurColorId,
|
|
||||||
pinnedChats = pinnedChats,
|
pinnedChats = pinnedChats,
|
||||||
onTogglePin = { opponentKey ->
|
onTogglePin = { opponentKey ->
|
||||||
mainScreenScope.launch { prefsManager.togglePinChat(opponentKey) }
|
mainScreenScope.launch { prefsManager.togglePinChat(opponentKey) }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Получить черновик для диалога */
|
/** Получить черновик для диалога */
|
||||||
|
|||||||
@@ -18,15 +18,31 @@ object ReleaseNotes {
|
|||||||
Update v$VERSION_PLACEHOLDER
|
Update v$VERSION_PLACEHOLDER
|
||||||
|
|
||||||
Звонки
|
Звонки
|
||||||
- Обновлён протокол WebRTC: publicKey и deviceId в каждом пакете
|
- Полноэкранный входящий звонок (IncomingCallActivity) поверх экрана блокировки с кнопками Принять/Отклонить
|
||||||
- Полноэкранный входящий звонок на экране блокировки
|
- Обновлён протокол WebRTC: publicKey и deviceId в каждом пакете (совместимость с новым сервером)
|
||||||
- Фикс бесконечного "Exchanging keys" при принятии звонка
|
- Звонок больше не сбрасывается при переподключении WebSocket
|
||||||
- Фикс краша ForegroundService при исходящем звонке
|
- Исправлен бесконечный статус "Exchanging keys" — KEY_EXCHANGE отправляется с ретраем до 6 сек
|
||||||
- Кастомный WebRTC с E2EE теперь работает в CI-сборках
|
- Автоматическая привязка аккаунта при принятии звонка из push-уведомления
|
||||||
|
- Исправлен краш ForegroundService при исходящем звонке (safeStopForeground)
|
||||||
|
- Убрано мелькание "Unknown" при завершении звонка
|
||||||
|
- Кнопка Decline теперь работает во всех фазах звонка
|
||||||
|
- Баннер активного звонка теперь отображается внутри диалога
|
||||||
|
- Дедупликация push + WebSocket сигналов (без мерцания уведомлений)
|
||||||
|
- Защита от фантомных звонков при принятии на другом устройстве
|
||||||
|
- Корректное освобождение PeerConnection (dispose) при завершении звонка
|
||||||
|
- Кастомный WebRTC AAR с E2EE добавлен в репозиторий для CI-сборок
|
||||||
|
- Диагностические логи звонков и уведомлений в rosettadev1
|
||||||
|
|
||||||
Уведомления
|
Уведомления
|
||||||
- Аватарки и имена в уведомлениях
|
- Аватарки и имена пользователей в уведомлениях о сообщениях и звонках
|
||||||
- Настройка отключения аватарок в уведомлениях
|
- Настройка включения/выключения аватарок в уведомлениях (Notifications → Avatars in Notifications)
|
||||||
|
- Сохранение FCM токена в rosettadev1 для диагностики
|
||||||
|
- Поддержка tokenType и deviceId в push-подписке
|
||||||
|
|
||||||
|
Интерфейс
|
||||||
|
- Ограничение масштаба шрифта до 1.3x — вёрстка не ломается на телефонах с огромным текстом
|
||||||
|
- Новые обои: Light 1-3 для светлой темы, Dark 1-3 для тёмной темы
|
||||||
|
- Убраны старые обои, исправлено растяжение превью обоев
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
fun getNotice(version: String): String =
|
fun getNotice(version: String): String =
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -367,9 +368,6 @@ fun ChatDetailScreen(
|
|||||||
// 🎨 Window reference для управления статус баром
|
// 🎨 Window reference для управления статус баром
|
||||||
val window = remember { (view.context as? Activity)?.window }
|
val window = remember { (view.context as? Activity)?.window }
|
||||||
|
|
||||||
// 🔥 Focus state for input
|
|
||||||
val inputFocusRequester = remember { FocusRequester() }
|
|
||||||
|
|
||||||
// 🔥 Emoji picker state
|
// 🔥 Emoji picker state
|
||||||
var showEmojiPicker by remember { mutableStateOf(false) }
|
var showEmojiPicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -538,7 +536,7 @@ fun ChatDetailScreen(
|
|||||||
} else {
|
} else {
|
||||||
val isOverlayControllingSystemBars = showMediaPicker
|
val isOverlayControllingSystemBars = showMediaPicker
|
||||||
|
|
||||||
if (!isOverlayControllingSystemBars && window != null && view != null) {
|
if (!isOverlayControllingSystemBars && window != null) {
|
||||||
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||||
ic.isAppearanceLightStatusBars = false
|
ic.isAppearanceLightStatusBars = false
|
||||||
@@ -556,7 +554,7 @@ fun ChatDetailScreen(
|
|||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
// Восстановить белые иконки статус-бара для chat list header
|
// Восстановить белые иконки статус-бара для chat list header
|
||||||
if (window != null && view != null) {
|
if (window != null) {
|
||||||
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||||
ic.isAppearanceLightStatusBars = false
|
ic.isAppearanceLightStatusBars = false
|
||||||
@@ -570,9 +568,6 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 📷 Camera: URI для сохранения фото
|
|
||||||
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
|
|
||||||
|
|
||||||
// 📷 Состояние для flow камеры: фото → редактор с caption → отправка
|
// 📷 Состояние для flow камеры: фото → редактор с caption → отправка
|
||||||
var pendingCameraPhotoUri by remember {
|
var pendingCameraPhotoUri by remember {
|
||||||
mutableStateOf<Uri?>(null)
|
mutableStateOf<Uri?>(null)
|
||||||
@@ -647,47 +642,6 @@ fun ChatDetailScreen(
|
|||||||
onDispose { onImageViewerChanged(false) }
|
onDispose { onImageViewerChanged(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// <20>📷 Camera launcher
|
|
||||||
val cameraLauncher =
|
|
||||||
rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.TakePicture()
|
|
||||||
) { success ->
|
|
||||||
if (success && cameraImageUri != null) {
|
|
||||||
// Очищаем фокус чтобы клавиатура не появилась
|
|
||||||
keyboardController?.hide()
|
|
||||||
focusManager.clearFocus()
|
|
||||||
// Открываем редактор вместо прямой отправки
|
|
||||||
pendingCameraPhotoUri = cameraImageUri
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// <20>️ Gallery-as-file launcher (sends images as compressed files, not as photos)
|
|
||||||
val galleryAsFileLauncher =
|
|
||||||
rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.GetContent()
|
|
||||||
) { uri ->
|
|
||||||
if (uri != null) {
|
|
||||||
scope.launch {
|
|
||||||
val fileName = MediaUtils.getFileName(context, uri)
|
|
||||||
val fileSize = MediaUtils.getFileSize(context, uri)
|
|
||||||
|
|
||||||
if (fileSize > MediaUtils.MAX_FILE_SIZE_MB * 1024 * 1024) {
|
|
||||||
android.widget.Toast.makeText(
|
|
||||||
context,
|
|
||||||
"File too large (max ${MediaUtils.MAX_FILE_SIZE_MB} MB)",
|
|
||||||
android.widget.Toast.LENGTH_LONG
|
|
||||||
).show()
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
val base64 = MediaUtils.uriToBase64File(context, uri)
|
|
||||||
if (base64 != null) {
|
|
||||||
viewModel.sendFileMessage(base64, fileName, fileSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// <20>📄 File picker launcher
|
// <20>📄 File picker launcher
|
||||||
val filePickerLauncher =
|
val filePickerLauncher =
|
||||||
rememberLauncherForActivityResult(
|
rememberLauncherForActivityResult(
|
||||||
@@ -729,8 +683,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 +710,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 +734,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) {
|
||||||
@@ -820,8 +780,9 @@ fun ChatDetailScreen(
|
|||||||
|
|
||||||
// Подключаем к ViewModel
|
// Подключаем к ViewModel
|
||||||
val messages by viewModel.messages.collectAsState()
|
val messages by viewModel.messages.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 +1305,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 +2033,7 @@ fun ChatDetailScreen(
|
|||||||
if (!isSavedMessages &&
|
if (!isSavedMessages &&
|
||||||
!isGroupChat &&
|
!isGroupChat &&
|
||||||
(chatHeaderVerified >
|
(chatHeaderVerified >
|
||||||
0 || isRosettaOfficial)
|
0 || isSystemAccount)
|
||||||
) {
|
) {
|
||||||
Spacer(
|
Spacer(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -2109,7 +2067,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
|
||||||
@@ -2694,66 +2656,40 @@ fun ChatDetailScreen(
|
|||||||
} else if (!isSystemAccount) {
|
} else if (!isSystemAccount) {
|
||||||
// INPUT BAR
|
// INPUT BAR
|
||||||
Column {
|
Column {
|
||||||
MessageInputBar(
|
ChatInputBarSection(
|
||||||
value = inputText,
|
viewModel = viewModel,
|
||||||
onValueChange = {
|
isSavedMessages = isSavedMessages,
|
||||||
viewModel
|
|
||||||
.updateInputText(
|
|
||||||
it
|
|
||||||
)
|
|
||||||
if (it.isNotEmpty() &&
|
|
||||||
!isSavedMessages
|
|
||||||
) {
|
|
||||||
viewModel
|
|
||||||
.sendTypingIndicator()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSend = {
|
onSend = {
|
||||||
isSendingMessage =
|
isSendingMessage = true
|
||||||
true
|
viewModel.sendMessage()
|
||||||
viewModel
|
|
||||||
.sendMessage()
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
delay(100)
|
delay(100)
|
||||||
listState
|
listState.animateScrollToItem(0)
|
||||||
.animateScrollToItem(
|
|
||||||
0
|
|
||||||
)
|
|
||||||
delay(300)
|
delay(300)
|
||||||
isSendingMessage =
|
isSendingMessage = false
|
||||||
false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
backgroundColor =
|
backgroundColor = backgroundColor,
|
||||||
backgroundColor,
|
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
placeholderColor =
|
|
||||||
secondaryTextColor,
|
|
||||||
secondaryTextColor =
|
secondaryTextColor =
|
||||||
secondaryTextColor,
|
secondaryTextColor,
|
||||||
replyMessages =
|
replyMessages = replyMessages,
|
||||||
replyMessages,
|
isForwardMode = isForwardMode,
|
||||||
isForwardMode =
|
|
||||||
isForwardMode,
|
|
||||||
onCloseReply = {
|
onCloseReply = {
|
||||||
viewModel
|
viewModel.clearReplyMessages()
|
||||||
.clearReplyMessages()
|
|
||||||
},
|
},
|
||||||
onShowForwardOptions = { panelMessages ->
|
onShowForwardOptions = { panelMessages ->
|
||||||
if (panelMessages.isEmpty()) {
|
if (panelMessages.isEmpty()) {
|
||||||
return@MessageInputBar
|
return@ChatInputBarSection
|
||||||
}
|
}
|
||||||
val forwardMessages =
|
val forwardMessages =
|
||||||
panelMessages.map { msg ->
|
panelMessages.map { msg ->
|
||||||
ForwardManager.ForwardMessage(
|
ForwardManager.ForwardMessage(
|
||||||
messageId =
|
messageId = msg.messageId,
|
||||||
msg.messageId,
|
|
||||||
text = msg.text,
|
text = msg.text,
|
||||||
timestamp =
|
timestamp = msg.timestamp,
|
||||||
msg.timestamp,
|
isOutgoing = msg.isOutgoing,
|
||||||
isOutgoing =
|
|
||||||
msg.isOutgoing,
|
|
||||||
senderPublicKey =
|
senderPublicKey =
|
||||||
msg.publicKey.ifEmpty {
|
msg.publicKey.ifEmpty {
|
||||||
if (msg.isOutgoing) currentUserPublicKey
|
if (msg.isOutgoing) currentUserPublicKey
|
||||||
@@ -2783,43 +2719,28 @@ fun ChatDetailScreen(
|
|||||||
},
|
},
|
||||||
chatTitle = chatTitle,
|
chatTitle = chatTitle,
|
||||||
isBlocked = isBlocked,
|
isBlocked = isBlocked,
|
||||||
showEmojiPicker =
|
showEmojiPicker = showEmojiPicker,
|
||||||
showEmojiPicker,
|
|
||||||
onToggleEmojiPicker = {
|
onToggleEmojiPicker = {
|
||||||
showEmojiPicker = it
|
showEmojiPicker = it
|
||||||
},
|
},
|
||||||
focusRequester =
|
|
||||||
inputFocusRequester,
|
|
||||||
coordinator = coordinator,
|
coordinator = coordinator,
|
||||||
displayReplyMessages =
|
displayReplyMessages =
|
||||||
displayReplyMessages,
|
displayReplyMessages,
|
||||||
onReplyClick =
|
onReplyClick = scrollToMessage,
|
||||||
scrollToMessage,
|
|
||||||
onAttachClick = {
|
onAttachClick = {
|
||||||
// Telegram-style:
|
// Telegram-style:
|
||||||
// галерея
|
// галерея открывается поверх клавиатуры.
|
||||||
// открывается
|
showMediaPicker = true
|
||||||
// ПОВЕРХ клавиатуры
|
|
||||||
// НЕ скрываем
|
|
||||||
// клавиатуру!
|
|
||||||
showMediaPicker =
|
|
||||||
true
|
|
||||||
},
|
},
|
||||||
myPublicKey =
|
myPublicKey =
|
||||||
viewModel
|
viewModel.myPublicKey ?: "",
|
||||||
.myPublicKey
|
opponentPublicKey = user.publicKey,
|
||||||
?: "",
|
myPrivateKey = currentUserPrivateKey,
|
||||||
opponentPublicKey =
|
|
||||||
user.publicKey,
|
|
||||||
myPrivateKey =
|
|
||||||
currentUserPrivateKey,
|
|
||||||
isGroupChat = isGroupChat,
|
isGroupChat = isGroupChat,
|
||||||
mentionCandidates = mentionCandidates,
|
mentionCandidates = mentionCandidates,
|
||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
inputFocusTrigger =
|
inputFocusTrigger = inputFocusTrigger,
|
||||||
inputFocusTrigger,
|
suppressKeyboard = showInAppCamera,
|
||||||
suppressKeyboard =
|
|
||||||
showInAppCamera,
|
|
||||||
hasNativeNavigationBar =
|
hasNativeNavigationBar =
|
||||||
hasNativeNavigationBar
|
hasNativeNavigationBar
|
||||||
)
|
)
|
||||||
@@ -4301,6 +4222,76 @@ fun ChatDetailScreen(
|
|||||||
} // Закрытие outer Box
|
} // Закрытие outer Box
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChatInputBarSection(
|
||||||
|
viewModel: ChatViewModel,
|
||||||
|
isSavedMessages: Boolean,
|
||||||
|
onSend: () -> Unit,
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
backgroundColor: Color,
|
||||||
|
textColor: Color,
|
||||||
|
secondaryTextColor: Color,
|
||||||
|
replyMessages: List<ChatViewModel.ReplyMessage>,
|
||||||
|
isForwardMode: Boolean,
|
||||||
|
onCloseReply: () -> Unit,
|
||||||
|
onShowForwardOptions: (List<ChatViewModel.ReplyMessage>) -> Unit,
|
||||||
|
chatTitle: String,
|
||||||
|
isBlocked: Boolean,
|
||||||
|
showEmojiPicker: Boolean,
|
||||||
|
onToggleEmojiPicker: (Boolean) -> Unit,
|
||||||
|
coordinator: KeyboardTransitionCoordinator,
|
||||||
|
displayReplyMessages: List<ChatViewModel.ReplyMessage>,
|
||||||
|
onReplyClick: (String) -> Unit,
|
||||||
|
onAttachClick: () -> Unit,
|
||||||
|
myPublicKey: String,
|
||||||
|
opponentPublicKey: String,
|
||||||
|
myPrivateKey: String,
|
||||||
|
isGroupChat: Boolean,
|
||||||
|
mentionCandidates: List<MentionCandidate>,
|
||||||
|
avatarRepository: AvatarRepository?,
|
||||||
|
inputFocusTrigger: Int,
|
||||||
|
suppressKeyboard: Boolean,
|
||||||
|
hasNativeNavigationBar: Boolean
|
||||||
|
) {
|
||||||
|
val inputText by viewModel.inputText.collectAsState()
|
||||||
|
|
||||||
|
MessageInputBar(
|
||||||
|
value = inputText,
|
||||||
|
onValueChange = {
|
||||||
|
viewModel.updateInputText(it)
|
||||||
|
if (it.isNotEmpty() && !isSavedMessages) {
|
||||||
|
viewModel.sendTypingIndicator()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSend = onSend,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
backgroundColor = backgroundColor,
|
||||||
|
textColor = textColor,
|
||||||
|
secondaryTextColor = secondaryTextColor,
|
||||||
|
replyMessages = replyMessages,
|
||||||
|
isForwardMode = isForwardMode,
|
||||||
|
onCloseReply = onCloseReply,
|
||||||
|
onShowForwardOptions = onShowForwardOptions,
|
||||||
|
chatTitle = chatTitle,
|
||||||
|
isBlocked = isBlocked,
|
||||||
|
showEmojiPicker = showEmojiPicker,
|
||||||
|
onToggleEmojiPicker = onToggleEmojiPicker,
|
||||||
|
coordinator = coordinator,
|
||||||
|
displayReplyMessages = displayReplyMessages,
|
||||||
|
onReplyClick = onReplyClick,
|
||||||
|
onAttachClick = onAttachClick,
|
||||||
|
myPublicKey = myPublicKey,
|
||||||
|
opponentPublicKey = opponentPublicKey,
|
||||||
|
myPrivateKey = myPrivateKey,
|
||||||
|
isGroupChat = isGroupChat,
|
||||||
|
mentionCandidates = mentionCandidates,
|
||||||
|
avatarRepository = avatarRepository,
|
||||||
|
inputFocusTrigger = inputFocusTrigger,
|
||||||
|
suppressKeyboard = suppressKeyboard,
|
||||||
|
hasNativeNavigationBar = hasNativeNavigationBar
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun GroupMembersSubtitleSkeleton() {
|
private fun GroupMembersSubtitleSkeleton() {
|
||||||
val transition = rememberInfiniteTransition(label = "groupMembersSkeleton")
|
val transition = rememberInfiniteTransition(label = "groupMembersSkeleton")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -249,24 +264,19 @@ fun ChatsListScreen(
|
|||||||
accountName: String,
|
accountName: String,
|
||||||
accountUsername: String,
|
accountUsername: String,
|
||||||
accountVerified: Int = 0,
|
accountVerified: Int = 0,
|
||||||
accountPhone: String,
|
|
||||||
accountPublicKey: String,
|
accountPublicKey: String,
|
||||||
accountPrivateKey: String = "",
|
accountPrivateKey: String = "",
|
||||||
privateKeyHash: String = "",
|
|
||||||
onToggleTheme: () -> Unit,
|
onToggleTheme: () -> Unit,
|
||||||
onProfileClick: () -> Unit,
|
onProfileClick: () -> Unit,
|
||||||
onNewGroupClick: () -> Unit,
|
onNewGroupClick: () -> Unit,
|
||||||
onContactsClick: () -> Unit,
|
|
||||||
onCallsClick: () -> Unit,
|
onCallsClick: () -> Unit,
|
||||||
onSavedMessagesClick: () -> Unit,
|
onSavedMessagesClick: () -> Unit,
|
||||||
onSettingsClick: () -> Unit,
|
onSettingsClick: () -> Unit,
|
||||||
onInviteFriendsClick: () -> Unit,
|
|
||||||
onSearchClick: () -> Unit,
|
onSearchClick: () -> Unit,
|
||||||
onRequestsClick: () -> Unit = {},
|
onRequestsClick: () -> Unit = {},
|
||||||
onNewChat: () -> Unit,
|
onNewChat: () -> Unit,
|
||||||
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
|
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
|
||||||
onStartCall: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
|
onStartCall: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
|
||||||
backgroundBlurColorId: String = "avatar",
|
|
||||||
pinnedChats: Set<String> = emptySet(),
|
pinnedChats: Set<String> = emptySet(),
|
||||||
onTogglePin: (String) -> Unit = {},
|
onTogglePin: (String) -> Unit = {},
|
||||||
chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
|
chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
|
||||||
@@ -435,6 +445,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) {
|
||||||
@@ -446,7 +457,6 @@ fun ChatsListScreen(
|
|||||||
|
|
||||||
// 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих
|
// 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих
|
||||||
// сообщений
|
// сообщений
|
||||||
val initStart = System.currentTimeMillis()
|
|
||||||
ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey)
|
ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey)
|
||||||
android.util.Log.d(
|
android.util.Log.d(
|
||||||
"ChatsListScreen",
|
"ChatsListScreen",
|
||||||
@@ -553,10 +563,6 @@ fun ChatsListScreen(
|
|||||||
allAccounts = accounts.sortedByDescending { it.publicKey == accountPublicKey }
|
allAccounts = accounts.sortedByDescending { it.publicKey == accountPublicKey }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 Используем rememberSaveable чтобы сохранить состояние при навигации
|
|
||||||
// Header сразу visible = true, без анимации при возврате из чата
|
|
||||||
var visible by rememberSaveable { mutableStateOf(true) }
|
|
||||||
|
|
||||||
// Confirmation dialogs state
|
// Confirmation dialogs state
|
||||||
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
|
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
|
||||||
var dialogToLeave by remember { mutableStateOf<DialogUiModel?>(null) }
|
var dialogToLeave by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||||
@@ -778,13 +784,6 @@ fun ChatsListScreen(
|
|||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// 🎨 DRAWER HEADER
|
// 🎨 DRAWER HEADER
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
val avatarColors =
|
|
||||||
getAvatarColor(
|
|
||||||
accountPublicKey,
|
|
||||||
isDarkTheme
|
|
||||||
)
|
|
||||||
val headerColor = avatarColors.backgroundColor
|
|
||||||
|
|
||||||
// Header: цвет шапки сайдбара
|
// Header: цвет шапки сайдбара
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
Box(
|
Box(
|
||||||
@@ -807,23 +806,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 +919,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 +2466,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 +2572,10 @@ fun ChatsListScreen(
|
|||||||
isDarkTheme,
|
isDarkTheme,
|
||||||
isTyping =
|
isTyping =
|
||||||
isTyping,
|
isTyping,
|
||||||
|
typingDisplayName =
|
||||||
|
typingDisplayName,
|
||||||
|
typingSenderPublicKey =
|
||||||
|
typingSenderPublicKey,
|
||||||
isBlocked =
|
isBlocked =
|
||||||
isBlocked,
|
isBlocked,
|
||||||
isSavedMessages =
|
isSavedMessages =
|
||||||
@@ -3387,9 +3445,6 @@ fun ChatItem(
|
|||||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||||
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
|
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
|
||||||
|
|
||||||
val avatarColors = getAvatarColor(chat.publicKey, isDarkTheme)
|
|
||||||
val avatarText = getAvatarText(chat.publicKey)
|
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -3627,6 +3682,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,
|
||||||
@@ -3862,13 +3919,11 @@ fun SwipeableDialogItem(
|
|||||||
velocityTracker.resetTracking()
|
velocityTracker.resetTracking()
|
||||||
var totalDragX = 0f
|
var totalDragX = 0f
|
||||||
var totalDragY = 0f
|
var totalDragY = 0f
|
||||||
var passedSlop = false
|
|
||||||
var claimed = false
|
var claimed = false
|
||||||
|
|
||||||
// Phase 1: Determine gesture type (tap / long-press / drag)
|
// Phase 1: Determine gesture type (tap / long-press / drag)
|
||||||
// Wait up to longPressTimeout; if no up or slop → long press
|
// Wait up to longPressTimeout; if no up or slop → long press
|
||||||
var gestureType = "unknown"
|
var gestureType = "unknown"
|
||||||
var fingerIsUp = false
|
|
||||||
|
|
||||||
val result = withTimeoutOrNull(longPressTimeoutMs) {
|
val result = withTimeoutOrNull(longPressTimeoutMs) {
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -3905,19 +3960,17 @@ fun SwipeableDialogItem(
|
|||||||
// Timeout → check if finger lifted during the race window
|
// Timeout → check if finger lifted during the race window
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
// Grace period: check if up event arrived just as timeout fired
|
// Grace period: check if up event arrived just as timeout fired
|
||||||
val graceResult = withTimeoutOrNull(32L) {
|
withTimeoutOrNull(32L) {
|
||||||
while (true) {
|
while (true) {
|
||||||
val event = awaitPointerEvent()
|
val event = awaitPointerEvent()
|
||||||
val change = event.changes.firstOrNull { it.id == down.id }
|
val change = event.changes.firstOrNull { it.id == down.id }
|
||||||
if (change == null) {
|
if (change == null) {
|
||||||
gestureType = "cancelled"
|
gestureType = "cancelled"
|
||||||
fingerIsUp = true
|
|
||||||
return@withTimeoutOrNull Unit
|
return@withTimeoutOrNull Unit
|
||||||
}
|
}
|
||||||
if (change.changedToUpIgnoreConsumed()) {
|
if (change.changedToUpIgnoreConsumed()) {
|
||||||
change.consume()
|
change.consume()
|
||||||
gestureType = "tap"
|
gestureType = "tap"
|
||||||
fingerIsUp = true
|
|
||||||
return@withTimeoutOrNull Unit
|
return@withTimeoutOrNull Unit
|
||||||
}
|
}
|
||||||
// Still moving/holding — it's a real long press
|
// Still moving/holding — it's a real long press
|
||||||
@@ -3957,13 +4010,11 @@ fun SwipeableDialogItem(
|
|||||||
when {
|
when {
|
||||||
// Horizontal left swipe — reveal action buttons
|
// Horizontal left swipe — reveal action buttons
|
||||||
currentSwipeEnabled && dominated && totalDragX < 0 -> {
|
currentSwipeEnabled && dominated && totalDragX < 0 -> {
|
||||||
passedSlop = true
|
|
||||||
claimed = true
|
claimed = true
|
||||||
currentOnSwipeStarted()
|
currentOnSwipeStarted()
|
||||||
}
|
}
|
||||||
// Horizontal right swipe with buttons open — close them
|
// Horizontal right swipe with buttons open — close them
|
||||||
dominated && totalDragX > 0 && offsetX != 0f -> {
|
dominated && totalDragX > 0 && offsetX != 0f -> {
|
||||||
passedSlop = true
|
|
||||||
claimed = true
|
claimed = true
|
||||||
}
|
}
|
||||||
// Right swipe with buttons closed — let drawer handle
|
// Right swipe with buttons closed — let drawer handle
|
||||||
@@ -4034,6 +4085,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 +4104,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,
|
||||||
@@ -4063,10 +4118,6 @@ fun DialogItemContent(
|
|||||||
val secondaryTextColor =
|
val secondaryTextColor =
|
||||||
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) }
|
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) }
|
||||||
|
|
||||||
val avatarColors =
|
|
||||||
remember(dialog.opponentKey, isDarkTheme) {
|
|
||||||
getAvatarColor(dialog.opponentKey, isDarkTheme)
|
|
||||||
}
|
|
||||||
val isGroupDialog = remember(dialog.opponentKey) { isGroupDialogKey(dialog.opponentKey) }
|
val isGroupDialog = remember(dialog.opponentKey) { isGroupDialogKey(dialog.opponentKey) }
|
||||||
|
|
||||||
// 📁 Для Saved Messages показываем специальное имя
|
// 📁 Для Saved Messages показываем специальное имя
|
||||||
@@ -4101,38 +4152,6 @@ fun DialogItemContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 📁 Для Saved Messages показываем иконку закладки
|
|
||||||
// 🔥 Как в Архиве: инициалы из title или username или DELETED
|
|
||||||
val initials =
|
|
||||||
remember(
|
|
||||||
dialog.opponentTitle,
|
|
||||||
dialog.opponentUsername,
|
|
||||||
dialog.opponentKey,
|
|
||||||
dialog.isSavedMessages
|
|
||||||
) {
|
|
||||||
if (dialog.isSavedMessages) {
|
|
||||||
"" // Для Saved Messages - пустая строка, будет использоваться
|
|
||||||
// иконка
|
|
||||||
} else if (dialog.opponentTitle.isNotEmpty() &&
|
|
||||||
dialog.opponentTitle != dialog.opponentKey &&
|
|
||||||
dialog.opponentTitle != dialog.opponentKey.take(7) &&
|
|
||||||
dialog.opponentTitle != dialog.opponentKey.take(8)
|
|
||||||
) {
|
|
||||||
// Используем title для инициалов
|
|
||||||
dialog.opponentTitle
|
|
||||||
.split(" ")
|
|
||||||
.take(2)
|
|
||||||
.mapNotNull { it.firstOrNull()?.uppercase() }
|
|
||||||
.joinToString("")
|
|
||||||
.ifEmpty { dialog.opponentTitle.take(2).uppercase() }
|
|
||||||
} else if (dialog.opponentUsername.isNotEmpty()) {
|
|
||||||
// Если только username - берем первые 2 символа
|
|
||||||
dialog.opponentUsername.take(2).uppercase()
|
|
||||||
} else {
|
|
||||||
dialog.opponentKey.take(2).uppercase()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
@@ -4245,13 +4264,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 +4472,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 +4510,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 +4731,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 +4768,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 +4809,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()
|
||||||
@@ -4882,7 +4941,6 @@ private fun RequestsRouteContent(
|
|||||||
RequestsScreen(
|
RequestsScreen(
|
||||||
requests = requests,
|
requests = requests,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
onBack = onBack,
|
|
||||||
onRequestClick = onRequestClick,
|
onRequestClick = onRequestClick,
|
||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
blockedUsers = blockedUsers,
|
blockedUsers = blockedUsers,
|
||||||
@@ -5017,7 +5075,6 @@ fun RequestsSection(
|
|||||||
fun RequestsScreen(
|
fun RequestsScreen(
|
||||||
requests: List<DialogUiModel>,
|
requests: List<DialogUiModel>,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
onBack: () -> Unit,
|
|
||||||
onRequestClick: (DialogUiModel) -> Unit,
|
onRequestClick: (DialogUiModel) -> Unit,
|
||||||
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
||||||
blockedUsers: Set<String> = emptySet(),
|
blockedUsers: Set<String> = emptySet(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ fun RequestsListScreen(
|
|||||||
RequestsScreen(
|
RequestsScreen(
|
||||||
requests = requests,
|
requests = requests,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
onBack = onBack,
|
|
||||||
onRequestClick = { request ->
|
onRequestClick = { request ->
|
||||||
onUserSelect(chatsViewModel.dialogToSearchUser(request))
|
onUserSelect(chatsViewModel.dialogToSearchUser(request))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
@@ -56,6 +55,7 @@ import com.rosetta.messenger.ui.chats.components.*
|
|||||||
import com.rosetta.messenger.ui.chats.utils.*
|
import com.rosetta.messenger.ui.chats.utils.*
|
||||||
import com.rosetta.messenger.ui.chats.ChatViewModel
|
import com.rosetta.messenger.ui.chats.ChatViewModel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@@ -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(
|
||||||
@@ -87,7 +89,6 @@ fun MessageInputBar(
|
|||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
backgroundColor: Color,
|
backgroundColor: Color,
|
||||||
textColor: Color,
|
textColor: Color,
|
||||||
placeholderColor: Color,
|
|
||||||
secondaryTextColor: Color,
|
secondaryTextColor: Color,
|
||||||
replyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
|
replyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
|
||||||
isForwardMode: Boolean = false,
|
isForwardMode: Boolean = false,
|
||||||
@@ -97,7 +98,6 @@ fun MessageInputBar(
|
|||||||
isBlocked: Boolean = false,
|
isBlocked: Boolean = false,
|
||||||
showEmojiPicker: Boolean = false,
|
showEmojiPicker: Boolean = false,
|
||||||
onToggleEmojiPicker: (Boolean) -> Unit = {},
|
onToggleEmojiPicker: (Boolean) -> Unit = {},
|
||||||
focusRequester: FocusRequester? = null,
|
|
||||||
coordinator: KeyboardTransitionCoordinator,
|
coordinator: KeyboardTransitionCoordinator,
|
||||||
displayReplyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
|
displayReplyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
|
||||||
onReplyClick: (String) -> Unit = {},
|
onReplyClick: (String) -> Unit = {},
|
||||||
@@ -187,7 +187,9 @@ fun MessageInputBar(
|
|||||||
|
|
||||||
// Update coordinator through snapshotFlow (no recomposition)
|
// Update coordinator through snapshotFlow (no recomposition)
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
snapshotFlow { with(density) { imeInsets.getBottom(density).toDp() } }.collect { currentImeHeight ->
|
snapshotFlow { with(density) { imeInsets.getBottom(density).toDp() } }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { currentImeHeight ->
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val heightChanged = kotlin.math.abs((currentImeHeight - lastStableKeyboardHeight).value) > 5f
|
val heightChanged = kotlin.math.abs((currentImeHeight - lastStableKeyboardHeight).value) > 5f
|
||||||
if (heightChanged && currentImeHeight.value > 0) {
|
if (heightChanged && currentImeHeight.value > 0) {
|
||||||
@@ -200,12 +202,16 @@ fun MessageInputBar(
|
|||||||
|
|
||||||
isKeyboardVisible = currentImeHeight > 50.dp
|
isKeyboardVisible = currentImeHeight > 50.dp
|
||||||
coordinator.updateKeyboardHeight(currentImeHeight)
|
coordinator.updateKeyboardHeight(currentImeHeight)
|
||||||
if (currentImeHeight > 100.dp) {
|
// Update "stable" height only after IME animation settles,
|
||||||
|
// otherwise low-end devices get many unnecessary recompositions.
|
||||||
|
if (!isKeyboardAnimating && currentImeHeight > 100.dp) {
|
||||||
coordinator.syncHeights()
|
coordinator.syncHeights()
|
||||||
|
if (kotlin.math.abs((currentImeHeight - lastStableKeyboardHeight).value) > 2f) {
|
||||||
lastStableKeyboardHeight = currentImeHeight
|
lastStableKeyboardHeight = currentImeHeight
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load saved keyboard height
|
// Load saved keyboard height
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@@ -232,7 +238,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 +256,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 +285,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) {
|
||||||
|
|||||||
@@ -357,6 +357,15 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
val textStr = editable.toString()
|
val textStr = editable.toString()
|
||||||
|
val hasEmojiHints = containsEmojiHints(textStr)
|
||||||
|
if (!hasEmojiHints) {
|
||||||
|
// Fast path for plain text: skip heavy grapheme/asset pipeline.
|
||||||
|
// Also drop stale spans if user removed emoji content.
|
||||||
|
editable.getSpans(0, editable.length, ImageSpan::class.java).forEach {
|
||||||
|
editable.removeSpan(it)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
val cursorPosition = selectionStart
|
val cursorPosition = selectionStart
|
||||||
|
|
||||||
// 🔥 Собираем все позиции эмодзи (и Unicode, и :emoji_code:)
|
// 🔥 Собираем все позиции эмодзи (и Unicode, и :emoji_code:)
|
||||||
@@ -430,6 +439,15 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun containsEmojiHints(text: String): Boolean {
|
||||||
|
if (text.isEmpty()) return false
|
||||||
|
if (text.indexOf(":emoji_") >= 0) return true
|
||||||
|
for (ch in text) {
|
||||||
|
if (Character.isSurrogate(ch) || ch == '\u200D' || ch == '\uFE0F') return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadFromAssets(unified: String): Bitmap? {
|
private fun loadFromAssets(unified: String): Bitmap? {
|
||||||
return try {
|
return try {
|
||||||
val inputStream = getContext().assets.open("emoji/$unified.png")
|
val inputStream = getContext().assets.open("emoji/$unified.png")
|
||||||
@@ -592,10 +610,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 +643,7 @@ fun AppleEmojiText(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
view.setTextWithEmojisIfNeeded(text)
|
||||||
},
|
},
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
@@ -644,6 +663,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 +688,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 +823,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 +912,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 +1088,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