Compare commits
38
Commits
bugfix_web
...
c592197017
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c592197017 | ||
|
|
b65c8f3633 | ||
|
|
c839a9bc03 | ||
|
|
b2c29958b5 | ||
|
|
38e7184a0d | ||
|
|
7c66e1c0c0 | ||
|
|
945134f029 | ||
|
|
f6400ce3ac | ||
|
|
d9462069e2 | ||
|
|
9e715fe3ab | ||
|
|
8c8ef55b58 | ||
|
|
70acad56fb | ||
|
|
629fddfca0 | ||
|
|
b68f68a1f2 | ||
|
|
754e9e8ad0 | ||
|
|
ed7521a563 | ||
|
|
bef30c2c86 | ||
|
|
8c00d1376d | ||
|
|
cea3f4d669 | ||
|
|
8409c51842 | ||
|
|
9560a9235f | ||
|
|
f17edfc0da | ||
|
|
c6bebec599 | ||
|
|
5a71e5bbfa | ||
|
|
5b0133d55e | ||
|
|
dcb733ac01 | ||
|
|
487cb1b12b | ||
|
|
58fdf1aca1 | ||
|
|
118f8b8971 | ||
|
|
8165b74e43 | ||
|
|
8ce4bc714f | ||
|
|
2d0bc0d75c | ||
|
|
d7e75797ef | ||
|
|
58bbdae26c | ||
|
|
3310a3c4a4 | ||
|
|
d8b0d86534 | ||
|
|
dc051fa9ae | ||
|
|
1fb1be47dd |
@@ -4,6 +4,7 @@ public interface IJwtTokenProvider
|
|||||||
{
|
{
|
||||||
string GenerateAccessToken(Guid userId, string username);
|
string GenerateAccessToken(Guid userId, string username);
|
||||||
string GenerateRefreshToken();
|
string GenerateRefreshToken();
|
||||||
|
DateTime GetRefreshTokenExpiry();
|
||||||
string Generate(Guid userId, string username, string displayName, string? avatar);
|
string Generate(Guid userId, string username, string displayName, string? avatar);
|
||||||
string Generate(Domain.UserContract user);
|
string Generate(Domain.UserContract user);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,7 @@ public static class AuthErrors
|
|||||||
public static Error IdentityInvalidCredentials => new("Auth.InvalidCredentials", "Invalid credentials");
|
public static Error IdentityInvalidCredentials => new("Auth.InvalidCredentials", "Invalid credentials");
|
||||||
public static Error IdentityRegistrationDisabled => new("Auth.RegistrationDisabled", "Registration is disabled");
|
public static Error IdentityRegistrationDisabled => new("Auth.RegistrationDisabled", "Registration is disabled");
|
||||||
public static Error IdentityUsernameNotUnique => new("Auth.UsernameNotUnique", "Username is already taken");
|
public static Error IdentityUsernameNotUnique => new("Auth.UsernameNotUnique", "Username is already taken");
|
||||||
|
public static Error IdentityRegistrationFailed => new("Auth.RegistrationFailed", "Failed to register user");
|
||||||
|
public static Error RefreshTokenExpired => new("Auth.RefreshTokenExpired", "Refresh token has expired. Please login again.");
|
||||||
public static Error UserNotFound => new("Auth.UserNotFound", "User not found");
|
public static Error UserNotFound => new("Auth.UserNotFound", "User not found");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,4 +19,12 @@ public class UserContract
|
|||||||
public bool IsExternal { get; set; }
|
public bool IsExternal { get; set; }
|
||||||
public string? Domain { get; set; }
|
public string? Domain { get; set; }
|
||||||
public DateTime? LastSeen { get; set; }
|
public DateTime? LastSeen { get; set; }
|
||||||
|
public string? RefreshToken { get; set; }
|
||||||
|
public DateTime? RefreshTokenExpiry { get; set; }
|
||||||
|
|
||||||
|
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
|
||||||
|
{
|
||||||
|
RefreshToken = refreshToken;
|
||||||
|
RefreshTokenExpiry = expiry;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public interface IMessageRepository
|
|||||||
|
|
||||||
Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, long? sequenceId, int limit, CancellationToken cancellationToken);
|
Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, long? sequenceId, int limit, CancellationToken cancellationToken);
|
||||||
Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken);
|
Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken);
|
||||||
|
Task<List<Message>> GetChatMessagesAfterAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken);
|
||||||
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
|
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task UpdateAsync(Message message, CancellationToken cancellationToken);
|
Task UpdateAsync(Message message, CancellationToken cancellationToken);
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"Secret": "knot_super_secret_key_1234567890_knot",
|
"Secret": "knot_super_secret_key_1234567890_knot",
|
||||||
"Issuer": "Knot",
|
"Issuer": "Knot",
|
||||||
"Audience": "KnotUsers",
|
"Audience": "KnotUsers",
|
||||||
"ExpiryInMinutes": 1440
|
"ExpiryInMinutes": 1440,
|
||||||
|
"RefreshExpiryInDays": 30
|
||||||
},
|
},
|
||||||
"KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot"
|
"KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot"
|
||||||
}
|
}
|
||||||
@@ -5,5 +5,8 @@ namespace Knot.Modules.Auth.Application.Abstractions;
|
|||||||
public interface IJwtTokenProvider
|
public interface IJwtTokenProvider
|
||||||
{
|
{
|
||||||
string Generate(User user);
|
string Generate(User user);
|
||||||
|
string Generate(Guid userId, string username, string displayName, string? avatar);
|
||||||
|
string GenerateRefreshToken();
|
||||||
|
DateTime GetRefreshTokenExpiry();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Knot.Contracts.Auth.Application.Abstractions;
|
||||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
|
|
||||||
@@ -9,10 +11,12 @@ public sealed record GetMeQuery(Guid UserId) : IQuery<AuthResponseDto>;
|
|||||||
internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponseDto>
|
internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponseDto>
|
||||||
{
|
{
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly IJwtTokenProvider _tokenProvider;
|
||||||
|
|
||||||
public GetMeQueryHandler(IUserRepository userRepository)
|
public GetMeQueryHandler(IUserRepository userRepository, IJwtTokenProvider tokenProvider)
|
||||||
{
|
{
|
||||||
_userRepository = userRepository;
|
_userRepository = userRepository;
|
||||||
|
_tokenProvider = tokenProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<AuthResponseDto>> Handle(GetMeQuery request, CancellationToken cancellationToken)
|
public async Task<Result<AuthResponseDto>> Handle(GetMeQuery request, CancellationToken cancellationToken)
|
||||||
@@ -23,9 +27,18 @@ internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponse
|
|||||||
return Result.Failure<AuthResponseDto>(AuthErrors.UserNotFound);
|
return Result.Failure<AuthResponseDto>(AuthErrors.UserNotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if access token needs to be refreshed (less than 1 hour remaining)
|
||||||
|
string? newAccessToken = null;
|
||||||
|
|
||||||
|
// We can't directly check the current token's expiry here, but we can
|
||||||
|
// always issue a new token if the user is authenticated
|
||||||
|
// For now, let's issue a new token on every request (simplified approach)
|
||||||
|
// A better approach would be to parse the incoming token and check expiry
|
||||||
|
newAccessToken = _tokenProvider.Generate(user);
|
||||||
|
|
||||||
var response = new AuthResponseDto
|
var response = new AuthResponseDto
|
||||||
{
|
{
|
||||||
AccessToken = string.Empty,
|
AccessToken = newAccessToken,
|
||||||
RefreshToken = string.Empty,
|
RefreshToken = string.Empty,
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
Username = user.Username,
|
Username = user.Username,
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ using Knot.Shared.Kernel;
|
|||||||
|
|
||||||
namespace Knot.Modules.Auth.Application.Users.Login;
|
namespace Knot.Modules.Auth.Application.Users.Login;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// ������� ��� ����� ������������. ���������� AuthResponseDto.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>;
|
public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>;
|
||||||
|
|
||||||
public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto>
|
public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto>
|
||||||
@@ -31,14 +28,21 @@ public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand,
|
|||||||
}
|
}
|
||||||
|
|
||||||
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
|
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
|
||||||
|
string refreshToken = _tokenProvider.GenerateRefreshToken();
|
||||||
|
DateTime refreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
|
||||||
|
|
||||||
|
// Save refresh token to database
|
||||||
|
user.SetRefreshToken(refreshToken, refreshExpiry);
|
||||||
|
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||||
|
|
||||||
return Result.Success(new AuthResponseDto
|
return Result.Success(new AuthResponseDto
|
||||||
{
|
{
|
||||||
AccessToken = token,
|
AccessToken = token,
|
||||||
RefreshToken = string.Empty,
|
RefreshToken = refreshToken,
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
Username = user.Username,
|
Username = user.Username,
|
||||||
DisplayName = user.DisplayName
|
DisplayName = user.DisplayName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
|
||||||
|
|
||||||
|
public record RefreshTokenCommand(string RefreshToken) : ICommand<AuthResponseDto>;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using Knot.Contracts.Auth.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||||
|
using Knot.Contracts.Auth.Domain;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
|
||||||
|
|
||||||
|
internal sealed class RefreshTokenCommandHandler : ICommandHandler<RefreshTokenCommand, AuthResponseDto>
|
||||||
|
{
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly IJwtTokenProvider _tokenProvider;
|
||||||
|
|
||||||
|
public RefreshTokenCommandHandler(
|
||||||
|
IUserRepository userRepository,
|
||||||
|
IJwtTokenProvider tokenProvider)
|
||||||
|
{
|
||||||
|
_userRepository = userRepository;
|
||||||
|
_tokenProvider = tokenProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<AuthResponseDto>> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.RefreshToken))
|
||||||
|
{
|
||||||
|
return Result.Failure<AuthResponseDto>(
|
||||||
|
new Error("Auth.InvalidRefreshToken", "Refresh token is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userRepository.GetByRefreshTokenAsync(request.RefreshToken, cancellationToken);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return Result.Failure<AuthResponseDto>(
|
||||||
|
new Error("Auth.InvalidRefreshToken", "Invalid or expired refresh token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if refresh token has expired
|
||||||
|
if (user.RefreshTokenExpiry.HasValue && user.RefreshTokenExpiry.Value < DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
// Clear expired refresh token
|
||||||
|
user.SetRefreshToken(null, null);
|
||||||
|
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||||
|
|
||||||
|
return Result.Failure<AuthResponseDto>(
|
||||||
|
new Error("Auth.RefreshTokenExpired", "Refresh token has expired. Please login again."));
|
||||||
|
}
|
||||||
|
|
||||||
|
var newAccessToken = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
|
||||||
|
|
||||||
|
var newRefreshToken = _tokenProvider.GenerateRefreshToken();
|
||||||
|
var newRefreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
|
||||||
|
user.SetRefreshToken(newRefreshToken, newRefreshExpiry);
|
||||||
|
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||||
|
|
||||||
|
return Result.Success(new AuthResponseDto
|
||||||
|
{
|
||||||
|
AccessToken = newAccessToken,
|
||||||
|
RefreshToken = newRefreshToken,
|
||||||
|
UserId = user.Id,
|
||||||
|
Username = user.Username,
|
||||||
|
DisplayName = user.DisplayName,
|
||||||
|
Avatar = user.Avatar
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using BCrypt.Net;
|
using BCrypt.Net;
|
||||||
using BCrypt.Net;
|
|
||||||
using Knot.Contracts.Auth.Application.Abstractions;
|
using Knot.Contracts.Auth.Application.Abstractions;
|
||||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||||
using Knot.Contracts.Settings.Application.Abstractions;
|
using Knot.Contracts.Settings.Application.Abstractions;
|
||||||
@@ -9,9 +8,6 @@ using Knot.Shared.Kernel;
|
|||||||
|
|
||||||
namespace Knot.Modules.Auth.Application.Users.Register;
|
namespace Knot.Modules.Auth.Application.Users.Register;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// ������� ��� ����������� ������ ������������.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record RegisterUserCommand(
|
public sealed record RegisterUserCommand(
|
||||||
string Username,
|
string Username,
|
||||||
string Password,
|
string Password,
|
||||||
@@ -19,9 +15,6 @@ public sealed record RegisterUserCommand(
|
|||||||
string? Email,
|
string? Email,
|
||||||
string? Bio) : ICommand<AuthResponseDto>;
|
string? Bio) : ICommand<AuthResponseDto>;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// ���������� ������� �����������.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto>
|
internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto>
|
||||||
{
|
{
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
@@ -48,16 +41,13 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
|
|||||||
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationDisabled);
|
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationDisabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. �������� ������������ username
|
|
||||||
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
|
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
|
||||||
{
|
{
|
||||||
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityUsernameNotUnique);
|
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityUsernameNotUnique);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. ����������� ������
|
|
||||||
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
|
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
|
||||||
|
|
||||||
// 3. �������� ��������
|
|
||||||
var user = User.Create(
|
var user = User.Create(
|
||||||
request.Username,
|
request.Username,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
@@ -65,21 +55,33 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
|
|||||||
request.Email,
|
request.Email,
|
||||||
request.Bio);
|
request.Bio);
|
||||||
|
|
||||||
// 4. ���������� - ���������� ����� � Domain User
|
var repoImpl = _userRepository as Infrastructure.Persistence.UserRepository;
|
||||||
var repoWithDomainUserAdd = _userRepository as Infrastructure.Persistence.UserRepository;
|
repoImpl?.Add(user);
|
||||||
repoWithDomainUserAdd?.Add(user);
|
|
||||||
|
|
||||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
|
// Get the saved user as contract
|
||||||
|
var userContract = await _userRepository.GetByUsernameAsync(request.Username, cancellationToken);
|
||||||
|
if (userContract == null)
|
||||||
|
{
|
||||||
|
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
string token = _tokenProvider.Generate(userContract.Id, userContract.Username, userContract.DisplayName, userContract.Avatar);
|
||||||
|
string refreshToken = _tokenProvider.GenerateRefreshToken();
|
||||||
|
DateTime refreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
|
||||||
|
|
||||||
|
userContract.SetRefreshToken(refreshToken, refreshExpiry);
|
||||||
|
await _userRepository.UpdateAsync(userContract, cancellationToken);
|
||||||
|
|
||||||
return Result.Success(new AuthResponseDto
|
return Result.Success(new AuthResponseDto
|
||||||
{
|
{
|
||||||
AccessToken = token,
|
AccessToken = token,
|
||||||
RefreshToken = string.Empty,
|
RefreshToken = refreshToken,
|
||||||
UserId = user.Id,
|
UserId = userContract.Id,
|
||||||
Username = user.Username,
|
Username = userContract.Username,
|
||||||
DisplayName = user.DisplayName
|
DisplayName = userContract.DisplayName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public sealed class User : AggregateRoot<Guid>
|
|||||||
public bool IsBanned { get; private set; }
|
public bool IsBanned { get; private set; }
|
||||||
public string? PhoneNumber { get; private set; }
|
public string? PhoneNumber { get; private set; }
|
||||||
public string? RefreshToken { get; private set; }
|
public string? RefreshToken { get; private set; }
|
||||||
|
public DateTime? RefreshTokenExpiry { get; private set; }
|
||||||
public DateTime? BannedUntil { get; private set; }
|
public DateTime? BannedUntil { get; private set; }
|
||||||
|
|
||||||
public void Ban() {
|
public void Ban() {
|
||||||
@@ -48,9 +49,10 @@ public sealed class User : AggregateRoot<Guid>
|
|||||||
PhoneNumber = phoneNumber;
|
PhoneNumber = phoneNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetRefreshToken(string? refreshToken)
|
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
|
||||||
{
|
{
|
||||||
RefreshToken = refreshToken;
|
RefreshToken = refreshToken;
|
||||||
|
RefreshTokenExpiry = expiry;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetBannedUntil(DateTime? bannedUntil)
|
public void SetBannedUntil(DateTime? bannedUntil)
|
||||||
@@ -79,6 +81,8 @@ public sealed class User : AggregateRoot<Guid>
|
|||||||
BannedUntil = contract.BannedUntil;
|
BannedUntil = contract.BannedUntil;
|
||||||
SetOnline(contract.IsOnline, contract.LastSeen);
|
SetOnline(contract.IsOnline, contract.LastSeen);
|
||||||
UserDomain = contract.Domain;
|
UserDomain = contract.Domain;
|
||||||
|
RefreshToken = contract.RefreshToken;
|
||||||
|
RefreshTokenExpiry = contract.RefreshTokenExpiry;
|
||||||
}
|
}
|
||||||
|
|
||||||
private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null)
|
private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null)
|
||||||
@@ -181,7 +185,9 @@ public sealed class User : AggregateRoot<Guid>
|
|||||||
IsOnline = IsOnline,
|
IsOnline = IsOnline,
|
||||||
IsExternal = IsExternal,
|
IsExternal = IsExternal,
|
||||||
Domain = _domain,
|
Domain = _domain,
|
||||||
LastSeen = LastSeen
|
LastSeen = LastSeen,
|
||||||
|
RefreshToken = RefreshToken,
|
||||||
|
RefreshTokenExpiry = RefreshTokenExpiry
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ internal sealed class JwtTokenProvider : IJwtTokenProvider
|
|||||||
return Convert.ToBase64String(randomBytes);
|
return Convert.ToBase64String(randomBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public DateTime GetRefreshTokenExpiry()
|
||||||
|
{
|
||||||
|
var expiryInDays = int.Parse(_configuration["Jwt:RefreshExpiryInDays"] ?? "30");
|
||||||
|
return DateTime.UtcNow.AddDays(expiryInDays);
|
||||||
|
}
|
||||||
|
|
||||||
public string Generate(Guid userId, string username, string displayName, string? avatar)
|
public string Generate(Guid userId, string username, string displayName, string? avatar)
|
||||||
{
|
{
|
||||||
var claims = new Claim[]
|
var claims = new Claim[]
|
||||||
|
|||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
using System;
|
||||||
|
using Knot.Modules.Auth.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Knot.Modules.Auth.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
[DbContext(typeof(AuthDbContext))]
|
||||||
|
[Migration("20270419220000_AddRefreshTokenExpiry")]
|
||||||
|
partial class AddRefreshTokenExpiry
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.0-rc.1.25451.105")
|
||||||
|
.HasAnnotation("Relational:DefaultSchema", "identity");
|
||||||
|
|
||||||
|
modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Avatar")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("BannedUntil")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Bio")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Birthday")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Domain")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("HideStatus")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("HideStoryViews")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBanned")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsExternal")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsOnline")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastSeen")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("RefreshToken")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RefreshTokenExpiry")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users", "identity");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Knot.Modules.Auth.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddRefreshTokenExpiry : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "RefreshTokenExpiry",
|
||||||
|
schema: "identity",
|
||||||
|
table: "Users",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RefreshTokenExpiry",
|
||||||
|
schema: "identity",
|
||||||
|
table: "Users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
using Knot.Shared.Kernel;
|
|
||||||
using Knot.Modules.Auth.Application.Users.Login;
|
|
||||||
using Knot.Modules.Auth.Application.Users.Register;
|
|
||||||
using Knot.Modules.Auth.Application.Users.GetMe;
|
using Knot.Modules.Auth.Application.Users.GetMe;
|
||||||
|
using Knot.Modules.Auth.Application.Users.Login;
|
||||||
|
using Knot.Modules.Auth.Application.Users.RefreshToken;
|
||||||
|
using Knot.Modules.Auth.Application.Users.Register;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Routing;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
|
||||||
namespace Knot.Modules.Auth.Presentation.Endpoints;
|
namespace Knot.Modules.Auth.Presentation.Endpoints;
|
||||||
|
|
||||||
@@ -28,6 +29,12 @@ public static class AuthEndpoints
|
|||||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
|
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group.MapPost("refresh", async ([FromBody] RefreshTokenCommand command, ISender sender, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var result = await sender.Send(command, ct);
|
||||||
|
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
|
||||||
|
});
|
||||||
|
|
||||||
group.MapGet("me", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
|
group.MapGet("me", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new GetMeQuery(userContext.UserId), ct);
|
var result = await sender.Send(new GetMeQuery(userContext.UserId), ct);
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ public record MessageDetailDto(
|
|||||||
bool? PollIsMultipleChoice = null,
|
bool? PollIsMultipleChoice = null,
|
||||||
bool? PollIsAnonymous = null,
|
bool? PollIsAnonymous = null,
|
||||||
bool? PollIsClosed = null,
|
bool? PollIsClosed = null,
|
||||||
List<Guid>? UserVotedOptionIds = null
|
List<Guid>? UserVotedOptionIds = null,
|
||||||
|
bool IsDeletedForUser = false
|
||||||
);
|
);
|
||||||
|
|
||||||
public record ReplyToMessageDto(
|
public record ReplyToMessageDto(
|
||||||
|
|||||||
+8
-13
@@ -42,10 +42,16 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
|
|||||||
|
|
||||||
if (request.DeleteForAll)
|
if (request.DeleteForAll)
|
||||||
{
|
{
|
||||||
|
// Only message sender can delete for everyone
|
||||||
if (message.SenderId == request.UserId)
|
if (message.SenderId == request.UserId)
|
||||||
{
|
{
|
||||||
message.Delete();
|
message.Delete();
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// If not the sender, just delete for current user
|
||||||
|
message.DeleteForUser(request.UserId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -55,24 +61,13 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
|
|||||||
await _messageRepository.UpdateAsync(message, cancellationToken);
|
await _messageRepository.UpdateAsync(message, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.DeleteForAll)
|
// Notify all clients in the chat about the deletion
|
||||||
{
|
|
||||||
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
|
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
|
||||||
{
|
{
|
||||||
chatId = request.ChatId,
|
chatId = request.ChatId,
|
||||||
messageIds = request.MessageIds,
|
messageIds = request.MessageIds,
|
||||||
deleteForAll = true
|
deleteForAll = request.DeleteForAll
|
||||||
});
|
});
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await _hubContext.Clients.User(request.UserId.ToString()).SendAsync("messages_deleted", new
|
|
||||||
{
|
|
||||||
chatId = request.ChatId,
|
|
||||||
messageIds = request.MessageIds,
|
|
||||||
deleteForAll = false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return global::Knot.Shared.Kernel.Result.Success();
|
return global::Knot.Shared.Kernel.Result.Success();
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-9
@@ -3,17 +3,17 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
using Knot.Contracts.Messaging.Domain;
|
using Knot.Contracts.Messaging.Domain;
|
||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
|
||||||
using Knot.Modules.Conversations.Application.DTOs;
|
using Knot.Modules.Conversations.Application.DTOs;
|
||||||
using Knot.Contracts.Conversations.Domain;
|
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Messages.GetMessages;
|
namespace Knot.Modules.Conversations.Application.Messages.GetMessages;
|
||||||
|
|
||||||
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor, long? Pivot = null, int? Limit = null) : IQuery<List<MessageDetailDto>>;
|
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor, long? Pivot = null, long? AfterSequenceId = null, int? Limit = null) : IQuery<List<MessageDetailDto>>;
|
||||||
|
|
||||||
internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>>
|
internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>>
|
||||||
{
|
{
|
||||||
@@ -41,7 +41,12 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
List<Message> messages;
|
List<Message> messages;
|
||||||
int queryLimit = request.Limit ?? ChatConstants.DefaultMessageQueryLimit;
|
int queryLimit = request.Limit ?? ChatConstants.DefaultMessageQueryLimit;
|
||||||
|
|
||||||
if (request.Pivot.HasValue)
|
if (request.AfterSequenceId.HasValue)
|
||||||
|
{
|
||||||
|
// Получаем только сообщения ПОСЛЕ указанного sequenceId (для синхронизации)
|
||||||
|
messages = await _messageRepository.GetChatMessagesAfterAsync(request.ChatId, request.AfterSequenceId.Value, queryLimit, cancellationToken);
|
||||||
|
}
|
||||||
|
else if (request.Pivot.HasValue)
|
||||||
{
|
{
|
||||||
messages = await _messageRepository.GetChatMessagesAroundAsync(request.ChatId, request.Pivot.Value, queryLimit, cancellationToken);
|
messages = await _messageRepository.GetChatMessagesAroundAsync(request.ChatId, request.Pivot.Value, queryLimit, cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -115,6 +120,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
senders.TryGetValue(message.SenderId, out var sender);
|
senders.TryGetValue(message.SenderId, out var sender);
|
||||||
reactionsByMessage.TryGetValue(message.Id, out var reactions);
|
reactionsByMessage.TryGetValue(message.Id, out var reactions);
|
||||||
|
|
||||||
|
|
||||||
Message? replyMsg = null;
|
Message? replyMsg = null;
|
||||||
if (message.ReplyToId.HasValue)
|
if (message.ReplyToId.HasValue)
|
||||||
{
|
{
|
||||||
@@ -141,7 +147,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
replyMsg is MediaMessage mm ? mm.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() : new List<MediaDto>(),
|
replyMsg is MediaMessage mm ? mm.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() : new List<MediaDto>(),
|
||||||
replySender != null ? new MessageSenderDto(replySender.Id, replySender.Username, replySender.DisplayName, replySender.Avatar) : null
|
replySender != null ? new MessageSenderDto(replySender.Id, replySender.Username, replySender.DisplayName, replySender.Avatar) : null
|
||||||
) : null,
|
) : null,
|
||||||
message is TextMessage tm ? tm.Quote : null,
|
(message as TextMessage)?.Quote,
|
||||||
message.IsEdited,
|
message.IsEdited,
|
||||||
message.IsDeleted,
|
message.IsDeleted,
|
||||||
message.CreatedAt,
|
message.CreatedAt,
|
||||||
@@ -154,19 +160,22 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
(message as MediaMessage)?.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List<MediaDto>(),
|
(message as MediaMessage)?.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List<MediaDto>(),
|
||||||
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
||||||
new List<ReadByDto>(), // ReadBy not implemented in this detailed view yet
|
new List<ReadByDto>(), // ReadBy not implemented in this detailed view yet
|
||||||
reactions?.Select(r => {
|
reactions?.Select(r =>
|
||||||
|
{
|
||||||
senders.TryGetValue(r.UserId, out var ru);
|
senders.TryGetValue(r.UserId, out var ru);
|
||||||
return new MessageReactionDto(r.Id, r.Emoji, r.UserId, ru != null ? new MessageSenderDto(ru.Id, ru.Username, ru.DisplayName, ru.Avatar) : null);
|
return new MessageReactionDto(r.Id, r.Emoji, r.UserId, ru != null ? new MessageSenderDto(ru.Id, ru.Username, ru.DisplayName, ru.Avatar) : null);
|
||||||
}).ToList() ?? new List<MessageReactionDto>(),
|
}).ToList() ?? new List<MessageReactionDto>(),
|
||||||
(message as CallMessage)?.CallType,
|
(message as CallMessage)?.CallType,
|
||||||
(message as CallMessage)?.CallStatus,
|
(message as CallMessage)?.CallStatus,
|
||||||
(message as CallMessage)?.Duration,
|
(message as CallMessage)?.Duration,
|
||||||
(message as PollMessage)?.Options.Select(o => {
|
(message as PollMessage)?.Options.Select(o =>
|
||||||
|
{
|
||||||
var pm = (PollMessage)message;
|
var pm = (PollMessage)message;
|
||||||
var voters = pm.IsAnonymous == false
|
var voters = pm.IsAnonymous == false
|
||||||
? pm.Votes
|
? pm.Votes
|
||||||
.Where(v => v.OptionId == o.Id)
|
.Where(v => v.OptionId == o.Id)
|
||||||
.Select(v => {
|
.Select(v =>
|
||||||
|
{
|
||||||
senders.TryGetValue(v.UserId, out var vu);
|
senders.TryGetValue(v.UserId, out var vu);
|
||||||
return vu != null
|
return vu != null
|
||||||
? new MessageSenderDto(vu.Id, vu.Username, vu.DisplayName, vu.Avatar)
|
? new MessageSenderDto(vu.Id, vu.Username, vu.DisplayName, vu.Avatar)
|
||||||
@@ -179,7 +188,8 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
(message as PollMessage)?.IsMultipleChoice,
|
(message as PollMessage)?.IsMultipleChoice,
|
||||||
(message as PollMessage)?.IsAnonymous,
|
(message as PollMessage)?.IsAnonymous,
|
||||||
(message as PollMessage)?.IsClosed,
|
(message as PollMessage)?.IsClosed,
|
||||||
(message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList()
|
(message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList(),
|
||||||
|
message.IsDeletedForUser(request.UserId)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,17 @@ public static class MessagesEndpoints
|
|||||||
{
|
{
|
||||||
var group = app.MapGroup("api/messages").RequireAuthorization();
|
var group = app.MapGroup("api/messages").RequireAuthorization();
|
||||||
|
|
||||||
group.MapGet("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromQuery] string? cursor, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
group.MapGet("chat/{chatId:guid}", async (
|
||||||
|
[FromRoute] Guid chatId,
|
||||||
|
[FromQuery] string? cursor,
|
||||||
|
[FromQuery] long? afterSequenceId,
|
||||||
|
[FromQuery] long? pivot,
|
||||||
|
[FromQuery] int? limit,
|
||||||
|
ISender sender,
|
||||||
|
IUserContext userContext,
|
||||||
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new GetMessagesQuery(userContext.UserId, chatId, cursor), ct);
|
var result = await sender.Send(new GetMessagesQuery(userContext.UserId, chatId, cursor, pivot, afterSequenceId, limit), ct);
|
||||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
|
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,20 @@ public sealed class MessageRepository : IMessageRepository
|
|||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<Message>> GetChatMessagesAfterAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var builder = Builders<Message>.Filter;
|
||||||
|
var filter = builder.And(
|
||||||
|
builder.Eq(m => m.ChatId, chatId),
|
||||||
|
builder.Gt(m => m.SequenceId, sequenceId)
|
||||||
|
);
|
||||||
|
|
||||||
|
return await _messages.Find(filter)
|
||||||
|
.SortBy(m => m.SequenceId)
|
||||||
|
.Limit(limit)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken)
|
public async Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var builder = Builders<Message>.Filter;
|
var builder = Builders<Message>.Filter;
|
||||||
|
|||||||
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.
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.
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
|
||||||
Binary file not shown.
@@ -0,0 +1,2 @@
|
|||||||
|
#Tue Apr 14 00:13:58 MSK 2026
|
||||||
|
java.home=C\:\\Program Files\\Android\\Android Studio\\jbr
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("com.google.dagger.hilt.android")
|
||||||
|
id("com.google.gms.google-services")
|
||||||
|
kotlin("kapt")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "ru.knot.messager"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "ru.knot.messager"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
vectorDrawables {
|
||||||
|
useSupportLibrary = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подключаем все наши папки с кодом как sourceSets
|
||||||
|
sourceSets {
|
||||||
|
getByName("main") {
|
||||||
|
java.srcDirs(
|
||||||
|
"src/main/kotlin",
|
||||||
|
"../auth",
|
||||||
|
"../chats",
|
||||||
|
"../core",
|
||||||
|
"../calls",
|
||||||
|
"../stories",
|
||||||
|
"../contacts",
|
||||||
|
"../profiles",
|
||||||
|
"../settings",
|
||||||
|
"../navigation"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = "1.5.8"
|
||||||
|
}
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// AndroidX & UI
|
||||||
|
implementation("androidx.core:core-ktx:1.12.0")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
|
||||||
|
implementation("androidx.activity:activity-compose:1.8.1")
|
||||||
|
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
|
||||||
|
implementation("androidx.compose.ui:ui")
|
||||||
|
implementation("androidx.compose.ui:ui-graphics")
|
||||||
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
implementation("androidx.compose.material3:material3")
|
||||||
|
implementation("com.google.android.material:material:1.11.0")
|
||||||
|
implementation("androidx.navigation:navigation-compose:2.7.5")
|
||||||
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
|
|
||||||
|
// Hilt
|
||||||
|
implementation("com.google.dagger:hilt-android:2.48")
|
||||||
|
kapt("com.google.dagger:hilt-android-compiler:2.48")
|
||||||
|
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
||||||
|
|
||||||
|
// Network & SignalR
|
||||||
|
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||||
|
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||||
|
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||||
|
implementation("com.microsoft.signalr:signalr:7.0.0")
|
||||||
|
|
||||||
|
// WebRTC
|
||||||
|
implementation("com.github.webrtc-sdk:android:104.5112.01")
|
||||||
|
|
||||||
|
// Media3 (ExoPlayer)
|
||||||
|
implementation("androidx.media3:media3-exoplayer:1.2.0")
|
||||||
|
implementation("androidx.media3:media3-ui:1.2.0")
|
||||||
|
implementation("androidx.media3:media3-common:1.2.0")
|
||||||
|
|
||||||
|
// Images & GIF
|
||||||
|
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||||
|
implementation("io.coil-kt:coil-gif:2.5.0")
|
||||||
|
implementation("io.coil-kt:coil-svg:2.5.0")
|
||||||
|
implementation("io.coil-kt:coil-video:2.5.0")
|
||||||
|
|
||||||
|
// Security
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
|
||||||
|
// UCrop (Image Cropping)
|
||||||
|
implementation("com.github.yalantis:ucrop:2.2.8")
|
||||||
|
|
||||||
|
// Firebase (Push Notifications)
|
||||||
|
implementation(platform("com.google.firebase:firebase-bom:32.7.0"))
|
||||||
|
implementation("com.google.firebase:firebase-messaging-ktx")
|
||||||
|
implementation("com.google.firebase:firebase-analytics-ktx")
|
||||||
|
|
||||||
|
// Room
|
||||||
|
val room_version = "2.6.1"
|
||||||
|
implementation("androidx.room:room-runtime:$room_version")
|
||||||
|
implementation("androidx.room:room-ktx:$room_version")
|
||||||
|
implementation("androidx.room:room-paging:$room_version")
|
||||||
|
kapt("androidx.room:room-compiler:$room_version")
|
||||||
|
|
||||||
|
// Paging 3
|
||||||
|
implementation("androidx.paging:paging-runtime-ktx:3.2.1")
|
||||||
|
implementation("androidx.paging:paging-compose:3.2.1")
|
||||||
|
|
||||||
|
// WorkManager
|
||||||
|
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||||
|
implementation("androidx.hilt:hilt-work:1.1.0")
|
||||||
|
kapt("androidx.hilt:hilt-compiler:1.1.0")
|
||||||
|
|
||||||
|
// Testing
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||||
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "483917912506",
|
||||||
|
"project_id": "knot-bad1a",
|
||||||
|
"storage_bucket": "knot-bad1a.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:483917912506:android:cd39213364869ef9e82583",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "ru.knot.messager"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyBAL_bZJYaa7rGERLX63LeFXz-__JXRWQY"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="ru.knot.messager">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name="com.knot.messenger.MainApplication"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.KnotMessenger"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name="com.knot.messenger.MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:theme="@style/Theme.KnotMessenger">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name="core.notifications.data.ForkFirebaseMessagingService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.knot.messenger
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import navigation.AppNavigation
|
||||||
|
import core.presentation.theme.ForkMessengerTheme
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
@javax.inject.Inject
|
||||||
|
lateinit var navigationManager: core.utils.NavigationManager
|
||||||
|
|
||||||
|
@javax.inject.Inject
|
||||||
|
lateinit var signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
android.util.Log.d("MainActivity", "onCreate called")
|
||||||
|
signalrNotificationObserver.start()
|
||||||
|
android.util.Log.d("MainActivity", "signalrNotificationObserver.start() called")
|
||||||
|
|
||||||
|
intent.getStringExtra("chatId")?.let { chatId ->
|
||||||
|
navigationManager.navigateToChat(chatId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request notifications permission for Android 13+
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
androidx.core.app.ActivityCompat.requestPermissions(
|
||||||
|
this,
|
||||||
|
arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
|
||||||
|
101
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
ForkMessengerTheme {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
AppNavigation(navigationManager = navigationManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: android.content.Intent?) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
intent?.getStringExtra("chatId")?.let { chatId ->
|
||||||
|
navigationManager.navigateToChat(chatId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.knot.messenger
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.ImageLoaderFactory
|
||||||
|
import coil.decode.VideoFrameDecoder
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class MainApplication : Application(), ImageLoaderFactory {
|
||||||
|
override fun newImageLoader(): ImageLoader {
|
||||||
|
return ImageLoader.Builder(this)
|
||||||
|
.components {
|
||||||
|
add(VideoFrameDecoder.Factory())
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,64 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">ForkMessenger</string>
|
||||||
|
<string name="login">Login</string>
|
||||||
|
<string name="register">Register</string>
|
||||||
|
<string name="username">Username</string>
|
||||||
|
<string name="password">Password</string>
|
||||||
|
<string name="display_name">Display Name</string>
|
||||||
|
<string name="settings">Settings</string>
|
||||||
|
<string name="save">Save</string>
|
||||||
|
<string name="back">Back</string>
|
||||||
|
<string name="server_connection">Server Connection</string>
|
||||||
|
<string name="api_base_url">API Base URL</string>
|
||||||
|
<string name="server_features">Server Features</string>
|
||||||
|
<string name="stories">Stories</string>
|
||||||
|
<string name="polls">Polls</string>
|
||||||
|
<string name="calls">Calls</string>
|
||||||
|
<string name="groups">Groups</string>
|
||||||
|
<string name="enabled">Enabled</string>
|
||||||
|
<string name="disabled">Disabled</string>
|
||||||
|
<string name="limits">Limits</string>
|
||||||
|
<string name="max_file_size">Max File Size</string>
|
||||||
|
<string name="max_group_members">Max Group Members</string>
|
||||||
|
<string name="message">Message</string>
|
||||||
|
<string name="call">Call</string>
|
||||||
|
<string name="block">Block</string>
|
||||||
|
<string name="profile">Profile</string>
|
||||||
|
<string name="confirm_password">Confirm Password</string>
|
||||||
|
<string name="passwords_not_match">Passwords do not match</string>
|
||||||
|
<string name="no_account_register">Don\'t have an account? Register</string>
|
||||||
|
<string name="already_have_account">Already have an account? Login</string>
|
||||||
|
<string name="error_occurred">An error occurred</string>
|
||||||
|
<string name="loading">Loading...</string>
|
||||||
|
<string name="chats_title">Chats</string>
|
||||||
|
<string name="contacts_title">Contacts</string>
|
||||||
|
<string name="stories_title">Stories</string>
|
||||||
|
<string name="create_story">Create Story</string>
|
||||||
|
<string name="send_message_hint">Type a message...</string>
|
||||||
|
<string name="reply_to_user">Reply to %1$s...</string>
|
||||||
|
<string name="story_editor">STORY EDITOR</string>
|
||||||
|
<string name="publish">PUBLISH</string>
|
||||||
|
<string name="start_creation">START CREATION</string>
|
||||||
|
<string name="text_tool">TEXT</string>
|
||||||
|
<string name="crop_tool">CROP</string>
|
||||||
|
<string name="stickers_tool">STICKERS</string>
|
||||||
|
<string name="brush_tool">BRUSH</string>
|
||||||
|
<string name="filters_tool">FILTERS</string>
|
||||||
|
<string name="remove">Remove</string>
|
||||||
|
<string name="no_chats_found">No chats found</string>
|
||||||
|
<string name="typing">typing...</string>
|
||||||
|
<string name="video_call">Video Call</string>
|
||||||
|
<string name="emoji">Emoji</string>
|
||||||
|
<string name="attach">Attach</string>
|
||||||
|
<string name="message_placeholder">Message...</string>
|
||||||
|
<string name="voice_message">Voice Message</string>
|
||||||
|
<string name="send">Send</string>
|
||||||
|
<string name="reply_photo">Photo</string>
|
||||||
|
<string name="reply_video">Video</string>
|
||||||
|
<string name="reply_audio">Audio</string>
|
||||||
|
<string name="reply_file">File</string>
|
||||||
|
<string name="reply_gif">GIF</string>
|
||||||
|
<string name="reply_prefix">Reply to </string>
|
||||||
|
<string name="reply_self">yourself</string>
|
||||||
|
<string name="no_messages_yet">No messages yet</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">ForkMessenger</string>
|
||||||
|
<string name="login">Войти</string>
|
||||||
|
<string name="register">Регистрация</string>
|
||||||
|
<string name="username">Имя пользователя</string>
|
||||||
|
<string name="password">Пароль</string>
|
||||||
|
<string name="display_name">Отображаемое имя</string>
|
||||||
|
<string name="settings">Настройки</string>
|
||||||
|
<string name="save">Сохранить</string>
|
||||||
|
<string name="back">Назад</string>
|
||||||
|
<string name="server_connection">Подключение к серверу</string>
|
||||||
|
<string name="api_base_url">API Base URL</string>
|
||||||
|
<string name="server_features">Функции сервера</string>
|
||||||
|
<string name="stories">Истории</string>
|
||||||
|
<string name="polls">Опросы</string>
|
||||||
|
<string name="calls">Звонки</string>
|
||||||
|
<string name="groups">Группы</string>
|
||||||
|
<string name="enabled">Включено</string>
|
||||||
|
<string name="disabled">Отключено</string>
|
||||||
|
<string name="limits">Лимиты</string>
|
||||||
|
<string name="max_file_size">Макс. размер файла</string>
|
||||||
|
<string name="max_group_members">Макс. участников в группе</string>
|
||||||
|
<string name="message">Сообщение</string>
|
||||||
|
<string name="call">Позвонить</string>
|
||||||
|
<string name="block">Заблокировать</string>
|
||||||
|
<string name="profile">Профиль</string>
|
||||||
|
<string name="confirm_password">Подтвердите пароль</string>
|
||||||
|
<string name="passwords_not_match">Пароли не совпадают</string>
|
||||||
|
<string name="no_account_register">Нет аккаунта? Зарегистрироваться</string>
|
||||||
|
<string name="already_have_account">Уже есть аккаунт? Войти</string>
|
||||||
|
<string name="error_occurred">Произошла ошибка</string>
|
||||||
|
<string name="loading">Загрузка...</string>
|
||||||
|
<string name="chats_title">Чаты</string>
|
||||||
|
<string name="contacts_title">Контакты</string>
|
||||||
|
<string name="stories_title">Истории</string>
|
||||||
|
<string name="create_story">Создать историю</string>
|
||||||
|
<string name="send_message_hint">Напишите сообщение...</string>
|
||||||
|
<string name="reply_to_user">Ответить %1$s...</string>
|
||||||
|
<string name="story_editor">РЕДАКТОР ИСТОРИЙ</string>
|
||||||
|
<string name="publish">ОПУБЛИКОВАТЬ</string>
|
||||||
|
<string name="start_creation">НАЧАТЬ СОЗДАНИЕ</string>
|
||||||
|
<string name="text_tool">ТЕКСТ</string>
|
||||||
|
<string name="crop_tool">ОБРЕЗКА</string>
|
||||||
|
<string name="stickers_tool">СТИКЕРЫ</string>
|
||||||
|
<string name="brush_tool">КИСТЬ</string>
|
||||||
|
<string name="filters_tool">ФИЛЬТРЫ</string>
|
||||||
|
<string name="remove">Удалить</string>
|
||||||
|
<string name="no_chats_found">Чаты не найдены</string>
|
||||||
|
<string name="typing">печатает...</string>
|
||||||
|
<string name="video_call">Видеозвонок</string>
|
||||||
|
<string name="emoji">Эмодзи</string>
|
||||||
|
<string name="attach">Прикрепить</string>
|
||||||
|
<string name="message_placeholder">Сообщение...</string>
|
||||||
|
<string name="voice_message">Голосовое сообщение</string>
|
||||||
|
<string name="send">Отправить</string>
|
||||||
|
<string name="search_hint">Поиск...</string>
|
||||||
|
<string name="online">В сети</string>
|
||||||
|
<string name="last_seen">Был(а): %1$s</string>
|
||||||
|
<string name="last_seen_recently">недавно</string>
|
||||||
|
<string name="all">Все</string>
|
||||||
|
<string name="online_tab">Онлайн</string>
|
||||||
|
<string name="blocked">Заблокированные</string>
|
||||||
|
<string name="media">Медиа</string>
|
||||||
|
<string name="notifications">Уведомления</string>
|
||||||
|
<string name="mute">Без звука</string>
|
||||||
|
<string name="unmute">Включить звук</string>
|
||||||
|
<string name="log_out">Выйти из аккаунта</string>
|
||||||
|
<string name="bio">О себе</string>
|
||||||
|
<string name="edit_profile">Редактировать профиль</string>
|
||||||
|
<string name="username_label">Имя пользователя</string>
|
||||||
|
<string name="change_photo">Изменить фото</string>
|
||||||
|
<string name="cancel">Отмена</string>
|
||||||
|
<string name="crop">Обрезать</string>
|
||||||
|
<string name="chats">Чаты</string>
|
||||||
|
<string name="contacts_tab">Контакты</string>
|
||||||
|
<string name="profile_tab">Профиль</string>
|
||||||
|
<string name="saving">Сохранение...</string>
|
||||||
|
<string name="reply_photo">Фото</string>
|
||||||
|
<string name="reply_video">Видео</string>
|
||||||
|
<string name="reply_audio">Аудио</string>
|
||||||
|
<string name="reply_file">Файл</string>
|
||||||
|
<string name="reply_gif">GIF</string>
|
||||||
|
<string name="reply_prefix">Ответ </string>
|
||||||
|
<string name="reply_self">самому себе</string>
|
||||||
|
<string name="no_messages_yet">Сообщений пока нет</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.KnotMessenger" parent="Theme.Material3.DayNight.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">#0F0F10</item>
|
||||||
|
<item name="android:windowBackground">#0F0F10</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<!-- Для отладки: доверяем пользовательским сертификатам -->
|
||||||
|
<debug-overrides>
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="user" />
|
||||||
|
<certificates src="system" />
|
||||||
|
</trust-anchors>
|
||||||
|
</debug-overrides>
|
||||||
|
|
||||||
|
<!-- Разрешаем cleartext (HTTP) трафик для локальных IP -->
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="true">localhost</domain>
|
||||||
|
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||||
|
<domain includeSubdomains="true">10.0.0.0/8</domain>
|
||||||
|
<domain includeSubdomains="true">172.16.0.0/12</domain>
|
||||||
|
<domain includeSubdomains="true">192.168.0.0/16</domain>
|
||||||
|
</domain-config>
|
||||||
|
</network-security-config>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package auth.data.remote.api
|
||||||
|
|
||||||
|
import auth.data.remote.dto.AuthRequest
|
||||||
|
import auth.data.remote.dto.AuthResponse
|
||||||
|
import auth.data.remote.dto.RefreshTokenRequest
|
||||||
|
import core.domain.model.ServerConfigModel
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Headers
|
||||||
|
import retrofit2.http.POST
|
||||||
|
|
||||||
|
interface AuthApi {
|
||||||
|
@POST("auth/login")
|
||||||
|
suspend fun login(@Body request: AuthRequest): AuthResponse
|
||||||
|
|
||||||
|
@POST("auth/register")
|
||||||
|
suspend fun register(@Body request: AuthRequest): AuthResponse
|
||||||
|
|
||||||
|
@POST("auth/refresh")
|
||||||
|
suspend fun refreshToken(@Body request: RefreshTokenRequest): AuthResponse
|
||||||
|
|
||||||
|
@GET("config")
|
||||||
|
@Headers("Cache-Control: no-cache")
|
||||||
|
suspend fun getConfig(): ServerConfigModel
|
||||||
|
|
||||||
|
@POST("auth/push-token")
|
||||||
|
@Headers("Cache-Control: no-cache")
|
||||||
|
suspend fun updatePushToken(@Body token: String): Unit
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package auth.data.remote.dto
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
data class AuthRequest(
|
||||||
|
@SerializedName("userName") val userName: String,
|
||||||
|
@SerializedName("password") val password: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AuthResponse(
|
||||||
|
@SerializedName("accessToken") val accessToken: String?,
|
||||||
|
@SerializedName("refreshToken") val refreshToken: String?,
|
||||||
|
@SerializedName("user") val user: UserDto?,
|
||||||
|
@SerializedName("userId") val userId: String?,
|
||||||
|
@SerializedName("username") val username: String?,
|
||||||
|
@SerializedName("displayName") val displayName: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UserDto(
|
||||||
|
@SerializedName("id") val id: String,
|
||||||
|
@SerializedName("userName") val userName: String,
|
||||||
|
@SerializedName("displayName") val displayName: String?,
|
||||||
|
@SerializedName("avatarUrl") val avatarUrl: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RefreshTokenRequest(
|
||||||
|
@SerializedName("refreshToken") val refreshToken: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package auth.data.repository
|
||||||
|
|
||||||
|
import auth.data.remote.api.AuthApi
|
||||||
|
import auth.data.remote.dto.AuthRequest
|
||||||
|
import auth.data.remote.dto.RefreshTokenRequest
|
||||||
|
import auth.domain.model.AuthResult
|
||||||
|
import auth.domain.repository.AuthRepository
|
||||||
|
import core.network.ServerConfig
|
||||||
|
import core.security.TokenManager
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class AuthRepositoryImpl @Inject constructor(
|
||||||
|
private val api: AuthApi,
|
||||||
|
private val tokenManager: TokenManager,
|
||||||
|
private val serverConfig: ServerConfig
|
||||||
|
) : AuthRepository {
|
||||||
|
|
||||||
|
private val _isAuthenticated = kotlinx.coroutines.flow.MutableStateFlow(tokenManager.getToken() != null)
|
||||||
|
|
||||||
|
override suspend fun login(userName: String, password: String): Result<AuthResult> {
|
||||||
|
return try {
|
||||||
|
val response = api.login(AuthRequest(userName, password))
|
||||||
|
val token = response.accessToken ?: return Result.failure(Exception("Token is null"))
|
||||||
|
val userId = response.userId ?: ""
|
||||||
|
|
||||||
|
tokenManager.saveToken(token, userId, response.refreshToken)
|
||||||
|
_isAuthenticated.value = true
|
||||||
|
fetchConfig()
|
||||||
|
Result.success(
|
||||||
|
AuthResult(
|
||||||
|
token = token,
|
||||||
|
refreshToken = response.refreshToken,
|
||||||
|
userId = userId,
|
||||||
|
userName = response.username ?: userName,
|
||||||
|
displayName = response.displayName ?: response.username ?: userName,
|
||||||
|
avatarUrl = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun register(userName: String, password: String): Result<AuthResult> {
|
||||||
|
return try {
|
||||||
|
val response = api.register(AuthRequest(userName, password))
|
||||||
|
val token = response.accessToken ?: return Result.failure(Exception("Token is null"))
|
||||||
|
val userId = response.userId ?: ""
|
||||||
|
|
||||||
|
tokenManager.saveToken(token, userId, response.refreshToken)
|
||||||
|
_isAuthenticated.value = true
|
||||||
|
fetchConfig()
|
||||||
|
Result.success(
|
||||||
|
AuthResult(
|
||||||
|
token = token,
|
||||||
|
refreshToken = response.refreshToken,
|
||||||
|
userId = userId,
|
||||||
|
userName = response.username ?: userName,
|
||||||
|
displayName = response.displayName ?: response.username ?: userName,
|
||||||
|
avatarUrl = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun logout() {
|
||||||
|
tokenManager.deleteToken()
|
||||||
|
_isAuthenticated.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isAuthenticated(): Boolean {
|
||||||
|
return _isAuthenticated.value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isAuthenticatedFlow(): kotlinx.coroutines.flow.StateFlow<Boolean> {
|
||||||
|
return _isAuthenticated.asStateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun fetchConfig(): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
val config = api.getConfig()
|
||||||
|
serverConfig.saveServerConfig(config)
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updatePushToken(token: String) {
|
||||||
|
try {
|
||||||
|
api.updatePushToken(token)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Silent fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun refreshToken(): Result<AuthResult> {
|
||||||
|
val currentRefreshToken = tokenManager.getRefreshToken()
|
||||||
|
if (currentRefreshToken == null) {
|
||||||
|
return Result.failure(Exception("Refresh token is null"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val response = api.refreshToken(RefreshTokenRequest(currentRefreshToken))
|
||||||
|
val newAccessToken = response.accessToken ?: return Result.failure(Exception("New access token is null"))
|
||||||
|
val newRefreshToken = response.refreshToken
|
||||||
|
val userId = response.userId ?: ""
|
||||||
|
|
||||||
|
tokenManager.saveToken(newAccessToken, userId, newRefreshToken)
|
||||||
|
_isAuthenticated.value = true
|
||||||
|
|
||||||
|
Result.success(
|
||||||
|
AuthResult(
|
||||||
|
token = newAccessToken,
|
||||||
|
refreshToken = newRefreshToken,
|
||||||
|
userId = userId,
|
||||||
|
userName = response.username ?: "",
|
||||||
|
displayName = response.displayName ?: "",
|
||||||
|
avatarUrl = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package auth.di
|
||||||
|
|
||||||
|
import auth.data.remote.api.AuthApi
|
||||||
|
import auth.data.repository.AuthRepositoryImpl
|
||||||
|
import auth.domain.repository.AuthRepository
|
||||||
|
import core.network.ServerConfig
|
||||||
|
import core.security.TokenManager
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object AuthModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAuthApi(retrofit: Retrofit): AuthApi {
|
||||||
|
return retrofit.create(AuthApi::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAuthRepository(
|
||||||
|
api: AuthApi,
|
||||||
|
tokenManager: TokenManager,
|
||||||
|
serverConfig: ServerConfig
|
||||||
|
): AuthRepository {
|
||||||
|
return AuthRepositoryImpl(api, tokenManager, serverConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package auth.domain.model
|
||||||
|
|
||||||
|
data class AuthResult(
|
||||||
|
val token: String,
|
||||||
|
val refreshToken: String?,
|
||||||
|
val userId: String,
|
||||||
|
val userName: String,
|
||||||
|
val displayName: String,
|
||||||
|
val avatarUrl: String?
|
||||||
|
)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package auth.domain.repository
|
||||||
|
|
||||||
|
import auth.domain.model.AuthResult
|
||||||
|
|
||||||
|
interface AuthRepository {
|
||||||
|
suspend fun login(userName: String, password: String): Result<AuthResult>
|
||||||
|
suspend fun register(userName: String, password: String): Result<AuthResult>
|
||||||
|
suspend fun logout()
|
||||||
|
suspend fun fetchConfig(): Result<Unit>
|
||||||
|
fun isAuthenticated(): Boolean
|
||||||
|
fun isAuthenticatedFlow(): kotlinx.coroutines.flow.StateFlow<Boolean>
|
||||||
|
suspend fun updatePushToken(token: String)
|
||||||
|
suspend fun refreshToken(): Result<AuthResult>
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package auth.presentation
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import auth.domain.repository.AuthRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class AuthState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val isAuthenticated: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class AuthViewModel @Inject constructor(
|
||||||
|
private val repository: AuthRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(AuthState(isAuthenticated = repository.isAuthenticated()))
|
||||||
|
val state: StateFlow<AuthState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.isAuthenticatedFlow().collect { authenticated ->
|
||||||
|
_state.update { it.copy(isAuthenticated = authenticated) }
|
||||||
|
if (authenticated) {
|
||||||
|
updatePushToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun checkAuth() {
|
||||||
|
_state.update { it.copy(isAuthenticated = repository.isAuthenticated()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun login(userName: String, password: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
repository.login(userName, password)
|
||||||
|
.onSuccess {
|
||||||
|
_state.update { it.copy(isLoading = false, isAuthenticated = true) }
|
||||||
|
updatePushToken()
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
_state.update { it.copy(isLoading = false, error = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun register(userName: String, password: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
repository.register(userName, password)
|
||||||
|
.onSuccess {
|
||||||
|
_state.update { it.copy(isLoading = false, isAuthenticated = true) }
|
||||||
|
updatePushToken()
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
_state.update { it.copy(isLoading = false, error = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshToken() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.refreshToken()
|
||||||
|
.onSuccess {
|
||||||
|
// Token refreshed successfully
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
// Refresh failed, will trigger logout via AuthInterceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updatePushToken() {
|
||||||
|
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
|
||||||
|
if (task.isSuccessful) {
|
||||||
|
val token = task.result
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.updatePushToken(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package auth.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import ru.knot.messager.R
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun LoginScreen(
|
||||||
|
viewModel: AuthViewModel,
|
||||||
|
onNavigateToRegister: () -> Unit,
|
||||||
|
onNavigateToSettings: () -> Unit,
|
||||||
|
onLoginSuccess: () -> Unit
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
var userName by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
LaunchedEffect(state.isAuthenticated) {
|
||||||
|
if (state.isAuthenticated) {
|
||||||
|
onLoginSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.login)) },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onNavigateToSettings) {
|
||||||
|
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = userName,
|
||||||
|
onValueChange = { userName = it },
|
||||||
|
label = { Text(stringResource(R.string.username)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text(stringResource(R.string.password)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (state.isLoading) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
} else {
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.login(userName, password) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = userName.isNotBlank() && password.isNotBlank()
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.login))
|
||||||
|
}
|
||||||
|
TextButton(onClick = onNavigateToRegister) {
|
||||||
|
Text(stringResource(R.string.no_account_register))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.error != null) {
|
||||||
|
Text(
|
||||||
|
text = state.error!!,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(top = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package auth.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import ru.knot.messager.R
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RegisterScreen(
|
||||||
|
viewModel: AuthViewModel,
|
||||||
|
onNavigateToLogin: () -> Unit,
|
||||||
|
onRegisterSuccess: () -> Unit
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
var userName by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var confirmPassword by remember { mutableStateOf("") }
|
||||||
|
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
val passwordsNotMatchMsg = stringResource(R.string.passwords_not_match)
|
||||||
|
|
||||||
|
LaunchedEffect(state.isAuthenticated) {
|
||||||
|
if (state.isAuthenticated) {
|
||||||
|
onRegisterSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(title = { Text(stringResource(R.string.register)) })
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = userName,
|
||||||
|
onValueChange = { userName = it },
|
||||||
|
label = { Text(stringResource(R.string.username)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text(stringResource(R.string.password)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = confirmPassword,
|
||||||
|
onValueChange = { confirmPassword = it },
|
||||||
|
label = { Text(stringResource(R.string.confirm_password)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (state.isLoading) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
} else {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (password == confirmPassword) {
|
||||||
|
errorMessage = null
|
||||||
|
viewModel.register(userName, password)
|
||||||
|
} else {
|
||||||
|
errorMessage = passwordsNotMatchMsg
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = userName.isNotBlank() && password.isNotBlank() && confirmPassword.isNotBlank()
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.register))
|
||||||
|
}
|
||||||
|
TextButton(onClick = onNavigateToLogin) {
|
||||||
|
Text(stringResource(R.string.already_have_account))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val displayError = state.error ?: errorMessage
|
||||||
|
if (displayError != null) {
|
||||||
|
Text(
|
||||||
|
text = displayError,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(top = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,167 @@
|
|||||||
|
# Архитектура Offline-first для мессенджера Knot
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
Система кэширования истории чатов реализует паттерн **Offline-first** с использованием:
|
||||||
|
- **Room** - локальная база данных
|
||||||
|
- **Paging 3** - пагинация с RemoteMediator
|
||||||
|
- **WorkManager** - фоновая синхронизация
|
||||||
|
- **SignalR** - real-time обновления
|
||||||
|
|
||||||
|
## Компоненты
|
||||||
|
|
||||||
|
### 1. Data Layer
|
||||||
|
|
||||||
|
#### MessageEntity
|
||||||
|
```kotlin
|
||||||
|
@Entity(tableName = "messages")
|
||||||
|
data class MessageEntity(
|
||||||
|
@PrimaryKey val id: String,
|
||||||
|
val chatId: String,
|
||||||
|
val senderId: String,
|
||||||
|
val content: String?,
|
||||||
|
val sequenceId: Int,
|
||||||
|
val createdAt: String,
|
||||||
|
|
||||||
|
// Поля синхронизации
|
||||||
|
val syncStatus: SyncStatus, // SYNCED, SYNCING, FAILED
|
||||||
|
val isDeletedLocally: Boolean, // Помечено на удаление
|
||||||
|
val isEditedLocally: Boolean, // Помечено на редактирование
|
||||||
|
val editedContent: String?, // Новое содержимое
|
||||||
|
val lastUpdated: Long // Время последнего изменения
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MessageDao
|
||||||
|
Основные методы:
|
||||||
|
- `getMessagesPagingSource()` - PagingSource для Paging 3
|
||||||
|
- `upsertMessage()` - Вставка/обновление с разрешением конфликтов
|
||||||
|
- `markAsDeletedLocally()` - Пометка на удаление
|
||||||
|
- `markAsEditedLocally()` - Пометка на редактирование
|
||||||
|
- `getPendingSyncMessages()` - Получение сообщений для синхронизации
|
||||||
|
|
||||||
|
### 2. Pagination (Paging 3)
|
||||||
|
|
||||||
|
#### MessageRemoteMediator
|
||||||
|
Управляет загрузкой данных:
|
||||||
|
- **REFRESH** - первая загрузка последних сообщений
|
||||||
|
- **APPEND** - загрузка более старых сообщений (прокрутка вниз)
|
||||||
|
- **PREPEND** - загрузка более новых сообщений (прокрутка вверх)
|
||||||
|
|
||||||
|
Логика:
|
||||||
|
1. Проверяет наличие данных в Room
|
||||||
|
2. При необходимости загружает из API
|
||||||
|
3. Сохраняет в Room
|
||||||
|
4. Paging читает из локальной базы
|
||||||
|
|
||||||
|
### 3. Background Sync (WorkManager)
|
||||||
|
|
||||||
|
#### MessageSyncWorker
|
||||||
|
Обрабатывает отложенную синхронизацию:
|
||||||
|
- Отправка новых сообщений (SYNCING)
|
||||||
|
- Обновление отредактированных (isEditedLocally = true)
|
||||||
|
- Удаление помеченных (isDeletedLocally = true)
|
||||||
|
- Повтор при ошибках (FAILED)
|
||||||
|
|
||||||
|
Политика повторных попыток:
|
||||||
|
- Экспоненциальная задержка
|
||||||
|
- Максимум 3 попытки
|
||||||
|
- Требуется подключение к сети
|
||||||
|
|
||||||
|
### 4. Real-time Updates (SignalR)
|
||||||
|
|
||||||
|
#### MessageSignalRHandler
|
||||||
|
Обрабатывает события:
|
||||||
|
- `new_message` - новое сообщение
|
||||||
|
- `message_edited` - редактирование
|
||||||
|
- `message_deleted` - удаление
|
||||||
|
- `messages_read` - прочтение
|
||||||
|
- `reaction_added/removed` - реакции
|
||||||
|
|
||||||
|
Все изменения сразу записываются в Room → UI обновляется через Flow
|
||||||
|
|
||||||
|
### 5. Repository
|
||||||
|
|
||||||
|
#### ChatRepositoryImpl
|
||||||
|
Единая точка входа для ViewModel:
|
||||||
|
- `getMessagesPaging()` - Paging 3 поток
|
||||||
|
- `getMessagesFlow()` - простой Flow списка
|
||||||
|
- `sendMessage()` - отправка с локальным сохранением
|
||||||
|
- `deleteLocalMessage()` - локальное удаление
|
||||||
|
- `editLocalMessage()` - локальное редактирование
|
||||||
|
|
||||||
|
## Conflict Resolution
|
||||||
|
|
||||||
|
Приоритет данных:
|
||||||
|
1. **Сообщения в процессе отправки (SYNCING)** - локальные данные имеют приоритет
|
||||||
|
2. **Сообщения в процессе редактирования** - локальные данные имеют приоритет
|
||||||
|
3. **Все остальные случаи** - серверные данные имеют приоритет
|
||||||
|
|
||||||
|
## Схема работы
|
||||||
|
|
||||||
|
### Отправка сообщения
|
||||||
|
```
|
||||||
|
User → sendMessage() → Сохранение в Room (SYNCING) → UI показывает сообщение
|
||||||
|
→ WorkManager планирует синхронизацию
|
||||||
|
→ Отправка на сервер
|
||||||
|
→ Обновление статуса (SYNCED)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Получение сообщений
|
||||||
|
```
|
||||||
|
UI ← getMessagesPaging() ← Room ← RemoteMediator ← API
|
||||||
|
↑
|
||||||
|
└─── SignalR обновления
|
||||||
|
```
|
||||||
|
|
||||||
|
### Удаление сообщения
|
||||||
|
```
|
||||||
|
User → deleteLocalMessage() → Пометка (isDeletedLocally = true)
|
||||||
|
→ WorkManager удаляет на сервере
|
||||||
|
→ Удаление из Room
|
||||||
|
```
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Paging 3 в ViewModel
|
||||||
|
```kotlin
|
||||||
|
@HiltViewModel
|
||||||
|
class ChatViewModel @Inject constructor(
|
||||||
|
private val repository: ChatRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val messages: Flow<PagingData<Message>> =
|
||||||
|
repository.getMessagesPaging(chatId)
|
||||||
|
.cachedIn(viewModelScope)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Офлайн отправка
|
||||||
|
```kotlin
|
||||||
|
// Сообщение сразу появится в UI
|
||||||
|
val message = repository.sendMessage(
|
||||||
|
chatId = chatId,
|
||||||
|
content = "Hello"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Синхронизация произойдёт в фоне
|
||||||
|
```
|
||||||
|
|
||||||
|
## Миграции
|
||||||
|
|
||||||
|
При обновлении схемы БД используется миграция `MIGRATION_1_2`:
|
||||||
|
- Добавляет поля синхронизации
|
||||||
|
- Сохраняет существующие данные
|
||||||
|
- Устанавливает значения по умолчанию
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
### Юнит-тесты
|
||||||
|
- MessageDao тесты
|
||||||
|
- MessageRemoteMediator тесты
|
||||||
|
- ChatRepositoryImpl тесты
|
||||||
|
|
||||||
|
### Интеграционные тесты
|
||||||
|
- Синхронизация с сервером
|
||||||
|
- Обработка конфликтов
|
||||||
|
- WorkManager сценарии
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# Сводка реализации системы кэширования
|
||||||
|
|
||||||
|
## 📁 Созданные файлы
|
||||||
|
|
||||||
|
### Data Layer
|
||||||
|
1. **core/database/data/ChatDatabase.kt** (обновлён)
|
||||||
|
- Добавлены поля синхронизации в MessageEntity
|
||||||
|
- Расширен MessageDao методами для Paging и офлайн-операций
|
||||||
|
- Добавлен SyncStatusConverter для Room
|
||||||
|
|
||||||
|
2. **core/database/data/Migrations.kt** (новый)
|
||||||
|
- Миграция MIGRATION_1_2 для обновления схемы БД
|
||||||
|
|
||||||
|
3. **core/di/DatabaseModule.kt** (обновлён)
|
||||||
|
- Добавлена миграция
|
||||||
|
- Изменено имя БД на константу
|
||||||
|
|
||||||
|
4. **core/di/WorkManagerModule.kt** (новый)
|
||||||
|
- DI модуль для WorkManager
|
||||||
|
|
||||||
|
### Paging 3
|
||||||
|
5. **chats/data/paging/MessageRemoteMediator.kt** (новый)
|
||||||
|
- RemoteMediator для загрузки данных из сети
|
||||||
|
- Управление пагинацией (REFRESH, APPEND, PREPEND)
|
||||||
|
- Сохранение в Room
|
||||||
|
|
||||||
|
6. **chats/data/paging/MessagePagingSource.kt** (новый)
|
||||||
|
- PagingSource для чтения из Room
|
||||||
|
|
||||||
|
### Sync (WorkManager)
|
||||||
|
7. **chats/data/sync/MessageSyncWorker.kt** (новый)
|
||||||
|
- Worker для фоновой синхронизации
|
||||||
|
- Обработка отправки, редактирования, удаления
|
||||||
|
- Политика повторных попыток (exponential backoff)
|
||||||
|
|
||||||
|
### SignalR Integration
|
||||||
|
8. **chats/data/signalr/MessageSignalRHandler.kt** (новый)
|
||||||
|
- Обработчик SignalR событий
|
||||||
|
- Обновление локального кэша в реальном времени
|
||||||
|
- Разрешение конфликтов
|
||||||
|
|
||||||
|
### Repository
|
||||||
|
9. **chats/domain/repository/ChatRepository.kt** (обновлён)
|
||||||
|
- Добавлен метод getMessagesPaging()
|
||||||
|
- Добавлены editLocalMessage()
|
||||||
|
|
||||||
|
10. **chats/data/repository/ChatRepositoryImpl.kt** (обновлён)
|
||||||
|
- Полная реализация Offline-first
|
||||||
|
- Интеграция Paging 3, SignalR, WorkManager
|
||||||
|
- Conflict Resolution логика
|
||||||
|
|
||||||
|
### DI
|
||||||
|
11. **chats/di/ChatModule.kt** (обновлён)
|
||||||
|
- Регистрация MessageSignalRHandler
|
||||||
|
- Обновлён ChatRepositoryImpl с новыми зависимостями
|
||||||
|
|
||||||
|
### Domain Models
|
||||||
|
12. **chats/domain/model/ChatModels.kt** (обновлён)
|
||||||
|
- Добавлен ChatMember
|
||||||
|
- Добавлено поле members в Chat
|
||||||
|
|
||||||
|
### Application
|
||||||
|
13. **app/src/main/kotlin/com/knot/messenger/MainApplication.kt** (обновлён)
|
||||||
|
- Реализация Configuration.Provider для WorkManager
|
||||||
|
- Интеграция Hilt WorkerFactory
|
||||||
|
|
||||||
|
### Документация
|
||||||
|
14. **chats/ARCHITECTURE.md** (новый)
|
||||||
|
- Описание архитектуры
|
||||||
|
- Схема работы компонентов
|
||||||
|
|
||||||
|
15. **chats/USAGE_EXAMPLES.md** (новый)
|
||||||
|
- Примеры использования
|
||||||
|
- Best practices
|
||||||
|
|
||||||
|
## 🔧 Изменения в зависимостях (app/build.gradle.kts)
|
||||||
|
|
||||||
|
Добавлено:
|
||||||
|
```kotlin
|
||||||
|
// Paging 3
|
||||||
|
implementation("androidx.paging:paging-runtime-ktx:3.2.1")
|
||||||
|
implementation("androidx.paging:paging-compose:3.2.1")
|
||||||
|
|
||||||
|
// WorkManager + Hilt
|
||||||
|
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||||
|
implementation("androidx.hilt:hilt-work:1.1.0")
|
||||||
|
kapt("androidx.hilt:hilt-compiler:1.1.0")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏗️ Архитектурные решения
|
||||||
|
|
||||||
|
### 1. Offline-first подход
|
||||||
|
- Все данные читаются из локальной Room базы
|
||||||
|
- Сетевые запросы только для синхронизации
|
||||||
|
- UI всегда работает с локальными данными
|
||||||
|
|
||||||
|
### 2. Paging 3 с RemoteMediator
|
||||||
|
- Единый источник истины - Room
|
||||||
|
- RemoteMediator управляет загрузкой из сети
|
||||||
|
- Автоматическая инвалидация при изменениях
|
||||||
|
|
||||||
|
### 3. Conflict Resolution
|
||||||
|
- **SYNCING/EDITING**: локальные данные имеют приоритет
|
||||||
|
- **SYNCED**: серверные данные имеют приоритет
|
||||||
|
- SignalR события применяются аккуратно
|
||||||
|
|
||||||
|
### 4. Background Sync
|
||||||
|
- WorkManager для надёжной доставки
|
||||||
|
- Exponential backoff при ошибках
|
||||||
|
- Требуется NetzwerkType.CONNECTED
|
||||||
|
|
||||||
|
### 5. Real-time Updates
|
||||||
|
- SignalR события → Room → Flow → UI
|
||||||
|
- Автоматическое обновление UI
|
||||||
|
- Минимальная задержка
|
||||||
|
|
||||||
|
## 📊 Схема потока данных
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
|
||||||
|
│ SignalR │────▶│ SignalR │────▶│ Room │
|
||||||
|
│ (Server) │ │ Handler │ │ (SQLite) │
|
||||||
|
└─────────────┘ └──────────────┘ └──────┬──────┘
|
||||||
|
│
|
||||||
|
┌─────────────┐ ┌──────────────┐ ┌──────▼──────┐
|
||||||
|
│ API │◀───▶│ Remote │◀───▶│ Paging │
|
||||||
|
│ (Retrofit) │ │ Mediator │ │ Source │
|
||||||
|
└─────────────┘ └──────────────┘ └──────┬──────┘
|
||||||
|
│
|
||||||
|
┌─────────────┐ ┌──────────────┐ ┌──────▼──────┐
|
||||||
|
│ WorkManager│◀───▶│ Repository │◀───▶│ UI │
|
||||||
|
│ (Sync) │ │ │ │ (Flow) │
|
||||||
|
└─────────────┘ └──────────────┘ └─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ Checklist реализации
|
||||||
|
|
||||||
|
- [x] MessageEntity с полями синхронизации
|
||||||
|
- [x] MessageDao с PagingSource методами
|
||||||
|
- [x] MessageRemoteMediator для Paging 3
|
||||||
|
- [x] MessageSyncWorker для WorkManager
|
||||||
|
- [x] MessageSignalRHandler для real-time
|
||||||
|
- [x] ChatRepositoryImpl с полной логикой
|
||||||
|
- [x] DI модули обновлены
|
||||||
|
- [x] Миграция БД
|
||||||
|
- [x] Hilt Worker интеграция
|
||||||
|
- [x] Документация
|
||||||
|
|
||||||
|
## 🚀 Следующие шаги
|
||||||
|
|
||||||
|
1. **Тестирование**
|
||||||
|
- Юнит-тесты для MessageDao
|
||||||
|
- Интеграционные тесты для Repository
|
||||||
|
- UI тесты с Paging 3
|
||||||
|
|
||||||
|
2. **Мониторинг**
|
||||||
|
- Логирование синхронизации
|
||||||
|
- Метрики ошибок
|
||||||
|
- Analytics офлайн-режима
|
||||||
|
|
||||||
|
3. **Оптимизация**
|
||||||
|
- Индексы в БД для производительности
|
||||||
|
- Кэширование изображений
|
||||||
|
- Оптимизация запросов
|
||||||
|
|
||||||
|
4. **Улучшения**
|
||||||
|
- Поиск по сообщениям
|
||||||
|
- Избранные сообщения
|
||||||
|
- Архивация чатов
|
||||||
|
|
||||||
|
## 🔍 Ключевые особенности
|
||||||
|
|
||||||
|
1. **Мгновенный UI** - сообщения появляются сразу
|
||||||
|
2. **Надёжная синхронизация** - WorkManager гарантирует доставку
|
||||||
|
3. **Real-time** - SignalR для мгновенных обновлений
|
||||||
|
4. **Офлайн-работа** - полное функционирование без сети
|
||||||
|
5. **Разрешение конфликтов** - умная логика приоритетов
|
||||||
|
6. **Пагинация** - эффективная работа с большими чатами
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
# Руководство по интеграции
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
### 1. Добавление зависимостей
|
||||||
|
|
||||||
|
В `app/build.gradle.kts` уже добавлены:
|
||||||
|
```kotlin
|
||||||
|
// Paging 3
|
||||||
|
implementation("androidx.paging:paging-runtime-ktx:3.2.1")
|
||||||
|
implementation("androidx.paging:paging-compose:3.2.1")
|
||||||
|
|
||||||
|
// WorkManager + Hilt
|
||||||
|
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||||
|
implementation("androidx.hilt:hilt-work:1.1.0")
|
||||||
|
kapt("androidx.hilt:hilt-compiler:1.1.0")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Обновление Application класса
|
||||||
|
|
||||||
|
`MainApplication.kt` уже обновлён:
|
||||||
|
```kotlin
|
||||||
|
@HiltAndroidApp
|
||||||
|
class MainApplication : Application(), ImageLoaderFactory, Configuration.Provider {
|
||||||
|
@Inject lateinit var workerFactory: WorkerFactory
|
||||||
|
|
||||||
|
override val workManagerConfiguration: Configuration
|
||||||
|
get() = Configuration.Builder()
|
||||||
|
.setWorkerFactory(workerFactory)
|
||||||
|
.setMinimumLoggingLevel(android.util.Log.INFO)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Миграция базы данных
|
||||||
|
|
||||||
|
База данных автоматически обновится при первом запуске благодаря `MIGRATION_1_2`.
|
||||||
|
|
||||||
|
## Использование в ViewModel
|
||||||
|
|
||||||
|
### Вариант 1: Paging 3 (рекомендуется для больших чатов)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@HiltViewModel
|
||||||
|
class ChatViewModel @Inject constructor(
|
||||||
|
private val repository: ChatRepository,
|
||||||
|
savedStateHandle: SavedStateHandle
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val chatId: String = savedStateHandle["chatId"] ?: ""
|
||||||
|
|
||||||
|
val messages: Flow<PagingData<Message>> = repository
|
||||||
|
.getMessagesPaging(chatId)
|
||||||
|
.cachedIn(viewModelScope)
|
||||||
|
|
||||||
|
fun sendMessage(content: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.sendMessage(chatId, content, "text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteMessage(messageId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.deleteLocalMessage(messageId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Вариант 2: Простой Flow (для небольших чатов)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@HiltViewModel
|
||||||
|
class ChatViewModel @Inject constructor(
|
||||||
|
private val repository: ChatRepository,
|
||||||
|
savedStateHandle: SavedStateHandle
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val chatId: String = savedStateHandle["chatId"] ?: ""
|
||||||
|
|
||||||
|
val messages: Flow<List<Message>> = repository
|
||||||
|
.getMessagesFlow(chatId)
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Использование в UI (Compose)
|
||||||
|
|
||||||
|
### С Paging 3
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun ChatScreen(viewModel: ChatViewModel = hiltViewModel()) {
|
||||||
|
val messages by viewModel.messages.collectAsLazyPagingItems()
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
reverseLayout = true, // Сообщения снизу вверх
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
count = messages.itemCount,
|
||||||
|
key = messages.key
|
||||||
|
) { index ->
|
||||||
|
messages[index]?.let { message ->
|
||||||
|
MessageItem(message = message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Индикатор загрузки
|
||||||
|
when {
|
||||||
|
messages.loadState.refresh is LoadState.Loading -> {
|
||||||
|
item { LoadingIndicator() }
|
||||||
|
}
|
||||||
|
messages.loadState.append is LoadState.Loading -> {
|
||||||
|
item { LoadingIndicator() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ошибки
|
||||||
|
messages.loadState.append.let { loadState ->
|
||||||
|
if (loadState is LoadState.Error) {
|
||||||
|
item {
|
||||||
|
Text("Ошибка: ${loadState.error.message}")
|
||||||
|
Button(onClick = { messages.retry() }) {
|
||||||
|
Text("Повторить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### С простым Flow
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun ChatScreen(viewModel: ChatViewModel = hiltViewModel()) {
|
||||||
|
val messages by viewModel.messages.collectAsState()
|
||||||
|
|
||||||
|
LazyColumn(reverseLayout = true) {
|
||||||
|
items(messages) { message ->
|
||||||
|
MessageItem(message = message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Отправка сообщения
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Мгновенное отображение в UI
|
||||||
|
viewModel.sendMessage("Привет!")
|
||||||
|
|
||||||
|
// Сообщение сохраняется локально и появляется в UI сразу
|
||||||
|
// WorkManager отправит его на сервер в фоне
|
||||||
|
```
|
||||||
|
|
||||||
|
## Удаление сообщения
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Мягкое удаление (через WorkManager)
|
||||||
|
viewModel.deleteMessage(messageId)
|
||||||
|
|
||||||
|
// Или немедленное удаление
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.deleteMessage(messageId, forEveryone = false)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Редактирование сообщения
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.editLocalMessage(messageId, "Новый текст")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Мониторинг синхронизации
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// В ViewModel
|
||||||
|
val syncStatus: Flow<List<MessageEntity>> = messageDao
|
||||||
|
.getPendingSyncMessagesFlow()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
|
||||||
|
|
||||||
|
// В UI
|
||||||
|
val pendingMessages by syncStatus.collectAsState()
|
||||||
|
if (pendingMessages.isNotEmpty()) {
|
||||||
|
Text("${pendingMessages.size} сообщений ожидают отправки")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Обработка офлайн-режима
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun MessageItem(message: Message) {
|
||||||
|
val isPending = message.id.startsWith("local_")
|
||||||
|
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = message.content ?: "",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Индикатор отправки
|
||||||
|
if (isPending) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Статус прочтения
|
||||||
|
Icon(
|
||||||
|
imageVector = if (message.isRead) Icons.Default.DoneAll else Icons.Default.Done,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Проверка сборки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client-mobile
|
||||||
|
./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Возможные проблемы и решения
|
||||||
|
|
||||||
|
### 1. Ошибка: "WorkerFactory not found"
|
||||||
|
**Решение:** Убедитесь, что `MainApplication` реализует `Configuration.Provider`
|
||||||
|
|
||||||
|
### 2. Ошибка: "Table messages has no column named syncStatus"
|
||||||
|
**Решение:** Проверьте, что миграция `MIGRATION_1_2` добавлена в `DatabaseModule`
|
||||||
|
|
||||||
|
### 3. Paging не загружает данные
|
||||||
|
**Решение:** Проверьте логи `MessageRemoteMediator` - возможны проблемы с API
|
||||||
|
|
||||||
|
### 4. Сообщения не синхронизируются
|
||||||
|
**Решение:** Проверьте WorkManager логи и наличие сетевого подключения
|
||||||
|
|
||||||
|
### 5. SignalR не подключается
|
||||||
|
**Решение:** Проверьте `ChatHubClient.connect()` - должен вызываться после авторизации
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
### Юнит-тесты
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `message saved locally should have SYNCING status`() = runTest {
|
||||||
|
val message = MessageEntity(
|
||||||
|
id = "test",
|
||||||
|
chatId = "chat1",
|
||||||
|
// ...
|
||||||
|
syncStatus = SyncStatus.SYNCING
|
||||||
|
)
|
||||||
|
dao.insertMessage(message)
|
||||||
|
|
||||||
|
val saved = dao.getMessageById("test")
|
||||||
|
assertEquals(SyncStatus.SYNCING, saved?.syncStatus)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Интеграционные тесты
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `sending message should save locally and sync to server`() = runTest {
|
||||||
|
// Arrange
|
||||||
|
val repository = ChatRepositoryImpl(...)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val message = repository.sendMessage("chat1", "Hello", "text")
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertTrue(message.id.startsWith("local_"))
|
||||||
|
|
||||||
|
// Wait for sync
|
||||||
|
delay(5000)
|
||||||
|
|
||||||
|
val synced = dao.getMessageById(message.id)
|
||||||
|
assertEquals(SyncStatus.SYNCED, synced?.syncStatus)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Дополнительные ресурсы
|
||||||
|
|
||||||
|
- [Paging 3 Documentation](https://developer.android.com/topic/libraries/architecture/paging/v3-overview)
|
||||||
|
- [WorkManager Documentation](https://developer.android.com/topic/libraries/architecture/workmanager)
|
||||||
|
- [Room Documentation](https://developer.android.com/training/data-storage/room)
|
||||||
|
- [ARCHITECTURE.md](ARCHITECTURE.md) - детальное описание архитектуры
|
||||||
|
- [USAGE_EXAMPLES.md](USAGE_EXAMPLES.md) - больше примеров использования
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
# Примеры использования системы кэширования
|
||||||
|
|
||||||
|
## 1. Paging 3 в ViewModel
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@HiltViewModel
|
||||||
|
class ChatViewModel @Inject constructor(
|
||||||
|
private val repository: ChatRepository,
|
||||||
|
savedStateHandle: SavedStateHandle
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val chatId: String = savedStateHandle["chatId"] ?: ""
|
||||||
|
|
||||||
|
// Paging 3 поток для UI
|
||||||
|
val messages: Flow<PagingData<Message>> = repository
|
||||||
|
.getMessagesPaging(chatId)
|
||||||
|
.cachedIn(viewModelScope)
|
||||||
|
|
||||||
|
// Простой Flow для небольших чатов
|
||||||
|
val messagesList: Flow<List<Message>> = repository
|
||||||
|
.getMessagesFlow(chatId)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. UI с Paging 3 (Jetpack Compose)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun ChatScreen(viewModel: ChatViewModel = hiltViewModel()) {
|
||||||
|
val messages by viewModel.messages.collectAsLazyPagingItems()
|
||||||
|
|
||||||
|
LazyColumn {
|
||||||
|
items(
|
||||||
|
count = messages.itemCount,
|
||||||
|
key = messages.key
|
||||||
|
) { index ->
|
||||||
|
val message = messages[index]
|
||||||
|
message?.let {
|
||||||
|
MessageItem(message = it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Индикаторы загрузки
|
||||||
|
when {
|
||||||
|
messages.loadState.refresh is LoadState.Loading -> {
|
||||||
|
item { LoadingIndicator() }
|
||||||
|
}
|
||||||
|
messages.loadState.append is LoadState.Loading -> {
|
||||||
|
item { LoadingIndicator() }
|
||||||
|
}
|
||||||
|
messages.loadState.prepend is LoadState.Loading -> {
|
||||||
|
item { LoadingIndicator() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка ошибок
|
||||||
|
messages.loadState.append.let { loadState ->
|
||||||
|
if (loadState is LoadState.Error) {
|
||||||
|
item {
|
||||||
|
ErrorView(
|
||||||
|
message = loadState.error.message,
|
||||||
|
onRetry = { messages.retry() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Отправка сообщения (Offline-first)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@HiltViewModel
|
||||||
|
class ChatViewModel @Inject constructor(
|
||||||
|
private val repository: ChatRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
fun sendMessage(chatId: String, content: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
// Сообщение сразу сохраняется локально и появляется в UI
|
||||||
|
val message = repository.sendMessage(
|
||||||
|
chatId = chatId,
|
||||||
|
content = content,
|
||||||
|
type = "text"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UI обновляется мгновенно через Flow
|
||||||
|
Log.d("ChatViewModel", "Message saved locally: ${message.id}")
|
||||||
|
|
||||||
|
// Синхронизация с сервером произойдёт в фоне
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("ChatViewModel", "Failed to send message", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Удаление сообщения
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun deleteMessage(messageId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Локальное удаление (сообщение скрывается из UI)
|
||||||
|
repository.deleteLocalMessage(messageId)
|
||||||
|
|
||||||
|
// WorkManager удалит сообщение на сервере в фоне
|
||||||
|
// При получении подтверждения - сообщение удаляется из БД
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Или немедленное удаление (если онлайн)
|
||||||
|
fun deleteMessageImmediately(messageId: String, forEveryone: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.deleteMessage(messageId, forEveryone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Редактирование сообщения
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun editMessage(messageId: String, newContent: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Локальное редактирование
|
||||||
|
repository.editLocalMessage(messageId, newContent)
|
||||||
|
|
||||||
|
// UI обновляется мгновенно
|
||||||
|
// WorkManager отправит изменения на сервер в фоне
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Отслеживание статуса синхронизации
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Наблюдение за сообщениями, ожидающими синхронизации
|
||||||
|
fun observePendingMessages() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
messageDao.getPendingSyncMessagesFlow().collect { messages ->
|
||||||
|
if (messages.isNotEmpty()) {
|
||||||
|
Log.d("Sync", "${messages.size} messages pending sync")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка статуса конкретного сообщения
|
||||||
|
fun isMessageSynced(messageId: String): Boolean {
|
||||||
|
return runBlocking {
|
||||||
|
val message = messageDao.getMessageById(messageId)
|
||||||
|
message?.syncStatus == SyncStatus.SYNCED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Обработка ошибок синхронизации
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun retryFailedMessages() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val failedMessages = messageDao.getFailedSyncMessages()
|
||||||
|
|
||||||
|
failedMessages.forEach { message ->
|
||||||
|
when {
|
||||||
|
message.isDeletedLocally -> {
|
||||||
|
// Повторить удаление
|
||||||
|
MessageSyncWorker.scheduleSync(context)
|
||||||
|
}
|
||||||
|
message.isEditedLocally -> {
|
||||||
|
// Повторить редактирование
|
||||||
|
MessageSyncWorker.scheduleSync(context)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Повторить отправку
|
||||||
|
MessageSyncWorker.scheduleSync(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Прочтение сообщений
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun markAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Отправляем статус прочтения через SignalR
|
||||||
|
repository.markMessagesAsRead(chatId, lastMessageId, lastReadSequenceId)
|
||||||
|
|
||||||
|
// Локальная база обновляется автоматически
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Real-time обновления
|
||||||
|
|
||||||
|
SignalR события обрабатываются автоматически:
|
||||||
|
- Новые сообщения появляются в UI мгновенно
|
||||||
|
- Редактирования/удаления синхронизируются
|
||||||
|
- Статусы прочтения обновляются
|
||||||
|
- Реакции отображаются в реальном времени
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Обработчик SignalR уже интегрирован в ChatRepositoryImpl
|
||||||
|
// Дополнительные действия можно добавить в MessageSignalRHandler
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. Кэширование в ViewModel
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@HiltViewModel
|
||||||
|
class ChatViewModel @Inject constructor(
|
||||||
|
private val repository: ChatRepository,
|
||||||
|
savedStateHandle: SavedStateHandle
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val chatId: String = savedStateHandle["chatId"] ?: ""
|
||||||
|
|
||||||
|
// Кэшируем PagingData в scope ViewModel
|
||||||
|
val messages: Flow<PagingData<Message>> = repository
|
||||||
|
.getMessagesPaging(chatId)
|
||||||
|
.cachedIn(viewModelScope) // Важно для сохранения состояния
|
||||||
|
|
||||||
|
// При повороте экрана пагинация сохраняется
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Рекомендации
|
||||||
|
|
||||||
|
### 1. Выбор между Paging 3 и Flow
|
||||||
|
- **Paging 3** - для больших чатов (>100 сообщений)
|
||||||
|
- **Flow<List>** - для небольших чатов или когда нужна вся история сразу
|
||||||
|
|
||||||
|
### 2. Обработка офлайн-режима
|
||||||
|
```kotlin
|
||||||
|
// UI должен показывать статус сообщения
|
||||||
|
@Composable
|
||||||
|
fun MessageItem(message: Message) {
|
||||||
|
val isPending = message.id.startsWith("local_")
|
||||||
|
|
||||||
|
Row {
|
||||||
|
Text(text = message.content)
|
||||||
|
if (isPending) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Конфликты данных
|
||||||
|
- Локальные изменения имеют приоритет во время отправки
|
||||||
|
- Серверные данные перезаписывают локальные после SYNCED
|
||||||
|
- SignalR события всегда применяются к актуальным данным
|
||||||
|
|
||||||
|
### 4. Производительность
|
||||||
|
- Используйте `cachedIn(viewModelScope)` для PagingData
|
||||||
|
- Избегайте частых вызовов `getMessages()` из сети
|
||||||
|
- Позволяйте WorkManager управлять синхронизацией
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package chats.data.paging
|
||||||
|
|
||||||
|
import androidx.paging.PagingSource
|
||||||
|
import androidx.paging.PagingState
|
||||||
|
import core.database.data.MessageDao
|
||||||
|
import core.database.data.MessageEntity
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PagingSource для загрузки сообщений из локальной базы Room
|
||||||
|
* Данные сортируются по sequenceId DESC (новые сообщения первыми)
|
||||||
|
*/
|
||||||
|
class MessagePagingSource(
|
||||||
|
private val dao: MessageDao,
|
||||||
|
private val chatId: String
|
||||||
|
) : PagingSource<Int, MessageEntity>() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "MessagePagingSource"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getRefreshKey(state: PagingState<Int, MessageEntity>): Int? {
|
||||||
|
Log.d(TAG, "getRefreshKey() called")
|
||||||
|
return state.anchorPosition?.let { anchorPosition ->
|
||||||
|
val anchorPage = state.closestPageToPosition(anchorPosition)
|
||||||
|
anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, MessageEntity> {
|
||||||
|
Log.d(TAG, ">>> load() called with: position=${params.key}, loadSize=${params.loadSize}")
|
||||||
|
return try {
|
||||||
|
// При DESC порядке:
|
||||||
|
// - key = sequenceId последнего сообщения в текущей странице
|
||||||
|
// - APPEND = загружаем сообщения С МЕНЬШИМ sequenceId (старые)
|
||||||
|
// - PREPEND = загружаем сообщения С БОЛЬШИМ sequenceId (новые)
|
||||||
|
|
||||||
|
val position = params.key // sequenceId граничного сообщения
|
||||||
|
val loadSize = params.loadSize
|
||||||
|
|
||||||
|
Log.d(TAG, "Loading: chatId=$chatId, position=$position, loadSize=$loadSize")
|
||||||
|
|
||||||
|
val messages = if (position == null) {
|
||||||
|
// Первая загрузка - получаем самые последние сообщения (включая maxSeq)
|
||||||
|
val maxSeq = dao.getMaxSequenceId(chatId)
|
||||||
|
Log.d(TAG, "First load: maxSeq=$maxSeq")
|
||||||
|
if (maxSeq == null) {
|
||||||
|
Log.d(TAG, "No messages in database")
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
// Используем <= чтобы включить самое последнее сообщение
|
||||||
|
dao.getMessagesUpToAndIncluding(chatId, maxSeq, loadSize)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Загружаем сообщения с sequenceId < position (старые)
|
||||||
|
Log.d(TAG, "Append: loading messages before sequenceId=$position")
|
||||||
|
dao.getMessagesBefore(chatId, position, loadSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Loaded ${messages.size} messages, first=${messages.firstOrNull()?.sequenceId}, last=${messages.lastOrNull()?.sequenceId}")
|
||||||
|
|
||||||
|
// Для DESC порядка:
|
||||||
|
// - prevKey = максимальный sequenceId в странице (для загрузки новых)
|
||||||
|
// - nextKey = минимальный sequenceId в странице (для загрузки старых)
|
||||||
|
val prevKey = messages.firstOrNull()?.sequenceId?.plus(1)
|
||||||
|
val nextKey = messages.lastOrNull()?.sequenceId?.minus(1)
|
||||||
|
|
||||||
|
Log.d(TAG, "prevKey=$prevKey, nextKey=$nextKey")
|
||||||
|
|
||||||
|
LoadResult.Page(
|
||||||
|
data = messages,
|
||||||
|
prevKey = prevKey,
|
||||||
|
nextKey = nextKey
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error loading messages", e)
|
||||||
|
LoadResult.Error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package chats.data.paging
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.paging.*
|
||||||
|
import chats.data.remote.api.ChatApi
|
||||||
|
import chats.data.remote.dto.MessageDto
|
||||||
|
import chats.domain.model.MediaType
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import core.database.data.ChatDatabase
|
||||||
|
import core.database.data.MessageDao
|
||||||
|
import core.database.data.MessageEntity
|
||||||
|
import core.database.data.SyncStatus
|
||||||
|
import core.network.ServerConfig
|
||||||
|
import core.security.TokenManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RemoteMediator для Paging 3
|
||||||
|
* Управляет загрузкой сообщений из сети и кэшированием в Room
|
||||||
|
*
|
||||||
|
* Логика работы:
|
||||||
|
* 1. При первой загрузке (REFRESH) - загружаем последние сообщения
|
||||||
|
* 2. При прокрутке вниз (APPEND) - загружаем более старые сообщения
|
||||||
|
* 3. При прокрутке вверх (PREPEND) - загружаем более новые сообщения
|
||||||
|
* 4. Данные сохраняются в Room, Paging читает из базы
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalPagingApi::class)
|
||||||
|
class MessageRemoteMediator(
|
||||||
|
private val chatId: String,
|
||||||
|
private val api: ChatApi,
|
||||||
|
private val database: ChatDatabase,
|
||||||
|
private val dao: core.database.data.MessageDao,
|
||||||
|
private val serverConfig: ServerConfig,
|
||||||
|
private val tokenManager: TokenManager
|
||||||
|
) : RemoteMediator<Int, MessageEntity>() {
|
||||||
|
|
||||||
|
private val gson = Gson()
|
||||||
|
private val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние пагинации
|
||||||
|
*/
|
||||||
|
data class MessageRemoteMediatorState(
|
||||||
|
val lastSequenceId: Int?,
|
||||||
|
val firstSequenceId: Int?
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun initialize(): InitializeAction {
|
||||||
|
// Всегда запускаем refresh для проверки кэша
|
||||||
|
return InitializeAction.LAUNCH_INITIAL_REFRESH
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun load(
|
||||||
|
loadType: LoadType,
|
||||||
|
state: PagingState<Int, MessageEntity>
|
||||||
|
): MediatorResult {
|
||||||
|
Log.d(TAG, ">>> load() loadType=$loadType")
|
||||||
|
return try {
|
||||||
|
// Проверяем наличие данных в локальной БД
|
||||||
|
val localMinSeq = dao.getMinSequenceId(chatId)
|
||||||
|
val localMaxSeq = dao.getMaxSequenceId(chatId)
|
||||||
|
Log.d(TAG, "Local DB: minSeq=$localMinSeq, maxSeq=$localMaxSeq")
|
||||||
|
|
||||||
|
when (loadType) {
|
||||||
|
LoadType.REFRESH -> {
|
||||||
|
// Если есть локальные данные - не делаем API запрос
|
||||||
|
if (localMaxSeq != null) {
|
||||||
|
Log.d(TAG, "REFRESH: Using cached data (maxSeq=$localMaxSeq)")
|
||||||
|
return MediatorResult.Success(endOfPaginationReached = false)
|
||||||
|
}
|
||||||
|
// Нет данных - загружаем последние сообщения
|
||||||
|
Log.d(TAG, "REFRESH: No cached data, fetching from API")
|
||||||
|
}
|
||||||
|
LoadType.APPEND -> {
|
||||||
|
Log.d(TAG, "APPEND: Loading older messages")
|
||||||
|
}
|
||||||
|
LoadType.PREPEND -> {
|
||||||
|
Log.d(TAG, "PREPEND: Loading newer messages")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определяем pivot для API запроса
|
||||||
|
val pivotValue: Long? = when (loadType) {
|
||||||
|
LoadType.REFRESH -> null // Последние сообщения
|
||||||
|
LoadType.APPEND -> {
|
||||||
|
// Старые сообщения - берём минимальный sequenceId из текущей страницы
|
||||||
|
(state.pages.lastOrNull()?.data?.lastOrNull()?.sequenceId
|
||||||
|
?: localMinSeq)?.toLong()?.minus(1)
|
||||||
|
}
|
||||||
|
LoadType.PREPEND -> {
|
||||||
|
// Новые сообщения - берём максимальный sequenceId
|
||||||
|
(state.pages.firstOrNull()?.data?.firstOrNull()?.sequenceId
|
||||||
|
?: localMaxSeq)?.toLong()?.plus(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val limit = when (loadType) {
|
||||||
|
LoadType.REFRESH -> state.config.initialLoadSize
|
||||||
|
else -> state.config.pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "API call: chatId=$chatId, pivot=$pivotValue, limit=$limit")
|
||||||
|
|
||||||
|
// Выполняем запрос к API
|
||||||
|
val messages = try {
|
||||||
|
api.getMessages(
|
||||||
|
chatId = chatId,
|
||||||
|
cursor = null,
|
||||||
|
pivot = pivotValue,
|
||||||
|
limit = limit
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "API call failed - offline?", e)
|
||||||
|
// При ошибке сети и наличии локальных данных - успех
|
||||||
|
if ((localMinSeq != null || localMaxSeq != null)) {
|
||||||
|
Log.d(TAG, "Offline mode: returning success with cached data")
|
||||||
|
return MediatorResult.Success(endOfPaginationReached = true)
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Received ${messages.size} messages from API")
|
||||||
|
|
||||||
|
if (messages.isEmpty()) {
|
||||||
|
Log.d(TAG, "End of pagination - no more messages")
|
||||||
|
return MediatorResult.Success(endOfPaginationReached = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем в базу
|
||||||
|
val currentUserId = tokenManager.getUserId() ?: ""
|
||||||
|
val entities = messages.map { dto ->
|
||||||
|
dto.toEntity(currentUserId, baseUrl, gson)
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Saving ${entities.size} messages to DB, first seq=${entities.firstOrNull()?.sequenceId}, last seq=${entities.lastOrNull()?.sequenceId}")
|
||||||
|
dao.upsertMessages(entities)
|
||||||
|
|
||||||
|
// Проверяем что данные сохранились
|
||||||
|
val savedCount = dao.getMessagesCount(chatId)
|
||||||
|
Log.d(TAG, "Total messages in DB after save: $savedCount")
|
||||||
|
|
||||||
|
val endOfPaginationReached = messages.size < limit
|
||||||
|
Log.d(TAG, "endOfPaginationReached=$endOfPaginationReached")
|
||||||
|
MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error loading messages", e)
|
||||||
|
MediatorResult.Error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "MessageRemoteMediator"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension function для преобразования DTO в Entity
|
||||||
|
*/
|
||||||
|
private fun MessageDto.toEntity(
|
||||||
|
currentUserId: String,
|
||||||
|
baseUrl: String,
|
||||||
|
gson: Gson
|
||||||
|
): MessageEntity {
|
||||||
|
// Определяем тип медиа
|
||||||
|
val mediaType = when {
|
||||||
|
media.isEmpty() -> MediaType.TEXT.name
|
||||||
|
media.any { it.type.startsWith("image") || it.url.endsWith(".gif") } -> MediaType.GIF.name
|
||||||
|
media.any { it.type.startsWith("image") } -> MediaType.IMAGE.name
|
||||||
|
media.any { it.type.startsWith("video") } -> MediaType.VIDEO.name
|
||||||
|
media.any { it.type.startsWith("audio") } -> MediaType.AUDIO.name
|
||||||
|
else -> MediaType.FILE.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сериализуем медиа в JSON
|
||||||
|
val mediaJson = gson.toJson(media)
|
||||||
|
|
||||||
|
// Сериализуем реакции в JSON
|
||||||
|
val reactionsMap = reactions?.associate { it.emoji to it.count } ?: emptyMap()
|
||||||
|
val reactionsJson = gson.toJson(reactionsMap)
|
||||||
|
|
||||||
|
// Определяем, прочитано ли сообщение текущим пользователем
|
||||||
|
val isRead = senderId == currentUserId
|
||||||
|
|
||||||
|
return MessageEntity(
|
||||||
|
id = id,
|
||||||
|
chatId = chatId ?: "",
|
||||||
|
senderId = senderId ?: "",
|
||||||
|
senderName = sender?.displayName ?: sender?.username ?: "Unknown",
|
||||||
|
senderAvatar = sender?.avatarUrl,
|
||||||
|
content = content,
|
||||||
|
sequenceId = sequenceId ?: 0,
|
||||||
|
createdAt = createdAt ?: "",
|
||||||
|
mediaType = mediaType,
|
||||||
|
mediaJson = mediaJson,
|
||||||
|
reactionsJson = reactionsJson,
|
||||||
|
isRead = isRead,
|
||||||
|
replyToId = replyTo?.id,
|
||||||
|
syncStatus = SyncStatus.SYNCED,
|
||||||
|
isDeletedLocally = false,
|
||||||
|
isEditedLocally = false,
|
||||||
|
editedContent = null,
|
||||||
|
lastUpdated = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package chats.data.remote.api
|
||||||
|
|
||||||
|
import chats.data.remote.dto.ChatDto
|
||||||
|
import chats.data.remote.dto.MessageDto
|
||||||
|
import retrofit2.http.*
|
||||||
|
|
||||||
|
data class SendMessageRequest(
|
||||||
|
val content: String?,
|
||||||
|
val type: String = "text",
|
||||||
|
val attachments: List<AttachmentRequest>? = null,
|
||||||
|
val replyToId: String? = null,
|
||||||
|
val quote: String? = null,
|
||||||
|
val forwardedFromId: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AttachmentRequest(
|
||||||
|
val type: String,
|
||||||
|
val url: String,
|
||||||
|
val fileName: String,
|
||||||
|
val fileSize: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ChatApi {
|
||||||
|
@GET("chats")
|
||||||
|
suspend fun getChats(): List<ChatDto>
|
||||||
|
|
||||||
|
@GET("messages/chat/{chatId}")
|
||||||
|
suspend fun getMessages(
|
||||||
|
@Path("chatId") chatId: String,
|
||||||
|
@Query("cursor") cursor: String? = null,
|
||||||
|
@Query("pivot") pivot: Long? = null,
|
||||||
|
@Query("afterSequenceId") afterSequenceId: Long? = null,
|
||||||
|
@Query("limit") limit: Int? = 50
|
||||||
|
): List<MessageDto>
|
||||||
|
|
||||||
|
@POST("messages/chat/{chatId}")
|
||||||
|
suspend fun sendMessage(@Path("chatId") chatId: String, @Body request: SendMessageRequest): MessageDto
|
||||||
|
|
||||||
|
@Multipart
|
||||||
|
@POST("messages/upload")
|
||||||
|
suspend fun uploadFile(@Part file: okhttp3.MultipartBody.Part): FileUploadResponse
|
||||||
|
|
||||||
|
// Klipy GIF API
|
||||||
|
@GET("klipy/trending")
|
||||||
|
suspend fun getTrendingGifs(@Query("page") page: Int): KlipyResponse
|
||||||
|
|
||||||
|
@GET("klipy/search")
|
||||||
|
suspend fun searchGifs(@Query("q") query: String, @Query("page") page: Int): KlipyResponse
|
||||||
|
|
||||||
|
@GET("klipy/categories")
|
||||||
|
suspend fun getGifCategories(): GifCategoriesResponse
|
||||||
|
|
||||||
|
@POST("klipy/shared/{id}")
|
||||||
|
suspend fun markGifShared(@Path("id") id: String, @Body query: String)
|
||||||
|
|
||||||
|
@POST("messages/{messageId}/reactions")
|
||||||
|
suspend fun addReaction(@Path("messageId") messageId: String, @Query("emoji") emoji: String)
|
||||||
|
|
||||||
|
@POST("chats/personal")
|
||||||
|
suspend fun createPersonalChat(@Body request: CreatePersonalChatRequest): ChatDto
|
||||||
|
|
||||||
|
@POST("chats/{chatId}/typing")
|
||||||
|
suspend fun sendTypingStatus(@Path("chatId") chatId: String)
|
||||||
|
|
||||||
|
@POST("chats/{chatId}/read")
|
||||||
|
suspend fun markMessagesAsRead(@Path("chatId") chatId: String, @Body lastMessageId: String)
|
||||||
|
|
||||||
|
@DELETE("messages/{messageId}")
|
||||||
|
suspend fun deleteMessage(@Path("messageId") messageId: String, @Query("forEveryone") forEveryone: Boolean): retrofit2.Response<Unit>
|
||||||
|
|
||||||
|
@PUT("messages/{messageId}")
|
||||||
|
suspend fun editMessage(@Path("messageId") messageId: String, @Body request: SendMessageRequest): MessageDto
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatePersonalChatRequest(
|
||||||
|
val userId: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class KlipyResponse(
|
||||||
|
val data: KlipyDataWrapper
|
||||||
|
)
|
||||||
|
|
||||||
|
data class KlipyDataWrapper(
|
||||||
|
val data: List<KlipyGifDto>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class KlipyGifDto(
|
||||||
|
val id: String,
|
||||||
|
val images: GifImagesDto? = null,
|
||||||
|
val files: Map<String, Map<String, GifImageSourceDto>>? = null,
|
||||||
|
val file: Map<String, Map<String, GifImageSourceDto>>? = null,
|
||||||
|
val media_formats: Map<String, GifImageSourceDto>? = null,
|
||||||
|
val title: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GifImagesDto(
|
||||||
|
val fixed_height: GifImageSourceDto? = null,
|
||||||
|
val original: GifImageSourceDto? = null,
|
||||||
|
val fixed_height_small: GifImageSourceDto? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GifImageSourceDto(
|
||||||
|
val url: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GifCategoryDto(
|
||||||
|
val category: String,
|
||||||
|
val preview_url: String,
|
||||||
|
val query: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class FileUploadResponse(
|
||||||
|
val url: String,
|
||||||
|
val filename: String,
|
||||||
|
val size: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GifCategoriesResponse(
|
||||||
|
val data: GifCategoriesData
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GifCategoriesData(
|
||||||
|
val categories: List<GifCategoryDto>
|
||||||
|
)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package chats.data.remote.dto
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
data class UserBasicDto(
|
||||||
|
@SerializedName("id") val id: String,
|
||||||
|
@SerializedName("username") val username: String? = null,
|
||||||
|
@SerializedName("displayName") val displayName: String? = null,
|
||||||
|
@SerializedName("avatarUrl") val avatarUrl: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MessageDto(
|
||||||
|
@SerializedName("id", alternate = ["Id"]) val id: String,
|
||||||
|
@SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null,
|
||||||
|
@SerializedName("senderId", alternate = ["SenderId"]) val senderId: String? = null,
|
||||||
|
@SerializedName("content", alternate = ["Content"]) val content: String? = null,
|
||||||
|
@SerializedName("type", alternate = ["Type"]) val type: String? = null,
|
||||||
|
@SerializedName("sequenceId", alternate = ["SequenceId"]) val sequenceId: Int? = null,
|
||||||
|
@SerializedName("createdAt", alternate = ["CreatedAt"]) val createdAt: String? = null,
|
||||||
|
@SerializedName("sender", alternate = ["Sender"]) val sender: UserBasicDto? = null,
|
||||||
|
@SerializedName("media", alternate = ["Media"]) val media: List<MediaItemDto> = emptyList(),
|
||||||
|
@SerializedName("reactions", alternate = ["Reactions"]) val reactions: List<ReactionDto>? = emptyList(),
|
||||||
|
@SerializedName("replyTo", alternate = ["ReplyTo"]) val replyTo: MessageDto? = null,
|
||||||
|
@SerializedName("isPinned", alternate = ["IsPinned"]) val isPinned: Boolean? = false,
|
||||||
|
@SerializedName("forwardedFromId", alternate = ["ForwardedFromId"]) val forwardedFromId: String? = null,
|
||||||
|
@SerializedName("forwardedFrom", alternate = ["ForwardedFrom"]) val forwardedFrom: UserBasicDto? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ReactionDto(
|
||||||
|
@SerializedName("emoji") val emoji: String,
|
||||||
|
@SerializedName("count") val count: Int,
|
||||||
|
@SerializedName("isSetByMe") val isSetByMe: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MediaItemDto(
|
||||||
|
@SerializedName("id") val id: String,
|
||||||
|
@SerializedName("type") val type: String,
|
||||||
|
@SerializedName("url") val url: String,
|
||||||
|
@SerializedName("filename") val filename: String? = null,
|
||||||
|
@SerializedName("size") val size: Long? = null,
|
||||||
|
@SerializedName("duration") val duration: Double? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatDto(
|
||||||
|
@SerializedName("id") val id: String,
|
||||||
|
@SerializedName("type") val type: String,
|
||||||
|
@SerializedName("name") val name: String? = null,
|
||||||
|
@SerializedName("avatar") val avatar: String? = null,
|
||||||
|
@SerializedName("unreadCount", alternate = ["UnreadCount", "unread_count"]) val unreadCount: Int = 0,
|
||||||
|
@SerializedName("messages") val messages: List<MessageDto> = emptyList(),
|
||||||
|
@SerializedName("members") val members: List<ChatMemberDto> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatMemberDto(
|
||||||
|
@SerializedName("userId") val userId: String,
|
||||||
|
@SerializedName("user") val user: UserBasicDto? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package chats.data.remote.signalr
|
||||||
|
|
||||||
|
import chats.data.remote.dto.ChatDto
|
||||||
|
import chats.data.remote.dto.MessageDto
|
||||||
|
|
||||||
|
sealed class ChatEvent {
|
||||||
|
data class NewMessage(val message: MessageDto) : ChatEvent()
|
||||||
|
data class MessageEdited(val messageId: String, val chatId: String, val content: String) : ChatEvent()
|
||||||
|
data class MessageDeleted(val messageId: String, val chatId: String) : ChatEvent()
|
||||||
|
data class MessagesRead(val chatId: String, val userId: String, val lastReadSequenceId: Int) : ChatEvent()
|
||||||
|
data class UserTyping(val chatId: String, val userId: String) : ChatEvent()
|
||||||
|
data class UserStoppedTyping(val chatId: String, val userId: String) : ChatEvent()
|
||||||
|
data class UserOnline(val userId: String) : ChatEvent()
|
||||||
|
data class UserOffline(val userId: String, val lastSeen: String?) : ChatEvent()
|
||||||
|
data class NewChat(val chat: ChatDto) : ChatEvent()
|
||||||
|
data class ReactionUpdated(val messageId: String, val chatId: String, val userId: String, val emoji: String, val isRemoved: Boolean = false) : ChatEvent()
|
||||||
|
data class MessagePinned(val chatId: String, val message: MessageDto) : ChatEvent()
|
||||||
|
data class MessageUnpinned(val chatId: String, val messageId: String) : ChatEvent()
|
||||||
|
|
||||||
|
// Call Events (WebRTC Signaling)
|
||||||
|
data class CallIncoming(val chatId: String, val from: String, val offer: String, val callType: String) : ChatEvent()
|
||||||
|
data class CallAnswered(val chatId: String, val answer: String) : ChatEvent()
|
||||||
|
data class IceCandidateReceived(val chatId: String, val candidate: String) : ChatEvent()
|
||||||
|
data class CallEnded(val chatId: String) : ChatEvent()
|
||||||
|
|
||||||
|
// Group Call Events
|
||||||
|
data class GroupCallIncoming(val chatId: String, val from: String, val callerInfo: Any) : ChatEvent()
|
||||||
|
data class GroupCallParticipants(val chatId: String, val participants: List<String>) : ChatEvent()
|
||||||
|
data class GroupCallUserJoined(val chatId: String, val userId: String) : ChatEvent()
|
||||||
|
data class GroupCallUserLeft(val chatId: String, val userId: String) : ChatEvent()
|
||||||
|
data class GroupCallOffer(val chatId: String, val from: String, val offer: String) : ChatEvent()
|
||||||
|
data class GroupCallAnswer(val chatId: String, val from: String, val answer: String) : ChatEvent()
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
package chats.data.remote.signalr
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import io.reactivex.rxjava3.core.Single
|
||||||
|
import com.microsoft.signalr.HubConnection
|
||||||
|
import com.microsoft.signalr.HubConnectionBuilder
|
||||||
|
import com.microsoft.signalr.HubConnectionState
|
||||||
|
import chats.data.remote.dto.ChatDto
|
||||||
|
import chats.data.remote.dto.MessageDto
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
data class ReadMessagesRequest(
|
||||||
|
val chatId: String,
|
||||||
|
val lastReadMessageId: String,
|
||||||
|
val lastReadSequenceId: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MessagesReadEvent(
|
||||||
|
@com.google.gson.annotations.SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null,
|
||||||
|
@com.google.gson.annotations.SerializedName("userId", alternate = ["UserId"]) val userId: String? = null,
|
||||||
|
@com.google.gson.annotations.SerializedName("lastReadSequenceId", alternate = ["LastReadSequenceId"]) val lastReadSequenceId: Int? = null
|
||||||
|
) {
|
||||||
|
val effectiveChatId: String get() = chatId ?: ""
|
||||||
|
val effectiveUserId: String get() = userId ?: ""
|
||||||
|
val effectiveLastReadSequenceId: Int get() = lastReadSequenceId ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ReactionEvent(
|
||||||
|
@com.google.gson.annotations.SerializedName("messageId", alternate = ["MessageId"]) val messageId: String? = null,
|
||||||
|
@com.google.gson.annotations.SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null,
|
||||||
|
@com.google.gson.annotations.SerializedName("userId", alternate = ["UserId"]) val userId: String? = null,
|
||||||
|
@com.google.gson.annotations.SerializedName("username", alternate = ["Username", "UserName"]) val username: String? = null,
|
||||||
|
@com.google.gson.annotations.SerializedName("emoji", alternate = ["Emoji"]) val emoji: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class ConnectionStatus { CONNECTED, CONNECTING, DISCONNECTED }
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ChatHubClient @Inject constructor() {
|
||||||
|
private var hubConnection: HubConnection? = null
|
||||||
|
// extraBufferCapacity=1024 позволяет буферизовать события пока нет подписчиков
|
||||||
|
private val _events = MutableSharedFlow<ChatEvent>(replay = 0, extraBufferCapacity = 1024)
|
||||||
|
val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
|
private val _status = MutableStateFlow(ConnectionStatus.DISCONNECTED)
|
||||||
|
val status: StateFlow<ConnectionStatus> = _status.asStateFlow()
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO)
|
||||||
|
private var lastBaseUrl: String? = null
|
||||||
|
private var lastToken: String? = null
|
||||||
|
|
||||||
|
fun connect(baseUrl: String, accessToken: String) {
|
||||||
|
// Проверяем текущее состояние
|
||||||
|
val currentState = hubConnection?.connectionState
|
||||||
|
if (currentState == HubConnectionState.CONNECTED) {
|
||||||
|
Log.d("ChatHubClient", "Already connected, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если соединение в процессе - останавливаем его
|
||||||
|
if (currentState == HubConnectionState.CONNECTING) {
|
||||||
|
Log.d("ChatHubClient", "Connection in progress ($currentState), stopping first...")
|
||||||
|
hubConnection?.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем параметры для переподключения
|
||||||
|
lastBaseUrl = baseUrl
|
||||||
|
lastToken = accessToken
|
||||||
|
_status.value = ConnectionStatus.CONNECTING
|
||||||
|
|
||||||
|
Log.d("ChatHubClient", "Connecting to ${baseUrl}/hubs/chat with token: ${accessToken.take(10)}...")
|
||||||
|
|
||||||
|
// Создаем новое соединение
|
||||||
|
val newHubConnection = HubConnectionBuilder.create("${baseUrl}/hubs/chat")
|
||||||
|
.withAccessTokenProvider(Single.just(accessToken))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
hubConnection = newHubConnection
|
||||||
|
|
||||||
|
setupHandlers()
|
||||||
|
|
||||||
|
hubConnection?.onClosed { exception ->
|
||||||
|
Log.e("ChatHubClient", "Connection closed. Reconnecting...", exception)
|
||||||
|
_status.value = ConnectionStatus.DISCONNECTED
|
||||||
|
scope.launch {
|
||||||
|
// Проверяем, есть ли еще актуальные параметры для переподключения
|
||||||
|
val reconnectBaseUrl = lastBaseUrl
|
||||||
|
val reconnectToken = lastToken
|
||||||
|
|
||||||
|
if (reconnectBaseUrl != null && reconnectToken != null) {
|
||||||
|
Log.d("ChatHubClient", "Attempting reconnection with saved parameters...")
|
||||||
|
delay(5000)
|
||||||
|
connect(reconnectBaseUrl, reconnectToken)
|
||||||
|
} else {
|
||||||
|
Log.w("ChatHubClient", "Cannot reconnect: missing baseUrl or token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
Log.d("ChatHubClient", "Starting SignalR connection...")
|
||||||
|
hubConnection?.start()?.blockingAwait()
|
||||||
|
_status.value = ConnectionStatus.CONNECTED
|
||||||
|
Log.d("ChatHubClient", "SignalR Connected successfully!")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("ChatHubClient", "SignalR Connection Error", e)
|
||||||
|
_status.value = ConnectionStatus.DISCONNECTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupHandlers() {
|
||||||
|
hubConnection?.let { conn ->
|
||||||
|
Log.d("ChatHubClient", "Setting up SignalR handlers")
|
||||||
|
|
||||||
|
conn.on("new_message", { message: MessageDto ->
|
||||||
|
Log.d("ChatHubClient", ">>> new_message event received: ${message.id} in chat ${message.chatId}")
|
||||||
|
_events.tryEmit(ChatEvent.NewMessage(message))
|
||||||
|
}, MessageDto::class.java)
|
||||||
|
|
||||||
|
conn.on("message_edited", { messageId: String, chatId: String, content: String ->
|
||||||
|
Log.d("ChatHubClient", ">>> message_edited event: $messageId")
|
||||||
|
_events.tryEmit(ChatEvent.MessageEdited(messageId, chatId, content))
|
||||||
|
}, String::class.java, String::class.java, String::class.java)
|
||||||
|
|
||||||
|
conn.on("message_deleted", { messageId: String, chatId: String ->
|
||||||
|
Log.d("ChatHubClient", ">>> message_deleted event: $messageId")
|
||||||
|
_events.tryEmit(ChatEvent.MessageDeleted(messageId, chatId))
|
||||||
|
}, String::class.java, String::class.java)
|
||||||
|
|
||||||
|
conn.on("messages_read", { data: MessagesReadEvent ->
|
||||||
|
Log.d("ChatHubClient", ">>> messages_read event: ${data.effectiveChatId}")
|
||||||
|
_events.tryEmit(ChatEvent.MessagesRead(
|
||||||
|
data.effectiveChatId,
|
||||||
|
data.effectiveUserId,
|
||||||
|
data.effectiveLastReadSequenceId
|
||||||
|
))
|
||||||
|
}, MessagesReadEvent::class.java)
|
||||||
|
|
||||||
|
conn.on("user_typing", { data: ReactionEvent ->
|
||||||
|
_events.tryEmit(ChatEvent.UserTyping(data.chatId ?: "", data.userId ?: ""))
|
||||||
|
}, ReactionEvent::class.java)
|
||||||
|
|
||||||
|
conn.on("user_stopped_typing", { data: ReactionEvent ->
|
||||||
|
_events.tryEmit(ChatEvent.UserStoppedTyping(data.chatId ?: "", data.userId ?: ""))
|
||||||
|
}, ReactionEvent::class.java)
|
||||||
|
|
||||||
|
conn.on("user_online", { userId: String ->
|
||||||
|
_events.tryEmit(ChatEvent.UserOnline(userId))
|
||||||
|
}, String::class.java)
|
||||||
|
|
||||||
|
conn.on("new_chat", { chat: ChatDto ->
|
||||||
|
Log.d("ChatHubClient", ">>> new_chat event: ${chat.id}")
|
||||||
|
_events.tryEmit(ChatEvent.NewChat(chat))
|
||||||
|
}, ChatDto::class.java)
|
||||||
|
|
||||||
|
conn.on("reaction_added", { data: ReactionEvent ->
|
||||||
|
Log.d("ChatHubClient", ">>> reaction_added event: ${data.emoji} on ${data.messageId}")
|
||||||
|
_events.tryEmit(ChatEvent.ReactionUpdated(
|
||||||
|
data.messageId ?: "",
|
||||||
|
data.chatId ?: "",
|
||||||
|
data.userId ?: "",
|
||||||
|
data.emoji ?: "",
|
||||||
|
isRemoved = false
|
||||||
|
))
|
||||||
|
}, ReactionEvent::class.java)
|
||||||
|
|
||||||
|
conn.on("reaction_removed", { data: ReactionEvent ->
|
||||||
|
Log.d("ChatHubClient", ">>> reaction_removed event: ${data.emoji} on ${data.messageId}")
|
||||||
|
_events.tryEmit(ChatEvent.ReactionUpdated(
|
||||||
|
data.messageId ?: "",
|
||||||
|
data.chatId ?: "",
|
||||||
|
data.userId ?: "",
|
||||||
|
data.emoji ?: "",
|
||||||
|
isRemoved = true
|
||||||
|
))
|
||||||
|
}, ReactionEvent::class.java)
|
||||||
|
|
||||||
|
// WebRTC Signaling Handlers
|
||||||
|
conn.on("call_incoming", { chatId: String, from: String, offer: String, callType: String ->
|
||||||
|
_events.tryEmit(ChatEvent.CallIncoming(chatId, from, offer, callType))
|
||||||
|
}, String::class.java, String::class.java, String::class.java, String::class.java)
|
||||||
|
|
||||||
|
conn.on("call_answered", { chatId: String, answer: String ->
|
||||||
|
_events.tryEmit(ChatEvent.CallAnswered(chatId, answer))
|
||||||
|
}, String::class.java, String::class.java)
|
||||||
|
|
||||||
|
conn.on("ice_candidate", { chatId: String, candidate: String ->
|
||||||
|
_events.tryEmit(ChatEvent.IceCandidateReceived(chatId, candidate))
|
||||||
|
}, String::class.java, String::class.java)
|
||||||
|
|
||||||
|
conn.on("call_ended", { chatId: String ->
|
||||||
|
_events.tryEmit(ChatEvent.CallEnded(chatId))
|
||||||
|
}, String::class.java)
|
||||||
|
conn.on("message_pinned", { data: Map<String, Any> ->
|
||||||
|
// The web version expects a message object, but here we might get a partial DTO or just IDs.
|
||||||
|
// Let's assume we get { chatId, message: MessageDto } based on web
|
||||||
|
// We'll trust the DTO mapping if possible, but SignalR java client is picky with nested objects in Maps.
|
||||||
|
// For simplicity, we might needs a dedicated DTO if it fails.
|
||||||
|
}, Map::class.java)
|
||||||
|
|
||||||
|
conn.on("message_pinned", { chatId: String, message: MessageDto ->
|
||||||
|
_events.tryEmit(ChatEvent.MessagePinned(chatId, message))
|
||||||
|
}, String::class.java, MessageDto::class.java)
|
||||||
|
|
||||||
|
conn.on("message_unpinned", { chatId: String, messageId: String ->
|
||||||
|
_events.tryEmit(ChatEvent.MessageUnpinned(chatId, messageId))
|
||||||
|
}, String::class.java, String::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
hubConnection?.stop()
|
||||||
|
_status.value = ConnectionStatus.DISCONNECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Принудительное переподключение - останавливает текущее соединение и создает новое
|
||||||
|
*/
|
||||||
|
fun reconnect() {
|
||||||
|
Log.d("ChatHubClient", "Forced reconnect requested")
|
||||||
|
val baseUrl = lastBaseUrl
|
||||||
|
val token = lastToken
|
||||||
|
|
||||||
|
if (baseUrl != null && token != null) {
|
||||||
|
disconnect()
|
||||||
|
// Небольшая задержка перед переподключением
|
||||||
|
scope.launch {
|
||||||
|
delay(1000)
|
||||||
|
connect(baseUrl, token)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w("ChatHubClient", "Cannot reconnect: missing saved credentials")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addReaction(messageId: String, chatId: String, emoji: String) {
|
||||||
|
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||||
|
hubConnection?.invoke("add_reaction", mapOf(
|
||||||
|
"messageId" to messageId,
|
||||||
|
"chatId" to chatId,
|
||||||
|
"emoji" to emoji
|
||||||
|
))?.doOnError { Log.e("ChatHubClient", "add_reaction error", it) }
|
||||||
|
?.subscribe()
|
||||||
|
Log.d("ChatHubClient", "Invoked add_reaction: $emoji on $messageId")
|
||||||
|
} else {
|
||||||
|
Log.w("ChatHubClient", "Cannot add_reaction: Not connected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeReaction(messageId: String, chatId: String, emoji: String) {
|
||||||
|
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||||
|
hubConnection?.invoke("remove_reaction", mapOf(
|
||||||
|
"messageId" to messageId,
|
||||||
|
"chatId" to chatId,
|
||||||
|
"emoji" to emoji
|
||||||
|
))?.doOnError { Log.e("ChatHubClient", "remove_reaction error", it) }
|
||||||
|
?.subscribe()
|
||||||
|
Log.d("ChatHubClient", "Invoked remove_reaction: $emoji on $messageId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pinMessage(messageId: String, chatId: String) {
|
||||||
|
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||||
|
hubConnection?.invoke("pin_message", mapOf(
|
||||||
|
"messageId" to messageId,
|
||||||
|
"chatId" to chatId
|
||||||
|
))?.doOnError { Log.e("ChatHubClient", "pin_message error", it) }
|
||||||
|
?.subscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unpinMessage(messageId: String, chatId: String) {
|
||||||
|
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||||
|
hubConnection?.invoke("unpin_message", mapOf(
|
||||||
|
"messageId" to messageId,
|
||||||
|
"chatId" to chatId
|
||||||
|
))?.doOnError { Log.e("ChatHubClient", "unpin_message error", it) }
|
||||||
|
?.subscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readMessages(request: ReadMessagesRequest) {
|
||||||
|
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||||
|
hubConnection?.invoke("read_messages", request)
|
||||||
|
?.doOnError { Log.e("ChatHubClient", "read_messages error", it) }
|
||||||
|
?.subscribe()
|
||||||
|
Log.d("ChatHubClient", "Sent read_messages for chat: ${request.chatId}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun joinChat(chatId: String) {
|
||||||
|
scope.launch {
|
||||||
|
// Wait for connection to be established if it's currently connecting
|
||||||
|
var attempts = 0
|
||||||
|
while (hubConnection?.connectionState != HubConnectionState.CONNECTED && attempts < 10) {
|
||||||
|
delay(500)
|
||||||
|
attempts++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||||
|
Log.d("ChatHubClient", "Joining chat room: $chatId")
|
||||||
|
hubConnection?.invoke("join_chat", chatId)
|
||||||
|
?.doOnError { Log.e("ChatHubClient", "join_chat error", it) }
|
||||||
|
?.subscribe()
|
||||||
|
Log.d("ChatHubClient", "Joined chat room: $chatId")
|
||||||
|
} else {
|
||||||
|
Log.e("ChatHubClient", "Failed to join chat room $chatId: Not connected (state=${hubConnection?.connectionState})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendTypingIndicator(chatId: String) {
|
||||||
|
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||||
|
hubConnection?.invoke("typing_start", chatId)
|
||||||
|
?.doOnError { Log.e("ChatHubClient", "typing_start error", it) }
|
||||||
|
?.subscribe()
|
||||||
|
Log.d("ChatHubClient", "Sent typing indicator for chat: $chatId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendUserStoppedTyping(chatId: String) {
|
||||||
|
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||||
|
hubConnection?.invoke("typing_stop", chatId)
|
||||||
|
?.doOnError { Log.e("ChatHubClient", "typing_stop error", it) }
|
||||||
|
?.subscribe()
|
||||||
|
Log.d("ChatHubClient", "Sent user stopped typing for chat: $chatId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
package chats.data.remote.signalr
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import core.network.NetworkManager
|
||||||
|
import core.network.ServerConfig
|
||||||
|
import core.notifications.data.ActiveChatTracker
|
||||||
|
import core.notifications.data.NotificationHelper
|
||||||
|
import core.security.TokenManager
|
||||||
|
import chats.data.sync.MessageSyncWorker
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import chats.data.remote.signalr.ConnectionStatus
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class SignalRNotificationObserver @Inject constructor(
|
||||||
|
private val signalrClient: ChatHubClient,
|
||||||
|
private val activeChatTracker: ActiveChatTracker,
|
||||||
|
private val tokenManager: TokenManager,
|
||||||
|
private val chatRepository: chats.domain.repository.ChatRepository,
|
||||||
|
private val serverConfig: ServerConfig,
|
||||||
|
private val networkManager: NetworkManager,
|
||||||
|
private val messageDao: core.database.data.MessageDao,
|
||||||
|
private val api: chats.data.remote.api.ChatApi,
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
private var isStarted = false
|
||||||
|
private val processedMessageIds = mutableSetOf<String>()
|
||||||
|
|
||||||
|
// OkHttpClient для ping запроса
|
||||||
|
private val pingClient = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(5, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(5, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
if (isStarted) return
|
||||||
|
isStarted = true
|
||||||
|
|
||||||
|
// Запускаем мониторинг сети
|
||||||
|
networkManager.startMonitoring()
|
||||||
|
|
||||||
|
// Принудительно обновляем состояние сети при старте
|
||||||
|
networkManager.refreshNetworkState()
|
||||||
|
|
||||||
|
// Подключаемся к SignalR при старте приложения
|
||||||
|
connectSignalR()
|
||||||
|
|
||||||
|
// Initial count load
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
// Запускаем периодическую проверку подключения SignalR
|
||||||
|
startConnectionHealthCheck()
|
||||||
|
|
||||||
|
// Слушаем восстановление сети и переподключаем SignalR
|
||||||
|
networkManager.isOnline
|
||||||
|
.filter { it } // Только переход в онлайн
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.onEach {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Network restored! Reconnecting SignalR and syncing...")
|
||||||
|
// Небольшая задержка чтобы сеть стабилизировалась
|
||||||
|
kotlinx.coroutines.delay(1500)
|
||||||
|
reconnectOnNetworkRestored()
|
||||||
|
}
|
||||||
|
.launchIn(scope)
|
||||||
|
|
||||||
|
// Также отслеживаем состояние SignalR для переподключения
|
||||||
|
signalrClient.status
|
||||||
|
.filter { it == ConnectionStatus.DISCONNECTED }
|
||||||
|
.onEach {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "SignalR disconnected, checking network...")
|
||||||
|
// Если мы offline и SignalR отключен - не делаем ничего
|
||||||
|
// Подключимся когда сеть восстановится
|
||||||
|
}
|
||||||
|
.launchIn(scope)
|
||||||
|
|
||||||
|
// При восстановлении соединения SignalR обновляем список чатов и вступаем в них
|
||||||
|
signalrClient.status
|
||||||
|
.filter { it == ConnectionStatus.CONNECTED }
|
||||||
|
.onEach {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "SignalR connected, refreshing chats and joining rooms")
|
||||||
|
onSignalRConnected()
|
||||||
|
}
|
||||||
|
.launchIn(scope)
|
||||||
|
|
||||||
|
// Слушаем события SignalR для уведомлений и обновления списка чатов
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Starting to listen to signalrClient.events")
|
||||||
|
signalrClient.events
|
||||||
|
.onEach { event ->
|
||||||
|
android.util.Log.d("SignalRNtfObserver", ">>> Received event: ${event::class.simpleName}")
|
||||||
|
when (event) {
|
||||||
|
is ChatEvent.NewMessage -> {
|
||||||
|
val currentUserId = tokenManager.getUserId()
|
||||||
|
val message = event.message
|
||||||
|
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "New message: ${message.id} from ${message.senderId}, current: $currentUserId")
|
||||||
|
|
||||||
|
// Don't show if it's our message or already processed
|
||||||
|
if (message.senderId == currentUserId) {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Skipping - own message")
|
||||||
|
return@onEach
|
||||||
|
}
|
||||||
|
if (processedMessageIds.contains(message.id)) {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Skipping - already processed")
|
||||||
|
return@onEach
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as processed
|
||||||
|
processedMessageIds.add(message.id)
|
||||||
|
if (processedMessageIds.size > 200) {
|
||||||
|
processedMessageIds.remove(processedMessageIds.first())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment immediately for UI feedback
|
||||||
|
activeChatTracker.incrementUnreadCount()
|
||||||
|
|
||||||
|
// Refresh total count from source of truth in background
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
// Don't show if this chat is currently open
|
||||||
|
if (activeChatTracker.currentChatId.value == message.chatId) {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Skipping - chat is open: ${message.chatId}")
|
||||||
|
return@onEach
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Showing notification for chat: ${message.chatId}")
|
||||||
|
NotificationHelper.showNotification(
|
||||||
|
context = context,
|
||||||
|
title = message.sender?.displayName ?: "Новое сообщение",
|
||||||
|
body = message.content ?: "Вам прислали вложение",
|
||||||
|
type = "chat",
|
||||||
|
chatId = message.chatId,
|
||||||
|
notificationId = message.id.hashCode(),
|
||||||
|
totalCount = activeChatTracker.totalUnreadCount.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is ChatEvent.MessagesRead -> {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Messages read event")
|
||||||
|
// If anyone read messages, sync our total count
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Unhandled event: ${event::class.simpleName}")
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startConnectionHealthCheck() {
|
||||||
|
// Каждые 10 секунд проверяем подключение и при необходимости переподключаемся
|
||||||
|
scope.launch {
|
||||||
|
var consecutiveFailures = 0
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
kotlinx.coroutines.delay(10000)
|
||||||
|
val currentStatus = signalrClient.status.value
|
||||||
|
val isOnline = networkManager.isOnline.value
|
||||||
|
|
||||||
|
if (isOnline && currentStatus == ConnectionStatus.DISCONNECTED) {
|
||||||
|
consecutiveFailures++
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Health check: Network online but SignalR disconnected (failures: $consecutiveFailures), reconnecting...")
|
||||||
|
signalrClient.reconnect()
|
||||||
|
} else if (isOnline && currentStatus == ConnectionStatus.CONNECTED) {
|
||||||
|
// Сбрасываем счетчик ошибок при успешном подключении
|
||||||
|
consecutiveFailures = 0
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Health check: Connection healthy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onSignalRConnected() {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "SignalR connected - syncing missed messages...")
|
||||||
|
refresh()
|
||||||
|
// Вступаем во все чаты для получения событий
|
||||||
|
joinAllChats()
|
||||||
|
// Синхронизируем пропущенные сообщения
|
||||||
|
syncMissedMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Синхронизирует пропущенные сообщения после восстановления соединения
|
||||||
|
* Запрашивает только НОВЫЕ сообщения с последнего известного sequenceId
|
||||||
|
* Показывает уведомления для непрочитанных сообщений
|
||||||
|
*/
|
||||||
|
private fun syncMissedMessages() {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Starting missed messages sync...")
|
||||||
|
val chats = chatRepository.getChats()
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Syncing ${chats.size} chats for missed messages")
|
||||||
|
|
||||||
|
// Запрашиваем только новые сообщения для каждого чата
|
||||||
|
chats.forEach { chat ->
|
||||||
|
try {
|
||||||
|
// Получаем последний известный sequenceId из локальной базы
|
||||||
|
val lastSequenceId = chatRepository.getLastKnownSequenceId(chat.id)
|
||||||
|
|
||||||
|
if (lastSequenceId != null) {
|
||||||
|
// Запрашиваем сообщения ПОСЛЕ lastSequenceId (только новые)
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Chat ${chat.id}: last known seqId=$lastSequenceId, fetching newer...")
|
||||||
|
val newMessages = chatRepository.getMessages(
|
||||||
|
chatId = chat.id,
|
||||||
|
afterSequenceId = lastSequenceId.toLong(),
|
||||||
|
limit = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Chat ${chat.id}: fetched ${newMessages.size} messages from API")
|
||||||
|
|
||||||
|
// Показываем уведомление если есть новые сообщения и чат не открыт
|
||||||
|
if (newMessages.isNotEmpty() && activeChatTracker.currentChatId.value != chat.id) {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Chat ${chat.id}: ${newMessages.size} new messages, showing notification")
|
||||||
|
showMissedMessagesNotification(chat, newMessages)
|
||||||
|
} else {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Chat ${chat.id}: skipping notification (messages=${newMessages.size}, chatOpen=${activeChatTracker.currentChatId.value == chat.id})")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Нет локальных сообщений - загружаем последние 50
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Chat ${chat.id}: no local messages, fetching last 50")
|
||||||
|
chatRepository.getMessages(chatId = chat.id, limit = 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Synced chat ${chat.id}")
|
||||||
|
// Небольшая пауза между чатами чтобы не перегружать сервер
|
||||||
|
kotlinx.coroutines.delay(100)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SignalRNtfObserver", "Failed to sync chat ${chat.id}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Missed messages sync completed")
|
||||||
|
|
||||||
|
// Обновляем счетчик непрочитанных после синхронизации
|
||||||
|
refresh()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SignalRNtfObserver", "Sync failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Показывает уведомление о пропущенных сообщениях
|
||||||
|
*/
|
||||||
|
private fun showMissedMessagesNotification(chat: chats.domain.model.Chat, newMessages: List<chats.domain.model.Message>) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "showMissedMessagesNotification called for chat ${chat.id} with ${newMessages.size} messages")
|
||||||
|
|
||||||
|
// Фильтруем сообщения не от текущего пользователя
|
||||||
|
val currentUserId = tokenManager.getUserId()
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Current user ID: $currentUserId")
|
||||||
|
|
||||||
|
val messagesFromOthers = newMessages.filter { it.senderId != currentUserId }
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Messages from others: ${messagesFromOthers.size}")
|
||||||
|
|
||||||
|
if (messagesFromOthers.isEmpty()) {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "No new messages from others in chat ${chat.id} - all messages are from current user")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группируем сообщения по отправителям
|
||||||
|
val messagesBySender = messagesFromOthers.groupBy { it.senderId }
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Messages grouped by ${messagesBySender.size} sender(s)")
|
||||||
|
|
||||||
|
// Для каждого отправителя показываем уведомление
|
||||||
|
messagesBySender.forEach { (senderId, messages) ->
|
||||||
|
val senderName = messages.firstOrNull()?.senderName ?: "Контакт"
|
||||||
|
val messageCount = messages.size
|
||||||
|
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Processing sender $senderName with $messageCount messages")
|
||||||
|
|
||||||
|
// Формируем текст уведомления
|
||||||
|
val notificationText = when {
|
||||||
|
messageCount == 1 -> {
|
||||||
|
messages.firstOrNull()?.content ?: "Новое сообщение"
|
||||||
|
}
|
||||||
|
messageCount <= 3 -> {
|
||||||
|
messages.take(3).mapNotNull { it.content }.joinToString(", ")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
"$messageCount новых сообщений"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Showing notification for chat ${chat.id}: $senderName - $notificationText")
|
||||||
|
|
||||||
|
// Небольшая задержка перед показом уведомления
|
||||||
|
kotlinx.coroutines.delay(500)
|
||||||
|
|
||||||
|
// Показываем уведомление
|
||||||
|
NotificationHelper.showNotification(
|
||||||
|
context = context,
|
||||||
|
title = senderName,
|
||||||
|
body = notificationText,
|
||||||
|
type = "chat",
|
||||||
|
chatId = chat.id,
|
||||||
|
notificationId = chat.id.hashCode(),
|
||||||
|
totalCount = activeChatTracker.totalUnreadCount.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SignalRNtfObserver", "Failed to show missed messages notification", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает последний известный sequenceId для чата из локальной базы
|
||||||
|
*/
|
||||||
|
private suspend fun getLastKnownSequenceId(chatId: String): Int? {
|
||||||
|
return try {
|
||||||
|
chatRepository.getLastKnownSequenceId(chatId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SignalRNtfObserver", "Failed to get last sequenceId for $chatId", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reconnectOnNetworkRestored() {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Network restored, starting reconnection sequence...")
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
// 1. Сначала делаем HTTP ping запрос чтобы "разбудить" сетевой стек
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Sending HTTP ping to wake up network...")
|
||||||
|
val pingSuccess = sendHttpPing()
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "HTTP ping result: $pingSuccess")
|
||||||
|
|
||||||
|
// 2. Небольшая пауза для стабилизации
|
||||||
|
kotlinx.coroutines.delay(1000)
|
||||||
|
|
||||||
|
// 3. Принудительное переподключение SignalR
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Forcing SignalR reconnect...")
|
||||||
|
signalrClient.reconnect()
|
||||||
|
|
||||||
|
// 4. Ждем пока SignalR подключится (максимум 10 секунд)
|
||||||
|
var waitCount = 0
|
||||||
|
while (signalrClient.status.value != ConnectionStatus.CONNECTED && waitCount < 20) {
|
||||||
|
kotlinx.coroutines.delay(500)
|
||||||
|
waitCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signalrClient.status.value == ConnectionStatus.CONNECTED) {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "SignalR reconnected, syncing missed messages...")
|
||||||
|
// 5. Синхронизируем пропущенные сообщения
|
||||||
|
syncMissedMessages()
|
||||||
|
|
||||||
|
// 6. Пробуем отправить отложенные сообщения НЕМЕДЛЕННО (не через WorkManager)
|
||||||
|
sendPendingMessagesImmediately()
|
||||||
|
|
||||||
|
// 7. Также планируем WorkManager на всякий случай
|
||||||
|
MessageSyncWorker.scheduleSync(context)
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Outgoing sync worker scheduled")
|
||||||
|
} else {
|
||||||
|
android.util.Log.w("SignalRNtfObserver", "SignalR failed to reconnect within timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Немедленно отправляет отложенные сообщения без ожидания WorkManager
|
||||||
|
* Вызывается сразу после восстановления соединения
|
||||||
|
*/
|
||||||
|
private fun sendPendingMessagesImmediately() {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Checking for pending messages to send...")
|
||||||
|
|
||||||
|
// Получаем все сообщения со статусом SYNCING
|
||||||
|
val pendingMessages = messageDao.getPendingSyncMessages()
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Found ${pendingMessages.size} pending messages")
|
||||||
|
|
||||||
|
if (pendingMessages.isEmpty()) {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "No pending messages to send")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
var successCount = 0
|
||||||
|
var failureCount = 0
|
||||||
|
|
||||||
|
for (message in pendingMessages) {
|
||||||
|
try {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Sending pending message: ${message.id}")
|
||||||
|
|
||||||
|
val attachments = parseAttachments(message.mediaJson)
|
||||||
|
val request = chats.data.remote.api.SendMessageRequest(
|
||||||
|
content = message.content,
|
||||||
|
type = message.mediaType.lowercase(),
|
||||||
|
attachments = attachments,
|
||||||
|
replyToId = message.replyToId
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = api.sendMessage(message.chatId, request)
|
||||||
|
|
||||||
|
// Обновляем сообщение в базе
|
||||||
|
val syncedMessage = message.copy(
|
||||||
|
id = response.id,
|
||||||
|
sequenceId = response.sequenceId ?: message.sequenceId,
|
||||||
|
createdAt = response.createdAt ?: message.createdAt,
|
||||||
|
syncStatus = core.database.data.SyncStatus.SYNCED,
|
||||||
|
isDeletedLocally = false,
|
||||||
|
isEditedLocally = false,
|
||||||
|
editedContent = null,
|
||||||
|
lastUpdated = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
|
messageDao.insertMessage(syncedMessage)
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Message sent successfully: ${response.id}")
|
||||||
|
successCount++
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SignalRNtfObserver", "Failed to send message ${message.id}", e)
|
||||||
|
failureCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Pending messages sync completed. Success: $successCount, Failed: $failureCount")
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SignalRNtfObserver", "sendPendingMessagesImmediately failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseAttachments(mediaJson: String): List<chats.data.remote.api.AttachmentRequest>? {
|
||||||
|
return try {
|
||||||
|
val gson = com.google.gson.Gson()
|
||||||
|
val mediaList = gson.fromJson(mediaJson, Array::class.java)
|
||||||
|
?.map { elem ->
|
||||||
|
val map = elem as Map<*, *>
|
||||||
|
chats.data.remote.api.AttachmentRequest(
|
||||||
|
type = map["type"] as? String ?: "file",
|
||||||
|
url = map["url"] as? String ?: "",
|
||||||
|
fileName = map["filename"] as? String ?: "file",
|
||||||
|
fileSize = (map["size"] as? Number)?.toLong() ?: 0L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
mediaList?.takeIf { it.isNotEmpty() }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SignalRNtfObserver", "Failed to parse attachments", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправляет HTTP ping запрос для активации сетевого соединения
|
||||||
|
*/
|
||||||
|
private suspend fun sendHttpPing(): Boolean {
|
||||||
|
return try {
|
||||||
|
val baseUrl = serverConfig.getBaseUrl()
|
||||||
|
val token = tokenManager.getToken()
|
||||||
|
|
||||||
|
if (baseUrl.isBlank() || token == null) {
|
||||||
|
android.util.Log.w("SignalRNtfObserver", "Cannot ping: missing baseUrl or token")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val url = "${baseUrl.removeSuffix("/api/")}/api/auth/refresh"
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.post(okhttp3.RequestBody.create(null, "{}"))
|
||||||
|
.addHeader("Authorization", "Bearer $token")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val response = pingClient.newCall(request).execute()
|
||||||
|
val success = response.isSuccessful || response.code == 401 // 401 OK для refresh
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "HTTP ping to $url: ${response.code}")
|
||||||
|
response.close()
|
||||||
|
success
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SignalRNtfObserver", "HTTP ping failed", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectSignalR() {
|
||||||
|
val token = tokenManager.getToken()
|
||||||
|
val baseUrl = serverConfig.getBaseUrl()
|
||||||
|
|
||||||
|
if (token == null || baseUrl.isBlank()) {
|
||||||
|
android.util.Log.w("SignalRNtfObserver", "Cannot connect SignalR: token=${token != null}, baseUrl=$baseUrl")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val isOnline = networkManager.isOnline.value
|
||||||
|
if (!isOnline) {
|
||||||
|
android.util.Log.w("SignalRNtfObserver", "Cannot connect SignalR: network is offline")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем текущее состояние SignalR
|
||||||
|
val currentStatus = signalrClient.status.value
|
||||||
|
if (currentStatus == ConnectionStatus.CONNECTED) {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "SignalR already connected, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Connecting SignalR with token: ${token.take(10)}..., baseUrl: $baseUrl")
|
||||||
|
signalrClient.connect(baseUrl.removeSuffix("/api/"), token)
|
||||||
|
|
||||||
|
// Если подключение не удалось в течение 5 секунд - пробуем снова
|
||||||
|
scope.launch {
|
||||||
|
kotlinx.coroutines.delay(5000)
|
||||||
|
if (signalrClient.status.value == ConnectionStatus.DISCONNECTED) {
|
||||||
|
android.util.Log.w("SignalRNtfObserver", "SignalR connection timeout, retrying with reconnect()...")
|
||||||
|
signalrClient.reconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun joinAllChats() {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val chats = chatRepository.getChats()
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Joining ${chats.size} chat rooms")
|
||||||
|
chats.forEach { chat ->
|
||||||
|
signalrClient.joinChat(chat.id)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SignalRNtfObserver", "Failed to join chats", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val chats = chatRepository.getChats()
|
||||||
|
val total = chats.sumOf { it.unreadCount }
|
||||||
|
activeChatTracker.setTotalUnreadCount(total)
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Refreshed chats: ${chats.size}, total unread: $total")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SignalRNtfObserver", "Failed to refresh", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
package chats.data.repository
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.paging.*
|
||||||
|
import chats.data.paging.MessageRemoteMediator
|
||||||
|
import chats.data.remote.api.ChatApi
|
||||||
|
import chats.data.remote.api.SendMessageRequest
|
||||||
|
import chats.data.remote.dto.ChatDto
|
||||||
|
import chats.data.remote.dto.MessageDto
|
||||||
|
import chats.data.signalr.MessageSignalRHandler
|
||||||
|
import chats.data.sync.MessageSyncWorker
|
||||||
|
import chats.domain.model.Chat
|
||||||
|
import chats.domain.model.Message
|
||||||
|
import chats.domain.model.MediaType
|
||||||
|
import chats.domain.repository.ChatRepository
|
||||||
|
import core.database.data.ChatDatabase
|
||||||
|
import core.database.data.ChatDao
|
||||||
|
import core.database.data.MessageDao
|
||||||
|
import core.database.data.MessageEntity
|
||||||
|
import core.database.data.SyncStatus
|
||||||
|
import core.network.ServerConfig
|
||||||
|
import chats.data.remote.signalr.ChatHubClient
|
||||||
|
import core.security.TokenManager
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Основная реализация репозитория чатов
|
||||||
|
*
|
||||||
|
* Архитектура Offline-first:
|
||||||
|
* 1. Все данные читаются из локальной базы Room
|
||||||
|
* 2. При изменении данных - сначала запись в БД, потом синхронизация с сервером
|
||||||
|
* 3. SignalR обновления сразу записываются в БД
|
||||||
|
* 4. WorkManager обрабатывает фоновую синхронизацию
|
||||||
|
*
|
||||||
|
* Conflict Resolution:
|
||||||
|
* - Серверные данные имеют приоритет над локальными
|
||||||
|
* - Исключение: сообщения в процессе отправки (SYNCING) или редактирования
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalPagingApi::class)
|
||||||
|
@Singleton
|
||||||
|
class ChatRepositoryImpl @Inject constructor(
|
||||||
|
private val api: ChatApi,
|
||||||
|
private val tokenManager: TokenManager,
|
||||||
|
private val serverConfig: ServerConfig,
|
||||||
|
private val messageDao: MessageDao,
|
||||||
|
private val chatDao: ChatDao,
|
||||||
|
private val database: ChatDatabase,
|
||||||
|
private val hubClient: ChatHubClient,
|
||||||
|
private val signalRHandler: MessageSignalRHandler,
|
||||||
|
private val context: Context
|
||||||
|
) : ChatRepository {
|
||||||
|
|
||||||
|
private val gson = Gson()
|
||||||
|
private val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
|
||||||
|
init {
|
||||||
|
signalRHandler.startListening()
|
||||||
|
Log.d(TAG, "ChatRepositoryImpl initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getChats(): List<Chat> {
|
||||||
|
val currentUserId = tokenManager.getUserId() ?: ""
|
||||||
|
return try {
|
||||||
|
// Пробуем загрузить из сети
|
||||||
|
val chats = api.getChats().map { it.toDomain(currentUserId, baseUrl) }
|
||||||
|
// Кэшируем в Room
|
||||||
|
val entities = chats.map { it.toEntity() }
|
||||||
|
chatDao.insertChats(entities)
|
||||||
|
// Кэшируем последние сообщения
|
||||||
|
chats.forEach { chat ->
|
||||||
|
chat.lastMessage?.let { msg ->
|
||||||
|
messageDao.upsertMessage(msg.toEntity(gson))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
android.util.Log.d(TAG, "Cached ${entities.size} chats with messages")
|
||||||
|
chats
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.d(TAG, "Network load failed, using cache")
|
||||||
|
// При ошибке - возвращаем из кэша с загрузкой последних сообщений
|
||||||
|
chatDao.getAllChats().map { entity ->
|
||||||
|
val lastMessage = entity.lastMessageId?.let { messageId ->
|
||||||
|
messageDao.getMessageById(messageId)?.toDomain(baseUrl, gson)
|
||||||
|
}
|
||||||
|
entity.toDomain(currentUserId, baseUrl, lastMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChatsFlow(): Flow<List<Chat>> {
|
||||||
|
val currentUserId = tokenManager.getUserId() ?: ""
|
||||||
|
return chatDao.getAllChatsFlow().map { entities ->
|
||||||
|
entities.map { entity ->
|
||||||
|
// Загружаем последнее сообщение из базы для каждого чата
|
||||||
|
val lastMessage = entity.lastMessageId?.let { messageId ->
|
||||||
|
messageDao.getMessageById(messageId)?.toDomain(baseUrl, gson)
|
||||||
|
}
|
||||||
|
entity.toDomain(currentUserId, baseUrl, lastMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getMessagesPaging(chatId: String): Flow<PagingData<Message>> {
|
||||||
|
val pagingConfig = PagingConfig(
|
||||||
|
pageSize = 30,
|
||||||
|
prefetchDistance = 10,
|
||||||
|
initialLoadSize = 50,
|
||||||
|
enablePlaceholders = false
|
||||||
|
)
|
||||||
|
|
||||||
|
return Pager(
|
||||||
|
config = pagingConfig,
|
||||||
|
pagingSourceFactory = { messageDao.getMessagesPagingSource(chatId) },
|
||||||
|
remoteMediator = MessageRemoteMediator(
|
||||||
|
chatId = chatId,
|
||||||
|
api = api,
|
||||||
|
database = database,
|
||||||
|
dao = messageDao,
|
||||||
|
serverConfig = serverConfig,
|
||||||
|
tokenManager = tokenManager
|
||||||
|
)
|
||||||
|
).flow.map { pagingData ->
|
||||||
|
pagingData.map { entity -> entity.toDomain(baseUrl, gson) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getMessagesFlow(chatId: String): Flow<List<Message>> {
|
||||||
|
return messageDao.getMessages(chatId).map { entities ->
|
||||||
|
entities.map { it.toDomain(baseUrl, gson) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getMessages(
|
||||||
|
chatId: String, cursor: String?, pivot: Long?, afterSequenceId: Long?, limit: Int?
|
||||||
|
): List<Message> {
|
||||||
|
val currentUserId = tokenManager.getUserId() ?: ""
|
||||||
|
return try {
|
||||||
|
Log.d(TAG, "Fetching messages from API: chatId=$chatId, afterSequenceId=$afterSequenceId, limit=$limit")
|
||||||
|
val messages = api.getMessages(chatId, cursor = cursor, pivot = pivot, afterSequenceId = afterSequenceId, limit = limit)
|
||||||
|
|
||||||
|
if (messages.isNotEmpty()) {
|
||||||
|
val entities = messages.map { it.toEntity(baseUrl, currentUserId, gson) }
|
||||||
|
messageDao.upsertMessages(entities)
|
||||||
|
Log.d(TAG, "Cached ${entities.size} messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.map { msg -> msg.toDomain(currentUserId, baseUrl).copy(isRead = true) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Fetch messages failed", e)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getLastKnownSequenceId(chatId: String): Int? {
|
||||||
|
return try {
|
||||||
|
messageDao.getMaxSequenceId(chatId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to get last sequenceId", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun sendMessage(
|
||||||
|
chatId: String, content: String?, type: String,
|
||||||
|
attachments: List<chats.data.remote.api.AttachmentRequest>?,
|
||||||
|
replyToId: String?, forwardedFromId: String?
|
||||||
|
): Message {
|
||||||
|
val userId = tokenManager.getUserId() ?: ""
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
val localId = "local_${currentTime}_${chatId}"
|
||||||
|
val localMessage = MessageEntity(
|
||||||
|
id = localId, chatId = chatId, senderId = userId,
|
||||||
|
senderName = "Вы", senderAvatar = null, content = content,
|
||||||
|
sequenceId = 0,
|
||||||
|
createdAt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).format(Date(currentTime)),
|
||||||
|
mediaType = type.uppercase(),
|
||||||
|
mediaJson = "[]",
|
||||||
|
reactionsJson = "{}",
|
||||||
|
isRead = true, replyToId = replyToId,
|
||||||
|
syncStatus = SyncStatus.SYNCING,
|
||||||
|
isDeletedLocally = false, isEditedLocally = false,
|
||||||
|
editedContent = null, lastUpdated = currentTime
|
||||||
|
)
|
||||||
|
|
||||||
|
messageDao.insertMessage(localMessage)
|
||||||
|
Log.d(TAG, "Saved local message: $localId")
|
||||||
|
|
||||||
|
// Пробуем отправить НЕМЕДЛЕННО через API
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "Sending message immediately via API: $localId")
|
||||||
|
val request = SendMessageRequest(
|
||||||
|
content = content,
|
||||||
|
type = type,
|
||||||
|
attachments = attachments,
|
||||||
|
replyToId = replyToId
|
||||||
|
)
|
||||||
|
val response = api.sendMessage(chatId, request)
|
||||||
|
Log.d(TAG, "Message sent successfully: ${response.id}")
|
||||||
|
|
||||||
|
// Обновляем сообщение в базе с серверными данными
|
||||||
|
val syncedMessage = localMessage.copy(
|
||||||
|
id = response.id,
|
||||||
|
sequenceId = response.sequenceId ?: 0,
|
||||||
|
createdAt = response.createdAt ?: localMessage.createdAt,
|
||||||
|
syncStatus = SyncStatus.SYNCED
|
||||||
|
)
|
||||||
|
messageDao.insertMessage(syncedMessage)
|
||||||
|
|
||||||
|
// Возвращаем доменную модель с серверными данными
|
||||||
|
return response.toDomain(userId, baseUrl)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to send message immediately, scheduling sync: ${e.message}")
|
||||||
|
// Ошибка - планируем синхронизацию через WorkManager
|
||||||
|
MessageSyncWorker.scheduleSync(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём доменную модель вручную для локального сообщения
|
||||||
|
return Message(
|
||||||
|
id = localId,
|
||||||
|
chatId = chatId,
|
||||||
|
senderId = userId,
|
||||||
|
senderName = "Вы",
|
||||||
|
senderAvatar = null,
|
||||||
|
content = content,
|
||||||
|
sequenceId = 0,
|
||||||
|
createdAt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).format(Date(currentTime)),
|
||||||
|
media = emptyList(),
|
||||||
|
mediaType = when (type) {
|
||||||
|
"image" -> MediaType.IMAGE
|
||||||
|
"video" -> MediaType.VIDEO
|
||||||
|
"audio" -> MediaType.AUDIO
|
||||||
|
"gif" -> MediaType.GIF
|
||||||
|
else -> MediaType.TEXT
|
||||||
|
},
|
||||||
|
reactions = emptyMap(),
|
||||||
|
isRead = true,
|
||||||
|
isPinned = false,
|
||||||
|
isForwarded = false,
|
||||||
|
forwardedFromName = null,
|
||||||
|
replyTo = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addReaction(messageId: String, emoji: String) {
|
||||||
|
hubClient.addReaction(messageId, "", emoji)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun sendTypingStatus(chatId: String) {
|
||||||
|
api.sendTypingStatus(chatId)
|
||||||
|
hubClient.sendTypingIndicator(chatId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
|
||||||
|
try {
|
||||||
|
hubClient.readMessages(chats.data.remote.signalr.ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error marking messages as read", e)
|
||||||
|
}
|
||||||
|
messageDao.markMessagesAsRead(chatId, lastReadSequenceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveMessage(message: Message) {
|
||||||
|
messageDao.insertMessage(message.toEntity(gson))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteLocalMessage(messageId: String) {
|
||||||
|
messageDao.markAsDeletedLocally(messageId)
|
||||||
|
MessageSyncWorker.scheduleSync(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun editLocalMessage(messageId: String, newContent: String) {
|
||||||
|
messageDao.markAsEditedLocally(messageId, newContent)
|
||||||
|
MessageSyncWorker.scheduleSync(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun uploadMedia(file: java.io.File): String {
|
||||||
|
val mimeType = when (file.extension.lowercase()) {
|
||||||
|
"jpg", "jpeg" -> "image/jpeg"
|
||||||
|
"png" -> "image/png"
|
||||||
|
"webp" -> "image/webp"
|
||||||
|
"mp4" -> "video/mp4"
|
||||||
|
"mp3", "m4a", "wav" -> "audio/mpeg"
|
||||||
|
else -> "application/octet-stream"
|
||||||
|
}
|
||||||
|
val requestFile = file.asRequestBody(mimeType.toMediaTypeOrNull())
|
||||||
|
val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
|
||||||
|
return api.uploadFile(body).url
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTrendingGifs(page: Int): List<chats.data.remote.api.KlipyGifDto> =
|
||||||
|
api.getTrendingGifs(page).data.data
|
||||||
|
|
||||||
|
override suspend fun searchGifs(query: String, page: Int): List<chats.data.remote.api.KlipyGifDto> =
|
||||||
|
api.searchGifs(query, page).data.data
|
||||||
|
|
||||||
|
override suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto> =
|
||||||
|
api.getGifCategories().data.categories
|
||||||
|
|
||||||
|
override suspend fun createPersonalChat(userId: String): Chat {
|
||||||
|
val currentUserId = tokenManager.getUserId() ?: ""
|
||||||
|
val request = chats.data.remote.api.CreatePersonalChatRequest(userId)
|
||||||
|
return api.createPersonalChat(request).toDomain(currentUserId, baseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteMessage(messageId: String, forEveryone: Boolean) {
|
||||||
|
api.deleteMessage(messageId, forEveryone)
|
||||||
|
messageDao.deleteMessage(messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun editMessage(messageId: String, content: String): Message {
|
||||||
|
val request = SendMessageRequest(content = content)
|
||||||
|
val currentUserId = tokenManager.getUserId() ?: ""
|
||||||
|
val response = api.editMessage(messageId, request)
|
||||||
|
messageDao.insertMessage(response.toEntity(baseUrl, currentUserId, gson))
|
||||||
|
return response.toDomain(currentUserId, baseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ChatRepositoryImpl"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user