2019-01-13 20:03:10 +00:00
using System ;
2016-10-29 22:22:20 +00:00
using System.Collections.Generic ;
using System.Linq ;
using System.Net ;
using System.Net.Http ;
using System.Text ;
2017-02-05 20:44:08 +00:00
using System.Threading ;
2016-10-29 22:22:20 +00:00
using System.Threading.Tasks ;
2016-12-04 21:55:02 +00:00
using MediaBrowser.Common.Net ;
2018-12-13 13:18:25 +00:00
using Microsoft.Extensions.Logging ;
2016-11-04 08:31:05 +00:00
using MediaBrowser.Model.Net ;
2016-10-29 22:22:20 +00:00
namespace Rssdp.Infrastructure
{
/// <summary>
/// Provides the platform independent logic for publishing device existence and responding to search requests.
/// </summary>
public sealed class SsdpCommunicationsServer : DisposableManagedObjectBase , ISsdpCommunicationsServer
{
#region Fields
2019-01-07 23:24:34 +00:00
/ * We could technically use one socket listening on port 1900 for everything .
* This should get both multicast ( notifications ) and unicast ( search response ) messages , however
* this often doesn ' t work under Windows because the MS SSDP service is running . If that service
* is running then it will steal the unicast messages and we will never see search responses .
* Since stopping the service would be a bad idea ( might not be allowed security wise and might
* break other apps running on the system ) the only other work around is to use two sockets .
*
* We use one socket to listen for / receive notifications and search requests ( _BroadcastListenSocket ) .
* We use a second socket , bound to a different local port , to send search requests and listen for
* responses ( _SendSocket ) . The responses are sent to the local port this socket is bound to ,
* which isn ' t port 1900 so the MS service doesn ' t steal them . While the caller can specify a local
* port to use , we will default to 0 which allows the underlying system to auto - assign a free port .
* /
2016-10-29 22:22:20 +00:00
private object _BroadcastListenSocketSynchroniser = new object ( ) ;
2017-03-02 20:50:09 +00:00
private ISocket _BroadcastListenSocket ;
2016-10-29 22:22:20 +00:00
private object _SendSocketSynchroniser = new object ( ) ;
2017-03-02 20:50:09 +00:00
private List < ISocket > _sendSockets ;
2016-10-29 22:22:20 +00:00
private HttpRequestParser _RequestParser ;
private HttpResponseParser _ResponseParser ;
2016-12-04 21:55:02 +00:00
private readonly ILogger _logger ;
2016-10-29 22:22:20 +00:00
private ISocketFactory _SocketFactory ;
2016-12-04 21:55:02 +00:00
private readonly INetworkManager _networkManager ;
2016-10-29 22:22:20 +00:00
private int _LocalPort ;
private int _MulticastTtl ;
private bool _IsShared ;
2016-12-05 18:46:38 +00:00
private readonly bool _enableMultiSocketBinding ;
2016-10-29 22:22:20 +00:00
#endregion
#region Events
/// <summary>
/// Raised when a HTTPU request message is received by a socket (unicast or multicast).
/// </summary>
public event EventHandler < RequestReceivedEventArgs > RequestReceived ;
/// <summary>
/// Raised when an HTTPU response message is received by a socket (unicast or multicast).
/// </summary>
public event EventHandler < ResponseReceivedEventArgs > ResponseReceived ;
#endregion
#region Constructors
/// <summary>
/// Minimum constructor.
/// </summary>
/// <exception cref="System.ArgumentNullException">The <paramref name="socketFactory"/> argument is null.</exception>
2016-12-05 18:46:38 +00:00
public SsdpCommunicationsServer ( ISocketFactory socketFactory , INetworkManager networkManager , ILogger logger , bool enableMultiSocketBinding )
: this ( socketFactory , 0 , SsdpConstants . SsdpDefaultMulticastTimeToLive , networkManager , logger , enableMultiSocketBinding )
2016-10-29 22:22:20 +00:00
{
}
/// <summary>
/// Full constructor.
/// </summary>
/// <exception cref="System.ArgumentNullException">The <paramref name="socketFactory"/> argument is null.</exception>
/// <exception cref="System.ArgumentOutOfRangeException">The <paramref name="multicastTimeToLive"/> argument is less than or equal to zero.</exception>
2016-12-05 18:46:38 +00:00
public SsdpCommunicationsServer ( ISocketFactory socketFactory , int localPort , int multicastTimeToLive , INetworkManager networkManager , ILogger logger , bool enableMultiSocketBinding )
2016-10-29 22:22:20 +00:00
{
2019-01-06 20:50:43 +00:00
if ( socketFactory = = null ) throw new ArgumentNullException ( nameof ( socketFactory ) ) ;
if ( multicastTimeToLive < = 0 ) throw new ArgumentOutOfRangeException ( nameof ( multicastTimeToLive ) , "multicastTimeToLive must be greater than zero." ) ;
2016-10-29 22:22:20 +00:00
_BroadcastListenSocketSynchroniser = new object ( ) ;
_SendSocketSynchroniser = new object ( ) ;
_LocalPort = localPort ;
_SocketFactory = socketFactory ;
_RequestParser = new HttpRequestParser ( ) ;
_ResponseParser = new HttpResponseParser ( ) ;
_MulticastTtl = multicastTimeToLive ;
2016-12-04 21:55:02 +00:00
_networkManager = networkManager ;
_logger = logger ;
2016-12-05 18:46:38 +00:00
_enableMultiSocketBinding = enableMultiSocketBinding ;
2016-10-29 22:22:20 +00:00
}
#endregion
#region Public Methods
/// <summary>
/// Causes the server to begin listening for multicast messages, being SSDP search requests and notifications.
/// </summary>
/// <exception cref="System.ObjectDisposedException">Thrown if the <see cref="DisposableManagedObjectBase.IsDisposed"/> property is true (because <seealso cref="DisposableManagedObjectBase.Dispose()" /> has been called previously).</exception>
public void BeginListeningForBroadcasts ( )
{
ThrowIfDisposed ( ) ;
if ( _BroadcastListenSocket = = null )
{
lock ( _BroadcastListenSocketSynchroniser )
{
if ( _BroadcastListenSocket = = null )
2018-09-12 17:26:21 +00:00
{
try
{
_BroadcastListenSocket = ListenForBroadcastsAsync ( ) ;
}
catch ( Exception ex )
{
2018-12-20 12:11:26 +00:00
_logger . LogError ( ex , "Error in BeginListeningForBroadcasts" ) ;
2018-09-12 17:26:21 +00:00
}
}
2016-10-29 22:22:20 +00:00
}
}
}
/// <summary>
/// Causes the server to stop listening for multicast messages, being SSDP search requests and notifications.
/// </summary>
/// <exception cref="System.ObjectDisposedException">Thrown if the <see cref="DisposableManagedObjectBase.IsDisposed"/> property is true (because <seealso cref="DisposableManagedObjectBase.Dispose()" /> has been called previously).</exception>
public void StopListeningForBroadcasts ( )
{
lock ( _BroadcastListenSocketSynchroniser )
{
if ( _BroadcastListenSocket ! = null )
{
2018-12-13 13:18:25 +00:00
_logger . LogInformation ( "{0} disposing _BroadcastListenSocket." , GetType ( ) . Name ) ;
2016-10-29 22:22:20 +00:00
_BroadcastListenSocket . Dispose ( ) ;
_BroadcastListenSocket = null ;
}
}
}
/// <summary>
/// Sends a message to a particular address (uni or multicast) and port.
/// </summary>
2017-02-05 20:44:08 +00:00
public async Task SendMessage ( byte [ ] messageData , IpEndPointInfo destination , IpAddressInfo fromLocalIpAddress , CancellationToken cancellationToken )
2016-10-29 22:22:20 +00:00
{
2019-01-06 20:50:43 +00:00
if ( messageData = = null ) throw new ArgumentNullException ( nameof ( messageData ) ) ;
2016-10-29 22:22:20 +00:00
ThrowIfDisposed ( ) ;
2016-12-04 21:55:02 +00:00
var sockets = GetSendSockets ( fromLocalIpAddress , destination ) ;
if ( sockets . Count = = 0 )
{
return ;
}
2016-10-29 22:22:20 +00:00
// SSDP spec recommends sending messages multiple times (not more than 3) to account for possible packet loss over UDP.
2016-11-04 23:57:21 +00:00
for ( var i = 0 ; i < SsdpConstants . UdpResendCount ; i + + )
{
2017-02-05 20:44:08 +00:00
var tasks = sockets . Select ( s = > SendFromSocket ( s , messageData , destination , cancellationToken ) ) . ToArray ( ) ;
2016-12-04 21:55:02 +00:00
await Task . WhenAll ( tasks ) . ConfigureAwait ( false ) ;
2016-11-04 23:57:21 +00:00
2017-02-05 20:44:08 +00:00
await Task . Delay ( 100 , cancellationToken ) . ConfigureAwait ( false ) ;
2016-11-04 23:57:21 +00:00
}
2016-10-29 22:22:20 +00:00
}
2017-03-02 20:50:09 +00:00
private async Task SendFromSocket ( ISocket socket , byte [ ] messageData , IpEndPointInfo destination , CancellationToken cancellationToken )
2016-12-04 21:55:02 +00:00
{
try
{
2017-05-24 19:12:55 +00:00
await socket . SendToAsync ( messageData , 0 , messageData . Length , destination , cancellationToken ) . ConfigureAwait ( false ) ;
2016-12-20 05:21:52 +00:00
}
catch ( ObjectDisposedException )
{
2017-03-26 19:00:35 +00:00
}
catch ( OperationCanceledException )
{
2016-12-04 21:55:02 +00:00
}
catch ( Exception ex )
{
2018-12-20 12:11:26 +00:00
_logger . LogError ( ex , "Error sending socket message from {0} to {1}" , socket . LocalIPAddress . ToString ( ) , destination . ToString ( ) ) ;
2016-12-04 21:55:02 +00:00
}
}
2017-03-02 20:50:09 +00:00
private List < ISocket > GetSendSockets ( IpAddressInfo fromLocalIpAddress , IpEndPointInfo destination )
2016-12-04 21:55:02 +00:00
{
EnsureSendSocketCreated ( ) ;
lock ( _SendSocketSynchroniser )
{
var sockets = _sendSockets . Where ( i = > i . LocalIPAddress . AddressFamily = = fromLocalIpAddress . AddressFamily ) ;
// Send from the Any socket and the socket with the matching address
if ( fromLocalIpAddress . AddressFamily = = IpAddressFamily . InterNetwork )
{
sockets = sockets . Where ( i = > i . LocalIPAddress . Equals ( IpAddressInfo . Any ) | | fromLocalIpAddress . Equals ( i . LocalIPAddress ) ) ;
2016-12-05 18:46:38 +00:00
// If sending to the loopback address, filter the socket list as well
if ( destination . IpAddress . Equals ( IpAddressInfo . Loopback ) )
{
sockets = sockets . Where ( i = > i . LocalIPAddress . Equals ( IpAddressInfo . Any ) | | i . LocalIPAddress . Equals ( IpAddressInfo . Loopback ) ) ;
}
2016-12-04 21:55:02 +00:00
}
else if ( fromLocalIpAddress . AddressFamily = = IpAddressFamily . InterNetworkV6 )
{
sockets = sockets . Where ( i = > i . LocalIPAddress . Equals ( IpAddressInfo . IPv6Any ) | | fromLocalIpAddress . Equals ( i . LocalIPAddress ) ) ;
2016-12-05 18:46:38 +00:00
// If sending to the loopback address, filter the socket list as well
if ( destination . IpAddress . Equals ( IpAddressInfo . IPv6Loopback ) )
{
sockets = sockets . Where ( i = > i . LocalIPAddress . Equals ( IpAddressInfo . IPv6Any ) | | i . LocalIPAddress . Equals ( IpAddressInfo . IPv6Loopback ) ) ;
}
2016-12-04 21:55:02 +00:00
}
return sockets . ToList ( ) ;
}
}
2018-09-12 17:26:21 +00:00
public Task SendMulticastMessage ( string message , CancellationToken cancellationToken )
{
return SendMulticastMessage ( message , SsdpConstants . UdpResendCount , cancellationToken ) ;
}
2016-10-29 22:22:20 +00:00
/// <summary>
/// Sends a message to the SSDP multicast address and port.
/// </summary>
2018-09-12 17:26:21 +00:00
public async Task SendMulticastMessage ( string message , int sendCount , CancellationToken cancellationToken )
2016-10-29 22:22:20 +00:00
{
2019-01-06 20:50:43 +00:00
if ( message = = null ) throw new ArgumentNullException ( nameof ( message ) ) ;
2016-11-14 19:48:01 +00:00
byte [ ] messageData = Encoding . UTF8 . GetBytes ( message ) ;
2016-10-29 22:22:20 +00:00
ThrowIfDisposed ( ) ;
2017-02-05 20:44:08 +00:00
cancellationToken . ThrowIfCancellationRequested ( ) ;
2016-10-29 22:22:20 +00:00
EnsureSendSocketCreated ( ) ;
// SSDP spec recommends sending messages multiple times (not more than 3) to account for possible packet loss over UDP.
2018-09-12 17:26:21 +00:00
for ( var i = 0 ; i < sendCount ; i + + )
2016-11-04 23:57:21 +00:00
{
await SendMessageIfSocketNotDisposed ( messageData , new IpEndPointInfo
{
2017-03-29 19:16:43 +00:00
IpAddress = new IpAddressInfo ( SsdpConstants . MulticastLocalAdminAddress , IpAddressFamily . InterNetwork ) ,
2016-11-04 23:57:21 +00:00
Port = SsdpConstants . MulticastPort
2017-02-05 20:44:08 +00:00
} , cancellationToken ) . ConfigureAwait ( false ) ;
2016-11-04 23:57:21 +00:00
2017-02-05 20:44:08 +00:00
await Task . Delay ( 100 , cancellationToken ) . ConfigureAwait ( false ) ;
2016-11-04 23:57:21 +00:00
}
2016-10-29 22:22:20 +00:00
}
/// <summary>
/// Stops listening for search responses on the local, unicast socket.
/// </summary>
/// <exception cref="System.ObjectDisposedException">Thrown if the <see cref="DisposableManagedObjectBase.IsDisposed"/> property is true (because <seealso cref="DisposableManagedObjectBase.Dispose()" /> has been called previously).</exception>
public void StopListeningForResponses ( )
{
lock ( _SendSocketSynchroniser )
{
2016-12-04 21:55:02 +00:00
if ( _sendSockets ! = null )
{
var sockets = _sendSockets . ToList ( ) ;
_sendSockets = null ;
2018-12-13 13:18:25 +00:00
_logger . LogInformation ( "{0} Disposing {1} sendSockets" , GetType ( ) . Name , sockets . Count ) ;
2018-09-12 17:26:21 +00:00
2016-12-04 21:55:02 +00:00
foreach ( var socket in sockets )
{
2018-12-13 13:18:25 +00:00
_logger . LogInformation ( "{0} disposing sendSocket from {1}" , GetType ( ) . Name , socket . LocalIPAddress ) ;
2016-12-04 21:55:02 +00:00
socket . Dispose ( ) ;
}
}
2016-10-29 22:22:20 +00:00
}
}
#endregion
#region Public Properties
/// <summary>
/// Gets or sets a boolean value indicating whether or not this instance is shared amongst multiple <see cref="SsdpDeviceLocatorBase"/> and/or <see cref="ISsdpDevicePublisher"/> instances.
/// </summary>
/// <remarks>
/// <para>If true, disposing an instance of a <see cref="SsdpDeviceLocatorBase"/>or a <see cref="ISsdpDevicePublisher"/> will not dispose this comms server instance. The calling code is responsible for managing the lifetime of the server.</para>
/// </remarks>
public bool IsShared
{
get { return _IsShared ; }
set { _IsShared = value ; }
}
#endregion
#region Overrides
/// <summary>
/// Stops listening for requests, disposes this instance and all internal resources.
/// </summary>
/// <param name="disposing"></param>
protected override void Dispose ( bool disposing )
{
if ( disposing )
{
2018-09-12 17:26:21 +00:00
StopListeningForBroadcasts ( ) ;
2016-10-29 22:22:20 +00:00
2018-09-12 17:26:21 +00:00
StopListeningForResponses ( ) ;
2016-10-29 22:22:20 +00:00
}
}
#endregion
#region Private Methods
2018-09-12 17:26:21 +00:00
private Task SendMessageIfSocketNotDisposed ( byte [ ] messageData , IpEndPointInfo destination , CancellationToken cancellationToken )
2016-10-29 22:22:20 +00:00
{
2016-12-04 21:55:02 +00:00
var sockets = _sendSockets ;
if ( sockets ! = null )
2016-10-29 22:22:20 +00:00
{
2016-12-04 21:55:02 +00:00
sockets = sockets . ToList ( ) ;
2018-09-12 17:26:21 +00:00
var tasks = sockets . Select ( s = > SendFromSocket ( s , messageData , destination , cancellationToken ) ) ;
return Task . WhenAll ( tasks ) ;
2016-10-29 22:22:20 +00:00
}
2018-09-12 17:26:21 +00:00
return Task . CompletedTask ;
2016-10-29 22:22:20 +00:00
}
2017-03-02 20:50:09 +00:00
private ISocket ListenForBroadcastsAsync ( )
2016-10-29 22:22:20 +00:00
{
var socket = _SocketFactory . CreateUdpMulticastSocket ( SsdpConstants . MulticastLocalAdminAddress , _MulticastTtl , SsdpConstants . MulticastPort ) ;
ListenToSocket ( socket ) ;
return socket ;
}
2017-03-02 20:50:09 +00:00
private List < ISocket > CreateSocketAndListenForResponsesAsync ( )
2016-10-29 22:22:20 +00:00
{
2017-03-02 20:50:09 +00:00
var sockets = new List < ISocket > ( ) ;
2016-10-29 22:22:20 +00:00
2016-12-04 21:55:02 +00:00
sockets . Add ( _SocketFactory . CreateSsdpUdpSocket ( IpAddressInfo . Any , _LocalPort ) ) ;
2016-10-29 22:22:20 +00:00
2016-12-05 18:46:38 +00:00
if ( _enableMultiSocketBinding )
2016-12-04 21:55:02 +00:00
{
2017-08-24 19:52:19 +00:00
foreach ( var address in _networkManager . GetLocalIpAddresses ( ) )
2016-12-04 21:55:02 +00:00
{
2017-11-12 21:05:40 +00:00
if ( address . AddressFamily = = IpAddressFamily . InterNetworkV6 )
{
// Not supported ?
continue ;
}
2016-12-05 18:46:38 +00:00
try
{
sockets . Add ( _SocketFactory . CreateSsdpUdpSocket ( address , _LocalPort ) ) ;
}
catch ( Exception ex )
{
2018-12-20 12:11:26 +00:00
_logger . LogError ( ex , "Error in CreateSsdpUdpSocket. IPAddress: {0}" , address ) ;
2016-12-05 18:46:38 +00:00
}
2016-12-04 21:55:02 +00:00
}
}
foreach ( var socket in sockets )
{
ListenToSocket ( socket ) ;
}
return sockets ;
2016-10-29 22:22:20 +00:00
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1804:RemoveUnusedLocals", MessageId = "t", Justification = "Capturing task to local variable removes compiler warning, task is not otherwise required.")]
2017-03-02 20:50:09 +00:00
private void ListenToSocket ( ISocket socket )
2016-10-29 22:22:20 +00:00
{
// Tasks are captured to local variables even if we don't use them just to avoid compiler warnings.
2018-09-12 17:26:21 +00:00
var t = Task . Run ( ( ) = > ListenToSocketInternal ( socket ) ) ;
}
private async Task ListenToSocketInternal ( ISocket socket )
{
var cancelled = false ;
var receiveBuffer = new byte [ 8192 ] ;
2017-05-24 19:12:55 +00:00
2018-09-12 17:26:21 +00:00
while ( ! cancelled & & ! IsDisposed )
{
try
2016-10-29 22:22:20 +00:00
{
2018-09-12 17:26:21 +00:00
var result = await socket . ReceiveAsync ( receiveBuffer , 0 , receiveBuffer . Length , CancellationToken . None ) . ConfigureAwait ( false ) ;
2016-10-29 22:22:20 +00:00
2018-09-12 17:26:21 +00:00
if ( result . ReceivedBytes > 0 )
2016-10-29 22:22:20 +00:00
{
2018-09-12 17:26:21 +00:00
// Strange cannot convert compiler error here if I don't explicitly
// assign or cast to Action first. Assignment is easier to read,
// so went with that.
ProcessMessage ( System . Text . UTF8Encoding . UTF8 . GetString ( result . Buffer , 0 , result . ReceivedBytes ) , result . RemoteEndPoint , result . LocalIPAddress ) ;
2016-10-29 22:22:20 +00:00
}
}
2018-09-12 17:26:21 +00:00
catch ( ObjectDisposedException )
{
cancelled = true ;
}
catch ( TaskCanceledException )
{
cancelled = true ;
}
}
2016-10-29 22:22:20 +00:00
}
private void EnsureSendSocketCreated ( )
{
2016-12-04 21:55:02 +00:00
if ( _sendSockets = = null )
2016-10-29 22:22:20 +00:00
{
lock ( _SendSocketSynchroniser )
{
2016-12-04 21:55:02 +00:00
if ( _sendSockets = = null )
2016-11-14 19:48:01 +00:00
{
2016-12-04 21:55:02 +00:00
_sendSockets = CreateSocketAndListenForResponsesAsync ( ) ;
2016-11-14 19:48:01 +00:00
}
2016-10-29 22:22:20 +00:00
}
}
}
2016-12-04 21:55:02 +00:00
private void ProcessMessage ( string data , IpEndPointInfo endPoint , IpAddressInfo receivedOnLocalIpAddress )
2016-10-29 22:22:20 +00:00
{
//Responses start with the HTTP version, prefixed with HTTP/ while
2019-01-07 23:24:34 +00:00
//requests start with a method which can vary and might be one we haven't
2016-10-29 22:22:20 +00:00
//seen/don't know. We'll check if this message is a request or a response
2018-09-12 17:26:21 +00:00
//by checking for the HTTP/ prefix on the start of the message.
2016-10-29 22:22:20 +00:00
if ( data . StartsWith ( "HTTP/" , StringComparison . OrdinalIgnoreCase ) )
{
HttpResponseMessage responseMessage = null ;
try
{
responseMessage = _ResponseParser . Parse ( data ) ;
}
2018-09-12 17:26:21 +00:00
catch ( ArgumentException )
2016-12-04 21:55:02 +00:00
{
// Ignore invalid packets.
}
2016-10-29 22:22:20 +00:00
if ( responseMessage ! = null )
2017-01-24 19:54:18 +00:00
OnResponseReceived ( responseMessage , endPoint , receivedOnLocalIpAddress ) ;
2016-10-29 22:22:20 +00:00
}
else
{
HttpRequestMessage requestMessage = null ;
try
{
requestMessage = _RequestParser . Parse ( data ) ;
}
2018-09-12 17:26:21 +00:00
catch ( ArgumentException )
2016-12-04 21:55:02 +00:00
{
// Ignore invalid packets.
}
2016-10-29 22:22:20 +00:00
if ( requestMessage ! = null )
2016-12-04 21:55:02 +00:00
{
OnRequestReceived ( requestMessage , endPoint , receivedOnLocalIpAddress ) ;
}
2016-10-29 22:22:20 +00:00
}
}
2016-12-04 21:55:02 +00:00
private void OnRequestReceived ( HttpRequestMessage data , IpEndPointInfo remoteEndPoint , IpAddressInfo receivedOnLocalIpAddress )
2016-10-29 22:22:20 +00:00
{
//SSDP specification says only * is currently used but other uri's might
//be implemented in the future and should be ignored unless understood.
//Section 4.2 - http://tools.ietf.org/html/draft-cai-ssdp-v1-03#page-11
2016-12-04 21:55:02 +00:00
if ( data . RequestUri . ToString ( ) ! = "*" )
{
return ;
}
2016-10-29 22:22:20 +00:00
var handlers = this . RequestReceived ;
if ( handlers ! = null )
2016-12-04 21:55:02 +00:00
handlers ( this , new RequestReceivedEventArgs ( data , remoteEndPoint , receivedOnLocalIpAddress ) ) ;
2016-10-29 22:22:20 +00:00
}
2017-01-24 19:54:18 +00:00
private void OnResponseReceived ( HttpResponseMessage data , IpEndPointInfo endPoint , IpAddressInfo localIpAddress )
2016-10-29 22:22:20 +00:00
{
var handlers = this . ResponseReceived ;
if ( handlers ! = null )
2017-01-24 19:54:18 +00:00
handlers ( this , new ResponseReceivedEventArgs ( data , endPoint )
{
LocalIpAddress = localIpAddress
} ) ;
2016-10-29 22:22:20 +00:00
}
#endregion
}
2018-12-13 13:18:25 +00:00
}