35 Commits
Author SHA1 Message Date
Халимов Рустам c592197017 Восстановил поломанную отправку и печать 2026-04-20 12:06:43 +03:00
Халимов Рустам b65c8f3633 Восстановление сети 2026-04-20 11:14:45 +03:00
Халимов Рустам c839a9bc03 Второй этап выхода из офлайн режима 2026-04-20 10:46:09 +03:00
Халимов Рустам b2c29958b5 Восстановление подключения, бэк 2026-04-20 10:44:07 +03:00
Халимов Рустам 38e7184a0d Офлайн режим, начало 2026-04-20 01:00:35 +03:00
Халимов Рустам 7c66e1c0c0 Кэш чатов 2026-04-20 00:25:40 +03:00
Халимов Рустам 945134f029 Нормальные токены 2026-04-19 22:55:38 +03:00
Халимов Рустам f6400ce3ac Merge branch 'main' into android 2026-04-19 22:31:31 +03:00
Халимов Рустам 8c8ef55b58 Merge branch 'main' into android 2026-04-19 21:24:40 +03:00
Халимов Рустам 629fddfca0 Реакции, но с багом 2026-04-18 00:41:05 +03:00
Халимов Рустам b68f68a1f2 Анимация, список реакций 2026-04-17 23:51:46 +03:00
Халимов Рустам 754e9e8ad0 Меню для сообщений 2026-04-17 23:44:11 +03:00
Халимов Рустам ed7521a563 Правка ответов, локализация, поддержка медиа 2026-04-17 22:07:33 +03:00
Халимов Рустам bef30c2c86 Ответы 2026-04-17 21:59:53 +03:00
Халимов Рустам 8c00d1376d Событие печати, смена адреса, иконки 2026-04-17 16:23:52 +03:00
Халимов Рустам cea3f4d669 Профиль, редактирование без аватара 2026-04-16 15:41:22 +03:00
Халимов Рустам 8409c51842 Пуши 2026-04-15 02:40:52 +03:00
Халимов Рустам 9560a9235f Сохранение состояния при сворачивании 2026-04-15 02:19:03 +03:00
Халимов Рустам f17edfc0da Зум и пролистывание 2026-04-15 02:12:43 +03:00
Халимов Рустам c6bebec599 Кнопка вниз 2026-04-15 02:01:46 +03:00
Халимов Рустам 5a71e5bbfa Подгрузка старых сообщений 2026-04-15 01:55:23 +03:00
Халимов Рустам 5b0133d55e Прокрутка чата 2026-04-15 01:35:22 +03:00
Халимов Рустам dcb733ac01 Нормальный чат 2026-04-15 01:30:48 +03:00
Халимов Рустам 487cb1b12b Рабочие счетчики и переработка чата 2026-04-15 01:14:55 +03:00
Халимов Рустам 58fdf1aca1 Чат, вложения 2026-04-14 21:53:44 +03:00
Халимов Рустам 118f8b8971 Прочтение 2026-04-14 15:09:44 +03:00
Халимов Рустам 8165b74e43 Отправка гиф 2026-04-14 13:55:01 +03:00
Халимов Рустам 8ce4bc714f Гиф 2026-04-14 13:46:17 +03:00
Халимов Рустам 2d0bc0d75c Чат, правки 2026-04-14 12:52:03 +03:00
Халимов Рустам d7e75797ef Ответы, аудио, голосовые 2026-04-14 12:29:32 +03:00
Халимов Рустам 58bbdae26c Настройки 2026-04-14 11:46:55 +03:00
Халимов Рустам 3310a3c4a4 Правка чата 2026-04-14 11:31:20 +03:00
Халимов Рустам d8b0d86534 Чат 2026-04-14 10:48:07 +03:00
Халимов Рустам dc051fa9ae Авторизация, нерабочий чат 2026-04-14 01:41:51 +03:00
Халимов Рустам 1fb1be47dd Приложение 2026-04-14 01:15:54 +03:00
181 changed files with 16008 additions and 968 deletions
@@ -23,9 +23,6 @@ public abstract class Message : AggregateRoot<Guid>
protected List<DeletedMessage> _deletedFor = new(); protected List<DeletedMessage> _deletedFor = new();
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly(); public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
protected List<Guid> _readByUsers = new();
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
protected Message() : base(Guid.Empty) { } protected Message() : base(Guid.Empty) { }
protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported) protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
@@ -56,14 +53,4 @@ public abstract class Message : AggregateRoot<Guid>
if (!_deletedFor.Exists(x => x.UserId == userId)) if (!_deletedFor.Exists(x => x.UserId == userId))
_deletedFor.Add(new DeletedMessage(Id, userId)); _deletedFor.Add(new DeletedMessage(Id, userId));
} }
public void MarkAsRead(Guid userId)
{
if (!_readByUsers.Contains(userId))
{
_readByUsers.Add(userId);
}
}
public bool IsReadBy(Guid userId) => _readByUsers.Contains(userId);
} }
@@ -1,15 +1,13 @@
using System; using System;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage; using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using MediatR; using MediatR;
using Microsoft.AspNetCore.SignalR; using System.Linq;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Shared.Kernel.Storage;
namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete; namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete;
@@ -21,21 +19,17 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
private readonly IMessageRepository _messageRepository; private readonly IMessageRepository _messageRepository;
private readonly IFileStorageService _fileStorage; private readonly IFileStorageService _fileStorage;
private readonly IChatsUnitOfWork _uow; private readonly IChatsUnitOfWork _uow;
private readonly IHubContext<ChatHub> _hubContext;
public LeaveOrDeleteChatCommandHandler( public LeaveOrDeleteChatCommandHandler(
IChatRepository chatRepository, IChatRepository chatRepository,
IMessageRepository messageRepository, IMessageRepository messageRepository,
IFileStorageService fileStorage, IFileStorageService fileStorage,
IChatsUnitOfWork uow, IChatsUnitOfWork uow)
IHubContext<ChatHub> hubContext)
{ {
_chatRepository = chatRepository; _chatRepository = chatRepository;
_messageRepository = messageRepository; _messageRepository = messageRepository;
_fileStorage = fileStorage; _fileStorage = fileStorage;
_uow = uow; _uow = uow;
_hubContext = hubContext;
} }
public async Task<Result<SuccessResponse>> Handle(LeaveOrDeleteChatCommand request, CancellationToken cancellationToken) public async Task<Result<SuccessResponse>> Handle(LeaveOrDeleteChatCommand request, CancellationToken cancellationToken)
@@ -64,14 +58,6 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
// DELETE ALL MESSAGES AND FILES FIRST // DELETE ALL MESSAGES AND FILES FIRST
await DeleteChatMediaAndMessagesAsync(chat.Id, cancellationToken); await DeleteChatMediaAndMessagesAsync(chat.Id, cancellationToken);
_chatRepository.Remove(chat); _chatRepository.Remove(chat);
// Notify all remaining members that the chat was deleted
foreach (var member in chat.Members)
{
await _hubContext.Clients.User(member.UserId.ToString())
.SendAsync("chat_deleted", chat.Id.ToString(), cancellationToken);
}
} }
await _uow.SaveChangesAsync(cancellationToken); await _uow.SaveChangesAsync(cancellationToken);
@@ -82,7 +68,6 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
private async Task DeleteChatMediaAndMessagesAsync(Guid chatId, CancellationToken ct) private async Task DeleteChatMediaAndMessagesAsync(Guid chatId, CancellationToken ct)
{ {
try try
{ {
// Get all messages directly from Mongo (not paged) // Get all messages directly from Mongo (not paged)
var messages = await _messageRepository.GetChatMessagesAsync(chatId, int.MaxValue, 0, ct); var messages = await _messageRepository.GetChatMessagesAsync(chatId, int.MaxValue, 0, ct);
@@ -159,7 +159,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
(message as StoryMessage)?.StoryMediaType, (message as StoryMessage)?.StoryMediaType,
(message as MediaMessage)?.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List<MediaDto>(), (message as MediaMessage)?.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List<MediaDto>(),
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null), sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
message.ReadByUsers.Select(id => new ReadByDto(id)).ToList(), new List<ReadByDto>(), // ReadBy not implemented in this detailed view yet
reactions?.Select(r => reactions?.Select(r =>
{ {
senders.TryGetValue(r.UserId, out var ru); senders.TryGetValue(r.UserId, out var ru);
@@ -1,8 +1,7 @@
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Shared.Kernel;
using MediatR; using MediatR;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Contracts.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Application.Messages.Read; namespace Knot.Modules.Conversations.Application.Messages.Read;
@@ -12,13 +11,11 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCom
{ {
private readonly IChatRepository _chatRepository; private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _unitOfWork; private readonly IChatsUnitOfWork _unitOfWork;
private readonly IMessageRepository _messageRepository;
public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork, IMessageRepository messageRepository) public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
{ {
_chatRepository = chatRepository; _chatRepository = chatRepository;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_messageRepository = messageRepository;
} }
public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken) public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken)
@@ -31,23 +28,6 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCom
member.UpdateReadCursor(request.LastReadMessageId, request.LastReadSequenceId); member.UpdateReadCursor(request.LastReadMessageId, request.LastReadSequenceId);
// Обновляем ReadByUsers для всех сообщений до LastReadSequenceId
var messages = await _messageRepository.GetChatMessagesAfterAsync(
request.ChatId,
0,
1000,
cancellationToken);
foreach (var message in messages)
{
if (message.SequenceId <= request.LastReadSequenceId &&
message.SenderId != request.UserId &&
!message.IsReadBy(request.UserId))
{
message.MarkAsRead(request.UserId);
}
}
await _unitOfWork.SaveChangesAsync(cancellationToken); await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(); return Result.Success();
@@ -3,10 +3,10 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain; using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.DTOs; using Knot.Modules.Conversations.Application.DTOs;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using MediatR; using MediatR;
@@ -41,8 +41,7 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken); var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken);
var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList()); var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList());
var result = messages.Select(message => var result = messages.Select(message => {
{
var textMessage = message as TextMessage; var textMessage = message as TextMessage;
var mediaMessage = message as MediaMessage; var mediaMessage = message as MediaMessage;
var storyMessage = message as StoryMessage; var storyMessage = message as StoryMessage;
@@ -67,7 +66,7 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(), mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(),
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null), senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList() : new List<SimpleReactionDto>(), reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList() : new List<SimpleReactionDto>(),
message.ReadByUsers.Select(id => new ReadByDto(id)).ToList() new List<ReadByDto>()
); );
}).ToList(); }).ToList();
@@ -1,8 +1,8 @@
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain; using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Application.Messages.Send; namespace Knot.Modules.Conversations.Application.Messages.Send;
@@ -192,7 +192,6 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
var senderMember = chat.Members.First(m => m.UserId == request.SenderId); var senderMember = chat.Members.First(m => m.UserId == request.SenderId);
senderMember.UpdateReadCursor(message.Id, message.SequenceId); senderMember.UpdateReadCursor(message.Id, message.SequenceId);
senderMember.UpdateDeliveredCursor(message.Id); senderMember.UpdateDeliveredCursor(message.Id);
message.MarkAsRead(request.SenderId); // Отправитель всегда "прочитал" своё сообщение
// 5. // 5.
_messageRepository.Add(message); _messageRepository.Add(message);
@@ -1,26 +1,26 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Security.Claims; using System.Security.Claims;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Application.Messages.Delete;
using Knot.Modules.Conversations.Application.Messages.Edit;
using Knot.Modules.Conversations.Application.Messages.Pin;
using Knot.Modules.Conversations.Application.Messages.React;
using Knot.Modules.Conversations.Application.Messages.Read;
using Knot.Modules.Conversations.Application.Messages.Send;
using Knot.Modules.Conversations.Application.Messages.Unpin;
using Knot.Modules.Conversations.Application.Messages.Vote;
using Knot.Shared.Kernel;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Knot.Modules.Conversations.Application.Messages.Send;
using Knot.Modules.Conversations.Application.Messages.Read;
using Knot.Modules.Conversations.Application.Messages.Delete;
using Knot.Modules.Conversations.Application.Messages.React;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using Microsoft.Extensions.Caching.Memory;
using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Modules.Conversations.Application.Messages.Pin;
using Knot.Modules.Conversations.Application.Messages.Unpin;
using Knot.Modules.Conversations.Application.Messages.Vote;
using Knot.Modules.Conversations.Application.Messages.Edit;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Infrastructure.SignalR; namespace Knot.Modules.Conversations.Infrastructure.SignalR;
@@ -158,7 +158,6 @@ public sealed class ChatHub : Hub
await _sender.Send(command); await _sender.Send(command);
} }
// Отправляем событие всем в чате о том, что пользователь прочитал сообщения
await Clients.Group(request.ChatId.ToString()).SendAsync("messages_read", new await Clients.Group(request.ChatId.ToString()).SendAsync("messages_read", new
{ {
ChatId = request.ChatId.ToString(), ChatId = request.ChatId.ToString(),
@@ -42,10 +42,6 @@ public abstract class Message : AggregateRoot<Guid>
protected List<DeletedMessage> _deletedFor = new(); protected List<DeletedMessage> _deletedFor = new();
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly(); public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
// ================== Прочитано ==================
protected List<Guid> _readByUsers = new();
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
// ================== Инфраструктурный конструктор EF ================== // ================== Инфраструктурный конструктор EF ==================
protected Message() : base(Guid.Empty) { } protected Message() : base(Guid.Empty) { }
@@ -93,16 +89,6 @@ public abstract class Message : AggregateRoot<Guid>
_deletedFor.Add(new DeletedMessage(Id, userId)); _deletedFor.Add(new DeletedMessage(Id, userId));
} }
} }
public void MarkAsRead(Guid userId)
{
if (!_readByUsers.Contains(userId))
{
_readByUsers.Add(userId);
}
}
public bool IsReadBy(Guid userId) => _readByUsers.Contains(userId);
} }
@@ -84,7 +84,7 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
size = m.Size size = m.Size
}).ToList() ?? (object)Array.Empty<object>(), }).ToList() ?? (object)Array.Empty<object>(),
sender = senderObj, sender = senderObj,
readBy = message.ReadByUsers.Select(id => new { id }).ToList(), readBy = new List<object>(),
storyId = (message as StoryMessage)?.StoryId, storyId = (message as StoryMessage)?.StoryId,
storyMediaUrl = (message as StoryMessage)?.StoryMediaUrl, storyMediaUrl = (message as StoryMessage)?.StoryMediaUrl,
storyMediaType = (message as StoryMessage)?.StoryMediaType, storyMediaType = (message as StoryMessage)?.StoryMediaType,
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
#Tue Apr 14 00:27:15 MSK 2026
gradle.version=8.5
+2
View File
@@ -0,0 +1,2 @@
#Tue Apr 14 00:13:58 MSK 2026
java.home=C\:\\Program Files\\Android\\Android Studio\\jbr
+141
View File
@@ -0,0 +1,141 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
id("com.google.gms.google-services")
kotlin("kapt")
}
android {
namespace = "ru.knot.messager"
compileSdk = 34
defaultConfig {
applicationId = "ru.knot.messager"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
// Подключаем все наши папки с кодом как sourceSets
sourceSets {
getByName("main") {
java.srcDirs(
"src/main/kotlin",
"../auth",
"../chats",
"../core",
"../calls",
"../stories",
"../contacts",
"../profiles",
"../settings",
"../navigation"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// AndroidX & UI
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
implementation("androidx.activity:activity-compose:1.8.1")
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.navigation:navigation-compose:2.7.5")
implementation("androidx.compose.material:material-icons-extended")
// Hilt
implementation("com.google.dagger:hilt-android:2.48")
kapt("com.google.dagger:hilt-android-compiler:2.48")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
// Network & SignalR
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("com.microsoft.signalr:signalr:7.0.0")
// WebRTC
implementation("com.github.webrtc-sdk:android:104.5112.01")
// Media3 (ExoPlayer)
implementation("androidx.media3:media3-exoplayer:1.2.0")
implementation("androidx.media3:media3-ui:1.2.0")
implementation("androidx.media3:media3-common:1.2.0")
// Images & GIF
implementation("io.coil-kt:coil-compose:2.5.0")
implementation("io.coil-kt:coil-gif:2.5.0")
implementation("io.coil-kt:coil-svg:2.5.0")
implementation("io.coil-kt:coil-video:2.5.0")
// Security
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// UCrop (Image Cropping)
implementation("com.github.yalantis:ucrop:2.2.8")
// Firebase (Push Notifications)
implementation(platform("com.google.firebase:firebase-bom:32.7.0"))
implementation("com.google.firebase:firebase-messaging-ktx")
implementation("com.google.firebase:firebase-analytics-ktx")
// Room
val room_version = "2.6.1"
implementation("androidx.room:room-runtime:$room_version")
implementation("androidx.room:room-ktx:$room_version")
implementation("androidx.room:room-paging:$room_version")
kapt("androidx.room:room-compiler:$room_version")
// Paging 3
implementation("androidx.paging:paging-runtime-ktx:3.2.1")
implementation("androidx.paging:paging-compose:3.2.1")
// WorkManager
implementation("androidx.work:work-runtime-ktx:2.9.0")
implementation("androidx.hilt:hilt-work:1.1.0")
kapt("androidx.hilt:hilt-compiler:1.1.0")
// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}
+29
View File
@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "483917912506",
"project_id": "knot-bad1a",
"storage_bucket": "knot-bad1a.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:483917912506:android:cd39213364869ef9e82583",
"android_client_info": {
"package_name": "ru.knot.messager"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBAL_bZJYaa7rGERLX63LeFXz-__JXRWQY"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="ru.knot.messager">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name="com.knot.messenger.MainApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.KnotMessenger"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true">
<activity
android:name="com.knot.messenger.MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.KnotMessenger">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name="core.notifications.data.ForkFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>
@@ -0,0 +1,60 @@
package com.knot.messenger
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import dagger.hilt.android.AndroidEntryPoint
import navigation.AppNavigation
import core.presentation.theme.ForkMessengerTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@javax.inject.Inject
lateinit var navigationManager: core.utils.NavigationManager
@javax.inject.Inject
lateinit var signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
android.util.Log.d("MainActivity", "onCreate called")
signalrNotificationObserver.start()
android.util.Log.d("MainActivity", "signalrNotificationObserver.start() called")
intent.getStringExtra("chatId")?.let { chatId ->
navigationManager.navigateToChat(chatId)
}
// Request notifications permission for Android 13+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
androidx.core.app.ActivityCompat.requestPermissions(
this,
arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
101
)
}
setContent {
ForkMessengerTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
AppNavigation(navigationManager = navigationManager)
}
}
}
}
override fun onNewIntent(intent: android.content.Intent?) {
super.onNewIntent(intent)
intent?.getStringExtra("chatId")?.let { chatId ->
navigationManager.navigateToChat(chatId)
}
}
}
@@ -0,0 +1,19 @@
package com.knot.messenger
import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.decode.VideoFrameDecoder
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class MainApplication : Application(), ImageLoaderFactory {
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.components {
add(VideoFrameDecoder.Factory())
}
.build()
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,64 @@
<resources>
<string name="app_name">ForkMessenger</string>
<string name="login">Login</string>
<string name="register">Register</string>
<string name="username">Username</string>
<string name="password">Password</string>
<string name="display_name">Display Name</string>
<string name="settings">Settings</string>
<string name="save">Save</string>
<string name="back">Back</string>
<string name="server_connection">Server Connection</string>
<string name="api_base_url">API Base URL</string>
<string name="server_features">Server Features</string>
<string name="stories">Stories</string>
<string name="polls">Polls</string>
<string name="calls">Calls</string>
<string name="groups">Groups</string>
<string name="enabled">Enabled</string>
<string name="disabled">Disabled</string>
<string name="limits">Limits</string>
<string name="max_file_size">Max File Size</string>
<string name="max_group_members">Max Group Members</string>
<string name="message">Message</string>
<string name="call">Call</string>
<string name="block">Block</string>
<string name="profile">Profile</string>
<string name="confirm_password">Confirm Password</string>
<string name="passwords_not_match">Passwords do not match</string>
<string name="no_account_register">Don\'t have an account? Register</string>
<string name="already_have_account">Already have an account? Login</string>
<string name="error_occurred">An error occurred</string>
<string name="loading">Loading...</string>
<string name="chats_title">Chats</string>
<string name="contacts_title">Contacts</string>
<string name="stories_title">Stories</string>
<string name="create_story">Create Story</string>
<string name="send_message_hint">Type a message...</string>
<string name="reply_to_user">Reply to %1$s...</string>
<string name="story_editor">STORY EDITOR</string>
<string name="publish">PUBLISH</string>
<string name="start_creation">START CREATION</string>
<string name="text_tool">TEXT</string>
<string name="crop_tool">CROP</string>
<string name="stickers_tool">STICKERS</string>
<string name="brush_tool">BRUSH</string>
<string name="filters_tool">FILTERS</string>
<string name="remove">Remove</string>
<string name="no_chats_found">No chats found</string>
<string name="typing">typing...</string>
<string name="video_call">Video Call</string>
<string name="emoji">Emoji</string>
<string name="attach">Attach</string>
<string name="message_placeholder">Message...</string>
<string name="voice_message">Voice Message</string>
<string name="send">Send</string>
<string name="reply_photo">Photo</string>
<string name="reply_video">Video</string>
<string name="reply_audio">Audio</string>
<string name="reply_file">File</string>
<string name="reply_gif">GIF</string>
<string name="reply_prefix">Reply to </string>
<string name="reply_self">yourself</string>
<string name="no_messages_yet">No messages yet</string>
</resources>
@@ -0,0 +1,86 @@
<resources>
<string name="app_name">ForkMessenger</string>
<string name="login">Войти</string>
<string name="register">Регистрация</string>
<string name="username">Имя пользователя</string>
<string name="password">Пароль</string>
<string name="display_name">Отображаемое имя</string>
<string name="settings">Настройки</string>
<string name="save">Сохранить</string>
<string name="back">Назад</string>
<string name="server_connection">Подключение к серверу</string>
<string name="api_base_url">API Base URL</string>
<string name="server_features">Функции сервера</string>
<string name="stories">Истории</string>
<string name="polls">Опросы</string>
<string name="calls">Звонки</string>
<string name="groups">Группы</string>
<string name="enabled">Включено</string>
<string name="disabled">Отключено</string>
<string name="limits">Лимиты</string>
<string name="max_file_size">Макс. размер файла</string>
<string name="max_group_members">Макс. участников в группе</string>
<string name="message">Сообщение</string>
<string name="call">Позвонить</string>
<string name="block">Заблокировать</string>
<string name="profile">Профиль</string>
<string name="confirm_password">Подтвердите пароль</string>
<string name="passwords_not_match">Пароли не совпадают</string>
<string name="no_account_register">Нет аккаунта? Зарегистрироваться</string>
<string name="already_have_account">Уже есть аккаунт? Войти</string>
<string name="error_occurred">Произошла ошибка</string>
<string name="loading">Загрузка...</string>
<string name="chats_title">Чаты</string>
<string name="contacts_title">Контакты</string>
<string name="stories_title">Истории</string>
<string name="create_story">Создать историю</string>
<string name="send_message_hint">Напишите сообщение...</string>
<string name="reply_to_user">Ответить %1$s...</string>
<string name="story_editor">РЕДАКТОР ИСТОРИЙ</string>
<string name="publish">ОПУБЛИКОВАТЬ</string>
<string name="start_creation">НАЧАТЬ СОЗДАНИЕ</string>
<string name="text_tool">ТЕКСТ</string>
<string name="crop_tool">ОБРЕЗКА</string>
<string name="stickers_tool">СТИКЕРЫ</string>
<string name="brush_tool">КИСТЬ</string>
<string name="filters_tool">ФИЛЬТРЫ</string>
<string name="remove">Удалить</string>
<string name="no_chats_found">Чаты не найдены</string>
<string name="typing">печатает...</string>
<string name="video_call">Видеозвонок</string>
<string name="emoji">Эмодзи</string>
<string name="attach">Прикрепить</string>
<string name="message_placeholder">Сообщение...</string>
<string name="voice_message">Голосовое сообщение</string>
<string name="send">Отправить</string>
<string name="search_hint">Поиск...</string>
<string name="online">В сети</string>
<string name="last_seen">Был(а): %1$s</string>
<string name="last_seen_recently">недавно</string>
<string name="all">Все</string>
<string name="online_tab">Онлайн</string>
<string name="blocked">Заблокированные</string>
<string name="media">Медиа</string>
<string name="notifications">Уведомления</string>
<string name="mute">Без звука</string>
<string name="unmute">Включить звук</string>
<string name="log_out">Выйти из аккаунта</string>
<string name="bio">О себе</string>
<string name="edit_profile">Редактировать профиль</string>
<string name="username_label">Имя пользователя</string>
<string name="change_photo">Изменить фото</string>
<string name="cancel">Отмена</string>
<string name="crop">Обрезать</string>
<string name="chats">Чаты</string>
<string name="contacts_tab">Контакты</string>
<string name="profile_tab">Профиль</string>
<string name="saving">Сохранение...</string>
<string name="reply_photo">Фото</string>
<string name="reply_video">Видео</string>
<string name="reply_audio">Аудио</string>
<string name="reply_file">Файл</string>
<string name="reply_gif">GIF</string>
<string name="reply_prefix">Ответ </string>
<string name="reply_self">самому себе</string>
<string name="no_messages_yet">Сообщений пока нет</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.KnotMessenger" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">#0F0F10</item>
<item name="android:windowBackground">#0F0F10</item>
</style>
</resources>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Для отладки: доверяем пользовательским сертификатам -->
<debug-overrides>
<trust-anchors>
<certificates src="user" />
<certificates src="system" />
</trust-anchors>
</debug-overrides>
<!-- Разрешаем cleartext (HTTP) трафик для локальных IP -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<domain includeSubdomains="true">10.0.0.0/8</domain>
<domain includeSubdomains="true">172.16.0.0/12</domain>
<domain includeSubdomains="true">192.168.0.0/16</domain>
</domain-config>
</network-security-config>
@@ -0,0 +1,29 @@
package auth.data.remote.api
import auth.data.remote.dto.AuthRequest
import auth.data.remote.dto.AuthResponse
import auth.data.remote.dto.RefreshTokenRequest
import core.domain.model.ServerConfigModel
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.POST
interface AuthApi {
@POST("auth/login")
suspend fun login(@Body request: AuthRequest): AuthResponse
@POST("auth/register")
suspend fun register(@Body request: AuthRequest): AuthResponse
@POST("auth/refresh")
suspend fun refreshToken(@Body request: RefreshTokenRequest): AuthResponse
@GET("config")
@Headers("Cache-Control: no-cache")
suspend fun getConfig(): ServerConfigModel
@POST("auth/push-token")
@Headers("Cache-Control: no-cache")
suspend fun updatePushToken(@Body token: String): Unit
}
@@ -0,0 +1,28 @@
package auth.data.remote.dto
import com.google.gson.annotations.SerializedName
data class AuthRequest(
@SerializedName("userName") val userName: String,
@SerializedName("password") val password: String
)
data class AuthResponse(
@SerializedName("accessToken") val accessToken: String?,
@SerializedName("refreshToken") val refreshToken: String?,
@SerializedName("user") val user: UserDto?,
@SerializedName("userId") val userId: String?,
@SerializedName("username") val username: String?,
@SerializedName("displayName") val displayName: String?
)
data class UserDto(
@SerializedName("id") val id: String,
@SerializedName("userName") val userName: String,
@SerializedName("displayName") val displayName: String?,
@SerializedName("avatarUrl") val avatarUrl: String?
)
data class RefreshTokenRequest(
@SerializedName("refreshToken") val refreshToken: String
)
@@ -0,0 +1,131 @@
package auth.data.repository
import auth.data.remote.api.AuthApi
import auth.data.remote.dto.AuthRequest
import auth.data.remote.dto.RefreshTokenRequest
import auth.domain.model.AuthResult
import auth.domain.repository.AuthRepository
import core.network.ServerConfig
import core.security.TokenManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
class AuthRepositoryImpl @Inject constructor(
private val api: AuthApi,
private val tokenManager: TokenManager,
private val serverConfig: ServerConfig
) : AuthRepository {
private val _isAuthenticated = kotlinx.coroutines.flow.MutableStateFlow(tokenManager.getToken() != null)
override suspend fun login(userName: String, password: String): Result<AuthResult> {
return try {
val response = api.login(AuthRequest(userName, password))
val token = response.accessToken ?: return Result.failure(Exception("Token is null"))
val userId = response.userId ?: ""
tokenManager.saveToken(token, userId, response.refreshToken)
_isAuthenticated.value = true
fetchConfig()
Result.success(
AuthResult(
token = token,
refreshToken = response.refreshToken,
userId = userId,
userName = response.username ?: userName,
displayName = response.displayName ?: response.username ?: userName,
avatarUrl = null
)
)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun register(userName: String, password: String): Result<AuthResult> {
return try {
val response = api.register(AuthRequest(userName, password))
val token = response.accessToken ?: return Result.failure(Exception("Token is null"))
val userId = response.userId ?: ""
tokenManager.saveToken(token, userId, response.refreshToken)
_isAuthenticated.value = true
fetchConfig()
Result.success(
AuthResult(
token = token,
refreshToken = response.refreshToken,
userId = userId,
userName = response.username ?: userName,
displayName = response.displayName ?: response.username ?: userName,
avatarUrl = null
)
)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun logout() {
tokenManager.deleteToken()
_isAuthenticated.value = false
}
override fun isAuthenticated(): Boolean {
return _isAuthenticated.value
}
override fun isAuthenticatedFlow(): kotlinx.coroutines.flow.StateFlow<Boolean> {
return _isAuthenticated.asStateFlow()
}
override suspend fun fetchConfig(): Result<Unit> {
return try {
val config = api.getConfig()
serverConfig.saveServerConfig(config)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun updatePushToken(token: String) {
try {
api.updatePushToken(token)
} catch (e: Exception) {
// Silent fail
}
}
override suspend fun refreshToken(): Result<AuthResult> {
val currentRefreshToken = tokenManager.getRefreshToken()
if (currentRefreshToken == null) {
return Result.failure(Exception("Refresh token is null"))
}
return try {
val response = api.refreshToken(RefreshTokenRequest(currentRefreshToken))
val newAccessToken = response.accessToken ?: return Result.failure(Exception("New access token is null"))
val newRefreshToken = response.refreshToken
val userId = response.userId ?: ""
tokenManager.saveToken(newAccessToken, userId, newRefreshToken)
_isAuthenticated.value = true
Result.success(
AuthResult(
token = newAccessToken,
refreshToken = newRefreshToken,
userId = userId,
userName = response.username ?: "",
displayName = response.displayName ?: "",
avatarUrl = null
)
)
} catch (e: Exception) {
Result.failure(e)
}
}
}
+34
View File
@@ -0,0 +1,34 @@
package auth.di
import auth.data.remote.api.AuthApi
import auth.data.repository.AuthRepositoryImpl
import auth.domain.repository.AuthRepository
import core.network.ServerConfig
import core.security.TokenManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import retrofit2.Retrofit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AuthModule {
@Provides
@Singleton
fun provideAuthApi(retrofit: Retrofit): AuthApi {
return retrofit.create(AuthApi::class.java)
}
@Provides
@Singleton
fun provideAuthRepository(
api: AuthApi,
tokenManager: TokenManager,
serverConfig: ServerConfig
): AuthRepository {
return AuthRepositoryImpl(api, tokenManager, serverConfig)
}
}
@@ -0,0 +1,10 @@
package auth.domain.model
data class AuthResult(
val token: String,
val refreshToken: String?,
val userId: String,
val userName: String,
val displayName: String,
val avatarUrl: String?
)
@@ -0,0 +1,14 @@
package auth.domain.repository
import auth.domain.model.AuthResult
interface AuthRepository {
suspend fun login(userName: String, password: String): Result<AuthResult>
suspend fun register(userName: String, password: String): Result<AuthResult>
suspend fun logout()
suspend fun fetchConfig(): Result<Unit>
fun isAuthenticated(): Boolean
fun isAuthenticatedFlow(): kotlinx.coroutines.flow.StateFlow<Boolean>
suspend fun updatePushToken(token: String)
suspend fun refreshToken(): Result<AuthResult>
}
@@ -0,0 +1,94 @@
package auth.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import auth.domain.repository.AuthRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import com.google.firebase.messaging.FirebaseMessaging
import javax.inject.Inject
data class AuthState(
val isLoading: Boolean = false,
val error: String? = null,
val isAuthenticated: Boolean = false
)
@HiltViewModel
class AuthViewModel @Inject constructor(
private val repository: AuthRepository
) : ViewModel() {
private val _state = MutableStateFlow(AuthState(isAuthenticated = repository.isAuthenticated()))
val state: StateFlow<AuthState> = _state.asStateFlow()
init {
viewModelScope.launch {
repository.isAuthenticatedFlow().collect { authenticated ->
_state.update { it.copy(isAuthenticated = authenticated) }
if (authenticated) {
updatePushToken()
}
}
}
}
fun checkAuth() {
_state.update { it.copy(isAuthenticated = repository.isAuthenticated()) }
}
fun login(userName: String, password: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
repository.login(userName, password)
.onSuccess {
_state.update { it.copy(isLoading = false, isAuthenticated = true) }
updatePushToken()
}
.onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message) }
}
}
}
fun register(userName: String, password: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
repository.register(userName, password)
.onSuccess {
_state.update { it.copy(isLoading = false, isAuthenticated = true) }
updatePushToken()
}
.onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message) }
}
}
}
fun refreshToken() {
viewModelScope.launch {
repository.refreshToken()
.onSuccess {
// Token refreshed successfully
}
.onFailure {
// Refresh failed, will trigger logout via AuthInterceptor
}
}
}
private fun updatePushToken() {
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (task.isSuccessful) {
val token = task.result
viewModelScope.launch {
repository.updatePushToken(token)
}
}
}
}
}
@@ -0,0 +1,95 @@
package auth.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import ru.knot.messager.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(
viewModel: AuthViewModel,
onNavigateToRegister: () -> Unit,
onNavigateToSettings: () -> Unit,
onLoginSuccess: () -> Unit
) {
val state by viewModel.state.collectAsState()
var userName by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
LaunchedEffect(state.isAuthenticated) {
if (state.isAuthenticated) {
onLoginSuccess()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.login)) },
actions = {
IconButton(onClick = onNavigateToSettings) {
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
OutlinedTextField(
value = userName,
onValueChange = { userName = it },
label = { Text(stringResource(R.string.username)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.password)) },
modifier = Modifier.fillMaxWidth(),
visualTransformation = PasswordVisualTransformation(),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
if (state.isLoading) {
CircularProgressIndicator()
} else {
Button(
onClick = { viewModel.login(userName, password) },
modifier = Modifier.fillMaxWidth(),
enabled = userName.isNotBlank() && password.isNotBlank()
) {
Text(stringResource(R.string.login))
}
TextButton(onClick = onNavigateToRegister) {
Text(stringResource(R.string.no_account_register))
}
}
if (state.error != null) {
Text(
text = state.error!!,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
}
@@ -0,0 +1,106 @@
package auth.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import ru.knot.messager.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RegisterScreen(
viewModel: AuthViewModel,
onNavigateToLogin: () -> Unit,
onRegisterSuccess: () -> Unit
) {
val state by viewModel.state.collectAsState()
var userName by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf<String?>(null) }
val passwordsNotMatchMsg = stringResource(R.string.passwords_not_match)
LaunchedEffect(state.isAuthenticated) {
if (state.isAuthenticated) {
onRegisterSuccess()
}
}
Scaffold(
topBar = {
TopAppBar(title = { Text(stringResource(R.string.register)) })
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
OutlinedTextField(
value = userName,
onValueChange = { userName = it },
label = { Text(stringResource(R.string.username)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.password)) },
modifier = Modifier.fillMaxWidth(),
visualTransformation = PasswordVisualTransformation(),
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = { Text(stringResource(R.string.confirm_password)) },
modifier = Modifier.fillMaxWidth(),
visualTransformation = PasswordVisualTransformation(),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
if (state.isLoading) {
CircularProgressIndicator()
} else {
Button(
onClick = {
if (password == confirmPassword) {
errorMessage = null
viewModel.register(userName, password)
} else {
errorMessage = passwordsNotMatchMsg
}
},
modifier = Modifier.fillMaxWidth(),
enabled = userName.isNotBlank() && password.isNotBlank() && confirmPassword.isNotBlank()
) {
Text(stringResource(R.string.register))
}
TextButton(onClick = onNavigateToLogin) {
Text(stringResource(R.string.already_have_account))
}
}
val displayError = state.error ?: errorMessage
if (displayError != null) {
Text(
text = displayError,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
// Top-level build file
plugins {
id("com.android.application") version "8.2.0" apply false
id("com.android.library") version "8.2.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
id("com.google.dagger.hilt.android") version "2.48" apply false
id("com.google.gms.google-services") version "4.4.0" apply false
}
@@ -0,0 +1,43 @@
package calls.data.remote
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
class CallAudioManager(private val context: Context) {
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private var originalMode: Int = AudioManager.MODE_NORMAL
private var originalIsSpeakerphoneOn: Boolean = false
fun startCallMode(isVideoCall: Boolean) {
originalMode = audioManager.mode
originalIsSpeakerphoneOn = audioManager.isSpeakerphoneOn
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
setSpeakerphoneOn(isVideoCall)
// Запрашиваем фокус аудио
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val playbackAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
.setAudioAttributes(playbackAttributes)
.build()
audioManager.requestAudioFocus(focusRequest)
}
}
fun setSpeakerphoneOn(on: Boolean) {
audioManager.isSpeakerphoneOn = on
}
fun stopCallMode() {
audioManager.mode = originalMode
audioManager.isSpeakerphoneOn = originalIsSpeakerphoneOn
audioManager.abandonAudioFocus(null)
}
}
@@ -0,0 +1,46 @@
package calls.data.remote
import android.content.Context
import org.webrtc.*
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class GroupWebRtcManager @Inject constructor(private val context: Context) {
private val peerConnections = ConcurrentHashMap<String, PeerConnection>()
private val factory: PeerConnectionFactory by lazy { createFactory() }
private fun createFactory(): PeerConnectionFactory {
PeerConnectionFactory.initialize(
PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions()
)
return PeerConnectionFactory.builder()
.setVideoEncoderFactory(DefaultVideoEncoderFactory(EglBase.create().eglBaseContext, true, true))
.setVideoDecoderFactory(DefaultVideoDecoderFactory(EglBase.create().eglBaseContext))
.createPeerConnectionFactory()
}
fun addParticipant(userId: String, observer: PeerConnection.Observer): PeerConnection? {
val iceServers = listOf(
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()
)
val pc = factory.createPeerConnection(iceServers, observer)
if (pc != null) {
peerConnections[userId] = pc
}
return pc
}
fun removeParticipant(userId: String) {
peerConnections[userId]?.dispose()
peerConnections.remove(userId)
}
fun getPeerConnection(userId: String): PeerConnection? = peerConnections[userId]
fun closeAll() {
peerConnections.values.forEach { it.dispose() }
peerConnections.clear()
}
}
@@ -0,0 +1,50 @@
package calls.data.remote
import android.content.Context
import org.webrtc.*
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class WebRtcManager @Inject constructor(private val context: Context) {
private var peerConnection: PeerConnection? = null
private val factory: PeerConnectionFactory by lazy { createFactory() }
// Аудио и видео источники
private val videoSource by lazy { factory.createVideoSource(false) }
private val audioSource by lazy { factory.createAudioSource(MediaConstraints()) }
private fun createFactory(): PeerConnectionFactory {
PeerConnectionFactory.initialize(
PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions()
)
return PeerConnectionFactory.builder()
.setVideoEncoderFactory(DefaultVideoEncoderFactory(EglBase.create().eglBaseContext, true, true))
.setVideoDecoderFactory(DefaultVideoDecoderFactory(EglBase.create().eglBaseContext))
.createPeerConnectionFactory()
}
fun initializePeerConnection(observer: PeerConnection.Observer) {
val iceServers = listOf(
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()
)
peerConnection = factory.createPeerConnection(iceServers, observer)
}
fun createOffer(observer: SdpObserver) {
peerConnection?.createOffer(observer, MediaConstraints())
}
fun setRemoteDescription(sdp: String, type: SessionDescription.Type, observer: SdpObserver) {
peerConnection?.setRemoteDescription(observer, SessionDescription(type, sdp))
}
fun addIceCandidate(candidate: IceCandidate) {
peerConnection?.addIceCandidate(candidate)
}
fun close() {
peerConnection?.dispose()
peerConnection = null
}
}
@@ -0,0 +1,148 @@
package calls.presentation
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import core.presentation.components.AppAvatar
import ru.knot.messager.R
@Composable
fun CallScreen(
viewModel: CallViewModel,
onBack: () -> Unit
) {
val state by viewModel.state.collectAsState()
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color(0xFF0F0F10),
Color(0xFF161618),
Color(0xFF6366F1).copy(alpha = 0.2f)
)
)
)
) {
// Контент звонка (Аватар или Видео)
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = 100.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
AppAvatar(
url = state.callerAvatar,
name = state.callerName,
size = 140.dp
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = state.callerName,
style = MaterialTheme.typography.headlineMedium,
color = Color.White,
fontWeight = FontWeight.Bold
)
Text(
text = when (state.status) {
CallStatus.INCOMING -> "Входящий звонок..."
CallStatus.OUTGOING -> "Вызов..."
CallStatus.CONNECTED -> "00:00" // TODO: Timer
CallStatus.ENDED -> "Звонок завершен"
else -> ""
},
style = MaterialTheme.typography.bodyLarge,
color = Color.White.copy(alpha = 0.7f)
)
}
// Кнопки управления (Внизу)
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 60.dp)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Row(
horizontalArrangement = Arrangement.spacedBy(32.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (state.status == CallStatus.INCOMING) {
// Кнопка отклонить
CallActionButton(
icon = Icons.Default.CallEnd,
backgroundColor = Color.Red,
onClick = { viewModel.endCall(); onBack() }
)
// Кнопка принять
CallActionButton(
icon = Icons.Default.Call,
backgroundColor = Color(0xFF10B981), // Green
onClick = { viewModel.acceptCall() }
)
} else {
// Стандартные кнопки во время разговора
IconButton(
onClick = { /* viewModel.toggleMic() */ },
modifier = Modifier.size(56.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.1f))
) {
Icon(Icons.Default.Mic, contentDescription = null, tint = Color.White)
}
CallActionButton(
icon = Icons.Default.CallEnd,
backgroundColor = Color.Red,
onClick = { viewModel.endCall(); onBack() }
)
IconButton(
onClick = { /* viewModel.toggleSpeaker() */ },
modifier = Modifier.size(56.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.1f))
) {
Icon(Icons.Default.VolumeUp, contentDescription = null, tint = Color.White)
}
}
}
}
}
}
@Composable
fun CallActionButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
backgroundColor: Color,
onClick: () -> Unit
) {
FloatingActionButton(
onClick = onClick,
containerColor = backgroundColor,
contentColor = Color.White,
shape = CircleShape,
modifier = Modifier.size(64.dp)
) {
Icon(icon, contentDescription = null, modifier = Modifier.size(32.dp))
}
}
@@ -0,0 +1,99 @@
package calls.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import calls.data.remote.WebRtcManager
import chats.data.remote.signalr.ChatEvent
import chats.data.remote.signalr.ChatHubClient
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import org.webrtc.*
import javax.inject.Inject
enum class CallStatus { IDLE, INCOMING, OUTGOING, CONNECTED, ENDED }
data class CallState(
val status: CallStatus = CallStatus.IDLE,
val chatId: String? = null,
val callerName: String = "",
val callerAvatar: String? = null,
val isMuted: Boolean = false,
val isSpeakerOn: Boolean = false,
val localVideoTrack: VideoTrack? = null,
val remoteVideoTrack: VideoTrack? = null
)
@HiltViewModel
class CallViewModel @Inject constructor(
private val webRtcManager: WebRtcManager,
private val signalrClient: ChatHubClient
) : ViewModel() {
private val _state = MutableStateFlow(CallState())
val state: StateFlow<CallState> = _state.asStateFlow()
init {
observeSignaling()
}
private fun observeSignaling() {
signalrClient.events
.onEach { event ->
when (event) {
is ChatEvent.CallIncoming -> onIncomingCall(event)
is ChatEvent.CallAnswered -> onCallAnswered(event)
is ChatEvent.IceCandidateReceived -> onIceCandidate(event)
is ChatEvent.CallEnded -> onCallEnded()
else -> Unit
}
}
.launchIn(viewModelScope)
}
private fun onIncomingCall(event: ChatEvent.CallIncoming) {
_state.update { it.copy(
status = CallStatus.INCOMING,
chatId = event.chatId,
callerName = "User ${event.from}" // TODO: Load actual user info
) }
// Set remote description from offer
webRtcManager.setRemoteDescription(event.offer, SessionDescription.Type.OFFER, object : SdpObserver {
override fun onCreateSuccess(p0: SessionDescription?) {}
override fun onSetSuccess() {}
override fun onCreateFailure(p0: String?) {}
override fun onSetFailure(p0: String?) {}
})
}
private fun onCallAnswered(event: ChatEvent.CallAnswered) {
_state.update { it.copy(status = CallStatus.CONNECTED) }
webRtcManager.setRemoteDescription(event.answer, SessionDescription.Type.ANSWER, object : SdpObserver {
override fun onCreateSuccess(p0: SessionDescription?) {}
override fun onSetSuccess() {}
override fun onCreateFailure(p0: String?) {}
override fun onSetFailure(p0: String?) {}
})
}
private fun onIceCandidate(event: ChatEvent.IceCandidateReceived) {
// Parse candidate JSON and add to peer connection
// webRtcManager.addIceCandidate(...)
}
private fun onCallEnded() {
_state.update { it.copy(status = CallStatus.ENDED) }
webRtcManager.close()
}
fun acceptCall() {
val chatId = _state.value.chatId ?: return
// Create answer and send via SignalR
}
fun endCall() {
val chatId = _state.value.chatId ?: return
// Send call_end via SignalR
onCallEnded()
}
}
@@ -0,0 +1,87 @@
package calls.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import chats.data.remote.signalr.ChatHubClient
import chats.data.remote.signalr.ChatEvent
import calls.data.remote.GroupWebRtcManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import org.webrtc.*
import javax.inject.Inject
data class ParticipantState(
val userId: String,
val videoTrack: VideoTrack? = null,
val isAudioMuted: Boolean = false,
val isVideoDisabled: Boolean = false
)
data class GroupCallState(
val chatId: String? = null,
val participants: Map<String, ParticipantState> = emptyMap(),
val isMicEnabled: Boolean = true,
val isCameraEnabled: Boolean = true
)
@HiltViewModel
class GroupCallViewModel @Inject constructor(
private val webRtcManager: GroupWebRtcManager,
private val signalrClient: ChatHubClient
) : ViewModel() {
private val _state = MutableStateFlow(GroupCallState())
val state: StateFlow<GroupCallState> = _state.asStateFlow()
init {
observeSignalREvents()
}
private fun observeSignalREvents() {
signalrClient.events.onEach { event ->
when (event) {
is ChatEvent.GroupCallUserJoined -> handleUserJoined(event.userId)
is ChatEvent.GroupCallUserLeft -> handleUserLeft(event.userId)
is ChatEvent.GroupCallOffer -> handleOffer(event.from, event.offer)
is ChatEvent.GroupCallAnswer -> handleAnswer(event.from, event.answer)
// Дополнительные обработчики ICE кандидатов и т.д.
else -> Unit
}
}.launchIn(viewModelScope)
}
private fun handleUserJoined(userId: String) {
// Создаем PeerConnection для нового участника
// Логика идентична портированному CallModal.tsx
_state.update { it.copy(participants = it.participants + (userId to ParticipantState(userId))) }
}
private fun handleUserLeft(userId: String) {
webRtcManager.removeParticipant(userId)
_state.update { it.copy(participants = it.participants - userId) }
}
private fun handleOffer(from: String, sdp: String) {
// Установка RemoteDescription и создание Answer
}
private fun handleAnswer(from: String, sdp: String) {
// Установка RemoteDescription
}
fun toggleMic() {
_state.update { it.copy(isMicEnabled = !it.isMicEnabled) }
// Логика управления AudioTrack
}
fun toggleCamera() {
_state.update { it.copy(isCameraEnabled = !it.isCameraEnabled) }
// Логика управления VideoTrack
}
override fun onCleared() {
super.onCleared()
webRtcManager.closeAll()
}
}
@@ -0,0 +1,67 @@
package calls.presentation.components
import android.view.ViewGroup
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import org.webrtc.EglBase
import org.webrtc.SurfaceViewRenderer
import org.webrtc.VideoTrack
@Composable
fun VideoGrid(
participants: Map<String, VideoTrack?>,
localVideoTrack: VideoTrack?
) {
val eglBaseContext = remember { EglBase.create().eglBaseContext }
val allVideoTracks = remember(participants, localVideoTrack) {
listOfNotNull(localVideoTrack) + participants.values.filterNotNull()
}
LazyVerticalGrid(
columns = GridCells.Fixed(if (allVideoTracks.size <= 2) 1 else 2),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(8.dp)
) {
items(allVideoTracks) { track ->
Box(
modifier = Modifier
.padding(4.dp)
.fillMaxWidth()
.aspectRatio(if (allVideoTracks.size == 1) 0.6f else 1f)
) {
VideoRenderer(videoTrack = track, eglBaseContext = eglBaseContext)
}
}
}
}
@Composable
fun VideoRenderer(
videoTrack: VideoTrack,
eglBaseContext: EglBase.Context
) {
AndroidView(
factory = { context ->
SurfaceViewRenderer(context).apply {
init(eglBaseContext, null)
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
},
update = { view ->
videoTrack.addSink(view)
},
onRelease = { view ->
videoTrack.removeSink(view)
view.release()
}
)
}
+167
View File
@@ -0,0 +1,167 @@
# Архитектура Offline-first для мессенджера Knot
## Обзор
Система кэширования истории чатов реализует паттерн **Offline-first** с использованием:
- **Room** - локальная база данных
- **Paging 3** - пагинация с RemoteMediator
- **WorkManager** - фоновая синхронизация
- **SignalR** - real-time обновления
## Компоненты
### 1. Data Layer
#### MessageEntity
```kotlin
@Entity(tableName = "messages")
data class MessageEntity(
@PrimaryKey val id: String,
val chatId: String,
val senderId: String,
val content: String?,
val sequenceId: Int,
val createdAt: String,
// Поля синхронизации
val syncStatus: SyncStatus, // SYNCED, SYNCING, FAILED
val isDeletedLocally: Boolean, // Помечено на удаление
val isEditedLocally: Boolean, // Помечено на редактирование
val editedContent: String?, // Новое содержимое
val lastUpdated: Long // Время последнего изменения
)
```
#### MessageDao
Основные методы:
- `getMessagesPagingSource()` - PagingSource для Paging 3
- `upsertMessage()` - Вставка/обновление с разрешением конфликтов
- `markAsDeletedLocally()` - Пометка на удаление
- `markAsEditedLocally()` - Пометка на редактирование
- `getPendingSyncMessages()` - Получение сообщений для синхронизации
### 2. Pagination (Paging 3)
#### MessageRemoteMediator
Управляет загрузкой данных:
- **REFRESH** - первая загрузка последних сообщений
- **APPEND** - загрузка более старых сообщений (прокрутка вниз)
- **PREPEND** - загрузка более новых сообщений (прокрутка вверх)
Логика:
1. Проверяет наличие данных в Room
2. При необходимости загружает из API
3. Сохраняет в Room
4. Paging читает из локальной базы
### 3. Background Sync (WorkManager)
#### MessageSyncWorker
Обрабатывает отложенную синхронизацию:
- Отправка новых сообщений (SYNCING)
- Обновление отредактированных (isEditedLocally = true)
- Удаление помеченных (isDeletedLocally = true)
- Повтор при ошибках (FAILED)
Политика повторных попыток:
- Экспоненциальная задержка
- Максимум 3 попытки
- Требуется подключение к сети
### 4. Real-time Updates (SignalR)
#### MessageSignalRHandler
Обрабатывает события:
- `new_message` - новое сообщение
- `message_edited` - редактирование
- `message_deleted` - удаление
- `messages_read` - прочтение
- `reaction_added/removed` - реакции
Все изменения сразу записываются в Room → UI обновляется через Flow
### 5. Repository
#### ChatRepositoryImpl
Единая точка входа для ViewModel:
- `getMessagesPaging()` - Paging 3 поток
- `getMessagesFlow()` - простой Flow списка
- `sendMessage()` - отправка с локальным сохранением
- `deleteLocalMessage()` - локальное удаление
- `editLocalMessage()` - локальное редактирование
## Conflict Resolution
Приоритет данных:
1. **Сообщения в процессе отправки (SYNCING)** - локальные данные имеют приоритет
2. **Сообщения в процессе редактирования** - локальные данные имеют приоритет
3. **Все остальные случаи** - серверные данные имеют приоритет
## Схема работы
### Отправка сообщения
```
User → sendMessage() → Сохранение в Room (SYNCING) → UI показывает сообщение
→ WorkManager планирует синхронизацию
→ Отправка на сервер
→ Обновление статуса (SYNCED)
```
### Получение сообщений
```
UI ← getMessagesPaging() ← Room ← RemoteMediator ← API
└─── SignalR обновления
```
### Удаление сообщения
```
User → deleteLocalMessage() → Пометка (isDeletedLocally = true)
→ WorkManager удаляет на сервере
→ Удаление из Room
```
## Использование
### Paging 3 в ViewModel
```kotlin
@HiltViewModel
class ChatViewModel @Inject constructor(
private val repository: ChatRepository
) : ViewModel() {
val messages: Flow<PagingData<Message>> =
repository.getMessagesPaging(chatId)
.cachedIn(viewModelScope)
}
```
### Офлайн отправка
```kotlin
// Сообщение сразу появится в UI
val message = repository.sendMessage(
chatId = chatId,
content = "Hello"
)
// Синхронизация произойдёт в фоне
```
## Миграции
При обновлении схемы БД используется миграция `MIGRATION_1_2`:
- Добавляет поля синхронизации
- Сохраняет существующие данные
- Устанавливает значения по умолчанию
## Тестирование
### Юнит-тесты
- MessageDao тесты
- MessageRemoteMediator тесты
- ChatRepositoryImpl тесты
### Интеграционные тесты
- Синхронизация с сервером
- Обработка конфликтов
- WorkManager сценарии
@@ -0,0 +1,178 @@
# Сводка реализации системы кэширования
## 📁 Созданные файлы
### Data Layer
1. **core/database/data/ChatDatabase.kt** (обновлён)
- Добавлены поля синхронизации в MessageEntity
- Расширен MessageDao методами для Paging и офлайн-операций
- Добавлен SyncStatusConverter для Room
2. **core/database/data/Migrations.kt** (новый)
- Миграция MIGRATION_1_2 для обновления схемы БД
3. **core/di/DatabaseModule.kt** (обновлён)
- Добавлена миграция
- Изменено имя БД на константу
4. **core/di/WorkManagerModule.kt** (новый)
- DI модуль для WorkManager
### Paging 3
5. **chats/data/paging/MessageRemoteMediator.kt** (новый)
- RemoteMediator для загрузки данных из сети
- Управление пагинацией (REFRESH, APPEND, PREPEND)
- Сохранение в Room
6. **chats/data/paging/MessagePagingSource.kt** (новый)
- PagingSource для чтения из Room
### Sync (WorkManager)
7. **chats/data/sync/MessageSyncWorker.kt** (новый)
- Worker для фоновой синхронизации
- Обработка отправки, редактирования, удаления
- Политика повторных попыток (exponential backoff)
### SignalR Integration
8. **chats/data/signalr/MessageSignalRHandler.kt** (новый)
- Обработчик SignalR событий
- Обновление локального кэша в реальном времени
- Разрешение конфликтов
### Repository
9. **chats/domain/repository/ChatRepository.kt** (обновлён)
- Добавлен метод getMessagesPaging()
- Добавлены editLocalMessage()
10. **chats/data/repository/ChatRepositoryImpl.kt** (обновлён)
- Полная реализация Offline-first
- Интеграция Paging 3, SignalR, WorkManager
- Conflict Resolution логика
### DI
11. **chats/di/ChatModule.kt** (обновлён)
- Регистрация MessageSignalRHandler
- Обновлён ChatRepositoryImpl с новыми зависимостями
### Domain Models
12. **chats/domain/model/ChatModels.kt** (обновлён)
- Добавлен ChatMember
- Добавлено поле members в Chat
### Application
13. **app/src/main/kotlin/com/knot/messenger/MainApplication.kt** (обновлён)
- Реализация Configuration.Provider для WorkManager
- Интеграция Hilt WorkerFactory
### Документация
14. **chats/ARCHITECTURE.md** (новый)
- Описание архитектуры
- Схема работы компонентов
15. **chats/USAGE_EXAMPLES.md** (новый)
- Примеры использования
- Best practices
## 🔧 Изменения в зависимостях (app/build.gradle.kts)
Добавлено:
```kotlin
// Paging 3
implementation("androidx.paging:paging-runtime-ktx:3.2.1")
implementation("androidx.paging:paging-compose:3.2.1")
// WorkManager + Hilt
implementation("androidx.work:work-runtime-ktx:2.9.0")
implementation("androidx.hilt:hilt-work:1.1.0")
kapt("androidx.hilt:hilt-compiler:1.1.0")
```
## 🏗️ Архитектурные решения
### 1. Offline-first подход
- Все данные читаются из локальной Room базы
- Сетевые запросы только для синхронизации
- UI всегда работает с локальными данными
### 2. Paging 3 с RemoteMediator
- Единый источник истины - Room
- RemoteMediator управляет загрузкой из сети
- Автоматическая инвалидация при изменениях
### 3. Conflict Resolution
- **SYNCING/EDITING**: локальные данные имеют приоритет
- **SYNCED**: серверные данные имеют приоритет
- SignalR события применяются аккуратно
### 4. Background Sync
- WorkManager для надёжной доставки
- Exponential backoff при ошибках
- Требуется NetzwerkType.CONNECTED
### 5. Real-time Updates
- SignalR события → Room → Flow → UI
- Автоматическое обновление UI
- Минимальная задержка
## 📊 Схема потока данных
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ SignalR │────▶│ SignalR │────▶│ Room │
│ (Server) │ │ Handler │ │ (SQLite) │
└─────────────┘ └──────────────┘ └──────┬──────┘
┌─────────────┐ ┌──────────────┐ ┌──────▼──────┐
│ API │◀───▶│ Remote │◀───▶│ Paging │
│ (Retrofit) │ │ Mediator │ │ Source │
└─────────────┘ └──────────────┘ └──────┬──────┘
┌─────────────┐ ┌──────────────┐ ┌──────▼──────┐
│ WorkManager│◀───▶│ Repository │◀───▶│ UI │
│ (Sync) │ │ │ │ (Flow) │
└─────────────┘ └──────────────┘ └─────────────┘
```
## ✅ Checklist реализации
- [x] MessageEntity с полями синхронизации
- [x] MessageDao с PagingSource методами
- [x] MessageRemoteMediator для Paging 3
- [x] MessageSyncWorker для WorkManager
- [x] MessageSignalRHandler для real-time
- [x] ChatRepositoryImpl с полной логикой
- [x] DI модули обновлены
- [x] Миграция БД
- [x] Hilt Worker интеграция
- [x] Документация
## 🚀 Следующие шаги
1. **Тестирование**
- Юнит-тесты для MessageDao
- Интеграционные тесты для Repository
- UI тесты с Paging 3
2. **Мониторинг**
- Логирование синхронизации
- Метрики ошибок
- Analytics офлайн-режима
3. **Оптимизация**
- Индексы в БД для производительности
- Кэширование изображений
- Оптимизация запросов
4. **Улучшения**
- Поиск по сообщениям
- Избранные сообщения
- Архивация чатов
## 🔍 Ключевые особенности
1. **Мгновенный UI** - сообщения появляются сразу
2. **Надёжная синхронизация** - WorkManager гарантирует доставку
3. **Real-time** - SignalR для мгновенных обновлений
4. **Офлайн-работа** - полное функционирование без сети
5. **Разрешение конфликтов** - умная логика приоритетов
6. **Пагинация** - эффективная работа с большими чатами
+296
View File
@@ -0,0 +1,296 @@
# Руководство по интеграции
## Быстрый старт
### 1. Добавление зависимостей
В `app/build.gradle.kts` уже добавлены:
```kotlin
// Paging 3
implementation("androidx.paging:paging-runtime-ktx:3.2.1")
implementation("androidx.paging:paging-compose:3.2.1")
// WorkManager + Hilt
implementation("androidx.work:work-runtime-ktx:2.9.0")
implementation("androidx.hilt:hilt-work:1.1.0")
kapt("androidx.hilt:hilt-compiler:1.1.0")
```
### 2. Обновление Application класса
`MainApplication.kt` уже обновлён:
```kotlin
@HiltAndroidApp
class MainApplication : Application(), ImageLoaderFactory, Configuration.Provider {
@Inject lateinit var workerFactory: WorkerFactory
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.setMinimumLoggingLevel(android.util.Log.INFO)
.build()
}
```
### 3. Миграция базы данных
База данных автоматически обновится при первом запуске благодаря `MIGRATION_1_2`.
## Использование в ViewModel
### Вариант 1: Paging 3 (рекомендуется для больших чатов)
```kotlin
@HiltViewModel
class ChatViewModel @Inject constructor(
private val repository: ChatRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val chatId: String = savedStateHandle["chatId"] ?: ""
val messages: Flow<PagingData<Message>> = repository
.getMessagesPaging(chatId)
.cachedIn(viewModelScope)
fun sendMessage(content: String) {
viewModelScope.launch {
repository.sendMessage(chatId, content, "text")
}
}
fun deleteMessage(messageId: String) {
viewModelScope.launch {
repository.deleteLocalMessage(messageId)
}
}
}
```
### Вариант 2: Простой Flow (для небольших чатов)
```kotlin
@HiltViewModel
class ChatViewModel @Inject constructor(
private val repository: ChatRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val chatId: String = savedStateHandle["chatId"] ?: ""
val messages: Flow<List<Message>> = repository
.getMessagesFlow(chatId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}
```
## Использование в UI (Compose)
### С Paging 3
```kotlin
@Composable
fun ChatScreen(viewModel: ChatViewModel = hiltViewModel()) {
val messages by viewModel.messages.collectAsLazyPagingItems()
LazyColumn(
reverseLayout = true, // Сообщения снизу вверх
modifier = Modifier.fillMaxSize()
) {
items(
count = messages.itemCount,
key = messages.key
) { index ->
messages[index]?.let { message ->
MessageItem(message = message)
}
}
// Индикатор загрузки
when {
messages.loadState.refresh is LoadState.Loading -> {
item { LoadingIndicator() }
}
messages.loadState.append is LoadState.Loading -> {
item { LoadingIndicator() }
}
}
// Ошибки
messages.loadState.append.let { loadState ->
if (loadState is LoadState.Error) {
item {
Text("Ошибка: ${loadState.error.message}")
Button(onClick = { messages.retry() }) {
Text("Повторить")
}
}
}
}
}
}
```
### С простым Flow
```kotlin
@Composable
fun ChatScreen(viewModel: ChatViewModel = hiltViewModel()) {
val messages by viewModel.messages.collectAsState()
LazyColumn(reverseLayout = true) {
items(messages) { message ->
MessageItem(message = message)
}
}
}
```
## Отправка сообщения
```kotlin
// Мгновенное отображение в UI
viewModel.sendMessage("Привет!")
// Сообщение сохраняется локально и появляется в UI сразу
// WorkManager отправит его на сервер в фоне
```
## Удаление сообщения
```kotlin
// Мягкое удаление (через WorkManager)
viewModel.deleteMessage(messageId)
// Или немедленное удаление
viewModelScope.launch {
repository.deleteMessage(messageId, forEveryone = false)
}
```
## Редактирование сообщения
```kotlin
viewModelScope.launch {
repository.editLocalMessage(messageId, "Новый текст")
}
```
## Мониторинг синхронизации
```kotlin
// В ViewModel
val syncStatus: Flow<List<MessageEntity>> = messageDao
.getPendingSyncMessagesFlow()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
// В UI
val pendingMessages by syncStatus.collectAsState()
if (pendingMessages.isNotEmpty()) {
Text("${pendingMessages.size} сообщений ожидают отправки")
}
```
## Обработка офлайн-режима
```kotlin
@Composable
fun MessageItem(message: Message) {
val isPending = message.id.startsWith("local_")
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = message.content ?: "",
modifier = Modifier.weight(1f)
)
// Индикатор отправки
if (isPending) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
}
// Статус прочтения
Icon(
imageVector = if (message.isRead) Icons.Default.DoneAll else Icons.Default.Done,
contentDescription = null
)
}
}
```
## Проверка сборки
```bash
cd client-mobile
./gradlew assembleDebug
```
## Возможные проблемы и решения
### 1. Ошибка: "WorkerFactory not found"
**Решение:** Убедитесь, что `MainApplication` реализует `Configuration.Provider`
### 2. Ошибка: "Table messages has no column named syncStatus"
**Решение:** Проверьте, что миграция `MIGRATION_1_2` добавлена в `DatabaseModule`
### 3. Paging не загружает данные
**Решение:** Проверьте логи `MessageRemoteMediator` - возможны проблемы с API
### 4. Сообщения не синхронизируются
**Решение:** Проверьте WorkManager логи и наличие сетевого подключения
### 5. SignalR не подключается
**Решение:** Проверьте `ChatHubClient.connect()` - должен вызываться после авторизации
## Тестирование
### Юнит-тесты
```kotlin
@Test
fun `message saved locally should have SYNCING status`() = runTest {
val message = MessageEntity(
id = "test",
chatId = "chat1",
// ...
syncStatus = SyncStatus.SYNCING
)
dao.insertMessage(message)
val saved = dao.getMessageById("test")
assertEquals(SyncStatus.SYNCING, saved?.syncStatus)
}
```
### Интеграционные тесты
```kotlin
@Test
fun `sending message should save locally and sync to server`() = runTest {
// Arrange
val repository = ChatRepositoryImpl(...)
// Act
val message = repository.sendMessage("chat1", "Hello", "text")
// Assert
assertTrue(message.id.startsWith("local_"))
// Wait for sync
delay(5000)
val synced = dao.getMessageById(message.id)
assertEquals(SyncStatus.SYNCED, synced?.syncStatus)
}
```
## Дополнительные ресурсы
- [Paging 3 Documentation](https://developer.android.com/topic/libraries/architecture/paging/v3-overview)
- [WorkManager Documentation](https://developer.android.com/topic/libraries/architecture/workmanager)
- [Room Documentation](https://developer.android.com/training/data-storage/room)
- [ARCHITECTURE.md](ARCHITECTURE.md) - детальное описание архитектуры
- [USAGE_EXAMPLES.md](USAGE_EXAMPLES.md) - больше примеров использования
+262
View File
@@ -0,0 +1,262 @@
# Примеры использования системы кэширования
## 1. Paging 3 в ViewModel
```kotlin
@HiltViewModel
class ChatViewModel @Inject constructor(
private val repository: ChatRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val chatId: String = savedStateHandle["chatId"] ?: ""
// Paging 3 поток для UI
val messages: Flow<PagingData<Message>> = repository
.getMessagesPaging(chatId)
.cachedIn(viewModelScope)
// Простой Flow для небольших чатов
val messagesList: Flow<List<Message>> = repository
.getMessagesFlow(chatId)
}
```
## 2. UI с Paging 3 (Jetpack Compose)
```kotlin
@Composable
fun ChatScreen(viewModel: ChatViewModel = hiltViewModel()) {
val messages by viewModel.messages.collectAsLazyPagingItems()
LazyColumn {
items(
count = messages.itemCount,
key = messages.key
) { index ->
val message = messages[index]
message?.let {
MessageItem(message = it)
}
}
// Индикаторы загрузки
when {
messages.loadState.refresh is LoadState.Loading -> {
item { LoadingIndicator() }
}
messages.loadState.append is LoadState.Loading -> {
item { LoadingIndicator() }
}
messages.loadState.prepend is LoadState.Loading -> {
item { LoadingIndicator() }
}
}
// Обработка ошибок
messages.loadState.append.let { loadState ->
if (loadState is LoadState.Error) {
item {
ErrorView(
message = loadState.error.message,
onRetry = { messages.retry() }
)
}
}
}
}
}
```
## 3. Отправка сообщения (Offline-first)
```kotlin
@HiltViewModel
class ChatViewModel @Inject constructor(
private val repository: ChatRepository
) : ViewModel() {
fun sendMessage(chatId: String, content: String) {
viewModelScope.launch {
try {
// Сообщение сразу сохраняется локально и появляется в UI
val message = repository.sendMessage(
chatId = chatId,
content = content,
type = "text"
)
// UI обновляется мгновенно через Flow
Log.d("ChatViewModel", "Message saved locally: ${message.id}")
// Синхронизация с сервером произойдёт в фоне
} catch (e: Exception) {
Log.e("ChatViewModel", "Failed to send message", e)
}
}
}
}
```
## 4. Удаление сообщения
```kotlin
fun deleteMessage(messageId: String) {
viewModelScope.launch {
// Локальное удаление (сообщение скрывается из UI)
repository.deleteLocalMessage(messageId)
// WorkManager удалит сообщение на сервере в фоне
// При получении подтверждения - сообщение удаляется из БД
}
}
// Или немедленное удаление (если онлайн)
fun deleteMessageImmediately(messageId: String, forEveryone: Boolean) {
viewModelScope.launch {
repository.deleteMessage(messageId, forEveryone)
}
}
```
## 5. Редактирование сообщения
```kotlin
fun editMessage(messageId: String, newContent: String) {
viewModelScope.launch {
// Локальное редактирование
repository.editLocalMessage(messageId, newContent)
// UI обновляется мгновенно
// WorkManager отправит изменения на сервер в фоне
}
}
```
## 6. Отслеживание статуса синхронизации
```kotlin
// Наблюдение за сообщениями, ожидающими синхронизации
fun observePendingMessages() {
viewModelScope.launch {
messageDao.getPendingSyncMessagesFlow().collect { messages ->
if (messages.isNotEmpty()) {
Log.d("Sync", "${messages.size} messages pending sync")
}
}
}
}
// Проверка статуса конкретного сообщения
fun isMessageSynced(messageId: String): Boolean {
return runBlocking {
val message = messageDao.getMessageById(messageId)
message?.syncStatus == SyncStatus.SYNCED
}
}
```
## 7. Обработка ошибок синхронизации
```kotlin
fun retryFailedMessages() {
viewModelScope.launch {
val failedMessages = messageDao.getFailedSyncMessages()
failedMessages.forEach { message ->
when {
message.isDeletedLocally -> {
// Повторить удаление
MessageSyncWorker.scheduleSync(context)
}
message.isEditedLocally -> {
// Повторить редактирование
MessageSyncWorker.scheduleSync(context)
}
else -> {
// Повторить отправку
MessageSyncWorker.scheduleSync(context)
}
}
}
}
}
```
## 8. Прочтение сообщений
```kotlin
fun markAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
viewModelScope.launch {
// Отправляем статус прочтения через SignalR
repository.markMessagesAsRead(chatId, lastMessageId, lastReadSequenceId)
// Локальная база обновляется автоматически
}
}
```
## 9. Real-time обновления
SignalR события обрабатываются автоматически:
- Новые сообщения появляются в UI мгновенно
- Редактирования/удаления синхронизируются
- Статусы прочтения обновляются
- Реакции отображаются в реальном времени
```kotlin
// Обработчик SignalR уже интегрирован в ChatRepositoryImpl
// Дополнительные действия можно добавить в MessageSignalRHandler
```
## 10. Кэширование в ViewModel
```kotlin
@HiltViewModel
class ChatViewModel @Inject constructor(
private val repository: ChatRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val chatId: String = savedStateHandle["chatId"] ?: ""
// Кэшируем PagingData в scope ViewModel
val messages: Flow<PagingData<Message>> = repository
.getMessagesPaging(chatId)
.cachedIn(viewModelScope) // Важно для сохранения состояния
// При повороте экрана пагинация сохраняется
}
```
## Рекомендации
### 1. Выбор между Paging 3 и Flow
- **Paging 3** - для больших чатов (>100 сообщений)
- **Flow<List>** - для небольших чатов или когда нужна вся история сразу
### 2. Обработка офлайн-режима
```kotlin
// UI должен показывать статус сообщения
@Composable
fun MessageItem(message: Message) {
val isPending = message.id.startsWith("local_")
Row {
Text(text = message.content)
if (isPending) {
CircularProgressIndicator(modifier = Modifier.size(12.dp))
}
}
}
```
### 3. Конфликты данных
- Локальные изменения имеют приоритет во время отправки
- Серверные данные перезаписывают локальные после SYNCED
- SignalR события всегда применяются к актуальным данным
### 4. Производительность
- Используйте `cachedIn(viewModelScope)` для PagingData
- Избегайте частых вызовов `getMessages()` из сети
- Позволяйте WorkManager управлять синхронизацией
@@ -0,0 +1,80 @@
package chats.data.paging
import androidx.paging.PagingSource
import androidx.paging.PagingState
import core.database.data.MessageDao
import core.database.data.MessageEntity
import android.util.Log
/**
* PagingSource для загрузки сообщений из локальной базы Room
* Данные сортируются по sequenceId DESC (новые сообщения первыми)
*/
class MessagePagingSource(
private val dao: MessageDao,
private val chatId: String
) : PagingSource<Int, MessageEntity>() {
companion object {
private const val TAG = "MessagePagingSource"
}
override fun getRefreshKey(state: PagingState<Int, MessageEntity>): Int? {
Log.d(TAG, "getRefreshKey() called")
return state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
}
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, MessageEntity> {
Log.d(TAG, ">>> load() called with: position=${params.key}, loadSize=${params.loadSize}")
return try {
// При DESC порядке:
// - key = sequenceId последнего сообщения в текущей странице
// - APPEND = загружаем сообщения С МЕНЬШИМ sequenceId (старые)
// - PREPEND = загружаем сообщения С БОЛЬШИМ sequenceId (новые)
val position = params.key // sequenceId граничного сообщения
val loadSize = params.loadSize
Log.d(TAG, "Loading: chatId=$chatId, position=$position, loadSize=$loadSize")
val messages = if (position == null) {
// Первая загрузка - получаем самые последние сообщения (включая maxSeq)
val maxSeq = dao.getMaxSequenceId(chatId)
Log.d(TAG, "First load: maxSeq=$maxSeq")
if (maxSeq == null) {
Log.d(TAG, "No messages in database")
emptyList()
} else {
// Используем <= чтобы включить самое последнее сообщение
dao.getMessagesUpToAndIncluding(chatId, maxSeq, loadSize)
}
} else {
// Загружаем сообщения с sequenceId < position (старые)
Log.d(TAG, "Append: loading messages before sequenceId=$position")
dao.getMessagesBefore(chatId, position, loadSize)
}
Log.d(TAG, "Loaded ${messages.size} messages, first=${messages.firstOrNull()?.sequenceId}, last=${messages.lastOrNull()?.sequenceId}")
// Для DESC порядка:
// - prevKey = максимальный sequenceId в странице (для загрузки новых)
// - nextKey = минимальный sequenceId в странице (для загрузки старых)
val prevKey = messages.firstOrNull()?.sequenceId?.plus(1)
val nextKey = messages.lastOrNull()?.sequenceId?.minus(1)
Log.d(TAG, "prevKey=$prevKey, nextKey=$nextKey")
LoadResult.Page(
data = messages,
prevKey = prevKey,
nextKey = nextKey
)
} catch (e: Exception) {
Log.e(TAG, "Error loading messages", e)
LoadResult.Error(e)
}
}
}
@@ -0,0 +1,203 @@
package chats.data.paging
import android.util.Log
import androidx.paging.*
import chats.data.remote.api.ChatApi
import chats.data.remote.dto.MessageDto
import chats.domain.model.MediaType
import com.google.gson.Gson
import core.database.data.ChatDatabase
import core.database.data.MessageDao
import core.database.data.MessageEntity
import core.database.data.SyncStatus
import core.network.ServerConfig
import core.security.TokenManager
/**
* RemoteMediator для Paging 3
* Управляет загрузкой сообщений из сети и кэшированием в Room
*
* Логика работы:
* 1. При первой загрузке (REFRESH) - загружаем последние сообщения
* 2. При прокрутке вниз (APPEND) - загружаем более старые сообщения
* 3. При прокрутке вверх (PREPEND) - загружаем более новые сообщения
* 4. Данные сохраняются в Room, Paging читает из базы
*/
@OptIn(ExperimentalPagingApi::class)
class MessageRemoteMediator(
private val chatId: String,
private val api: ChatApi,
private val database: ChatDatabase,
private val dao: core.database.data.MessageDao,
private val serverConfig: ServerConfig,
private val tokenManager: TokenManager
) : RemoteMediator<Int, MessageEntity>() {
private val gson = Gson()
private val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
/**
* Состояние пагинации
*/
data class MessageRemoteMediatorState(
val lastSequenceId: Int?,
val firstSequenceId: Int?
)
override suspend fun initialize(): InitializeAction {
// Всегда запускаем refresh для проверки кэша
return InitializeAction.LAUNCH_INITIAL_REFRESH
}
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, MessageEntity>
): MediatorResult {
Log.d(TAG, ">>> load() loadType=$loadType")
return try {
// Проверяем наличие данных в локальной БД
val localMinSeq = dao.getMinSequenceId(chatId)
val localMaxSeq = dao.getMaxSequenceId(chatId)
Log.d(TAG, "Local DB: minSeq=$localMinSeq, maxSeq=$localMaxSeq")
when (loadType) {
LoadType.REFRESH -> {
// Если есть локальные данные - не делаем API запрос
if (localMaxSeq != null) {
Log.d(TAG, "REFRESH: Using cached data (maxSeq=$localMaxSeq)")
return MediatorResult.Success(endOfPaginationReached = false)
}
// Нет данных - загружаем последние сообщения
Log.d(TAG, "REFRESH: No cached data, fetching from API")
}
LoadType.APPEND -> {
Log.d(TAG, "APPEND: Loading older messages")
}
LoadType.PREPEND -> {
Log.d(TAG, "PREPEND: Loading newer messages")
}
}
// Определяем pivot для API запроса
val pivotValue: Long? = when (loadType) {
LoadType.REFRESH -> null // Последние сообщения
LoadType.APPEND -> {
// Старые сообщения - берём минимальный sequenceId из текущей страницы
(state.pages.lastOrNull()?.data?.lastOrNull()?.sequenceId
?: localMinSeq)?.toLong()?.minus(1)
}
LoadType.PREPEND -> {
// Новые сообщения - берём максимальный sequenceId
(state.pages.firstOrNull()?.data?.firstOrNull()?.sequenceId
?: localMaxSeq)?.toLong()?.plus(1)
}
}
val limit = when (loadType) {
LoadType.REFRESH -> state.config.initialLoadSize
else -> state.config.pageSize
}
Log.d(TAG, "API call: chatId=$chatId, pivot=$pivotValue, limit=$limit")
// Выполняем запрос к API
val messages = try {
api.getMessages(
chatId = chatId,
cursor = null,
pivot = pivotValue,
limit = limit
)
} catch (e: Exception) {
Log.e(TAG, "API call failed - offline?", e)
// При ошибке сети и наличии локальных данных - успех
if ((localMinSeq != null || localMaxSeq != null)) {
Log.d(TAG, "Offline mode: returning success with cached data")
return MediatorResult.Success(endOfPaginationReached = true)
}
throw e
}
Log.d(TAG, "Received ${messages.size} messages from API")
if (messages.isEmpty()) {
Log.d(TAG, "End of pagination - no more messages")
return MediatorResult.Success(endOfPaginationReached = true)
}
// Сохраняем в базу
val currentUserId = tokenManager.getUserId() ?: ""
val entities = messages.map { dto ->
dto.toEntity(currentUserId, baseUrl, gson)
}
Log.d(TAG, "Saving ${entities.size} messages to DB, first seq=${entities.firstOrNull()?.sequenceId}, last seq=${entities.lastOrNull()?.sequenceId}")
dao.upsertMessages(entities)
// Проверяем что данные сохранились
val savedCount = dao.getMessagesCount(chatId)
Log.d(TAG, "Total messages in DB after save: $savedCount")
val endOfPaginationReached = messages.size < limit
Log.d(TAG, "endOfPaginationReached=$endOfPaginationReached")
MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
} catch (e: Exception) {
Log.e(TAG, "Error loading messages", e)
MediatorResult.Error(e)
}
}
companion object {
private const val TAG = "MessageRemoteMediator"
}
}
/**
* Extension function для преобразования DTO в Entity
*/
private fun MessageDto.toEntity(
currentUserId: String,
baseUrl: String,
gson: Gson
): MessageEntity {
// Определяем тип медиа
val mediaType = when {
media.isEmpty() -> MediaType.TEXT.name
media.any { it.type.startsWith("image") || it.url.endsWith(".gif") } -> MediaType.GIF.name
media.any { it.type.startsWith("image") } -> MediaType.IMAGE.name
media.any { it.type.startsWith("video") } -> MediaType.VIDEO.name
media.any { it.type.startsWith("audio") } -> MediaType.AUDIO.name
else -> MediaType.FILE.name
}
// Сериализуем медиа в JSON
val mediaJson = gson.toJson(media)
// Сериализуем реакции в JSON
val reactionsMap = reactions?.associate { it.emoji to it.count } ?: emptyMap()
val reactionsJson = gson.toJson(reactionsMap)
// Определяем, прочитано ли сообщение текущим пользователем
val isRead = senderId == currentUserId
return MessageEntity(
id = id,
chatId = chatId ?: "",
senderId = senderId ?: "",
senderName = sender?.displayName ?: sender?.username ?: "Unknown",
senderAvatar = sender?.avatarUrl,
content = content,
sequenceId = sequenceId ?: 0,
createdAt = createdAt ?: "",
mediaType = mediaType,
mediaJson = mediaJson,
reactionsJson = reactionsJson,
isRead = isRead,
replyToId = replyTo?.id,
syncStatus = SyncStatus.SYNCED,
isDeletedLocally = false,
isEditedLocally = false,
editedContent = null,
lastUpdated = System.currentTimeMillis()
)
}
@@ -0,0 +1,124 @@
package chats.data.remote.api
import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto
import retrofit2.http.*
data class SendMessageRequest(
val content: String?,
val type: String = "text",
val attachments: List<AttachmentRequest>? = null,
val replyToId: String? = null,
val quote: String? = null,
val forwardedFromId: String? = null
)
data class AttachmentRequest(
val type: String,
val url: String,
val fileName: String,
val fileSize: Long
)
interface ChatApi {
@GET("chats")
suspend fun getChats(): List<ChatDto>
@GET("messages/chat/{chatId}")
suspend fun getMessages(
@Path("chatId") chatId: String,
@Query("cursor") cursor: String? = null,
@Query("pivot") pivot: Long? = null,
@Query("afterSequenceId") afterSequenceId: Long? = null,
@Query("limit") limit: Int? = 50
): List<MessageDto>
@POST("messages/chat/{chatId}")
suspend fun sendMessage(@Path("chatId") chatId: String, @Body request: SendMessageRequest): MessageDto
@Multipart
@POST("messages/upload")
suspend fun uploadFile(@Part file: okhttp3.MultipartBody.Part): FileUploadResponse
// Klipy GIF API
@GET("klipy/trending")
suspend fun getTrendingGifs(@Query("page") page: Int): KlipyResponse
@GET("klipy/search")
suspend fun searchGifs(@Query("q") query: String, @Query("page") page: Int): KlipyResponse
@GET("klipy/categories")
suspend fun getGifCategories(): GifCategoriesResponse
@POST("klipy/shared/{id}")
suspend fun markGifShared(@Path("id") id: String, @Body query: String)
@POST("messages/{messageId}/reactions")
suspend fun addReaction(@Path("messageId") messageId: String, @Query("emoji") emoji: String)
@POST("chats/personal")
suspend fun createPersonalChat(@Body request: CreatePersonalChatRequest): ChatDto
@POST("chats/{chatId}/typing")
suspend fun sendTypingStatus(@Path("chatId") chatId: String)
@POST("chats/{chatId}/read")
suspend fun markMessagesAsRead(@Path("chatId") chatId: String, @Body lastMessageId: String)
@DELETE("messages/{messageId}")
suspend fun deleteMessage(@Path("messageId") messageId: String, @Query("forEveryone") forEveryone: Boolean): retrofit2.Response<Unit>
@PUT("messages/{messageId}")
suspend fun editMessage(@Path("messageId") messageId: String, @Body request: SendMessageRequest): MessageDto
}
data class CreatePersonalChatRequest(
val userId: String
)
data class KlipyResponse(
val data: KlipyDataWrapper
)
data class KlipyDataWrapper(
val data: List<KlipyGifDto>
)
data class KlipyGifDto(
val id: String,
val images: GifImagesDto? = null,
val files: Map<String, Map<String, GifImageSourceDto>>? = null,
val file: Map<String, Map<String, GifImageSourceDto>>? = null,
val media_formats: Map<String, GifImageSourceDto>? = null,
val title: String? = null
)
data class GifImagesDto(
val fixed_height: GifImageSourceDto? = null,
val original: GifImageSourceDto? = null,
val fixed_height_small: GifImageSourceDto? = null
)
data class GifImageSourceDto(
val url: String
)
data class GifCategoryDto(
val category: String,
val preview_url: String,
val query: String
)
data class FileUploadResponse(
val url: String,
val filename: String,
val size: Long
)
data class GifCategoriesResponse(
val data: GifCategoriesData
)
data class GifCategoriesData(
val categories: List<GifCategoryDto>
)
@@ -0,0 +1,57 @@
package chats.data.remote.dto
import com.google.gson.annotations.SerializedName
data class UserBasicDto(
@SerializedName("id") val id: String,
@SerializedName("username") val username: String? = null,
@SerializedName("displayName") val displayName: String? = null,
@SerializedName("avatarUrl") val avatarUrl: String? = null
)
data class MessageDto(
@SerializedName("id", alternate = ["Id"]) val id: String,
@SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null,
@SerializedName("senderId", alternate = ["SenderId"]) val senderId: String? = null,
@SerializedName("content", alternate = ["Content"]) val content: String? = null,
@SerializedName("type", alternate = ["Type"]) val type: String? = null,
@SerializedName("sequenceId", alternate = ["SequenceId"]) val sequenceId: Int? = null,
@SerializedName("createdAt", alternate = ["CreatedAt"]) val createdAt: String? = null,
@SerializedName("sender", alternate = ["Sender"]) val sender: UserBasicDto? = null,
@SerializedName("media", alternate = ["Media"]) val media: List<MediaItemDto> = emptyList(),
@SerializedName("reactions", alternate = ["Reactions"]) val reactions: List<ReactionDto>? = emptyList(),
@SerializedName("replyTo", alternate = ["ReplyTo"]) val replyTo: MessageDto? = null,
@SerializedName("isPinned", alternate = ["IsPinned"]) val isPinned: Boolean? = false,
@SerializedName("forwardedFromId", alternate = ["ForwardedFromId"]) val forwardedFromId: String? = null,
@SerializedName("forwardedFrom", alternate = ["ForwardedFrom"]) val forwardedFrom: UserBasicDto? = null
)
data class ReactionDto(
@SerializedName("emoji") val emoji: String,
@SerializedName("count") val count: Int,
@SerializedName("isSetByMe") val isSetByMe: Boolean
)
data class MediaItemDto(
@SerializedName("id") val id: String,
@SerializedName("type") val type: String,
@SerializedName("url") val url: String,
@SerializedName("filename") val filename: String? = null,
@SerializedName("size") val size: Long? = null,
@SerializedName("duration") val duration: Double? = null
)
data class ChatDto(
@SerializedName("id") val id: String,
@SerializedName("type") val type: String,
@SerializedName("name") val name: String? = null,
@SerializedName("avatar") val avatar: String? = null,
@SerializedName("unreadCount", alternate = ["UnreadCount", "unread_count"]) val unreadCount: Int = 0,
@SerializedName("messages") val messages: List<MessageDto> = emptyList(),
@SerializedName("members") val members: List<ChatMemberDto> = emptyList()
)
data class ChatMemberDto(
@SerializedName("userId") val userId: String,
@SerializedName("user") val user: UserBasicDto? = null
)
@@ -0,0 +1,33 @@
package chats.data.remote.signalr
import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto
sealed class ChatEvent {
data class NewMessage(val message: MessageDto) : ChatEvent()
data class MessageEdited(val messageId: String, val chatId: String, val content: String) : ChatEvent()
data class MessageDeleted(val messageId: String, val chatId: String) : ChatEvent()
data class MessagesRead(val chatId: String, val userId: String, val lastReadSequenceId: Int) : ChatEvent()
data class UserTyping(val chatId: String, val userId: String) : ChatEvent()
data class UserStoppedTyping(val chatId: String, val userId: String) : ChatEvent()
data class UserOnline(val userId: String) : ChatEvent()
data class UserOffline(val userId: String, val lastSeen: String?) : ChatEvent()
data class NewChat(val chat: ChatDto) : ChatEvent()
data class ReactionUpdated(val messageId: String, val chatId: String, val userId: String, val emoji: String, val isRemoved: Boolean = false) : ChatEvent()
data class MessagePinned(val chatId: String, val message: MessageDto) : ChatEvent()
data class MessageUnpinned(val chatId: String, val messageId: String) : ChatEvent()
// Call Events (WebRTC Signaling)
data class CallIncoming(val chatId: String, val from: String, val offer: String, val callType: String) : ChatEvent()
data class CallAnswered(val chatId: String, val answer: String) : ChatEvent()
data class IceCandidateReceived(val chatId: String, val candidate: String) : ChatEvent()
data class CallEnded(val chatId: String) : ChatEvent()
// Group Call Events
data class GroupCallIncoming(val chatId: String, val from: String, val callerInfo: Any) : ChatEvent()
data class GroupCallParticipants(val chatId: String, val participants: List<String>) : ChatEvent()
data class GroupCallUserJoined(val chatId: String, val userId: String) : ChatEvent()
data class GroupCallUserLeft(val chatId: String, val userId: String) : ChatEvent()
data class GroupCallOffer(val chatId: String, val from: String, val offer: String) : ChatEvent()
data class GroupCallAnswer(val chatId: String, val from: String, val answer: String) : ChatEvent()
}
@@ -0,0 +1,338 @@
package chats.data.remote.signalr
import android.util.Log
import io.reactivex.rxjava3.core.Single
import com.microsoft.signalr.HubConnection
import com.microsoft.signalr.HubConnectionBuilder
import com.microsoft.signalr.HubConnectionState
import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.delay
data class ReadMessagesRequest(
val chatId: String,
val lastReadMessageId: String,
val lastReadSequenceId: Int
)
data class MessagesReadEvent(
@com.google.gson.annotations.SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null,
@com.google.gson.annotations.SerializedName("userId", alternate = ["UserId"]) val userId: String? = null,
@com.google.gson.annotations.SerializedName("lastReadSequenceId", alternate = ["LastReadSequenceId"]) val lastReadSequenceId: Int? = null
) {
val effectiveChatId: String get() = chatId ?: ""
val effectiveUserId: String get() = userId ?: ""
val effectiveLastReadSequenceId: Int get() = lastReadSequenceId ?: 0
}
data class ReactionEvent(
@com.google.gson.annotations.SerializedName("messageId", alternate = ["MessageId"]) val messageId: String? = null,
@com.google.gson.annotations.SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null,
@com.google.gson.annotations.SerializedName("userId", alternate = ["UserId"]) val userId: String? = null,
@com.google.gson.annotations.SerializedName("username", alternate = ["Username", "UserName"]) val username: String? = null,
@com.google.gson.annotations.SerializedName("emoji", alternate = ["Emoji"]) val emoji: String? = null
)
enum class ConnectionStatus { CONNECTED, CONNECTING, DISCONNECTED }
@Singleton
class ChatHubClient @Inject constructor() {
private var hubConnection: HubConnection? = null
// extraBufferCapacity=1024 позволяет буферизовать события пока нет подписчиков
private val _events = MutableSharedFlow<ChatEvent>(replay = 0, extraBufferCapacity = 1024)
val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
private val _status = MutableStateFlow(ConnectionStatus.DISCONNECTED)
val status: StateFlow<ConnectionStatus> = _status.asStateFlow()
private val scope = CoroutineScope(Dispatchers.IO)
private var lastBaseUrl: String? = null
private var lastToken: String? = null
fun connect(baseUrl: String, accessToken: String) {
// Проверяем текущее состояние
val currentState = hubConnection?.connectionState
if (currentState == HubConnectionState.CONNECTED) {
Log.d("ChatHubClient", "Already connected, skipping")
return
}
// Если соединение в процессе - останавливаем его
if (currentState == HubConnectionState.CONNECTING) {
Log.d("ChatHubClient", "Connection in progress ($currentState), stopping first...")
hubConnection?.stop()
}
// Сохраняем параметры для переподключения
lastBaseUrl = baseUrl
lastToken = accessToken
_status.value = ConnectionStatus.CONNECTING
Log.d("ChatHubClient", "Connecting to ${baseUrl}/hubs/chat with token: ${accessToken.take(10)}...")
// Создаем новое соединение
val newHubConnection = HubConnectionBuilder.create("${baseUrl}/hubs/chat")
.withAccessTokenProvider(Single.just(accessToken))
.build()
hubConnection = newHubConnection
setupHandlers()
hubConnection?.onClosed { exception ->
Log.e("ChatHubClient", "Connection closed. Reconnecting...", exception)
_status.value = ConnectionStatus.DISCONNECTED
scope.launch {
// Проверяем, есть ли еще актуальные параметры для переподключения
val reconnectBaseUrl = lastBaseUrl
val reconnectToken = lastToken
if (reconnectBaseUrl != null && reconnectToken != null) {
Log.d("ChatHubClient", "Attempting reconnection with saved parameters...")
delay(5000)
connect(reconnectBaseUrl, reconnectToken)
} else {
Log.w("ChatHubClient", "Cannot reconnect: missing baseUrl or token")
}
}
}
scope.launch {
try {
Log.d("ChatHubClient", "Starting SignalR connection...")
hubConnection?.start()?.blockingAwait()
_status.value = ConnectionStatus.CONNECTED
Log.d("ChatHubClient", "SignalR Connected successfully!")
} catch (e: Exception) {
Log.e("ChatHubClient", "SignalR Connection Error", e)
_status.value = ConnectionStatus.DISCONNECTED
}
}
}
private fun setupHandlers() {
hubConnection?.let { conn ->
Log.d("ChatHubClient", "Setting up SignalR handlers")
conn.on("new_message", { message: MessageDto ->
Log.d("ChatHubClient", ">>> new_message event received: ${message.id} in chat ${message.chatId}")
_events.tryEmit(ChatEvent.NewMessage(message))
}, MessageDto::class.java)
conn.on("message_edited", { messageId: String, chatId: String, content: String ->
Log.d("ChatHubClient", ">>> message_edited event: $messageId")
_events.tryEmit(ChatEvent.MessageEdited(messageId, chatId, content))
}, String::class.java, String::class.java, String::class.java)
conn.on("message_deleted", { messageId: String, chatId: String ->
Log.d("ChatHubClient", ">>> message_deleted event: $messageId")
_events.tryEmit(ChatEvent.MessageDeleted(messageId, chatId))
}, String::class.java, String::class.java)
conn.on("messages_read", { data: MessagesReadEvent ->
Log.d("ChatHubClient", ">>> messages_read event: ${data.effectiveChatId}")
_events.tryEmit(ChatEvent.MessagesRead(
data.effectiveChatId,
data.effectiveUserId,
data.effectiveLastReadSequenceId
))
}, MessagesReadEvent::class.java)
conn.on("user_typing", { data: ReactionEvent ->
_events.tryEmit(ChatEvent.UserTyping(data.chatId ?: "", data.userId ?: ""))
}, ReactionEvent::class.java)
conn.on("user_stopped_typing", { data: ReactionEvent ->
_events.tryEmit(ChatEvent.UserStoppedTyping(data.chatId ?: "", data.userId ?: ""))
}, ReactionEvent::class.java)
conn.on("user_online", { userId: String ->
_events.tryEmit(ChatEvent.UserOnline(userId))
}, String::class.java)
conn.on("new_chat", { chat: ChatDto ->
Log.d("ChatHubClient", ">>> new_chat event: ${chat.id}")
_events.tryEmit(ChatEvent.NewChat(chat))
}, ChatDto::class.java)
conn.on("reaction_added", { data: ReactionEvent ->
Log.d("ChatHubClient", ">>> reaction_added event: ${data.emoji} on ${data.messageId}")
_events.tryEmit(ChatEvent.ReactionUpdated(
data.messageId ?: "",
data.chatId ?: "",
data.userId ?: "",
data.emoji ?: "",
isRemoved = false
))
}, ReactionEvent::class.java)
conn.on("reaction_removed", { data: ReactionEvent ->
Log.d("ChatHubClient", ">>> reaction_removed event: ${data.emoji} on ${data.messageId}")
_events.tryEmit(ChatEvent.ReactionUpdated(
data.messageId ?: "",
data.chatId ?: "",
data.userId ?: "",
data.emoji ?: "",
isRemoved = true
))
}, ReactionEvent::class.java)
// WebRTC Signaling Handlers
conn.on("call_incoming", { chatId: String, from: String, offer: String, callType: String ->
_events.tryEmit(ChatEvent.CallIncoming(chatId, from, offer, callType))
}, String::class.java, String::class.java, String::class.java, String::class.java)
conn.on("call_answered", { chatId: String, answer: String ->
_events.tryEmit(ChatEvent.CallAnswered(chatId, answer))
}, String::class.java, String::class.java)
conn.on("ice_candidate", { chatId: String, candidate: String ->
_events.tryEmit(ChatEvent.IceCandidateReceived(chatId, candidate))
}, String::class.java, String::class.java)
conn.on("call_ended", { chatId: String ->
_events.tryEmit(ChatEvent.CallEnded(chatId))
}, String::class.java)
conn.on("message_pinned", { data: Map<String, Any> ->
// The web version expects a message object, but here we might get a partial DTO or just IDs.
// Let's assume we get { chatId, message: MessageDto } based on web
// We'll trust the DTO mapping if possible, but SignalR java client is picky with nested objects in Maps.
// For simplicity, we might needs a dedicated DTO if it fails.
}, Map::class.java)
conn.on("message_pinned", { chatId: String, message: MessageDto ->
_events.tryEmit(ChatEvent.MessagePinned(chatId, message))
}, String::class.java, MessageDto::class.java)
conn.on("message_unpinned", { chatId: String, messageId: String ->
_events.tryEmit(ChatEvent.MessageUnpinned(chatId, messageId))
}, String::class.java, String::class.java)
}
}
fun disconnect() {
hubConnection?.stop()
_status.value = ConnectionStatus.DISCONNECTED
}
/**
* Принудительное переподключение - останавливает текущее соединение и создает новое
*/
fun reconnect() {
Log.d("ChatHubClient", "Forced reconnect requested")
val baseUrl = lastBaseUrl
val token = lastToken
if (baseUrl != null && token != null) {
disconnect()
// Небольшая задержка перед переподключением
scope.launch {
delay(1000)
connect(baseUrl, token)
}
} else {
Log.w("ChatHubClient", "Cannot reconnect: missing saved credentials")
}
}
fun addReaction(messageId: String, chatId: String, emoji: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("add_reaction", mapOf(
"messageId" to messageId,
"chatId" to chatId,
"emoji" to emoji
))?.doOnError { Log.e("ChatHubClient", "add_reaction error", it) }
?.subscribe()
Log.d("ChatHubClient", "Invoked add_reaction: $emoji on $messageId")
} else {
Log.w("ChatHubClient", "Cannot add_reaction: Not connected")
}
}
fun removeReaction(messageId: String, chatId: String, emoji: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("remove_reaction", mapOf(
"messageId" to messageId,
"chatId" to chatId,
"emoji" to emoji
))?.doOnError { Log.e("ChatHubClient", "remove_reaction error", it) }
?.subscribe()
Log.d("ChatHubClient", "Invoked remove_reaction: $emoji on $messageId")
}
}
fun pinMessage(messageId: String, chatId: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("pin_message", mapOf(
"messageId" to messageId,
"chatId" to chatId
))?.doOnError { Log.e("ChatHubClient", "pin_message error", it) }
?.subscribe()
}
}
fun unpinMessage(messageId: String, chatId: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("unpin_message", mapOf(
"messageId" to messageId,
"chatId" to chatId
))?.doOnError { Log.e("ChatHubClient", "unpin_message error", it) }
?.subscribe()
}
}
fun readMessages(request: ReadMessagesRequest) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("read_messages", request)
?.doOnError { Log.e("ChatHubClient", "read_messages error", it) }
?.subscribe()
Log.d("ChatHubClient", "Sent read_messages for chat: ${request.chatId}")
}
}
fun joinChat(chatId: String) {
scope.launch {
// Wait for connection to be established if it's currently connecting
var attempts = 0
while (hubConnection?.connectionState != HubConnectionState.CONNECTED && attempts < 10) {
delay(500)
attempts++
}
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
Log.d("ChatHubClient", "Joining chat room: $chatId")
hubConnection?.invoke("join_chat", chatId)
?.doOnError { Log.e("ChatHubClient", "join_chat error", it) }
?.subscribe()
Log.d("ChatHubClient", "Joined chat room: $chatId")
} else {
Log.e("ChatHubClient", "Failed to join chat room $chatId: Not connected (state=${hubConnection?.connectionState})")
}
}
}
fun sendTypingIndicator(chatId: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("typing_start", chatId)
?.doOnError { Log.e("ChatHubClient", "typing_start error", it) }
?.subscribe()
Log.d("ChatHubClient", "Sent typing indicator for chat: $chatId")
}
}
fun sendUserStoppedTyping(chatId: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("typing_stop", chatId)
?.doOnError { Log.e("ChatHubClient", "typing_stop error", it) }
?.subscribe()
Log.d("ChatHubClient", "Sent user stopped typing for chat: $chatId")
}
}
}
@@ -0,0 +1,552 @@
package chats.data.remote.signalr
import android.content.Context
import core.network.NetworkManager
import core.network.ServerConfig
import core.notifications.data.ActiveChatTracker
import core.notifications.data.NotificationHelper
import core.security.TokenManager
import chats.data.sync.MessageSyncWorker
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.collect
import javax.inject.Inject
import javax.inject.Singleton
import chats.data.remote.signalr.ConnectionStatus
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
@Singleton
class SignalRNotificationObserver @Inject constructor(
private val signalrClient: ChatHubClient,
private val activeChatTracker: ActiveChatTracker,
private val tokenManager: TokenManager,
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)
private var isStarted = false
private val processedMessageIds = mutableSetOf<String>()
// OkHttpClient для ping запроса
private val pingClient = OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build()
fun start() {
if (isStarted) return
isStarted = true
// Запускаем мониторинг сети
networkManager.startMonitoring()
// Принудительно обновляем состояние сети при старте
networkManager.refreshNetworkState()
// Подключаемся к SignalR при старте приложения
connectSignalR()
// Initial count load
refresh()
// Запускаем периодическую проверку подключения SignalR
startConnectionHealthCheck()
// Слушаем восстановление сети и переподключаем SignalR
networkManager.isOnline
.filter { it } // Только переход в онлайн
.distinctUntilChanged()
.onEach {
android.util.Log.d("SignalRNtfObserver", "Network restored! Reconnecting SignalR and syncing...")
// Небольшая задержка чтобы сеть стабилизировалась
kotlinx.coroutines.delay(1500)
reconnectOnNetworkRestored()
}
.launchIn(scope)
// Также отслеживаем состояние SignalR для переподключения
signalrClient.status
.filter { it == ConnectionStatus.DISCONNECTED }
.onEach {
android.util.Log.d("SignalRNtfObserver", "SignalR disconnected, checking network...")
// Если мы offline и SignalR отключен - не делаем ничего
// Подключимся когда сеть восстановится
}
.launchIn(scope)
// При восстановлении соединения SignalR обновляем список чатов и вступаем в них
signalrClient.status
.filter { it == ConnectionStatus.CONNECTED }
.onEach {
android.util.Log.d("SignalRNtfObserver", "SignalR connected, refreshing chats and joining rooms")
onSignalRConnected()
}
.launchIn(scope)
// Слушаем события SignalR для уведомлений и обновления списка чатов
android.util.Log.d("SignalRNtfObserver", "Starting to listen to signalrClient.events")
signalrClient.events
.onEach { event ->
android.util.Log.d("SignalRNtfObserver", ">>> Received event: ${event::class.simpleName}")
when (event) {
is ChatEvent.NewMessage -> {
val currentUserId = tokenManager.getUserId()
val message = event.message
android.util.Log.d("SignalRNtfObserver", "New message: ${message.id} from ${message.senderId}, current: $currentUserId")
// Don't show if it's our message or already processed
if (message.senderId == currentUserId) {
android.util.Log.d("SignalRNtfObserver", "Skipping - own message")
return@onEach
}
if (processedMessageIds.contains(message.id)) {
android.util.Log.d("SignalRNtfObserver", "Skipping - already processed")
return@onEach
}
// Mark as processed
processedMessageIds.add(message.id)
if (processedMessageIds.size > 200) {
processedMessageIds.remove(processedMessageIds.first())
}
// Increment immediately for UI feedback
activeChatTracker.incrementUnreadCount()
// Refresh total count from source of truth in background
refresh()
// Don't show if this chat is currently open
if (activeChatTracker.currentChatId.value == message.chatId) {
android.util.Log.d("SignalRNtfObserver", "Skipping - chat is open: ${message.chatId}")
return@onEach
}
android.util.Log.d("SignalRNtfObserver", "Showing notification for chat: ${message.chatId}")
NotificationHelper.showNotification(
context = context,
title = message.sender?.displayName ?: "Новое сообщение",
body = message.content ?: "Вам прислали вложение",
type = "chat",
chatId = message.chatId,
notificationId = message.id.hashCode(),
totalCount = activeChatTracker.totalUnreadCount.value
)
}
is ChatEvent.MessagesRead -> {
android.util.Log.d("SignalRNtfObserver", "Messages read event")
// If anyone read messages, sync our total count
refresh()
}
else -> {
android.util.Log.d("SignalRNtfObserver", "Unhandled event: ${event::class.simpleName}")
Unit
}
}
}
.launchIn(scope)
}
private fun startConnectionHealthCheck() {
// Каждые 10 секунд проверяем подключение и при необходимости переподключаемся
scope.launch {
var consecutiveFailures = 0
while (true) {
kotlinx.coroutines.delay(10000)
val currentStatus = signalrClient.status.value
val isOnline = networkManager.isOnline.value
if (isOnline && currentStatus == ConnectionStatus.DISCONNECTED) {
consecutiveFailures++
android.util.Log.d("SignalRNtfObserver", "Health check: Network online but SignalR disconnected (failures: $consecutiveFailures), reconnecting...")
signalrClient.reconnect()
} else if (isOnline && currentStatus == ConnectionStatus.CONNECTED) {
// Сбрасываем счетчик ошибок при успешном подключении
consecutiveFailures = 0
android.util.Log.d("SignalRNtfObserver", "Health check: Connection healthy")
}
}
}
}
private fun onSignalRConnected() {
android.util.Log.d("SignalRNtfObserver", "SignalR connected - syncing missed messages...")
refresh()
// Вступаем во все чаты для получения событий
joinAllChats()
// Синхронизируем пропущенные сообщения
syncMissedMessages()
}
/**
* Синхронизирует пропущенные сообщения после восстановления соединения
* Запрашивает только НОВЫЕ сообщения с последнего известного sequenceId
* Показывает уведомления для непрочитанных сообщений
*/
private fun syncMissedMessages() {
scope.launch {
try {
android.util.Log.d("SignalRNtfObserver", "Starting missed messages sync...")
val chats = chatRepository.getChats()
android.util.Log.d("SignalRNtfObserver", "Syncing ${chats.size} chats for missed messages")
// Запрашиваем только новые сообщения для каждого чата
chats.forEach { chat ->
try {
// Получаем последний известный sequenceId из локальной базы
val lastSequenceId = chatRepository.getLastKnownSequenceId(chat.id)
if (lastSequenceId != null) {
// Запрашиваем сообщения ПОСЛЕ lastSequenceId (только новые)
android.util.Log.d("SignalRNtfObserver", "Chat ${chat.id}: last known seqId=$lastSequenceId, fetching newer...")
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")
chatRepository.getMessages(chatId = chat.id, limit = 50)
}
android.util.Log.d("SignalRNtfObserver", "Synced chat ${chat.id}")
// Небольшая пауза между чатами чтобы не перегружать сервер
kotlinx.coroutines.delay(100)
} catch (e: Exception) {
android.util.Log.e("SignalRNtfObserver", "Failed to sync chat ${chat.id}", e)
}
}
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<chats.domain.model.Message>) {
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 для чата из локальной базы
*/
private suspend fun getLastKnownSequenceId(chatId: String): Int? {
return try {
chatRepository.getLastKnownSequenceId(chatId)
} catch (e: Exception) {
android.util.Log.e("SignalRNtfObserver", "Failed to get last sequenceId for $chatId", e)
null
}
}
private fun reconnectOnNetworkRestored() {
android.util.Log.d("SignalRNtfObserver", "Network restored, starting reconnection sequence...")
scope.launch {
// 1. Сначала делаем HTTP ping запрос чтобы "разбудить" сетевой стек
android.util.Log.d("SignalRNtfObserver", "Sending HTTP ping to wake up network...")
val pingSuccess = sendHttpPing()
android.util.Log.d("SignalRNtfObserver", "HTTP ping result: $pingSuccess")
// 2. Небольшая пауза для стабилизации
kotlinx.coroutines.delay(1000)
// 3. Принудительное переподключение SignalR
android.util.Log.d("SignalRNtfObserver", "Forcing SignalR reconnect...")
signalrClient.reconnect()
// 4. Ждем пока SignalR подключится (максимум 10 секунд)
var waitCount = 0
while (signalrClient.status.value != ConnectionStatus.CONNECTED && waitCount < 20) {
kotlinx.coroutines.delay(500)
waitCount++
}
if (signalrClient.status.value == ConnectionStatus.CONNECTED) {
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")
}
}
}
/**
* Немедленно отправляет отложенные сообщения без ожидания 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<chats.data.remote.api.AttachmentRequest>? {
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
}
}
/**
* Отправляет HTTP ping запрос для активации сетевого соединения
*/
private suspend fun sendHttpPing(): Boolean {
return try {
val baseUrl = serverConfig.getBaseUrl()
val token = tokenManager.getToken()
if (baseUrl.isBlank() || token == null) {
android.util.Log.w("SignalRNtfObserver", "Cannot ping: missing baseUrl or token")
return false
}
val url = "${baseUrl.removeSuffix("/api/")}/api/auth/refresh"
val request = Request.Builder()
.url(url)
.post(okhttp3.RequestBody.create(null, "{}"))
.addHeader("Authorization", "Bearer $token")
.build()
val response = pingClient.newCall(request).execute()
val success = response.isSuccessful || response.code == 401 // 401 OK для refresh
android.util.Log.d("SignalRNtfObserver", "HTTP ping to $url: ${response.code}")
response.close()
success
} catch (e: Exception) {
android.util.Log.e("SignalRNtfObserver", "HTTP ping failed", e)
false
}
}
private fun connectSignalR() {
val token = tokenManager.getToken()
val baseUrl = serverConfig.getBaseUrl()
if (token == null || baseUrl.isBlank()) {
android.util.Log.w("SignalRNtfObserver", "Cannot connect SignalR: token=${token != null}, baseUrl=$baseUrl")
return
}
val isOnline = networkManager.isOnline.value
if (!isOnline) {
android.util.Log.w("SignalRNtfObserver", "Cannot connect SignalR: network is offline")
return
}
// Проверяем текущее состояние SignalR
val currentStatus = signalrClient.status.value
if (currentStatus == ConnectionStatus.CONNECTED) {
android.util.Log.d("SignalRNtfObserver", "SignalR already connected, skipping")
return
}
android.util.Log.d("SignalRNtfObserver", "Connecting SignalR with token: ${token.take(10)}..., baseUrl: $baseUrl")
signalrClient.connect(baseUrl.removeSuffix("/api/"), token)
// Если подключение не удалось в течение 5 секунд - пробуем снова
scope.launch {
kotlinx.coroutines.delay(5000)
if (signalrClient.status.value == ConnectionStatus.DISCONNECTED) {
android.util.Log.w("SignalRNtfObserver", "SignalR connection timeout, retrying with reconnect()...")
signalrClient.reconnect()
}
}
}
private fun joinAllChats() {
scope.launch {
try {
val chats = chatRepository.getChats()
android.util.Log.d("SignalRNtfObserver", "Joining ${chats.size} chat rooms")
chats.forEach { chat ->
signalrClient.joinChat(chat.id)
}
} catch (e: Exception) {
android.util.Log.e("SignalRNtfObserver", "Failed to join chats", e)
}
}
}
fun refresh() {
scope.launch {
try {
val chats = chatRepository.getChats()
val total = chats.sumOf { it.unreadCount }
activeChatTracker.setTotalUnreadCount(total)
android.util.Log.d("SignalRNtfObserver", "Refreshed chats: ${chats.size}, total unread: $total")
} catch (e: Exception) {
android.util.Log.e("SignalRNtfObserver", "Failed to refresh", e)
}
}
}
}
@@ -0,0 +1,332 @@
package chats.data.repository
import android.content.Context
import android.util.Log
import androidx.paging.*
import chats.data.paging.MessageRemoteMediator
import chats.data.remote.api.ChatApi
import chats.data.remote.api.SendMessageRequest
import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto
import chats.data.signalr.MessageSignalRHandler
import chats.data.sync.MessageSyncWorker
import chats.domain.model.Chat
import chats.domain.model.Message
import chats.domain.model.MediaType
import chats.domain.repository.ChatRepository
import core.database.data.ChatDatabase
import core.database.data.ChatDao
import core.database.data.MessageDao
import core.database.data.MessageEntity
import core.database.data.SyncStatus
import core.network.ServerConfig
import chats.data.remote.signalr.ChatHubClient
import core.security.TokenManager
import com.google.gson.Gson
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
/**
* Основная реализация репозитория чатов
*
* Архитектура Offline-first:
* 1. Все данные читаются из локальной базы Room
* 2. При изменении данных - сначала запись в БД, потом синхронизация с сервером
* 3. SignalR обновления сразу записываются в БД
* 4. WorkManager обрабатывает фоновую синхронизацию
*
* Conflict Resolution:
* - Серверные данные имеют приоритет над локальными
* - Исключение: сообщения в процессе отправки (SYNCING) или редактирования
*/
@OptIn(ExperimentalPagingApi::class)
@Singleton
class ChatRepositoryImpl @Inject constructor(
private val api: ChatApi,
private val tokenManager: TokenManager,
private val serverConfig: ServerConfig,
private val messageDao: MessageDao,
private val chatDao: ChatDao,
private val database: ChatDatabase,
private val hubClient: ChatHubClient,
private val signalRHandler: MessageSignalRHandler,
private val context: Context
) : ChatRepository {
private val gson = Gson()
private val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
init {
signalRHandler.startListening()
Log.d(TAG, "ChatRepositoryImpl initialized")
}
override suspend fun getChats(): List<Chat> {
val currentUserId = tokenManager.getUserId() ?: ""
return try {
// Пробуем загрузить из сети
val chats = api.getChats().map { it.toDomain(currentUserId, baseUrl) }
// Кэшируем в Room
val entities = chats.map { it.toEntity() }
chatDao.insertChats(entities)
// Кэшируем последние сообщения
chats.forEach { chat ->
chat.lastMessage?.let { msg ->
messageDao.upsertMessage(msg.toEntity(gson))
}
}
android.util.Log.d(TAG, "Cached ${entities.size} chats with messages")
chats
} catch (e: Exception) {
android.util.Log.d(TAG, "Network load failed, using cache")
// При ошибке - возвращаем из кэша с загрузкой последних сообщений
chatDao.getAllChats().map { entity ->
val lastMessage = entity.lastMessageId?.let { messageId ->
messageDao.getMessageById(messageId)?.toDomain(baseUrl, gson)
}
entity.toDomain(currentUserId, baseUrl, lastMessage)
}
}
}
override fun getChatsFlow(): Flow<List<Chat>> {
val currentUserId = tokenManager.getUserId() ?: ""
return chatDao.getAllChatsFlow().map { entities ->
entities.map { entity ->
// Загружаем последнее сообщение из базы для каждого чата
val lastMessage = entity.lastMessageId?.let { messageId ->
messageDao.getMessageById(messageId)?.toDomain(baseUrl, gson)
}
entity.toDomain(currentUserId, baseUrl, lastMessage)
}
}
}
override fun getMessagesPaging(chatId: String): Flow<PagingData<Message>> {
val pagingConfig = PagingConfig(
pageSize = 30,
prefetchDistance = 10,
initialLoadSize = 50,
enablePlaceholders = false
)
return Pager(
config = pagingConfig,
pagingSourceFactory = { messageDao.getMessagesPagingSource(chatId) },
remoteMediator = MessageRemoteMediator(
chatId = chatId,
api = api,
database = database,
dao = messageDao,
serverConfig = serverConfig,
tokenManager = tokenManager
)
).flow.map { pagingData ->
pagingData.map { entity -> entity.toDomain(baseUrl, gson) }
}
}
override fun getMessagesFlow(chatId: String): Flow<List<Message>> {
return messageDao.getMessages(chatId).map { entities ->
entities.map { it.toDomain(baseUrl, gson) }
}
}
override suspend fun getMessages(
chatId: String, cursor: String?, pivot: Long?, afterSequenceId: Long?, limit: Int?
): List<Message> {
val currentUserId = tokenManager.getUserId() ?: ""
return try {
Log.d(TAG, "Fetching messages from API: chatId=$chatId, afterSequenceId=$afterSequenceId, limit=$limit")
val messages = api.getMessages(chatId, cursor = cursor, pivot = pivot, afterSequenceId = afterSequenceId, limit = limit)
if (messages.isNotEmpty()) {
val entities = messages.map { it.toEntity(baseUrl, currentUserId, gson) }
messageDao.upsertMessages(entities)
Log.d(TAG, "Cached ${entities.size} messages")
}
messages.map { msg -> msg.toDomain(currentUserId, baseUrl).copy(isRead = true) }
} catch (e: Exception) {
Log.e(TAG, "Fetch messages failed", e)
emptyList()
}
}
override suspend fun getLastKnownSequenceId(chatId: String): Int? {
return try {
messageDao.getMaxSequenceId(chatId)
} catch (e: Exception) {
Log.e(TAG, "Failed to get last sequenceId", e)
null
}
}
override suspend fun sendMessage(
chatId: String, content: String?, type: String,
attachments: List<chats.data.remote.api.AttachmentRequest>?,
replyToId: String?, forwardedFromId: String?
): Message {
val userId = tokenManager.getUserId() ?: ""
val currentTime = System.currentTimeMillis()
val localId = "local_${currentTime}_${chatId}"
val localMessage = MessageEntity(
id = localId, chatId = chatId, senderId = userId,
senderName = "Вы", senderAvatar = null, content = content,
sequenceId = 0,
createdAt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).format(Date(currentTime)),
mediaType = type.uppercase(),
mediaJson = "[]",
reactionsJson = "{}",
isRead = true, replyToId = replyToId,
syncStatus = SyncStatus.SYNCING,
isDeletedLocally = false, isEditedLocally = false,
editedContent = null, lastUpdated = currentTime
)
messageDao.insertMessage(localMessage)
Log.d(TAG, "Saved local message: $localId")
// Пробуем отправить НЕМЕДЛЕННО через 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(
id = localId,
chatId = chatId,
senderId = userId,
senderName = "Вы",
senderAvatar = null,
content = content,
sequenceId = 0,
createdAt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).format(Date(currentTime)),
media = emptyList(),
mediaType = when (type) {
"image" -> MediaType.IMAGE
"video" -> MediaType.VIDEO
"audio" -> MediaType.AUDIO
"gif" -> MediaType.GIF
else -> MediaType.TEXT
},
reactions = emptyMap(),
isRead = true,
isPinned = false,
isForwarded = false,
forwardedFromName = null,
replyTo = null
)
}
override suspend fun addReaction(messageId: String, emoji: String) {
hubClient.addReaction(messageId, "", emoji)
}
override suspend fun sendTypingStatus(chatId: String) {
api.sendTypingStatus(chatId)
hubClient.sendTypingIndicator(chatId)
}
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
try {
hubClient.readMessages(chats.data.remote.signalr.ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId))
} catch (e: Exception) {
Log.e(TAG, "Error marking messages as read", e)
}
messageDao.markMessagesAsRead(chatId, lastReadSequenceId)
}
override suspend fun saveMessage(message: Message) {
messageDao.insertMessage(message.toEntity(gson))
}
override suspend fun deleteLocalMessage(messageId: String) {
messageDao.markAsDeletedLocally(messageId)
MessageSyncWorker.scheduleSync(context)
}
override suspend fun editLocalMessage(messageId: String, newContent: String) {
messageDao.markAsEditedLocally(messageId, newContent)
MessageSyncWorker.scheduleSync(context)
}
override suspend fun uploadMedia(file: java.io.File): String {
val mimeType = when (file.extension.lowercase()) {
"jpg", "jpeg" -> "image/jpeg"
"png" -> "image/png"
"webp" -> "image/webp"
"mp4" -> "video/mp4"
"mp3", "m4a", "wav" -> "audio/mpeg"
else -> "application/octet-stream"
}
val requestFile = file.asRequestBody(mimeType.toMediaTypeOrNull())
val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
return api.uploadFile(body).url
}
override suspend fun getTrendingGifs(page: Int): List<chats.data.remote.api.KlipyGifDto> =
api.getTrendingGifs(page).data.data
override suspend fun searchGifs(query: String, page: Int): List<chats.data.remote.api.KlipyGifDto> =
api.searchGifs(query, page).data.data
override suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto> =
api.getGifCategories().data.categories
override suspend fun createPersonalChat(userId: String): Chat {
val currentUserId = tokenManager.getUserId() ?: ""
val request = chats.data.remote.api.CreatePersonalChatRequest(userId)
return api.createPersonalChat(request).toDomain(currentUserId, baseUrl)
}
override suspend fun deleteMessage(messageId: String, forEveryone: Boolean) {
api.deleteMessage(messageId, forEveryone)
messageDao.deleteMessage(messageId)
}
override suspend fun editMessage(messageId: String, content: String): Message {
val request = SendMessageRequest(content = content)
val currentUserId = tokenManager.getUserId() ?: ""
val response = api.editMessage(messageId, request)
messageDao.insertMessage(response.toEntity(baseUrl, currentUserId, gson))
return response.toDomain(currentUserId, baseUrl)
}
companion object {
private const val TAG = "ChatRepositoryImpl"
}
}
@@ -0,0 +1,210 @@
package chats.data.repository
import chats.data.remote.dto.*
import chats.domain.model.*
import core.database.data.ChatEntity
import core.database.data.MessageEntity
import core.database.data.SyncStatus
// Mappers
fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat {
val chatName = name ?: if (type == "personal") {
members.firstOrNull { it.userId != currentUserId }?.user?.displayName ?: "Unknown Chat"
} else "Group Chat"
val chatAvatar = (avatar ?: if (type == "personal") {
members.firstOrNull { it.userId != currentUserId }?.user?.avatarUrl
} else null)?.ensureAbsoluteUrl(baseUrl)
return Chat(
id = id,
type = type,
name = chatName,
avatar = chatAvatar,
unreadCount = unreadCount,
lastMessage = messages.firstOrNull()?.toDomain(currentUserId, baseUrl)
)
}
fun Chat.toEntity(): ChatEntity {
return ChatEntity(
id = id,
name = name,
avatar = avatar,
type = type,
lastMessageId = lastMessage?.id,
lastMessageText = lastMessage?.content,
lastMessageAt = lastMessage?.createdAt?.let {
try { java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US).parse(it)?.time ?: 0L }
catch (e: Exception) { 0L }
} ?: 0L,
unreadCount = unreadCount,
isPinned = false
)
}
fun ChatEntity.toDomain(
currentUserId: String,
baseUrl: String,
lastMessage: Message? = null
): Chat {
return Chat(
id = id,
type = type,
name = name,
avatar = avatar,
unreadCount = unreadCount,
lastMessage = lastMessage
)
}
fun Message.toEntity(gson: com.google.gson.Gson): core.database.data.MessageEntity {
return core.database.data.MessageEntity(
id = id,
chatId = chatId,
senderId = senderId,
senderName = senderName,
senderAvatar = senderAvatar,
content = content,
sequenceId = sequenceId,
createdAt = createdAt,
mediaType = mediaType.name.lowercase(),
mediaJson = gson.toJson(media),
reactionsJson = gson.toJson(reactions),
isRead = isRead,
replyToId = replyTo?.id,
syncStatus = SyncStatus.SYNCED,
isDeletedLocally = false,
isEditedLocally = false,
editedContent = null,
lastUpdated = System.currentTimeMillis()
)
}
fun MessageDto.toDomain(currentUserId: String, baseUrl: String): Message {
val domainMediaType = when (type) {
"gif" -> MediaType.GIF
"image", "photo" -> MediaType.IMAGE
"video" -> MediaType.VIDEO
"audio", "voice" -> MediaType.AUDIO
"file" -> MediaType.FILE
else -> when (media.firstOrNull()?.type) {
"image", "photo" -> MediaType.IMAGE
"video" -> MediaType.VIDEO
"audio", "voice" -> MediaType.AUDIO
"file" -> MediaType.FILE
else -> MediaType.TEXT
}
}
return Message(
id = id,
chatId = chatId ?: "",
senderId = senderId ?: "",
senderName = sender?.displayName ?: "Unknown",
senderAvatar = sender?.avatarUrl?.ensureAbsoluteUrl(baseUrl),
content = content,
sequenceId = sequenceId ?: 0,
createdAt = createdAt ?: "",
media = media.map {
chats.domain.model.Media(
id = it.id ?: java.util.UUID.randomUUID().toString(),
type = it.type ?: "unknown",
url = (it.url ?: "").ensureAbsoluteUrl(baseUrl),
filename = it.filename,
size = it.size,
duration = it.duration
)
},
mediaType = domainMediaType,
reactions = reactions?.associate { it.emoji to it.count } ?: emptyMap(),
isRead = senderId == currentUserId,
isPinned = isPinned ?: false,
isForwarded = forwardedFromId != null,
forwardedFromName = forwardedFrom?.displayName ?: forwardedFrom?.username,
replyTo = replyTo?.toDomain(currentUserId, baseUrl)
)
}
fun MessageDto.toEntity(baseUrl: String, currentUserId: String, gson: com.google.gson.Gson): core.database.data.MessageEntity {
return core.database.data.MessageEntity(
id = id,
chatId = chatId ?: "",
senderId = senderId ?: "",
senderName = sender?.displayName ?: "Unknown",
senderAvatar = sender?.avatarUrl?.ensureAbsoluteUrl(baseUrl),
content = content,
sequenceId = sequenceId ?: 0,
createdAt = createdAt ?: "",
mediaType = type ?: "text",
mediaJson = gson.toJson(media),
reactionsJson = gson.toJson(reactions),
isRead = senderId == currentUserId,
replyToId = replyTo?.id
)
}
fun core.database.data.MessageEntity.toDomain(baseUrl: String, gson: com.google.gson.Gson): Message {
val mediaTypeEnum = when (mediaType) {
"gif" -> MediaType.GIF
"image", "photo" -> MediaType.IMAGE
"video" -> MediaType.VIDEO
"audio", "voice" -> MediaType.AUDIO
"file" -> MediaType.FILE
else -> MediaType.TEXT
}
val mediaTypeToken = object : com.google.gson.reflect.TypeToken<List<chats.data.remote.dto.MediaItemDto>>() {}.type
val mediaDtos: List<chats.data.remote.dto.MediaItemDto> = try {
gson.fromJson(mediaJson, mediaTypeToken)
} catch (e: Exception) {
emptyList()
} ?: emptyList()
val reactionsTypeToken = object : com.google.gson.reflect.TypeToken<List<chats.data.remote.dto.ReactionDto>>() {}.type
val reactionDtos: List<chats.data.remote.dto.ReactionDto> = try {
gson.fromJson(reactionsJson, reactionsTypeToken)
} catch (e: Exception) {
try {
val mapType = object : com.google.gson.reflect.TypeToken<Map<String, Int>>() {}.type
val map: Map<String, Int> = gson.fromJson(reactionsJson, mapType) ?: emptyMap()
map.map { chats.data.remote.dto.ReactionDto(it.key, it.value, false) }
} catch (innerE: Exception) {
emptyList()
}
} ?: emptyList()
return Message(
id = id,
chatId = chatId,
senderId = senderId,
senderName = senderName,
senderAvatar = senderAvatar,
content = content,
sequenceId = sequenceId,
createdAt = createdAt,
media = mediaDtos.map {
chats.domain.model.Media(
id = it.id ?: java.util.UUID.randomUUID().toString(),
type = it.type ?: "unknown",
url = (it.url ?: "").ensureAbsoluteUrl(baseUrl),
filename = it.filename,
size = it.size,
duration = it.duration
)
},
mediaType = mediaTypeEnum,
reactions = reactionDtos.associate { it.emoji to it.count },
isRead = isRead
)
}
fun String.ensureAbsoluteUrl(baseUrl: String): String {
return if (this.startsWith("http")) {
this
} else {
val base = baseUrl.removeSuffix("/")
val path = if (this.startsWith("/")) this else "/$this"
"$base$path"
}
}
@@ -0,0 +1,194 @@
package chats.data.signalr
import android.util.Log
import chats.data.remote.dto.MessageDto
import chats.data.remote.signalr.ChatEvent
import chats.data.remote.signalr.ChatHubClient
import chats.data.remote.signalr.MessagesReadEvent
import chats.data.remote.signalr.ReactionEvent
import chats.data.repository.toEntity
import com.google.gson.Gson
import core.database.data.MessageDao
import core.database.data.MessageEntity
import core.database.data.SyncStatus
import core.network.ServerConfig
import core.security.TokenManager
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest
import javax.inject.Inject
import javax.inject.Singleton
/**
* Обработчик SignalR событий для обновления локального кэша
*
* Обрабатывает события:
* - new_message: новое сообщение в чате
* - message_edited: сообщение отредактировано
* - message_deleted: сообщение удалено
* - messages_read: сообщения прочитаны
* - reaction_added/removed: реакция добавлена/удалена
*
* Все изменения сразу записываются в Room, UI обновляется через Flow
*/
@Singleton
class MessageSignalRHandler @Inject constructor(
private val hubClient: ChatHubClient,
private val dao: MessageDao,
private val serverConfig: ServerConfig,
private val tokenManager: TokenManager
) {
private val gson = Gson()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
private var isListening = false
/**
* Начинает прослушивание SignalR событий
* Вызывается один раз при инициализации приложения
*/
fun startListening() {
if (isListening) {
Log.w(TAG, "Already listening to SignalR events")
return
}
isListening = true
Log.d(TAG, "Started listening to SignalR events")
scope.launch {
hubClient.events.collectLatest { event ->
handleEvent(event)
}
}
}
/**
* Останавливает прослушивание событий
*/
fun stopListening() {
isListening = false
scope.cancel()
Log.d(TAG, "Stopped listening to SignalR events")
}
private suspend fun handleEvent(event: ChatEvent) {
try {
when (event) {
is ChatEvent.NewMessage -> handleNewMessage(event.message)
is ChatEvent.MessageEdited -> handleMessageEdited(event.messageId, event.chatId, event.content)
is ChatEvent.MessageDeleted -> handleMessageDeleted(event.messageId, event.chatId)
is ChatEvent.MessagesRead -> handleMessagesRead(event.chatId, event.lastReadSequenceId)
is ChatEvent.ReactionUpdated -> handleReactionUpdated(
event.messageId,
event.emoji,
event.isRemoved
)
else -> Log.d(TAG, "Unhandled event: ${event::class.simpleName}")
}
} catch (e: Exception) {
Log.e(TAG, "Error handling SignalR event: ${event::class.simpleName}", e)
}
}
private suspend fun handleNewMessage(dto: MessageDto) {
Log.d(TAG, "New message received: ${dto.id} in chat ${dto.chatId}")
val currentUserId = tokenManager.getUserId() ?: ""
val entity = dto.toEntity(baseUrl, currentUserId, gson).copy(
syncStatus = SyncStatus.SYNCED
)
val existing = dao.getMessageById(dto.id)
if (existing != null && existing.syncStatus == SyncStatus.SYNCING) {
val syncedEntity = entity.copy(
syncStatus = SyncStatus.SYNCED,
isDeletedLocally = false,
isEditedLocally = false,
editedContent = null
)
dao.insertMessage(syncedEntity)
Log.d(TAG, "Merged local message with server response: ${dto.id}")
} else {
dao.insertMessage(entity)
Log.d(TAG, "Saved new message: ${dto.id}")
}
}
private suspend fun handleMessageEdited(messageId: String, chatId: String, content: String) {
Log.d(TAG, "Message edited: $messageId")
val existing = dao.getMessageById(messageId)
if (existing != null) {
if (existing.isEditedLocally) {
Log.d(TAG, "Message is being edited locally, skipping server update: $messageId")
return
}
val updated = existing.copy(
content = content,
lastUpdated = System.currentTimeMillis()
)
dao.insertMessage(updated)
Log.d(TAG, "Updated edited message: $messageId")
} else {
Log.w(TAG, "Edited message not found in cache: $messageId")
}
}
private suspend fun handleMessageDeleted(messageId: String, chatId: String) {
Log.d(TAG, "Message deleted: $messageId")
val existing = dao.getMessageById(messageId)
if (existing?.isDeletedLocally == true) {
dao.deleteMessage(messageId)
Log.d(TAG, "Completed local deletion: $messageId")
} else {
dao.deleteMessage(messageId)
Log.d(TAG, "Removed deleted message from cache: $messageId")
}
}
private suspend fun handleMessagesRead(chatId: String, lastReadSequenceId: Int?) {
if (lastReadSequenceId == null) {
Log.w(TAG, "Messages read event with null sequenceId")
return
}
Log.d(TAG, "Messages read in chat $chatId up to sequenceId $lastReadSequenceId")
dao.markMessagesAsRead(chatId, lastReadSequenceId)
}
private suspend fun handleReactionUpdated(messageId: String, emoji: String, isRemoved: Boolean) {
Log.d(TAG, "Reaction ${if (isRemoved) "removed" else "added"}: $emoji on message $messageId")
val existing = dao.getMessageById(messageId)
if (existing != null) {
val reactions = gson.fromJson<Map<String, Int>>(existing.reactionsJson, Map::class.java) ?: emptyMap()
val updatedReactions = reactions.toMutableMap()
if (isRemoved) {
val currentCount = updatedReactions[emoji] ?: 0
if (currentCount > 1) {
updatedReactions[emoji] = currentCount - 1
} else {
updatedReactions.remove(emoji)
}
} else {
updatedReactions[emoji] = (updatedReactions[emoji] ?: 0) + 1
}
val updated = existing.copy(
reactionsJson = gson.toJson(updatedReactions),
lastUpdated = System.currentTimeMillis()
)
dao.insertMessage(updated)
Log.d(TAG, "Updated reactions for message: $messageId")
}
}
companion object {
private const val TAG = "MessageSignalRHandler"
}
}
@@ -0,0 +1,225 @@
package chats.data.sync
import android.content.Context
import android.util.Log
import androidx.hilt.work.HiltWorker
import androidx.work.*
import chats.data.remote.api.ChatApi
import chats.data.remote.api.SendMessageRequest
import core.database.data.MessageDao
import core.database.data.MessageEntity
import core.database.data.SyncStatus
import core.network.ServerConfig
import core.security.TokenManager
import com.google.gson.Gson
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import java.util.concurrent.TimeUnit
/**
* WorkManager Worker для фоновой синхронизации сообщений
*
* Обрабатывает:
* 1. Отправку новых сообщений (SYNCING)
* 2. Обновление отредактированных сообщений (isEditedLocally = true)
* 3. Удаление сообщений (isDeletedLocally = true)
* 4. Повторную отправку при ошибках (FAILED)
*
* Политика повторных попыток:
* - Экспоненциальная задержка (backoff)
* - Максимум 3 попытки
* - Таймаут 10 минут на выполнение
*/
@HiltWorker
class MessageSyncWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
private val dao: MessageDao,
private val api: ChatApi,
private val serverConfig: ServerConfig,
private val tokenManager: TokenManager
) : CoroutineWorker(appContext, params) {
private val gson = Gson()
companion object {
const val WORK_NAME = "message_sync_worker"
private const val TAG = "MessageSyncWorker"
/**
* Планирует синхронизацию
* Вызывается при изменении сообщений в базе
*/
fun scheduleSync(context: Context) {
Log.d(TAG, "Scheduling sync")
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(false)
.build()
val workRequest = OneTimeWorkRequestBuilder<MessageSyncWorker>()
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
WorkRequest.MIN_BACKOFF_MILLIS,
TimeUnit.MILLISECONDS
)
.addTag(WORK_NAME)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
WORK_NAME,
ExistingWorkPolicy.REPLACE,
workRequest
)
Log.d(TAG, "Sync scheduled")
}
/**
* Отменяет запланированную синхронизацию
*/
fun cancelSync(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
Log.d(TAG, "Sync cancelled")
}
}
override suspend fun doWork(): Result {
Log.d(TAG, "Starting message sync. Attempt: ${runAttemptCount + 1}")
if (runAttemptCount >= 3) {
Log.e(TAG, "Max retry attempts reached")
return Result.failure()
}
return try {
val pendingMessages = dao.getPendingSyncMessages()
Log.d(TAG, "Found ${pendingMessages.size} messages to sync")
if (pendingMessages.isEmpty()) {
Log.d(TAG, "No pending messages. Sync complete.")
return Result.success()
}
var successCount = 0
var failureCount = 0
for (message in pendingMessages) {
try {
when {
message.isDeletedLocally -> {
handleDeleteMessage(message)
successCount++
}
message.isEditedLocally -> {
handleEditMessage(message)
successCount++
}
message.syncStatus == SyncStatus.SYNCING -> {
handleSendMessage(message)
successCount++
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to sync message ${message.id}", e)
dao.markAsSyncFailed(message.id)
failureCount++
}
}
Log.d(TAG, "Sync completed. Success: $successCount, Failed: $failureCount")
if (failureCount > 0 && successCount == 0) {
Result.retry()
} else {
Result.success()
}
} catch (e: Exception) {
Log.e(TAG, "Sync failed with exception", e)
Result.retry()
}
}
private suspend fun handleSendMessage(message: MessageEntity) {
Log.d(TAG, "Sending message: ${message.id}")
val attachments = parseAttachments(message.mediaJson)
val request = 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 = SyncStatus.SYNCED,
isDeletedLocally = false,
isEditedLocally = false,
editedContent = null,
lastUpdated = System.currentTimeMillis()
)
dao.insertMessage(syncedMessage)
Log.d(TAG, "Message sent successfully: ${response.id}")
}
private suspend fun handleEditMessage(message: MessageEntity) {
Log.d(TAG, "Editing message: ${message.id}")
val newContent = message.editedContent ?: message.content
val request = SendMessageRequest(content = newContent)
val response = api.editMessage(message.id, request)
val syncedMessage = message.copy(
content = response.content,
syncStatus = SyncStatus.SYNCED,
isEditedLocally = false,
editedContent = null,
lastUpdated = System.currentTimeMillis()
)
dao.insertMessage(syncedMessage)
Log.d(TAG, "Message edited successfully: ${message.id}")
}
private suspend fun handleDeleteMessage(message: MessageEntity) {
Log.d(TAG, "Deleting message: ${message.id}")
val response = api.deleteMessage(message.id, forEveryone = false)
if (response.isSuccessful || response.code() == 404) {
dao.deleteMessage(message.id)
Log.d(TAG, "Message deleted successfully: ${message.id}")
} else {
throw Exception("Delete failed with code: ${response.code()}")
}
}
private fun parseAttachments(mediaJson: String): List<chats.data.remote.api.AttachmentRequest>? {
return try {
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) {
Log.e(TAG, "Failed to parse attachments", e)
null
}
}
}
+70
View File
@@ -0,0 +1,70 @@
package chats.di
import android.content.Context
import chats.data.remote.api.ChatApi
import chats.data.remote.signalr.ChatHubClient
import chats.data.repository.ChatRepositoryImpl
import chats.data.signalr.MessageSignalRHandler
import chats.domain.repository.ChatRepository
import core.database.data.ChatDatabase
import core.database.data.ChatDao
import core.database.data.MessageDao
import core.network.ServerConfig
import core.security.TokenManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import retrofit2.Retrofit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ChatModule {
@Provides
@Singleton
fun provideChatApi(retrofit: Retrofit): ChatApi {
return retrofit.create(ChatApi::class.java)
}
@Provides
@Singleton
fun provideMessageSignalRHandler(
hubClient: ChatHubClient,
dao: MessageDao,
serverConfig: ServerConfig,
tokenManager: TokenManager
): MessageSignalRHandler {
return MessageSignalRHandler(hubClient, dao, serverConfig, tokenManager)
}
@Provides
@Singleton
fun provideChatDao(database: ChatDatabase): ChatDao {
return database.chatDao()
}
@Provides
@Singleton
fun provideChatRepository(
api: ChatApi,
tokenManager: TokenManager,
serverConfig: ServerConfig,
messageDao: MessageDao,
chatDao: ChatDao,
database: ChatDatabase,
hubClient: ChatHubClient,
signalRHandler: MessageSignalRHandler,
@ApplicationContext context: Context
): ChatRepository {
return ChatRepositoryImpl(api, tokenManager, serverConfig, messageDao, chatDao, database, hubClient, signalRHandler, context)
}
@Provides
@Singleton
fun provideChatHubClient(): ChatHubClient {
return ChatHubClient()
}
}
@@ -0,0 +1,18 @@
package chats.domain.model
data class Chat(
val id: String,
val type: String,
val name: String,
val avatar: String?,
val unreadCount: Int,
val lastMessage: Message? = null,
val members: List<ChatMember> = emptyList()
)
data class ChatMember(
val userId: String,
val username: String,
val displayName: String? = null,
val avatarUrl: String? = null
)
@@ -0,0 +1,33 @@
package chats.domain.model
data class Message(
val id: String,
val chatId: String,
val senderId: String,
val senderName: String,
val senderAvatar: String? = null,
val content: String?,
val sequenceId: Int,
val createdAt: String,
val media: List<Media> = emptyList(),
val mediaType: MediaType = MediaType.TEXT,
val reactions: Map<String, Int> = emptyMap(),
val isRead: Boolean = false,
val isPinned: Boolean = false,
val isForwarded: Boolean = false,
val forwardedFromName: String? = null,
val replyTo: Message? = null
)
data class Media(
val id: String,
val type: String,
val url: String,
val filename: String? = null,
val size: Long? = null,
val duration: Double? = null
)
enum class MediaType {
TEXT, IMAGE, VIDEO, AUDIO, FILE, STORY_REPLY, GIF
}
@@ -0,0 +1,52 @@
package chats.domain.repository
import androidx.paging.PagingData
import chats.domain.model.Chat
import chats.domain.model.Message
import kotlinx.coroutines.flow.Flow
interface ChatRepository {
suspend fun getChats(): List<Chat>
fun getChatsFlow(): Flow<List<Chat>>
// Flow для UI (простой список)
fun getMessagesFlow(chatId: String): Flow<List<Message>>
// Paging 3 для пагинированного списка
fun getMessagesPaging(chatId: String): Flow<PagingData<Message>>
// Загрузка из сети (для начальной синхронизации)
suspend fun getMessages(chatId: String, cursor: String? = null, pivot: Long? = null, afterSequenceId: Long? = null, limit: Int? = null): List<Message>
// Получить последний известный sequenceId для чата (из локальной базы)
suspend fun getLastKnownSequenceId(chatId: String): Int?
suspend fun sendMessage(
chatId: String,
content: String?,
type: String = "text",
attachments: List<chats.data.remote.api.AttachmentRequest>? = null,
replyToId: String? = null,
forwardedFromId: String? = null
): Message
suspend fun addReaction(messageId: String, emoji: String)
suspend fun sendTypingStatus(chatId: String)
suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int)
// Локальные операции с офлайн-поддержкой
suspend fun saveMessage(message: Message)
suspend fun deleteLocalMessage(messageId: String)
suspend fun editLocalMessage(messageId: String, newContent: String)
suspend fun uploadMedia(file: java.io.File): String
suspend fun getTrendingGifs(page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
suspend fun searchGifs(query: String, page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto>
suspend fun createPersonalChat(userId: String): Chat
// Серверные операции
suspend fun deleteMessage(messageId: String, forEveryone: Boolean)
suspend fun editMessage(messageId: String, content: String): Message
}
@@ -0,0 +1,24 @@
package chats.domain.usecase
import chats.data.remote.api.ChatApi
import chats.data.remote.api.FileUploadResponse
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File
import javax.inject.Inject
class UploadMediaUseCase @Inject constructor(
private val api: ChatApi
) {
suspend operator fun invoke(file: File): Result<FileUploadResponse> {
return try {
val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
val response = api.uploadFile(body)
Result.success(response)
} catch (e: Exception) {
Result.failure(e)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,877 @@
package chats.presentation.chat_detail
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import chats.data.remote.signalr.ChatEvent
import chats.data.remote.signalr.ChatHubClient
import chats.data.remote.signalr.ConnectionStatus
import chats.data.repository.toDomain
import chats.domain.model.Message
import chats.domain.repository.ChatRepository
import core.network.NetworkManager
import core.network.ServerConfig
import core.security.TokenManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
import core.utils.copyUriToFile
import core.utils.ImageUtils
import chats.data.remote.api.KlipyGifDto
private const val TAG = "ChatDetailViewModel"
data class ChatDetailState(
val messages: List<Message> = emptyList(),
val chatName: String? = null,
val chatAvatar: String? = null,
val isLoading: Boolean = false,
val isLoadingMore: Boolean = false,
val isTyping: Boolean = false,
val typingUser: String? = null,
val error: String? = null,
val canCall: Boolean = true,
val maxFileSize: Long = 100 * 1024 * 1024,
val trendingGifs: List<KlipyGifDto> = emptyList(),
val searchedGifs: List<KlipyGifDto> = emptyList(),
val recentGifs: List<KlipyGifDto> = emptyList(),
val gifCategories: List<chats.data.remote.api.GifCategoryDto> = emptyList(),
val isGifsLoading: Boolean = false,
val initialScrollIndex: Int? = null,
val pendingAttachments: List<File> = emptyList(),
val isUploading: Boolean = false,
val isCompressionEnabled: Boolean = true,
val inputText: String = "",
val replyingMessage: Message? = null,
val editingMessage: Message? = null,
val forwardingMessages: List<Message> = emptyList(),
val availableChatsToForward: List<chats.domain.model.Chat> = emptyList(),
val selectedMessageIds: Set<String> = emptySet(),
val pinnedMessages: List<Message> = emptyList()
)
@HiltViewModel
class ChatDetailViewModel @Inject constructor(
private val repository: ChatRepository,
private val signalrClient: ChatHubClient,
private val serverConfig: ServerConfig,
private val tokenManager: TokenManager,
private val activeChatTracker: core.notifications.data.ActiveChatTracker,
private val signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver,
private val networkManager: NetworkManager,
@dagger.hilt.android.qualifiers.ApplicationContext private val context: android.content.Context
) : ViewModel() {
private val _state = MutableStateFlow(ChatDetailState())
val state: StateFlow<ChatDetailState> = _state.asStateFlow()
private var currentChatId: String? = null
private var typingTimerJob: Job? = null
private var signalrEventsJob: Job? = null
private var lastTypingSentTime: Long = 0
private val prefs = context.getSharedPreferences("chat_settings", android.content.Context.MODE_PRIVATE)
init {
val config = serverConfig.getServerConfig()
val savedCompression = prefs.getBoolean("compression_enabled", true)
_state.update { it.copy(
canCall = config.features.calls,
maxFileSize = config.limits.maxFileSize,
isCompressionEnabled = savedCompression
) }
}
fun toggleCompression() {
_state.update { currentState ->
val newValue = !currentState.isCompressionEnabled
prefs.edit().putBoolean("compression_enabled", newValue).apply()
currentState.copy(isCompressionEnabled = newValue)
}
}
fun getCurrentUserId(): String {
return tokenManager.getUserId() ?: ""
}
fun setChatId(chatId: String) {
if (currentChatId == chatId) return
currentChatId = chatId
activeChatTracker.setChatId(chatId)
_state.update { it.copy(
messages = emptyList(),
isLoading = true,
initialScrollIndex = null
) }
// Ensure SignalR is connected and join the chat room
val token = tokenManager.getToken()
val baseUrl = serverConfig.getBaseUrl()
if (token != null && baseUrl.isNotBlank()) {
signalrClient.connect(baseUrl.removeSuffix("/api/"), token)
signalrClient.joinChat(chatId)
}
loadChatInfo(chatId)
observeSignalRStatus(chatId)
observeSignalREvents(chatId)
observeNetworkStatus(chatId)
// Initial sync from network
refreshMessages(chatId)
}
private fun observeNetworkStatus(chatId: String) {
// При восстановлении сети обновляем сообщения
networkManager.isOnline
.filter { it } // Только переход в онлайн
.distinctUntilChanged()
.onEach {
android.util.Log.d(TAG, "Network restored in chat detail, refreshing messages")
kotlinx.coroutines.delay(1000) // Дадим сети стабилизироваться
refreshMessages(chatId)
}
.launchIn(viewModelScope)
}
private fun observeSignalRStatus(chatId: String) {
// При переподключении SignalR обновляем сообщения
signalrClient.status
.filter { it == ConnectionStatus.CONNECTED }
.distinctUntilChanged()
.onEach {
android.util.Log.d(TAG, "SignalR connected, refreshing messages for chat $chatId")
refreshMessages(chatId)
}
.launchIn(viewModelScope)
}
private fun updateMessages(messages: List<Message>) {
val sortedMessages = messages.sortedByDescending { it.sequenceId }
_state.update { it.copy(
messages = sortedMessages,
isLoading = false,
initialScrollIndex = 0 // In reverse layout, 0 is the bottom
) }
}
fun onInitialScrollDone() {
_state.update { it.copy(initialScrollIndex = -1) }
}
fun refreshMessages(chatId: String) {
viewModelScope.launch {
try {
// Сначала пытаемся загрузить из сети
val messages = repository.getMessages(chatId)
if (messages.isNotEmpty()) {
updateMessages(messages)
return@launch
}
} catch (e: Exception) {
android.util.Log.d(TAG, "Network load failed, trying cache")
}
// Если сеть не доступна или пуста - загружаем из Room
try {
val cachedMessages = repository.getMessagesFlow(chatId).first()
android.util.Log.d(TAG, "Loaded ${cachedMessages.size} messages from cache")
updateMessages(cachedMessages)
} catch (e: Exception) {
android.util.Log.e(TAG, "Cache load failed", e)
_state.update { it.copy(isLoading = false, messages = emptyList()) }
}
}
}
fun loadMoreMessages() {
val chatId = currentChatId ?: return
if (_state.value.isLoading || _state.value.isLoadingMore) return
val oldestMsg = _state.value.messages.lastOrNull() ?: return
viewModelScope.launch {
_state.update { it.copy(isLoadingMore = true) }
try {
android.util.Log.d("ChatDetailVM", "Loading more history before seqId: ${oldestMsg.sequenceId}")
val moreMessages = repository.getMessages(chatId, cursor = oldestMsg.sequenceId.toString())
if (moreMessages.isNotEmpty()) {
val newSorted = moreMessages.sortedByDescending { it.sequenceId }
_state.update { currentState ->
// Избегаем дубликатов
val existingIds = currentState.messages.map { it.id }.toSet()
val uniqueMore = newSorted.filter { it.id !in existingIds }
currentState.copy(messages = currentState.messages + uniqueMore)
}
}
} finally {
_state.update { it.copy(isLoadingMore = false) }
}
}
}
private fun loadChatInfo(chatId: String) {
viewModelScope.launch {
// Пробуем загрузить из API
try {
val chats = repository.getChats()
val chat = chats.find { it.id == chatId }
chat?.let { c ->
_state.update { it.copy(chatName = c.name, chatAvatar = c.avatar) }
android.util.Log.d(TAG, "Loaded chat info from API: ${c.name}")
return@launch
}
} catch (e: Exception) {
android.util.Log.d(TAG, "API load failed, will use messages for title")
}
// Если API недоступно - берём имя из сообщений (для личных чатов)
try {
val cachedMessages = repository.getMessagesFlow(chatId).first()
val otherUserMessage = cachedMessages.firstOrNull { it.senderId != getCurrentUserId() }
otherUserMessage?.let { msg ->
_state.update { it.copy(chatName = msg.senderName, chatAvatar = msg.senderAvatar) }
android.util.Log.d(TAG, "Loaded chat title from messages: ${msg.senderName}")
}
} catch (e: Exception) {
android.util.Log.e(TAG, "Failed to load chat title from messages", e)
}
}
}
private fun updateMessageReaction(messageId: String, userId: String, emoji: String) {
_state.update { s ->
val updatedMessages = s.messages.map { msg ->
if (msg.id == messageId) {
val currentReactions = msg.reactions.toMutableMap()
currentReactions[emoji] = (currentReactions[emoji] ?: 0) + 1
msg.copy(reactions = currentReactions)
} else msg
}
s.copy(messages = updatedMessages)
}
}
// For handling reaction removed event
private fun removeMessageReaction(messageId: String, userId: String, emoji: String) {
_state.update { s ->
val updatedMessages = s.messages.map { msg ->
if (msg.id == messageId) {
val currentReactions = msg.reactions.toMutableMap()
val count = currentReactions[emoji] ?: 0
if (count > 1) {
currentReactions[emoji] = count - 1
} else {
currentReactions.remove(emoji)
}
msg.copy(reactions = currentReactions)
} else msg
}
s.copy(messages = updatedMessages)
}
}
private fun observeSignalREvents(chatId: String) {
signalrEventsJob?.cancel()
signalrEventsJob = signalrClient.events
.onEach { android.util.Log.d("ChatDetailVM", "Received SignalR event: $it for chat: $chatId") }
.filter { event ->
val eventChatId = when(event) {
is ChatEvent.NewMessage -> event.message.chatId
is ChatEvent.ReactionUpdated -> event.chatId
is ChatEvent.UserTyping -> event.chatId
is ChatEvent.MessagesRead -> event.chatId
else -> null
}
if (eventChatId == null) return@filter false
val match = eventChatId == chatId || eventChatId.contains(chatId) || chatId.contains(eventChatId)
if (match) {
android.util.Log.d("ChatDetailVM", "Event MATCHED chat $chatId: $event")
}
match
}
.onEach { event ->
when (event) {
is ChatEvent.NewMessage -> {
android.util.Log.d("ChatDetailVM", "New message added to bottom, NOT marking as read automatically")
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val domainMsg = event.message.toDomain(getCurrentUserId(), baseUrl)
_state.update { currentState ->
if (currentState.messages.any { it.id == domainMsg.id }) return@update currentState
currentState.copy(messages = listOf(domainMsg) + currentState.messages)
}
}
is ChatEvent.ReactionUpdated -> {
if (event.isRemoved) {
removeMessageReaction(event.messageId, event.userId, event.emoji)
} else {
updateMessageReaction(event.messageId, event.userId, event.emoji)
}
}
is ChatEvent.MessageDeleted -> {
_state.update { currentState ->
currentState.copy(messages = currentState.messages.filter { it.id != event.messageId })
}
}
is ChatEvent.MessageEdited -> {
_state.update { currentState ->
val updated = currentState.messages.map { msg ->
if (msg.id == event.messageId) msg.copy(content = event.content) else msg
}
currentState.copy(messages = updated)
}
}
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 {
delay(3000)
_state.update { it.copy(isTyping = false) }
}
}
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 -> {
_state.update { currentState ->
val updatedMessages = currentState.messages.map { msg ->
if (msg.sequenceId <= event.lastReadSequenceId) {
msg.copy(isRead = true)
} else msg
}
currentState.copy(messages = updatedMessages)
}
}
is ChatEvent.MessagePinned -> {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
_state.update { s ->
val domainMsg = event.message.toDomain(getCurrentUserId(), baseUrl)
s.copy(pinnedMessages = (s.pinnedMessages + domainMsg).distinctBy { it.id })
}
}
is ChatEvent.MessageUnpinned -> {
_state.update { s ->
s.copy(pinnedMessages = s.pinnedMessages.filter { it.id != event.messageId })
}
}
else -> Unit
}
}
.launchIn(viewModelScope)
}
fun clearChatId() {
android.util.Log.d("ChatDetailVM", "KILLING ALL SESSION JOBS for $currentChatId")
signalrEventsJob?.cancel()
signalrEventsJob = null
currentChatId = null
activeChatTracker.setChatId(null)
_state.update { it.copy(messages = emptyList(), isLoading = false) }
}
fun markAsRead() {
val chatId = currentChatId ?: return
val messages = _state.value.messages
if (messages.isEmpty()) return
// В нашем reverseLayout (newest first) первое сообщение - самое новое от собеседника
val currentUserId = getCurrentUserId()
val lastMessageFromOther = messages.firstOrNull { it.senderId != currentUserId } ?: return
viewModelScope.launch {
try {
// Мгновенно обновляем в памяти для "галочек"
_state.update { currentState ->
val updatedMessages = currentState.messages.map { msg ->
if (msg.senderId != currentUserId && msg.sequenceId <= lastMessageFromOther.sequenceId) {
msg.copy(isRead = true)
} else msg
}
currentState.copy(messages = updatedMessages)
}
repository.markMessagesAsRead(chatId, lastMessageFromOther.id, lastMessageFromOther.sequenceId)
signalrNotificationObserver.refresh()
} catch (e: Exception) {
// Ignore
}
}
}
fun onInputTextChanged(text: String) {
_state.update { it.copy(inputText = text) }
val chatId = currentChatId ?: return
// Отправляем индикатор набора текста
val now = System.currentTimeMillis()
if (now - lastTypingSentTime > 1000) {
signalrClient.sendTypingIndicator(chatId)
lastTypingSentTime = now
// Отправляем "остановился печатать" через 3 секунды
typingTimerJob?.cancel()
typingTimerJob = viewModelScope.launch {
delay(3000)
signalrClient.sendUserStoppedTyping(chatId)
}
}
}
fun onReply(message: Message) {
_state.update { it.copy(replyingMessage = message) }
}
fun forwardMessages(targetChatId: String, messages: List<Message>) {
viewModelScope.launch {
messages.forEach { msg ->
repository.sendMessage(
chatId = targetChatId,
content = msg.content,
type = msg.mediaType.name.lowercase(),
forwardedFromId = msg.senderId
)
}
}
}
fun onForward(message: Message) {
_state.update { it.copy(forwardingMessages = listOf(message)) }
loadChatsForForwarding()
}
fun loadChatsForForwarding() {
viewModelScope.launch {
try {
val chats = repository.getChats()
_state.update { it.copy(availableChatsToForward = chats) }
} catch (e: Exception) {
// Ignore
}
}
}
fun cancelForwarding() {
_state.update { it.copy(forwardingMessages = emptyList(), availableChatsToForward = emptyList()) }
}
fun onForwardSelectedMessages() {
val selectedIds = _state.value.selectedMessageIds
val messages = _state.value.messages.filter { selectedIds.contains(it.id) }
_state.update { it.copy(forwardingMessages = messages) }
loadChatsForForwarding()
}
fun toggleSelection(messageId: String) {
_state.update { s ->
val newSelection = if (s.selectedMessageIds.contains(messageId)) {
s.selectedMessageIds - messageId
} else {
s.selectedMessageIds + messageId
}
s.copy(selectedMessageIds = newSelection)
}
}
fun clearSelection() {
_state.update { it.copy(selectedMessageIds = emptySet()) }
}
fun cancelReply() {
_state.update { it.copy(replyingMessage = null) }
}
fun cancelEdit() {
_state.update { it.copy(editingMessage = null, inputText = "") }
}
fun deleteMessage(message: Message, forEveryone: Boolean) {
viewModelScope.launch {
try {
repository.deleteMessage(message.id, forEveryone)
// Local update if needed (will also come via SignalR for everyone, but forMe might need local only update)
_state.update { currentState ->
currentState.copy(messages = currentState.messages.filter { it.id != message.id })
}
} catch (e: Exception) {
_state.update { it.copy(error = "Delete failed: ${e.localizedMessage}") }
}
}
}
fun onEdit(message: Message) {
_state.update { it.copy(editingMessage = message, inputText = message.content ?: "", replyingMessage = null) }
}
fun pinMessage(messageId: String) {
val chatId = currentChatId ?: return
signalrClient.pinMessage(messageId, chatId)
}
fun unpinMessage(messageId: String) {
val chatId = currentChatId ?: return
signalrClient.unpinMessage(messageId, chatId)
}
fun onPin(message: Message) {
pinMessage(message.id)
}
fun sendMessage(onFail: (String) -> Unit = {}) {
val chatId = currentChatId ?: return
val text = _state.value.inputText
val pending = _state.value.pendingAttachments
val replyToId = _state.value.replyingMessage?.id
val editingMsg = _state.value.editingMessage
if (text.isBlank() && pending.isEmpty()) return
// Handle Edit
if (editingMsg != null) {
_state.update { it.copy(inputText = "", editingMessage = null) }
viewModelScope.launch {
try {
val updated = repository.editMessage(editingMsg.id, text)
_state.update { currentState ->
val updatedList = currentState.messages.map {
if (it.id == updated.id) updated else it
}
currentState.copy(messages = updatedList)
}
} catch (e: Exception) {
_state.update { it.copy(error = "Edit failed: ${e.localizedMessage}") }
onFail(text)
}
}
return
}
// Clear input immediately to avoid double clicks and ensure UI experience
_state.update { it.copy(inputText = "", replyingMessage = null) }
val tempId = "temp_${System.currentTimeMillis()}"
val userId = getCurrentUserId()
// Determine mediaType based on attachments
val mediaType = when {
pending.isEmpty() -> chats.domain.model.MediaType.TEXT
pending.any { it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif") } -> chats.domain.model.MediaType.IMAGE
pending.any { it.extension.lowercase() in listOf("mp4", "mov", "webm") } -> chats.domain.model.MediaType.VIDEO
else -> chats.domain.model.MediaType.TEXT
}
val tempMessage = Message(
id = tempId,
chatId = chatId,
senderId = userId,
content = if (text.isBlank()) null else text,
createdAt = java.util.Date().toString(),
mediaType = mediaType,
media = emptyList(),
senderName = "Вы",
senderAvatar = null,
reactions = emptyMap(),
isRead = false,
sequenceId = 0
)
viewModelScope.launch {
repository.saveMessage(tempMessage)
}
viewModelScope.launch {
try {
// Upload attachments if any
val attachmentRequests = if (_state.value.pendingAttachments.isNotEmpty()) {
_state.update { it.copy(isUploading = true) }
val requests = _state.value.pendingAttachments.map { file ->
val url = repository.uploadMedia(file)
chats.data.remote.api.AttachmentRequest(
type = when {
file.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif", "heic", "heif") -> "image"
file.extension.lowercase() in listOf("mp4", "mov", "3gp", "mkv", "webm") -> "video"
file.extension.lowercase() in listOf("mp3", "m4a", "wav", "aac", "ogg") -> "audio"
else -> "file"
},
url = url,
fileName = file.name,
fileSize = file.length()
)
}
_state.update { it.copy(isUploading = false, pendingAttachments = emptyList()) }
requests
} else {
null
}
val sentMessage = repository.sendMessage(
chatId = chatId,
content = if (text.isBlank()) null else text,
type = if (attachmentRequests != null) "media" else "text",
attachments = attachmentRequests,
replyToId = replyToId
)
repository.deleteLocalMessage(tempId)
repository.saveMessage(sentMessage)
// Clear attachments on success
_state.update { it.copy(pendingAttachments = emptyList()) }
} catch (e: Exception) {
repository.deleteLocalMessage(tempId)
_state.update { it.copy(error = e.localizedMessage, isUploading = false) }
onFail(text)
}
}
}
fun addReaction(messageId: String, emoji: String) {
val chatId = currentChatId ?: return
// We rely on SignalR event to update the count to avoid double counting
// especially since domain Message doesn't track user IDs for reactions yet.
signalrClient.addReaction(messageId, chatId, emoji)
}
fun sendVoiceMessage(file: File) {
val chatId = currentChatId ?: return
val replyToId = _state.value.replyingMessage?.id
_state.update { it.copy(replyingMessage = null) }
val tempId = "temp_voice_${System.currentTimeMillis()}"
val userId = getCurrentUserId()
val tempMessage = Message(
id = tempId,
chatId = chatId,
senderId = userId,
content = null,
createdAt = java.util.Date().toString(),
mediaType = chats.domain.model.MediaType.AUDIO,
media = emptyList(),
senderName = "Вы",
reactions = emptyMap(),
isRead = false,
sequenceId = 0
)
viewModelScope.launch {
repository.saveMessage(tempMessage)
}
viewModelScope.launch {
try {
// 1. Upload the audio file
val url = repository.uploadMedia(file)
// 2. Send the message with the attachment
val attachment = chats.data.remote.api.AttachmentRequest(
type = "voice",
url = url,
fileName = file.name,
fileSize = file.length()
)
val sentMessage = repository.sendMessage(
chatId = chatId,
content = null,
type = "audio",
attachments = listOf(attachment),
replyToId = replyToId
)
repository.deleteLocalMessage(tempId)
repository.saveMessage(sentMessage)
} catch (e: Exception) {
repository.deleteLocalMessage(tempId)
_state.update { it.copy(error = "Ошибка отправки голосового: ${e.localizedMessage}") }
}
}
}
fun addPendingAttachment(uri: android.net.Uri, context: android.content.Context) {
viewModelScope.launch {
val mimeType = context.contentResolver.getType(uri) ?: ""
val file = if (_state.value.isCompressionEnabled && mimeType.startsWith("image")) {
ImageUtils.compressImage(context, uri)
} else {
copyUriToFile(context, uri)
}
file?.let { f ->
_state.update { it.copy(pendingAttachments = it.pendingAttachments + f) }
}
}
}
fun formatDateHeader(dateString: String): String {
return try {
// Парсим ISO 8601 (например 2024-04-14T20:56:00Z)
val isoFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}
val date = isoFormat.parse(dateString) ?: return dateString
// Форматируем в локальное время: "14 апреля"
java.text.SimpleDateFormat("d MMMM", java.util.Locale("ru")).format(date)
} catch (e: Exception) {
dateString
}
}
fun getLocalDateString(isoDate: String): String {
return try {
val isoFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}
val date = isoFormat.parse(isoDate) ?: return isoDate
// Возвращаем просто дату YYYY-MM-DD в локальном часовом поясе для группировки
java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault()).format(date)
} catch (e: Exception) {
isoDate.split("T").first()
}
}
fun removePendingAttachment(file: File) {
_state.update { it.copy(pendingAttachments = it.pendingAttachments - file) }
}
fun uploadMedia(file: File) {
if (file.length() > _state.value.maxFileSize) {
_state.update { it.copy(error = "File too large") }
return
}
viewModelScope.launch {
try {
val url = repository.uploadMedia(file)
// After upload, we might want to send a message with this media
// For now, let's just log it or handle as per app requirements
} catch (e: Exception) {
_state.update { it.copy(error = e.localizedMessage) }
}
}
}
fun loadTrendingGifs() {
if (_state.value.trendingGifs.isNotEmpty()) return
viewModelScope.launch {
_state.update { it.copy(isGifsLoading = true) }
try {
val gifs = repository.getTrendingGifs(0)
_state.update { it.copy(trendingGifs = gifs, isGifsLoading = false) }
} catch (e: Exception) {
_state.update { it.copy(isGifsLoading = false, error = "GIF load error: ${e.message}") }
}
}
}
fun loadGifCategories() {
if (_state.value.gifCategories.isNotEmpty()) return
viewModelScope.launch {
try {
val categories = repository.getGifCategories()
_state.update { it.copy(gifCategories = categories) }
} catch (e: Exception) {
// Ignore failure
}
}
}
private var searchJob: Job? = null
fun searchGifs(query: String) {
searchJob?.cancel()
if (query.isBlank()) {
_state.update { it.copy(searchedGifs = emptyList()) }
return
}
searchJob = viewModelScope.launch {
delay(500)
_state.update { it.copy(isGifsLoading = true) }
try {
val gifs = repository.searchGifs(query, 0)
_state.update { it.copy(searchedGifs = gifs, isGifsLoading = false) }
} catch (e: Exception) {
_state.update { it.copy(isGifsLoading = false) }
}
}
}
fun sendGif(url: String) {
val chatId = currentChatId ?: return
val replyToId = _state.value.replyingMessage?.id
_state.update { it.copy(replyingMessage = null) }
val tempId = "temp_gif_${System.currentTimeMillis()}"
val userId = getCurrentUserId()
val tempMessage = Message(
id = tempId,
chatId = chatId,
senderId = userId,
content = url,
createdAt = java.util.Date().toString(),
mediaType = chats.domain.model.MediaType.IMAGE, // We map GIF to IMAGE for rendering
media = listOf(chats.domain.model.Media(url = url, type = "image", id = "temp_media")),
senderName = "Вы",
senderAvatar = null,
reactions = emptyMap(),
isRead = false,
sequenceId = 0
)
viewModelScope.launch {
repository.saveMessage(tempMessage)
}
viewModelScope.launch {
try {
val attachment = chats.data.remote.api.AttachmentRequest(
type = "image",
url = url,
fileName = "gif.gif",
fileSize = 0
)
val sentMessage = repository.sendMessage(chatId, null, "image", listOf(attachment), replyToId)
repository.deleteLocalMessage(tempId)
repository.saveMessage(sentMessage)
// Add to recent
val allGifs = _state.value.trendingGifs + _state.value.searchedGifs + _state.value.recentGifs
val selectedGif = allGifs.find { gif ->
val gifUrl = gif.files?.get("hd")?.get("gif")?.url
?: gif.files?.get("sd")?.get("gif")?.url
?: gif.file?.get("hd")?.get("gif")?.url
?: gif.file?.get("sd")?.get("gif")?.url
?: gif.media_formats?.get("gif")?.url
?: gif.images?.original?.url
gifUrl == url
}
selectedGif?.let { gif ->
val newList = (listOf(gif) + _state.value.recentGifs).distinctBy { it.id }.take(20)
_state.update { it.copy(recentGifs = newList) }
}
} catch (e: Exception) {
repository.deleteLocalMessage(tempId)
_state.update { it.copy(error = e.localizedMessage) }
}
}
}
}
@@ -0,0 +1,127 @@
package chats.presentation.chat_list
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chats.presentation.components.ChatItem
import stories.presentation.StoryViewModel
import stories.presentation.components.StoryThumbnail
import ru.knot.messager.R
@Composable
fun HorizontalDividerComponent(
modifier: Modifier = Modifier,
thickness: androidx.compose.ui.unit.Dp = 1.dp,
color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.outlineVariant
) {
androidx.compose.material3.Divider(
modifier = modifier,
thickness = thickness,
color = color
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatListScreen(
viewModel: ChatListViewModel,
storyViewModel: StoryViewModel,
onChatClick: (String, String) -> Unit,
onStoryClick: (Int) -> Unit
) {
val state by viewModel.state.collectAsState()
val storyState by storyViewModel.state.collectAsState()
androidx.compose.runtime.LaunchedEffect(Unit) {
viewModel.loadChats()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.chats_title), fontWeight = FontWeight.Bold) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
titleContentColor = Color.White
)
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
if (state.isLoading && state.chats.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else {
LazyColumn {
// Ряд историй сверху списка чатов
item {
if (state.isStoriesEnabled && storyState.storyGroups.isNotEmpty()) {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
contentPadding = PaddingValues(horizontal = 8.dp)
) {
items(storyState.storyGroups.size) { index ->
val group = storyState.storyGroups[index]
StoryThumbnail(
username = group.username,
avatarUrl = group.avatar,
hasUnseen = true, // В идеале проверяем по статусам
onClick = { onStoryClick(index) }
)
}
}
HorizontalDividerComponent(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant
)
}
}
if (state.error != null && state.chats.isEmpty()) {
item {
Text(
text = "${stringResource(R.string.error_occurred)}: ${state.error}",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp)
)
}
} else if (state.chats.isEmpty() && !state.isLoading) {
item {
Box(modifier = Modifier.fillParentMaxSize()) {
Text(
text = stringResource(R.string.no_chats_found),
modifier = Modifier.align(Alignment.Center)
)
}
}
} else {
items(state.chats) { chat ->
ChatItem(chat = chat, onClick = { onChatClick(chat.id, chat.name) })
HorizontalDividerComponent(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant
)
}
}
}
}
}
}
}
@@ -0,0 +1,189 @@
package chats.presentation.chat_list
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import chats.domain.model.Chat
import chats.domain.repository.ChatRepository
import chats.data.remote.signalr.ChatHubClient
import chats.data.remote.signalr.ConnectionStatus
import chats.data.remote.signalr.ChatEvent
import core.network.NetworkManager
import core.network.ServerConfig
import core.security.TokenManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
import chats.data.repository.toDomain
private const val TAG = "ChatListViewModel"
data class ChatListState(
val chats: List<Chat> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val isStoriesEnabled: Boolean = true
)
@HiltViewModel
class ChatListViewModel @Inject constructor(
private val repository: ChatRepository,
private val hubClient: ChatHubClient,
private val serverConfig: ServerConfig,
private val tokenManager: TokenManager,
private val networkManager: NetworkManager
) : ViewModel() {
private val _state = MutableStateFlow(ChatListState())
val state: StateFlow<ChatListState> = _state.asStateFlow()
init {
loadChats()
observeSignalRStatus()
observeSignalREvents()
observeNetworkStatus()
}
private fun observeNetworkStatus() {
// При восстановлении сети обновляем чаты
networkManager.isOnline
.filter { it } // Только переход в онлайн
.distinctUntilChanged()
.onEach {
android.util.Log.d(TAG, "Network restored in chat list, refreshing chats")
kotlinx.coroutines.delay(1000) // Дадим сети стабилизироваться
repository.getChats()
}
.launchIn(viewModelScope)
}
private fun getCurrentUserId(): String = tokenManager.getUserId() ?: ""
fun loadChats() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
try {
// Пробуем загрузить из сети (это закэширует в Room)
repository.getChats()
} catch (e: Exception) {
android.util.Log.d(TAG, "Initial load failed, will use cache")
}
// Подписываемся на Flow из Room (всегда работает, даже оффлайн)
repository.getChatsFlow()
.catch { e ->
android.util.Log.e(TAG, "Flow error", e)
emit(emptyList())
}
.collect { chats ->
_state.update {
it.copy(
chats = sortChats(chats),
isLoading = false
)
}
}
}
}
private fun observeSignalRStatus() {
// Наблюдаем за статусом подключения SignalR и обновляем чаты при переподключении
hubClient.status
.filter { it == ConnectionStatus.CONNECTED }
.distinctUntilChanged()
.onEach {
android.util.Log.d(TAG, "SignalR connected, refreshing chats")
// При переподключении обновляем чаты из сети
repository.getChats()
}
.launchIn(viewModelScope)
}
private fun sortChats(chats: List<Chat>): List<Chat> {
return chats.sortedWith(compareByDescending<Chat> {
it.name.equals("Избранное", ignoreCase = true) || it.name.equals("Saved Messages", ignoreCase = true)
}.thenByDescending { it.lastMessage?.createdAt })
}
private fun observeSignalREvents() {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
android.util.Log.d(TAG, "Starting to observe SignalR events")
hubClient.events
.onEach { event ->
android.util.Log.d(TAG, ">>> ChatListVM received event: ${event::class.simpleName}")
when (event) {
is ChatEvent.NewMessage -> {
updateChatsWithNewMessage(event)
}
is ChatEvent.NewChat -> {
val currentUserId = getCurrentUserId()
_state.update { it.copy(chats = listOf(event.chat.toDomain(currentUserId, baseUrl)) + it.chats) }
}
is ChatEvent.MessagesRead -> {
if (event.userId == getCurrentUserId()) {
_state.update { currentState ->
val updatedChats = currentState.chats.map { chat ->
if (chat.id == event.chatId) {
chat.copy(unreadCount = 0)
} else chat
}
currentState.copy(chats = sortChats(updatedChats))
}
}
}
else -> Unit
}
}
.launchIn(viewModelScope)
}
private fun updateChatsWithNewMessage(event: ChatEvent.NewMessage) {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val currentUserId = getCurrentUserId()
_state.update { currentState ->
val chatIndex = currentState.chats.indexOfFirst {
it.id.equals(event.message.chatId, ignoreCase = true)
}
if (chatIndex >= 0) {
// Чат есть в списке - обновляем его
val chat = currentState.chats[chatIndex]
val isMyMessage = event.message.senderId == currentUserId
val isAlreadySeen = chat.lastMessage?.id == event.message.id
val lastMsgDomain = event.message.toDomain(currentUserId, baseUrl)
val newCount = if (isMyMessage || isAlreadySeen) {
chat.unreadCount
} else {
chat.unreadCount + 1
}
if (!isAlreadySeen) {
android.util.Log.d("ChatListVM", "Message ${event.message.id} -> Count ${chat.unreadCount} -> $newCount")
}
val updatedChat = chat.copy(
lastMessage = lastMsgDomain,
unreadCount = newCount
)
val updatedChats = currentState.chats.toMutableList()
updatedChats[chatIndex] = updatedChat
currentState.copy(chats = sortChats(updatedChats))
} else {
// Чата нет в списке - обновляем весь список из репозитория
android.util.Log.d("ChatListVM", "Chat ${event.message.chatId} not found in list, refreshing from repository")
viewModelScope.launch {
try {
repository.getChats() // Это обновит Room и Flow
} catch (e: Exception) {
android.util.Log.e("ChatListVM", "Failed to refresh chats", e)
}
}
currentState
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More