feat(calls): add consent-based signaling to existing ChatHub
Wire shared PostIt call signaling, account-derived relay policy and temporary TURN credentials. Keep calls disabled pending native Android/Linux media integration. Document experimental Android crash and laboratory removal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
56ca115f50
commit
5e48831d6d
20 changed files with 1368 additions and 16 deletions
54
src/PostIt/PostIt.Tests/CallSignalingClientTests.cs
Normal file
54
src/PostIt/PostIt.Tests/CallSignalingClientTests.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using PostIt.Services;
|
||||
using Yavsc.Abstract.Chat.Calls;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public sealed class CallSignalingClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task All_operations_fail_explicitly_without_a_connected_chat_transport()
|
||||
{
|
||||
await using var connection = new HubConnectionBuilder().WithUrl("http://127.0.0.1:1/chathub").Build();
|
||||
using var client = new CallSignalingClient(connection);
|
||||
var id = Guid.NewGuid();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => client.InviteAsync("General", "bob", ct));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => client.AcceptAsync(id, ct));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => client.EndAsync(id, ct));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => client.SendAsync(id, new("offer", "sdp"), ct));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disposing_call_subscriptions_does_not_dispose_the_chat_connection()
|
||||
{
|
||||
await using var connection = new HubConnectionBuilder().WithUrl("http://127.0.0.1:1/chathub").Build();
|
||||
var client = new CallSignalingClient(connection);
|
||||
client.Dispose();
|
||||
client.Dispose();
|
||||
await Assert.ThrowsAsync<ObjectDisposedException>(() => client.AcceptAsync(Guid.NewGuid(), TestContext.Current.CancellationToken));
|
||||
Assert.Equal(HubConnectionState.Disconnected, connection.State);
|
||||
using var replacement = new CallSignalingClient(connection);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => replacement.InviteAsync("General", "bob", TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shared_contracts_round_trip_with_signalr_web_json_naming()
|
||||
{
|
||||
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
var call = new CallSession(Guid.NewGuid(), "General", "alice", "bob", true,
|
||||
DateTimeOffset.Parse("2026-10-26T01:00:00Z"));
|
||||
var ready = new CallAccepted(call, [new(["turns:turn.example.test:5349"], "expires:nonce", "credential")]);
|
||||
var json = JsonSerializer.Serialize(ready, options);
|
||||
var read = JsonSerializer.Deserialize<CallAccepted>(json, options);
|
||||
Assert.NotNull(read);
|
||||
Assert.Equal(call, read.Session);
|
||||
Assert.Equal(ready.IceServers[0].Urls, read.IceServers[0].Urls);
|
||||
Assert.Equal(ready.IceServers[0].Username, read.IceServers[0].Username);
|
||||
Assert.Equal(ready.IceServers[0].Credential, read.IceServers[0].Credential);
|
||||
var signal = new CallSignal("candidate", Candidate: "candidate:1 1 udp 100 192.0.2.1 5000 typ relay",
|
||||
SdpMid: "video", SdpMLineIndex: 1);
|
||||
Assert.Equal(signal, JsonSerializer.Deserialize<CallSignal>(JsonSerializer.Serialize(signal, options), options));
|
||||
}
|
||||
}
|
||||
81
src/PostIt/PostIt/Services/CallSignalingClient.cs
Normal file
81
src/PostIt/PostIt/Services/CallSignalingClient.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Yavsc.Abstract.Chat.Calls;
|
||||
|
||||
namespace PostIt.Services;
|
||||
|
||||
public sealed class CallSignalingClient : IDisposable
|
||||
{
|
||||
private readonly HubConnection _connection;
|
||||
private readonly IDisposable[] _subscriptions;
|
||||
private bool _disposed;
|
||||
|
||||
public event Action<CallSession>? Invitation;
|
||||
public event Action<CallAccepted>? Accepted;
|
||||
public event Action<CallEnded>? Ended;
|
||||
public event Action<Guid, CallSignal>? Signal;
|
||||
public event Action? Interrupted;
|
||||
|
||||
public CallSignalingClient(HubConnection connection)
|
||||
{
|
||||
_connection = connection;
|
||||
_subscriptions =
|
||||
[
|
||||
connection.On<CallSession>(CallHubMethods.Invitation, call => Invitation?.Invoke(call)),
|
||||
connection.On<CallAccepted>(CallHubMethods.Accepted, call => Accepted?.Invoke(call)),
|
||||
connection.On<CallEnded>(CallHubMethods.Ended, call => Ended?.Invoke(call)),
|
||||
connection.On<Guid, CallSignal>(CallHubMethods.SignalReceived,
|
||||
(id, signal) => Signal?.Invoke(id, signal))
|
||||
];
|
||||
connection.Reconnecting += OnInterruptedAsync;
|
||||
connection.Closed += OnInterruptedAsync;
|
||||
}
|
||||
|
||||
public Task<CallSession> InviteAsync(string room, string user, CancellationToken ct = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
return _connection.InvokeAsync<CallSession>(CallHubMethods.Invite, room, user, ct);
|
||||
}
|
||||
|
||||
public Task AcceptAsync(Guid callId, CancellationToken ct = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
return _connection.InvokeAsync(CallHubMethods.Accept, callId, ct);
|
||||
}
|
||||
|
||||
public Task EndAsync(Guid callId, CancellationToken ct = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
return _connection.InvokeAsync(CallHubMethods.End, callId, ct);
|
||||
}
|
||||
|
||||
public Task SendAsync(Guid callId, CallSignal signal, CancellationToken ct = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
return _connection.InvokeAsync(CallHubMethods.Signal, callId, signal, ct);
|
||||
}
|
||||
|
||||
private Task OnInterruptedAsync(Exception? error)
|
||||
{
|
||||
Interrupted?.Invoke();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void EnsureConnected()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_connection.State != HubConnectionState.Connected)
|
||||
throw new InvalidOperationException("La connexion de chat est interrompue ; l'appel ne peut pas continuer.");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_connection.Reconnecting -= OnInterruptedAsync;
|
||||
_connection.Closed -= OnInterruptedAsync;
|
||||
foreach (var subscription in _subscriptions) subscription.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -37,6 +37,8 @@ namespace PostIt.ViewModels.Chat
|
|||
|
||||
private HubConnection? _connection;
|
||||
|
||||
public CallSignalingClient? Calls { get; private set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string CurrentRoom { get; set; } = "General";
|
||||
|
||||
|
|
@ -130,6 +132,8 @@ namespace PostIt.ViewModels.Chat
|
|||
|
||||
private async Task DisconnectAsync()
|
||||
{
|
||||
Calls?.Dispose();
|
||||
Calls = null;
|
||||
if (_connection != null)
|
||||
{
|
||||
await _connection.StopAsync();
|
||||
|
|
@ -150,6 +154,13 @@ namespace PostIt.ViewModels.Chat
|
|||
{
|
||||
try
|
||||
{
|
||||
if (_connection != null)
|
||||
{
|
||||
Calls?.Dispose();
|
||||
Calls = null;
|
||||
await _connection.DisposeAsync();
|
||||
_connection = null;
|
||||
}
|
||||
Status = new StatusNotice("Connexion en cours...", StatusSeverity.Info,
|
||||
new RelayCheckCommand("Vérifier la connexion", async () => await CheckConnectionAsync()));
|
||||
|
||||
|
|
@ -188,6 +199,12 @@ namespace PostIt.ViewModels.Chat
|
|||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
Calls = new CallSignalingClient(_connection);
|
||||
Calls.Invitation += call => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
Log($"Invitation audio-vidéo de {call.Caller} dans {call.RoomName} ; moteur natif non disponible."));
|
||||
Calls.Ended += call => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
Log($"Appel terminé : {call.Reason}."));
|
||||
|
||||
// 1. Ecoute des notifications globales (notifyUser)
|
||||
_connection.On<string, string, string>("notifyUser", (type, user, msg) =>
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue