diff --git a/Network/SipClient.cs b/Network/SipClient.cs
index 8a673a8..302c18e 100644
--- a/Network/SipClient.cs
+++ b/Network/SipClient.cs
@@ -65,6 +65,7 @@ public class SipClient : IDisposable
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;
@@ -104,6 +105,15 @@ public class SipClient : IDisposable
/// RFC2833 / telephone-event digit (0-9, *, #, A-D). Raised once per complete tone.
public event Action? DtmfDigitReceived;
+ ///
+ /// Raised once per outbound call () 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. '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.
+ ///
+ public event Action? CallRinging;
+
// Properties
///
/// True after a successful REGISTER. Cleared by any registration failure, temporary or hard,
@@ -327,7 +337,7 @@ private SIPUserAgent CreateNewUserAgent(SIPTransport sipTransport)
{
var userAgent = new SIPUserAgent(sipTransport, null);
userAgent.ClientCallTrying += CallTrying;
- userAgent.ClientCallRinging += CallRinging;
+ userAgent.ClientCallRinging += OnClientCallRinging;
userAgent.ClientCallAnswered += CallAnswered;
userAgent.ClientCallFailed += CallFailed;
userAgent.OnCallHungup += CallFinished;
@@ -548,6 +558,7 @@ public async Task CallAsync(
ArgumentNullException.ThrowIfNull(audioSource);
LastOutboundFailure = null;
+ Interlocked.Exchange(ref _outboundRingingRaised, 0);
string uri;
try
{
@@ -1008,10 +1019,15 @@ private void CallTrying(ISIPClientUserAgent uac, SIPResponse sipResponse)
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)
diff --git a/tests/SipBotLib.Tests/CallRingingEventTests.cs b/tests/SipBotLib.Tests/CallRingingEventTests.cs
new file mode 100644
index 0000000..46353f4
--- /dev/null
+++ b/tests/SipBotLib.Tests/CallRingingEventTests.cs
@@ -0,0 +1,116 @@
+using System.Collections.Concurrent;
+using System.Net;
+using SIPSorcery.SIP;
+using SipBot;
+using Xunit;
+
+namespace SipBotLib.Tests;
+
+///
+/// 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.
+///
+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 _ringing = new();
+ private readonly ConcurrentQueue _status = new();
+ private volatile SIPResponseStatusCodesEnum[] _script = Array.Empty();
+
+ 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 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() },
+ { Array.Empty(), Array.Empty() },
+ };
+
+ [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"));
+ }
+}