44 Commits
Author SHA1 Message Date
Халимов Рустам 2c3d691f27 Удаление чата из кэша при удалении на бэке 2026-04-20 22:28:53 +03:00
Халимов Рустам 0dd06ce89d Уведомление об удалении чата 2026-04-20 22:28:21 +03:00
Халимов Рустам 962323814c Исправление работы доставки, прочтения сообщений 2026-04-20 22:17:29 +03:00
Халимов Рустам fc1cf1fd6e Merge branch 'main' into android 2026-04-20 21:56:42 +03:00
Халимов Рустам 83ed328dd5 Исправлен механизм прочтения сообщений 2026-04-20 21:44:39 +03:00
Халимов Рустам c592197017 Восстановил поломанную отправку и печать 2026-04-20 12:06:43 +03:00
Халимов Рустам b65c8f3633 Восстановление сети 2026-04-20 11:14:45 +03:00
Халимов Рустам 0f593e52e0 Восстановление подключения, бэк 2026-04-20 10:46:25 +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
Халимов Рустам d9462069e2 Миграции 2026-04-19 22:22:43 +03:00
Халимов Рустам 9e715fe3ab Токены, правильная работа 2026-04-19 22:13:20 +03:00
Халимов Рустам 8c8ef55b58 Merge branch 'main' into android 2026-04-19 21:24:40 +03:00
Халимов Рустам 70acad56fb Удаление сообщений 2026-04-19 21:05:56 +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
201 changed files with 16509 additions and 801 deletions
@@ -4,6 +4,7 @@ public interface IJwtTokenProvider
{ {
string GenerateAccessToken(Guid userId, string username); string GenerateAccessToken(Guid userId, string username);
string GenerateRefreshToken(); string GenerateRefreshToken();
DateTime GetRefreshTokenExpiry();
string Generate(Guid userId, string username, string displayName, string? avatar); string Generate(Guid userId, string username, string displayName, string? avatar);
string Generate(Domain.UserContract user); string Generate(Domain.UserContract user);
} }
@@ -7,5 +7,7 @@ public static class AuthErrors
public static Error IdentityInvalidCredentials => new("Auth.InvalidCredentials", "Invalid credentials"); public static Error IdentityInvalidCredentials => new("Auth.InvalidCredentials", "Invalid credentials");
public static Error IdentityRegistrationDisabled => new("Auth.RegistrationDisabled", "Registration is disabled"); public static Error IdentityRegistrationDisabled => new("Auth.RegistrationDisabled", "Registration is disabled");
public static Error IdentityUsernameNotUnique => new("Auth.UsernameNotUnique", "Username is already taken"); public static Error IdentityUsernameNotUnique => new("Auth.UsernameNotUnique", "Username is already taken");
public static Error IdentityRegistrationFailed => new("Auth.RegistrationFailed", "Failed to register user");
public static Error RefreshTokenExpired => new("Auth.RefreshTokenExpired", "Refresh token has expired. Please login again.");
public static Error UserNotFound => new("Auth.UserNotFound", "User not found"); public static Error UserNotFound => new("Auth.UserNotFound", "User not found");
} }
@@ -19,4 +19,12 @@ public class UserContract
public bool IsExternal { get; set; } public bool IsExternal { get; set; }
public string? Domain { get; set; } public string? Domain { get; set; }
public DateTime? LastSeen { get; set; } public DateTime? LastSeen { get; set; }
public string? RefreshToken { get; set; }
public DateTime? RefreshTokenExpiry { get; set; }
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
{
RefreshToken = refreshToken;
RefreshTokenExpiry = expiry;
}
} }
@@ -13,6 +13,7 @@ public interface IMessageRepository
Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, long? sequenceId, int limit, CancellationToken cancellationToken); Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, long? sequenceId, int limit, CancellationToken cancellationToken);
Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken); Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken);
Task<List<Message>> GetChatMessagesAfterAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken);
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken); Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
Task UpdateAsync(Message message, CancellationToken cancellationToken); Task UpdateAsync(Message message, CancellationToken cancellationToken);
@@ -19,13 +19,16 @@ public abstract class Message : AggregateRoot<Guid>
public bool IsEdited => HasState(MessageState.IsEdited); public bool IsEdited => HasState(MessageState.IsEdited);
public bool IsDeleted => HasState(MessageState.IsDeleted); public bool IsDeleted => HasState(MessageState.IsDeleted);
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)
: base(id) : base(id)
{ {
ChatId = chatId; ChatId = chatId;
@@ -42,15 +45,25 @@ public abstract class Message : AggregateRoot<Guid>
public bool IsDeletedForUser(Guid userId) => _deletedFor.Exists(d => d.UserId == userId); public bool IsDeletedForUser(Guid userId) => _deletedFor.Exists(d => d.UserId == userId);
public virtual void Delete() => AddState(MessageState.IsDeleted); public virtual void Delete() => AddState(MessageState.IsDeleted);
public virtual void Edit(string newContent) public virtual void Edit(string newContent)
{ {
Content = newContent; Content = newContent;
AddState(MessageState.IsEdited); AddState(MessageState.IsEdited);
} }
public void DeleteForUser(Guid userId) public void DeleteForUser(Guid userId)
{ {
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);
} }
+3 -2
View File
@@ -13,7 +13,8 @@
"Secret": "knot_super_secret_key_1234567890_knot", "Secret": "knot_super_secret_key_1234567890_knot",
"Issuer": "Knot", "Issuer": "Knot",
"Audience": "KnotUsers", "Audience": "KnotUsers",
"ExpiryInMinutes": 1440 "ExpiryInMinutes": 1440,
"RefreshExpiryInDays": 30
}, },
"KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot" "KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot"
} }
@@ -5,5 +5,8 @@ namespace Knot.Modules.Auth.Application.Abstractions;
public interface IJwtTokenProvider public interface IJwtTokenProvider
{ {
string Generate(User user); string Generate(User user);
string Generate(Guid userId, string username, string displayName, string? avatar);
string GenerateRefreshToken();
DateTime GetRefreshTokenExpiry();
} }
@@ -1,4 +1,6 @@
using Knot.Contracts.Auth.Application.Auth.DTOs; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs; using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
@@ -9,10 +11,12 @@ public sealed record GetMeQuery(Guid UserId) : IQuery<AuthResponseDto>;
internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponseDto> internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponseDto>
{ {
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly IJwtTokenProvider _tokenProvider;
public GetMeQueryHandler(IUserRepository userRepository) public GetMeQueryHandler(IUserRepository userRepository, IJwtTokenProvider tokenProvider)
{ {
_userRepository = userRepository; _userRepository = userRepository;
_tokenProvider = tokenProvider;
} }
public async Task<Result<AuthResponseDto>> Handle(GetMeQuery request, CancellationToken cancellationToken) public async Task<Result<AuthResponseDto>> Handle(GetMeQuery request, CancellationToken cancellationToken)
@@ -23,9 +27,18 @@ internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponse
return Result.Failure<AuthResponseDto>(AuthErrors.UserNotFound); return Result.Failure<AuthResponseDto>(AuthErrors.UserNotFound);
} }
// Check if access token needs to be refreshed (less than 1 hour remaining)
string? newAccessToken = null;
// We can't directly check the current token's expiry here, but we can
// always issue a new token if the user is authenticated
// For now, let's issue a new token on every request (simplified approach)
// A better approach would be to parse the incoming token and check expiry
newAccessToken = _tokenProvider.Generate(user);
var response = new AuthResponseDto var response = new AuthResponseDto
{ {
AccessToken = string.Empty, AccessToken = newAccessToken,
RefreshToken = string.Empty, RefreshToken = string.Empty,
UserId = user.Id, UserId = user.Id,
Username = user.Username, Username = user.Username,
@@ -5,9 +5,6 @@ using Knot.Shared.Kernel;
namespace Knot.Modules.Auth.Application.Users.Login; namespace Knot.Modules.Auth.Application.Users.Login;
/// <summary>
/// . AuthResponseDto.
/// </summary>
public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>; public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>;
public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto> public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto>
@@ -31,14 +28,21 @@ public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand,
} }
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar); string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
string refreshToken = _tokenProvider.GenerateRefreshToken();
DateTime refreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
// Save refresh token to database
user.SetRefreshToken(refreshToken, refreshExpiry);
await _userRepository.UpdateAsync(user, cancellationToken);
return Result.Success(new AuthResponseDto return Result.Success(new AuthResponseDto
{ {
AccessToken = token, AccessToken = token,
RefreshToken = string.Empty, RefreshToken = refreshToken,
UserId = user.Id, UserId = user.Id,
Username = user.Username, Username = user.Username,
DisplayName = user.DisplayName DisplayName = user.DisplayName
}); });
} }
} }
@@ -0,0 +1,7 @@
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
public record RefreshTokenCommand(string RefreshToken) : ICommand<AuthResponseDto>;
@@ -0,0 +1,65 @@
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Contracts.Auth.Domain;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
internal sealed class RefreshTokenCommandHandler : ICommandHandler<RefreshTokenCommand, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
private readonly IJwtTokenProvider _tokenProvider;
public RefreshTokenCommandHandler(
IUserRepository userRepository,
IJwtTokenProvider tokenProvider)
{
_userRepository = userRepository;
_tokenProvider = tokenProvider;
}
public async Task<Result<AuthResponseDto>> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.RefreshToken))
{
return Result.Failure<AuthResponseDto>(
new Error("Auth.InvalidRefreshToken", "Refresh token is required"));
}
var user = await _userRepository.GetByRefreshTokenAsync(request.RefreshToken, cancellationToken);
if (user == null)
{
return Result.Failure<AuthResponseDto>(
new Error("Auth.InvalidRefreshToken", "Invalid or expired refresh token"));
}
// Check if refresh token has expired
if (user.RefreshTokenExpiry.HasValue && user.RefreshTokenExpiry.Value < DateTime.UtcNow)
{
// Clear expired refresh token
user.SetRefreshToken(null, null);
await _userRepository.UpdateAsync(user, cancellationToken);
return Result.Failure<AuthResponseDto>(
new Error("Auth.RefreshTokenExpired", "Refresh token has expired. Please login again."));
}
var newAccessToken = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
var newRefreshToken = _tokenProvider.GenerateRefreshToken();
var newRefreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
user.SetRefreshToken(newRefreshToken, newRefreshExpiry);
await _userRepository.UpdateAsync(user, cancellationToken);
return Result.Success(new AuthResponseDto
{
AccessToken = newAccessToken,
RefreshToken = newRefreshToken,
UserId = user.Id,
Username = user.Username,
DisplayName = user.DisplayName,
Avatar = user.Avatar
});
}
}
@@ -1,5 +1,4 @@
using BCrypt.Net; using BCrypt.Net;
using BCrypt.Net;
using Knot.Contracts.Auth.Application.Abstractions; using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs; using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Contracts.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
@@ -9,9 +8,6 @@ using Knot.Shared.Kernel;
namespace Knot.Modules.Auth.Application.Users.Register; namespace Knot.Modules.Auth.Application.Users.Register;
/// <summary>
/// .
/// </summary>
public sealed record RegisterUserCommand( public sealed record RegisterUserCommand(
string Username, string Username,
string Password, string Password,
@@ -19,9 +15,6 @@ public sealed record RegisterUserCommand(
string? Email, string? Email,
string? Bio) : ICommand<AuthResponseDto>; string? Bio) : ICommand<AuthResponseDto>;
/// <summary>
/// .
/// </summary>
internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto> internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto>
{ {
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
@@ -48,16 +41,13 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationDisabled); return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationDisabled);
} }
// 1. username
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken)) if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
{ {
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityUsernameNotUnique); return Result.Failure<AuthResponseDto>(AuthErrors.IdentityUsernameNotUnique);
} }
// 2.
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password); string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
// 3.
var user = User.Create( var user = User.Create(
request.Username, request.Username,
passwordHash, passwordHash,
@@ -65,21 +55,33 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
request.Email, request.Email,
request.Bio); request.Bio);
// 4. - Domain User var repoImpl = _userRepository as Infrastructure.Persistence.UserRepository;
var repoWithDomainUserAdd = _userRepository as Infrastructure.Persistence.UserRepository; repoImpl?.Add(user);
repoWithDomainUserAdd?.Add(user);
await _unitOfWork.SaveChangesAsync(cancellationToken); await _unitOfWork.SaveChangesAsync(cancellationToken);
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar); // Get the saved user as contract
var userContract = await _userRepository.GetByUsernameAsync(request.Username, cancellationToken);
if (userContract == null)
{
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationFailed);
}
string token = _tokenProvider.Generate(userContract.Id, userContract.Username, userContract.DisplayName, userContract.Avatar);
string refreshToken = _tokenProvider.GenerateRefreshToken();
DateTime refreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
userContract.SetRefreshToken(refreshToken, refreshExpiry);
await _userRepository.UpdateAsync(userContract, cancellationToken);
return Result.Success(new AuthResponseDto return Result.Success(new AuthResponseDto
{ {
AccessToken = token, AccessToken = token,
RefreshToken = string.Empty, RefreshToken = refreshToken,
UserId = user.Id, UserId = userContract.Id,
Username = user.Username, Username = userContract.Username,
DisplayName = user.DisplayName DisplayName = userContract.DisplayName
}); });
} }
} }
+8 -2
View File
@@ -26,6 +26,7 @@ public sealed class User : AggregateRoot<Guid>
public bool IsBanned { get; private set; } public bool IsBanned { get; private set; }
public string? PhoneNumber { get; private set; } public string? PhoneNumber { get; private set; }
public string? RefreshToken { get; private set; } public string? RefreshToken { get; private set; }
public DateTime? RefreshTokenExpiry { get; private set; }
public DateTime? BannedUntil { get; private set; } public DateTime? BannedUntil { get; private set; }
public void Ban() { public void Ban() {
@@ -48,9 +49,10 @@ public sealed class User : AggregateRoot<Guid>
PhoneNumber = phoneNumber; PhoneNumber = phoneNumber;
} }
public void SetRefreshToken(string? refreshToken) public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
{ {
RefreshToken = refreshToken; RefreshToken = refreshToken;
RefreshTokenExpiry = expiry;
} }
public void SetBannedUntil(DateTime? bannedUntil) public void SetBannedUntil(DateTime? bannedUntil)
@@ -79,6 +81,8 @@ public sealed class User : AggregateRoot<Guid>
BannedUntil = contract.BannedUntil; BannedUntil = contract.BannedUntil;
SetOnline(contract.IsOnline, contract.LastSeen); SetOnline(contract.IsOnline, contract.LastSeen);
UserDomain = contract.Domain; UserDomain = contract.Domain;
RefreshToken = contract.RefreshToken;
RefreshTokenExpiry = contract.RefreshTokenExpiry;
} }
private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null) private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null)
@@ -181,7 +185,9 @@ public sealed class User : AggregateRoot<Guid>
IsOnline = IsOnline, IsOnline = IsOnline,
IsExternal = IsExternal, IsExternal = IsExternal,
Domain = _domain, Domain = _domain,
LastSeen = LastSeen LastSeen = LastSeen,
RefreshToken = RefreshToken,
RefreshTokenExpiry = RefreshTokenExpiry
}; };
} }
} }
@@ -50,6 +50,12 @@ internal sealed class JwtTokenProvider : IJwtTokenProvider
return Convert.ToBase64String(randomBytes); return Convert.ToBase64String(randomBytes);
} }
public DateTime GetRefreshTokenExpiry()
{
var expiryInDays = int.Parse(_configuration["Jwt:RefreshExpiryInDays"] ?? "30");
return DateTime.UtcNow.AddDays(expiryInDays);
}
public string Generate(Guid userId, string username, string displayName, string? avatar) public string Generate(Guid userId, string username, string displayName, string? avatar)
{ {
var claims = new Claim[] var claims = new Claim[]
@@ -0,0 +1,100 @@
using System;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Modules.Auth.Migrations
{
/// <inheritdoc />
[DbContext(typeof(AuthDbContext))]
[Migration("20270419220000_AddRefreshTokenExpiry")]
partial class AddRefreshTokenExpiry
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0-rc.1.25451.105")
.HasAnnotation("Relational:DefaultSchema", "identity");
modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime?>("BannedUntil")
.HasColumnType("timestamp with time zone");
b.Property<string>("Bio")
.HasColumnType("text");
b.Property<DateTime?>("Birthday")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.HasColumnType("text");
b.Property<string>("Domain")
.HasColumnType("text");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("HideStatus")
.HasColumnType("boolean");
b.Property<bool>("HideStoryViews")
.HasColumnType("boolean");
b.Property<bool>("IsBanned")
.HasColumnType("boolean");
b.Property<bool>("IsExternal")
.HasColumnType("boolean");
b.Property<bool>("IsOnline")
.HasColumnType("boolean");
b.Property<DateTime?>("LastSeen")
.HasColumnType("timestamp with time zone");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<string>("RefreshToken")
.HasColumnType("text");
b.Property<DateTime?>("RefreshTokenExpiry")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users", "identity");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Auth.Migrations
{
/// <inheritdoc />
public partial class AddRefreshTokenExpiry : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "RefreshTokenExpiry",
schema: "identity",
table: "Users",
type: "timestamp with time zone",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "RefreshTokenExpiry",
schema: "identity",
table: "Users");
}
}
}
@@ -1,12 +1,13 @@
using Knot.Shared.Kernel;
using Knot.Modules.Auth.Application.Users.Login;
using Knot.Modules.Auth.Application.Users.Register;
using Knot.Modules.Auth.Application.Users.GetMe; using Knot.Modules.Auth.Application.Users.GetMe;
using Knot.Modules.Auth.Application.Users.Login;
using Knot.Modules.Auth.Application.Users.RefreshToken;
using Knot.Modules.Auth.Application.Users.Register;
using Knot.Shared.Kernel;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
namespace Knot.Modules.Auth.Presentation.Endpoints; namespace Knot.Modules.Auth.Presentation.Endpoints;
@@ -28,6 +29,12 @@ public static class AuthEndpoints
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized(); return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
}); });
group.MapPost("refresh", async ([FromBody] RefreshTokenCommand command, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
});
group.MapGet("me", async (ISender sender, IUserContext userContext, CancellationToken ct) => group.MapGet("me", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
{ {
var result = await sender.Send(new GetMeQuery(userContext.UserId), ct); var result = await sender.Send(new GetMeQuery(userContext.UserId), ct);
@@ -1,13 +1,14 @@
using System; using System;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Application.Abstractions; using Knot.Contracts.Conversations.Application.Abstractions;
using MediatR; using Knot.Contracts.Conversations.Domain;
using System.Linq; using Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Contracts.Messaging.Application.Abstractions; using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage; using Knot.Shared.Kernel.Storage;
using MediatR;
using Microsoft.AspNetCore.SignalR;
namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete; namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete;
@@ -19,17 +20,21 @@ 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)
@@ -58,6 +63,14 @@ 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);
@@ -67,7 +80,8 @@ 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);
@@ -32,7 +32,8 @@ public record MessageDetailDto(
bool? PollIsMultipleChoice = null, bool? PollIsMultipleChoice = null,
bool? PollIsAnonymous = null, bool? PollIsAnonymous = null,
bool? PollIsClosed = null, bool? PollIsClosed = null,
List<Guid>? UserVotedOptionIds = null List<Guid>? UserVotedOptionIds = null,
bool IsDeletedForUser = false
); );
public record ReplyToMessageDto( public record ReplyToMessageDto(
@@ -42,10 +42,16 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
if (request.DeleteForAll) if (request.DeleteForAll)
{ {
// Only message sender can delete for everyone
if (message.SenderId == request.UserId) if (message.SenderId == request.UserId)
{ {
message.Delete(); message.Delete();
} }
else
{
// If not the sender, just delete for current user
message.DeleteForUser(request.UserId);
}
} }
else else
{ {
@@ -55,24 +61,13 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
await _messageRepository.UpdateAsync(message, cancellationToken); await _messageRepository.UpdateAsync(message, cancellationToken);
} }
if (request.DeleteForAll) // Notify all clients in the chat about the deletion
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
{ {
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new chatId = request.ChatId,
{ messageIds = request.MessageIds,
chatId = request.ChatId, deleteForAll = request.DeleteForAll
messageIds = request.MessageIds, });
deleteForAll = true
});
}
else
{
await _hubContext.Clients.User(request.UserId.ToString()).SendAsync("messages_deleted", new
{
chatId = request.ChatId,
messageIds = request.MessageIds,
deleteForAll = false
});
}
return global::Knot.Shared.Kernel.Result.Success(); return global::Knot.Shared.Kernel.Result.Success();
} }
@@ -3,17 +3,17 @@ 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.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.Conversations.Application.Abstractions;
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;
namespace Knot.Modules.Conversations.Application.Messages.GetMessages; namespace Knot.Modules.Conversations.Application.Messages.GetMessages;
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor, long? Pivot = null, int? Limit = null) : IQuery<List<MessageDetailDto>>; public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor, long? Pivot = null, long? AfterSequenceId = null, int? Limit = null) : IQuery<List<MessageDetailDto>>;
internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>> internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>>
{ {
@@ -41,7 +41,12 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
List<Message> messages; List<Message> messages;
int queryLimit = request.Limit ?? ChatConstants.DefaultMessageQueryLimit; int queryLimit = request.Limit ?? ChatConstants.DefaultMessageQueryLimit;
if (request.Pivot.HasValue) if (request.AfterSequenceId.HasValue)
{
// Получаем только сообщения ПОСЛЕ указанного sequenceId (для синхронизации)
messages = await _messageRepository.GetChatMessagesAfterAsync(request.ChatId, request.AfterSequenceId.Value, queryLimit, cancellationToken);
}
else if (request.Pivot.HasValue)
{ {
messages = await _messageRepository.GetChatMessagesAroundAsync(request.ChatId, request.Pivot.Value, queryLimit, cancellationToken); messages = await _messageRepository.GetChatMessagesAroundAsync(request.ChatId, request.Pivot.Value, queryLimit, cancellationToken);
} }
@@ -114,7 +119,8 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
senders.TryGetValue(message.SenderId, out var sender); senders.TryGetValue(message.SenderId, out var sender);
reactionsByMessage.TryGetValue(message.Id, out var reactions); reactionsByMessage.TryGetValue(message.Id, out var reactions);
Message? replyMsg = null; Message? replyMsg = null;
if (message.ReplyToId.HasValue) if (message.ReplyToId.HasValue)
{ {
@@ -141,7 +147,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
replyMsg is MediaMessage mm ? mm.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() : new List<MediaDto>(), replyMsg is MediaMessage mm ? mm.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() : new List<MediaDto>(),
replySender != null ? new MessageSenderDto(replySender.Id, replySender.Username, replySender.DisplayName, replySender.Avatar) : null replySender != null ? new MessageSenderDto(replySender.Id, replySender.Username, replySender.DisplayName, replySender.Avatar) : null
) : null, ) : null,
message is TextMessage tm ? tm.Quote : null, (message as TextMessage)?.Quote,
message.IsEdited, message.IsEdited,
message.IsDeleted, message.IsDeleted,
message.CreatedAt, message.CreatedAt,
@@ -153,20 +159,23 @@ 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),
new List<ReadByDto>(), // ReadBy not implemented in this detailed view yet message.ReadByUsers.Select(id => new ReadByDto(id)).ToList(),
reactions?.Select(r => { reactions?.Select(r =>
{
senders.TryGetValue(r.UserId, out var ru); senders.TryGetValue(r.UserId, out var ru);
return new MessageReactionDto(r.Id, r.Emoji, r.UserId, ru != null ? new MessageSenderDto(ru.Id, ru.Username, ru.DisplayName, ru.Avatar) : null); return new MessageReactionDto(r.Id, r.Emoji, r.UserId, ru != null ? new MessageSenderDto(ru.Id, ru.Username, ru.DisplayName, ru.Avatar) : null);
}).ToList() ?? new List<MessageReactionDto>(), }).ToList() ?? new List<MessageReactionDto>(),
(message as CallMessage)?.CallType, (message as CallMessage)?.CallType,
(message as CallMessage)?.CallStatus, (message as CallMessage)?.CallStatus,
(message as CallMessage)?.Duration, (message as CallMessage)?.Duration,
(message as PollMessage)?.Options.Select(o => { (message as PollMessage)?.Options.Select(o =>
{
var pm = (PollMessage)message; var pm = (PollMessage)message;
var voters = pm.IsAnonymous == false var voters = pm.IsAnonymous == false
? pm.Votes ? pm.Votes
.Where(v => v.OptionId == o.Id) .Where(v => v.OptionId == o.Id)
.Select(v => { .Select(v =>
{
senders.TryGetValue(v.UserId, out var vu); senders.TryGetValue(v.UserId, out var vu);
return vu != null return vu != null
? new MessageSenderDto(vu.Id, vu.Username, vu.DisplayName, vu.Avatar) ? new MessageSenderDto(vu.Id, vu.Username, vu.DisplayName, vu.Avatar)
@@ -174,12 +183,13 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
}) })
.ToList() .ToList()
: null; : null;
return new PollOptionDto(o.Id, o.Text, o.VoteCount, voters, pm.IsAnonymous == false ? pm.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList() : null); return new PollOptionDto(o.Id, o.Text, o.VoteCount, voters, pm.IsAnonymous == false ? pm.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList() : null);
}).ToList(), }).ToList(),
(message as PollMessage)?.IsMultipleChoice, (message as PollMessage)?.IsMultipleChoice,
(message as PollMessage)?.IsAnonymous, (message as PollMessage)?.IsAnonymous,
(message as PollMessage)?.IsClosed, (message as PollMessage)?.IsClosed,
(message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList() (message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList(),
message.IsDeletedForUser(request.UserId)
)); ));
} }
@@ -1,7 +1,8 @@
using MediatR;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Contracts.Conversations.Application.Abstractions; using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Conversations.Application.Messages.Read; namespace Knot.Modules.Conversations.Application.Messages.Read;
@@ -11,11 +12,13 @@ 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) public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork, IMessageRepository messageRepository)
{ {
_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)
@@ -28,6 +31,23 @@ 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,7 +41,8 @@ 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;
@@ -66,7 +67,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>(),
new List<ReadByDto>() message.ReadByUsers.Select(id => new ReadByDto(id)).ToList()
); );
}).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,6 +192,7 @@ 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.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 Microsoft.Extensions.Caching.Memory;
using Knot.Contracts.Auth.Domain; using Microsoft.Extensions.Logging;
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;
@@ -37,7 +37,7 @@ public sealed class ChatHub : Hub
public static int OnlineUsersCount => _userConnections.Count; public static int OnlineUsersCount => _userConnections.Count;
public static bool IsUserOnline(string userId) => _userConnections.ContainsKey(userId); public static bool IsUserOnline(string userId) => _userConnections.ContainsKey(userId);
// userId → CallSession (one user can be in only one call at a time) // userId → CallSession (one user can be in only one call at a time)
private static readonly ConcurrentDictionary<string, CallSession> _activeSessionsByUser = new(); private static readonly ConcurrentDictionary<string, CallSession> _activeSessionsByUser = new();
// chatId → (startTime, callType) // chatId → (startTime, callType)
@@ -53,12 +53,12 @@ public sealed class ChatHub : Hub
private readonly IUserDisplayNameProvider _userProvider; private readonly IUserDisplayNameProvider _userProvider;
public ChatHub( public ChatHub(
ISender sender, ISender sender,
IUserContext userContext, IUserContext userContext,
IChatRepository chatRepository, IChatRepository chatRepository,
IUserRepository userRepository, IUserRepository userRepository,
IMessageRepository messageRepository, IMessageRepository messageRepository,
ILogger<ChatHub> logger, ILogger<ChatHub> logger,
IMemoryCache cache, IMemoryCache cache,
IUserDisplayNameProvider userProvider) IUserDisplayNameProvider userProvider)
{ {
@@ -158,6 +158,7 @@ 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(),
@@ -261,7 +262,7 @@ public sealed class ChatHub : Hub
{ {
var senderInfo = await _userProvider.GetUsersInfoAsync(new[] { message.SenderId }); var senderInfo = await _userProvider.GetUsersInfoAsync(new[] { message.SenderId });
var dto = MessageMapper.MapToDto(message, senderInfo, Enumerable.Empty<MessageReaction>(), Enumerable.Empty<Guid>()); var dto = MessageMapper.MapToDto(message, senderInfo, Enumerable.Empty<MessageReaction>(), Enumerable.Empty<Guid>());
await Clients.Group(request.ChatId.ToString()).SendAsync("message_pinned", new await Clients.Group(request.ChatId.ToString()).SendAsync("message_pinned", new
{ {
chatId = request.ChatId, chatId = request.ChatId,
@@ -276,7 +277,7 @@ public sealed class ChatHub : Hub
{ {
var command = new UnpinMessageCommand(request.MessageId, request.ChatId, _userContext.UserId); var command = new UnpinMessageCommand(request.MessageId, request.ChatId, _userContext.UserId);
var result = await _sender.Send(command); var result = await _sender.Send(command);
await Clients.Group(request.ChatId.ToString()).SendAsync("message_unpinned", new await Clients.Group(request.ChatId.ToString()).SendAsync("message_unpinned", new
{ {
chatId = request.ChatId, chatId = request.ChatId,
@@ -328,7 +329,7 @@ public sealed class ChatHub : Hub
public async Task FriendAccepted(FriendSignalRequest request) public async Task FriendAccepted(FriendSignalRequest request)
{ {
if (request == null || string.IsNullOrEmpty(request.FriendId)) return; if (request == null || string.IsNullOrEmpty(request.FriendId)) return;
_logger.LogInformation("Signaling friend_request_accepted to {FriendId} from {UserId}", request.FriendId, _userContext.UserId); _logger.LogInformation("Signaling friend_request_accepted to {FriendId} from {UserId}", request.FriendId, _userContext.UserId);
await SendToUserAsync(request.FriendId, "friend_request_accepted", new { userId = _userContext.UserId }); await SendToUserAsync(request.FriendId, "friend_request_accepted", new { userId = _userContext.UserId });
} }
@@ -350,8 +351,8 @@ public sealed class ChatHub : Hub
{ {
if (string.IsNullOrEmpty(targetUserId)) if (string.IsNullOrEmpty(targetUserId))
{ {
_logger.LogWarning("SendToUserAsync called with null or empty targetUserId"); _logger.LogWarning("SendToUserAsync called with null or empty targetUserId");
return; return;
} }
if (_userConnections.TryGetValue(targetUserId, out var connectionIds)) if (_userConnections.TryGetValue(targetUserId, out var connectionIds))
@@ -398,15 +399,15 @@ public sealed class ChatHub : Hub
// Track session for history // Track session for history
Guid? chatId = null; Guid? chatId = null;
if (Guid.TryParse(request.ChatId, out var parsedChatId)) chatId = parsedChatId; if (Guid.TryParse(request.ChatId, out var parsedChatId)) chatId = parsedChatId;
if (!chatId.HasValue) if (!chatId.HasValue)
{ {
var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted); var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted);
if (Guid.TryParse(request.TargetUserId, out var targetId)) if (Guid.TryParse(request.TargetUserId, out var targetId))
{ {
var personalChat = userChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.Any(m => m.UserId == targetId)); var personalChat = userChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.Any(m => m.UserId == targetId));
if (personalChat != null) chatId = personalChat.Id; if (personalChat != null) chatId = personalChat.Id;
} }
} }
var session = new CallSession(chatId, _userContext.UserId, Guid.Parse(request.TargetUserId), request.CallType, DateTime.UtcNow); var session = new CallSession(chatId, _userContext.UserId, Guid.Parse(request.TargetUserId), request.CallType, DateTime.UtcNow);
@@ -461,10 +462,10 @@ public sealed class ChatHub : Hub
_activeSessionsByUser.TryRemove(request.TargetUserId, out _); _activeSessionsByUser.TryRemove(request.TargetUserId, out _);
if (session.ChatId.HasValue) if (session.ChatId.HasValue)
{ {
int duration = session.IsAnswered && session.AnswerTime.HasValue int duration = session.IsAnswered && session.AnswerTime.HasValue
? (int)(DateTime.UtcNow - session.AnswerTime.Value).TotalSeconds ? (int)(DateTime.UtcNow - session.AnswerTime.Value).TotalSeconds
: 0; : 0;
string status = session.IsAnswered ? "completed" : (_userContext.UserId == session.FromUserId ? "cancelled" : "missed"); string status = session.IsAnswered ? "completed" : (_userContext.UserId == session.FromUserId ? "cancelled" : "missed");
await CreateCallMessage(session.ChatId.Value, session.FromUserId, session.CallType, status, duration); await CreateCallMessage(session.ChatId.Value, session.FromUserId, session.CallType, status, duration);
} }
@@ -573,7 +574,7 @@ public sealed class ChatHub : Hub
var userInfo = new ParticipantInfo(userId, username, displayName, avatar); var userInfo = new ParticipantInfo(userId, username, displayName, avatar);
var participants = _groupCallParticipants.GetOrAdd(chatId, _ => var participants = _groupCallParticipants.GetOrAdd(chatId, _ =>
{ {
_activeGroupCalls[chatId] = (DateTime.UtcNow, request.CallType); _activeGroupCalls[chatId] = (DateTime.UtcNow, request.CallType);
return new ConcurrentDictionary<string, ParticipantInfo>(); return new ConcurrentDictionary<string, ParticipantInfo>();
@@ -19,9 +19,17 @@ public static class MessagesEndpoints
{ {
var group = app.MapGroup("api/messages").RequireAuthorization(); var group = app.MapGroup("api/messages").RequireAuthorization();
group.MapGet("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromQuery] string? cursor, ISender sender, IUserContext userContext, CancellationToken ct) => group.MapGet("chat/{chatId:guid}", async (
[FromRoute] Guid chatId,
[FromQuery] string? cursor,
[FromQuery] long? afterSequenceId,
[FromQuery] long? pivot,
[FromQuery] int? limit,
ISender sender,
IUserContext userContext,
CancellationToken ct) =>
{ {
var result = await sender.Send(new GetMessagesQuery(userContext.UserId, chatId, cursor), ct); var result = await sender.Send(new GetMessagesQuery(userContext.UserId, chatId, cursor, pivot, afterSequenceId, limit), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description); return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
}); });
+24 -10
View File
@@ -18,7 +18,7 @@ public abstract class Message : AggregateRoot<Guid>
public Guid SenderId { get; protected set; } public Guid SenderId { get; protected set; }
public DateTime CreatedAt { get; protected set; } public DateTime CreatedAt { get; protected set; }
public long SequenceId { get; protected set; } public long SequenceId { get; protected set; }
public void SetSequenceId(long sequenceId) public void SetSequenceId(long sequenceId)
{ {
SequenceId = sequenceId; SequenceId = sequenceId;
@@ -26,10 +26,10 @@ public abstract class Message : AggregateRoot<Guid>
// ================== Опциональные метаданные (общего назначения) ================== // ================== Опциональные метаданные (общего назначения) ==================
public Guid? ReplyToId { get; protected set; } public Guid? ReplyToId { get; protected set; }
public Guid? ForwardedFromId { get; protected set; } public Guid? ForwardedFromId { get; protected set; }
// ================== Флаги ================== // ================== Флаги ==================
public MessageState State { get; protected set; } public MessageState State { get; protected set; }
// ================== Абстрактные / Виртуальные свойства ================== // ================== Абстрактные / Виртуальные свойства ==================
public abstract string Type { get; } public abstract string Type { get; }
public abstract string? Content { get; protected set; } public abstract string? Content { get; protected set; }
@@ -42,16 +42,20 @@ 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) { }
protected Message( protected Message(
Guid id, Guid id,
Guid chatId, Guid chatId,
Guid senderId, Guid senderId,
Guid? replyToId, Guid? replyToId,
Guid? forwardedFromId, Guid? forwardedFromId,
DateTime createdAt, DateTime createdAt,
bool isImported) : base(id) bool isImported) : base(id)
{ {
ChatId = chatId; ChatId = chatId;
@@ -59,7 +63,7 @@ public abstract class Message : AggregateRoot<Guid>
ReplyToId = replyToId; ReplyToId = replyToId;
ForwardedFromId = forwardedFromId; ForwardedFromId = forwardedFromId;
CreatedAt = createdAt; CreatedAt = createdAt;
if (isImported) AddState(MessageState.IsImported); if (isImported) AddState(MessageState.IsImported);
} }
@@ -89,6 +93,16 @@ 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 = new List<object>(), readBy = message.ReadByUsers.Select(id => new { id }).ToList(),
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,
@@ -95,14 +95,28 @@ public sealed class MessageRepository : IMessageRepository
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
public async Task<List<Message>> GetChatMessagesAfterAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken)
{
var builder = Builders<Message>.Filter;
var filter = builder.And(
builder.Eq(m => m.ChatId, chatId),
builder.Gt(m => m.SequenceId, sequenceId)
);
return await _messages.Find(filter)
.SortBy(m => m.SequenceId)
.Limit(limit)
.ToListAsync(cancellationToken);
}
public async Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken) public async Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken)
{ {
var builder = Builders<Message>.Filter; var builder = Builders<Message>.Filter;
// Target message // Target message
var targetFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Eq(m => m.SequenceId, sequenceId)); var targetFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Eq(m => m.SequenceId, sequenceId));
var targetMsg = await _messages.Find(targetFilter).FirstOrDefaultAsync(cancellationToken); var targetMsg = await _messages.Find(targetFilter).FirstOrDefaultAsync(cancellationToken);
// Older messages // Older messages
var olderFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Lt(m => m.SequenceId, sequenceId)); var olderFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Lt(m => m.SequenceId, sequenceId));
var older = await _messages.Find(olderFilter) var older = await _messages.Find(olderFilter)
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 управлять синхронизацией

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