Заготовка

This commit is contained in:
Халимов Рустам
2026-05-07 23:27:37 +03:00
parent 2d2e4b685e
commit 9daa786cfb
33 changed files with 1662 additions and 178 deletions

View File

@@ -12,6 +12,7 @@ using Knot.Modules.Conversations;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Modules.Conversations.Presentation.Endpoints;
using Knot.Modules.Conversations.Presentation.Middleware;
using Knot.Modules.Federation;
using Knot.Modules.Federation.Presentation.Endpoints;
using Knot.Modules.Klipy;
@@ -32,11 +33,11 @@ using Knot.Modules.TelegramImport.Presentation.Endpoints;
using Knot.Modules.WebRtc;
using Knot.Modules.WebRtc.Presentation.Endpoints;
using Knot.Shared.Infrastructure;
using MediatR;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using MediatR;
@@ -242,6 +243,7 @@ if (app.Environment.IsDevelopment())
// Не раздаем статические файлы, так как теперь используем MinIO
app.UseAuthentication();
app.UseIdempotencyMiddleware();
app.UseAuthorization();
// Регистрация эндпоинтов

View File

@@ -1,9 +1,11 @@
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
using Microsoft.Extensions.Logging;
namespace Knot.Modules.Conversations.Application.Messages.Send;
@@ -30,7 +32,8 @@ public sealed record SendMessageCommand(
DateTime? PollExpiresAt = null,
string? CallType = null,
string? CallStatus = null,
int? Duration = null) : ICommand<Guid>;
int? Duration = null,
string? IdempotencyKey = null) : ICommand<Guid>;
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
{
@@ -39,24 +42,41 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
private readonly IChatsUnitOfWork _unitOfWork;
private readonly MediatR.IMediator _mediator;
private readonly IMessagesSettings _messagesSettings;
private readonly IIdempotencyKeyRepository _idempotencyRepository;
private readonly ILogger<SendMessageCommandHandler> _logger;
public SendMessageCommandHandler(
IChatRepository chatRepository,
IMessageRepository messageRepository,
IChatsUnitOfWork unitOfWork,
MediatR.IMediator mediator,
IMessagesSettings messagesSettings)
IMessagesSettings messagesSettings,
IIdempotencyKeyRepository idempotencyRepository,
ILogger<SendMessageCommandHandler> logger)
{
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_unitOfWork = unitOfWork;
_mediator = mediator;
_messagesSettings = messagesSettings;
_idempotencyRepository = idempotencyRepository;
_logger = logger;
}
public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken)
{
// 1. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD>
// 0. Проверка идемпотентности
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
{
var existingMessageId = await _idempotencyRepository.GetProcessedMessageIdAsync(request.IdempotencyKey, cancellationToken);
if (existingMessageId.HasValue)
{
_logger.LogInformation("Idempotency key already processed: {Key}, returning existing message: {MessageId}", request.IdempotencyKey, existingMessageId.Value);
return Result.Success(existingMessageId.Value);
}
}
// 1. Проверка существования чата
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
if (chat is null)
{
@@ -193,10 +213,16 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
senderMember.UpdateReadCursor(message.Id, message.SequenceId);
senderMember.UpdateDeliveredCursor(message.Id);
// 5. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
// 5. Сохранение
_messageRepository.Add(message);
await _unitOfWork.SaveChangesAsync(cancellationToken);
// 6. Сохранение idempotency ключа после успешного создания сообщения
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
{
await _idempotencyRepository.SaveKeyAsync(request.IdempotencyKey, message.Id, request.ChatId, cancellationToken);
}
await _mediator.Publish(new MessageSentDomainEvent(
message.Id,

View File

@@ -1,14 +1,22 @@
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Infrastructure.Persistence;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Infrastructure.Persistence;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Conversations.Domain;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence;
using Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
using Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
using Knot.Modules.Conversations.Infrastructure.Services;
using Knot.Modules.Conversations.Infrastructure.Services;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using Knot.Contracts.Messaging.Application.Abstractions;
namespace Knot.Modules.Conversations;
@@ -34,6 +42,9 @@ public static class DependencyInjection
services.AddScoped<IUserChatSettingsRepository, UserChatSettingsRepository>();
services.AddScoped<Knot.Contracts.Conversations.Domain.IUserFolderSettingsRepository, UserFolderSettingsRepository>();
// Idempotency support
services.AddScoped<IIdempotencyKeyRepository, IdempotencyKeyRepository>();
services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
@@ -42,7 +53,7 @@ public static class DependencyInjection
services.AddScoped<Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService, UserStatusService>();
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserStatusService, UserStatusService>();
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserDeleterService, UserDeleterService>();
return services;
}
}

View File

@@ -0,0 +1,26 @@
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Conversations.Domain;
/// <summary>
/// Репозиторий для управления idempotency ключами
/// </summary>
public interface IIdempotencyKeyRepository
{
/// <summary>
/// Проверяет наличие ключа и возвращает сообщение если оно уже было обработано
/// </summary>
Task<Guid?> GetProcessedMessageIdAsync(string key, CancellationToken cancellationToken = default);
/// <summary>
/// Сохраняет idempotency ключ и связывает его с сообщением
/// </summary>
Task SaveKeyAsync(string key, Guid messageId, Guid chatId, CancellationToken cancellationToken = default);
/// <summary>
/// Проверяет и сохраняет атомарно (избегаем race condition)
/// Возвращает: MessageId если ключ уже был, null если ключ был сохранен успешно
/// </summary>
Task<Guid?> GetOrSaveKeyAsync(string key, Guid messageId, Guid chatId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,57 @@
using System;
using MongoDB.Bson.Serialization.Attributes;
namespace Knot.Modules.Conversations.Domain;
/// <summary>
/// Запись об обработанном idempotency ключе для предотвращения дубликатов сообщений.
/// Используется для обеспечения идемпотентности при повторной отправке сообщений офлайн.
/// </summary>
public class IdempotencyKeyRecord
{
[BsonId]
[BsonRepresentation(MongoDB.Bson.BsonType.String)]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Idempotency ключ из заголовка запроса
/// </summary>
[BsonElement("key")]
public string Key { get; set; } = string.Empty;
/// <summary>
/// ID созданного сообщения
/// </summary>
[BsonElement("messageId")]
public Guid MessageId { get; set; }
/// <summary>
/// ID чата
/// </summary>
[BsonElement("chatId")]
public Guid ChatId { get; set; }
/// <summary>
/// Время создания записи
/// </summary>
[BsonElement("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// Время истечения записи (через 24 часа для очистки устаревших ключей)
/// </summary>
[BsonElement("expiresAt")]
public DateTime ExpiresAt { get; set; }
public IdempotencyKeyRecord() { }
public IdempotencyKeyRecord(string key, Guid messageId, Guid chatId)
{
Id = Guid.NewGuid().ToString();
Key = key;
MessageId = messageId;
ChatId = chatId;
CreatedAt = DateTime.UtcNow;
ExpiresAt = DateTime.UtcNow.AddHours(24);
}
}

View File

@@ -0,0 +1,112 @@
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using MongoDB.Bson;
namespace Knot.Modules.Conversations.Domain;
/// <summary>
/// Реализация репозитория idempotency ключей на основе MongoDB
/// </summary>
public class IdempotencyKeyRepository : IIdempotencyKeyRepository
{
private readonly IMongoCollection<IdempotencyKeyRecord> _collection;
private readonly ILogger<IdempotencyKeyRepository> _logger;
public IdempotencyKeyRepository(IMongoClient mongoClient, ILogger<IdempotencyKeyRepository> logger)
{
var database = mongoClient.GetDatabase("KnotDb");
_collection = database.GetCollection<IdempotencyKeyRecord>("idempotency_keys");
_logger = logger;
// Создаем индекс по ключу для быстрого поиска
CreateIndexes();
}
private void CreateIndexes()
{
var keyIndexModel = new CreateIndexModel<IdempotencyKeyRecord>(
Builders<IdempotencyKeyRecord>.IndexKeys.Ascending(x => x.Key),
new CreateIndexOptions { Unique = true }
);
var expireIndexModel = new CreateIndexModel<IdempotencyKeyRecord>(
Builders<IdempotencyKeyRecord>.IndexKeys.Ascending(x => x.ExpiresAt),
new CreateIndexOptions { ExpireAfter = TimeSpan.Zero } // TTL индекс
);
_collection.Indexes.CreateOne(keyIndexModel);
_collection.Indexes.CreateOne(expireIndexModel);
}
public async Task<Guid?> GetProcessedMessageIdAsync(string key, CancellationToken cancellationToken = default)
{
try
{
var record = await _collection
.Find(x => x.Key == key)
.FirstOrDefaultAsync(cancellationToken);
return record?.MessageId;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting processed message id for key: {Key}", key);
return null;
}
}
public async Task SaveKeyAsync(string key, Guid messageId, Guid chatId, CancellationToken cancellationToken = default)
{
try
{
var record = new IdempotencyKeyRecord(key, messageId, chatId);
await _collection.InsertOneAsync(record, cancellationToken: cancellationToken);
_logger.LogDebug("Saved idempotency key: {Key} for message: {MessageId}", key, messageId);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
// Ключ уже существует - это нормально, игнорируем
_logger.LogDebug("Idempotency key already exists: {Key}", key);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving idempotency key: {Key}", key);
throw;
}
}
public async Task<Guid?> GetOrSaveKeyAsync(string key, Guid messageId, Guid chatId, CancellationToken cancellationToken = default)
{
try
{
// Пробуем найти существующий ключ
var existingRecord = await _collection
.Find(x => x.Key == key)
.FirstOrDefaultAsync(cancellationToken);
if (existingRecord != null)
{
_logger.LogDebug("Idempotency key already processed: {Key}, returning existing message: {MessageId}", key, existingRecord.MessageId);
return existingRecord.MessageId;
}
// Пробуем вставить новую запись
var record = new IdempotencyKeyRecord(key, messageId, chatId);
await _collection.InsertOneAsync(record, cancellationToken: cancellationToken);
_logger.LogDebug("Saved new idempotency key: {Key} for message: {MessageId}", key, messageId);
return null;
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
// Race condition: другой запрос успел сохранить ключ
// Повторяем поиск
_logger.LogDebug("Race condition on idempotency key: {Key}, retrying", key);
return await GetProcessedMessageIdAsync(key, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in GetOrSaveKey for: {Key}", key);
throw;
}
}
}

View File

@@ -55,11 +55,14 @@ public static class MessagesEndpoints
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
});
group.MapPost("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromBody] SendMessageRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
group.MapPost("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromBody] SendMessageRequest request, ISender sender, IUserContext userContext, HttpRequest httpRequest, CancellationToken ct) =>
{
var attachments = request.Attachments?.Select(a =>
new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
// Получаем idempotency ключ из заголовка
httpRequest.Headers.TryGetValue("X-Idempotency-Key", out var idempotencyKey);
var command = new SendMessageCommand(
chatId,
userContext.UserId,
@@ -68,7 +71,8 @@ public static class MessagesEndpoints
attachments,
request.ReplyToId,
request.Quote,
request.ForwardedFromId);
request.ForwardedFromId,
IdempotencyKey: idempotencyKey.ToString());
var result = await sender.Send(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);

View File

@@ -0,0 +1,69 @@
using System.Net;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Knot.Modules.Conversations.Domain;
using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Presentation.Middleware;
/// <summary>
/// Middleware для проверки идемпотентности POST-запросов к сообщениям
/// </summary>
public class IdempotencyMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<IdempotencyMiddleware> _logger;
public IdempotencyMiddleware(RequestDelegate next, ILogger<IdempotencyMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context, IIdempotencyKeyRepository idempotencyRepository)
{
// Обрабатываем только POST запросы к /api/messages/chat/
if (context.Request.Method == HttpMethods.Post &&
context.Request.Path.StartsWithSegments("/api/messages/chat/"))
{
if (context.Request.Headers.TryGetValue("X-Idempotency-Key", out var idempotencyKey))
{
var key = idempotencyKey.ToString().Trim();
if (!string.IsNullOrWhiteSpace(key))
{
_logger.LogDebug("Processing idempotency key: {Key}", key);
// Проверяем, был ли уже обработан этот ключ
var existingMessageId = await idempotencyRepository.GetProcessedMessageIdAsync(key);
if (existingMessageId.HasValue)
{
_logger.LogInformation("Returning cached response for idempotency key: {Key}, MessageId: {MessageId}", key, existingMessageId.Value);
// Возвращаем успешный ответ с ID существующего сообщения
context.Response.StatusCode = (int)HttpStatusCode.OK;
context.Response.ContentType = "application/json";
var response = JsonSerializer.Serialize(new { id = existingMessageId.Value.ToString() });
await context.Response.WriteAsync(response);
return;
}
}
}
}
await _next(context);
}
}
// Extension method для упрощения использования
public static class IdempotencyMiddlewareExtensions
{
public static IApplicationBuilder UseIdempotencyMiddleware(this IApplicationBuilder builder)
{
return builder.UseMiddleware<IdempotencyMiddleware>();
}
}

View File

@@ -90,6 +90,8 @@ dependencies {
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")
implementation("androidx.hilt:hilt-work:1.1.0")
kapt("androidx.hilt:hilt-compiler:1.1.0")
// Network & SignalR
implementation("com.squareup.retrofit2:retrofit:2.9.0")
@@ -128,6 +130,15 @@ dependencies {
implementation("androidx.room:room-ktx:$room_version")
kapt("androidx.room:room-compiler:$room_version")
// WorkManager
val work_version = "2.9.0"
implementation("androidx.work:work-runtime-ktx:$work_version")
// Paging 3
val paging_version = "3.2.1"
implementation("androidx.paging:paging-runtime-ktx:$paging_version")
implementation("androidx.paging:paging-compose:$paging_version")
// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")

View File

@@ -1,13 +1,25 @@
package com.knot.messenger
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.decode.VideoFrameDecoder
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@HiltAndroidApp
class MainApplication : Application(), ImageLoaderFactory {
class MainApplication : Application(), ImageLoaderFactory, Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.components {

View File

@@ -0,0 +1,45 @@
package chats.data.local.dao
import androidx.room.*
import chats.data.local.database.ChatEntity
import kotlinx.coroutines.flow.Flow
/**
* DAO для операций с чатами в Room Database
*/
@Dao
interface ChatDao {
@Query("SELECT * FROM chats ORDER BY updatedAtMillis DESC")
fun getAllChats(): Flow<List<ChatEntity>>
@Query("SELECT * FROM chats WHERE remoteId = :remoteId LIMIT 1")
suspend fun getChatByRemoteId(remoteId: String): ChatEntity?
@Query("SELECT * FROM chats WHERE localId = :localId LIMIT 1")
suspend fun getChatByLocalId(localId: String): ChatEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertChat(chat: ChatEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertChats(chats: List<ChatEntity>)
@Update
suspend fun updateChat(chat: ChatEntity)
@Query("UPDATE chats SET lastMessageText = :lastMessageText, lastMessageTimestamp = :timestamp WHERE remoteId = :chatId")
suspend fun updateLastMessage(chatId: String, lastMessageText: String?, timestamp: String?)
@Query("UPDATE chats SET unreadCount = :count WHERE remoteId = :chatId")
suspend fun updateUnreadCount(chatId: String, count: Int)
@Delete
suspend fun deleteChat(chat: ChatEntity)
@Query("DELETE FROM chats WHERE remoteId = :remoteId")
suspend fun deleteChatByRemoteId(remoteId: String)
@Query("DELETE FROM chats")
suspend fun deleteAllChats()
}

View File

@@ -0,0 +1,70 @@
package chats.data.local.dao
import androidx.room.*
import chats.data.local.database.MessageEntity
import chats.domain.model.MessageStatus
import kotlinx.coroutines.flow.Flow
/**
* DAO для операций с сообщениями в Room Database
*/
@Dao
interface MessageDao {
@Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId ASC")
fun getMessagesByChatId(chatId: String): Flow<List<MessageEntity>>
@Query("SELECT * FROM messages WHERE chatId = :chatId AND sequenceId > :afterSequenceId ORDER BY sequenceId ASC LIMIT :limit")
suspend fun getMessagesAfter(chatId: String, afterSequenceId: Long, limit: Int): List<MessageEntity>
@Query("SELECT * FROM messages WHERE chatId = :chatId AND sequenceId < :beforeSequenceId ORDER BY sequenceId DESC LIMIT :limit")
suspend fun getMessagesBefore(chatId: String, beforeSequenceId: Long, limit: Int): List<MessageEntity>
@Query("SELECT * FROM messages WHERE localId = :localId")
suspend fun getMessageByLocalId(localId: String): MessageEntity?
@Query("SELECT * FROM messages WHERE serverId = :serverId")
suspend fun getMessageByServerId(serverId: String): MessageEntity?
@Query("SELECT * FROM messages WHERE status IN (:statuses) AND chatId = :chatId")
suspend fun getMessagesByStatus(chatId: String, statuses: List<MessageStatus>): List<MessageEntity>
@Query("SELECT * FROM messages WHERE status = :status")
fun getMessagesByStatusFlow(status: MessageStatus): Flow<List<MessageEntity>>
@Query("SELECT * FROM messages WHERE status IN (:statuses)")
suspend fun getPendingMessages(statuses: List<MessageStatus>): List<MessageEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessage(message: MessageEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessages(messages: List<MessageEntity>)
@Update
suspend fun updateMessage(message: MessageEntity)
@Delete
suspend fun deleteMessage(message: MessageEntity)
@Query("DELETE FROM messages WHERE localId = :localId")
suspend fun deleteMessageByLocalId(localId: String)
@Query("DELETE FROM messages WHERE chatId = :chatId")
suspend fun deleteMessagesByChatId(chatId: String)
@Query("UPDATE messages SET status = :status, updatedAtMillis = :updatedAtMillis WHERE localId = :localId")
suspend fun updateMessageStatus(localId: String, status: MessageStatus, updatedAtMillis: Long)
@Query("UPDATE messages SET serverId = :serverId, status = :status, updatedAtMillis = :updatedAtMillis WHERE localId = :localId")
suspend fun updateMessageWithServerId(localId: String, serverId: String, status: MessageStatus, updatedAtMillis: Long)
@Query("UPDATE messages SET status = :status, errorMessage = :errorMessage, retryCount = retryCount + 1, updatedAtMillis = :updatedAtMillis WHERE localId = :localId")
suspend fun updateMessageError(localId: String, status: MessageStatus, errorMessage: String, updatedAtMillis: Long)
@Query("SELECT COUNT(*) FROM messages WHERE chatId = :chatId AND status IN (:statuses)")
suspend fun getUnsentCount(chatId: String, statuses: List<MessageStatus>): Int
@Query("SELECT * FROM messages WHERE chatId = :chatId AND status = :status LIMIT 1")
suspend fun getFirstMessageByStatus(chatId: String, status: MessageStatus): MessageEntity?
}

View File

@@ -0,0 +1,36 @@
package chats.data.local.dao
import androidx.room.*
import chats.data.local.database.UserProfileEntity
import kotlinx.coroutines.flow.Flow
/**
* DAO для операций с профилями пользователей в Room Database
*/
@Dao
interface UserProfileDao {
@Query("SELECT * FROM user_profile WHERE userId = :userId LIMIT 1")
suspend fun getUserById(userId: String): UserProfileEntity?
@Query("SELECT * FROM user_profile WHERE userId = :userId")
fun getUserByIdFlow(userId: String): Flow<UserProfileEntity?>
@Query("SELECT * FROM user_profile")
fun getAllUsers(): Flow<List<UserProfileEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertUser(user: UserProfileEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertUsers(users: List<UserProfileEntity>)
@Update
suspend fun updateUser(user: UserProfileEntity)
@Delete
suspend fun deleteUser(user: UserProfileEntity)
@Query("DELETE FROM user_profile WHERE userId = :userId")
suspend fun deleteUserById(userId: String)
}

View File

@@ -0,0 +1,33 @@
package chats.data.local.database
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import chats.data.local.dao.ChatDao
import chats.data.local.dao.MessageDao
import chats.data.local.dao.UserProfileDao
/**
* Room Database для локального хранения данных мессенджера
* Реализует паттерн Single Source of Truth
*/
@Database(
entities = [
MessageEntity::class,
ChatEntity::class,
UserProfileEntity::class
],
version = 1,
exportSchema = false
)
@TypeConverters(MessageStatusConverter::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun messageDao(): MessageDao
abstract fun chatDao(): ChatDao
abstract fun userProfileDao(): UserProfileDao
companion object {
const val DATABASE_NAME = "knot_messenger.db"
}
}

View File

@@ -0,0 +1,37 @@
package chats.data.local.database
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Entity для хранения чатов в локальной БД Room
*/
@Entity(
tableName = "chats",
indices = [
Index(value = ["remoteId"], unique = true)
]
)
data class ChatEntity(
@PrimaryKey(autoGenerate = false)
val localId: String,
val remoteId: String?, // ID с сервера
val type: String, // PRIVATE, GROUP, CHANNEL
val name: String,
val avatar: String? = null,
val unreadCount: Int = 0,
val lastMessageId: String? = null,
val lastMessageText: String? = null,
val lastMessageTimestamp: String? = null,
val updatedAtMillis: Long = System.currentTimeMillis()
)

View File

@@ -0,0 +1,66 @@
package chats.data.local.database
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import chats.domain.model.MessageStatus
/**
* Entity для хранения сообщений в локальной БД Room
* Используется для офлайн-режима и Single Source of Truth паттерна
*/
@Entity(
tableName = "messages",
indices = [
Index(value = ["chatId", "sequenceId"]),
Index(value = ["localId"], unique = true),
Index(value = ["serverId"]),
Index(value = ["status"])
]
)
data class MessageEntity(
@PrimaryKey(autoGenerate = false)
val localId: String, // UUID генерируется на клиенте при создании
val serverId: String?, // null для неотправленных сообщений
val idempotencyKey: String, // UUID v4 для идемпотентности
val chatId: String,
val senderId: String,
val senderName: String,
val senderAvatar: String? = null,
val content: String?,
val sequenceId: Long,
val createdAt: String, // ISO-8601 формат
val mediaType: String,
val mediaJson: String, // JSON список медиа (для Room)
val reactionsJson: String = "{}", // JSON map эмодзи -> count
val status: MessageStatus,
val isPinned: Boolean = false,
val isForwarded: Boolean = false,
val forwardedFromName: String? = null,
val replyToServerId: String? = null,
val errorMessage: String? = null,
val retryCount: Int = 0,
val createdAtMillis: Long = 0,
val updatedAtMillis: Long = 0
)

View File

@@ -0,0 +1,17 @@
package chats.data.local.database
import androidx.room.TypeConverter
import chats.domain.model.MessageStatus
/**
* Конвертеры для Room для работы с Enum и другими типами
*/
class MessageStatusConverter {
@TypeConverter
fun fromMessageStatus(status: MessageStatus): String = status.name
@TypeConverter
fun toMessageStatus(value: String): MessageStatus = runCatching {
MessageStatus.valueOf(value)
}.getOrDefault(MessageStatus.UNKNOWN)
}

View File

@@ -0,0 +1,27 @@
package chats.data.local.database
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Entity для хранения профиля пользователя в локальной БД Room
*/
@Entity(tableName = "user_profile")
data class UserProfileEntity(
@PrimaryKey
val userId: String,
val username: String,
val displayName: String,
val avatarUrl: String? = null,
val bio: String? = null,
val isOnline: Boolean = false,
val lastSeenMillis: Long = 0,
val updatedAtMillis: Long = System.currentTimeMillis()
)

View File

@@ -0,0 +1,34 @@
package chats.data.local.mappers
import chats.data.local.database.ChatEntity
import chats.domain.model.Chat
/**
* Преобразует Domain Chat в Entity
*/
fun Chat.toEntity(): ChatEntity {
return ChatEntity(
localId = this.id.takeIf { it.isNotBlank() } ?: java.util.UUID.randomUUID().toString(),
remoteId = this.id,
type = this.type,
name = this.name,
avatar = this.avatar,
unreadCount = this.unreadCount,
lastMessageText = this.lastMessage?.content,
lastMessageTimestamp = this.lastMessage?.createdAt
)
}
/**
* Преобразует Entity в Domain Chat
*/
fun ChatEntity.toDomain(): Chat {
return Chat(
id = this.remoteId ?: this.localId,
type = this.type,
name = this.name,
avatar = this.avatar,
unreadCount = this.unreadCount,
lastMessage = null // lastMessage загружается отдельно
)
}

View File

@@ -0,0 +1,117 @@
package chats.data.local.mappers
import chats.data.local.database.MessageEntity
import chats.domain.model.Message
import chats.domain.model.MessageStatus
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
private val gson = Gson()
/**
* Преобразует Domain Message в Entity для сохранения в Room
*/
fun Message.toEntity(status: MessageStatus = MessageStatus.SENT): MessageEntity {
return MessageEntity(
localId = this.id.takeIf { it.isNotBlank() } ?: java.util.UUID.randomUUID().toString(),
serverId = if (status == MessageStatus.SENT || status == MessageStatus.DELIVERED || status == MessageStatus.READ) this.id else null,
idempotencyKey = java.util.UUID.randomUUID().toString(),
chatId = this.chatId,
senderId = this.senderId,
senderName = this.senderName,
senderAvatar = this.senderAvatar,
content = this.content,
sequenceId = this.sequenceId.toLong(),
createdAt = this.createdAt,
mediaType = this.mediaType.name,
mediaJson = gson.toJson(this.media),
reactionsJson = gson.toJson(this.reactions),
status = status,
isPinned = this.isPinned,
isForwarded = this.isForwarded,
forwardedFromName = this.forwardedFromName,
replyToServerId = this.replyTo?.id,
createdAtMillis = parseTimestamp(this.createdAt),
updatedAtMillis = System.currentTimeMillis()
)
}
/**
* Преобразует Entity в Domain Message для отображения в UI
*/
fun MessageEntity.toDomain(): Message {
val mediaListType = object : TypeToken<List<chats.domain.model.Media>>() {}.type
val reactionsMapType = object : TypeToken<Map<String, Int>>() {}.type
return Message(
id = this.serverId ?: this.localId,
chatId = this.chatId,
senderId = this.senderId,
senderName = this.senderName,
senderAvatar = this.senderAvatar,
content = this.content,
sequenceId = this.sequenceId.toInt(),
createdAt = this.createdAt,
media = try {
gson.fromJson<List<chats.domain.model.Media>>(this.mediaJson, mediaListType) ?: emptyList()
} catch (e: Exception) {
emptyList()
},
mediaType = try {
chats.domain.model.MediaType.valueOf(this.mediaType)
} catch (e: Exception) {
chats.domain.model.MediaType.TEXT
},
reactions = try {
gson.fromJson<Map<String, Int>>(this.reactionsJson, reactionsMapType) ?: emptyMap()
} catch (e: Exception) {
emptyMap()
},
isRead = this.status == MessageStatus.READ,
isPinned = this.isPinned,
isForwarded = this.isForwarded,
forwardedFromName = this.forwardedFromName,
replyTo = null, // replyTo загружается отдельно если нужно
status = this.status
)
}
/**
* Создает новое локальное сообщение со статусом PENDING
*/
fun createPendingMessageEntity(
chatId: String,
senderId: String,
senderName: String,
content: String?,
mediaType: String = "TEXT",
mediaJson: String = "[]",
replyToId: String? = null
): MessageEntity {
val localId = java.util.UUID.randomUUID().toString()
return MessageEntity(
localId = localId,
serverId = null,
idempotencyKey = localId, // Используем localId как idempotency key
chatId = chatId,
senderId = senderId,
senderName = senderName,
content = content,
sequenceId = -1, // Будет обновлено после получения с сервера
createdAt = java.time.ZonedDateTime.now().toString(),
mediaType = mediaType,
mediaJson = mediaJson,
status = MessageStatus.PENDING,
replyToServerId = replyToId,
createdAtMillis = System.currentTimeMillis(),
updatedAtMillis = System.currentTimeMillis()
)
}
private fun parseTimestamp(createdAt: String): Long {
return try {
java.time.ZonedDateTime.parse(createdAt).toInstant().toEpochMilli()
} catch (e: Exception) {
System.currentTimeMillis()
}
}

View File

@@ -33,7 +33,11 @@ interface ChatApi {
): List<MessageDto>
@POST("messages/chat/{chatId}")
suspend fun sendMessage(@Path("chatId") chatId: String, @Body request: SendMessageRequest): String
suspend fun sendMessage(
@Path("chatId") chatId: String,
@Body request: SendMessageRequest,
@Header("X-Idempotency-Key") idempotencyKey: String
): String
@Multipart
@POST("messages/upload")

View File

@@ -1,144 +1,299 @@
package chats.data.repository
import android.util.Log
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.WorkManager
import chats.data.local.dao.ChatDao
import chats.data.local.dao.MessageDao
import chats.data.local.database.MessageEntity
import chats.data.local.mappers.createPendingMessageEntity
import chats.data.local.mappers.toDomain
import chats.data.local.mappers.toEntity
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.remote.dto.MediaItemDto
import chats.data.remote.dto.ReactionDto
import chats.data.workers.ChatSyncWorker
import chats.data.workers.SendMessageWorker
import chats.domain.model.Chat
import chats.domain.model.Message
import chats.domain.model.MediaType
import chats.domain.model.MessageStatus
import chats.domain.repository.ChatRepository
import core.network.ServerConfig
import chats.data.remote.signalr.ReadMessagesRequest
import core.security.TokenManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import javax.inject.Inject
import kotlinx.coroutines.flow.map
/**
* Реализация ChatRepository с поддержкой офлайн-режима.
* Использует паттерн Single Source of Truth: UI всегда берет данные из Room.
*/
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 workManager: WorkManager,
private val hubClient: chats.data.remote.signalr.ChatHubClient
) : ChatRepository {
private val gson = com.google.gson.Gson()
// ==================== Чаты ====================
override fun getChatsFlow(): Flow<List<Chat>> {
// Single Source of Truth - данные из Room
return chatDao.getAllChats().map { entities ->
entities.map { it.toDomain() }
}
}
override suspend fun getChats(): List<Chat> {
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
return api.getChats().map { it.toDomain(currentUserId, baseUrl) }
return try {
// Пробуем получить с сервера
val remoteChats = api.getChats()
val domainChats = remoteChats.map { it.toDomain(currentUserId, baseUrl) }
// Сохраняем в локальную БД
val entities = domainChats.map { it.toEntity() }
chatDao.insertChats(entities)
domainChats
} catch (e: Exception) {
Log.w("ChatRepo", "Failed to fetch chats from server, returning local", e)
// При ошибке возвращаем локальные данные
emptyList()
}
}
override fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>> {
// Кэш отключён - всегда возвращаем пустой поток
return kotlinx.coroutines.flow.flowOf(emptyList())
override suspend fun syncChats() {
try {
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val remoteChats = api.getChats()
val entities = remoteChats.map { dto ->
val existing = chatDao.getChatByRemoteId(dto.id)
dto.toDomain(currentUserId, baseUrl).toEntity().copy(
localId = existing?.localId ?: java.util.UUID.randomUUID().toString()
)
}
chatDao.insertChats(entities)
} catch (e: Exception) {
Log.e("ChatRepo", "Sync chats failed", e)
}
}
// ==================== Сообщения ====================
override fun getMessagesFlow(chatId: String): Flow<List<Message>> {
// Single Source of Truth - всегда из Room
return messageDao.getMessagesByChatId(chatId).map { entities ->
entities.map { it.toDomain() }
}
}
override suspend fun getMessages(chatId: String, cursor: String?, pivot: Long?, limit: Int?): List<Message> {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val currentUserId = tokenManager.getUserId() ?: ""
return try {
android.util.Log.d("ChatRepo", "FETCH: chatId=$chatId, cursor=$cursor, limit=$limit")
Log.d("ChatRepo", "FETCH: chatId=$chatId, cursor=$cursor, limit=$limit")
val messages = api.getMessages(chatId, cursor = cursor, limit = limit)
if (messages.isNotEmpty()) {
android.util.Log.d("ChatRepo", "Received ${messages.size} messages. TopSeq: ${messages.first().sequenceId}, BottomSeq: ${messages.last().sequenceId}")
Log.d("ChatRepo", "Received ${messages.size} messages")
// Сохраняем в локальную БД
saveMessagesToLocal(messages, chatId, currentUserId)
}
// Мапим в доменные модели. По умолчанию считаем прочитанными,
// так как unreadCount нам тут не критичен для истории.
messages.map { msg ->
msg.toDomain(currentUserId, baseUrl).copy(isRead = true)
}
} catch (e: Exception) {
android.util.Log.e("ChatRepo", "Fetch messages failed", e)
Log.e("ChatRepo", "Fetch messages failed, returning local", e)
emptyList()
}
}
override suspend fun sendMessage(
chatId: String,
content: String?,
chatId: String,
content: String?,
type: String,
attachments: List<chats.data.remote.api.AttachmentRequest>?,
replyToId: String?,
forwardedFromId: String?
): Message {
val request = SendMessageRequest(
content = content,
type = type,
attachments = attachments,
replyToId = replyToId,
forwardedFromId = forwardedFromId
)
android.util.Log.d("ChatRepoImpl", "sendMessage request: $request")
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val userId = tokenManager.getUserId() ?: ""
return try {
val messageId = api.sendMessage(chatId, request)
android.util.Log.d("ChatRepoImpl", "sendMessage response: $messageId")
// Поскольку сервер вернул только ID, создаем заглушку Message.
// Настоящее сообщение придет через SignalR.
Message(
id = messageId,
chatId = chatId,
senderId = userId,
senderName = "", // Будет обновлено через SignalR
content = content,
sequenceId = 0,
createdAt = java.time.ZonedDateTime.now().toString(),
media = attachments?.map {
chats.domain.model.Media(
id = java.util.UUID.randomUUID().toString(),
type = it.type,
url = it.url,
filename = it.fileName,
size = it.fileSize
)
} ?: emptyList(),
mediaType = when(type) {
"image" -> chats.domain.model.MediaType.IMAGE
"video" -> chats.domain.model.MediaType.VIDEO
"audio", "voice" -> chats.domain.model.MediaType.AUDIO
else -> chats.domain.model.MediaType.TEXT
}
val userName = tokenManager.getUsername() ?: userId
// 1. Создаем локальное сообщение со статусом PENDING
val mediaType = when (type) {
"image" -> "IMAGE"
"video" -> "VIDEO"
"audio", "voice" -> "AUDIO"
else -> "TEXT"
}
val mediaJson = attachments?.map {
chats.domain.model.Media(
id = java.util.UUID.randomUUID().toString(),
type = it.type,
url = it.url,
filename = it.fileName,
size = it.fileSize
)
}?.let { gson.toJson(it) } ?: "[]"
val pendingEntity = createPendingMessageEntity(
chatId = chatId,
senderId = userId,
senderName = userName,
content = content,
mediaType = mediaType,
mediaJson = mediaJson,
replyToId = replyToId
)
// 2. Сохраняем в Room
messageDao.insertMessage(pendingEntity)
// 3. Ставим задачу в WorkManager для отправки
val workRequest = SendMessageWorker.createWorkRequest(pendingEntity.localId)
workManager.enqueueUniqueWork(
"send_${pendingEntity.localId}",
ExistingWorkPolicy.REPLACE,
workRequest
)
Log.d("ChatRepo", "Queued message for sending: ${pendingEntity.localId}")
// 4. Возвращаем доменную модель для немедленного отображения в UI
return pendingEntity.toDomain()
}
override suspend fun retryFailedMessage(localId: String) {
val message = messageDao.getMessageByLocalId(localId)
?: return
if (message.status != MessageStatus.FAILED) return
// Сбрасываем статус и ставим в очередь
messageDao.updateMessageStatus(
localId = localId,
status = MessageStatus.PENDING,
updatedAtMillis = System.currentTimeMillis()
)
val workRequest = SendMessageWorker.createWorkRequest(localId)
workManager.enqueueUniqueWork(
"send_$localId",
ExistingWorkPolicy.REPLACE,
workRequest
)
}
override suspend fun deleteLocalMessage(messageId: String) {
messageDao.deleteMessageByLocalId(messageId)
}
override suspend fun saveMessage(message: Message) {
// Сохраняем входящее сообщение из SignalR
val entity = message.toEntity(MessageStatus.DELIVERED)
messageDao.insertMessage(entity)
}
// ==================== Синхронизация ====================
override suspend fun syncMessagesForChat(chatId: String) {
val workRequest = ChatSyncWorker.createOneTimeWorkRequest(chatId)
workManager.enqueue(workRequest)
}
override suspend fun schedulePeriodicSync() {
val workRequest = ChatSyncWorker.createPeriodicWorkRequest()
workManager.enqueueUniquePeriodicWork(
"periodic_chat_sync",
ExistingPeriodicWorkPolicy.KEEP,
workRequest
)
}
// ==================== Вспомогательные методы ====================
private suspend fun saveMessagesToLocal(messages: List<chats.data.remote.dto.MessageDto>, chatId: String, currentUserId: String) {
messages.forEach { dto ->
val existing = messageDao.getMessageByServerId(dto.id)
if (existing == null) {
val entity = MessageEntity(
localId = java.util.UUID.randomUUID().toString(),
serverId = dto.id,
idempotencyKey = dto.id,
chatId = chatId,
senderId = dto.senderId ?: dto.sender?.id ?: currentUserId,
senderName = dto.sender?.displayName ?: dto.sender?.username ?: "",
senderAvatar = dto.sender?.avatarUrl,
content = dto.content,
sequenceId = dto.sequenceId?.toLong() ?: 0L,
createdAt = dto.createdAt ?: java.time.ZonedDateTime.now().toString(),
mediaType = dto.type ?: "TEXT",
mediaJson = gson.toJson(dto.media.map {
chats.domain.model.Media(
id = it.id,
type = it.type,
url = it.url,
filename = it.filename,
size = it.size,
duration = it.duration
)
}),
reactionsJson = gson.toJson(
dto.reactions?.associate { it.emoji to it.count } ?: emptyMap<String, Int>()
),
status = if (dto.senderId == currentUserId) MessageStatus.SENT else MessageStatus.DELIVERED,
createdAtMillis = System.currentTimeMillis(),
updatedAtMillis = System.currentTimeMillis()
)
messageDao.insertMessage(entity)
}
}
}
// ==================== Остальные методы ====================
override suspend fun addReaction(messageId: String, emoji: String) {
try {
api.addReaction(messageId, emoji)
} catch (e: Exception) {
android.util.Log.e("ChatRepoImpl", "sendMessage error", e)
Log.e("ChatRepo", "Add reaction failed", e)
throw e
}
}
override suspend fun addReaction(messageId: String, emoji: String) {
api.addReaction(messageId, emoji)
}
override suspend fun sendTypingStatus(chatId: String) {
api.sendTypingStatus(chatId)
try {
api.sendTypingStatus(chatId)
} catch (e: Exception) {
// Игнорируем ошибки typing status
}
}
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
try {
android.util.Log.d("ChatRepoImpl", "markMessagesAsRead CALLED FOR $chatId")
hubClient.readMessages(ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId))
hubClient.readMessages(chats.data.remote.signalr.ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId))
} catch (e: Exception) {
android.util.Log.e("ChatRepo", "Error marking messages as read", e)
Log.e("ChatRepo", "Error marking messages as read", e)
}
}
override suspend fun saveMessage(message: Message) {
// Кэш отключён
}
override suspend fun deleteLocalMessage(messageId: String) {
// Локальное удаление не поддерживается без кэша
}
override suspend fun uploadMedia(file: java.io.File): String {
val mimeType = when (file.extension.lowercase()) {
"jpg", "jpeg" -> "image/jpeg"
@@ -180,10 +335,10 @@ class ChatRepositoryImpl @Inject constructor(
val request = SendMessageRequest(content = content)
val currentUserId = tokenManager.getUserId() ?: ""
val returnedId = api.editMessage(messageId, request)
return Message(
id = returnedId,
chatId = "",
chatId = "",
senderId = currentUserId,
senderName = "",
content = content,

View File

@@ -0,0 +1,184 @@
package chats.data.workers
import android.content.Context
import android.util.Log
import androidx.hilt.work.HiltWorker
import androidx.work.*
import chats.data.local.dao.ChatDao
import chats.data.local.dao.MessageDao
import chats.domain.model.MessageStatus
import core.security.TokenManager
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import java.net.SocketTimeoutException
import java.util.concurrent.TimeUnit
/**
* Worker для периодической синхронизации чатов и сообщений.
* Запускается при наличии сети для получения новых сообщений с сервера.
*/
@HiltWorker
class ChatSyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val chatDao: ChatDao,
private val messageDao: MessageDao,
private val chatApi: chats.data.remote.api.ChatApi
) : CoroutineWorker(context, params) {
companion object {
const val WORK_TAG = "chat_sync_worker"
const val KEY_CHAT_ID = "chat_id"
const val KEY_CURSOR = "cursor"
/**
* Создает периодический запрос на синхронизацию всех чатов
*/
fun createPeriodicWorkRequest(): PeriodicWorkRequest {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
return PeriodicWorkRequestBuilder<ChatSyncWorker>(
repeatInterval = 15,
repeatIntervalTimeUnit = TimeUnit.MINUTES
)
.setConstraints(constraints)
.addTag(WORK_TAG)
.build()
}
/**
* Создает разовый запрос на синхронизацию конкретного чата
*/
fun createOneTimeWorkRequest(chatId: String? = null, cursor: String? = null): OneTimeWorkRequest {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val inputData = workDataOf(
KEY_CHAT_ID to chatId,
KEY_CURSOR to cursor
)
return OneTimeWorkRequestBuilder<ChatSyncWorker>()
.setConstraints(constraints)
.setInputData(inputData)
.addTag(WORK_TAG)
.build()
}
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
Log.d("ChatSyncWorker", "Starting chat sync")
try {
// 1. Синхронизируем список чатов
syncChats()
// 2. Если указан chatId - синхронизируем сообщения
val chatId = inputData.getString(KEY_CHAT_ID)
if (chatId != null) {
syncMessagesForChat(chatId)
}
Log.i("ChatSyncWorker", "Sync completed successfully")
Result.success()
} catch (e: IOException) {
Log.w("ChatSyncWorker", "Network error during sync: ${e.message}")
Result.retry()
} catch (e: SocketTimeoutException) {
Log.w("ChatSyncWorker", "Timeout during sync: ${e.message}")
Result.retry()
} catch (e: Exception) {
Log.e("ChatSyncWorker", "Error during sync", e)
Result.failure()
}
}
private suspend fun syncChats() {
val remoteChats = chatApi.getChats()
val localChats = mutableListOf<chats.data.local.database.ChatEntity>()
remoteChats.forEach { dto ->
val existingChat = chatDao.getChatByRemoteId(dto.id)
val chatEntity = chats.data.local.database.ChatEntity(
localId = existingChat?.localId ?: java.util.UUID.randomUUID().toString(),
remoteId = dto.id,
type = dto.type,
name = dto.name ?: "",
avatar = dto.avatar,
unreadCount = dto.unreadCount,
lastMessageText = dto.messages.firstOrNull()?.content,
lastMessageTimestamp = dto.messages.firstOrNull()?.createdAt
)
localChats.add(chatEntity)
}
chatDao.insertChats(localChats)
Log.d("ChatSyncWorker", "Synced ${localChats.size} chats")
}
private suspend fun syncMessagesForChat(chatId: String) {
val lastMessage = messageDao.getMessagesByStatus(
chatId = chatId,
statuses = listOf(MessageStatus.SENT, MessageStatus.DELIVERED, MessageStatus.READ)
).maxByOrNull { it.sequenceId }
val cursor = lastMessage?.serverId
val messages = chatApi.getMessages(chatId, cursor = cursor, limit = 50)
if (messages.isNotEmpty()) {
saveMessagesToLocal(messages, chatId)
Log.d("ChatSyncWorker", "Synced ${messages.size} messages for chat $chatId")
}
}
private suspend fun saveMessagesToLocal(
messages: List<chats.data.remote.dto.MessageDto>,
chatId: String
) {
val currentUserId = tokenManager.getUserId() ?: ""
val gson = com.google.gson.Gson()
messages.forEach { dto ->
val existing = messageDao.getMessageByServerId(dto.id)
if (existing == null) {
val entity = chats.data.local.database.MessageEntity(
localId = java.util.UUID.randomUUID().toString(),
serverId = dto.id,
idempotencyKey = dto.id,
chatId = chatId,
senderId = dto.senderId ?: dto.sender?.id ?: currentUserId,
senderName = dto.sender?.displayName ?: dto.sender?.username ?: "",
senderAvatar = dto.sender?.avatarUrl,
content = dto.content,
sequenceId = dto.sequenceId?.toLong() ?: 0L,
createdAt = dto.createdAt ?: java.time.ZonedDateTime.now().toString(),
mediaType = dto.type ?: "TEXT",
mediaJson = gson.toJson(dto.media.map {
chats.domain.model.Media(
id = it.id,
type = it.type,
url = it.url,
filename = it.filename,
size = it.size,
duration = it.duration
)
}),
reactionsJson = gson.toJson(
dto.reactions?.associate { it.emoji to it.count } ?: emptyMap<String, Int>()
),
status = if (dto.senderId == currentUserId) MessageStatus.SENT else MessageStatus.DELIVERED,
createdAtMillis = System.currentTimeMillis(),
updatedAtMillis = System.currentTimeMillis()
)
messageDao.insertMessage(entity)
}
}
}
}

View File

@@ -0,0 +1,150 @@
package chats.data.workers
import android.content.Context
import android.util.Log
import androidx.hilt.work.HiltWorker
import androidx.work.*
import chats.data.local.dao.MessageDao
import chats.data.local.database.MessageEntity
import chats.data.remote.api.ChatApi
import chats.data.remote.api.SendMessageRequest
import chats.domain.model.MessageStatus
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import java.net.SocketTimeoutException
import java.util.concurrent.TimeUnit
/**
* Worker для отправки сообщений с поддержкой офлайн-режима.
* Использует WorkManager для гарантированной доставки при появлении сети.
*/
@HiltWorker
class SendMessageWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val messageDao: MessageDao,
private val chatApi: ChatApi
) : CoroutineWorker(context, params) {
companion object {
const val WORK_TAG = "send_message_worker"
const val KEY_LOCAL_ID = "local_id"
const val MAX_RETRY_COUNT = 5
/**
* Создает запрос на отправку сообщения через WorkManager
*/
fun createWorkRequest(localId: String): OneTimeWorkRequest {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val inputData = workDataOf(KEY_LOCAL_ID to localId)
return OneTimeWorkRequestBuilder<SendMessageWorker>()
.setConstraints(constraints)
.setInputData(inputData)
.addTag(WORK_TAG)
.addTag("$WORK_TAG:$localId")
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
WorkRequest.MIN_BACKOFF_MILLIS,
TimeUnit.MILLISECONDS
)
.build()
}
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val localId = inputData.getString(KEY_LOCAL_ID)
?: return@withContext Result.failure()
Log.d("SendMessageWorker", "Starting work for message: $localId")
try {
// Получаем сообщение из локальной БД
val message = messageDao.getMessageByLocalId(localId)
?: return@withContext Result.failure(
workDataOf("error" to "Message not found: $localId")
)
// Проверяем, не отправлено ли уже
if (message.status == MessageStatus.SENT || message.status == MessageStatus.DELIVERED || message.status == MessageStatus.READ) {
Log.d("SendMessageWorker", "Message already sent: $localId")
return@withContext Result.success()
}
// Проверяем лимит повторных попыток
if (message.retryCount >= MAX_RETRY_COUNT) {
Log.w("SendMessageWorker", "Max retries reached for message: $localId")
messageDao.updateMessageError(
localId = localId,
status = MessageStatus.FAILED,
errorMessage = "Max retry count exceeded",
updatedAtMillis = System.currentTimeMillis()
)
return@withContext Result.failure(
workDataOf("error" to "Max retries reached")
)
}
// Обновляем статус на SENDING
messageDao.updateMessageStatus(
localId = localId,
status = MessageStatus.SENDING,
updatedAtMillis = System.currentTimeMillis()
)
// Выполняем запрос к API с Idempotency-Key
val request = SendMessageRequest(
content = message.content,
type = message.mediaType.lowercase(),
replyToId = message.replyToServerId
)
val serverId = chatApi.sendMessage(
chatId = message.chatId,
request = request,
idempotencyKey = message.idempotencyKey
)
// Успешная отправка - обновляем статус и serverId
messageDao.updateMessageWithServerId(
localId = localId,
serverId = serverId,
status = MessageStatus.SENT,
updatedAtMillis = System.currentTimeMillis()
)
Log.i("SendMessageWorker", "Message sent successfully: local=$localId, server=$serverId")
Result.success()
} catch (e: IOException) {
// Сетевая ошибка - повторяем позже
Log.w("SendMessageWorker", "Network error for message $localId: ${e.message}")
Result.retry()
} catch (e: SocketTimeoutException) {
Log.w("SendMessageWorker", "Timeout for message $localId: ${e.message}")
Result.retry()
} catch (e: Exception) {
// Остальные ошибки - помечаем как FAILED
Log.e("SendMessageWorker", "Error sending message $localId", e)
try {
messageDao.updateMessageError(
localId = localId,
status = MessageStatus.FAILED,
errorMessage = e.message ?: "Unknown error",
updatedAtMillis = System.currentTimeMillis()
)
} catch (dbError: Exception) {
Log.e("SendMessageWorker", "Failed to update error status", dbError)
}
Result.failure(
workDataOf("error" to (e.message ?: "Unknown error"))
)
}
}
}

View File

@@ -26,13 +26,8 @@ object ChatModule {
@Provides
@Singleton
fun provideChatRepository(
api: ChatApi,
tokenManager: TokenManager,
serverConfig: ServerConfig,
hubClient: chats.data.remote.signalr.ChatHubClient
): ChatRepository {
return ChatRepositoryImpl(api, tokenManager, serverConfig, hubClient)
}
impl: ChatRepositoryImpl
): ChatRepository = impl
@Provides
@Singleton

View File

@@ -0,0 +1,50 @@
package chats.di
import android.content.Context
import androidx.room.Room
import androidx.work.WorkManager
import chats.data.local.dao.ChatDao
import chats.data.local.dao.MessageDao
import chats.data.local.dao.UserProfileDao
import chats.data.local.database.AppDatabase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Hilt модуль для предоставления зависимостей локальной базы данных и WorkManager
*/
@Module
@InstallIn(SingletonComponent::class)
object LocalDatabaseModule {
@Provides
@Singleton
fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
AppDatabase.DATABASE_NAME
)
.fallbackToDestructiveMigration()
.build()
}
@Provides
fun provideMessageDao(database: AppDatabase): MessageDao = database.messageDao()
@Provides
fun provideChatDao(database: AppDatabase): ChatDao = database.chatDao()
@Provides
fun provideUserProfileDao(database: AppDatabase): UserProfileDao = database.userProfileDao()
@Provides
@Singleton
fun provideWorkManager(@ApplicationContext context: Context): WorkManager {
return WorkManager.getInstance(context)
}
}

View File

@@ -2,6 +2,8 @@ package chats.domain.model
import androidx.compose.runtime.Immutable
import chats.domain.model.MessageStatus
@Immutable
data class Message(
val id: String,
@@ -19,7 +21,8 @@ data class Message(
val isPinned: Boolean = false,
val isForwarded: Boolean = false,
val forwardedFromName: String? = null,
val replyTo: Message? = null
val replyTo: Message? = null,
val status: MessageStatus = MessageStatus.SENT // Новое поле для офлайн-статуса
)
@Immutable

View File

@@ -0,0 +1,30 @@
package chats.domain.model
/**
* Статусы сообщения для отображения в UI и синхронизации
*/
enum class MessageStatus {
/** Сообщение создано локально, ожидает отправки */
PENDING,
/** Начата отправка на сервер */
SENDING,
/** Сообщение успешно отправлено на сервер */
SENT,
/** Сообщение доставлено получателю */
DELIVERED,
/** Сообщение прочитано получателем */
READ,
/** Ошибка отправки */
FAILED,
/** Удалено локально */
DELETED,
/** Неизвестный статус */
UNKNOWN
}

View File

@@ -3,10 +3,19 @@ package chats.domain.repository
import chats.domain.model.Chat
import chats.domain.model.Message
import kotlinx.coroutines.flow.Flow
interface ChatRepository {
// Чаты
fun getChatsFlow(): Flow<List<Chat>>
suspend fun getChats(): List<Chat>
fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>>
suspend fun syncChats()
// Сообщения - Single Source of Truth через Flow
fun getMessagesFlow(chatId: String): Flow<List<Message>>
suspend fun getMessages(chatId: String, cursor: String? = null, pivot: Long? = null, limit: Int? = null): List<Message>
// Отправка сообщений с поддержкой офлайн
suspend fun sendMessage(
chatId: String,
content: String?,
@@ -15,15 +24,27 @@ interface ChatRepository {
replyToId: String? = null,
forwardedFromId: String? = null
): Message
suspend fun retryFailedMessage(localId: String)
suspend fun syncMessagesForChat(chatId: String)
suspend fun schedulePeriodicSync()
// Реакции
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 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

View File

@@ -2,6 +2,7 @@ package chats.presentation.chat_detail
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import android.util.Log
import chats.data.remote.signalr.ChatEvent
import chats.data.remote.signalr.ChatHubClient
import chats.data.repository.toDomain
@@ -20,6 +21,7 @@ import core.utils.copyUriToFile
import core.utils.ImageUtils
import chats.data.remote.api.KlipyGifDto
import chats.domain.model.MessageStatus
data class ChatDetailState(
val messages: List<Message> = emptyList(),
@@ -47,7 +49,8 @@ data class ChatDetailState(
val forwardingMessages: List<Message> = emptyList(),
val availableChatsToForward: List<chats.domain.model.Chat> = emptyList(),
val selectedMessageIds: Set<String> = emptySet(),
val pinnedMessages: List<Message> = emptyList()
val pinnedMessages: List<Message> = emptyList(),
val isOffline: Boolean = false
)
@HiltViewModel
@@ -93,6 +96,8 @@ class ChatDetailViewModel @Inject constructor(
return tokenManager.getUserId() ?: ""
}
private var messagesFlowJob: Job? = null
fun setChatId(chatId: String) {
if (currentChatId == chatId) return
currentChatId = chatId
@@ -115,30 +120,43 @@ class ChatDetailViewModel @Inject constructor(
loadChatInfo(chatId)
observeSignalREvents(chatId)
// Initial sync from network
refreshMessages(chatId)
// Подписываемся на Flow из Room (Single Source of Truth)
observeMessagesFromLocal(chatId)
// Запускаем синхронизацию с сервером
viewModelScope.launch {
repository.syncMessagesForChat(chatId)
}
}
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
) }
/**
* Подписка на локальные сообщения из Room (Single Source of Truth)
*/
private fun observeMessagesFromLocal(chatId: String) {
messagesFlowJob?.cancel()
messagesFlowJob = viewModelScope.launch {
repository.getMessagesFlow(chatId)
.catch { e ->
Log.e("ChatDetailVM", "Error observing messages", e)
_state.update { it.copy(isLoading = false, error = e.message) }
}
.collect { messages ->
val sortedMessages = messages.sortedByDescending { it.sequenceId }
_state.update { currentState ->
currentState.copy(
messages = sortedMessages,
isLoading = false,
initialScrollIndex = if (currentState.initialScrollIndex == null) 0 else currentState.initialScrollIndex
)
}
}
}
}
fun onInitialScrollDone() {
_state.update { it.copy(initialScrollIndex = -1) }
}
fun refreshMessages(chatId: String) {
viewModelScope.launch {
val messages = repository.getMessages(chatId)
updateMessages(messages)
}
}
fun loadMoreMessages() {
val chatId = currentChatId ?: return
if (_state.value.isLoading || _state.value.isLoadingMore) return
@@ -488,45 +506,24 @@ class ChatDetailViewModel @Inject constructor(
return
}
// Clear input immediately to avoid double clicks and ensure UI experience
_state.update { it.copy(inputText = "", replyingMessage = null) }
// Clear input immediately для отзывчивого UI
_state.update { it.copy(inputText = "", replyingMessage = null, pendingAttachments = emptyList()) }
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)
pending.isEmpty() -> "text"
pending.any { it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif") } -> "image"
pending.any { it.extension.lowercase() in listOf("mp4", "mov", "webm") } -> "video"
pending.any { it.extension.lowercase() in listOf("mp3", "m4a", "wav") } -> "audio"
else -> "text"
}
viewModelScope.launch {
try {
// Upload attachments if any
val attachmentRequests = if (_state.value.pendingAttachments.isNotEmpty()) {
// Upload attachments если есть
val attachmentRequests = if (pending.isNotEmpty()) {
_state.update { it.copy(isUploading = true) }
val requests = _state.value.pendingAttachments.map { file ->
val requests = pending.map { file ->
val url = repository.uploadMedia(file)
chats.data.remote.api.AttachmentRequest(
type = when {
@@ -540,31 +537,46 @@ class ChatDetailViewModel @Inject constructor(
fileSize = file.length()
)
}
_state.update { it.copy(isUploading = false, pendingAttachments = emptyList()) }
_state.update { it.copy(isUploading = false) }
requests
} else {
null
}
val sentMessage = repository.sendMessage(
// Отправляем сообщение - теперь оно создается локально и ставится в очередь
repository.sendMessage(
chatId = chatId,
content = if (text.isBlank()) null else text,
type = if (attachmentRequests != null) "media" else "text",
type = mediaType,
attachments = attachmentRequests,
replyToId = replyToId
)
repository.deleteLocalMessage(tempId)
repository.saveMessage(sentMessage)
// Clear attachments on success
_state.update { it.copy(pendingAttachments = emptyList()) }
// Сообщение автоматически появится в UI через Flow из Room
} catch (e: Exception) {
repository.deleteLocalMessage(tempId)
_state.update { it.copy(error = e.localizedMessage, isUploading = false) }
onFail(text)
}
}
}
/**
* Повторная отправка неудачного сообщения
*/
fun retryMessage(messageId: String) {
viewModelScope.launch {
repository.retryFailedMessage(messageId)
}
}
/**
* Удаление локального сообщения (отмена отправки)
*/
fun cancelMessage(messageId: String) {
viewModelScope.launch {
repository.deleteLocalMessage(messageId)
}
}
fun addReaction(messageId: String, emoji: String) {
val chatId = currentChatId ?: return
@@ -581,7 +593,7 @@ class ChatDetailViewModel @Inject constructor(
}
val replyToId = _state.value.replyingMessage?.id
_state.update { it.copy(replyingMessage = null) }
val tempId = "temp_voice_${System.currentTimeMillis()}"
val userId = getCurrentUserId()

View File

@@ -51,10 +51,51 @@ class ChatListViewModel @Inject constructor(
signalrClient.connect(baseUrl.removeSuffix("/api/"), token)
}
loadChats()
// Single Source of Truth - подписываемся на Flow из Room
observeChatsFromLocal()
// Запускаем периодическую синхронизацию
viewModelScope.launch {
repository.schedulePeriodicSync()
}
observeSignalREvents()
}
/**
* Подписка на локальные чаты из Room (Single Source of Truth)
*/
private fun observeChatsFromLocal() {
repository.getChatsFlow()
.onEach { chats ->
_state.update { currentState ->
currentState.copy(
chats = sortChats(chats),
isLoading = false
)
}
}
.catch { e ->
android.util.Log.e("ChatListVM", "Error observing chats", e)
_state.update { it.copy(isLoading = false, error = e.message) }
}
.launchIn(viewModelScope)
}
/**
* Принудительная синхронизация чатов с сервером
*/
fun loadChats() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
try {
repository.syncChats()
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.message) }
}
}
}
private fun updatePushToken() {
com.google.firebase.messaging.FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (task.isSuccessful) {
@@ -70,21 +111,8 @@ class ChatListViewModel @Inject constructor(
}
}
private fun getCurrentUserId(): String = tokenManager.getUserId() ?: ""
fun loadChats() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
try {
val chats = repository.getChats()
_state.update { it.copy(chats = sortChats(chats), isLoading = false) }
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.message) }
}
}
}
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)
@@ -121,7 +149,6 @@ class ChatListViewModel @Inject constructor(
.launchIn(viewModelScope)
}
private fun updateChatsWithNewMessage(event: ChatEvent.NewMessage) {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val currentUserId = getCurrentUserId()

View File

@@ -29,6 +29,7 @@ import androidx.compose.ui.window.Popup
import chats.domain.model.Message
import chats.domain.model.MediaType
import chats.domain.model.Media
import chats.domain.model.MessageStatus
import coil.compose.AsyncImage
import core.presentation.components.AppVideoPlayer
import core.presentation.components.AppAudioPlayer
@@ -483,12 +484,44 @@ fun MessageBubble(
}
if (isCurrentUser) {
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = if (message.isRead) Icons.Default.DoneAll else Icons.Default.Done,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = contentColor.copy(alpha = 0.6f)
)
// Отображение статуса отправки сообщения
when (message.status) {
MessageStatus.PENDING -> {
// Часы - ожидает отправки
Icon(
imageVector = Icons.Default.Schedule,
contentDescription = "Pending",
modifier = Modifier.size(12.dp),
tint = contentColor.copy(alpha = 0.6f)
)
}
MessageStatus.SENDING -> {
// Круговой индикатор загрузки
CircularProgressIndicator(
modifier = Modifier.size(10.dp),
strokeWidth = 1.5.dp,
color = contentColor.copy(alpha = 0.6f)
)
}
MessageStatus.FAILED -> {
// Красный крестик - ошибка отправки
Icon(
imageVector = Icons.Default.Error,
contentDescription = "Failed",
modifier = Modifier.size(12.dp),
tint = Color(0xFFFF5252)
)
}
else -> {
// Одна галочка для SENT, две для DELIVERED/READ
Icon(
imageVector = if (message.status == MessageStatus.READ) Icons.Default.DoneAll else Icons.Default.Done,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = if (message.status == MessageStatus.READ) Color(0xFF4CAF50) else contentColor.copy(alpha = 0.6f)
)
}
}
}
}
} // End of bubble Column

View File

@@ -43,6 +43,14 @@ class TokenManager @Inject constructor(context: Context) {
return prefs.getString("user_id", null)
}
fun getUsername(): String? {
return prefs.getString("username", null)
}
fun saveUsername(username: String) {
prefs.edit().putString("username", username).apply()
}
fun deleteToken() {
prefs.edit().remove("jwt_token").remove("user_id").remove("refresh_token").apply()
}