feat(chat-input): привести lock flow записи ГС к Telegram (геометрия и анимации)

This commit is contained in:
2026-04-19 21:37:55 +05:00
parent 5e6d66b762
commit b32d8ed061
7 changed files with 741 additions and 325 deletions

View File

@@ -32,8 +32,7 @@ flowchart TB
end end
subgraph DI["Hilt Singleton Graph"] subgraph DI["Hilt Singleton Graph"]
D1["ProtocolGateway"] D1["ProtocolGateway -> ProtocolRuntime"]
D1A["ProtocolRuntime"]
D2["SessionCoordinator"] D2["SessionCoordinator"]
D3["IdentityGateway"] D3["IdentityGateway"]
D4["AccountManager / PreferencesManager"] D4["AccountManager / PreferencesManager"]
@@ -42,54 +41,57 @@ flowchart TB
subgraph CHAT_UI["Chat UI Orchestration"] subgraph CHAT_UI["Chat UI Orchestration"]
C1["ChatDetailScreen / ChatsListScreen"] C1["ChatDetailScreen / ChatsListScreen"]
C2["ChatViewModel (host)"] C2["ChatViewModel (host-state)"]
C3["Messages/Voice/Attachments/Typing ViewModel"] C3["Feature VM: Messages/Voice/Attachments/Typing"]
C4["Messages/Forward/Attachments Coordinator"] C4["Coordinators: Messages/Forward/Attachments"]
end end
subgraph CHAT_DOMAIN["Chat Domain UseCases"] subgraph CHAT_DOMAIN["Chat Domain UseCases"]
U1["SendText / SendMedia / SendForward"] U1["SendText / SendMedia / SendForward"]
U2["SendVoice / SendTyping / SendReadReceipt"] U2["SendVoice / SendTyping / SendReadReceipt"]
U3["CreateAttachment / EncryptAndUpload"] U3["CreateAttachment / EncryptAndUpload / VideoCircle"]
end end
subgraph SESSION["Session / Identity Runtime"] subgraph SESSION["Session / Identity Runtime"]
S1["SessionStore"] S1["SessionStore / SessionReducer"]
S2["SessionReducer"] S2["IdentityStore / AppSessionCoordinator"]
S3["IdentityStore"]
S4["AppSessionCoordinator"]
end end
subgraph NET["Network Runtime"] subgraph NET["Network Runtime"]
N0["ProtocolRuntime"] N0["ProtocolRuntime"]
N1C["RuntimeComposition"] N1["RuntimeComposition (wiring only)"]
N1A["ProtocolManager (compat facade)"] N2["RuntimeConnectionControlFacade"]
N2["Protocol"] N3["RuntimeDirectoryFacade"]
N3["PacketSubscriptionRegistry"] N4["RuntimePacketIoFacade"]
N4["ReadyPacketGate"] N5["Assemblies: Transport / Messaging / State / Routing"]
N6["ProtocolInstanceManager -> Protocol"]
N7["ProtocolManager (legacy compat)"]
end end
subgraph DATA["Data + Persistence"] subgraph DATA["Data + Persistence"]
R1["MessageRepository"] R1["MessageRepository / GroupRepository"]
R2["GroupRepository"] R2["Room: RosettaDatabase"]
R3["Room: RosettaDatabase"]
end end
ENTRY --> DI ENTRY --> DI
DI --> SESSION DI --> SESSION
DI --> NET
DI --> DATA DI --> DATA
DI --> CHAT_UI DI --> CHAT_UI
DI --> N0
CHAT_UI --> CHAT_DOMAIN CHAT_UI --> CHAT_DOMAIN
CHAT_UI --> DATA CHAT_UI --> R1
CHAT_DOMAIN --> D1 CHAT_DOMAIN --> D1
D1 --> D1A D1 --> N0
D1A --> N1C N0 --> N1
N1A --> N1C N1 --> N2
SESSION --> NET N1 --> N3
DATA --> NET N1 --> N4
DATA --> R3 N1 --> N5
N1C --> N2 N5 --> N6
N7 --> N0
SESSION --> N0
R1 --> N0
R1 --> R2
``` ```
--- ---
@@ -216,41 +218,40 @@ stateDiagram-v2
На hot-path `ProtocolRuntime` берет runtime API (`RuntimeConnectionControlFacade`/`RuntimeDirectoryFacade`/`RuntimePacketIoFacade`) напрямую из `RuntimeComposition`, поэтому лишний proxy-hop через публичные методы composition не используется. На hot-path `ProtocolRuntime` берет runtime API (`RuntimeConnectionControlFacade`/`RuntimeDirectoryFacade`/`RuntimePacketIoFacade`) напрямую из `RuntimeComposition`, поэтому лишний proxy-hop через публичные методы composition не используется.
```mermaid ```mermaid
flowchart TB flowchart TB
PR["ProtocolRuntime (ProtocolGateway impl)"] --> RC["RuntimeComposition"] PR["ProtocolRuntime (ProtocolGateway impl)"] --> RC["RuntimeComposition"]
RC --> RCC["RuntimeConnectionControlFacade"] RC --> RCC["RuntimeConnectionControlFacade"]
RC --> RDF["RuntimeDirectoryFacade"] RC --> RDF["RuntimeDirectoryFacade"]
RC --> RPF["RuntimePacketIoFacade"] RC --> RPF["RuntimePacketIoFacade"]
RC --> CO["ConnectionOrchestrator"]
RC --> PIM["ProtocolInstanceManager"] RC --> RTA["RuntimeTransportAssembly"]
RC --> RLSM["RuntimeLifecycleStateMachine"] RC --> RMA["RuntimeMessagingAssembly"]
RC --> RIC["RuntimeInitializationCoordinator"] RC --> RSA["RuntimeStateAssembly"]
RC --> PLSS["ProtocolLifecycleStateStoreImpl"] RC --> RRA["RuntimeRoutingAssembly"]
RC --> OPFT["OwnProfileFallbackTimerService"]
RC --> ARS["AuthRestoreService"] RTA --> PIM["ProtocolInstanceManager"]
RC --> RSC["RuntimeShutdownCoordinator"] RTA --> PSF["PacketSubscriptionFacade"]
RC --> CER["ConnectionEventRouter"] RTA --> NCF["NetworkConnectivityFacade"]
RC --> NCF["NetworkConnectivityFacade"]
RC --> PLC["ProtocolLifecycleCoordinator"] RMA --> SC["SyncCoordinator"]
RC --> PAC["ProtocolAccountSessionCoordinator"] RMA --> PROUTER["PacketRouter"]
RC --> RPDC["ReadyPacketDispatchCoordinator"] RMA --> OMPS["OutgoingMessagePipelineService"]
RC --> PABC["ProtocolPostAuthBootstrapCoordinator"] RMA --> CSB["CallSignalBridge"]
RC --> BC["BootstrapCoordinator"] RMA --> IPR["InboundPacketHandlerRegistrar"]
RC --> SC["SyncCoordinator"]
RC --> PT["PresenceTypingService"] RSA --> RLSM["RuntimeLifecycleStateMachine"]
RC --> PR["PacketRouter"] RSA --> BC["BootstrapCoordinator"]
RC --> OPS["OwnProfileSyncService"] RSA --> RPG["ReadyPacketGate"]
RC --> RQ["RetryQueueService"] RSA --> PLSS["ProtocolLifecycleStateStoreImpl"]
RC --> ABC["AuthBootstrapCoordinator"]
RC --> NRW["NetworkReconnectWatcher"] RRA --> SUP["ProtocolConnectionSupervisor"]
RC --> DVS["DeviceVerificationService"] RRA --> CER["ConnectionEventRouter"]
RC --> CSB["CallSignalBridge"]
RC --> PSF["PacketSubscriptionFacade"] CER --> CO["ConnectionOrchestrator"]
RC --> PSR["PacketSubscriptionRegistry"] CER --> PLC["ProtocolLifecycleCoordinator"]
RC --> IPR["InboundPacketHandlerRegistrar"] CER --> PAC["ProtocolAccountSessionCoordinator"]
RC --> IQ["InboundTaskQueueService"] CER --> RPDC["ReadyPacketDispatchCoordinator"]
RC --> SUP["ProtocolConnectionSupervisor"]
RC --> RPG["ReadyPacketGate"]
PIM --> P["Protocol (WebSocket + packet codec)"] PIM --> P["Protocol (WebSocket + packet codec)"]
``` ```
@@ -267,12 +268,16 @@ stateDiagram-v2
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant Feature as Feature/Service participant Feature as Feature/Service
participant PM as Runtime API (Core/Facade) participant PR as ProtocolRuntime
participant RPF as RuntimePacketIoFacade
participant PSF as PacketSubscriptionFacade
participant REG as PacketSubscriptionRegistry participant REG as PacketSubscriptionRegistry
participant P as Protocol participant P as Protocol
Feature->>PM: waitPacket(0x03, callback) Feature->>PR: waitPacket(0x03, callback)
PM->>REG: addCallback(0x03, callback) PR->>RPF: waitPacket(0x03, callback)
RPF->>PSF: waitPacket(0x03, callback)
PSF->>REG: addCallback(0x03, callback)
REG->>P: waitPacket(0x03, protocolBridge) [once per packetId] REG->>P: waitPacket(0x03, protocolBridge) [once per packetId]
P-->>REG: Packet(0x03) P-->>REG: Packet(0x03)
@@ -319,12 +324,14 @@ flowchart LR
CVM --> COORD["Messages/Forward/Attachments Coordinator"] CVM --> COORD["Messages/Forward/Attachments Coordinator"]
CVM --> UC["domain/chats/usecase/*"] CVM --> UC["domain/chats/usecase/*"]
COORD --> UC COORD --> UC
UC --> GW["ProtocolGateway.sendMessageWithRetry"] UC --> GW["ProtocolGateway.send / sendMessageWithRetry"]
GW --> PR["ProtocolRuntime"] GW --> PR["ProtocolRuntime"]
PR --> RC["RuntimeComposition"] PR --> RPF["RuntimePacketIoFacade"]
RC --> RQ["RetryQueueService"] RPF --> OMP["OutgoingMessagePipelineService"]
RC --> RG["ReadyPacketGate"] OMP --> RQ["RetryQueueService"]
RC --> P["Protocol.sendPacket"] OMP --> RR["RuntimeRoutingAssembly"]
RR --> RG["ReadyPacketGate / ReadyPacketDispatchCoordinator"]
RG --> P["Protocol.sendPacket"]
``` ```
### 7.2 Декомпозиция ChatViewModel (host + feature/coordinator слой) ### 7.2 Декомпозиция ChatViewModel (host + feature/coordinator слой)
@@ -351,17 +358,19 @@ flowchart TB
CD --> TVM["TypingViewModel"] CD --> TVM["TypingViewModel"]
CD --> VVM["VoiceRecordingViewModel"] CD --> VVM["VoiceRecordingViewModel"]
CD --> AVM["AttachmentsViewModel"] CD --> AVM["AttachmentsViewModel"]
MVM --> CVM["ChatViewModel"] MVM --> CVM["ChatViewModel (host-state)"]
TVM --> CVM TVM --> CVM
VVM --> CVM VVM --> CVM
AVM --> CVM AVM --> CVM
CVM --> MCO["MessagesCoordinator"] CVM --> MCO["MessagesCoordinator"]
CVM --> FCO["ForwardCoordinator"] CVM --> FCO["ForwardCoordinator"]
CVM --> ACO["AttachmentsCoordinator"] CVM --> ACO["AttachmentsCoordinator"]
AVM --> AFCO["AttachmentsFeatureCoordinator"]
CVM --> U["domain/chats/usecase/*"] CVM --> U["domain/chats/usecase/*"]
MCO --> U MCO --> U
FCO --> U FCO --> U
ACO --> U ACO --> U
AFCO --> U
``` ```
Важно: после вынесения `MessagesCoordinator`, `ForwardCoordinator` и `AttachmentsCoordinator` `ChatViewModel` выступает как host-state и bridge для feature/coordinator подсистем. Важно: после вынесения `MessagesCoordinator`, `ForwardCoordinator` и `AttachmentsCoordinator` `ChatViewModel` выступает как host-state и bridge для feature/coordinator подсистем.
@@ -388,7 +397,10 @@ sequenceDiagram
participant SC as SessionCoordinatorImpl participant SC as SessionCoordinatorImpl
participant SS as SessionStore participant SS as SessionStore
participant PG as ProtocolGateway participant PG as ProtocolGateway
participant RC as RuntimeComposition participant PR as ProtocolRuntime
participant RCC as RuntimeConnectionControlFacade
participant RRA as RuntimeRoutingAssembly
participant RSA as RuntimeStateAssembly
participant AM as AccountManager participant AM as AccountManager
UI->>SC: bootstrapAuthenticatedSession(account, reason) UI->>SC: bootstrapAuthenticatedSession(account, reason)
@@ -400,9 +412,12 @@ sequenceDiagram
SC->>AM: setCurrentAccount(public) SC->>AM: setCurrentAccount(public)
SC->>SS: dispatch(Ready) SC->>SS: dispatch(Ready)
RC-->>RC: HANDSHAKE -> AUTHENTICATED -> BOOTSTRAPPING PG->>PR: runtime API calls
RC-->>RC: SyncCompleted + OwnProfileResolved PR->>RCC: connection/auth commands
RC-->>RC: connectionLifecycleState = READY RCC->>RRA: post(ConnectionEvent.*)
RRA-->>RRA: Supervisor + Router route events
RRA-->>RSA: apply lifecycle transitions
RSA-->>RSA: AUTHENTICATED -> BOOTSTRAPPING -> READY
``` ```
Важно: `SessionState.Ready` (app-session готова) и `connectionLifecycleState = READY` (сеть готова) — это разные state-модели. Важно: `SessionState.Ready` (app-session готова) и `connectionLifecycleState = READY` (сеть готова) — это разные state-модели.
@@ -429,6 +444,9 @@ stateDiagram-v2
HANDSHAKING --> AUTHENTICATED HANDSHAKING --> AUTHENTICATED
AUTHENTICATED --> BOOTSTRAPPING AUTHENTICATED --> BOOTSTRAPPING
BOOTSTRAPPING --> READY BOOTSTRAPPING --> READY
READY --> HANDSHAKING
AUTHENTICATED --> DISCONNECTED
BOOTSTRAPPING --> DISCONNECTED
READY --> DISCONNECTED READY --> DISCONNECTED
DEVICE_VERIFICATION_REQUIRED --> CONNECTING DEVICE_VERIFICATION_REQUIRED --> CONNECTING
``` ```
@@ -519,13 +537,12 @@ stateDiagram-v2
## 11. Что осталось как технический долг ## 11. Что осталось как технический долг
Актуальные открытые хвосты: Актуальные открытые хвосты:
- `RuntimeComposition` остается composition-root, но уже существенно сжат (около 501 строки) после выноса `RuntimeTransportAssembly`, `RuntimeMessagingAssembly`, `RuntimeStateAssembly`, `RuntimeRoutingAssembly` и удаления публичных proxy-методов; следующий шаг — перенос части lifecycle/orchestration helper-кода в отдельные domain-oriented service/adapters. - `RuntimeComposition` остается composition-root (около 501 строки): публичные proxy-методы уже убраны, но внутри все еще смешаны wiring и часть helper-логики (`setupStateMonitoring`, event-bridge, log helpers). Следующий шаг: вынести эти helper-блоки в отдельные adapters/services.
- `ProtocolRuntime` и `ProtocolRuntimePort` все еще имеют широкий proxy-surface; нужен audit методов и дальнейшее сужение публичного API по use-case группам. - `ProtocolRuntime` + `ProtocolRuntimePort` все еще имеют широкий API surface (connection + directory + packet IO + call signaling + debug). Нужен audit и сужение публичных контрактов по use-case группам.
- `ChatViewModel` остается крупным host-классом (state + bridge/proxy API к feature/coordinator/use-case слоям). - `ChatViewModel` остается очень крупным host-классом (около 4391 строки) с большим bridge/proxy surface к feature/coordinator/use-case слоям.
- High-level media сценарии теперь в `AttachmentsFeatureCoordinator`, но остаются крупными и требуют дальнейшей декомпозиции на более узкие coordinator/service/use-case блоки. - `AttachmentsFeatureCoordinator` остается крупным (около 761 строки): high-level media сценарии стоит резать на более узкие upload/transform/packet-assembly сервисы.
- Тестовое покрытие архитектурных слоев все еще недостаточно: - Тестовое покрытие архитектурно-критичных слоев недостаточно: `app/src/test` = 7, `app/src/androidTest` = 1; не покрыты runtime-routing/lifecycle компоненты (`RuntimeRoutingAssembly`, `ConnectionEventRouter`, `ProtocolLifecycle*`, `ReadyPacketDispatchCoordinator`) и chat coordinators (`Messages/Forward/Attachments*`).
- сейчас в `app/src/test` всего 7 unit-тестов (в основном crypto/data/helpers), в `app/src/androidTest` — 1 тест; - В runtime все еще несколько точек входа (`ProtocolRuntime`, `ProtocolRuntimeAccess`, `ProtocolManager` legacy), что повышает cognitive load; целевой шаг — дальнейшее сокращение legacy/static call-sites.
- не покрыты network runtime/coordinator слои (`RuntimeComposition`, `ConnectionEventRouter`, `ProtocolLifecycle*`, `ReadyPacketDispatchCoordinator`) и chat orchestration (`Messages/Forward/Attachments*`).
Уже закрыто и больше не считается техдолгом: Уже закрыто и больше не считается техдолгом:
- `UiDependencyAccess.get(...)` удален из `ui/*`. - `UiDependencyAccess.get(...)` удален из `ui/*`.

View File

@@ -27,10 +27,8 @@
<application <application
android:name=".RosettaApplication" android:name=".RosettaApplication"
android:allowBackup="true" android:allowBackup="false"
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"

View File

@@ -1,6 +1,7 @@
package com.rosetta.messenger.data package com.rosetta.messenger.data
import android.content.Context import android.content.Context
import com.rosetta.messenger.BuildConfig
import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.database.* import com.rosetta.messenger.database.*
@@ -266,7 +267,7 @@ class MessageRepository @Inject constructor(
try { try {
CryptoManager.encryptWithPassword(messageText, privateKey) CryptoManager.encryptWithPassword(messageText, privateKey)
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("ReleaseNotes", "❌ encryptWithPassword failed", e) if (BuildConfig.DEBUG) android.util.Log.e("ReleaseNotes", "❌ encryptWithPassword failed", e)
return null return null
} }
@@ -351,12 +352,12 @@ class MessageRepository @Inject constructor(
suspend fun checkAndSendVersionUpdateMessage() { suspend fun checkAndSendVersionUpdateMessage() {
val account = currentAccount val account = currentAccount
if (account == null) { if (account == null) {
android.util.Log.w("ReleaseNotes", "❌ currentAccount is null, skipping update message") if (BuildConfig.DEBUG) android.util.Log.w("ReleaseNotes", "❌ currentAccount is null, skipping update message")
return return
} }
val privateKey = currentPrivateKey val privateKey = currentPrivateKey
if (privateKey == null) { if (privateKey == null) {
android.util.Log.w("ReleaseNotes", "❌ currentPrivateKey is null, skipping update message") if (BuildConfig.DEBUG) android.util.Log.w("ReleaseNotes", "❌ currentPrivateKey is null, skipping update message")
return return
} }
val prefs = context.getSharedPreferences("rosetta_system_${account}", Context.MODE_PRIVATE) val prefs = context.getSharedPreferences("rosetta_system_${account}", Context.MODE_PRIVATE)
@@ -364,7 +365,7 @@ class MessageRepository @Inject constructor(
val currentVersion = com.rosetta.messenger.BuildConfig.VERSION_NAME val currentVersion = com.rosetta.messenger.BuildConfig.VERSION_NAME
val currentKey = "${currentVersion}_${ReleaseNotes.noticeHash}" val currentKey = "${currentVersion}_${ReleaseNotes.noticeHash}"
android.util.Log.d("ReleaseNotes", "checkUpdate: version=$currentVersion, lastKey=$lastNoticeKey, currentKey=$currentKey, match=${lastNoticeKey == currentKey}") if (BuildConfig.DEBUG) android.util.Log.d("ReleaseNotes", "checkUpdate: version=$currentVersion, lastKey=$lastNoticeKey, currentKey=$currentKey, match=${lastNoticeKey == currentKey}")
if (lastNoticeKey != currentKey) { if (lastNoticeKey != currentKey) {
// Delete the previous message for this version (if any) // Delete the previous message for this version (if any)
@@ -375,15 +376,15 @@ class MessageRepository @Inject constructor(
} }
val messageId = addUpdateSystemMessage(ReleaseNotes.getNotice(currentVersion)) val messageId = addUpdateSystemMessage(ReleaseNotes.getNotice(currentVersion))
android.util.Log.d("ReleaseNotes", "addUpdateSystemMessage result: messageId=$messageId") if (BuildConfig.DEBUG) android.util.Log.d("ReleaseNotes", "addUpdateSystemMessage result: messageId=$messageId")
if (messageId != null) { if (messageId != null) {
prefs.edit() prefs.edit()
.putString("lastNoticeKey", currentKey) .putString("lastNoticeKey", currentKey)
.putString("lastNoticeMessageId_$currentVersion", messageId) .putString("lastNoticeMessageId_$currentVersion", messageId)
.apply() .apply()
android.util.Log.d("ReleaseNotes", "✅ Update message saved successfully") if (BuildConfig.DEBUG) android.util.Log.d("ReleaseNotes", "✅ Update message saved successfully")
} else { } else {
android.util.Log.e("ReleaseNotes", "❌ Failed to create update message") if (BuildConfig.DEBUG) android.util.Log.e("ReleaseNotes", "❌ Failed to create update message")
} }
} }
} }
@@ -881,7 +882,7 @@ class MessageRepository @Inject constructor(
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey) MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
} catch (e: Exception) { } catch (e: Exception) {
// Fallback: если дешифровка не удалась (напр. CALL с encrypted empty content) // Fallback: если дешифровка не удалась (напр. CALL с encrypted empty content)
android.util.Log.w("MessageRepository", "Decryption fallback for ${messageId.take(8)}: ${e.message}") if (BuildConfig.DEBUG) android.util.Log.w("MessageRepository", "Decryption fallback for ${messageId.take(8)}: ${e.message}")
"" ""
} }
} }
@@ -1326,7 +1327,7 @@ class MessageRepository @Inject constructor(
syncedOpponentsWithWrongStatus.forEach { opponentKey -> syncedOpponentsWithWrongStatus.forEach { opponentKey ->
runCatching { dialogDao.updateDialogFromMessages(account, opponentKey) } runCatching { dialogDao.updateDialogFromMessages(account, opponentKey) }
} }
android.util.Log.i( if (BuildConfig.DEBUG) android.util.Log.i(
"MessageRepository", "MessageRepository",
"✅ Normalized $normalizedSyncedCount synced own messages to DELIVERED" "✅ Normalized $normalizedSyncedCount synced own messages to DELIVERED"
) )
@@ -1335,14 +1336,14 @@ class MessageRepository @Inject constructor(
// Mark expired messages as ERROR (older than 80 seconds) // Mark expired messages as ERROR (older than 80 seconds)
val expiredCount = messageDao.markExpiredWaitingAsError(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS) val expiredCount = messageDao.markExpiredWaitingAsError(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS)
if (expiredCount > 0) { if (expiredCount > 0) {
android.util.Log.w("MessageRepository", "⚠️ Marked $expiredCount expired WAITING messages as ERROR") if (BuildConfig.DEBUG) android.util.Log.w("MessageRepository", "⚠️ Marked $expiredCount expired WAITING messages as ERROR")
} }
// Get remaining WAITING messages (younger than 80s) // Get remaining WAITING messages (younger than 80s)
val waitingMessages = messageDao.getWaitingMessages(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS) val waitingMessages = messageDao.getWaitingMessages(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS)
if (waitingMessages.isEmpty()) return if (waitingMessages.isEmpty()) return
android.util.Log.i("MessageRepository", "🔄 Retrying ${waitingMessages.size} WAITING messages") if (BuildConfig.DEBUG) android.util.Log.i("MessageRepository", "🔄 Retrying ${waitingMessages.size} WAITING messages")
for (entity in waitingMessages) { for (entity in waitingMessages) {
// Skip saved messages (should not happen, but guard) // Skip saved messages (should not happen, but guard)
@@ -1366,7 +1367,7 @@ class MessageRepository @Inject constructor(
privateKey privateKey
) )
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.w("MessageRepository", "⚠️ Cannot regenerate aesChachaKey for ${entity.messageId.take(8)}, sending without it") if (BuildConfig.DEBUG) android.util.Log.w("MessageRepository", "⚠️ Cannot regenerate aesChachaKey for ${entity.messageId.take(8)}, sending without it")
"" ""
} }
} }
@@ -1393,9 +1394,9 @@ class MessageRepository @Inject constructor(
// iOS parity: use retry mechanism for reconnect-resent messages too // iOS parity: use retry mechanism for reconnect-resent messages too
protocolClient.sendMessageWithRetry(packet) protocolClient.sendMessageWithRetry(packet)
android.util.Log.d("MessageRepository", "🔄 Resent WAITING message: ${entity.messageId.take(8)}") if (BuildConfig.DEBUG) android.util.Log.d("MessageRepository", "🔄 Resent WAITING message: ${entity.messageId.take(8)}")
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("MessageRepository", "❌ Failed to retry message ${entity.messageId.take(8)}: ${e.message}") if (BuildConfig.DEBUG) android.util.Log.e("MessageRepository", "❌ Failed to retry message ${entity.messageId.take(8)}: ${e.message}")
// Mark as ERROR if retry fails // Mark as ERROR if retry fails
messageDao.updateDeliveryStatus(account, entity.messageId, DeliveryStatus.ERROR.value) messageDao.updateDeliveryStatus(account, entity.messageId, DeliveryStatus.ERROR.value)
val dialogKey = getDialogKey(entity.toPublicKey) val dialogKey = getDialogKey(entity.toPublicKey)

View File

@@ -10,6 +10,7 @@ import android.graphics.BitmapFactory
import android.os.Build import android.os.Build
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import com.rosetta.messenger.BuildConfig
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.IconCompat
import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.FirebaseMessagingService
@@ -136,7 +137,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
/** Вызывается когда получено push-уведомление */ /** Вызывается когда получено push-уведомление */
override fun onMessageReceived(remoteMessage: RemoteMessage) { override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage) super.onMessageReceived(remoteMessage)
Log.d(TAG, "\u2709\ufe0f onMessageReceived: messageId=${remoteMessage.messageId} from=${remoteMessage.from} data=${remoteMessage.data} notif=${remoteMessage.notification?.body}") if (BuildConfig.DEBUG) Log.d(TAG, "\u2709\ufe0f onMessageReceived: messageId=${remoteMessage.messageId} from=${remoteMessage.from} data=${remoteMessage.data} notif=${remoteMessage.notification?.body}")
val data = remoteMessage.data val data = remoteMessage.data
val notificationTitle = remoteMessage.notification?.title?.trim().orEmpty() val notificationTitle = remoteMessage.notification?.title?.trim().orEmpty()
@@ -153,7 +154,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val hasNotificationContent = notificationTitle.isNotBlank() || notificationBody.isNotBlank() val hasNotificationContent = notificationTitle.isNotBlank() || notificationBody.isNotBlank()
if (!hasDataContent && !hasNotificationContent) { if (!hasDataContent && !hasNotificationContent) {
Log.d(TAG, "Silent/empty push ignored (iOS wake-up push)") if (BuildConfig.DEBUG) Log.d(TAG, "Silent/empty push ignored (iOS wake-up push)")
// Still trigger reconnect if WebSocket is disconnected // Still trigger reconnect if WebSocket is disconnected
protocolGateway.reconnectNowIfNeeded("silent_push") protocolGateway.reconnectNowIfNeeded("silent_push")
return return
@@ -226,14 +227,14 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
isReadEvent -> { isReadEvent -> {
val keysToClear = collectReadDialogKeys(data, dialogKey, senderPublicKey) val keysToClear = collectReadDialogKeys(data, dialogKey, senderPublicKey)
if (keysToClear.isEmpty()) { if (keysToClear.isEmpty()) {
Log.d(TAG, "READ push received but no dialog key in payload: $data") if (BuildConfig.DEBUG) Log.d(TAG, "READ push received but no dialog key in payload: $data")
} else { } else {
keysToClear.forEach { key -> keysToClear.forEach { key ->
cancelNotificationForChat(applicationContext, key) cancelNotificationForChat(applicationContext, key)
} }
val titleHints = collectReadTitleHints(data, keysToClear) val titleHints = collectReadTitleHints(data, keysToClear)
cancelMatchingActiveNotifications(keysToClear, titleHints) cancelMatchingActiveNotifications(keysToClear, titleHints)
Log.d( if (BuildConfig.DEBUG) Log.d(
TAG, TAG,
"READ push cleared notifications for keys=$keysToClear titles=$titleHints" "READ push cleared notifications for keys=$keysToClear titles=$titleHints"
) )
@@ -317,11 +318,11 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val lastTs = lastNotifTimestamps[dedupKey] val lastTs = lastNotifTimestamps[dedupKey]
if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) { if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) {
Log.d(TAG, "\ud83d\udeab Dedup BLOCKED notification for key=$dedupKey, delta=${now - lastTs}ms") if (BuildConfig.DEBUG) Log.d(TAG, "\ud83d\udeab Dedup BLOCKED notification for key=$dedupKey, delta=${now - lastTs}ms")
return // duplicate push — skip return // duplicate push — skip
} }
lastNotifTimestamps[dedupKey] = now lastNotifTimestamps[dedupKey] = now
Log.d(TAG, "\u2705 Showing notification for key=$dedupKey") if (BuildConfig.DEBUG) Log.d(TAG, "\u2705 Showing notification for key=$dedupKey")
val senderKey = senderPublicKey?.trim().orEmpty() val senderKey = senderPublicKey?.trim().orEmpty()
if (senderKey.isNotEmpty() && isDialogMuted(senderKey)) { if (senderKey.isNotEmpty() && isDialogMuted(senderKey)) {
return return
@@ -508,7 +509,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
} }
private fun pushCallLog(msg: String) { private fun pushCallLog(msg: String) {
Log.d(TAG, msg) if (BuildConfig.DEBUG) Log.d(TAG, msg)
try { try {
val dir = java.io.File(applicationContext.filesDir, "crash_reports") val dir = java.io.File(applicationContext.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs() if (!dir.exists()) dir.mkdirs()
@@ -534,7 +535,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
pushCallLog("wakeProtocolFromPush: authRestore=$restored account=${account.take(8)}") pushCallLog("wakeProtocolFromPush: authRestore=$restored account=${account.take(8)}")
protocolGateway.reconnectNowIfNeeded("push_$reason") protocolGateway.reconnectNowIfNeeded("push_$reason")
}.onFailure { error -> }.onFailure { error ->
Log.w(TAG, "wakeProtocolFromPush failed: ${error.message}") if (BuildConfig.DEBUG) Log.w(TAG, "wakeProtocolFromPush failed: ${error.message}")
} }
} }
@@ -717,7 +718,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
if (matchesDeterministicId || matchesDialogKey || matchesHint) { if (matchesDeterministicId || matchesDialogKey || matchesHint) {
manager.cancel(sbn.tag, sbn.id) manager.cancel(sbn.tag, sbn.id)
Log.d( if (BuildConfig.DEBUG) Log.d(
TAG, TAG,
"READ push fallback cancel id=${sbn.id} tag=${sbn.tag} " + "READ push fallback cancel id=${sbn.id} tag=${sbn.tag} " +
"channel=${notification.channelId} title='$title' " + "channel=${notification.channelId} title='$title' " +
@@ -726,7 +727,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
} }
} }
}.onFailure { error -> }.onFailure { error ->
Log.w(TAG, "cancelMatchingActiveNotifications failed: ${error.message}") if (BuildConfig.DEBUG) Log.w(TAG, "cancelMatchingActiveNotifications failed: ${error.message}")
} }
} }

View File

@@ -2471,7 +2471,9 @@ fun CallAttachment(
text = callUi.subtitle, text = callUi.subtitle,
fontSize = 12.sp, fontSize = 12.sp,
color = color =
if (callUi.isError) { if (callUi.isError && isOutgoing) {
Color.White.copy(alpha = 0.72f)
} else if (callUi.isError) {
Color(0xFFE55A5A) Color(0xFFE55A5A)
} else if (isOutgoing) { } else if (isOutgoing) {
Color.White.copy(alpha = 0.72f) Color.White.copy(alpha = 0.72f)

View File

@@ -563,7 +563,6 @@ fun MessageBubble(
Modifier.fillMaxWidth().pointerInput(isSystemSafeChat, textSelectionHelper?.isActive, isVoiceWaveGestureActive) { Modifier.fillMaxWidth().pointerInput(isSystemSafeChat, textSelectionHelper?.isActive, isVoiceWaveGestureActive) {
if (isSystemSafeChat) return@pointerInput if (isSystemSafeChat) return@pointerInput
if (textSelectionHelper?.isActive == true) return@pointerInput if (textSelectionHelper?.isActive == true) return@pointerInput
if (hasVoiceAttachmentForGesture) return@pointerInput
if (isVoiceWaveGestureActive) return@pointerInput if (isVoiceWaveGestureActive) return@pointerInput
// 🔥 Простой горизонтальный свайп для reply // 🔥 Простой горизонтальный свайп для reply
// Используем detectHorizontalDragGestures который лучше работает со // Используем detectHorizontalDragGestures который лучше работает со