feat: Update RosettaDatabase to include Message and Dialog entities, increment version to 2

feat: Implement message packets for sending and receiving messages, including delivery and read notifications

feat: Enhance ProtocolManager to handle message sending, delivery, and typing status with appropriate logging

feat: Refactor ChatDetailScreen to utilize ChatViewModel for managing chat state and message input

feat: Create ChatViewModel to manage chat messages, input state, and packet listeners for incoming messages

build: Add KSP plugin for annotation processing and configure Java 17 for the build environment
This commit is contained in:
k1ngsterr1
2026-01-10 22:15:27 +05:00
parent 286706188b
commit 6014d23d69
12 changed files with 1643 additions and 142 deletions

View File

@@ -4,6 +4,7 @@ import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -22,6 +23,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
@@ -37,18 +39,23 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.ui.unit.Dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rosetta.messenger.data.Message
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.network.DeliveryStatus
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.components.AppleEmojiPickerPanel
import com.rosetta.messenger.ui.components.AppleEmojiTextField
import androidx.compose.ui.text.font.FontFamily
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.*
/**
* Модель сообщения
* Модель сообщения (Legacy - для совместимости)
*/
data class ChatMessage(
val id: String,
@@ -62,6 +69,19 @@ enum class MessageStatus {
SENDING, SENT, DELIVERED, READ
}
// Extension для конвертации
private fun Message.toChatMessage() = ChatMessage(
id = messageId,
text = content,
isOutgoing = isFromMe,
timestamp = Date(timestamp),
status = when (deliveryStatus) {
DeliveryStatus.WAITING -> MessageStatus.SENDING
DeliveryStatus.DELIVERED -> if (isRead) MessageStatus.READ else MessageStatus.DELIVERED
DeliveryStatus.ERROR -> MessageStatus.SENT
}
)
/**
* Экран детального чата с пользователем
*/
@@ -70,12 +90,13 @@ enum class MessageStatus {
fun ChatDetailScreen(
user: SearchUser,
currentUserPublicKey: String,
currentUserPrivateKey: String,
isDarkTheme: Boolean,
onBack: () -> Unit,
onUserProfileClick: () -> Unit = {}
onUserProfileClick: () -> Unit = {},
viewModel: ChatViewModel = viewModel()
) {
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
val surfaceColor = if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val inputBackgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0)
@@ -85,28 +106,37 @@ fun ChatDetailScreen(
val chatTitle = if (isSavedMessages) "Saved Messages" else user.title.ifEmpty { user.publicKey.take(10) }
val chatSubtitle = if (isSavedMessages) "Notes" else if (user.online == 1) "online" else "last seen recently"
// Состояние сообщений
var messages by remember { mutableStateOf<List<ChatMessage>>(emptyList()) }
var inputText by remember { mutableStateOf("") }
// Состояние показа логов
var showLogs by remember { mutableStateOf(false) }
val debugLogs by ProtocolManager.debugLogs.collectAsState()
// Подключаем к ViewModel
val messages by viewModel.messages.collectAsState()
val inputText by viewModel.inputText.collectAsState()
val isTyping by viewModel.opponentTyping.collectAsState()
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
// Инициализируем ViewModel с ключами и открываем диалог
LaunchedEffect(user.publicKey) {
viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey)
viewModel.openDialog(user.publicKey)
}
// Прокрутка при новых сообщениях
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(0)
}
}
// Аватар
val avatarColors = getAvatarColor(
if (isSavedMessages) "SavedMessages" else user.title.ifEmpty { user.publicKey },
isDarkTheme
)
// Анимация появления
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { visible = true }
// Логируем открытие чата
LaunchedEffect(user.publicKey) {
ProtocolManager.addLog("💬 Chat opened with: ${user.title.ifEmpty { user.publicKey.take(10) }}")
ProtocolManager.addLog(" PublicKey: ${user.publicKey.take(20)}...")
}
Scaffold(
topBar = {
// Кастомный TopAppBar для чата
@@ -198,6 +228,15 @@ fun ChatDetailScreen(
}
}
// Кнопка логов (для отладки)
IconButton(onClick = { showLogs = true }) {
Icon(
Icons.Default.BugReport,
contentDescription = "Logs",
tint = if (debugLogs.isNotEmpty()) PrimaryBlue else textColor
)
}
IconButton(onClick = { /* TODO: More options */ }) {
Icon(
Icons.Default.MoreVert,
@@ -276,30 +315,10 @@ fun ChatDetailScreen(
// Поле ввода сообщения
MessageInputBar(
value = inputText,
onValueChange = { inputText = it },
onValueChange = { viewModel.updateInputText(it) },
onSend = {
if (inputText.isNotBlank()) {
val newMessage = ChatMessage(
id = UUID.randomUUID().toString(),
text = inputText.trim(),
isOutgoing = true,
timestamp = Date(),
status = MessageStatus.SENDING
)
messages = messages + newMessage
// Логируем отправку
ProtocolManager.addLog("📤 Message sent: \"${inputText.take(30)}...\"")
inputText = ""
// Прокрутка вниз
scope.launch {
listState.animateScrollToItem(0)
}
// TODO: Отправить через протокол
}
viewModel.sendMessage()
ProtocolManager.addLog("📤 Sending message...")
},
isDarkTheme = isDarkTheme,
backgroundColor = inputBackgroundColor,
@@ -308,6 +327,55 @@ fun ChatDetailScreen(
)
}
}
// Диалог логов
if (showLogs) {
AlertDialog(
onDismissRequest = { showLogs = false },
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text("Debug Logs", fontWeight = FontWeight.Bold)
IconButton(onClick = { ProtocolManager.clearLogs() }) {
Icon(Icons.Default.Delete, contentDescription = "Clear")
}
}
},
text = {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 400.dp)
) {
items(debugLogs.reversed()) { log ->
Text(
text = log,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(vertical = 2.dp)
)
}
if (debugLogs.isEmpty()) {
item {
Text(
text = "No logs yet. Try sending a message.",
color = Color.Gray,
fontSize = 12.sp
)
}
}
}
},
confirmButton = {
TextButton(onClick = { showLogs = false }) {
Text("Close")
}
}
)
}
}
/**
@@ -393,9 +461,7 @@ private fun MessageBubble(
/**
* Панель ввода сообщения 1:1 как в React Native
* - Слева: круглая кнопка Attach (скрепка)
* - Посередине: стеклянный инпут с текстом + справа emoji + send
* - Справа: круглая кнопка Mic (уезжает когда есть текст)
* Оптимизированная версия с правильным позиционированием
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
@@ -410,63 +476,71 @@ private fun MessageInputBar(
) {
var showEmojiPicker by remember { mutableStateOf(false) }
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val interactionSource = remember { MutableInteractionSource() }
// Цвета как в RN
// Цвета
val circleBackground = if (isDarkTheme) Color(0xFF3C3C3C).copy(alpha = 0.8f) else Color(0xFFF0F0F0).copy(alpha = 0.85f)
val circleBorder = if (isDarkTheme) Color.White.copy(alpha = 0.25f) else Color.Black.copy(alpha = 0.1f)
val circleIcon = if (isDarkTheme) Color.White else Color(0xFF333333)
val glassBackground = if (isDarkTheme) Color(0xFF3C3C3C).copy(alpha = 0.9f) else Color(0xFFF0F0F0).copy(alpha = 0.92f)
val glassBorder = if (isDarkTheme) Color.White.copy(alpha = 0.25f) else Color.Black.copy(alpha = 0.1f)
val emojiIconColor = if (isDarkTheme) Color.White.copy(alpha = 0.62f) else Color.Black.copy(alpha = 0.5f)
val panelBackground = if (isDarkTheme) Color(0xFF1A1A1A).copy(alpha = 0.95f) else Color.White.copy(alpha = 0.95f)
// === Анимации как в React Native ===
val canSend = value.isNotBlank()
// Состояние отправки
val canSend = remember(value) { value.isNotBlank() }
// Easing functions
// Easing
val backEasing = CubicBezierEasing(0.34f, 1.56f, 0.64f, 1f)
val smoothEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f)
// Send button animations
val sendOpacity by animateFloatAsState(
targetValue = if (canSend) 1f else 0f,
animationSpec = tween(200, easing = smoothEasing),
label = "sendOpacity"
)
// Анимации Send
val sendScale by animateFloatAsState(
targetValue = if (canSend) 1f else 0.5f,
targetValue = if (canSend) 1f else 0f,
animationSpec = tween(220, easing = backEasing),
label = "sendScale"
)
// Mic button animations
// Анимации Mic
val micOpacity by animateFloatAsState(
targetValue = if (canSend) 0f else 1f,
animationSpec = if (canSend) tween(150, easing = smoothEasing) else tween(200, delayMillis = 100, easing = smoothEasing),
animationSpec = tween(200, easing = smoothEasing),
label = "micOpacity"
)
val micTranslateX by animateFloatAsState(
targetValue = if (canSend) 80f else 0f,
animationSpec = if (canSend) tween(250, easing = smoothEasing) else tween(250, delayMillis = 80, easing = smoothEasing),
animationSpec = tween(250, easing = smoothEasing),
label = "micTranslateX"
)
// Emoji button animation (сдвигается влево когда появляется send)
val emojiTranslateX by animateFloatAsState(
targetValue = if (canSend) -50f else 0f,
animationSpec = tween(220, easing = smoothEasing),
label = "emojiTranslateX"
)
// Input margin animation (расширяется когда текст есть)
// Input margin
val inputEndMargin by animateDpAsState(
targetValue = if (canSend) 0.dp else 56.dp,
animationSpec = tween(220, easing = smoothEasing),
label = "inputEndMargin"
)
// Функция переключения emoji picker
fun toggleEmojiPicker() {
if (showEmojiPicker) {
showEmojiPicker = false
} else {
// Скрываем клавиатуру и убираем фокус
keyboardController?.hide()
focusManager.clearFocus()
showEmojiPicker = true
}
}
// Функция отправки
fun handleSend() {
if (value.isNotBlank()) {
onSend()
onValueChange("")
}
}
Column(
modifier = Modifier
.fillMaxWidth()
@@ -483,14 +557,17 @@ private fun MessageInputBar(
.padding(horizontal = 14.dp, vertical = 8.dp),
verticalAlignment = Alignment.Bottom
) {
// === ATTACH BUTTON (круг слева) ===
// ATTACH BUTTON
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(circleBackground)
.border(1.dp, circleBorder, CircleShape)
.clickable { /* TODO: Attach */ },
.clickable(
interactionSource = interactionSource,
indication = null
) { /* TODO */ },
contentAlignment = Alignment.Center
) {
Icon(
@@ -503,89 +580,89 @@ private fun MessageInputBar(
Spacer(modifier = Modifier.width(8.dp))
// === GLASS INPUT (расширяется вправо) ===
// GLASS INPUT
Box(
modifier = Modifier
.weight(1f)
.padding(end = inputEndMargin)
.heightIn(min = 48.dp, max = 120.dp)
.clip(RoundedCornerShape(22.dp))
.clip(RoundedCornerShape(24.dp))
.background(glassBackground)
.border(1.dp, glassBorder, RoundedCornerShape(22.dp))
.border(1.dp, glassBorder, RoundedCornerShape(24.dp))
) {
Row(
// Text input
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
.padding(start = 16.dp, end = 52.dp, top = 12.dp, bottom = 12.dp),
contentAlignment = Alignment.CenterStart
) {
// Apple Emoji Text Field (с PNG эмодзи)
Box(
modifier = Modifier
.weight(1f)
.padding(top = 8.dp, bottom = 8.dp, end = 70.dp), // место для emoji + send
contentAlignment = Alignment.CenterStart
) {
AppleEmojiTextField(
value = value,
onValueChange = onValueChange,
textColor = textColor,
textSize = 16f,
hint = "Message",
hintColor = placeholderColor.copy(alpha = 0.6f),
modifier = Modifier.fillMaxWidth()
)
}
AppleEmojiTextField(
value = value,
onValueChange = { newValue ->
// Закрываем emoji picker при печати с клавиатуры
if (showEmojiPicker && newValue.length > value.length) {
// Не закрываем - пользователь мог выбрать emoji
}
onValueChange(newValue)
},
textColor = textColor,
textSize = 16f,
hint = "Message",
hintColor = placeholderColor.copy(alpha = 0.6f),
modifier = Modifier.fillMaxWidth()
)
}
// === RIGHT ZONE (emoji + send) - абсолютная позиция справа внутри инпута ===
// RIGHT ZONE - emoji или send
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 6.dp)
.size(40.dp)
) {
// Emoji button (сдвигается влево при send)
Box(
modifier = Modifier
.graphicsLayer { translationX = emojiTranslateX }
.size(40.dp)
.clickable {
if (showEmojiPicker) {
showEmojiPicker = false
} else {
keyboardController?.hide()
showEmojiPicker = true
}
},
contentAlignment = Alignment.Center
) {
Icon(
if (showEmojiPicker) Icons.Default.Keyboard else Icons.Default.EmojiEmotions,
contentDescription = "Emoji",
tint = if (showEmojiPicker) PrimaryBlue else emojiIconColor,
modifier = Modifier.size(24.dp)
)
// Emoji button (показывается когда нет текста)
if (!canSend) {
Box(
modifier = Modifier
.fillMaxSize()
.clickable(
interactionSource = interactionSource,
indication = null,
onClick = { toggleEmojiPicker() }
),
contentAlignment = Alignment.Center
) {
Icon(
if (showEmojiPicker) Icons.Default.Keyboard else Icons.Default.EmojiEmotions,
contentDescription = "Emoji",
tint = if (showEmojiPicker) PrimaryBlue else emojiIconColor,
modifier = Modifier.size(24.dp)
)
}
}
// Send button - красивая круглая кнопка с градиентом
// Send button (показывается когда есть текст)
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = sendScale
scaleY = sendScale
alpha = sendOpacity
alpha = sendScale
}
.size(40.dp)
.clip(CircleShape)
.background(
brush = Brush.linearGradient(
colors = listOf(
Color(0xFF007AFF),
Color(0xFF5AC8FA)
)
colors = listOf(Color(0xFF007AFF), Color(0xFF5AC8FA))
)
)
.clickable(enabled = canSend) { onSend() },
.clickable(
interactionSource = interactionSource,
indication = null,
enabled = canSend,
onClick = { handleSend() }
),
contentAlignment = Alignment.Center
) {
Icon(
@@ -599,7 +676,7 @@ private fun MessageInputBar(
}
}
// === MIC BUTTON (абсолютная позиция справа, уезжает вправо) ===
// MIC BUTTON
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
@@ -612,7 +689,11 @@ private fun MessageInputBar(
.clip(CircleShape)
.background(circleBackground)
.border(1.dp, circleBorder, CircleShape)
.clickable(enabled = !canSend) { /* TODO: Voice */ },
.clickable(
interactionSource = interactionSource,
indication = null,
enabled = !canSend
) { /* TODO */ },
contentAlignment = Alignment.Center
) {
Icon(
@@ -624,20 +705,11 @@ private fun MessageInputBar(
}
}
// Apple Emoji Picker с PNG изображениями
// Apple Emoji Picker
AnimatedVisibility(
visible = showEmojiPicker,
enter = expandVertically(
expandFrom = Alignment.Top,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow
)
) + fadeIn(animationSpec = tween(150)),
exit = shrinkVertically(
shrinkTowards = Alignment.Top,
animationSpec = tween(200)
) + fadeOut(animationSpec = tween(100))
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut()
) {
AppleEmojiPickerPanel(
isDarkTheme = isDarkTheme,
@@ -648,7 +720,6 @@ private fun MessageInputBar(
)
}
// Spacer для navigation bar когда эмодзи пикер НЕ открыт
if (!showEmojiPicker) {
Spacer(modifier = Modifier.navigationBarsPadding())
}

View File

@@ -0,0 +1,227 @@
package com.rosetta.messenger.ui.chats
import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.network.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.util.UUID
import java.util.Date
/**
* ViewModel для экрана чата - упрощенная рабочая версия
* Без зависимости от MessageRepository
*/
class ChatViewModel(application: Application) : AndroidViewModel(application) {
companion object {
private const val TAG = "ChatViewModel"
}
// Текущий диалог
private var opponentKey: String? = null
private var myPublicKey: String? = null
private var myPrivateKey: String? = null
// UI State - сообщения хранятся локально в памяти
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _opponentTyping = MutableStateFlow(false)
val opponentTyping: StateFlow<Boolean> = _opponentTyping.asStateFlow()
// Input state
private val _inputText = MutableStateFlow("")
val inputText: StateFlow<String> = _inputText.asStateFlow()
// Защита от двойной отправки
private var isSending = false
init {
setupPacketListeners()
}
private fun setupPacketListeners() {
// Входящие сообщения
ProtocolManager.waitPacket(0x06) { packet ->
val msgPacket = packet as PacketMessage
if (msgPacket.fromPublicKey == opponentKey || msgPacket.toPublicKey == opponentKey) {
viewModelScope.launch {
handleIncomingMessage(msgPacket)
}
}
}
// Доставка
ProtocolManager.waitPacket(0x08) { packet ->
val deliveryPacket = packet as PacketDelivery
viewModelScope.launch {
updateMessageStatus(deliveryPacket.messageId, MessageStatus.DELIVERED)
ProtocolManager.addLog("✓ Delivered: ${deliveryPacket.messageId.take(8)}...")
}
}
// Прочитано
ProtocolManager.waitPacket(0x07) { packet ->
val readPacket = packet as PacketRead
viewModelScope.launch {
updateMessageStatus(readPacket.messageId, MessageStatus.READ)
ProtocolManager.addLog("✓✓ Read: ${readPacket.messageId.take(8)}...")
}
}
// Typing
ProtocolManager.waitPacket(0x0B) { packet ->
val typingPacket = packet as PacketTyping
if (typingPacket.fromPublicKey == opponentKey) {
showTypingIndicator()
}
}
}
private fun handleIncomingMessage(packet: PacketMessage) {
try {
val message = ChatMessage(
id = packet.messageId,
text = "[Encrypted] ${packet.content.take(20)}...",
isOutgoing = packet.fromPublicKey == myPublicKey,
timestamp = Date(packet.timestamp),
status = MessageStatus.DELIVERED
)
_messages.value = _messages.value + message
ProtocolManager.addLog("📩 Incoming: ${packet.messageId.take(8)}...")
} catch (e: Exception) {
ProtocolManager.addLog("❌ Error: ${e.message}")
}
}
private fun updateMessageStatus(messageId: String, status: MessageStatus) {
_messages.value = _messages.value.map { msg ->
if (msg.id == messageId) msg.copy(status = status) else msg
}
}
/**
* Установить ключи пользователя
*/
fun setUserKeys(publicKey: String, privateKey: String) {
myPublicKey = publicKey
myPrivateKey = privateKey
ProtocolManager.addLog("🔑 Keys set: ${publicKey.take(16)}...")
}
/**
* Открыть диалог
*/
fun openDialog(publicKey: String) {
opponentKey = publicKey
_messages.value = emptyList()
ProtocolManager.addLog("💬 Dialog: ${publicKey.take(16)}...")
}
/**
* Обновить текст ввода
*/
fun updateInputText(text: String) {
_inputText.value = text
}
/**
* Отправить сообщение - Optimistic UI
*/
fun sendMessage() {
val text = _inputText.value.trim()
val recipient = opponentKey
val sender = myPublicKey
val privateKey = myPrivateKey
if (text.isEmpty()) {
ProtocolManager.addLog("❌ Empty text")
return
}
if (recipient == null) {
ProtocolManager.addLog("❌ No recipient")
return
}
if (sender == null || privateKey == null) {
ProtocolManager.addLog("❌ No keys - set via setUserKeys()")
return
}
if (isSending) {
ProtocolManager.addLog("⏳ Already sending...")
return
}
isSending = true
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
// 1. Optimistic UI
val optimisticMessage = ChatMessage(
id = messageId,
text = text,
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING
)
_messages.value = _messages.value + optimisticMessage
_inputText.value = ""
ProtocolManager.addLog("📤 Send: \"${text.take(20)}...\"")
// 2. Отправка в фоне
viewModelScope.launch {
try {
ProtocolManager.addLog("🔐 Encrypting...")
val (encryptedContent, encryptedKey) = MessageCrypto.encryptForSending(text, recipient)
ProtocolManager.addLog("✓ Encrypted")
val packet = PacketMessage().apply {
fromPublicKey = sender
toPublicKey = recipient
content = encryptedContent
chachaKey = encryptedKey
this.timestamp = timestamp
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
this.messageId = messageId
attachments = emptyList()
}
ProtocolManager.addLog("📡 Sending packet...")
ProtocolManager.send(packet)
updateMessageStatus(messageId, MessageStatus.SENT)
ProtocolManager.addLog("✓ Sent!")
} catch (e: Exception) {
ProtocolManager.addLog("❌ Error: ${e.message}")
Log.e(TAG, "Send error", e)
} finally {
isSending = false
}
}
}
private fun showTypingIndicator() {
_opponentTyping.value = true
viewModelScope.launch {
kotlinx.coroutines.delay(3000)
_opponentTyping.value = false
}
}
fun canSend(): Boolean = _inputText.value.isNotBlank() && !isSending
override fun onCleared() {
super.onCleared()
opponentKey = null
}
}