27 Commits
Author SHA1 Message Date
Халимов Рустам 8c8ef55b58 Merge branch 'main' into android 2026-04-19 21:24:40 +03:00
Халимов Рустам 629fddfca0 Реакции, но с багом 2026-04-18 00:41:05 +03:00
Халимов Рустам b68f68a1f2 Анимация, список реакций 2026-04-17 23:51:46 +03:00
Халимов Рустам 754e9e8ad0 Меню для сообщений 2026-04-17 23:44:11 +03:00
Халимов Рустам ed7521a563 Правка ответов, локализация, поддержка медиа 2026-04-17 22:07:33 +03:00
Халимов Рустам bef30c2c86 Ответы 2026-04-17 21:59:53 +03:00
Халимов Рустам 8c00d1376d Событие печати, смена адреса, иконки 2026-04-17 16:23:52 +03:00
Халимов Рустам cea3f4d669 Профиль, редактирование без аватара 2026-04-16 15:41:22 +03:00
Халимов Рустам 8409c51842 Пуши 2026-04-15 02:40:52 +03:00
Халимов Рустам 9560a9235f Сохранение состояния при сворачивании 2026-04-15 02:19:03 +03:00
Халимов Рустам f17edfc0da Зум и пролистывание 2026-04-15 02:12:43 +03:00
Халимов Рустам c6bebec599 Кнопка вниз 2026-04-15 02:01:46 +03:00
Халимов Рустам 5a71e5bbfa Подгрузка старых сообщений 2026-04-15 01:55:23 +03:00
Халимов Рустам 5b0133d55e Прокрутка чата 2026-04-15 01:35:22 +03:00
Халимов Рустам dcb733ac01 Нормальный чат 2026-04-15 01:30:48 +03:00
Халимов Рустам 487cb1b12b Рабочие счетчики и переработка чата 2026-04-15 01:14:55 +03:00
Халимов Рустам 58fdf1aca1 Чат, вложения 2026-04-14 21:53:44 +03:00
Халимов Рустам 118f8b8971 Прочтение 2026-04-14 15:09:44 +03:00
Халимов Рустам 8165b74e43 Отправка гиф 2026-04-14 13:55:01 +03:00
Халимов Рустам 8ce4bc714f Гиф 2026-04-14 13:46:17 +03:00
Халимов Рустам 2d0bc0d75c Чат, правки 2026-04-14 12:52:03 +03:00
Халимов Рустам d7e75797ef Ответы, аудио, голосовые 2026-04-14 12:29:32 +03:00
Халимов Рустам 58bbdae26c Настройки 2026-04-14 11:46:55 +03:00
Халимов Рустам 3310a3c4a4 Правка чата 2026-04-14 11:31:20 +03:00
Халимов Рустам d8b0d86534 Чат 2026-04-14 10:48:07 +03:00
Халимов Рустам dc051fa9ae Авторизация, нерабочий чат 2026-04-14 01:41:51 +03:00
Халимов Рустам 1fb1be47dd Приложение 2026-04-14 01:15:54 +03:00
190 changed files with 12903 additions and 1425 deletions
@@ -4,7 +4,6 @@ public interface IJwtTokenProvider
{
string GenerateAccessToken(Guid userId, string username);
string GenerateRefreshToken();
DateTime GetRefreshTokenExpiry();
string Generate(Guid userId, string username, string displayName, string? avatar);
string Generate(Domain.UserContract user);
}
@@ -7,7 +7,5 @@ public static class AuthErrors
public static Error IdentityInvalidCredentials => new("Auth.InvalidCredentials", "Invalid credentials");
public static Error IdentityRegistrationDisabled => new("Auth.RegistrationDisabled", "Registration is disabled");
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");
}
@@ -19,12 +19,4 @@ public class UserContract
public bool IsExternal { get; set; }
public string? Domain { 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,7 +13,6 @@ public interface IMessageRepository
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>> GetChatMessagesAfterAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken);
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
Task UpdateAsync(Message message, CancellationToken cancellationToken);
@@ -23,9 +23,6 @@ public abstract class Message : AggregateRoot<Guid>
protected List<DeletedMessage> _deletedFor = new();
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
protected List<Guid> _readByUsers = new();
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
protected Message() : base(Guid.Empty) { }
protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
@@ -56,14 +53,4 @@ public abstract class Message : AggregateRoot<Guid>
if (!_deletedFor.Exists(x => x.UserId == userId))
_deletedFor.Add(new DeletedMessage(Id, userId));
}
public void MarkAsRead(Guid userId)
{
if (!_readByUsers.Contains(userId))
{
_readByUsers.Add(userId);
}
}
public bool IsReadBy(Guid userId) => _readByUsers.Contains(userId);
}
+1 -2
View File
@@ -13,8 +13,7 @@
"Secret": "knot_super_secret_key_1234567890_knot",
"Issuer": "Knot",
"Audience": "KnotUsers",
"ExpiryInMinutes": 1440,
"RefreshExpiryInDays": 30
"ExpiryInMinutes": 1440
},
"KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot"
}
@@ -5,8 +5,5 @@ namespace Knot.Modules.Auth.Application.Abstractions;
public interface IJwtTokenProvider
{
string Generate(User user);
string Generate(Guid userId, string username, string displayName, string? avatar);
string GenerateRefreshToken();
DateTime GetRefreshTokenExpiry();
}
@@ -1,6 +1,4 @@
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;
@@ -11,12 +9,10 @@ public sealed record GetMeQuery(Guid UserId) : IQuery<AuthResponseDto>;
internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
private readonly IJwtTokenProvider _tokenProvider;
public GetMeQueryHandler(IUserRepository userRepository, IJwtTokenProvider tokenProvider)
public GetMeQueryHandler(IUserRepository userRepository)
{
_userRepository = userRepository;
_tokenProvider = tokenProvider;
}
public async Task<Result<AuthResponseDto>> Handle(GetMeQuery request, CancellationToken cancellationToken)
@@ -27,18 +23,9 @@ internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponse
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
{
AccessToken = newAccessToken,
AccessToken = string.Empty,
RefreshToken = string.Empty,
UserId = user.Id,
Username = user.Username,
@@ -5,6 +5,9 @@ using Knot.Shared.Kernel;
namespace Knot.Modules.Auth.Application.Users.Login;
/// <summary>
/// . AuthResponseDto.
/// </summary>
public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>;
public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto>
@@ -28,21 +31,14 @@ public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand,
}
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
{
AccessToken = token,
RefreshToken = refreshToken,
RefreshToken = string.Empty,
UserId = user.Id,
Username = user.Username,
DisplayName = user.DisplayName
});
}
}
@@ -1,7 +0,0 @@
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>;
@@ -1,65 +0,0 @@
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,4 +1,5 @@
using BCrypt.Net;
using BCrypt.Net;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Contracts.Settings.Application.Abstractions;
@@ -8,6 +9,9 @@ using Knot.Shared.Kernel;
namespace Knot.Modules.Auth.Application.Users.Register;
/// <summary>
/// .
/// </summary>
public sealed record RegisterUserCommand(
string Username,
string Password,
@@ -15,6 +19,9 @@ public sealed record RegisterUserCommand(
string? Email,
string? Bio) : ICommand<AuthResponseDto>;
/// <summary>
/// .
/// </summary>
internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
@@ -41,13 +48,16 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationDisabled);
}
// 1. username
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
{
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityUsernameNotUnique);
}
// 2.
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
// 3.
var user = User.Create(
request.Username,
passwordHash,
@@ -55,33 +65,21 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
request.Email,
request.Bio);
var repoImpl = _userRepository as Infrastructure.Persistence.UserRepository;
repoImpl?.Add(user);
// 4. - Domain User
var repoWithDomainUserAdd = _userRepository as Infrastructure.Persistence.UserRepository;
repoWithDomainUserAdd?.Add(user);
await _unitOfWork.SaveChangesAsync(cancellationToken);
// 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);
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
return Result.Success(new AuthResponseDto
{
AccessToken = token,
RefreshToken = refreshToken,
UserId = userContract.Id,
Username = userContract.Username,
DisplayName = userContract.DisplayName
RefreshToken = string.Empty,
UserId = user.Id,
Username = user.Username,
DisplayName = user.DisplayName
});
}
}
+2 -8
View File
@@ -26,7 +26,6 @@ public sealed class User : AggregateRoot<Guid>
public bool IsBanned { get; private set; }
public string? PhoneNumber { get; private set; }
public string? RefreshToken { get; private set; }
public DateTime? RefreshTokenExpiry { get; private set; }
public DateTime? BannedUntil { get; private set; }
public void Ban() {
@@ -49,10 +48,9 @@ public sealed class User : AggregateRoot<Guid>
PhoneNumber = phoneNumber;
}
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
public void SetRefreshToken(string? refreshToken)
{
RefreshToken = refreshToken;
RefreshTokenExpiry = expiry;
}
public void SetBannedUntil(DateTime? bannedUntil)
@@ -81,8 +79,6 @@ public sealed class User : AggregateRoot<Guid>
BannedUntil = contract.BannedUntil;
SetOnline(contract.IsOnline, contract.LastSeen);
UserDomain = contract.Domain;
RefreshToken = contract.RefreshToken;
RefreshTokenExpiry = contract.RefreshTokenExpiry;
}
private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null)
@@ -185,9 +181,7 @@ public sealed class User : AggregateRoot<Guid>
IsOnline = IsOnline,
IsExternal = IsExternal,
Domain = _domain,
LastSeen = LastSeen,
RefreshToken = RefreshToken,
RefreshTokenExpiry = RefreshTokenExpiry
LastSeen = LastSeen
};
}
}
@@ -50,12 +50,6 @@ internal sealed class JwtTokenProvider : IJwtTokenProvider
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)
{
var claims = new Claim[]
@@ -1,100 +0,0 @@
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
}
}
}
@@ -1,28 +0,0 @@
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,13 +1,12 @@
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 Knot.Modules.Auth.Application.Users.Login;
using Knot.Modules.Auth.Application.Users.Register;
using Knot.Modules.Auth.Application.Users.GetMe;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc;
namespace Knot.Modules.Auth.Presentation.Endpoints;
@@ -29,12 +28,6 @@ public static class AuthEndpoints
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) =>
{
var result = await sender.Send(new GetMeQuery(userContext.UserId), ct);
@@ -1,15 +1,13 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using MediatR;
using Microsoft.AspNetCore.SignalR;
using System.Linq;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Shared.Kernel.Storage;
namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete;
@@ -21,21 +19,17 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
private readonly IMessageRepository _messageRepository;
private readonly IFileStorageService _fileStorage;
private readonly IChatsUnitOfWork _uow;
private readonly IHubContext<ChatHub> _hubContext;
public LeaveOrDeleteChatCommandHandler(
IChatRepository chatRepository,
IMessageRepository messageRepository,
IFileStorageService fileStorage,
IChatsUnitOfWork uow,
IHubContext<ChatHub> hubContext)
IChatsUnitOfWork uow)
{
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_fileStorage = fileStorage;
_uow = uow;
_hubContext = hubContext;
}
public async Task<Result<SuccessResponse>> Handle(LeaveOrDeleteChatCommand request, CancellationToken cancellationToken)
@@ -64,14 +58,6 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
// DELETE ALL MESSAGES AND FILES FIRST
await DeleteChatMediaAndMessagesAsync(chat.Id, cancellationToken);
_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);
@@ -82,7 +68,6 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
private async Task DeleteChatMediaAndMessagesAsync(Guid chatId, CancellationToken ct)
{
try
{
// Get all messages directly from Mongo (not paged)
var messages = await _messageRepository.GetChatMessagesAsync(chatId, int.MaxValue, 0, ct);
@@ -13,7 +13,7 @@ using MediatR;
namespace Knot.Modules.Conversations.Application.Messages.GetMessages;
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor, long? Pivot = null, long? AfterSequenceId = null, int? Limit = null) : IQuery<List<MessageDetailDto>>;
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor, long? Pivot = null, int? Limit = null) : IQuery<List<MessageDetailDto>>;
internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>>
{
@@ -41,12 +41,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
List<Message> messages;
int queryLimit = request.Limit ?? ChatConstants.DefaultMessageQueryLimit;
if (request.AfterSequenceId.HasValue)
{
// Получаем только сообщения ПОСЛЕ указанного sequenceId (для синхронизации)
messages = await _messageRepository.GetChatMessagesAfterAsync(request.ChatId, request.AfterSequenceId.Value, queryLimit, cancellationToken);
}
else if (request.Pivot.HasValue)
if (request.Pivot.HasValue)
{
messages = await _messageRepository.GetChatMessagesAroundAsync(request.ChatId, request.Pivot.Value, queryLimit, cancellationToken);
}
@@ -159,7 +154,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
(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>(),
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
message.ReadByUsers.Select(id => new ReadByDto(id)).ToList(),
new List<ReadByDto>(), // ReadBy not implemented in this detailed view yet
reactions?.Select(r =>
{
senders.TryGetValue(r.UserId, out var ru);
@@ -1,8 +1,7 @@
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Shared.Kernel;
using MediatR;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Contracts.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Application.Messages.Read;
@@ -12,13 +11,11 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCom
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly IMessageRepository _messageRepository;
public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork, IMessageRepository messageRepository)
public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
{
_chatRepository = chatRepository;
_unitOfWork = unitOfWork;
_messageRepository = messageRepository;
}
public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken)
@@ -31,23 +28,6 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCom
member.UpdateReadCursor(request.LastReadMessageId, request.LastReadSequenceId);
// Обновляем 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);
return Result.Success();
@@ -3,10 +3,10 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using MediatR;
@@ -41,8 +41,7 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken);
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 mediaMessage = message as MediaMessage;
var storyMessage = message as StoryMessage;
@@ -67,7 +66,7 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(),
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>(),
message.ReadByUsers.Select(id => new ReadByDto(id)).ToList()
new List<ReadByDto>()
);
}).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.Domain;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Application.Messages.Send;
@@ -192,7 +192,6 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
var senderMember = chat.Members.First(m => m.UserId == request.SenderId);
senderMember.UpdateReadCursor(message.Id, message.SequenceId);
senderMember.UpdateDeliveredCursor(message.Id);
message.MarkAsRead(request.SenderId); // Отправитель всегда "прочитал" своё сообщение
// 5.
_messageRepository.Add(message);
@@ -1,26 +1,26 @@
using System.Collections.Concurrent;
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 Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Knot.Modules.Conversations.Application.Messages.Send;
using Knot.Modules.Conversations.Application.Messages.Read;
using Knot.Modules.Conversations.Application.Messages.Delete;
using Knot.Modules.Conversations.Application.Messages.React;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using Microsoft.Extensions.Caching.Memory;
using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Modules.Conversations.Application.Messages.Pin;
using Knot.Modules.Conversations.Application.Messages.Unpin;
using Knot.Modules.Conversations.Application.Messages.Vote;
using Knot.Modules.Conversations.Application.Messages.Edit;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
@@ -158,7 +158,6 @@ public sealed class ChatHub : Hub
await _sender.Send(command);
}
// Отправляем событие всем в чате о том, что пользователь прочитал сообщения
await Clients.Group(request.ChatId.ToString()).SendAsync("messages_read", new
{
ChatId = request.ChatId.ToString(),
@@ -19,17 +19,9 @@ public static class MessagesEndpoints
{
var group = app.MapGroup("api/messages").RequireAuthorization();
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) =>
group.MapGet("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromQuery] string? cursor, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new GetMessagesQuery(userContext.UserId, chatId, cursor, pivot, afterSequenceId, limit), ct);
var result = await sender.Send(new GetMessagesQuery(userContext.UserId, chatId, cursor), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
});
@@ -42,10 +42,6 @@ public abstract class Message : AggregateRoot<Guid>
protected List<DeletedMessage> _deletedFor = new();
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
// ================== Прочитано ==================
protected List<Guid> _readByUsers = new();
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
// ================== Инфраструктурный конструктор EF ==================
protected Message() : base(Guid.Empty) { }
@@ -93,16 +89,6 @@ public abstract class Message : AggregateRoot<Guid>
_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
}).ToList() ?? (object)Array.Empty<object>(),
sender = senderObj,
readBy = message.ReadByUsers.Select(id => new { id }).ToList(),
readBy = new List<object>(),
storyId = (message as StoryMessage)?.StoryId,
storyMediaUrl = (message as StoryMessage)?.StoryMediaUrl,
storyMediaType = (message as StoryMessage)?.StoryMediaType,
@@ -95,20 +95,6 @@ public sealed class MessageRepository : IMessageRepository
.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)
{
var builder = Builders<Message>.Filter;
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
+131
View File
@@ -0,0 +1,131 @@
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")
kapt("androidx.room:room-compiler:$room_version")
// 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,42 @@
<?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.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,58 @@
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)
signalrNotificationObserver.start()
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,18 @@
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,63 @@
<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>
</resources>
@@ -0,0 +1,85 @@
<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>
</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,27 @@
package auth.data.remote.api
import auth.data.remote.dto.AuthRequest
import auth.data.remote.dto.AuthResponse
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")
@Headers("Cache-Control: no-cache")
suspend fun login(@Body request: AuthRequest): AuthResponse
@POST("auth/register")
@Headers("Cache-Control: no-cache")
suspend fun register(@Body request: AuthRequest): 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,23 @@
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("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?
)
@@ -0,0 +1,98 @@
package auth.data.repository
import auth.data.remote.api.AuthApi
import auth.data.remote.dto.AuthRequest
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)
_isAuthenticated.value = true
fetchConfig()
Result.success(
AuthResult(
token = token,
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)
_isAuthenticated.value = true
fetchConfig()
Result.success(
AuthResult(
token = token,
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
}
}
}
+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,9 @@
package auth.domain.model
data class AuthResult(
val token: String,
val userId: String,
val userName: String,
val displayName: String,
val avatarUrl: String?
)
@@ -0,0 +1,13 @@
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)
}
@@ -0,0 +1,82 @@
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) }
}
}
}
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()
}
)
}
@@ -0,0 +1,123 @@
package chats.data.remote.api
import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto
import retrofit2.http.*
data class SendMessageRequest(
val content: String?,
val type: String = "text",
val attachments: List<AttachmentRequest>? = null,
val replyToId: String? = null,
val quote: String? = null,
val forwardedFromId: String? = null
)
data class AttachmentRequest(
val type: String,
val url: String,
val fileName: String,
val fileSize: Long
)
interface ChatApi {
@GET("chats")
suspend fun getChats(): List<ChatDto>
@GET("messages/chat/{chatId}")
suspend fun getMessages(
@Path("chatId") chatId: String,
@Query("cursor") cursor: String? = null,
@Query("pivot") pivot: Long? = null,
@Query("limit") limit: Int? = 50
): List<MessageDto>
@POST("messages/chat/{chatId}")
suspend fun sendMessage(@Path("chatId") chatId: String, @Body request: SendMessageRequest): MessageDto
@Multipart
@POST("messages/upload")
suspend fun uploadFile(@Part file: okhttp3.MultipartBody.Part): FileUploadResponse
// Klipy GIF API
@GET("klipy/trending")
suspend fun getTrendingGifs(@Query("page") page: Int): KlipyResponse
@GET("klipy/search")
suspend fun searchGifs(@Query("q") query: String, @Query("page") page: Int): KlipyResponse
@GET("klipy/categories")
suspend fun getGifCategories(): GifCategoriesResponse
@POST("klipy/shared/{id}")
suspend fun markGifShared(@Path("id") id: String, @Body query: String)
@POST("messages/{messageId}/reactions")
suspend fun addReaction(@Path("messageId") messageId: String, @Query("emoji") emoji: String)
@POST("chats/personal")
suspend fun createPersonalChat(@Body request: CreatePersonalChatRequest): ChatDto
@POST("chats/{chatId}/typing")
suspend fun sendTypingStatus(@Path("chatId") chatId: String)
@POST("chats/{chatId}/read")
suspend fun markMessagesAsRead(@Path("chatId") chatId: String, @Body lastMessageId: String)
@DELETE("messages/{messageId}")
suspend fun deleteMessage(@Path("messageId") messageId: String, @Query("forEveryone") forEveryone: Boolean): retrofit2.Response<Unit>
@PUT("messages/{messageId}")
suspend fun editMessage(@Path("messageId") messageId: String, @Body request: SendMessageRequest): MessageDto
}
data class CreatePersonalChatRequest(
val userId: String
)
data class KlipyResponse(
val data: KlipyDataWrapper
)
data class KlipyDataWrapper(
val data: List<KlipyGifDto>
)
data class KlipyGifDto(
val id: String,
val images: GifImagesDto? = null,
val files: Map<String, Map<String, GifImageSourceDto>>? = null,
val file: Map<String, Map<String, GifImageSourceDto>>? = null,
val media_formats: Map<String, GifImageSourceDto>? = null,
val title: String? = null
)
data class GifImagesDto(
val fixed_height: GifImageSourceDto? = null,
val original: GifImageSourceDto? = null,
val fixed_height_small: GifImageSourceDto? = null
)
data class GifImageSourceDto(
val url: String
)
data class GifCategoryDto(
val category: String,
val preview_url: String,
val query: String
)
data class FileUploadResponse(
val url: String,
val filename: String,
val size: Long
)
data class GifCategoriesResponse(
val data: GifCategoriesData
)
data class GifCategoriesData(
val categories: List<GifCategoryDto>
)
@@ -0,0 +1,57 @@
package chats.data.remote.dto
import com.google.gson.annotations.SerializedName
data class UserBasicDto(
@SerializedName("id") val id: String,
@SerializedName("username") val username: String? = null,
@SerializedName("displayName") val displayName: String? = null,
@SerializedName("avatarUrl") val avatarUrl: String? = null
)
data class MessageDto(
@SerializedName("id", alternate = ["Id"]) val id: String,
@SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null,
@SerializedName("senderId", alternate = ["SenderId"]) val senderId: String? = null,
@SerializedName("content", alternate = ["Content"]) val content: String? = null,
@SerializedName("type", alternate = ["Type"]) val type: String? = null,
@SerializedName("sequenceId", alternate = ["SequenceId"]) val sequenceId: Int? = null,
@SerializedName("createdAt", alternate = ["CreatedAt"]) val createdAt: String? = null,
@SerializedName("sender", alternate = ["Sender"]) val sender: UserBasicDto? = null,
@SerializedName("media", alternate = ["Media"]) val media: List<MediaItemDto> = emptyList(),
@SerializedName("reactions", alternate = ["Reactions"]) val reactions: List<ReactionDto>? = emptyList(),
@SerializedName("replyTo", alternate = ["ReplyTo"]) val replyTo: MessageDto? = null,
@SerializedName("isPinned", alternate = ["IsPinned"]) val isPinned: Boolean? = false,
@SerializedName("forwardedFromId", alternate = ["ForwardedFromId"]) val forwardedFromId: String? = null,
@SerializedName("forwardedFrom", alternate = ["ForwardedFrom"]) val forwardedFrom: UserBasicDto? = null
)
data class ReactionDto(
@SerializedName("emoji") val emoji: String,
@SerializedName("count") val count: Int,
@SerializedName("isSetByMe") val isSetByMe: Boolean
)
data class MediaItemDto(
@SerializedName("id") val id: String,
@SerializedName("type") val type: String,
@SerializedName("url") val url: String,
@SerializedName("filename") val filename: String? = null,
@SerializedName("size") val size: Long? = null,
@SerializedName("duration") val duration: Double? = null
)
data class ChatDto(
@SerializedName("id") val id: String,
@SerializedName("type") val type: String,
@SerializedName("name") val name: String? = null,
@SerializedName("avatar") val avatar: String? = null,
@SerializedName("unreadCount", alternate = ["UnreadCount", "unread_count"]) val unreadCount: Int = 0,
@SerializedName("messages") val messages: List<MessageDto> = emptyList(),
@SerializedName("members") val members: List<ChatMemberDto> = emptyList()
)
data class ChatMemberDto(
@SerializedName("userId") val userId: String,
@SerializedName("user") val user: UserBasicDto? = null
)
@@ -0,0 +1,33 @@
package chats.data.remote.signalr
import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto
sealed class ChatEvent {
data class NewMessage(val message: MessageDto) : ChatEvent()
data class MessageEdited(val messageId: String, val chatId: String, val content: String) : ChatEvent()
data class MessageDeleted(val messageId: String, val chatId: String) : ChatEvent()
data class MessagesRead(val chatId: String, val userId: String, val lastReadSequenceId: Int) : ChatEvent()
data class UserTyping(val chatId: String, val userId: String) : ChatEvent()
data class UserStoppedTyping(val chatId: String, val userId: String) : ChatEvent()
data class UserOnline(val userId: String) : ChatEvent()
data class UserOffline(val userId: String, val lastSeen: String?) : ChatEvent()
data class NewChat(val chat: ChatDto) : ChatEvent()
data class ReactionUpdated(val messageId: String, val chatId: String, val userId: String, val emoji: String, val isRemoved: Boolean = false) : ChatEvent()
data class MessagePinned(val chatId: String, val message: MessageDto) : ChatEvent()
data class MessageUnpinned(val chatId: String, val messageId: String) : ChatEvent()
// Call Events (WebRTC Signaling)
data class CallIncoming(val chatId: String, val from: String, val offer: String, val callType: String) : ChatEvent()
data class CallAnswered(val chatId: String, val answer: String) : ChatEvent()
data class IceCandidateReceived(val chatId: String, val candidate: String) : ChatEvent()
data class CallEnded(val chatId: String) : ChatEvent()
// Group Call Events
data class GroupCallIncoming(val chatId: String, val from: String, val callerInfo: Any) : ChatEvent()
data class GroupCallParticipants(val chatId: String, val participants: List<String>) : ChatEvent()
data class GroupCallUserJoined(val chatId: String, val userId: String) : ChatEvent()
data class GroupCallUserLeft(val chatId: String, val userId: String) : ChatEvent()
data class GroupCallOffer(val chatId: String, val from: String, val offer: String) : ChatEvent()
data class GroupCallAnswer(val chatId: String, val from: String, val answer: String) : ChatEvent()
}
@@ -0,0 +1,280 @@
package chats.data.remote.signalr
import android.util.Log
import io.reactivex.rxjava3.core.Single
import com.microsoft.signalr.HubConnection
import com.microsoft.signalr.HubConnectionBuilder
import com.microsoft.signalr.HubConnectionState
import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.delay
data class ReadMessagesRequest(
val chatId: String,
val lastReadMessageId: String,
val lastReadSequenceId: Int
)
data class MessagesReadEvent(
@com.google.gson.annotations.SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null,
@com.google.gson.annotations.SerializedName("userId", alternate = ["UserId"]) val userId: String? = null,
@com.google.gson.annotations.SerializedName("lastReadSequenceId", alternate = ["LastReadSequenceId"]) val lastReadSequenceId: Int? = null
) {
val effectiveChatId: String get() = chatId ?: ""
val effectiveUserId: String get() = userId ?: ""
val effectiveLastReadSequenceId: Int get() = lastReadSequenceId ?: 0
}
data class ReactionEvent(
@com.google.gson.annotations.SerializedName("messageId", alternate = ["MessageId"]) val messageId: String? = null,
@com.google.gson.annotations.SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null,
@com.google.gson.annotations.SerializedName("userId", alternate = ["UserId"]) val userId: String? = null,
@com.google.gson.annotations.SerializedName("username", alternate = ["Username", "UserName"]) val username: String? = null,
@com.google.gson.annotations.SerializedName("emoji", alternate = ["Emoji"]) val emoji: String? = null
)
enum class ConnectionStatus { CONNECTED, CONNECTING, DISCONNECTED }
@Singleton
class ChatHubClient @Inject constructor() {
private var hubConnection: HubConnection? = null
private val _events = MutableSharedFlow<ChatEvent>(extraBufferCapacity = 1024)
val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
private val _status = MutableStateFlow(ConnectionStatus.DISCONNECTED)
val status: StateFlow<ConnectionStatus> = _status.asStateFlow()
private val scope = CoroutineScope(Dispatchers.IO)
private var lastBaseUrl: String? = null
private var lastToken: String? = null
fun connect(baseUrl: String, accessToken: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) return
lastBaseUrl = baseUrl
lastToken = accessToken
_status.value = ConnectionStatus.CONNECTING
hubConnection = HubConnectionBuilder.create("${baseUrl}/hubs/chat")
.withAccessTokenProvider(Single.just(accessToken))
.build()
setupHandlers()
hubConnection?.onClosed { exception ->
Log.e("ChatHubClient", "Connection closed. Reconnecting...", exception)
_status.value = ConnectionStatus.DISCONNECTED
scope.launch {
delay(5000)
connect(baseUrl, accessToken)
}
}
scope.launch {
try {
hubConnection?.start()?.blockingAwait()
_status.value = ConnectionStatus.CONNECTED
Log.d("ChatHubClient", "SignalR Connected")
} catch (e: Exception) {
Log.e("ChatHubClient", "SignalR Connection Error", e)
_status.value = ConnectionStatus.DISCONNECTED
}
}
}
private fun setupHandlers() {
hubConnection?.let { conn ->
conn.on("new_message", { message: MessageDto ->
_events.tryEmit(ChatEvent.NewMessage(message))
}, MessageDto::class.java)
conn.on("message_edited", { messageId: String, chatId: String, content: String ->
_events.tryEmit(ChatEvent.MessageEdited(messageId, chatId, content))
}, String::class.java, String::class.java, String::class.java)
conn.on("message_deleted", { messageId: String, chatId: String ->
_events.tryEmit(ChatEvent.MessageDeleted(messageId, chatId))
}, String::class.java, String::class.java)
conn.on("messages_read", { data: MessagesReadEvent ->
_events.tryEmit(ChatEvent.MessagesRead(
data.effectiveChatId,
data.effectiveUserId,
data.effectiveLastReadSequenceId
))
}, MessagesReadEvent::class.java)
conn.on("user_typing", { data: ReactionEvent ->
_events.tryEmit(ChatEvent.UserTyping(data.chatId ?: "", data.userId ?: ""))
}, ReactionEvent::class.java)
conn.on("user_stopped_typing", { data: ReactionEvent ->
_events.tryEmit(ChatEvent.UserStoppedTyping(data.chatId ?: "", data.userId ?: ""))
}, ReactionEvent::class.java)
conn.on("user_online", { userId: String ->
_events.tryEmit(ChatEvent.UserOnline(userId))
}, String::class.java)
conn.on("new_chat", { chat: ChatDto ->
_events.tryEmit(ChatEvent.NewChat(chat))
}, ChatDto::class.java)
conn.on("reaction_added", { data: ReactionEvent ->
_events.tryEmit(ChatEvent.ReactionUpdated(
data.messageId ?: "",
data.chatId ?: "",
data.userId ?: "",
data.emoji ?: "",
isRemoved = false
))
}, ReactionEvent::class.java)
conn.on("reaction_removed", { data: ReactionEvent ->
_events.tryEmit(ChatEvent.ReactionUpdated(
data.messageId ?: "",
data.chatId ?: "",
data.userId ?: "",
data.emoji ?: "",
isRemoved = true
))
}, ReactionEvent::class.java)
// WebRTC Signaling Handlers
conn.on("call_incoming", { chatId: String, from: String, offer: String, callType: String ->
_events.tryEmit(ChatEvent.CallIncoming(chatId, from, offer, callType))
}, String::class.java, String::class.java, String::class.java, String::class.java)
conn.on("call_answered", { chatId: String, answer: String ->
_events.tryEmit(ChatEvent.CallAnswered(chatId, answer))
}, String::class.java, String::class.java)
conn.on("ice_candidate", { chatId: String, candidate: String ->
_events.tryEmit(ChatEvent.IceCandidateReceived(chatId, candidate))
}, String::class.java, String::class.java)
conn.on("call_ended", { chatId: String ->
_events.tryEmit(ChatEvent.CallEnded(chatId))
}, String::class.java)
conn.on("message_pinned", { data: Map<String, Any> ->
// The web version expects a message object, but here we might get a partial DTO or just IDs.
// Let's assume we get { chatId, message: MessageDto } based on web
// We'll trust the DTO mapping if possible, but SignalR java client is picky with nested objects in Maps.
// For simplicity, we might needs a dedicated DTO if it fails.
}, Map::class.java)
conn.on("message_pinned", { chatId: String, message: MessageDto ->
_events.tryEmit(ChatEvent.MessagePinned(chatId, message))
}, String::class.java, MessageDto::class.java)
conn.on("message_unpinned", { chatId: String, messageId: String ->
_events.tryEmit(ChatEvent.MessageUnpinned(chatId, messageId))
}, String::class.java, String::class.java)
}
}
fun disconnect() {
hubConnection?.stop()
_status.value = ConnectionStatus.DISCONNECTED
}
fun addReaction(messageId: String, chatId: String, emoji: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("add_reaction", mapOf(
"messageId" to messageId,
"chatId" to chatId,
"emoji" to emoji
))?.doOnError { Log.e("ChatHubClient", "add_reaction error", it) }
?.subscribe()
Log.d("ChatHubClient", "Invoked add_reaction: $emoji on $messageId")
} else {
Log.w("ChatHubClient", "Cannot add_reaction: Not connected")
}
}
fun removeReaction(messageId: String, chatId: String, emoji: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("remove_reaction", mapOf(
"messageId" to messageId,
"chatId" to chatId,
"emoji" to emoji
))?.doOnError { Log.e("ChatHubClient", "remove_reaction error", it) }
?.subscribe()
Log.d("ChatHubClient", "Invoked remove_reaction: $emoji on $messageId")
}
}
fun pinMessage(messageId: String, chatId: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("pin_message", mapOf(
"messageId" to messageId,
"chatId" to chatId
))?.doOnError { Log.e("ChatHubClient", "pin_message error", it) }
?.subscribe()
}
}
fun unpinMessage(messageId: String, chatId: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("unpin_message", mapOf(
"messageId" to messageId,
"chatId" to chatId
))?.doOnError { Log.e("ChatHubClient", "unpin_message error", it) }
?.subscribe()
}
}
fun readMessages(request: ReadMessagesRequest) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("read_messages", request)
?.doOnError { Log.e("ChatHubClient", "read_messages error", it) }
?.subscribe()
Log.d("ChatHubClient", "Sent read_messages for chat: ${request.chatId}")
}
}
fun joinChat(chatId: String) {
scope.launch {
// Wait for connection to be established if it's currently connecting
var attempts = 0
while (hubConnection?.connectionState != HubConnectionState.CONNECTED && attempts < 10) {
delay(500)
attempts++
}
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("join_chat", chatId)
?.doOnError { Log.e("ChatHubClient", "join_chat error", it) }
?.subscribe()
Log.d("ChatHubClient", "Joined chat room: $chatId")
} else {
Log.e("ChatHubClient", "Failed to join chat room $chatId: Not connected")
}
}
}
fun sendTypingIndicator(chatId: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("typing_start", chatId)
?.doOnError { Log.e("ChatHubClient", "typing_start error", it) }
?.subscribe()
Log.d("ChatHubClient", "Sent typing indicator for chat: $chatId")
}
}
fun sendUserStoppedTyping(chatId: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("typing_stop", chatId)
?.doOnError { Log.e("ChatHubClient", "typing_stop error", it) }
?.subscribe()
Log.d("ChatHubClient", "Sent user stopped typing for chat: $chatId")
}
}
}
@@ -0,0 +1,94 @@
package chats.data.remote.signalr
import android.content.Context
import core.notifications.data.ActiveChatTracker
import core.notifications.data.NotificationHelper
import core.security.TokenManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SignalRNotificationObserver @Inject constructor(
private val signalrClient: ChatHubClient,
private val activeChatTracker: ActiveChatTracker,
private val tokenManager: TokenManager,
private val chatRepository: chats.domain.repository.ChatRepository,
@ApplicationContext private val context: Context
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var isStarted = false
private val processedMessageIds = mutableSetOf<String>()
fun refresh() {
scope.launch {
try {
val chats = chatRepository.getChats()
val total = chats.sumOf { it.unreadCount }
activeChatTracker.setTotalUnreadCount(total)
} catch (e: Exception) {
// Ignore load error
}
}
}
fun start() {
if (isStarted) return
isStarted = true
// Initial count load
refresh()
signalrClient.events
.onEach { event ->
when (event) {
is ChatEvent.NewMessage -> {
val currentUserId = tokenManager.getUserId()
val message = event.message
// Don't show if it's our message or already processed
if (message.senderId == currentUserId) return@onEach
if (processedMessageIds.contains(message.id)) return@onEach
// Mark as processed
processedMessageIds.add(message.id)
if (processedMessageIds.size > 200) {
processedMessageIds.remove(processedMessageIds.first())
}
// Increment immediately for UI feedback
activeChatTracker.incrementUnreadCount()
// Refresh total count from source of truth in background
refresh()
// Don't show if this chat is currently open
if (activeChatTracker.currentChatId.value == message.chatId) return@onEach
NotificationHelper.showNotification(
context = context,
title = message.sender?.displayName ?: "Новое сообщение",
body = message.content ?: "Вам прислали вложение",
type = "chat",
chatId = message.chatId,
notificationId = message.id.hashCode(),
totalCount = activeChatTracker.totalUnreadCount.value
)
}
is ChatEvent.MessagesRead -> {
// If anyone read messages, sync our total count
refresh()
}
else -> Unit
}
}
.launchIn(scope)
}
}
@@ -0,0 +1,157 @@
package chats.data.repository
import chats.data.remote.api.ChatApi
import chats.data.remote.api.SendMessageRequest
import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto
import chats.data.remote.dto.MediaItemDto
import chats.data.remote.dto.ReactionDto
import chats.domain.model.Chat
import chats.domain.model.Message
import chats.domain.model.MediaType
import chats.domain.repository.ChatRepository
import core.network.ServerConfig
import chats.data.remote.signalr.ReadMessagesRequest
import core.security.TokenManager
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import javax.inject.Inject
import kotlinx.coroutines.flow.map
class ChatRepositoryImpl @Inject constructor(
private val api: ChatApi,
private val tokenManager: TokenManager,
private val serverConfig: ServerConfig,
private val messageDao: core.database.data.MessageDao,
private val hubClient: chats.data.remote.signalr.ChatHubClient
) : ChatRepository {
private val gson = com.google.gson.Gson()
override suspend fun getChats(): List<Chat> {
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
return api.getChats().map { it.toDomain(currentUserId, baseUrl) }
}
override fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>> {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
return messageDao.getMessages(chatId).map { entities: List<core.database.data.MessageEntity> ->
entities.map { it.toDomain(baseUrl, gson) }
}
}
override suspend fun getMessages(chatId: String, cursor: String?, pivot: Long?, limit: Int?): List<Message> {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val currentUserId = tokenManager.getUserId() ?: ""
return try {
android.util.Log.d("ChatRepo", "FETCH: chatId=$chatId, cursor=$cursor, limit=$limit")
val messages = api.getMessages(chatId, cursor = cursor, limit = limit)
if (messages.isNotEmpty()) {
android.util.Log.d("ChatRepo", "Received ${messages.size} messages. TopSeq: ${messages.first().sequenceId}, BottomSeq: ${messages.last().sequenceId}")
}
// Мапим в доменные модели. По умолчанию считаем прочитанными,
// так как unreadCount нам тут не критичен для истории.
messages.map { msg ->
msg.toDomain(currentUserId, baseUrl).copy(isRead = true)
}
} catch (e: Exception) {
android.util.Log.e("ChatRepo", "Fetch messages failed", e)
emptyList()
}
}
override suspend fun sendMessage(
chatId: String,
content: String?,
type: String,
attachments: List<chats.data.remote.api.AttachmentRequest>?,
replyToId: String?,
forwardedFromId: String?
): Message {
val request = SendMessageRequest(
content = content,
type = type,
attachments = attachments,
replyToId = replyToId,
forwardedFromId = forwardedFromId
)
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val userId = tokenManager.getUserId() ?: ""
return api.sendMessage(chatId, request).toDomain(userId, baseUrl)
}
override suspend fun addReaction(messageId: String, emoji: String) {
api.addReaction(messageId, emoji)
}
override suspend fun sendTypingStatus(chatId: String) {
api.sendTypingStatus(chatId)
}
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
try {
android.util.Log.d("ChatRepoImpl", "markMessagesAsRead CALLED FOR $chatId. Caller stack: ${android.util.Log.getStackTraceString(Throwable())}")
hubClient.readMessages(ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId))
} catch (e: Exception) {
android.util.Log.e("ChatRepo", "Error marking messages as read", e)
}
// Обновляем локальную БД
messageDao.markMessagesAsRead(chatId, lastReadSequenceId)
}
override suspend fun saveMessage(message: Message) {
android.util.Log.d("ChatRepo", "DB cache disabled, skipping save: ${message.id}")
}
override suspend fun deleteLocalMessage(messageId: String) {
messageDao.deleteMessage(messageId)
}
override suspend fun uploadMedia(file: java.io.File): String {
val mimeType = when (file.extension.lowercase()) {
"jpg", "jpeg" -> "image/jpeg"
"png" -> "image/png"
"webp" -> "image/webp"
"mp4" -> "video/mp4"
"mp3", "m4a", "wav" -> "audio/mpeg"
else -> "application/octet-stream"
}
val requestFile = file.asRequestBody(mimeType.toMediaTypeOrNull())
val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
return api.uploadFile(body).url
}
override suspend fun getTrendingGifs(page: Int): List<chats.data.remote.api.KlipyGifDto> {
return api.getTrendingGifs(page).data.data
}
override suspend fun searchGifs(query: String, page: Int): List<chats.data.remote.api.KlipyGifDto> {
return api.searchGifs(query, page).data.data
}
override suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto> {
return api.getGifCategories().data.categories
}
override suspend fun createPersonalChat(userId: String): Chat {
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val request = chats.data.remote.api.CreatePersonalChatRequest(userId)
return api.createPersonalChat(request).toDomain(currentUserId, baseUrl)
}
override suspend fun deleteMessage(messageId: String, forEveryone: Boolean) {
api.deleteMessage(messageId, forEveryone)
messageDao.deleteMessage(messageId)
}
override suspend fun editMessage(messageId: String, content: String): Message {
val request = SendMessageRequest(content = content)
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
return api.editMessage(messageId, request).toDomain(currentUserId, baseUrl)
}
}

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