diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin index 7284c77..9c90ac1 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin differ diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock index 3b140c9..2b230d1 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin index 8788b1d..96249d1 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock index fec8e56..fbb5f21 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin index 3af06e7..1f63230 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin and b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin differ diff --git a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock index e70bdf5..7dc01d9 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin index ff6e274..e0faab7 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin and b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/client-mobile/chats/data/remote/signalr/SignalRNotificationObserver.kt b/client-mobile/chats/data/remote/signalr/SignalRNotificationObserver.kt index 3314e4f..57e75c9 100644 --- a/client-mobile/chats/data/remote/signalr/SignalRNotificationObserver.kt +++ b/client-mobile/chats/data/remote/signalr/SignalRNotificationObserver.kt @@ -33,6 +33,8 @@ class SignalRNotificationObserver @Inject constructor( private val chatRepository: chats.domain.repository.ChatRepository, private val serverConfig: ServerConfig, private val networkManager: NetworkManager, + private val messageDao: core.database.data.MessageDao, + private val api: chats.data.remote.api.ChatApi, @ApplicationContext private val context: Context ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) @@ -195,6 +197,7 @@ class SignalRNotificationObserver @Inject constructor( /** * Синхронизирует пропущенные сообщения после восстановления соединения * Запрашивает только НОВЫЕ сообщения с последнего известного sequenceId + * Показывает уведомления для непрочитанных сообщений */ private fun syncMissedMessages() { scope.launch { @@ -212,11 +215,21 @@ class SignalRNotificationObserver @Inject constructor( if (lastSequenceId != null) { // Запрашиваем сообщения ПОСЛЕ lastSequenceId (только новые) android.util.Log.d("SignalRNtfObserver", "Chat ${chat.id}: last known seqId=$lastSequenceId, fetching newer...") - chatRepository.getMessages( + val newMessages = chatRepository.getMessages( chatId = chat.id, afterSequenceId = lastSequenceId.toLong(), limit = 100 ) + + android.util.Log.d("SignalRNtfObserver", "Chat ${chat.id}: fetched ${newMessages.size} messages from API") + + // Показываем уведомление если есть новые сообщения и чат не открыт + if (newMessages.isNotEmpty() && activeChatTracker.currentChatId.value != chat.id) { + android.util.Log.d("SignalRNtfObserver", "Chat ${chat.id}: ${newMessages.size} new messages, showing notification") + showMissedMessagesNotification(chat, newMessages) + } else { + android.util.Log.d("SignalRNtfObserver", "Chat ${chat.id}: skipping notification (messages=${newMessages.size}, chatOpen=${activeChatTracker.currentChatId.value == chat.id})") + } } else { // Нет локальных сообщений - загружаем последние 50 android.util.Log.d("SignalRNtfObserver", "Chat ${chat.id}: no local messages, fetching last 50") @@ -232,12 +245,81 @@ class SignalRNotificationObserver @Inject constructor( } android.util.Log.d("SignalRNtfObserver", "Missed messages sync completed") + + // Обновляем счетчик непрочитанных после синхронизации + refresh() } catch (e: Exception) { android.util.Log.e("SignalRNtfObserver", "Sync failed", e) } } } + /** + * Показывает уведомление о пропущенных сообщениях + */ + private fun showMissedMessagesNotification(chat: chats.domain.model.Chat, newMessages: List) { + scope.launch { + try { + android.util.Log.d("SignalRNtfObserver", "showMissedMessagesNotification called for chat ${chat.id} with ${newMessages.size} messages") + + // Фильтруем сообщения не от текущего пользователя + val currentUserId = tokenManager.getUserId() + android.util.Log.d("SignalRNtfObserver", "Current user ID: $currentUserId") + + val messagesFromOthers = newMessages.filter { it.senderId != currentUserId } + android.util.Log.d("SignalRNtfObserver", "Messages from others: ${messagesFromOthers.size}") + + if (messagesFromOthers.isEmpty()) { + android.util.Log.d("SignalRNtfObserver", "No new messages from others in chat ${chat.id} - all messages are from current user") + return@launch + } + + // Группируем сообщения по отправителям + val messagesBySender = messagesFromOthers.groupBy { it.senderId } + android.util.Log.d("SignalRNtfObserver", "Messages grouped by ${messagesBySender.size} sender(s)") + + // Для каждого отправителя показываем уведомление + messagesBySender.forEach { (senderId, messages) -> + val senderName = messages.firstOrNull()?.senderName ?: "Контакт" + val messageCount = messages.size + + android.util.Log.d("SignalRNtfObserver", "Processing sender $senderName with $messageCount messages") + + // Формируем текст уведомления + val notificationText = when { + messageCount == 1 -> { + messages.firstOrNull()?.content ?: "Новое сообщение" + } + messageCount <= 3 -> { + messages.take(3).mapNotNull { it.content }.joinToString(", ") + } + else -> { + "$messageCount новых сообщений" + } + } + + android.util.Log.d("SignalRNtfObserver", "Showing notification for chat ${chat.id}: $senderName - $notificationText") + + // Небольшая задержка перед показом уведомления + kotlinx.coroutines.delay(500) + + // Показываем уведомление + NotificationHelper.showNotification( + context = context, + title = senderName, + body = notificationText, + type = "chat", + chatId = chat.id, + notificationId = chat.id.hashCode(), + totalCount = activeChatTracker.totalUnreadCount.value + ) + } + } catch (e: Exception) { + android.util.Log.e("SignalRNtfObserver", "Failed to show missed messages notification", e) + } + } + } + /** * Получает последний известный sequenceId для чата из локальной базы */ @@ -277,13 +359,101 @@ class SignalRNotificationObserver @Inject constructor( android.util.Log.d("SignalRNtfObserver", "SignalR reconnected, syncing missed messages...") // 5. Синхронизируем пропущенные сообщения syncMissedMessages() + + // 6. Пробуем отправить отложенные сообщения НЕМЕДЛЕННО (не через WorkManager) + sendPendingMessagesImmediately() + + // 7. Также планируем WorkManager на всякий случай + MessageSyncWorker.scheduleSync(context) + android.util.Log.d("SignalRNtfObserver", "Outgoing sync worker scheduled") } else { android.util.Log.w("SignalRNtfObserver", "SignalR failed to reconnect within timeout") } - - // 6. Запускаем синхронизацию отложенных сообщений - MessageSyncWorker.scheduleSync(context) - android.util.Log.d("SignalRNtfObserver", "Outgoing sync worker scheduled") + } + } + + /** + * Немедленно отправляет отложенные сообщения без ожидания WorkManager + * Вызывается сразу после восстановления соединения + */ + private fun sendPendingMessagesImmediately() { + scope.launch { + try { + android.util.Log.d("SignalRNtfObserver", "Checking for pending messages to send...") + + // Получаем все сообщения со статусом SYNCING + val pendingMessages = messageDao.getPendingSyncMessages() + android.util.Log.d("SignalRNtfObserver", "Found ${pendingMessages.size} pending messages") + + if (pendingMessages.isEmpty()) { + android.util.Log.d("SignalRNtfObserver", "No pending messages to send") + return@launch + } + + var successCount = 0 + var failureCount = 0 + + for (message in pendingMessages) { + try { + android.util.Log.d("SignalRNtfObserver", "Sending pending message: ${message.id}") + + val attachments = parseAttachments(message.mediaJson) + val request = chats.data.remote.api.SendMessageRequest( + content = message.content, + type = message.mediaType.lowercase(), + attachments = attachments, + replyToId = message.replyToId + ) + + val response = api.sendMessage(message.chatId, request) + + // Обновляем сообщение в базе + val syncedMessage = message.copy( + id = response.id, + sequenceId = response.sequenceId ?: message.sequenceId, + createdAt = response.createdAt ?: message.createdAt, + syncStatus = core.database.data.SyncStatus.SYNCED, + isDeletedLocally = false, + isEditedLocally = false, + editedContent = null, + lastUpdated = System.currentTimeMillis() + ) + + messageDao.insertMessage(syncedMessage) + android.util.Log.d("SignalRNtfObserver", "Message sent successfully: ${response.id}") + successCount++ + + } catch (e: Exception) { + android.util.Log.e("SignalRNtfObserver", "Failed to send message ${message.id}", e) + failureCount++ + } + } + + android.util.Log.d("SignalRNtfObserver", "Pending messages sync completed. Success: $successCount, Failed: $failureCount") + + } catch (e: Exception) { + android.util.Log.e("SignalRNtfObserver", "sendPendingMessagesImmediately failed", e) + } + } + } + + private fun parseAttachments(mediaJson: String): List? { + return try { + val gson = com.google.gson.Gson() + val mediaList = gson.fromJson(mediaJson, Array::class.java) + ?.map { elem -> + val map = elem as Map<*, *> + chats.data.remote.api.AttachmentRequest( + type = map["type"] as? String ?: "file", + url = map["url"] as? String ?: "", + fileName = map["filename"] as? String ?: "file", + fileSize = (map["size"] as? Number)?.toLong() ?: 0L + ) + } + mediaList?.takeIf { it.isNotEmpty() } + } catch (e: Exception) { + android.util.Log.e("SignalRNtfObserver", "Failed to parse attachments", e) + null } } diff --git a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt index bac3b9d..529d1de 100644 --- a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt +++ b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt @@ -194,7 +194,35 @@ class ChatRepositoryImpl @Inject constructor( messageDao.insertMessage(localMessage) Log.d(TAG, "Saved local message: $localId") - MessageSyncWorker.scheduleSync(context) + + // Пробуем отправить НЕМЕДЛЕННО через API + try { + Log.d(TAG, "Sending message immediately via API: $localId") + val request = SendMessageRequest( + content = content, + type = type, + attachments = attachments, + replyToId = replyToId + ) + val response = api.sendMessage(chatId, request) + Log.d(TAG, "Message sent successfully: ${response.id}") + + // Обновляем сообщение в базе с серверными данными + val syncedMessage = localMessage.copy( + id = response.id, + sequenceId = response.sequenceId ?: 0, + createdAt = response.createdAt ?: localMessage.createdAt, + syncStatus = SyncStatus.SYNCED + ) + messageDao.insertMessage(syncedMessage) + + // Возвращаем доменную модель с серверными данными + return response.toDomain(userId, baseUrl) + } catch (e: Exception) { + Log.e(TAG, "Failed to send message immediately, scheduling sync: ${e.message}") + // Ошибка - планируем синхронизацию через WorkManager + MessageSyncWorker.scheduleSync(context) + } // Создаём доменную модель вручную для локального сообщения return Message( diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt index 44ba42f..eb1d715 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt @@ -329,6 +329,11 @@ class ChatDetailViewModel @Inject constructor( } } is ChatEvent.UserTyping -> { + // Игнорируем свои же typing события + if (event.userId == getCurrentUserId()) { + android.util.Log.d("ChatDetailVM", "Ignoring own typing event") + return@onEach + } _state.update { it.copy(isTyping = true) } typingTimerJob?.cancel() typingTimerJob = viewModelScope.launch { @@ -337,6 +342,11 @@ class ChatDetailViewModel @Inject constructor( } } is ChatEvent.UserStoppedTyping -> { + // Игнорируем свои же события + if (event.userId == getCurrentUserId()) { + android.util.Log.d("ChatDetailVM", "Ignoring own stopped typing event") + return@onEach + } _state.update { it.copy(isTyping = false) } } is ChatEvent.MessagesRead -> {