Skip to content
Open
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
3 changes: 3 additions & 0 deletions lib/sip_ua.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
export 'src/enums.dart';
export 'src/event_manager/call_events.dart' hide CallEvent;
export 'src/event_manager/internal_events.dart';
export 'src/publish.dart';
export 'src/sip_message.dart';
export 'src/sip_ua_helper.dart';
export 'src/transport_type.dart';
Expand Down
3 changes: 2 additions & 1 deletion lib/src/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ enum SipMethod {
MESSAGE,
NOTIFY,
OPTIONS,
PUBLISH,
REGISTER,
REFER,
UPDATE,
Expand Down Expand Up @@ -209,7 +210,7 @@ Map<int, String> REASON_PHRASE = <int, String>{
};

const String ALLOWED_METHODS =
'INVITE,ACK,CANCEL,BYE,UPDATE,MESSAGE,OPTIONS,REFER,INFO,NOTIFY';
'INVITE,ACK,CANCEL,BYE,UPDATE,MESSAGE,OPTIONS,REFER,INFO,NOTIFY,PUBLISH';
const String ACCEPTED_BODY_TYPES = 'application/sdp, application/dtmf-relay';
const int MAX_FORWARDS = 69;
const int SESSION_EXPIRES = 90;
Expand Down
195 changes: 195 additions & 0 deletions lib/src/publish.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import 'constants.dart' as DartSIP_C;
import 'constants.dart';
import 'enums.dart';
import 'event_manager/event_manager.dart';
import 'event_manager/internal_events.dart';
import 'exceptions.dart' as Exceptions;
import 'logger.dart';
import 'request_sender.dart';
import 'sip_message.dart';
import 'ua.dart';
import 'uri.dart';
import 'utils.dart' as Utils;

/// Lightweight SIP PUBLISH (RFC 3903) sender.
///
/// Publishes presence/status information to the SIP server.
/// Tracks the SIP-ETag from the server for conditional publication
/// on subsequent PUBLISH requests.
class Publish extends EventManager {
Publish(this._ua);

final UA _ua;
dynamic _request;
bool _closed = false;
String? _etag;

/// The SIP-ETag returned by the server on the last successful PUBLISH.
/// Include this as SIP-If-Match on the next publish for this event package.
String? get etag => _etag;

/// Send a PUBLISH request.
///
/// [target] is the SIP URI to publish to (typically your own AOR).
/// [body] is the PIDF XML presence document (RFC 3863).
/// [expires] controls how long the server retains this publication (default 3600).
/// [contentType] must be 'application/pidf+xml' for RFC 3903 compliance.
/// [sipIfMatch] is the SIP-ETag from a previous publication, for conditional updates.
/// [extraHeaders] are any additional SIP headers.
void send(
String target,
String body, {
int expires = 3600,
String contentType = 'application/pidf+xml',
String? sipIfMatch,
Map<String, dynamic>? params,
Map<String, dynamic>? options,
}) {
final String originalTarget = target;
options = options ?? <String, dynamic>{};

if (target.isEmpty || body.isEmpty) {
throw Exceptions.TypeError('Not enough arguments: target and body required');
}

// Check target validity.
final URI? normalized = _ua.normalizeTarget(target);
if (normalized == null) {
throw Exceptions.TypeError('Invalid target: $originalTarget');
}

// Build extra headers.
final List<dynamic> extraHeaders =
Utils.cloneArray(options['extraHeaders'] ?? <dynamic>[]);
final EventManager eventHandlers =
options['eventHandlers'] ?? EventManager();

// Set event handlers from caller.
addAllEventHandlers(eventHandlers);

// Content-Type for PIDF.
extraHeaders.add('Content-Type: $contentType');

// Event header (RFC 3903 Section 6.1).
extraHeaders.add('Event: presence');

// Expires header.
extraHeaders.add('Expires: $expires');

// Conditional publication (RFC 3903 Section 6.3).
if (sipIfMatch != null && sipIfMatch.isNotEmpty) {
extraHeaders.add('SIP-If-Match: $sipIfMatch');
}

_request = OutgoingRequest(
SipMethod.PUBLISH, normalized, _ua, params, extraHeaders);
_request.body = body;

final EventManager handlers = EventManager();
handlers.on(EventOnRequestTimeout(), (EventOnRequestTimeout value) {
_onRequestTimeout();
});
handlers.on(EventOnTransportError(), (EventOnTransportError value) {
_onTransportError();
});
handlers.on(EventOnReceiveResponse(), (EventOnReceiveResponse event) {
_receiveResponse(event.response);
});

final RequestSender requestSender = RequestSender(_ua, _request, handlers);
requestSender.send();
}

void _receiveResponse(IncomingResponse? response) {
if (_closed) return;

if (response == null) return;

final int? statusCode = response.status_code;

// 1xx — provisional, ignore.
if (statusCode != null && statusCode >= 100 && statusCode < 200) {
return;
}

// 2xx — success.
if (statusCode != null && statusCode >= 200 && statusCode < 300) {
// Extract SIP-ETag for conditional publication on next PUBLISH.
final dynamic etagHeader = response.getHeader('SIP-ETag');
if (etagHeader != null && etagHeader is String && etagHeader.isNotEmpty) {
_etag = etagHeader;
}

// Extract Expires to know how long the server will retain this.
final dynamic expiresHeader = response.getHeader('Expires');
int? serverExpires;
if (expiresHeader != null && expiresHeader is String) {
serverExpires = int.tryParse(expiresHeader);
}

logger.d('PUBLISH succeeded'
'${_etag != null ? ', etag=$_etag' : ''}'
'${serverExpires != null ? ', expires=$serverExpires' : ''}');

_succeeded(response);
return;
}

// 412 Conditional Request Failed — need to refresh (no ETag, or expired).
if (statusCode == 412) {
logger.d('PUBLISH 412 — conditional request failed, clearing etag');
_etag = null;
_failed(412, DartSIP_C.CausesType.SIP_FAILURE_CODE,
'Conditional Request Failed');
return;
}

// 423 Interval Too Brief — server wants a longer expiry.
if (statusCode == 423) {
final dynamic minExpires = response.getHeader('Min-Expires');
logger.d('PUBLISH 423 — interval too brief, min-expires=$minExpires');
_failed(423, DartSIP_C.CausesType.SIP_FAILURE_CODE,
'Interval Too Brief (Min-Expires: $minExpires)');
return;
}

// Any other failure.
final String cause = Utils.sipErrorCause(response.status_code);
_failed(
statusCode ?? 500,
cause,
response.reason_phrase ?? 'PUBLISH failed');
}

void _onRequestTimeout() {
if (_closed) return;
_failed(408, DartSIP_C.CausesType.REQUEST_TIMEOUT, 'Request Timeout');
}

void _onTransportError() {
if (_closed) return;
_failed(500, DartSIP_C.CausesType.CONNECTION_ERROR, 'Transport Error');
}

void _succeeded(IncomingResponse response) {
close();
logger.d('emit "succeeded"');
emit(EventSucceeded(originator: Originator.local, response: response));
}

void _failed(int statusCode, String cause, String reasonPhrase) {
logger.d('PUBLISH failed: $reasonPhrase');
close();
logger.d('emit "failed"');
emit(EventCallFailed(
originator: Originator.local,
cause: ErrorCause(
cause: cause,
status_code: statusCode,
reason_phrase: reasonPhrase)));
}

void close() {
_closed = true;
}
}
30 changes: 30 additions & 0 deletions lib/src/sip_ua_helper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import 'logger.dart';
import 'map_helper.dart';
import 'message.dart';
import 'options.dart';
import 'publish.dart';
import 'rtc_session.dart';
import 'rtc_session/refer_subscriber.dart';
import 'sip_message.dart';
Expand Down Expand Up @@ -442,6 +443,35 @@ class SIPUAHelper extends EventManager {
s.subscribe();
}

/// Send a SIP PUBLISH request (RFC 3903) to publish presence/status.
///
/// [target] is the SIP URI to publish to (typically your own AOR).
/// [body] is the PIDF XML presence document (RFC 3863).
/// [expires] controls how long the server retains this publication.
/// [sipIfMatch] is the SIP-ETag for conditional publication.
/// Returns the [Publish] instance for event handling.
Publish publish(
String target,
String body, {
int expires = 3600,
String contentType = 'application/pidf+xml',
String? sipIfMatch,
Map<String, dynamic>? params,
Map<String, dynamic>? options,
}) {
assert(_ua != null,
'publish called but not started, you must call start first.');
return _ua!.publish(
target,
body,
expires: expires,
contentType: contentType,
sipIfMatch: sipIfMatch,
params: params,
options: options,
);
}

void terminateSessions(Map<String, dynamic> options) {
_ua!.terminateSessions(options);
}
Expand Down
2 changes: 1 addition & 1 deletion lib/src/transports/websocket_web_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class SIPUAWebSocketImpl {
required WebSocketSettings webSocketSettings}) async {
logger.i('connect $_url, ${webSocketSettings.extraHeaders}, $protocols');
try {
_socket = WebSocket(_url, 'sip');
_socket = WebSocket(_url, 'sip'.toJS);
_socket!.onOpen.listen((Event e) {
onOpen?.call();
});
Expand Down
34 changes: 34 additions & 0 deletions lib/src/ua.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import 'rtc_session.dart';
import 'sanity_check.dart';
import 'sip_message.dart';
import 'socket_transport.dart';
import 'publish.dart';
import 'subscriber.dart';
import 'timers.dart';
import 'transactions/invite_client.dart';
Expand Down Expand Up @@ -209,6 +210,39 @@ class UA extends EventManager {
allowEvents, requestParams, extraHeaders);
}

/**
* Create a PUBLISH request (RFC 3903).
*
* Publishes presence/status information to the SIP server using PIDF
* (Presence Information Data Format, RFC 3863).
*
* Returns a [Publish] instance that emits [EventSucceeded] or
* [EventCallFailed] events.
*/
Publish publish(
String target,
String body, {
int expires = 3600,
String contentType = 'application/pidf+xml',
String? sipIfMatch,
Map<String, dynamic>? params,
Map<String, dynamic>? options,
}) {
logger.d('publish()');

final Publish publisher = Publish(this);
publisher.send(
target,
body,
expires: expires,
contentType: contentType,
sipIfMatch: sipIfMatch,
params: params,
options: options,
);
return publisher;
}

/**
* Get the Registrator instance.
*/
Expand Down