Release IKAR 1.0.8: fix incoming calls, cancellation and call history
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Tests / test (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Tests / test (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
using System.Text.Json;
|
||||
using Ikar.Server.Controllers;
|
||||
using Ikar.Server.Data;
|
||||
using Ikar.Server.Data.Entities;
|
||||
using Ikar.Server.Infrastructure.Calls;
|
||||
using Ikar.Server.Infrastructure.Hubs;
|
||||
using Ikar.Server.Infrastructure.Push;
|
||||
using Ikar.Shared;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Ikar.Server.Tests;
|
||||
|
||||
public sealed partial class QlyraCompatibilityControllerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false, false, "CANCELED")]
|
||||
[InlineData(false, true, "REJECTED")]
|
||||
[InlineData(true, false, "HUNGUP")]
|
||||
public async Task FinishedDirectCall_IsDurableForBothParticipants_AndIdempotent(bool accepted, bool reject, string expected)
|
||||
{
|
||||
await using var database = new SqliteTestDatabase();
|
||||
await using var db = await database.CreateContextAsync();
|
||||
var caller = new User { Username = "call-a", NormalizedUsername = "CALL-A", DisplayName = "A", PasswordHash = "test" };
|
||||
var callee = new User { Username = "call-b", NormalizedUsername = "CALL-B", DisplayName = "B", PasswordHash = "test" };
|
||||
db.Users.AddRange(caller, callee);
|
||||
db.PushDevices.AddRange(new PushDevice { UserId = caller.Id, Platform = PushPlatform.Android, InstallationId = "a", DeviceToken = "a", NotificationsEnabled = true },
|
||||
new PushDevice { UserId = callee.Id, Platform = PushPlatform.Android, InstallationId = "b", DeviceToken = "b", NotificationsEnabled = true });
|
||||
await db.SaveChangesAsync();
|
||||
var push = new RecordingQlyraPushSender();
|
||||
var options = new DbContextOptionsBuilder<IkarDbContext>().UseSqlite(db.Database.GetDbConnection()).Options;
|
||||
await using var services = new ServiceCollection().AddScoped(_ => new IkarDbContext(options))
|
||||
.AddSingleton<IPushNotificationSender>(push).BuildServiceProvider();
|
||||
var lifecycle = new QlyraCallLifecycle(services.GetRequiredService<IServiceScopeFactory>(),
|
||||
new NoopHubContext<MessengerHub>(), NullLogger<QlyraCallLifecycle>.Instance);
|
||||
var store = new QlyraCallCompatibilityStore(Options.Create(new WebRtcOptions()), lifecycle);
|
||||
var call = store.Create("durable-call", true, (caller.Id, 10), (callee.Id, 20));
|
||||
if (accepted) call.AcceptedAt = DateTimeOffset.UtcNow.AddSeconds(-5);
|
||||
|
||||
// Caller also sends accept-call automatically; this must not count as an answer.
|
||||
if (!reject && !accepted)
|
||||
await store.HandleWebSocketAsync(CreateWebSocketContext(call.Participants[caller.Id].Token,
|
||||
new ScriptedWebSocket("{\"command\":\"accept-call\",\"sequence\":1}", "{\"command\":\"hangup\",\"sequence\":2}")));
|
||||
else Assert.True(await store.EndAsync(call.Id, reject ? callee.Id : caller.Id));
|
||||
Assert.True(await store.EndAsync(call.Id, caller.Id));
|
||||
Assert.Null(store.Find(call.Id));
|
||||
Assert.Single(push.Finished);
|
||||
Assert.Equal(new[] { caller.Id, callee.Id }.Order(), push.Finished[0].Devices.Select(d => d.UserId).Order());
|
||||
|
||||
// A fresh DB context/store must still return the history, not an in-memory log.
|
||||
await using var fresh = new IkarDbContext(options);
|
||||
var messages = await fresh.Messages.AsNoTracking().ToListAsync();
|
||||
var message = Assert.Single(messages);
|
||||
Assert.Equal(caller.Id, message.SenderId);
|
||||
using var attach = JsonDocument.Parse(message.QlyraAttachesJson!);
|
||||
Assert.Equal(expected, attach.RootElement[0].GetProperty("hangupType").GetString());
|
||||
Assert.Equal("VIDEO", attach.RootElement[0].GetProperty("callType").GetString());
|
||||
Assert.Equal(accepted, attach.RootElement[0].GetProperty("duration").GetInt64() > 0);
|
||||
foreach (var participant in new[] { caller, callee })
|
||||
{
|
||||
var response = await CreateController(fresh, participant.Id).Dispatch(79,
|
||||
new QlyraCompatibilityRequest(JsonSerializer.SerializeToElement(new { })), CancellationToken.None);
|
||||
var payload = JsonSerializer.SerializeToElement(Assert.IsType<QlyraCompatibilityResponse>(Assert.IsType<OkObjectResult>(response).Value).Payload);
|
||||
Assert.Single(payload.GetProperty("history").EnumerateArray());
|
||||
}
|
||||
var staleContext = CreateWebSocketContext(call.Participants[callee.Id].Token, new ScriptedWebSocket());
|
||||
await store.HandleWebSocketAsync(staleContext);
|
||||
Assert.Equal(404, staleContext.Response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Expiration_CreatesMissedCall_AndDoesNotCloseTwoMemberConference()
|
||||
{
|
||||
var lifecycle = new RecordingLifecycle();
|
||||
var store = new QlyraCallCompatibilityStore(Options.Create(new WebRtcOptions()), lifecycle);
|
||||
var caller = Guid.NewGuid(); var callee = Guid.NewGuid();
|
||||
var direct = store.Create("missed", false, (caller, 1), (callee, 2));
|
||||
var conference = store.Create("conference", false, (caller, 1));
|
||||
store.AddParticipant(conference, callee, 2);
|
||||
await store.ExpireAsync(DateTimeOffset.UtcNow.AddMinutes(2));
|
||||
Assert.Null(store.Find(direct.Id));
|
||||
Assert.Equal("MISSED", Assert.Single(lifecycle.Calls).EndReason);
|
||||
Assert.True(await store.EndAsync(conference.Id, caller));
|
||||
Assert.Same(conference, store.Find(conference.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EndCall_RejectsNonParticipant_AndRetriesFailedPersistence()
|
||||
{
|
||||
var lifecycle = new RecordingLifecycle { FailNext = true };
|
||||
var store = new QlyraCallCompatibilityStore(Options.Create(new WebRtcOptions()), lifecycle);
|
||||
var caller = Guid.NewGuid();
|
||||
var call = store.Create("retry-call", false, (caller, 1), (Guid.NewGuid(), 2));
|
||||
Assert.False(await store.EndAsync(call.Id, Guid.NewGuid()));
|
||||
Assert.Same(call, store.Find(call.Id));
|
||||
await store.EndAsync(call.Id, caller);
|
||||
Assert.Empty(lifecycle.Calls);
|
||||
await store.ExpireAsync(DateTimeOffset.UtcNow);
|
||||
Assert.Same(call, Assert.Single(lifecycle.Calls));
|
||||
await store.ExpireAsync(DateTimeOffset.UtcNow);
|
||||
Assert.Single(lifecycle.Calls);
|
||||
}
|
||||
|
||||
private sealed class RecordingLifecycle : IQlyraCallLifecycle
|
||||
{
|
||||
public bool FailNext { get; set; }
|
||||
public List<QlyraCallConversation> Calls { get; } = [];
|
||||
public Task CompleteAsync(QlyraCallConversation call)
|
||||
{
|
||||
if (FailNext) { FailNext = false; throw new IOException("Temporary database error"); }
|
||||
Calls.Add(call); return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -911,6 +911,11 @@ public sealed partial class QlyraCompatibilityControllerTests
|
||||
private sealed class RecordingQlyraPushSender : IPushNotificationSender
|
||||
{
|
||||
public List<(QlyraIncomingCallPushDto Call, IReadOnlyList<PushDevice> Devices)> Calls { get; } = [];
|
||||
public List<(string Id, IReadOnlyList<PushDevice> Devices)> Finished { get; } = [];
|
||||
public Task SendQlyraCallFinishedAsync(string conversationId, IReadOnlyCollection<PushDevice> devices, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Finished.Add((conversationId, devices.ToList())); return Task.CompletedTask;
|
||||
}
|
||||
public Task SendMessageAsync(Chat chat, Message message, IReadOnlyCollection<PushDevice> devices, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task SendMessageReactionAsync(Chat chat, Message message, User reactor, string emoji, IReadOnlyCollection<PushDevice> devices, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task SendIncomingCallAsync(IncomingCallDto call, IReadOnlyCollection<PushDevice> devices, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
@@ -95,6 +95,7 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase
|
||||
76 or 78 or 84 or 166 => await CallFeatureAsync(opcode, request.Payload, cancellationToken),
|
||||
77 => await UpdateChatMembersAsync(opcode, request.Payload, cancellationToken),
|
||||
79 or 164 => await CallHistoryAsync(opcode, request.Payload, cancellationToken),
|
||||
1001 => await EndCallAsync(opcode, request.Payload, cancellationToken),
|
||||
80 or 82 or 87 => await RequestUploadAsync(opcode, request.Payload, cancellationToken),
|
||||
83 or 88 => await ResolveMediaAsync(opcode, request.Payload, cancellationToken),
|
||||
89 => await GetLinkInfoAsync(opcode, request.Payload, cancellationToken),
|
||||
@@ -743,18 +744,20 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase
|
||||
var calleeClientId = ReadIntList(payload, "calleeIds").FirstOrDefault();
|
||||
var calleeId = await clientIdMapper.FindEntityIdAsync("user", calleeClientId, cancellationToken);
|
||||
if (calleeId is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CALL_USER_NOT_FOUND", "Call recipient was not found."));
|
||||
if (calleeId == user.Id || qlyraCallStore.Find(conversationId) is not null)
|
||||
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "CALL_INVALID", "Invalid call recipient or conversation."));
|
||||
conversation = qlyraCallStore.Create(conversationId, ReadBool(payload, "isVideo") == true, (user.Id, callerClientId), (calleeId.Value, calleeClientId));
|
||||
participant = conversation.Participants[user.Id];
|
||||
var callee = conversation.Participants[calleeId.Value];
|
||||
var vcp = BuildCallVcp(callee.Token, conversation.IsVideo, callee.ClientId);
|
||||
await PublishPacketAsync([calleeId.Value], 137, new { vcp, conversationId, callerId = callerClientId, type = conversation.IsVideo ? "VIDEO" : "AUDIO", country = "RU", isContact = true }, cancellationToken);
|
||||
await PublishPacketAsync([calleeId.Value], 137, new { vcp, conversationId, callerId = callerClientId, userName = user.DisplayName, expiresAt = conversation.ExpiresAt.ToUnixTimeMilliseconds(), type = conversation.IsVideo ? "VIDEO" : "AUDIO", country = "RU", isContact = true }, cancellationToken);
|
||||
if (pushNotificationSender is not null)
|
||||
{
|
||||
var devices = await dbContext.PushDevices.AsNoTracking()
|
||||
.Where(device => device.UserId == calleeId.Value && device.NotificationsEnabled)
|
||||
.ToListAsync(cancellationToken);
|
||||
await pushNotificationSender.SendQlyraIncomingCallAsync(
|
||||
new QlyraIncomingCallPushDto(vcp, conversationId, callerClientId, user.DisplayName, callee.ClientId, conversation.IsVideo),
|
||||
new QlyraIncomingCallPushDto(vcp, conversationId, callerClientId, user.DisplayName, callee.ClientId, conversation.IsVideo, conversation.ExpiresAt.ToUnixTimeMilliseconds()),
|
||||
devices,
|
||||
cancellationToken);
|
||||
}
|
||||
@@ -782,6 +785,17 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase
|
||||
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { conversationId = conversation.Id, internalCallerParams = internalParams, internalParams, joinLink = conversation.JoinToken }));
|
||||
}
|
||||
|
||||
private async Task<IActionResult> EndCallAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await RequireCurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
||||
if (qlyraCallStore is null) return StatusCode(503);
|
||||
var id = ReadString(payload, "conversationId");
|
||||
if (string.IsNullOrWhiteSpace(id)) return BadRequest();
|
||||
if (!await qlyraCallStore.EndAsync(id, user.Id, ReadString(payload, "reason"))) return Forbid();
|
||||
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true }));
|
||||
}
|
||||
|
||||
private async Task<IActionResult> CallHistoryAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await RequireCurrentUserAsync(cancellationToken);
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Ikar.Server.Infrastructure.Calls;
|
||||
|
||||
public sealed class CallSessionCleanupService(
|
||||
CallSessionStore store,
|
||||
QlyraCallCompatibilityStore qlyraStore,
|
||||
IHubContext<MessengerHub> hubContext,
|
||||
IOptions<WebRtcOptions> options,
|
||||
ILogger<CallSessionCleanupService> logger) : BackgroundService
|
||||
@@ -16,6 +17,7 @@ public sealed class CallSessionCleanupService(
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
await qlyraStore.ExpireAsync(DateTimeOffset.UtcNow);
|
||||
var expired = store.DrainExpired(
|
||||
DateTimeOffset.UtcNow,
|
||||
TimeSpan.FromSeconds(Math.Clamp(options.Value.RingingTimeoutSeconds, 15, 300)),
|
||||
|
||||
@@ -6,22 +6,31 @@ using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Ikar.Server.Infrastructure.Calls;
|
||||
|
||||
public sealed class QlyraCallCompatibilityStore(IOptions<WebRtcOptions> webRtcOptions)
|
||||
public sealed class QlyraCallCompatibilityStore(
|
||||
IOptions<WebRtcOptions> webRtcOptions,
|
||||
IQlyraCallLifecycle? lifecycle = null,
|
||||
ILogger<QlyraCallCompatibilityStore>? logger = null)
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, QlyraCallConversation> _pendingHistory = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, QlyraCallConversation> _conversations = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, (QlyraCallConversation Conversation, QlyraCallParticipant Participant)> _tokens = new(StringComparer.Ordinal);
|
||||
public IReadOnlyList<Ikar.Shared.CallIceServerDto> IceServers => webRtcOptions.Value.ToDto();
|
||||
|
||||
public QlyraCallConversation Create(string conversationId, bool isVideo, params (Guid UserId, int ClientId)[] users)
|
||||
{
|
||||
var conversation = new QlyraCallConversation(conversationId, isVideo);
|
||||
var conversation = new QlyraCallConversation(conversationId, isVideo)
|
||||
{
|
||||
CallerId = users[0].UserId,
|
||||
IsDirect = users.Length == 2,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Clamp(webRtcOptions.Value.RingingTimeoutSeconds, 15, 45))
|
||||
};
|
||||
if (!_conversations.TryAdd(conversationId, conversation)) throw new InvalidOperationException("Call already exists.");
|
||||
foreach (var user in users)
|
||||
{
|
||||
var participant = new QlyraCallParticipant(user.UserId, user.ClientId);
|
||||
conversation.Participants[user.UserId] = participant;
|
||||
_tokens[participant.Token] = (conversation, participant);
|
||||
}
|
||||
_conversations[conversationId] = conversation;
|
||||
return conversation;
|
||||
}
|
||||
|
||||
@@ -74,6 +83,11 @@ public sealed class QlyraCallCompatibilityStore(IOptions<WebRtcOptions> webRtcOp
|
||||
}
|
||||
else if (command == "accept-call")
|
||||
{
|
||||
if (resolved.Participant.UserId != resolved.Conversation.CallerId)
|
||||
{
|
||||
lock (resolved.Conversation)
|
||||
resolved.Conversation.AcceptedAt ??= DateTimeOffset.UtcNow;
|
||||
}
|
||||
await SendAsync(resolved.Participant, new { type = "notification", notification = "accepted-call", participantId = resolved.Participant.ClientId }, context.RequestAborted);
|
||||
await BroadcastAsync(resolved.Conversation, resolved.Participant, new { type = "notification", notification = "accepted-call", participantId = resolved.Participant.ClientId }, context.RequestAborted);
|
||||
}
|
||||
@@ -84,8 +98,8 @@ public sealed class QlyraCallCompatibilityStore(IOptions<WebRtcOptions> webRtcOp
|
||||
}
|
||||
else if (command == "hangup")
|
||||
{
|
||||
await BroadcastAsync(resolved.Conversation, resolved.Participant, new { type = "notification", notification = "hungup", participantId = resolved.Participant.ClientId }, context.RequestAborted);
|
||||
await FinishDirectAsync(resolved.Conversation, resolved.Participant);
|
||||
await EndAsync(resolved.Conversation.Id, resolved.Participant.UserId,
|
||||
root.TryGetProperty("reason", out var reason) ? reason.GetString() : null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -98,11 +112,57 @@ public sealed class QlyraCallCompatibilityStore(IOptions<WebRtcOptions> webRtcOp
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FinishDirectAsync(QlyraCallConversation conversation, QlyraCallParticipant sender)
|
||||
public async Task<bool> EndAsync(string conversationId, Guid senderId, string? reason = null)
|
||||
{
|
||||
if (conversation.Participants.Count > 2 || !_conversations.TryRemove(conversation.Id, out _)) return;
|
||||
if (!_conversations.TryGetValue(conversationId, out var conversation)) return true;
|
||||
if (!conversation.Participants.TryGetValue(senderId, out var sender)) return false;
|
||||
if (!conversation.IsDirect)
|
||||
{
|
||||
await BroadcastAsync(conversation, sender, new { type = "notification", notification = "hungup", participantId = sender.ClientId }, CancellationToken.None);
|
||||
return true;
|
||||
}
|
||||
await FinishAsync(conversation, senderId == conversation.CallerId ? "CANCELED" : "REJECTED");
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task ExpireAsync(DateTimeOffset now)
|
||||
{
|
||||
foreach (var conversation in _conversations.Values.Where(c => c.IsDirect))
|
||||
{
|
||||
var expires = conversation.AcceptedAt?.AddHours(Math.Clamp(webRtcOptions.Value.MaximumCallDurationHours, 1, 24)) ?? conversation.ExpiresAt;
|
||||
if (expires <= now) await FinishAsync(conversation, "MISSED");
|
||||
}
|
||||
foreach (var conversation in _pendingHistory.Values) await CompleteAsync(conversation);
|
||||
}
|
||||
|
||||
private async Task FinishAsync(QlyraCallConversation conversation, string reason)
|
||||
{
|
||||
if (!_conversations.TryRemove(conversation.Id, out _)) return;
|
||||
conversation.EndedAt = DateTimeOffset.UtcNow;
|
||||
conversation.EndReason = conversation.AcceptedAt is null ? reason : "HUNGUP";
|
||||
foreach (var participant in conversation.Participants.Values) _tokens.TryRemove(participant.Token, out _);
|
||||
await BroadcastAsync(conversation, sender, new { type = "notification", notification = "closed-conversation" }, CancellationToken.None);
|
||||
_pendingHistory[conversation.Id] = conversation;
|
||||
// The independent lifecycle reaches phones that have not opened WS2 yet.
|
||||
await CompleteAsync(conversation);
|
||||
foreach (var participant in conversation.Participants.Values)
|
||||
{
|
||||
try { await SendAsync(participant, new { type = "notification", notification = "closed-conversation" }, CancellationToken.None); }
|
||||
catch (Exception exception) when (exception is WebSocketException or ObjectDisposedException or InvalidOperationException) { }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CompleteAsync(QlyraCallConversation conversation)
|
||||
{
|
||||
if (lifecycle is null) { _pendingHistory.TryRemove(conversation.Id, out _); return; }
|
||||
await conversation.CompletionLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (!_pendingHistory.ContainsKey(conversation.Id)) return;
|
||||
await lifecycle.CompleteAsync(conversation);
|
||||
_pendingHistory.TryRemove(conversation.Id, out _);
|
||||
}
|
||||
catch (Exception exception) { logger?.LogError(exception, "Unable to complete call {ConversationId}; will retry.", conversation.Id); }
|
||||
finally { conversation.CompletionLock.Release(); }
|
||||
}
|
||||
|
||||
private object ConnectionNotification(QlyraCallConversation conversation)
|
||||
@@ -189,6 +249,15 @@ public sealed class QlyraCallConversation(string id, bool isVideo)
|
||||
{
|
||||
public string Id { get; } = id;
|
||||
public bool IsVideo { get; } = isVideo;
|
||||
public bool IsDirect { get; init; }
|
||||
public Guid CallerId { get; init; }
|
||||
public Guid HistoryId { get; } = Guid.NewGuid();
|
||||
public DateTimeOffset CreatedAt { get; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
public DateTimeOffset? AcceptedAt { get; set; }
|
||||
public DateTimeOffset? EndedAt { get; set; }
|
||||
public string EndReason { get; set; } = "CANCELED";
|
||||
public SemaphoreSlim CompletionLock { get; } = new(1, 1);
|
||||
public string JoinToken { get; } = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(18)).ToLowerInvariant();
|
||||
public ConcurrentDictionary<Guid, QlyraCallParticipant> Participants { get; } = new();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Text.Json;
|
||||
using Ikar.Server.Data;
|
||||
using Ikar.Server.Data.Entities;
|
||||
using Ikar.Server.Infrastructure.Hubs;
|
||||
using Ikar.Server.Infrastructure.Push;
|
||||
using Ikar.Shared;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Ikar.Server.Infrastructure.Calls;
|
||||
|
||||
public interface IQlyraCallLifecycle
|
||||
{
|
||||
Task CompleteAsync(QlyraCallConversation conversation);
|
||||
}
|
||||
|
||||
public sealed class QlyraCallLifecycle(
|
||||
IServiceScopeFactory scopes,
|
||||
IHubContext<MessengerHub> hub,
|
||||
ILogger<QlyraCallLifecycle> logger) : IQlyraCallLifecycle
|
||||
{
|
||||
// Serialize direct-chat creation and retry with a stable message ID.
|
||||
private readonly SemaphoreSlim _historyLock = new(1, 1);
|
||||
|
||||
public async Task CompleteAsync(QlyraCallConversation call)
|
||||
{
|
||||
var recipients = call.Participants.Keys.Select(id => id.ToString()).ToArray();
|
||||
await PublishAsync(recipients, new { conversationId = call.Id, @event = "ended" });
|
||||
using var scope = scopes.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IkarDbContext>();
|
||||
var push = scope.ServiceProvider.GetRequiredService<IPushNotificationSender>();
|
||||
var devices = await db.PushDevices.AsNoTracking()
|
||||
.Where(d => call.Participants.Keys.Contains(d.UserId) && d.NotificationsEnabled).ToListAsync();
|
||||
// Push failures must not prevent the durable call record.
|
||||
var pushTask = FinishPushAsync(push, call.Id, devices);
|
||||
await _historyLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (!await db.Messages.AnyAsync(m => m.Id == call.HistoryId))
|
||||
{
|
||||
var caller = call.Participants[call.CallerId];
|
||||
var callee = call.Participants.Values.Single(p => p.UserId != call.CallerId);
|
||||
var chat = await db.Chats.Include(c => c.Members).FirstOrDefaultAsync(c =>
|
||||
c.Type == ChatType.Direct && c.Members.Count == 2 &&
|
||||
c.Members.Any(m => m.UserId == caller.UserId) && c.Members.Any(m => m.UserId == callee.UserId));
|
||||
if (chat is null)
|
||||
{
|
||||
chat = new Chat { Type = ChatType.Direct, CreatedById = caller.UserId,
|
||||
Title = await db.Users.Where(u => u.Id == callee.UserId).Select(u => u.DisplayName).SingleAsync() };
|
||||
chat.Members.Add(new ChatMember { UserId = caller.UserId, IsOwner = true, Role = ChatMemberRole.Owner,
|
||||
CanPostMessages = true, CanDeleteMessages = true, CanPinMessages = true });
|
||||
chat.Members.Add(new ChatMember { UserId = callee.UserId, Role = ChatMemberRole.Member,
|
||||
CanPostMessages = true, CanPinMessages = true });
|
||||
db.Chats.Add(chat);
|
||||
}
|
||||
var duration = call.AcceptedAt is { } accepted
|
||||
? Math.Max(1L, (long)((call.EndedAt ?? DateTimeOffset.UtcNow) - accepted).TotalMilliseconds) : 0L;
|
||||
chat.LastActivityAt = call.EndedAt ?? DateTimeOffset.UtcNow;
|
||||
db.Messages.Add(new Message { Id = call.HistoryId, Chat = chat, SenderId = caller.UserId,
|
||||
SentAt = call.CreatedAt, QlyraAttachesJson = JsonSerializer.Serialize(new[] { new {
|
||||
_type = "CALL", callType = call.IsVideo ? "VIDEO" : "AUDIO", conversationId = call.Id,
|
||||
duration, hangupType = call.EndReason, contactIds = new[] { callee.ClientId }
|
||||
} }) });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
finally { _historyLock.Release(); }
|
||||
// This event follows the commit, so an open Calls tab fetches the new record.
|
||||
await PublishAsync(recipients, new { conversationId = call.Id, @event = "history" });
|
||||
await pushTask;
|
||||
}
|
||||
|
||||
private async Task PublishAsync(string[] recipients, object payload)
|
||||
{
|
||||
try { await hub.Clients.Users(recipients).SendAsync("QlyraPacket", new { opcode = 137, payload }); }
|
||||
catch (Exception ex) { logger.LogWarning(ex, "Unable to send call lifecycle event."); }
|
||||
}
|
||||
|
||||
private async Task FinishPushAsync(IPushNotificationSender push, string id, IReadOnlyCollection<PushDevice> devices)
|
||||
{
|
||||
try { await push.SendQlyraCallFinishedAsync(id, devices); }
|
||||
catch (Exception ex) { logger.LogWarning(ex, "Unable to send call finished push for {ConversationId}.", id); }
|
||||
}
|
||||
}
|
||||
@@ -317,12 +317,31 @@ public sealed class FirebasePushNotificationSender(
|
||||
[PushDataKeys.AccountId] = call.CalleeClientId.ToString(),
|
||||
[PushDataKeys.IsVideo] = call.IsVideo ? "true" : "false"
|
||||
};
|
||||
if (call.ExpiresAt is { } expiresAt) data["expiresAt"] = expiresAt.ToString();
|
||||
foreach (var device in androidDevices)
|
||||
{
|
||||
await SendToAndroidAsync(projectId, accessToken, device, data, EphemeralCallTtl, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendQlyraCallFinishedAsync(string conversationId, IReadOnlyCollection<PushDevice> devices, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var androidDevices = GetAndroidDevices(devices);
|
||||
if (androidDevices.Count == 0) return;
|
||||
var credentials = LoadCredentials();
|
||||
if (credentials is null) return;
|
||||
var projectId = ResolveProjectId(credentials);
|
||||
if (string.IsNullOrWhiteSpace(projectId)) return;
|
||||
var accessToken = await GetAccessTokenAsync(credentials, cancellationToken);
|
||||
var data = new Dictionary<string, string>
|
||||
{
|
||||
[PushDataKeys.Type] = "CallFinished",
|
||||
[PushDataKeys.ConversationId] = conversationId
|
||||
};
|
||||
foreach (var device in androidDevices)
|
||||
await SendToAndroidAsync(projectId, accessToken, device, data, EphemeralCallTtl, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> SendLoginCodeAsync(
|
||||
string code,
|
||||
string challengeId,
|
||||
|
||||
@@ -39,6 +39,11 @@ public interface IPushNotificationSender
|
||||
IReadOnlyCollection<PushDevice> devices,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task SendQlyraCallFinishedAsync(
|
||||
string conversationId,
|
||||
IReadOnlyCollection<PushDevice> devices,
|
||||
CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
Task<bool> SendLoginCodeAsync(
|
||||
string code,
|
||||
string challengeId,
|
||||
|
||||
@@ -207,6 +207,7 @@ builder.Services.AddAuthorization(options =>
|
||||
builder.Services.AddSingleton<PresenceTracker>();
|
||||
builder.Services.AddSingleton<CallSessionStore>();
|
||||
builder.Services.AddSingleton<QlyraCallCompatibilityStore>();
|
||||
builder.Services.AddSingleton<IQlyraCallLifecycle, QlyraCallLifecycle>();
|
||||
builder.Services.AddSingleton<QlyraWebPushSessionStore>();
|
||||
builder.Services.AddHostedService<CallSessionCleanupService>();
|
||||
builder.Services.AddSingleton(new ServerRuntimeState(DateTimeOffset.UtcNow));
|
||||
|
||||
@@ -29,7 +29,8 @@ public sealed record QlyraIncomingCallPushDto(
|
||||
int CallerClientId,
|
||||
string CallerName,
|
||||
int CalleeClientId,
|
||||
bool IsVideo);
|
||||
bool IsVideo,
|
||||
long? ExpiresAt = null);
|
||||
|
||||
public static class PushDataKeys
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user