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
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val rosettaVersionName = "1.4.4"
|
||||
val rosettaVersionCode = 46 // Increment on each release
|
||||
val rosettaVersionName = "1.4.5"
|
||||
val rosettaVersionCode = 47 // Increment on each release
|
||||
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
||||
|
||||
android {
|
||||
|
||||
@@ -81,22 +81,29 @@ class IncomingCallActivity : ComponentActivity() {
|
||||
LaunchedEffect(callState.phase) {
|
||||
callLog("phase changed: ${callState.phase}")
|
||||
if (callState.phase == CallPhase.INCOMING) wasIncoming = true
|
||||
// Закрываем только если звонок реально начался и потом завершился
|
||||
// Закрываем только когда звонок завершился
|
||||
if (callState.phase == CallPhase.IDLE && wasIncoming) {
|
||||
callLog("IDLE after INCOMING → finish()")
|
||||
finish()
|
||||
} else if (callState.phase == CallPhase.CONNECTING ||
|
||||
callState.phase == CallPhase.ACTIVE) {
|
||||
callLog("${callState.phase} → openMainActivity + finish")
|
||||
openMainActivity()
|
||||
finish()
|
||||
}
|
||||
// НЕ закрываемся при CONNECTING/ACTIVE — остаёмся на экране звонка
|
||||
// IncomingCallActivity показывает полный CallOverlay, не нужно переходить в MainActivity
|
||||
}
|
||||
|
||||
// Показываем INCOMING даже если CallManager ещё в IDLE (push раньше WebSocket)
|
||||
val displayState = if (callState.phase == CallPhase.IDLE) {
|
||||
// Показываем INCOMING в IDLE только до первого реального входящего состояния.
|
||||
// Иначе после Decline/END на мгновение мелькает "Unknown".
|
||||
val shouldShowProvisionalIncoming =
|
||||
callState.phase == CallPhase.IDLE &&
|
||||
!wasIncoming &&
|
||||
(callState.peerPublicKey.isNotBlank() ||
|
||||
callState.peerTitle.isNotBlank() ||
|
||||
callState.peerUsername.isNotBlank())
|
||||
|
||||
val displayState = if (shouldShowProvisionalIncoming) {
|
||||
callState.copy(phase = CallPhase.INCOMING, statusText = "Incoming call...")
|
||||
} else callState
|
||||
} else {
|
||||
callState
|
||||
}
|
||||
|
||||
RosettaAndroidTheme(darkTheme = true) {
|
||||
CallOverlay(
|
||||
@@ -108,16 +115,10 @@ class IncomingCallActivity : ComponentActivity() {
|
||||
if (callState.phase == CallPhase.INCOMING) {
|
||||
val result = CallManager.acceptIncomingCall()
|
||||
callLog("acceptIncomingCall result=$result")
|
||||
if (result == CallActionResult.STARTED) {
|
||||
openMainActivity()
|
||||
finish()
|
||||
}
|
||||
// Остаёмся на IncomingCallActivity — она покажет CONNECTING → ACTIVE
|
||||
} else {
|
||||
callLog("onAccept: phase not INCOMING yet, waiting...")
|
||||
// WebSocket ещё не доставил CALL — открываем MainActivity,
|
||||
// она подождёт и примет звонок
|
||||
openMainActivity()
|
||||
finish()
|
||||
callLog("onAccept: phase=${callState.phase}, trying accept anyway")
|
||||
CallManager.acceptIncomingCall()
|
||||
}
|
||||
},
|
||||
onDecline = {
|
||||
|
||||
@@ -1122,18 +1122,13 @@ fun MainScreen(
|
||||
accountName = accountName,
|
||||
accountUsername = accountUsername,
|
||||
accountVerified = accountVerified,
|
||||
accountPhone = accountPhone,
|
||||
accountPublicKey = accountPublicKey,
|
||||
accountPrivateKey = accountPrivateKey,
|
||||
privateKeyHash = privateKeyHash,
|
||||
onToggleTheme = onToggleTheme,
|
||||
onProfileClick = { pushScreen(Screen.Profile) },
|
||||
onNewGroupClick = {
|
||||
pushScreen(Screen.GroupSetup)
|
||||
},
|
||||
onContactsClick = {
|
||||
// TODO: Navigate to contacts
|
||||
},
|
||||
onCallsClick = {
|
||||
// TODO: Navigate to calls
|
||||
},
|
||||
@@ -1152,9 +1147,6 @@ fun MainScreen(
|
||||
)
|
||||
},
|
||||
onSettingsClick = { pushScreen(Screen.Profile) },
|
||||
onInviteFriendsClick = {
|
||||
// TODO: Share invite link
|
||||
},
|
||||
onSearchClick = { pushScreen(Screen.Search) },
|
||||
onRequestsClick = { pushScreen(Screen.Requests) },
|
||||
onNewChat = {
|
||||
@@ -1166,7 +1158,6 @@ fun MainScreen(
|
||||
onStartCall = { user ->
|
||||
startCallWithPermission(user)
|
||||
},
|
||||
backgroundBlurColorId = backgroundBlurColorId,
|
||||
pinnedChats = pinnedChats,
|
||||
onTogglePin = { opponentKey ->
|
||||
mainScreenScope.launch { prefsManager.togglePinChat(opponentKey) }
|
||||
|
||||
@@ -46,19 +46,24 @@ object DraftManager {
|
||||
fun saveDraft(opponentKey: String, text: String) {
|
||||
if (currentAccount.isEmpty()) return
|
||||
|
||||
val trimmed = text.trim()
|
||||
val currentDrafts = _drafts.value.toMutableMap()
|
||||
val hasContent = text.any { !it.isWhitespace() }
|
||||
val existing = _drafts.value[opponentKey]
|
||||
|
||||
if (trimmed.isEmpty()) {
|
||||
if (!hasContent) {
|
||||
if (existing == null) return
|
||||
val currentDrafts = _drafts.value.toMutableMap()
|
||||
// Удаляем черновик если текст пустой
|
||||
currentDrafts.remove(opponentKey)
|
||||
prefs?.edit()?.remove(prefKey(opponentKey))?.apply()
|
||||
_drafts.value = currentDrafts
|
||||
} else {
|
||||
currentDrafts[opponentKey] = trimmed
|
||||
prefs?.edit()?.putString(prefKey(opponentKey), trimmed)?.apply()
|
||||
// Ничего не делаем, если текст не изменился — это частый путь при больших вставках.
|
||||
if (existing == text) return
|
||||
val currentDrafts = _drafts.value.toMutableMap()
|
||||
currentDrafts[opponentKey] = text
|
||||
prefs?.edit()?.putString(prefKey(opponentKey), text)?.apply()
|
||||
_drafts.value = currentDrafts
|
||||
}
|
||||
|
||||
_drafts.value = currentDrafts
|
||||
}
|
||||
|
||||
/** Получить черновик для диалога */
|
||||
|
||||
@@ -18,15 +18,31 @@ object ReleaseNotes {
|
||||
Update v$VERSION_PLACEHOLDER
|
||||
|
||||
Звонки
|
||||
- Обновлён протокол WebRTC: publicKey и deviceId в каждом пакете
|
||||
- Полноэкранный входящий звонок на экране блокировки
|
||||
- Фикс бесконечного "Exchanging keys" при принятии звонка
|
||||
- Фикс краша ForegroundService при исходящем звонке
|
||||
- Кастомный WebRTC с E2EE теперь работает в CI-сборках
|
||||
- Полноэкранный входящий звонок (IncomingCallActivity) поверх экрана блокировки с кнопками Принять/Отклонить
|
||||
- Обновлён протокол WebRTC: publicKey и deviceId в каждом пакете (совместимость с новым сервером)
|
||||
- Звонок больше не сбрасывается при переподключении WebSocket
|
||||
- Исправлен бесконечный статус "Exchanging keys" — KEY_EXCHANGE отправляется с ретраем до 6 сек
|
||||
- Автоматическая привязка аккаунта при принятии звонка из 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()
|
||||
|
||||
fun getNotice(version: String): String =
|
||||
|
||||
@@ -48,13 +48,18 @@ class CallForegroundService : Service() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val action = intent?.action ?: ACTION_SYNC
|
||||
CallManager.initialize(applicationContext)
|
||||
notifLog("onStartCommand action=$action phase=${CallManager.state.value.phase}")
|
||||
val phaseNow = CallManager.state.value.phase
|
||||
notifLog("onStartCommand action=$action phase=$phaseNow")
|
||||
|
||||
when (action) {
|
||||
ACTION_STOP -> {
|
||||
notifLog("ACTION_STOP → stopSelf")
|
||||
safeStopForeground()
|
||||
return START_NOT_STICKY
|
||||
if (phaseNow == CallPhase.IDLE) {
|
||||
notifLog("ACTION_STOP → stopSelf")
|
||||
safeStopForeground()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
// Может прилететь поздний STOP от прошлой сессии, не глушим живой звонок.
|
||||
notifLog("ACTION_STOP ignored: phase=$phaseNow")
|
||||
}
|
||||
ACTION_END -> {
|
||||
notifLog("ACTION_END → endCall")
|
||||
|
||||
@@ -96,6 +96,7 @@ object CallManager {
|
||||
private const val PROTOCOL_LOG_TAIL_LINES = 180
|
||||
private const val MAX_LOG_PREFIX = 180
|
||||
private const val INCOMING_RING_TIMEOUT_MS = 45_000L
|
||||
private const val CONNECTING_TIMEOUT_MS = 30_000L
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val secureRandom = SecureRandom()
|
||||
@@ -125,6 +126,7 @@ object CallManager {
|
||||
private var protocolStateJob: Job? = null
|
||||
private var disconnectResetJob: Job? = null
|
||||
private var incomingRingTimeoutJob: Job? = null
|
||||
private var connectingTimeoutJob: Job? = null
|
||||
|
||||
private var signalWaiter: ((Packet) -> Unit)? = null
|
||||
private var webRtcWaiter: ((Packet) -> Unit)? = null
|
||||
@@ -146,6 +148,7 @@ object CallManager {
|
||||
private var lastLocalOfferFingerprint: String = ""
|
||||
private var e2eeRebindJob: Job? = null
|
||||
|
||||
@Volatile private var resetting = false
|
||||
private var iceServers: List<PeerConnection.IceServer> = emptyList()
|
||||
|
||||
fun initialize(context: Context) {
|
||||
@@ -173,7 +176,26 @@ object CallManager {
|
||||
ProtocolManager.requestIceServers()
|
||||
}
|
||||
ProtocolState.DISCONNECTED -> {
|
||||
resetSession(reason = "Disconnected", notifyPeer = false)
|
||||
// Не сбрасываем звонок при переподключении WebSocket —
|
||||
// push мог разбудить процесс и вызвать reconnect,
|
||||
// а звонок уже в INCOMING/CONNECTING
|
||||
val phase = _state.value.phase
|
||||
if (phase == CallPhase.IDLE) {
|
||||
val hasResidualSession =
|
||||
callSessionId.isNotBlank() ||
|
||||
roomId.isNotBlank() ||
|
||||
role != null ||
|
||||
_state.value.peerPublicKey.isNotBlank() ||
|
||||
sharedKeyBytes != null ||
|
||||
peerConnection != null
|
||||
if (hasResidualSession) {
|
||||
resetSession(reason = "Disconnected", notifyPeer = false)
|
||||
} else {
|
||||
breadcrumb("DISCONNECTED in IDLE — skip reset (no active session)")
|
||||
}
|
||||
} else {
|
||||
breadcrumb("DISCONNECTED but phase=$phase — keeping call alive")
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
@@ -288,6 +310,7 @@ object CallManager {
|
||||
statusText = "Connecting..."
|
||||
)
|
||||
}
|
||||
armConnectingTimeout("acceptIncomingCall")
|
||||
|
||||
// Отправляем KEY_EXCHANGE — если WebSocket не подключен, ждём и ретраим
|
||||
scope.launch {
|
||||
@@ -489,6 +512,7 @@ object CallManager {
|
||||
statusText = "Connecting..."
|
||||
)
|
||||
}
|
||||
armConnectingTimeout("signal:create_room")
|
||||
ensurePeerConnectionAndOffer()
|
||||
}
|
||||
SignalType.ACTIVE_CALL -> Unit
|
||||
@@ -549,6 +573,7 @@ object CallManager {
|
||||
createRoomSent = true
|
||||
}
|
||||
updateState { it.copy(phase = CallPhase.CONNECTING) }
|
||||
armConnectingTimeout("key_exchange:caller")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -565,6 +590,7 @@ object CallManager {
|
||||
setupE2EE(sharedKey)
|
||||
breadcrumb("KE: CALLEE — E2EE ready, waiting for CREATE_ROOM")
|
||||
updateState { it.copy(keyCast = sharedKey, phase = CallPhase.CONNECTING) }
|
||||
armConnectingTimeout("key_exchange:callee")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -844,6 +870,7 @@ object CallManager {
|
||||
}
|
||||
|
||||
private fun onCallConnected() {
|
||||
disarmConnectingTimeout("connected")
|
||||
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CONNECTED) }
|
||||
val keyFp = sharedKeyBytes?.fingerprintHex(8) ?: "null"
|
||||
breadcrumb("CONNECTED: e2eeAvail=$e2eeAvailable keyFp=$keyFp sEnc=${senderEncryptors.size} rDec=${receiverDecryptors.size} nativeLoaded=${XChaCha20E2EE.nativeLoaded}")
|
||||
@@ -862,6 +889,31 @@ object CallManager {
|
||||
}
|
||||
}
|
||||
|
||||
private fun armConnectingTimeout(origin: String) {
|
||||
connectingTimeoutJob?.cancel()
|
||||
connectingTimeoutJob =
|
||||
scope.launch {
|
||||
delay(CONNECTING_TIMEOUT_MS)
|
||||
val snapshot = _state.value
|
||||
if (snapshot.phase != CallPhase.CONNECTING) return@launch
|
||||
breadcrumb(
|
||||
"CONNECTING TIMEOUT origin=$origin role=$role room=${roomId.take(12)} " +
|
||||
"keyExSent=$keyExchangeSent createRoomSent=$createRoomSent offerSent=$offerSent " +
|
||||
"remoteDesc=$remoteDescriptionSet peer=${snapshot.peerPublicKey.take(8)}…"
|
||||
)
|
||||
resetSession(reason = "Connecting timeout", notifyPeer = false)
|
||||
}
|
||||
breadcrumb("CONNECTING watchdog armed origin=$origin timeoutMs=$CONNECTING_TIMEOUT_MS")
|
||||
}
|
||||
|
||||
private fun disarmConnectingTimeout(origin: String) {
|
||||
if (connectingTimeoutJob != null) {
|
||||
connectingTimeoutJob?.cancel()
|
||||
connectingTimeoutJob = null
|
||||
breadcrumb("CONNECTING watchdog disarmed origin=$origin")
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPeer(publicKey: String, title: String, username: String) {
|
||||
updateState {
|
||||
it.copy(
|
||||
@@ -920,6 +972,8 @@ object CallManager {
|
||||
}
|
||||
|
||||
private fun resetSession(reason: String?, notifyPeer: Boolean) {
|
||||
resetting = true
|
||||
disarmConnectingTimeout("resetSession")
|
||||
breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}")
|
||||
breadcrumbState("resetSession")
|
||||
val snapshot = _state.value
|
||||
@@ -932,6 +986,15 @@ object CallManager {
|
||||
dst = peerToNotify
|
||||
)
|
||||
}
|
||||
// Отменяем все jobs ПЕРВЫМИ — чтобы они не вызвали updateState с пустым state
|
||||
durationJob?.cancel()
|
||||
durationJob = null
|
||||
e2eeRebindJob?.cancel()
|
||||
e2eeRebindJob = null
|
||||
disconnectResetJob?.cancel()
|
||||
disconnectResetJob = null
|
||||
incomingRingTimeoutJob?.cancel()
|
||||
incomingRingTimeoutJob = null
|
||||
// Play end call sound, then stop all
|
||||
if (wasActive) {
|
||||
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.END_CALL) }
|
||||
@@ -956,22 +1019,15 @@ object CallManager {
|
||||
lastPeerSharedPublicHex = ""
|
||||
lastRemoteOfferFingerprint = ""
|
||||
lastLocalOfferFingerprint = ""
|
||||
e2eeRebindJob?.cancel()
|
||||
e2eeRebindJob = null
|
||||
localPrivateKey = null
|
||||
localPublicKey = null
|
||||
callSessionId = ""
|
||||
callStartedAtMs = 0L
|
||||
durationJob?.cancel()
|
||||
durationJob = null
|
||||
disconnectResetJob?.cancel()
|
||||
disconnectResetJob = null
|
||||
incomingRingTimeoutJob?.cancel()
|
||||
incomingRingTimeoutJob = null
|
||||
setSpeakerphone(false)
|
||||
// Останавливаем ForegroundService ДО сброса state — иначе "Unknown" мелькает
|
||||
// Останавливаем ForegroundService и сбрасываем state
|
||||
appContext?.let { CallForegroundService.stop(it) }
|
||||
_state.value = CallUiState()
|
||||
resetting = false
|
||||
}
|
||||
|
||||
private fun resetRtcObjects() {
|
||||
@@ -1440,11 +1496,11 @@ object CallManager {
|
||||
}
|
||||
|
||||
private fun updateState(reducer: (CallUiState) -> CallUiState) {
|
||||
if (resetting) return // Не синхронизируем во время resetSession — иначе "Unknown" мелькает
|
||||
val old = _state.value
|
||||
_state.update(reducer)
|
||||
val newState = _state.value
|
||||
// Синхронизируем ForegroundService при смене фазы или имени
|
||||
// Не синхронизируем при IDLE — resetSession уже вызывает CallForegroundService.stop()
|
||||
if (newState.phase != CallPhase.IDLE &&
|
||||
(newState.phase != old.phase || newState.displayName != old.displayName)) {
|
||||
appContext?.let { ctx ->
|
||||
|
||||
@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import okhttp3.*
|
||||
import okio.ByteString
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
@@ -36,12 +37,131 @@ class Protocol(
|
||||
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
|
||||
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
|
||||
private const val HEARTBEAT_OK_LOG_THROTTLE_MS = 30_000L
|
||||
private const val HEX_PREVIEW_BYTES = 64
|
||||
private const val TEXT_PREVIEW_CHARS = 80
|
||||
}
|
||||
|
||||
private fun log(message: String) {
|
||||
// TEMPORARY: Enable logging for debugging PacketUserInfo
|
||||
logger(message)
|
||||
}
|
||||
|
||||
private fun packetName(packetId: Int): String =
|
||||
when (packetId) {
|
||||
0x00 -> "HANDSHAKE"
|
||||
0x01 -> "USER_INFO"
|
||||
0x02 -> "RESULT"
|
||||
0x03 -> "SEARCH"
|
||||
0x04 -> "ONLINE_SUBSCRIBE"
|
||||
0x05 -> "ONLINE_STATE"
|
||||
0x06 -> "MESSAGE"
|
||||
0x07 -> "READ"
|
||||
0x08 -> "DELIVERY"
|
||||
0x09 -> "DEVICE_NEW"
|
||||
0x0A -> "REQUEST_UPDATE"
|
||||
0x0B -> "TYPING"
|
||||
0x0F -> "REQUEST_TRANSPORT"
|
||||
0x10 -> "PUSH_NOTIFICATION"
|
||||
0x11 -> "GROUP_CREATE"
|
||||
0x12 -> "GROUP_INFO"
|
||||
0x13 -> "GROUP_INVITE_INFO"
|
||||
0x14 -> "GROUP_JOIN"
|
||||
0x15 -> "GROUP_LEAVE"
|
||||
0x16 -> "GROUP_BAN"
|
||||
0x17 -> "DEVICE_LIST"
|
||||
0x18 -> "DEVICE_RESOLVE"
|
||||
0x19 -> "SYNC"
|
||||
0x1A -> "SIGNAL_PEER"
|
||||
0x1B -> "WEBRTC"
|
||||
0x1C -> "ICE_SERVERS"
|
||||
else -> "UNKNOWN"
|
||||
}
|
||||
|
||||
private fun shortKey(value: String, visible: Int = 8): String {
|
||||
val raw = value.trim()
|
||||
if (raw.isBlank()) return "<empty>"
|
||||
if (raw.length <= visible) return raw
|
||||
return "${raw.take(visible)}…"
|
||||
}
|
||||
|
||||
private fun shortText(value: String, limit: Int = TEXT_PREVIEW_CHARS): String {
|
||||
val normalized = value.replace('\n', ' ').replace('\r', ' ').trim()
|
||||
if (normalized.isBlank()) return "<empty>"
|
||||
return if (normalized.length <= limit) normalized else "${normalized.take(limit)}…"
|
||||
}
|
||||
|
||||
private fun hexPreview(bytes: ByteArray, limit: Int = HEX_PREVIEW_BYTES): String {
|
||||
return bytes
|
||||
.take(limit)
|
||||
.joinToString(" ") { b -> String.format(Locale.US, "%02X", b.toInt() and 0xFF) } +
|
||||
if (bytes.size > limit) " …" else ""
|
||||
}
|
||||
|
||||
private fun packetSummary(packet: Packet): String {
|
||||
return when (packet) {
|
||||
is PacketHandshake ->
|
||||
"state=${packet.handshakeState} proto=${packet.protocolVersion} hb=${packet.heartbeatInterval}s " +
|
||||
"pub=${shortKey(packet.publicKey, 12)} privLen=${packet.privateKey.length} " +
|
||||
"deviceId=${shortKey(packet.device.deviceId, 12)} device='${shortText(packet.device.deviceName, 40)}' " +
|
||||
"os='${shortText(packet.device.deviceOs, 32)}'"
|
||||
is PacketResult ->
|
||||
"code=${packet.resultCode}"
|
||||
is PacketSearch ->
|
||||
"query='${shortText(packet.search, 48)}' users=${packet.users.size} privLen=${packet.privateKey.length}"
|
||||
is PacketOnlineSubscribe ->
|
||||
"keys=${packet.publicKeys.size} first=${packet.publicKeys.firstOrNull()?.let { shortKey(it) } ?: "<none>"} " +
|
||||
"privLen=${packet.privateKey.length}"
|
||||
is PacketOnlineState ->
|
||||
"entries=${packet.publicKeysState.size} first=${packet.publicKeysState.firstOrNull()?.let { "${shortKey(it.publicKey)}:${it.state}" } ?: "<none>"}"
|
||||
is PacketMessage ->
|
||||
"id=${shortKey(packet.messageId, 12)} from=${shortKey(packet.fromPublicKey)} to=${shortKey(packet.toPublicKey)} " +
|
||||
"ts=${packet.timestamp} contentLen=${packet.content.length} chachaLen=${packet.chachaKey.length} aesLen=${packet.aesChachaKey.length} " +
|
||||
"att=${packet.attachments.size}" +
|
||||
if (packet.attachments.isNotEmpty()) {
|
||||
" attTypes=${packet.attachments.joinToString(",") { it.type.name }}"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
is PacketRead ->
|
||||
"from=${shortKey(packet.fromPublicKey)} to=${shortKey(packet.toPublicKey)} privLen=${packet.privateKey.length}"
|
||||
is PacketDelivery ->
|
||||
"id=${shortKey(packet.messageId, 12)} to=${shortKey(packet.toPublicKey)}"
|
||||
is PacketTyping ->
|
||||
"from=${shortKey(packet.fromPublicKey)} to=${shortKey(packet.toPublicKey)} privLen=${packet.privateKey.length}"
|
||||
is PacketPushNotification ->
|
||||
"action=${packet.action} tokenType=${packet.tokenType} tokenLen=${packet.notificationsToken.length} " +
|
||||
"deviceId=${shortKey(packet.deviceId, 12)}"
|
||||
is PacketSync ->
|
||||
"status=${packet.status} ts=${packet.timestamp}"
|
||||
is PacketSignalPeer ->
|
||||
"type=${packet.signalType} src=${shortKey(packet.src)} dst=${shortKey(packet.dst)} " +
|
||||
"sharedLen=${packet.sharedPublic.length} room=${shortKey(packet.roomId, 12)}"
|
||||
is PacketWebRTC ->
|
||||
"type=${packet.signalType} sdpLen=${packet.sdpOrCandidate.length} " +
|
||||
"pk=${shortKey(packet.publicKey)} device=${shortKey(packet.deviceId, 12)} " +
|
||||
"preview='${shortText(packet.sdpOrCandidate, 64)}'"
|
||||
is PacketIceServers ->
|
||||
"count=${packet.iceServers.size} firstUrl='${packet.iceServers.firstOrNull()?.url?.let { shortText(it, 40) } ?: "<none>"}'"
|
||||
is PacketRequestTransport ->
|
||||
"requestTransport"
|
||||
is PacketDeviceResolve ->
|
||||
"deviceId=${shortKey(packet.deviceId, 12)} solution=${packet.solution}"
|
||||
is PacketDeviceList ->
|
||||
"devices=${packet.devices.size}"
|
||||
is PacketDeviceNew ->
|
||||
"ip='${shortText(packet.ipAddress, 32)}' deviceId=${shortKey(packet.device.deviceId, 12)} " +
|
||||
"device='${shortText(packet.device.deviceName, 32)}' os='${shortText(packet.device.deviceOs, 24)}'"
|
||||
is PacketUserInfo ->
|
||||
"username='${shortText(packet.username, 20)}' title='${shortText(packet.title, 24)}' privLen=${packet.privateKey.length}"
|
||||
else ->
|
||||
packet::class.java.simpleName
|
||||
}
|
||||
}
|
||||
|
||||
private fun describePacket(packet: Packet, bytes: Int): String {
|
||||
val packetId = packet.getPacketId()
|
||||
return "id=0x${packetId.toString(16).uppercase(Locale.ROOT)}(${packetId}) name=${packetName(packetId)} bytes=$bytes ${packetSummary(packet)}"
|
||||
}
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
@@ -486,12 +606,9 @@ class Protocol(
|
||||
private fun sendPacketDirect(packet: Packet) {
|
||||
val stream = packet.send()
|
||||
val data = stream.getStream()
|
||||
|
||||
log("📤 Sending packet: ${packet.getPacketId()} (${data.size} bytes)")
|
||||
|
||||
// Debug: log first 50 bytes as hex
|
||||
val hexDump = data.take(50).joinToString(" ") { String.format("%02X", it) }
|
||||
log(" Hex: $hexDump${if (data.size > 50) "..." else ""}")
|
||||
|
||||
log("➡️ CLIENT -> SERVER ${describePacket(packet, data.size)}")
|
||||
log(" TX_HEX: ${hexPreview(data)}")
|
||||
|
||||
val socket = webSocket
|
||||
if (socket == null) {
|
||||
@@ -504,16 +621,17 @@ class Protocol(
|
||||
try {
|
||||
val sent = socket.send(ByteString.of(*data))
|
||||
if (!sent) {
|
||||
log("❌ WebSocket rejected packet ${packet.getPacketId()}, re-queueing")
|
||||
log("❌ TX rejected by WebSocket for id=0x${packet.getPacketId().toString(16).uppercase(Locale.ROOT)}")
|
||||
log("📦 TX re-queue packet id=0x${packet.getPacketId().toString(16).uppercase(Locale.ROOT)}")
|
||||
packetQueue.add(packet)
|
||||
return
|
||||
}
|
||||
log("✅ Packet ${packet.getPacketId()} sent successfully")
|
||||
log("✅ TX delivered id=0x${packet.getPacketId().toString(16).uppercase(Locale.ROOT)}")
|
||||
} catch (e: Exception) {
|
||||
log("❌ Exception sending packet ${packet.getPacketId()}: ${e.message}")
|
||||
log("❌ TX exception id=0x${packet.getPacketId().toString(16).uppercase(Locale.ROOT)} err=${e.message}")
|
||||
e.printStackTrace()
|
||||
// Как в Архиве - возвращаем пакет в очередь при ошибке отправки
|
||||
log("📦 Re-queueing packet ${packet.getPacketId()} due to send error")
|
||||
log("📦 TX re-queue packet id=0x${packet.getPacketId().toString(16).uppercase(Locale.ROOT)} due to send error")
|
||||
packetQueue.add(packet)
|
||||
}
|
||||
}
|
||||
@@ -530,9 +648,8 @@ class Protocol(
|
||||
|
||||
private fun handleMessage(data: ByteArray) {
|
||||
try {
|
||||
// Debug: log first 50 bytes as hex
|
||||
val hexDump = data.take(50).joinToString(" ") { String.format("%02X", it.toInt() and 0xFF) }
|
||||
log("📥 Received ${data.size} bytes: $hexDump${if (data.size > 50) "..." else ""}")
|
||||
log("⬅️ SERVER -> CLIENT rawBytes=${data.size}")
|
||||
log(" RX_HEX: ${hexPreview(data)}")
|
||||
|
||||
val stream = Stream(data)
|
||||
if (stream.getRemainingBits() < MIN_PACKET_ID_BITS) {
|
||||
@@ -558,9 +675,15 @@ class Protocol(
|
||||
return
|
||||
}
|
||||
|
||||
log("⬅️ SERVER -> CLIENT ${describePacket(packet, data.size)}")
|
||||
val remainingBits = stream.getRemainingBits()
|
||||
if (remainingBits > 0) {
|
||||
log("⚠️ RX parser leftover bits for packet id=0x${packetId.toString(16).uppercase(Locale.ROOT)}: $remainingBits")
|
||||
}
|
||||
|
||||
// Notify waiters
|
||||
val waitersCount = packetWaiters[packetId]?.size ?: 0
|
||||
log("📥 Notifying $waitersCount waiter(s) for packet $packetId")
|
||||
log("📥 RX dispatch packet id=0x${packetId.toString(16).uppercase(Locale.ROOT)} waiters=$waitersCount")
|
||||
|
||||
packetWaiters[packetId]?.forEach { callback ->
|
||||
try {
|
||||
|
||||
@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.File
|
||||
import java.security.SecureRandom
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -33,6 +34,9 @@ object ProtocolManager {
|
||||
private const val DEBUG_LOG_FLUSH_DELAY_MS = 60L
|
||||
private const val HEARTBEAT_OK_LOG_MIN_INTERVAL_MS = 5_000L
|
||||
private const val TYPING_INDICATOR_TIMEOUT_MS = 3_000L
|
||||
private const val PROTOCOL_TRACE_FILE_NAME = "protocol_wire_log.txt"
|
||||
private const val PROTOCOL_TRACE_MAX_BYTES = 2_000_000L
|
||||
private const val PROTOCOL_TRACE_KEEP_BYTES = 1_200_000
|
||||
private const val PACKET_SIGNAL_PEER = 0x1A
|
||||
private const val PACKET_WEB_RTC = 0x1B
|
||||
private const val PACKET_ICE_SERVERS = 0x1C
|
||||
@@ -61,6 +65,7 @@ object ProtocolManager {
|
||||
val debugLogs: StateFlow<List<String>> = _debugLogs.asStateFlow()
|
||||
private val debugLogsBuffer = ArrayDeque<String>(MAX_DEBUG_LOGS)
|
||||
private val debugLogsLock = Any()
|
||||
private val protocolTraceLock = Any()
|
||||
@Volatile private var debugFlushJob: Job? = null
|
||||
private val debugFlushPending = AtomicBoolean(false)
|
||||
@Volatile private var lastHeartbeatOkLogAtMs: Long = 0L
|
||||
@@ -69,6 +74,10 @@ object ProtocolManager {
|
||||
// Typing status
|
||||
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
|
||||
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow()
|
||||
private val _typingUsersByDialogSnapshot =
|
||||
MutableStateFlow<Map<String, Set<String>>>(emptyMap())
|
||||
val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>> =
|
||||
_typingUsersByDialogSnapshot.asStateFlow()
|
||||
private val typingStateLock = Any()
|
||||
private val typingUsersByDialog = mutableMapOf<String, MutableSet<String>>()
|
||||
private val typingTimeoutJobs = ConcurrentHashMap<String, Job>()
|
||||
@@ -134,7 +143,6 @@ object ProtocolManager {
|
||||
}
|
||||
|
||||
fun addLog(message: String) {
|
||||
if (!uiLogsEnabled) return
|
||||
var normalizedMessage = message
|
||||
val isHeartbeatOk = normalizedMessage.startsWith("💓 Heartbeat OK")
|
||||
if (isHeartbeatOk) {
|
||||
@@ -152,6 +160,8 @@ object ProtocolManager {
|
||||
val timestamp =
|
||||
java.text.SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
|
||||
val line = "[$timestamp] $normalizedMessage"
|
||||
persistProtocolTraceLine(line)
|
||||
if (!uiLogsEnabled) return
|
||||
synchronized(debugLogsLock) {
|
||||
if (debugLogsBuffer.size >= MAX_DEBUG_LOGS) {
|
||||
debugLogsBuffer.removeFirst()
|
||||
@@ -161,6 +171,24 @@ object ProtocolManager {
|
||||
flushDebugLogsThrottled()
|
||||
}
|
||||
|
||||
private fun persistProtocolTraceLine(line: String) {
|
||||
val context = appContext ?: return
|
||||
runCatching {
|
||||
val dir = File(context.filesDir, "crash_reports")
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
val traceFile = File(dir, PROTOCOL_TRACE_FILE_NAME)
|
||||
synchronized(protocolTraceLock) {
|
||||
if (traceFile.exists() && traceFile.length() > PROTOCOL_TRACE_MAX_BYTES) {
|
||||
val tail = runCatching {
|
||||
traceFile.readText(Charsets.UTF_8).takeLast(PROTOCOL_TRACE_KEEP_BYTES)
|
||||
}.getOrDefault("")
|
||||
traceFile.writeText("=== PROTOCOL TRACE ROTATED ===\n$tail\n", Charsets.UTF_8)
|
||||
}
|
||||
traceFile.appendText("$line\n", Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun enableUILogs(enabled: Boolean) {
|
||||
uiLogsEnabled = enabled
|
||||
MessageLogger.setEnabled(enabled)
|
||||
@@ -656,6 +684,20 @@ object ProtocolManager {
|
||||
return "${dialogKey.lowercase(Locale.ROOT)}|${fromPublicKey.lowercase(Locale.ROOT)}"
|
||||
}
|
||||
|
||||
fun getTypingUsersForDialog(dialogKey: String): Set<String> {
|
||||
val normalizedDialogKey =
|
||||
if (isGroupDialogKey(dialogKey)) {
|
||||
normalizeGroupDialogKey(dialogKey)
|
||||
} else {
|
||||
dialogKey.trim()
|
||||
}
|
||||
if (normalizedDialogKey.isBlank()) return emptySet()
|
||||
|
||||
synchronized(typingStateLock) {
|
||||
return typingUsersByDialog[normalizedDialogKey]?.toSet() ?: emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
private fun rememberTypingEvent(dialogKey: String, fromPublicKey: String) {
|
||||
val normalizedDialogKey =
|
||||
if (isGroupDialogKey(dialogKey)) normalizeGroupDialogKey(dialogKey) else dialogKey.trim()
|
||||
@@ -666,6 +708,8 @@ object ProtocolManager {
|
||||
val users = typingUsersByDialog.getOrPut(normalizedDialogKey) { mutableSetOf() }
|
||||
users.add(normalizedFrom)
|
||||
_typingUsers.value = typingUsersByDialog.keys.toSet()
|
||||
_typingUsersByDialogSnapshot.value =
|
||||
typingUsersByDialog.mapValues { entry -> entry.value.toSet() }
|
||||
}
|
||||
|
||||
val timeoutKey = makeTypingTimeoutKey(normalizedDialogKey, normalizedFrom)
|
||||
@@ -680,6 +724,8 @@ object ProtocolManager {
|
||||
typingUsersByDialog.remove(normalizedDialogKey)
|
||||
}
|
||||
_typingUsers.value = typingUsersByDialog.keys.toSet()
|
||||
_typingUsersByDialogSnapshot.value =
|
||||
typingUsersByDialog.mapValues { entry -> entry.value.toSet() }
|
||||
}
|
||||
typingTimeoutJobs.remove(timeoutKey)
|
||||
}
|
||||
@@ -691,6 +737,7 @@ object ProtocolManager {
|
||||
synchronized(typingStateLock) {
|
||||
typingUsersByDialog.clear()
|
||||
_typingUsers.value = emptySet()
|
||||
_typingUsersByDialogSnapshot.value = emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1328,6 +1375,10 @@ object ProtocolManager {
|
||||
sharedPublic: String = "",
|
||||
roomId: String = ""
|
||||
) {
|
||||
addLog(
|
||||
"📡 CALL TX type=$signalType src=${shortKeyForLog(src)} dst=${shortKeyForLog(dst)} " +
|
||||
"sharedLen=${sharedPublic.length} room=${shortKeyForLog(roomId, 12)}"
|
||||
)
|
||||
send(
|
||||
PacketSignalPeer().apply {
|
||||
this.signalType = signalType
|
||||
@@ -1345,6 +1396,11 @@ object ProtocolManager {
|
||||
fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) {
|
||||
val pk = try { getProtocol().getPublicKey().orEmpty() } catch (_: Exception) { "" }
|
||||
val did = appContext?.let { getOrCreateDeviceId(it) } ?: ""
|
||||
addLog(
|
||||
"📡 WEBRTC TX type=$signalType sdpLen=${sdpOrCandidate.length} " +
|
||||
"pk=${shortKeyForLog(pk)} did=${shortKeyForLog(did, 12)} " +
|
||||
"preview='${shortTextForLog(sdpOrCandidate, 56)}'"
|
||||
)
|
||||
send(
|
||||
PacketWebRTC().apply {
|
||||
this.signalType = signalType
|
||||
@@ -1359,6 +1415,7 @@ object ProtocolManager {
|
||||
* Request ICE servers from server (0x1C).
|
||||
*/
|
||||
fun requestIceServers() {
|
||||
addLog("📡 ICE TX request")
|
||||
send(PacketIceServers())
|
||||
}
|
||||
|
||||
@@ -1368,7 +1425,13 @@ object ProtocolManager {
|
||||
*/
|
||||
fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit {
|
||||
val wrapper: (Packet) -> Unit = { packet ->
|
||||
(packet as? PacketSignalPeer)?.let(callback)
|
||||
(packet as? PacketSignalPeer)?.let {
|
||||
addLog(
|
||||
"📡 CALL RX type=${it.signalType} src=${shortKeyForLog(it.src)} dst=${shortKeyForLog(it.dst)} " +
|
||||
"sharedLen=${it.sharedPublic.length} room=${shortKeyForLog(it.roomId, 12)}"
|
||||
)
|
||||
callback(it)
|
||||
}
|
||||
}
|
||||
waitPacket(PACKET_SIGNAL_PEER, wrapper)
|
||||
return wrapper
|
||||
@@ -1384,7 +1447,14 @@ object ProtocolManager {
|
||||
*/
|
||||
fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit {
|
||||
val wrapper: (Packet) -> Unit = { packet ->
|
||||
(packet as? PacketWebRTC)?.let(callback)
|
||||
(packet as? PacketWebRTC)?.let {
|
||||
addLog(
|
||||
"📡 WEBRTC RX type=${it.signalType} sdpLen=${it.sdpOrCandidate.length} " +
|
||||
"pk=${shortKeyForLog(it.publicKey)} did=${shortKeyForLog(it.deviceId, 12)} " +
|
||||
"preview='${shortTextForLog(it.sdpOrCandidate, 56)}'"
|
||||
)
|
||||
callback(it)
|
||||
}
|
||||
}
|
||||
waitPacket(PACKET_WEB_RTC, wrapper)
|
||||
return wrapper
|
||||
@@ -1400,7 +1470,11 @@ object ProtocolManager {
|
||||
*/
|
||||
fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit {
|
||||
val wrapper: (Packet) -> Unit = { packet ->
|
||||
(packet as? PacketIceServers)?.let(callback)
|
||||
(packet as? PacketIceServers)?.let {
|
||||
val firstUrl = it.iceServers.firstOrNull()?.url.orEmpty()
|
||||
addLog("📡 ICE RX count=${it.iceServers.size} first='${shortTextForLog(firstUrl, 56)}'")
|
||||
callback(it)
|
||||
}
|
||||
}
|
||||
waitPacket(PACKET_ICE_SERVERS, wrapper)
|
||||
return wrapper
|
||||
@@ -1467,6 +1541,18 @@ object ProtocolManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shortKeyForLog(value: String, visible: Int = 8): String {
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.isBlank()) return "<empty>"
|
||||
return if (trimmed.length <= visible) trimmed else "${trimmed.take(visible)}…"
|
||||
}
|
||||
|
||||
private fun shortTextForLog(value: String, limit: Int = 80): String {
|
||||
val normalized = value.replace('\n', ' ').replace('\r', ' ').trim()
|
||||
if (normalized.isBlank()) return "<empty>"
|
||||
return if (normalized.length <= limit) normalized else "${normalized.take(limit)}…"
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect and clear
|
||||
|
||||
@@ -126,6 +126,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private val groupMembersCountCache = java.util.concurrent.ConcurrentHashMap<String, Int>()
|
||||
private val groupAdminKeysCache = java.util.concurrent.ConcurrentHashMap<String, Set<String>>()
|
||||
private const val GROUP_MESSAGE_STACK_TIME_DIFF_MS = 5 * 60_000L
|
||||
private const val DIRECT_MESSAGE_STACK_TIME_DIFF_MS = 60_000L
|
||||
private const val MESSAGE_SKELETON_VISIBILITY_DELAY_MS = 180L
|
||||
@@ -367,9 +368,6 @@ fun ChatDetailScreen(
|
||||
// 🎨 Window reference для управления статус баром
|
||||
val window = remember { (view.context as? Activity)?.window }
|
||||
|
||||
// 🔥 Focus state for input
|
||||
val inputFocusRequester = remember { FocusRequester() }
|
||||
|
||||
// 🔥 Emoji picker state
|
||||
var showEmojiPicker by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -538,7 +536,7 @@ fun ChatDetailScreen(
|
||||
} else {
|
||||
val isOverlayControllingSystemBars = showMediaPicker
|
||||
|
||||
if (!isOverlayControllingSystemBars && window != null && view != null) {
|
||||
if (!isOverlayControllingSystemBars && window != null) {
|
||||
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||
ic.isAppearanceLightStatusBars = false
|
||||
@@ -556,7 +554,7 @@ fun ChatDetailScreen(
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
// Восстановить белые иконки статус-бара для chat list header
|
||||
if (window != null && view != null) {
|
||||
if (window != null) {
|
||||
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||
ic.isAppearanceLightStatusBars = false
|
||||
@@ -570,9 +568,6 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 📷 Camera: URI для сохранения фото
|
||||
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
|
||||
|
||||
// 📷 Состояние для flow камеры: фото → редактор с caption → отправка
|
||||
var pendingCameraPhotoUri by remember {
|
||||
mutableStateOf<Uri?>(null)
|
||||
@@ -647,47 +642,6 @@ fun ChatDetailScreen(
|
||||
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
|
||||
val filePickerLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
@@ -729,8 +683,8 @@ fun ChatDetailScreen(
|
||||
remember(user.publicKey, currentUserPublicKey) {
|
||||
"${currentUserPublicKey.trim()}::${user.publicKey.trim()}"
|
||||
}
|
||||
var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) {
|
||||
mutableStateOf<Set<String>>(emptySet())
|
||||
var groupAdminKeys by remember(groupMembersCacheKey) {
|
||||
mutableStateOf(groupAdminKeysCache[groupMembersCacheKey] ?: emptySet())
|
||||
}
|
||||
var groupMembersCount by remember(groupMembersCacheKey) {
|
||||
mutableStateOf<Int?>(groupMembersCountCache[groupMembersCacheKey])
|
||||
@@ -756,12 +710,15 @@ fun ChatDetailScreen(
|
||||
|
||||
val cachedMembersCount = groupMembersCountCache[groupMembersCacheKey]
|
||||
groupMembersCount = cachedMembersCount
|
||||
val cachedAdminKeys = groupAdminKeysCache[groupMembersCacheKey]
|
||||
if (!cachedAdminKeys.isNullOrEmpty()) {
|
||||
groupAdminKeys = cachedAdminKeys
|
||||
}
|
||||
|
||||
val members = withContext(Dispatchers.IO) {
|
||||
groupRepository.requestGroupMembers(user.publicKey)
|
||||
}
|
||||
if (members == null) {
|
||||
groupAdminKeys = emptySet()
|
||||
mentionCandidates = emptyList()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
@@ -777,6 +734,9 @@ fun ChatDetailScreen(
|
||||
val adminKey = normalizedMembers.firstOrNull().orEmpty()
|
||||
groupAdminKeys =
|
||||
if (adminKey.isBlank()) emptySet() else setOf(adminKey)
|
||||
if (groupAdminKeys.isNotEmpty()) {
|
||||
groupAdminKeysCache[groupMembersCacheKey] = groupAdminKeys
|
||||
}
|
||||
|
||||
mentionCandidates =
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -820,8 +780,9 @@ fun ChatDetailScreen(
|
||||
|
||||
// Подключаем к ViewModel
|
||||
val messages by viewModel.messages.collectAsState()
|
||||
val inputText by viewModel.inputText.collectAsState()
|
||||
val isTyping by viewModel.opponentTyping.collectAsState()
|
||||
val typingDisplayName by viewModel.typingDisplayName.collectAsState()
|
||||
val typingDisplayPublicKey by viewModel.typingDisplayPublicKey.collectAsState()
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val isLoadingMore by viewModel.isLoadingMore.collectAsState()
|
||||
val rawIsOnline by viewModel.opponentOnline.collectAsState()
|
||||
@@ -1344,9 +1305,6 @@ fun ChatDetailScreen(
|
||||
|
||||
// Динамический subtitle: typing > online > offline
|
||||
val isSystemAccount = MessageRepository.isSystemAccount(user.publicKey)
|
||||
val isRosettaOfficial = user.title.equals("Rosetta", ignoreCase = true) ||
|
||||
user.username.equals("rosetta", ignoreCase = true) ||
|
||||
isSystemAccount
|
||||
val groupMembersSubtitleCount = groupMembersCount
|
||||
val groupMembersSubtitle =
|
||||
if (groupMembersSubtitleCount == null) {
|
||||
@@ -2075,7 +2033,7 @@ fun ChatDetailScreen(
|
||||
if (!isSavedMessages &&
|
||||
!isGroupChat &&
|
||||
(chatHeaderVerified >
|
||||
0 || isRosettaOfficial)
|
||||
0 || isSystemAccount)
|
||||
) {
|
||||
Spacer(
|
||||
modifier =
|
||||
@@ -2109,7 +2067,11 @@ fun ChatDetailScreen(
|
||||
if (isTyping) {
|
||||
TypingIndicator(
|
||||
isDarkTheme =
|
||||
isDarkTheme
|
||||
isDarkTheme,
|
||||
typingDisplayName =
|
||||
if (isGroupChat) typingDisplayName else "",
|
||||
typingSenderPublicKey =
|
||||
if (isGroupChat) typingDisplayPublicKey else ""
|
||||
)
|
||||
} else if (isGroupChat &&
|
||||
groupMembersCount == null
|
||||
@@ -2694,66 +2656,40 @@ fun ChatDetailScreen(
|
||||
} else if (!isSystemAccount) {
|
||||
// INPUT BAR
|
||||
Column {
|
||||
MessageInputBar(
|
||||
value = inputText,
|
||||
onValueChange = {
|
||||
viewModel
|
||||
.updateInputText(
|
||||
it
|
||||
)
|
||||
if (it.isNotEmpty() &&
|
||||
!isSavedMessages
|
||||
) {
|
||||
viewModel
|
||||
.sendTypingIndicator()
|
||||
}
|
||||
},
|
||||
ChatInputBarSection(
|
||||
viewModel = viewModel,
|
||||
isSavedMessages = isSavedMessages,
|
||||
onSend = {
|
||||
isSendingMessage =
|
||||
true
|
||||
viewModel
|
||||
.sendMessage()
|
||||
isSendingMessage = true
|
||||
viewModel.sendMessage()
|
||||
scope.launch {
|
||||
delay(100)
|
||||
listState
|
||||
.animateScrollToItem(
|
||||
0
|
||||
)
|
||||
listState.animateScrollToItem(0)
|
||||
delay(300)
|
||||
isSendingMessage =
|
||||
false
|
||||
isSendingMessage = false
|
||||
}
|
||||
},
|
||||
isDarkTheme = isDarkTheme,
|
||||
backgroundColor =
|
||||
backgroundColor,
|
||||
backgroundColor = backgroundColor,
|
||||
textColor = textColor,
|
||||
placeholderColor =
|
||||
secondaryTextColor,
|
||||
secondaryTextColor =
|
||||
secondaryTextColor,
|
||||
replyMessages =
|
||||
replyMessages,
|
||||
isForwardMode =
|
||||
isForwardMode,
|
||||
replyMessages = replyMessages,
|
||||
isForwardMode = isForwardMode,
|
||||
onCloseReply = {
|
||||
viewModel
|
||||
.clearReplyMessages()
|
||||
viewModel.clearReplyMessages()
|
||||
},
|
||||
onShowForwardOptions = { panelMessages ->
|
||||
if (panelMessages.isEmpty()) {
|
||||
return@MessageInputBar
|
||||
return@ChatInputBarSection
|
||||
}
|
||||
val forwardMessages =
|
||||
panelMessages.map { msg ->
|
||||
ForwardManager.ForwardMessage(
|
||||
messageId =
|
||||
msg.messageId,
|
||||
messageId = msg.messageId,
|
||||
text = msg.text,
|
||||
timestamp =
|
||||
msg.timestamp,
|
||||
isOutgoing =
|
||||
msg.isOutgoing,
|
||||
timestamp = msg.timestamp,
|
||||
isOutgoing = msg.isOutgoing,
|
||||
senderPublicKey =
|
||||
msg.publicKey.ifEmpty {
|
||||
if (msg.isOutgoing) currentUserPublicKey
|
||||
@@ -2783,43 +2719,28 @@ fun ChatDetailScreen(
|
||||
},
|
||||
chatTitle = chatTitle,
|
||||
isBlocked = isBlocked,
|
||||
showEmojiPicker =
|
||||
showEmojiPicker,
|
||||
showEmojiPicker = showEmojiPicker,
|
||||
onToggleEmojiPicker = {
|
||||
showEmojiPicker = it
|
||||
},
|
||||
focusRequester =
|
||||
inputFocusRequester,
|
||||
coordinator = coordinator,
|
||||
displayReplyMessages =
|
||||
displayReplyMessages,
|
||||
onReplyClick =
|
||||
scrollToMessage,
|
||||
onReplyClick = scrollToMessage,
|
||||
onAttachClick = {
|
||||
// Telegram-style:
|
||||
// галерея
|
||||
// открывается
|
||||
// ПОВЕРХ клавиатуры
|
||||
// НЕ скрываем
|
||||
// клавиатуру!
|
||||
showMediaPicker =
|
||||
true
|
||||
// галерея открывается поверх клавиатуры.
|
||||
showMediaPicker = true
|
||||
},
|
||||
myPublicKey =
|
||||
viewModel
|
||||
.myPublicKey
|
||||
?: "",
|
||||
opponentPublicKey =
|
||||
user.publicKey,
|
||||
myPrivateKey =
|
||||
currentUserPrivateKey,
|
||||
viewModel.myPublicKey ?: "",
|
||||
opponentPublicKey = user.publicKey,
|
||||
myPrivateKey = currentUserPrivateKey,
|
||||
isGroupChat = isGroupChat,
|
||||
mentionCandidates = mentionCandidates,
|
||||
avatarRepository = avatarRepository,
|
||||
inputFocusTrigger =
|
||||
inputFocusTrigger,
|
||||
suppressKeyboard =
|
||||
showInAppCamera,
|
||||
inputFocusTrigger = inputFocusTrigger,
|
||||
suppressKeyboard = showInAppCamera,
|
||||
hasNativeNavigationBar =
|
||||
hasNativeNavigationBar
|
||||
)
|
||||
@@ -4301,6 +4222,76 @@ fun ChatDetailScreen(
|
||||
} // Закрытие 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
|
||||
private fun GroupMembersSubtitleSkeleton() {
|
||||
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_PARALLELISM = 4 // Параллельная расшифровка
|
||||
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
|
||||
private const val DRAFT_SAVE_DEBOUNCE_MS = 250L
|
||||
private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val chatMessageAscComparator =
|
||||
compareBy<ChatMessage>({ it.timestamp.time }, { it.id })
|
||||
@@ -167,7 +168,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val _opponentTyping = MutableStateFlow(false)
|
||||
val opponentTyping: StateFlow<Boolean> = _opponentTyping.asStateFlow()
|
||||
private val _typingDisplayName = MutableStateFlow("")
|
||||
val typingDisplayName: StateFlow<String> = _typingDisplayName.asStateFlow()
|
||||
private val _typingDisplayPublicKey = MutableStateFlow("")
|
||||
val typingDisplayPublicKey: StateFlow<String> = _typingDisplayPublicKey.asStateFlow()
|
||||
private var typingTimeoutJob: kotlinx.coroutines.Job? = null
|
||||
private var typingNameResolveJob: kotlinx.coroutines.Job? = null
|
||||
@Volatile private var typingSenderPublicKey: String? = null
|
||||
@Volatile private var typingUsersCount: Int = 1
|
||||
|
||||
// 🟢 Онлайн статус собеседника
|
||||
private val _opponentOnline = MutableStateFlow(false)
|
||||
@@ -218,6 +226,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// Job для отмены загрузки при смене диалога
|
||||
private var loadingJob: Job? = null
|
||||
private var draftSaveJob: Job? = null
|
||||
|
||||
// 🔥 Throttling для typing индикатора
|
||||
private var lastTypingSentTime = 0L
|
||||
@@ -359,7 +368,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
if (shouldShowTyping) {
|
||||
showTypingIndicator()
|
||||
if (isGroupDialogKey(currentDialog)) {
|
||||
val typingUsers = ProtocolManager.getTypingUsersForDialog(currentDialog).toMutableSet()
|
||||
typingUsers.add(fromPublicKey)
|
||||
showTypingIndicator(
|
||||
senderPublicKey = fromPublicKey,
|
||||
typingUsersCount = typingUsers.size
|
||||
)
|
||||
} else {
|
||||
showTypingIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -730,7 +748,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
_opponentOnline.value = false
|
||||
_opponentTyping.value = false
|
||||
_typingDisplayName.value = ""
|
||||
_typingDisplayPublicKey.value = ""
|
||||
typingSenderPublicKey = null
|
||||
typingUsersCount = 1
|
||||
typingTimeoutJob?.cancel()
|
||||
typingNameResolveJob?.cancel()
|
||||
currentOffset = 0
|
||||
hasMoreMessages = true
|
||||
isLoadingMessages = false
|
||||
@@ -1370,6 +1393,52 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return "${trimmed.take(6)}...${trimmed.takeLast(4)}"
|
||||
}
|
||||
|
||||
private fun buildGroupTypingDisplayName(baseName: String, participantsCount: Int): String {
|
||||
val normalizedName = baseName.trim()
|
||||
if (normalizedName.isBlank()) return ""
|
||||
val extraParticipants = (participantsCount - 1).coerceAtLeast(0)
|
||||
return if (extraParticipants > 0) {
|
||||
"$normalizedName and $extraParticipants"
|
||||
} else {
|
||||
normalizedName
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveKnownGroupSenderName(publicKey: String): String {
|
||||
val normalizedPublicKey = publicKey.trim()
|
||||
if (normalizedPublicKey.isBlank()) return ""
|
||||
|
||||
groupSenderNameCache[normalizedPublicKey]?.let { cached ->
|
||||
if (isUsableSenderName(cached, normalizedPublicKey)) return cached
|
||||
}
|
||||
|
||||
val nameFromMessages =
|
||||
_messages.value.asSequence().mapNotNull { message ->
|
||||
if (message.senderPublicKey.trim()
|
||||
.equals(normalizedPublicKey, ignoreCase = true)
|
||||
) {
|
||||
message.senderName.trim()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.firstOrNull { isUsableSenderName(it, normalizedPublicKey) }
|
||||
|
||||
if (!nameFromMessages.isNullOrBlank()) {
|
||||
groupSenderNameCache[normalizedPublicKey] = nameFromMessages
|
||||
return nameFromMessages
|
||||
}
|
||||
|
||||
val cachedInfo = ProtocolManager.getCachedUserInfo(normalizedPublicKey)
|
||||
val protocolName =
|
||||
cachedInfo?.title?.trim().orEmpty().ifBlank { cachedInfo?.username?.trim().orEmpty() }
|
||||
if (isUsableSenderName(protocolName, normalizedPublicKey)) {
|
||||
groupSenderNameCache[normalizedPublicKey] = protocolName
|
||||
return protocolName
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
private suspend fun resolveGroupSenderName(publicKey: String): String {
|
||||
val normalizedPublicKey = publicKey.trim()
|
||||
if (normalizedPublicKey.isBlank()) return ""
|
||||
@@ -1436,6 +1505,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
groupSenderNameCache[normalizedPublicKey] = name
|
||||
withContext(Dispatchers.Main) {
|
||||
if (_opponentTyping.value &&
|
||||
typingSenderPublicKey.equals(normalizedPublicKey, ignoreCase = true)
|
||||
) {
|
||||
_typingDisplayName.value =
|
||||
buildGroupTypingDisplayName(name, typingUsersCount)
|
||||
}
|
||||
_messages.update { current ->
|
||||
current.map { message ->
|
||||
if (message.senderPublicKey.trim() == normalizedPublicKey &&
|
||||
@@ -1500,7 +1575,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private fun isLikelyCallAttachmentPreview(preview: String): Boolean {
|
||||
val normalized = preview.trim()
|
||||
if (normalized.isEmpty()) return true
|
||||
// Empty preview must NOT be treated as call, otherwise we get false-positive
|
||||
// "empty call" attachments in regular/group messages.
|
||||
if (normalized.isEmpty()) return false
|
||||
|
||||
val tail = normalized.substringAfterLast("::", normalized).trim()
|
||||
if (tail.toIntOrNull() != null) return true
|
||||
@@ -2286,11 +2363,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
/** Обновить текст ввода */
|
||||
fun updateInputText(text: String) {
|
||||
if (_inputText.value == text) return
|
||||
_inputText.value = text
|
||||
// 📝 Сохраняем черновик при каждом изменении текста (draft, как в Telegram)
|
||||
opponentKey?.let { key ->
|
||||
com.rosetta.messenger.data.DraftManager.saveDraft(key, text)
|
||||
}
|
||||
|
||||
val key = opponentKey ?: return
|
||||
draftSaveJob?.cancel()
|
||||
draftSaveJob =
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
delay(DRAFT_SAVE_DEBOUNCE_MS)
|
||||
// Если за время debounce текст изменился — сохраняем только свежую версию.
|
||||
if (_inputText.value != text) return@launch
|
||||
com.rosetta.messenger.data.DraftManager.saveDraft(key, text)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5082,7 +5166,51 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun showTypingIndicator() {
|
||||
private fun showTypingIndicator(senderPublicKey: String? = null, typingUsersCount: Int = 1) {
|
||||
val currentDialog = opponentKey?.trim().orEmpty()
|
||||
val normalizedSender = senderPublicKey?.trim().orEmpty()
|
||||
val isGroupTyping = isGroupDialogKey(currentDialog) && normalizedSender.isNotBlank()
|
||||
val normalizedTypingUsersCount = typingUsersCount.coerceAtLeast(1)
|
||||
|
||||
if (isGroupTyping) {
|
||||
typingSenderPublicKey = normalizedSender
|
||||
_typingDisplayPublicKey.value = normalizedSender
|
||||
this.typingUsersCount = normalizedTypingUsersCount
|
||||
val knownName = resolveKnownGroupSenderName(normalizedSender)
|
||||
val initialName =
|
||||
if (isUsableSenderName(knownName, normalizedSender)) {
|
||||
knownName
|
||||
} else {
|
||||
shortPublicKey(normalizedSender)
|
||||
}
|
||||
_typingDisplayName.value =
|
||||
buildGroupTypingDisplayName(initialName, normalizedTypingUsersCount)
|
||||
|
||||
requestGroupSenderNameIfNeeded(normalizedSender)
|
||||
|
||||
typingNameResolveJob?.cancel()
|
||||
typingNameResolveJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val resolvedName = resolveGroupSenderName(normalizedSender)
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
if (_opponentTyping.value &&
|
||||
typingSenderPublicKey.equals(normalizedSender, ignoreCase = true)
|
||||
) {
|
||||
_typingDisplayName.value =
|
||||
buildGroupTypingDisplayName(
|
||||
resolvedName,
|
||||
this@ChatViewModel.typingUsersCount
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
typingSenderPublicKey = null
|
||||
_typingDisplayName.value = ""
|
||||
_typingDisplayPublicKey.value = ""
|
||||
this.typingUsersCount = 1
|
||||
}
|
||||
|
||||
_opponentTyping.value = true
|
||||
// Отменяем предыдущий таймер, чтобы избежать race condition
|
||||
typingTimeoutJob?.cancel()
|
||||
@@ -5090,6 +5218,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
kotlinx.coroutines.delay(3000)
|
||||
_opponentTyping.value = false
|
||||
_typingDisplayName.value = ""
|
||||
_typingDisplayPublicKey.value = ""
|
||||
typingSenderPublicKey = null
|
||||
this@ChatViewModel.typingUsersCount = 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5333,6 +5465,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
isCleared = true
|
||||
typingTimeoutJob?.cancel()
|
||||
typingNameResolveJob?.cancel()
|
||||
draftSaveJob?.cancel()
|
||||
pinnedCollectionJob?.cancel()
|
||||
|
||||
// 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов
|
||||
|
||||
@@ -222,6 +222,21 @@ private fun isTypingForDialog(dialogKey: String, typingDialogs: Set<String>): Bo
|
||||
return typingDialogs.any { it.equals(dialogKey.trim(), ignoreCase = true) }
|
||||
}
|
||||
|
||||
private fun shortPublicKey(value: String): String {
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.length <= 12) return trimmed
|
||||
return "${trimmed.take(6)}...${trimmed.takeLast(4)}"
|
||||
}
|
||||
|
||||
private fun resolveTypingDisplayName(publicKey: String): String {
|
||||
val normalized = publicKey.trim()
|
||||
if (normalized.isBlank()) return ""
|
||||
val cached = ProtocolManager.getCachedUserInfo(normalized)
|
||||
val resolvedName =
|
||||
cached?.title?.trim().orEmpty().ifBlank { cached?.username?.trim().orEmpty() }
|
||||
return if (resolvedName.isNotBlank()) resolvedName else shortPublicKey(normalized)
|
||||
}
|
||||
|
||||
private val TELEGRAM_DIALOG_AVATAR_START = 10.dp
|
||||
private val TELEGRAM_DIALOG_TEXT_START = 72.dp
|
||||
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
|
||||
@@ -249,24 +264,19 @@ fun ChatsListScreen(
|
||||
accountName: String,
|
||||
accountUsername: String,
|
||||
accountVerified: Int = 0,
|
||||
accountPhone: String,
|
||||
accountPublicKey: String,
|
||||
accountPrivateKey: String = "",
|
||||
privateKeyHash: String = "",
|
||||
onToggleTheme: () -> Unit,
|
||||
onProfileClick: () -> Unit,
|
||||
onNewGroupClick: () -> Unit,
|
||||
onContactsClick: () -> Unit,
|
||||
onCallsClick: () -> Unit,
|
||||
onSavedMessagesClick: () -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
onInviteFriendsClick: () -> Unit,
|
||||
onSearchClick: () -> Unit,
|
||||
onRequestsClick: () -> Unit = {},
|
||||
onNewChat: () -> Unit,
|
||||
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
|
||||
onStartCall: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
|
||||
backgroundBlurColorId: String = "avatar",
|
||||
pinnedChats: Set<String> = emptySet(),
|
||||
onTogglePin: (String) -> Unit = {},
|
||||
chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
|
||||
@@ -435,6 +445,7 @@ fun ChatsListScreen(
|
||||
|
||||
// <20>🔥 Пользователи, которые сейчас печатают
|
||||
val typingUsers by ProtocolManager.typingUsers.collectAsState()
|
||||
val typingUsersByDialogSnapshot by ProtocolManager.typingUsersByDialogSnapshot.collectAsState()
|
||||
|
||||
// Load dialogs when account is available
|
||||
LaunchedEffect(accountPublicKey, accountPrivateKey) {
|
||||
@@ -446,7 +457,6 @@ fun ChatsListScreen(
|
||||
|
||||
// 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих
|
||||
// сообщений
|
||||
val initStart = System.currentTimeMillis()
|
||||
ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey)
|
||||
android.util.Log.d(
|
||||
"ChatsListScreen",
|
||||
@@ -553,10 +563,6 @@ fun ChatsListScreen(
|
||||
allAccounts = accounts.sortedByDescending { it.publicKey == accountPublicKey }
|
||||
}
|
||||
|
||||
// 🔥 Используем rememberSaveable чтобы сохранить состояние при навигации
|
||||
// Header сразу visible = true, без анимации при возврате из чата
|
||||
var visible by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
// Confirmation dialogs state
|
||||
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
|
||||
var dialogToLeave by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||
@@ -778,13 +784,6 @@ fun ChatsListScreen(
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🎨 DRAWER HEADER
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val avatarColors =
|
||||
getAvatarColor(
|
||||
accountPublicKey,
|
||||
isDarkTheme
|
||||
)
|
||||
val headerColor = avatarColors.backgroundColor
|
||||
|
||||
// Header: цвет шапки сайдбара
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
@@ -807,24 +806,10 @@ fun ChatsListScreen(
|
||||
bottom = 12.dp
|
||||
)
|
||||
) {
|
||||
val isRosettaOfficial =
|
||||
accountName.equals(
|
||||
"Rosetta",
|
||||
ignoreCase = true
|
||||
) ||
|
||||
accountUsername.equals(
|
||||
"rosetta",
|
||||
ignoreCase = true
|
||||
)
|
||||
val isFreddyOfficial =
|
||||
accountName.equals(
|
||||
"freddy",
|
||||
ignoreCase = true
|
||||
) ||
|
||||
accountUsername.equals(
|
||||
"freddy",
|
||||
ignoreCase = true
|
||||
)
|
||||
val isOfficialByKey =
|
||||
MessageRepository.isSystemAccount(
|
||||
accountPublicKey
|
||||
)
|
||||
// Avatar row with theme toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -934,7 +919,7 @@ fun ChatsListScreen(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
if (accountVerified > 0 || isRosettaOfficial || isFreddyOfficial) {
|
||||
if (accountVerified > 0 || isOfficialByKey) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
VerifiedBadge(
|
||||
verified = if (accountVerified > 0) accountVerified else 1,
|
||||
@@ -2481,6 +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 =
|
||||
selectedChatKeys
|
||||
.contains(
|
||||
@@ -2518,6 +2572,10 @@ fun ChatsListScreen(
|
||||
isDarkTheme,
|
||||
isTyping =
|
||||
isTyping,
|
||||
typingDisplayName =
|
||||
typingDisplayName,
|
||||
typingSenderPublicKey =
|
||||
typingSenderPublicKey,
|
||||
isBlocked =
|
||||
isBlocked,
|
||||
isSavedMessages =
|
||||
@@ -3387,9 +3445,6 @@ fun ChatItem(
|
||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
|
||||
|
||||
val avatarColors = getAvatarColor(chat.publicKey, isDarkTheme)
|
||||
val avatarText = getAvatarText(chat.publicKey)
|
||||
|
||||
Column {
|
||||
Row(
|
||||
modifier =
|
||||
@@ -3627,6 +3682,8 @@ fun SwipeableDialogItem(
|
||||
dialog: DialogUiModel,
|
||||
isDarkTheme: Boolean,
|
||||
isTyping: Boolean = false,
|
||||
typingDisplayName: String = "",
|
||||
typingSenderPublicKey: String = "",
|
||||
isBlocked: Boolean = false,
|
||||
isGroupChat: Boolean = false,
|
||||
isSavedMessages: Boolean = false,
|
||||
@@ -3862,13 +3919,11 @@ fun SwipeableDialogItem(
|
||||
velocityTracker.resetTracking()
|
||||
var totalDragX = 0f
|
||||
var totalDragY = 0f
|
||||
var passedSlop = false
|
||||
var claimed = false
|
||||
|
||||
// Phase 1: Determine gesture type (tap / long-press / drag)
|
||||
// Wait up to longPressTimeout; if no up or slop → long press
|
||||
var gestureType = "unknown"
|
||||
var fingerIsUp = false
|
||||
|
||||
val result = withTimeoutOrNull(longPressTimeoutMs) {
|
||||
while (true) {
|
||||
@@ -3905,19 +3960,17 @@ fun SwipeableDialogItem(
|
||||
// Timeout → check if finger lifted during the race window
|
||||
if (result == null) {
|
||||
// Grace period: check if up event arrived just as timeout fired
|
||||
val graceResult = withTimeoutOrNull(32L) {
|
||||
withTimeoutOrNull(32L) {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null) {
|
||||
gestureType = "cancelled"
|
||||
fingerIsUp = true
|
||||
return@withTimeoutOrNull Unit
|
||||
}
|
||||
if (change.changedToUpIgnoreConsumed()) {
|
||||
change.consume()
|
||||
gestureType = "tap"
|
||||
fingerIsUp = true
|
||||
return@withTimeoutOrNull Unit
|
||||
}
|
||||
// Still moving/holding — it's a real long press
|
||||
@@ -3957,13 +4010,11 @@ fun SwipeableDialogItem(
|
||||
when {
|
||||
// Horizontal left swipe — reveal action buttons
|
||||
currentSwipeEnabled && dominated && totalDragX < 0 -> {
|
||||
passedSlop = true
|
||||
claimed = true
|
||||
currentOnSwipeStarted()
|
||||
}
|
||||
// Horizontal right swipe with buttons open — close them
|
||||
dominated && totalDragX > 0 && offsetX != 0f -> {
|
||||
passedSlop = true
|
||||
claimed = true
|
||||
}
|
||||
// Right swipe with buttons closed — let drawer handle
|
||||
@@ -4034,6 +4085,8 @@ fun SwipeableDialogItem(
|
||||
dialog = dialog,
|
||||
isDarkTheme = isDarkTheme,
|
||||
isTyping = isTyping,
|
||||
typingDisplayName = typingDisplayName,
|
||||
typingSenderPublicKey = typingSenderPublicKey,
|
||||
isPinned = isPinned,
|
||||
isBlocked = isBlocked,
|
||||
isMuted = isMuted,
|
||||
@@ -4051,6 +4104,8 @@ fun DialogItemContent(
|
||||
dialog: DialogUiModel,
|
||||
isDarkTheme: Boolean,
|
||||
isTyping: Boolean = false,
|
||||
typingDisplayName: String = "",
|
||||
typingSenderPublicKey: String = "",
|
||||
isPinned: Boolean = false,
|
||||
isBlocked: Boolean = false,
|
||||
isMuted: Boolean = false,
|
||||
@@ -4063,10 +4118,6 @@ fun DialogItemContent(
|
||||
val secondaryTextColor =
|
||||
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) }
|
||||
|
||||
// 📁 Для 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(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
@@ -4245,13 +4264,8 @@ fun DialogItemContent(
|
||||
modifier = Modifier.size(15.dp)
|
||||
)
|
||||
}
|
||||
val isRosettaOfficial = dialog.opponentTitle.equals("Rosetta", ignoreCase = true) ||
|
||||
dialog.opponentUsername.equals("rosetta", ignoreCase = true) ||
|
||||
MessageRepository.isSystemAccount(dialog.opponentKey)
|
||||
val isFreddyVerified = dialog.opponentUsername.equals("freddy", ignoreCase = true) ||
|
||||
dialog.opponentTitle.equals("freddy", ignoreCase = true) ||
|
||||
displayName.equals("freddy", ignoreCase = true)
|
||||
if (dialog.verified > 0 || isRosettaOfficial || isFreddyVerified) {
|
||||
val isOfficialByKey = MessageRepository.isSystemAccount(dialog.opponentKey)
|
||||
if (dialog.verified > 0 || isOfficialByKey) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
VerifiedBadge(
|
||||
verified = if (dialog.verified > 0) dialog.verified else 1,
|
||||
@@ -4458,7 +4472,11 @@ fun DialogItemContent(
|
||||
label = "chatSubtitle"
|
||||
) { showTyping ->
|
||||
if (showTyping) {
|
||||
TypingIndicatorSmall()
|
||||
TypingIndicatorSmall(
|
||||
isDarkTheme = isDarkTheme,
|
||||
typingDisplayName = typingDisplayName,
|
||||
typingSenderPublicKey = typingSenderPublicKey
|
||||
)
|
||||
} else if (!dialog.draftText.isNullOrEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
@@ -4492,7 +4510,8 @@ fun DialogItemContent(
|
||||
dialog.lastMessageAttachmentType ==
|
||||
"Call" -> "Call"
|
||||
dialog.lastMessageAttachmentType ==
|
||||
"Forwarded" -> "Forwarded message"
|
||||
"Forwarded" ->
|
||||
"Forwarded message"
|
||||
dialog.lastMessage.isEmpty() ->
|
||||
"No messages"
|
||||
else -> dialog.lastMessage
|
||||
@@ -4712,8 +4731,21 @@ fun DialogItemContent(
|
||||
* with sequential wave animation (scale + vertical offset + opacity).
|
||||
*/
|
||||
@Composable
|
||||
fun TypingIndicatorSmall() {
|
||||
fun TypingIndicatorSmall(
|
||||
isDarkTheme: Boolean,
|
||||
typingDisplayName: String = "",
|
||||
typingSenderPublicKey: String = ""
|
||||
) {
|
||||
val typingColor = PrimaryBlue
|
||||
val senderTypingColor =
|
||||
remember(typingSenderPublicKey, isDarkTheme) {
|
||||
if (typingSenderPublicKey.isBlank()) {
|
||||
typingColor
|
||||
} else {
|
||||
getAvatarColor(typingSenderPublicKey, isDarkTheme).textColor
|
||||
}
|
||||
}
|
||||
val normalizedDisplayName = remember(typingDisplayName) { typingDisplayName.trim() }
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "typing")
|
||||
|
||||
// Each dot animates 0→1→0 in a 1200 ms cycle, staggered by 150 ms
|
||||
@@ -4736,13 +4768,40 @@ fun TypingIndicatorSmall() {
|
||||
)
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "typing",
|
||||
fontSize = 14.sp,
|
||||
color = typingColor,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.heightIn(min = 18.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (normalizedDisplayName.isBlank()) {
|
||||
AppleEmojiText(
|
||||
text = "typing",
|
||||
fontSize = 14.sp,
|
||||
color = typingColor,
|
||||
fontWeight = FontWeight.Medium,
|
||||
enableLinks = false,
|
||||
minHeightMultiplier = 1f
|
||||
)
|
||||
} else {
|
||||
AppleEmojiText(
|
||||
text = "$normalizedDisplayName ",
|
||||
fontSize = 14.sp,
|
||||
color = senderTypingColor,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = android.text.TextUtils.TruncateAt.END,
|
||||
modifier = Modifier.widthIn(max = 180.dp),
|
||||
enableLinks = false,
|
||||
minHeightMultiplier = 1f
|
||||
)
|
||||
AppleEmojiText(
|
||||
text = "typing",
|
||||
fontSize = 14.sp,
|
||||
color = typingColor,
|
||||
fontWeight = FontWeight.Medium,
|
||||
enableLinks = false,
|
||||
minHeightMultiplier = 1f
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
|
||||
// Fixed-size canvas — big enough for bounce, never changes layout
|
||||
@@ -4750,7 +4809,7 @@ fun TypingIndicatorSmall() {
|
||||
val dotRadius = 1.5.dp.toPx()
|
||||
val dotSpacing = 2.5.dp.toPx()
|
||||
val maxBounce = 2.dp.toPx()
|
||||
val centerY = size.height / 2f + 1.dp.toPx()
|
||||
val centerY = size.height / 2f
|
||||
for (i in 0..2) {
|
||||
val p = dotProgresses[i].value
|
||||
val bounce = kotlin.math.sin(p * Math.PI).toFloat()
|
||||
@@ -4882,7 +4941,6 @@ private fun RequestsRouteContent(
|
||||
RequestsScreen(
|
||||
requests = requests,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onBack = onBack,
|
||||
onRequestClick = onRequestClick,
|
||||
avatarRepository = avatarRepository,
|
||||
blockedUsers = blockedUsers,
|
||||
@@ -5017,7 +5075,6 @@ fun RequestsSection(
|
||||
fun RequestsScreen(
|
||||
requests: List<DialogUiModel>,
|
||||
isDarkTheme: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onRequestClick: (DialogUiModel) -> Unit,
|
||||
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
||||
blockedUsers: Set<String> = emptySet(),
|
||||
|
||||
@@ -553,11 +553,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
val inferredCall = isLikelyCallAttachmentJson(lastMessageAttachments)
|
||||
return when (attachmentType) {
|
||||
0 -> if (inferredCall) "Call" else "Photo" // AttachmentType.IMAGE = 0
|
||||
1 -> {
|
||||
// AttachmentType.MESSAGES = 1 (Reply/Forward).
|
||||
// Если текст пустой — показываем "Forwarded" как в desktop.
|
||||
if (decryptedLastMessage.isNotEmpty()) null else "Forwarded"
|
||||
}
|
||||
1 -> if (decryptedLastMessage.isNotEmpty()) null else "Forwarded" // AttachmentType.MESSAGES = 1 (Reply/Forward)
|
||||
2 -> "File" // AttachmentType.FILE = 2
|
||||
3 -> "Avatar" // AttachmentType.AVATAR = 3
|
||||
4 -> "Call" // AttachmentType.CALL = 4
|
||||
@@ -589,7 +585,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
if (typeValue == 4) return true
|
||||
|
||||
val preview = first.optString("preview", "").trim()
|
||||
if (preview.isEmpty()) return true
|
||||
if (preview.isEmpty()) return false
|
||||
val tail = preview.substringAfterLast("::", preview).trim()
|
||||
if (tail.toIntOrNull() != null) return true
|
||||
|
||||
|
||||
@@ -87,7 +87,6 @@ fun RequestsListScreen(
|
||||
RequestsScreen(
|
||||
requests = requests,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onBack = onBack,
|
||||
onRequestClick = { request ->
|
||||
onUserSelect(chatsViewModel.dialogToSearchUser(request))
|
||||
},
|
||||
|
||||
@@ -178,12 +178,8 @@ fun CallOverlay(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false)
|
||||
)
|
||||
val isRosettaOfficial = state.peerTitle.equals("Rosetta", ignoreCase = true) ||
|
||||
state.peerUsername.equals("rosetta", ignoreCase = true) ||
|
||||
MessageRepository.isSystemAccount(state.peerPublicKey)
|
||||
val isFreddyVerified = state.peerUsername.equals("freddy", ignoreCase = true) ||
|
||||
state.peerTitle.equals("freddy", ignoreCase = true)
|
||||
if (isRosettaOfficial || isFreddyVerified) {
|
||||
val isOfficialByKey = MessageRepository.isSystemAccount(state.peerPublicKey)
|
||||
if (isOfficialByKey) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
VerifiedBadge(
|
||||
verified = 1,
|
||||
|
||||
@@ -238,12 +238,8 @@ private fun CallHistoryRowItem(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false)
|
||||
)
|
||||
val isRosettaOfficial = item.peerTitle.equals("Rosetta", ignoreCase = true) ||
|
||||
item.peerUsername.equals("rosetta", ignoreCase = true) ||
|
||||
MessageRepository.isSystemAccount(item.peerKey)
|
||||
val isFreddyVerified = item.peerUsername.equals("freddy", ignoreCase = true) ||
|
||||
item.peerTitle.equals("freddy", ignoreCase = true)
|
||||
if (item.peerVerified > 0 || isRosettaOfficial || isFreddyVerified) {
|
||||
val isOfficialByKey = MessageRepository.isSystemAccount(item.peerKey)
|
||||
if (item.peerVerified > 0 || isOfficialByKey) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
VerifiedBadge(
|
||||
verified = if (item.peerVerified > 0) item.peerVerified else 1,
|
||||
|
||||
@@ -228,9 +228,22 @@ fun DateHeader(
|
||||
* with sequential wave animation (scale + vertical offset + opacity).
|
||||
*/
|
||||
@Composable
|
||||
fun TypingIndicator(isDarkTheme: Boolean) {
|
||||
val typingColor = if (isDarkTheme) Color(0xFF54A9EB) else Color.White.copy(alpha = 0.7f)
|
||||
fun TypingIndicator(
|
||||
isDarkTheme: Boolean,
|
||||
typingDisplayName: String = "",
|
||||
typingSenderPublicKey: String = ""
|
||||
) {
|
||||
val typingColor = Color(0xFF54A9EB)
|
||||
val senderTypingColor =
|
||||
remember(typingSenderPublicKey, isDarkTheme) {
|
||||
if (typingSenderPublicKey.isBlank()) {
|
||||
typingColor
|
||||
} else {
|
||||
groupSenderLabelColor(typingSenderPublicKey, isDarkTheme)
|
||||
}
|
||||
}
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "typing")
|
||||
val normalizedDisplayName = remember(typingDisplayName) { typingDisplayName.trim() }
|
||||
|
||||
// Each dot animates through a 0→1→0 cycle, staggered by 150 ms
|
||||
val dotProgresses = List(3) { index ->
|
||||
@@ -253,7 +266,30 @@ fun TypingIndicator(isDarkTheme: Boolean) {
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = "typing", fontSize = 13.sp, color = typingColor)
|
||||
if (normalizedDisplayName.isBlank()) {
|
||||
Text(
|
||||
text = "typing",
|
||||
fontSize = 13.sp,
|
||||
color = typingColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "$normalizedDisplayName ",
|
||||
fontSize = 13.sp,
|
||||
color = senderTypingColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = "typing",
|
||||
fontSize = 13.sp,
|
||||
color = typingColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
|
||||
// Fixed-size canvas — big enough for bounce, never changes layout
|
||||
@@ -333,12 +369,6 @@ fun MessageBubble(
|
||||
val swipeProgress = (kotlin.math.abs(animatedOffset) / swipeThreshold).coerceIn(0f, 1f)
|
||||
|
||||
// Selection animations
|
||||
val selectionScale by
|
||||
animateFloatAsState(
|
||||
targetValue = if (isSelected) 0.95f else 1f,
|
||||
animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f),
|
||||
label = "selectionScale"
|
||||
)
|
||||
val selectionAlpha by
|
||||
animateFloatAsState(
|
||||
targetValue = if (isSelected) 0.85f else 1f,
|
||||
@@ -785,8 +815,6 @@ fun MessageBubble(
|
||||
.then(bubbleWidthModifier)
|
||||
.graphicsLayer {
|
||||
this.alpha = selectionAlpha
|
||||
this.scaleX = selectionScale
|
||||
this.scaleY = selectionScale
|
||||
}
|
||||
.combinedClickable(
|
||||
indication = null,
|
||||
|
||||
@@ -23,7 +23,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
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.ChatViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
|
||||
@@ -78,6 +78,8 @@ data class MentionCandidate(
|
||||
val publicKey: String
|
||||
)
|
||||
|
||||
private const val LARGE_INPUT_ANALYSIS_THRESHOLD = 2000
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun MessageInputBar(
|
||||
@@ -87,7 +89,6 @@ fun MessageInputBar(
|
||||
isDarkTheme: Boolean,
|
||||
backgroundColor: Color,
|
||||
textColor: Color,
|
||||
placeholderColor: Color,
|
||||
secondaryTextColor: Color,
|
||||
replyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
|
||||
isForwardMode: Boolean = false,
|
||||
@@ -97,7 +98,6 @@ fun MessageInputBar(
|
||||
isBlocked: Boolean = false,
|
||||
showEmojiPicker: Boolean = false,
|
||||
onToggleEmojiPicker: (Boolean) -> Unit = {},
|
||||
focusRequester: FocusRequester? = null,
|
||||
coordinator: KeyboardTransitionCoordinator,
|
||||
displayReplyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
|
||||
onReplyClick: (String) -> Unit = {},
|
||||
@@ -187,7 +187,9 @@ fun MessageInputBar(
|
||||
|
||||
// Update coordinator through snapshotFlow (no recomposition)
|
||||
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 heightChanged = kotlin.math.abs((currentImeHeight - lastStableKeyboardHeight).value) > 5f
|
||||
if (heightChanged && currentImeHeight.value > 0) {
|
||||
@@ -200,9 +202,13 @@ fun MessageInputBar(
|
||||
|
||||
isKeyboardVisible = currentImeHeight > 50.dp
|
||||
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()
|
||||
lastStableKeyboardHeight = currentImeHeight
|
||||
if (kotlin.math.abs((currentImeHeight - lastStableKeyboardHeight).value) > 2f) {
|
||||
lastStableKeyboardHeight = currentImeHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,7 +238,11 @@ fun MessageInputBar(
|
||||
}
|
||||
val mentionPattern = remember { Regex("@([\\w\\d_]*)$") }
|
||||
val mentionedPattern = remember { Regex("@([\\w\\d_]{1,})") }
|
||||
val mentionMatch = remember(value, isGroupChat) { if (isGroupChat) mentionPattern.find(value) else null }
|
||||
val skipHeavyInputAnalysis = remember(value) { value.length > LARGE_INPUT_ANALYSIS_THRESHOLD }
|
||||
val mentionMatch =
|
||||
remember(value, isGroupChat, skipHeavyInputAnalysis) {
|
||||
if (isGroupChat && !skipHeavyInputAnalysis) mentionPattern.find(value) else null
|
||||
}
|
||||
|
||||
val mentionQuery =
|
||||
remember(mentionMatch) {
|
||||
@@ -246,9 +256,10 @@ fun MessageInputBar(
|
||||
value,
|
||||
mentionCandidates,
|
||||
mentionQuery,
|
||||
shouldShowMentionSuggestions
|
||||
shouldShowMentionSuggestions,
|
||||
skipHeavyInputAnalysis
|
||||
) {
|
||||
if (!shouldShowMentionSuggestions) {
|
||||
if (!shouldShowMentionSuggestions || skipHeavyInputAnalysis) {
|
||||
emptyList()
|
||||
} else {
|
||||
val mentionedInText =
|
||||
@@ -274,8 +285,12 @@ fun MessageInputBar(
|
||||
}
|
||||
|
||||
val emojiWordMatch =
|
||||
remember(value, selectionStart, selectionEnd) {
|
||||
EmojiKeywordSuggester.findWordAtCursor(value, selectionStart, selectionEnd)
|
||||
remember(value, selectionStart, selectionEnd, skipHeavyInputAnalysis) {
|
||||
if (skipHeavyInputAnalysis) {
|
||||
null
|
||||
} else {
|
||||
EmojiKeywordSuggester.findWordAtCursor(value, selectionStart, selectionEnd)
|
||||
}
|
||||
}
|
||||
|
||||
val emojiSuggestions =
|
||||
|
||||
@@ -357,6 +357,15 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
|
||||
|
||||
try {
|
||||
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
|
||||
|
||||
// 🔥 Собираем все позиции эмодзи (и 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? {
|
||||
return try {
|
||||
val inputStream = getContext().assets.open("emoji/$unified.png")
|
||||
@@ -592,10 +610,10 @@ fun AppleEmojiText(
|
||||
null
|
||||
}
|
||||
)
|
||||
setTextWithEmojisIfNeeded(text)
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
view.setTextWithEmojis(text)
|
||||
view.setTextColor(color.toArgb())
|
||||
view.setTypeface(view.typeface, typefaceStyle)
|
||||
// 🔥 Обновляем maxLines и ellipsize
|
||||
@@ -625,6 +643,7 @@ fun AppleEmojiText(
|
||||
null
|
||||
}
|
||||
)
|
||||
view.setTextWithEmojisIfNeeded(text)
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
@@ -644,6 +663,7 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
) : android.widget.TextView(context, attrs, defStyleAttr) {
|
||||
|
||||
companion object {
|
||||
private const val LARGE_TEXT_RENDER_THRESHOLD = 4000
|
||||
private val EMOJI_PATTERN = AppleEmojiEditTextView.EMOJI_PATTERN
|
||||
// 🔥 Паттерн для :emoji_XXXX: формата (из React Native)
|
||||
private val EMOJI_CODE_PATTERN = Pattern.compile(":emoji_([a-fA-F0-9_-]+):")
|
||||
@@ -668,6 +688,10 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
private var mentionsEnabled: Boolean = false
|
||||
private var mentionClickCallback: ((String) -> Unit)? = null
|
||||
private var clickableSpanPressStartCallback: (() -> Unit)? = null
|
||||
private var lastRenderedText: String? = null
|
||||
private var lastRenderedLinksEnabled: Boolean = false
|
||||
private var lastRenderedMentionsEnabled: Boolean = false
|
||||
private var lastRenderedMentionClickable: Boolean = false
|
||||
|
||||
// 🔥 Long press callback для selection в MessageBubble
|
||||
var onLongClickCallback: (() -> Unit)? = null
|
||||
@@ -799,6 +823,17 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
fun setTextWithEmojis(text: String) {
|
||||
val isLargeText = text.length > LARGE_TEXT_RENDER_THRESHOLD
|
||||
val processMentions = mentionsEnabled && !isLargeText
|
||||
val processLinks = linksEnabled && !isLargeText
|
||||
val processEmoji = !isLargeText || containsEmojiHints(text)
|
||||
|
||||
// Для длинных логов (без emoji/links/mentions) не запускаем дорогой regex/span пайплайн.
|
||||
if (!processEmoji && !processMentions && !processLinks) {
|
||||
setText(text)
|
||||
return
|
||||
}
|
||||
|
||||
// 🔥 Сначала заменяем :emoji_XXXX: на PNG изображения
|
||||
val spannable = SpannableStringBuilder(text)
|
||||
|
||||
@@ -877,17 +912,33 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
if (mentionsEnabled) {
|
||||
if (processMentions) {
|
||||
addMentionHighlights(spannable)
|
||||
}
|
||||
|
||||
// 🔥 5. Добавляем кликабельные ссылки после обработки эмодзи
|
||||
if (linksEnabled) {
|
||||
if (processLinks) {
|
||||
addClickableLinks(spannable)
|
||||
}
|
||||
|
||||
|
||||
setText(spannable)
|
||||
}
|
||||
|
||||
fun setTextWithEmojisIfNeeded(text: String) {
|
||||
val mentionClickable = mentionsEnabled && mentionClickCallback != null
|
||||
if (lastRenderedText == text &&
|
||||
lastRenderedLinksEnabled == linksEnabled &&
|
||||
lastRenderedMentionsEnabled == mentionsEnabled &&
|
||||
lastRenderedMentionClickable == mentionClickable
|
||||
) {
|
||||
return
|
||||
}
|
||||
setTextWithEmojis(text)
|
||||
lastRenderedText = text
|
||||
lastRenderedLinksEnabled = linksEnabled
|
||||
lastRenderedMentionsEnabled = mentionsEnabled
|
||||
lastRenderedMentionClickable = mentionClickable
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 Добавляет кликабельные ссылки (URL, email, телефоны) в spannable
|
||||
@@ -1037,4 +1088,14 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun containsEmojiHints(text: String): Boolean {
|
||||
if (text.indexOf(":emoji_") >= 0) return true
|
||||
for (ch in text) {
|
||||
if (Character.isSurrogate(ch) || ch == '\u200D' || ch == '\uFE0F') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1915,12 +1915,6 @@ private fun CollapsingOtherProfileHeader(
|
||||
|
||||
val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress)
|
||||
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress)
|
||||
val isRosettaOfficial =
|
||||
name.equals("Rosetta", ignoreCase = true) ||
|
||||
username.equals("rosetta", ignoreCase = true)
|
||||
val isFreddyOfficial =
|
||||
name.equals("freddy", ignoreCase = true) ||
|
||||
username.equals("freddy", ignoreCase = true)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой
|
||||
@@ -2182,7 +2176,7 @@ private fun CollapsingOtherProfileHeader(
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
if (verified > 0 || isRosettaOfficial || isFreddyOfficial) {
|
||||
if (verified > 0 || isSystemAccount) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.padding(start = 4.dp)
|
||||
|
||||
@@ -59,6 +59,7 @@ import androidx.palette.graphics.Palette as AndroidPalette
|
||||
import com.rosetta.messenger.biometric.BiometricAuthManager
|
||||
import com.rosetta.messenger.biometric.BiometricAvailability
|
||||
import com.rosetta.messenger.biometric.BiometricPreferences
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
|
||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||
@@ -1146,12 +1147,7 @@ private fun CollapsingProfileHeader(
|
||||
val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress)
|
||||
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress)
|
||||
val rosettaBadgeBlue = Color(0xFF1DA1F2)
|
||||
val isRosettaOfficial =
|
||||
name.equals("Rosetta", ignoreCase = true) ||
|
||||
username.equals("rosetta", ignoreCase = true)
|
||||
val isFreddyOfficial =
|
||||
name.equals("freddy", ignoreCase = true) ||
|
||||
username.equals("freddy", ignoreCase = true)
|
||||
val isOfficialByKey = MessageRepository.isSystemAccount(publicKey)
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) {
|
||||
// Expansion fraction — computed early so gradient can fade during expansion
|
||||
@@ -1416,7 +1412,7 @@ private fun CollapsingProfileHeader(
|
||||
modifier = Modifier.widthIn(max = 220.dp).alignByBaseline(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
if (verified > 0 || isRosettaOfficial || isFreddyOfficial) {
|
||||
if (verified > 0 || isOfficialByKey) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.padding(start = 4.dp)
|
||||
|
||||
Reference in New Issue
Block a user