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:
Paul Schneider 2026-10-26 01:00:19 +00:00
commit 5e48831d6d
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
20 changed files with 1368 additions and 16 deletions

View 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));
}
}

View 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();
}
}

View file

@ -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) =>
{