Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions Network/SipClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
private volatile bool _isShutdown = false;
private volatile bool _isRegistered = false;
private int _reconnectionAttempts = 0;
private int _outboundRingingRaised;
private bool _reconnectPending;
private bool _registrationStarted;
private RegistrationState _registrationState = RegistrationState.NotStarted;
Expand All @@ -82,8 +83,8 @@
public event Action<SipClient>? CallAnswer;
public event Action<SipClient>? CallEnded;
public event Action<SipClient, string>? StatusMessage;
public event Action<SipClient>? RemotePutOnHold;

Check warning on line 86 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

The event 'SipClient.RemotePutOnHold' is never used

Check warning on line 86 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

The event 'SipClient.RemotePutOnHold' is never used
public event Action<SipClient>? RemoteTookOffHold;

Check warning on line 87 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

The event 'SipClient.RemoteTookOffHold' is never used

Check warning on line 87 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

The event 'SipClient.RemoteTookOffHold' is never used
public event Action<SipClient, Exception>? ErrorOccurred;
public event Action<SipClient>? RegistrationStatusChanged;
public event Action<SipClient, TimeSpan>? CallDurationUpdated;
Expand All @@ -104,6 +105,15 @@
/// <summary>RFC2833 / telephone-event digit (0-9, *, #, A-D). Raised once per complete tone.</summary>
public event Action<SipClient, char>? DtmfDigitReceived;

/// <summary>
/// Raised once per outbound call (<see cref="CallAsync"/>) when the first ringing response
/// arrives: 180 Ringing or 183 Session Progress. The argument is that status code. Not raised
/// if the call gets no ringing response. <see cref="CallAsync"/>'s ring timeout starts when the
/// INVITE is sent, so a host that wants to time the ring from when the phone rings can pass a
/// larger timeout, start its own timer here, and cancel through the CancellationToken.
/// </summary>
public event Action<SipClient, int>? CallRinging;

// Properties
/// <summary>
/// True after a successful REGISTER. Cleared by any registration failure, temporary or hard,
Expand Down Expand Up @@ -145,7 +155,7 @@
public string Username => _sipUsername;
public string Server => _sipServer;

public SipClient(

Check warning on line 158 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_registrationAgent' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 158 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_userAgent' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 158 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_userAgent' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 158 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_registrationAgent' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 158 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_userAgent' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.
SIPTransport sipTransport,
SipConfig sipSettings,
int registrationExpirySeconds = DefaultRegistrationExpirySeconds,
Expand Down Expand Up @@ -327,7 +337,7 @@
{
var userAgent = new SIPUserAgent(sipTransport, null);
userAgent.ClientCallTrying += CallTrying;
userAgent.ClientCallRinging += CallRinging;
userAgent.ClientCallRinging += OnClientCallRinging;
userAgent.ClientCallAnswered += CallAnswered;
userAgent.ClientCallFailed += CallFailed;
userAgent.OnCallHungup += CallFinished;
Expand Down Expand Up @@ -548,6 +558,7 @@
ArgumentNullException.ThrowIfNull(audioSource);

LastOutboundFailure = null;
Interlocked.Exchange(ref _outboundRingingRaised, 0);
string uri;
try
{
Expand Down Expand Up @@ -1008,10 +1019,15 @@
IncrementMetric("call_trying");
}

private void CallRinging(ISIPClientUserAgent uac, SIPResponse sipResponse)
// SIPSorcery routes exactly 180 Ringing and 183 Session Progress here (other provisional
// responses go to ClientCallTrying).
private void OnClientCallRinging(ISIPClientUserAgent uac, SIPResponse sipResponse)
{
StatusMessage?.Invoke(this, "Call ringing: " + sipResponse.StatusCode + " " + sipResponse.ReasonPhrase + ".");
IncrementMetric("call_ringing");

if (Interlocked.Exchange(ref _outboundRingingRaised, 1) == 0)
CallRinging?.Invoke(this, sipResponse.StatusCode);
}

private void CallFailed(ISIPClientUserAgent uac, string errorMessage, SIPResponse? failureResponse)
Expand Down
116 changes: 116 additions & 0 deletions tests/SipBotLib.Tests/CallRingingEventTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using System.Collections.Concurrent;
using System.Net;
using SIPSorcery.SIP;
using SipBot;
using Xunit;

namespace SipBotLib.Tests;

/// <summary>
/// SipClient.CallRinging fires once per outbound call, on the first 180 or 183, with that status;
/// never for other provisional responses, never twice, and not at all without a ringing response.
/// A scripted callee on loopback answers each INVITE with the given provisional responses and then
/// 486, so no media is needed.
/// </summary>
public class CallRingingEventTests : IDisposable
{
private sealed class SilentEndPoint : BaseAudioEndPoint
{
public override Task InitializeAsync() => Task.CompletedTask;
public override Task ShutdownAsync() => Task.CompletedTask;
protected override Task ProcessAudioAsync(byte[] pcm, int sampleRateHz) => Task.CompletedTask;
}

private readonly SIPTransport _callerTransport = new();
private readonly SIPTransport _calleeTransport = new();
private readonly SipClient _caller;
private readonly int _calleePort;
private readonly ConcurrentQueue<int> _ringing = new();
private readonly ConcurrentQueue<string> _status = new();
private volatile SIPResponseStatusCodesEnum[] _script = Array.Empty<SIPResponseStatusCodesEnum>();

public CallRingingEventTests()
{
_callerTransport.AddSIPChannel(new SIPUDPChannel(IPAddress.Loopback, 0));
var calleeChannel = new SIPUDPChannel(IPAddress.Loopback, 0);
_calleeTransport.AddSIPChannel(calleeChannel);
_calleePort = calleeChannel.Port;
_calleeTransport.SIPTransportRequestReceived += OnCalleeRequest;

var config = new SipConfig { Server = "127.0.0.1", Username = "101", Password = "x" };
_caller = new SipClient(_callerTransport, config, enableAutoReconnection: false, enableHealthMonitoring: false);
_caller.CallRinging += (_, status) => _ringing.Enqueue(status);
_caller.StatusMessage += (_, msg) => _status.Enqueue(msg);
}

public void Dispose()
{
_caller.Dispose();
_calleeTransport.Shutdown();
}

private Task OnCalleeRequest(SIPEndPoint localEP, SIPEndPoint remoteEP, SIPRequest req)
{
if (req.Method != SIPMethodsEnum.INVITE)
return Task.CompletedTask;

var script = _script;
_ = Task.Run(async () =>
{
var tx = new UASInviteTransaction(_calleeTransport, req, null);
foreach (var status in script)
{
await tx.SendProvisionalResponse(SIPResponse.GetResponse(req, status, null));
await Task.Delay(100);
}
tx.SendFinalResponse(SIPResponse.GetResponse(req, SIPResponseStatusCodesEnum.BusyHere, null));
});
return Task.CompletedTask;
}

private async Task PlaceCallAsync(params SIPResponseStatusCodesEnum[] provisional)
{
_script = provisional;
var audio = new SilentEndPoint();
bool answered = await _caller.CallAsync($"sip:b@127.0.0.1:{_calleePort}", audio, audio, ringTimeoutSeconds: 10);
Assert.False(answered);
Assert.StartsWith("486", _caller.LastOutboundFailure);
await Task.Delay(200); // let any late event arrive before asserting on it
}

public static TheoryData<SIPResponseStatusCodesEnum[], int[]> Scripts() => new()
{
{ new[] { SIPResponseStatusCodesEnum.Ringing }, new[] { 180 } },
{ new[] { SIPResponseStatusCodesEnum.SessionProgress }, new[] { 183 } },
{ new[] { SIPResponseStatusCodesEnum.Ringing, SIPResponseStatusCodesEnum.Ringing }, new[] { 180 } },
{ new[] { SIPResponseStatusCodesEnum.SessionProgress, SIPResponseStatusCodesEnum.Ringing }, new[] { 183 } },
{ new[] { SIPResponseStatusCodesEnum.CallIsBeingForwarded }, Array.Empty<int>() },
{ Array.Empty<SIPResponseStatusCodesEnum>(), Array.Empty<int>() },
};

[Theory]
[MemberData(nameof(Scripts))]
public async Task Fires_once_on_the_first_180_or_183(SIPResponseStatusCodesEnum[] provisional, int[] expected)
{
await PlaceCallAsync(provisional);

Assert.Equal(expected, _ringing.ToArray());
}

[Fact]
public async Task Fires_again_for_the_next_call()
{
await PlaceCallAsync(SIPResponseStatusCodesEnum.Ringing);
await PlaceCallAsync(SIPResponseStatusCodesEnum.SessionProgress);

Assert.Equal(new[] { 180, 183 }, _ringing.ToArray());
}

[Fact]
public async Task StatusMessage_still_reports_ringing()
{
await PlaceCallAsync(SIPResponseStatusCodesEnum.Ringing);

Assert.Contains(_status, m => m.StartsWith("Call ringing: 180"));
}
}
Loading