mirror of
https://github.com/asterisk/asterisk.git
synced 2025-09-03 03:20:57 +00:00
chan_pjsip: Add support for multiple streams of the same type.
The stream topology (list of streams and order) is now stored with the configured PJSIP endpoints and used during the negotiation process. Media negotiation state information has been changed to be stored in a separate object. Two of these objects exist at any one time on a session. The active media state information is what was previously negotiated and the pending media state information is what the media state will become if negotiation succeeds. Streams and other state information is stored in this object using the index (or position) of each individual stream for easy lookup. The ability for a media type handler to specify a callback for writing has been added as well as the ability to add file descriptors with a callback which is invoked when data is available to be read on them. This allows media logic to live outside of the chan_pjsip module. Direct media has been changed so that only the first audio and video stream are directly connected. In the future once the RTP engine glue API has been updated to know about streams each individual stream can be directly connected as appropriate. Media negotiation itself will currently answer all the provided streams on an offer within configured limits and on an offer will use the topology created as a result of the disallow/allow codec lines. If a stream has been removed or declined we will now mark it as such within the resulting SDP. Applications can now also request that the stream topology change. If we are told to do so we will limit any provided formats to the ones configured on the endpoint and send a re-invite with the new topology. Two new configuration options have also been added to PJSIP endpoints: max_audio_streams: determines the maximum number of audio streams to offer/accept from an endpoint. Defaults to 1. max_video_streams: determines the maximum number of video streams to offer/accept from an endpoint. Defaults to 1. ASTERISK-27076 Change-Id: I8afd8dd2eb538806a39b887af0abd046266e14c7
This commit is contained in:
committed by
Joshua Colp
parent
a48d3e4d31
commit
45df25a579
@@ -43,6 +43,8 @@
|
||||
#include "asterisk/netsock2.h"
|
||||
#include "asterisk/channel.h"
|
||||
#include "asterisk/acl.h"
|
||||
#include "asterisk/stream.h"
|
||||
#include "asterisk/format_cache.h"
|
||||
|
||||
#include "asterisk/res_pjsip.h"
|
||||
#include "asterisk/res_pjsip_session.h"
|
||||
@@ -63,11 +65,16 @@ struct t38_state {
|
||||
struct ast_control_t38_parameters their_parms;
|
||||
/*! \brief Timer entry for automatically rejecting an inbound re-invite */
|
||||
pj_timer_entry timer;
|
||||
/*! Preserved media state for when T.38 ends */
|
||||
struct ast_sip_session_media_state *media_state;
|
||||
};
|
||||
|
||||
/*! \brief Destructor for T.38 state information */
|
||||
static void t38_state_destroy(void *obj)
|
||||
{
|
||||
struct t38_state *state = obj;
|
||||
|
||||
ast_sip_session_media_state_free(state->media_state);
|
||||
ast_free(obj);
|
||||
}
|
||||
|
||||
@@ -195,7 +202,7 @@ static int t38_automatic_reject(void *obj)
|
||||
{
|
||||
RAII_VAR(struct ast_sip_session *, session, obj, ao2_cleanup);
|
||||
RAII_VAR(struct ast_datastore *, datastore, ast_sip_session_get_datastore(session, "t38"), ao2_cleanup);
|
||||
RAII_VAR(struct ast_sip_session_media *, session_media, ao2_find(session->media, "image", OBJ_KEY), ao2_cleanup);
|
||||
struct ast_sip_session_media *session_media;
|
||||
|
||||
if (!datastore) {
|
||||
return 0;
|
||||
@@ -204,6 +211,7 @@ static int t38_automatic_reject(void *obj)
|
||||
ast_debug(2, "Automatically rejecting T.38 request on channel '%s'\n",
|
||||
session->channel ? ast_channel_name(session->channel) : "<gone>");
|
||||
|
||||
session_media = session->pending_media_state->default_session[AST_MEDIA_TYPE_IMAGE];
|
||||
t38_change_state(session, session_media, datastore->data, T38_REJECTED);
|
||||
ast_sip_session_resume_reinvite(session);
|
||||
|
||||
@@ -259,7 +267,6 @@ static int t38_initialize_session(struct ast_sip_session *session, struct ast_si
|
||||
return -1;
|
||||
}
|
||||
|
||||
ast_channel_set_fd(session->channel, 5, ast_udptl_fd(session_media->udptl));
|
||||
ast_udptl_set_error_correction_scheme(session_media->udptl, session->endpoint->media.t38.error_correction);
|
||||
ast_udptl_setnat(session_media->udptl, session->endpoint->media.t38.nat);
|
||||
ast_udptl_set_far_max_datagram(session_media->udptl, session->endpoint->media.t38.maxdatagram);
|
||||
@@ -271,19 +278,15 @@ static int t38_initialize_session(struct ast_sip_session *session, struct ast_si
|
||||
/*! \brief Callback for when T.38 reinvite SDP is created */
|
||||
static int t38_reinvite_sdp_cb(struct ast_sip_session *session, pjmedia_sdp_session *sdp)
|
||||
{
|
||||
int stream;
|
||||
struct t38_state *state;
|
||||
|
||||
/* Move the image media stream to the front and have it as the only stream, pjmedia will fill in
|
||||
* dummy streams for the rest
|
||||
*/
|
||||
for (stream = 0; stream < sdp->media_count; ++stream) {
|
||||
if (!pj_strcmp2(&sdp->media[stream]->desc.media, "image")) {
|
||||
sdp->media[0] = sdp->media[stream];
|
||||
sdp->media_count = 1;
|
||||
break;
|
||||
}
|
||||
state = t38_state_get_or_alloc(session);
|
||||
if (!state) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
state->media_state = ast_sip_session_media_state_clone(session->active_media_state);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -292,34 +295,109 @@ static int t38_reinvite_response_cb(struct ast_sip_session *session, pjsip_rx_da
|
||||
{
|
||||
struct pjsip_status_line status = rdata->msg_info.msg->line.status;
|
||||
struct t38_state *state;
|
||||
RAII_VAR(struct ast_sip_session_media *, session_media, NULL, ao2_cleanup);
|
||||
struct ast_sip_session_media *session_media = NULL;
|
||||
|
||||
if (status.code == 100) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!(state = t38_state_get_or_alloc(session)) ||
|
||||
!(session_media = ao2_find(session->media, "image", OBJ_KEY))) {
|
||||
state = t38_state_get_or_alloc(session);
|
||||
if (!state) {
|
||||
ast_log(LOG_WARNING, "Received response to T.38 re-invite on '%s' but state unavailable\n",
|
||||
ast_channel_name(session->channel));
|
||||
return 0;
|
||||
}
|
||||
|
||||
t38_change_state(session, session_media, state, (status.code == 200) ? T38_ENABLED : T38_REJECTED);
|
||||
if (status.code == 200) {
|
||||
int index;
|
||||
|
||||
session_media = session->active_media_state->default_session[AST_MEDIA_TYPE_IMAGE];
|
||||
t38_change_state(session, session_media, state, T38_ENABLED);
|
||||
|
||||
/* Stop all the streams in the stored away active state, they'll go back to being active once
|
||||
* we reinvite back.
|
||||
*/
|
||||
for (index = 0; index < AST_VECTOR_SIZE(&state->media_state->sessions); ++index) {
|
||||
struct ast_sip_session_media *session_media = AST_VECTOR_GET(&state->media_state->sessions, index);
|
||||
|
||||
if (session_media && session_media->handler && session_media->handler->stream_stop) {
|
||||
session_media->handler->stream_stop(session_media);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
session_media = session->pending_media_state->default_session[AST_MEDIA_TYPE_IMAGE];
|
||||
t38_change_state(session, session_media, state, T38_REJECTED);
|
||||
|
||||
/* Abort this attempt at switching to T.38 by resetting the pending state and freeing our stored away active state */
|
||||
ast_sip_session_media_state_free(state->media_state);
|
||||
state->media_state = NULL;
|
||||
ast_sip_session_media_state_reset(session->pending_media_state);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/*! \brief Helper function which creates a media state for strictly T.38 */
|
||||
static struct ast_sip_session_media_state *t38_create_media_state(struct ast_sip_session *session)
|
||||
{
|
||||
struct ast_sip_session_media_state *media_state;
|
||||
struct ast_stream *stream;
|
||||
struct ast_format_cap *caps;
|
||||
struct ast_sip_session_media *session_media;
|
||||
|
||||
media_state = ast_sip_session_media_state_alloc();
|
||||
if (!media_state) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
media_state->topology = ast_stream_topology_alloc();
|
||||
if (!media_state->topology) {
|
||||
ast_sip_session_media_state_free(media_state);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
stream = ast_stream_alloc("t38", AST_MEDIA_TYPE_IMAGE);
|
||||
if (!stream) {
|
||||
ast_sip_session_media_state_free(media_state);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
ast_stream_set_state(stream, AST_STREAM_STATE_SENDRECV);
|
||||
ast_stream_topology_set_stream(media_state->topology, 0, stream);
|
||||
|
||||
caps = ast_format_cap_alloc(AST_FORMAT_CAP_FLAG_DEFAULT);
|
||||
if (!caps) {
|
||||
ast_sip_session_media_state_free(media_state);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
ast_format_cap_append(caps, ast_format_t38, 0);
|
||||
ast_stream_set_formats(stream, caps);
|
||||
ao2_ref(caps, -1);
|
||||
|
||||
session_media = ast_sip_session_media_state_add(session, media_state, AST_MEDIA_TYPE_IMAGE, 0);
|
||||
if (!session_media) {
|
||||
ast_sip_session_media_state_free(media_state);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (t38_initialize_session(session, session_media)) {
|
||||
ast_sip_session_media_state_free(media_state);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
return media_state;
|
||||
}
|
||||
|
||||
/*! \brief Task for reacting to T.38 control frame */
|
||||
static int t38_interpret_parameters(void *obj)
|
||||
{
|
||||
RAII_VAR(struct t38_parameters_task_data *, data, obj, ao2_cleanup);
|
||||
const struct ast_control_t38_parameters *parameters = data->frame->data.ptr;
|
||||
struct t38_state *state = t38_state_get_or_alloc(data->session);
|
||||
RAII_VAR(struct ast_sip_session_media *, session_media, ao2_find(data->session->media, "image", OBJ_KEY), ao2_cleanup);
|
||||
struct ast_sip_session_media *session_media = NULL;
|
||||
|
||||
/* Without session media or state we can't interpret parameters */
|
||||
if (!session_media || !state) {
|
||||
if (!state) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -329,12 +407,15 @@ static int t38_interpret_parameters(void *obj)
|
||||
/* Negotiation can not take place without a valid max_ifp value. */
|
||||
if (!parameters->max_ifp) {
|
||||
if (data->session->t38state == T38_PEER_REINVITE) {
|
||||
session_media = data->session->pending_media_state->default_session[AST_MEDIA_TYPE_IMAGE];
|
||||
t38_change_state(data->session, session_media, state, T38_REJECTED);
|
||||
ast_sip_session_resume_reinvite(data->session);
|
||||
} else if (data->session->t38state == T38_ENABLED) {
|
||||
session_media = data->session->active_media_state->default_session[AST_MEDIA_TYPE_IMAGE];
|
||||
t38_change_state(data->session, session_media, state, T38_DISABLED);
|
||||
ast_sip_session_refresh(data->session, NULL, NULL, NULL,
|
||||
AST_SIP_SESSION_REFRESH_METHOD_INVITE, 1);
|
||||
AST_SIP_SESSION_REFRESH_METHOD_INVITE, 1, state->media_state);
|
||||
state->media_state = NULL;
|
||||
}
|
||||
break;
|
||||
} else if (data->session->t38state == T38_PEER_REINVITE) {
|
||||
@@ -353,37 +434,46 @@ static int t38_interpret_parameters(void *obj)
|
||||
}
|
||||
state->our_parms.version = MIN(state->our_parms.version, state->their_parms.version);
|
||||
state->our_parms.rate_management = state->their_parms.rate_management;
|
||||
session_media = data->session->pending_media_state->default_session[AST_MEDIA_TYPE_IMAGE];
|
||||
ast_udptl_set_local_max_ifp(session_media->udptl, state->our_parms.max_ifp);
|
||||
t38_change_state(data->session, session_media, state, T38_ENABLED);
|
||||
ast_sip_session_resume_reinvite(data->session);
|
||||
} else if ((data->session->t38state != T38_ENABLED) ||
|
||||
((data->session->t38state == T38_ENABLED) &&
|
||||
(parameters->request_response == AST_T38_REQUEST_NEGOTIATE))) {
|
||||
if (t38_initialize_session(data->session, session_media)) {
|
||||
struct ast_sip_session_media_state *media_state;
|
||||
|
||||
media_state = t38_create_media_state(data->session);
|
||||
if (!media_state) {
|
||||
break;
|
||||
}
|
||||
state->our_parms = *parameters;
|
||||
session_media = media_state->default_session[AST_MEDIA_TYPE_IMAGE];
|
||||
ast_udptl_set_local_max_ifp(session_media->udptl, state->our_parms.max_ifp);
|
||||
t38_change_state(data->session, session_media, state, T38_LOCAL_REINVITE);
|
||||
ast_sip_session_refresh(data->session, NULL, t38_reinvite_sdp_cb, t38_reinvite_response_cb,
|
||||
AST_SIP_SESSION_REFRESH_METHOD_INVITE, 1);
|
||||
AST_SIP_SESSION_REFRESH_METHOD_INVITE, 1, media_state);
|
||||
}
|
||||
break;
|
||||
case AST_T38_TERMINATED:
|
||||
case AST_T38_REFUSED:
|
||||
case AST_T38_REQUEST_TERMINATE: /* Shutdown T38 */
|
||||
if (data->session->t38state == T38_PEER_REINVITE) {
|
||||
session_media = data->session->pending_media_state->default_session[AST_MEDIA_TYPE_IMAGE];
|
||||
t38_change_state(data->session, session_media, state, T38_REJECTED);
|
||||
ast_sip_session_resume_reinvite(data->session);
|
||||
} else if (data->session->t38state == T38_ENABLED) {
|
||||
session_media = data->session->active_media_state->default_session[AST_MEDIA_TYPE_IMAGE];
|
||||
t38_change_state(data->session, session_media, state, T38_DISABLED);
|
||||
ast_sip_session_refresh(data->session, NULL, NULL, NULL, AST_SIP_SESSION_REFRESH_METHOD_INVITE, 1);
|
||||
ast_sip_session_refresh(data->session, NULL, NULL, NULL, AST_SIP_SESSION_REFRESH_METHOD_INVITE, 1, state->media_state);
|
||||
state->media_state = NULL;
|
||||
}
|
||||
break;
|
||||
case AST_T38_REQUEST_PARMS: { /* Application wants remote's parameters re-sent */
|
||||
struct ast_control_t38_parameters parameters = state->their_parms;
|
||||
|
||||
if (data->session->t38state == T38_PEER_REINVITE) {
|
||||
session_media = data->session->pending_media_state->default_session[AST_MEDIA_TYPE_IMAGE];
|
||||
parameters.max_ifp = ast_udptl_get_far_max_ifp(session_media->udptl);
|
||||
parameters.request_response = AST_T38_REQUEST_NEGOTIATE;
|
||||
ast_queue_control_data(data->session->channel, AST_CONTROL_T38_PARAMETERS, ¶meters, sizeof(parameters));
|
||||
@@ -397,67 +487,27 @@ static int t38_interpret_parameters(void *obj)
|
||||
return 0;
|
||||
}
|
||||
|
||||
/*! \brief Frame hook callback for writing */
|
||||
static struct ast_frame *t38_framehook_write(struct ast_channel *chan,
|
||||
struct ast_sip_session *session, struct ast_frame *f)
|
||||
{
|
||||
if (f->frametype == AST_FRAME_CONTROL && f->subclass.integer == AST_CONTROL_T38_PARAMETERS &&
|
||||
session->endpoint->media.t38.enabled) {
|
||||
struct t38_parameters_task_data *data = t38_parameters_task_data_alloc(session, f);
|
||||
|
||||
if (!data) {
|
||||
return f;
|
||||
}
|
||||
|
||||
if (ast_sip_push_task(session->serializer, t38_interpret_parameters, data)) {
|
||||
ao2_ref(data, -1);
|
||||
}
|
||||
} else if (f->frametype == AST_FRAME_MODEM) {
|
||||
struct ast_sip_session_media *session_media;
|
||||
|
||||
/* Avoid deadlock between chan and the session->media container lock */
|
||||
ast_channel_unlock(chan);
|
||||
session_media = ao2_find(session->media, "image", OBJ_SEARCH_KEY);
|
||||
ast_channel_lock(chan);
|
||||
if (session_media && session_media->udptl) {
|
||||
ast_udptl_write(session_media->udptl, f);
|
||||
}
|
||||
ao2_cleanup(session_media);
|
||||
}
|
||||
|
||||
return f;
|
||||
}
|
||||
|
||||
/*! \brief Frame hook callback for reading */
|
||||
static struct ast_frame *t38_framehook_read(struct ast_channel *chan,
|
||||
struct ast_sip_session *session, struct ast_frame *f)
|
||||
{
|
||||
if (ast_channel_fdno(session->channel) == 5) {
|
||||
struct ast_sip_session_media *session_media;
|
||||
|
||||
/* Avoid deadlock between chan and the session->media container lock */
|
||||
ast_channel_unlock(chan);
|
||||
session_media = ao2_find(session->media, "image", OBJ_SEARCH_KEY);
|
||||
ast_channel_lock(chan);
|
||||
if (session_media && session_media->udptl) {
|
||||
f = ast_udptl_read(session_media->udptl);
|
||||
}
|
||||
ao2_cleanup(session_media);
|
||||
}
|
||||
|
||||
return f;
|
||||
}
|
||||
|
||||
/*! \brief Frame hook callback for T.38 related stuff */
|
||||
static struct ast_frame *t38_framehook(struct ast_channel *chan, struct ast_frame *f,
|
||||
enum ast_framehook_event event, void *data)
|
||||
{
|
||||
struct ast_sip_channel_pvt *channel = ast_channel_tech_pvt(chan);
|
||||
|
||||
if (event == AST_FRAMEHOOK_EVENT_READ) {
|
||||
f = t38_framehook_read(chan, channel->session, f);
|
||||
} else if (event == AST_FRAMEHOOK_EVENT_WRITE) {
|
||||
f = t38_framehook_write(chan, channel->session, f);
|
||||
if (event != AST_FRAMEHOOK_EVENT_WRITE) {
|
||||
return f;
|
||||
}
|
||||
|
||||
if (f->frametype == AST_FRAME_CONTROL && f->subclass.integer == AST_CONTROL_T38_PARAMETERS &&
|
||||
channel->session->endpoint->media.t38.enabled) {
|
||||
struct t38_parameters_task_data *data = t38_parameters_task_data_alloc(channel->session, f);
|
||||
|
||||
if (!data) {
|
||||
return f;
|
||||
}
|
||||
|
||||
if (ast_sip_push_task(channel->session->serializer, t38_interpret_parameters, data)) {
|
||||
ao2_ref(data, -1);
|
||||
}
|
||||
}
|
||||
|
||||
return f;
|
||||
@@ -476,7 +526,7 @@ static void t38_masq(void *data, int framehook_id,
|
||||
|
||||
static int t38_consume(void *data, enum ast_frame_type type)
|
||||
{
|
||||
return 0;
|
||||
return (type == AST_FRAME_CONTROL) ? 1 : 0;
|
||||
}
|
||||
|
||||
static const struct ast_datastore_info t38_framehook_datastore = {
|
||||
@@ -676,11 +726,13 @@ static enum ast_sip_session_sdp_stream_defer defer_incoming_sdp_stream(
|
||||
}
|
||||
|
||||
/*! \brief Function which negotiates an incoming media stream */
|
||||
static int negotiate_incoming_sdp_stream(struct ast_sip_session *session, struct ast_sip_session_media *session_media,
|
||||
const struct pjmedia_sdp_session *sdp, const struct pjmedia_sdp_media *stream)
|
||||
static int negotiate_incoming_sdp_stream(struct ast_sip_session *session,
|
||||
struct ast_sip_session_media *session_media, const struct pjmedia_sdp_session *sdp,
|
||||
int index, struct ast_stream *asterisk_stream)
|
||||
{
|
||||
struct t38_state *state;
|
||||
char host[NI_MAXHOST];
|
||||
pjmedia_sdp_media *stream = sdp->media[index];
|
||||
RAII_VAR(struct ast_sockaddr *, addrs, NULL, ast_free);
|
||||
|
||||
if (!session->endpoint->media.t38.enabled) {
|
||||
@@ -720,7 +772,7 @@ static int negotiate_incoming_sdp_stream(struct ast_sip_session *session, struct
|
||||
|
||||
/*! \brief Function which creates an outgoing stream */
|
||||
static int create_outgoing_sdp_stream(struct ast_sip_session *session, struct ast_sip_session_media *session_media,
|
||||
struct pjmedia_sdp_session *sdp)
|
||||
struct pjmedia_sdp_session *sdp, const struct pjmedia_sdp_session *remote, struct ast_stream *stream)
|
||||
{
|
||||
pj_pool_t *pool = session->inv_session->pool_prov;
|
||||
static const pj_str_t STR_IN = { "IN", 2 };
|
||||
@@ -758,7 +810,7 @@ static int create_outgoing_sdp_stream(struct ast_sip_session *session, struct as
|
||||
return -1;
|
||||
}
|
||||
|
||||
media->desc.media = pj_str(session_media->stream_type);
|
||||
pj_strdup2(pool, &media->desc.media, ast_codec_media_type2str(session_media->type));
|
||||
media->desc.transport = STR_UDPTL;
|
||||
|
||||
if (ast_strlen_zero(session->endpoint->media.address)) {
|
||||
@@ -826,12 +878,31 @@ static int create_outgoing_sdp_stream(struct ast_sip_session *session, struct as
|
||||
return 1;
|
||||
}
|
||||
|
||||
static struct ast_frame *media_session_udptl_read_callback(struct ast_sip_session *session, struct ast_sip_session_media *session_media)
|
||||
{
|
||||
if (!session_media->udptl) {
|
||||
return &ast_null_frame;
|
||||
}
|
||||
|
||||
return ast_udptl_read(session_media->udptl);
|
||||
}
|
||||
|
||||
static int media_session_udptl_write_callback(struct ast_sip_session *session, struct ast_sip_session_media *session_media, struct ast_frame *frame)
|
||||
{
|
||||
if (!session_media->udptl) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ast_udptl_write(session_media->udptl, frame);
|
||||
}
|
||||
|
||||
/*! \brief Function which applies a negotiated stream */
|
||||
static int apply_negotiated_sdp_stream(struct ast_sip_session *session, struct ast_sip_session_media *session_media,
|
||||
const struct pjmedia_sdp_session *local, const struct pjmedia_sdp_media *local_stream,
|
||||
const struct pjmedia_sdp_session *remote, const struct pjmedia_sdp_media *remote_stream)
|
||||
static int apply_negotiated_sdp_stream(struct ast_sip_session *session,
|
||||
struct ast_sip_session_media *session_media, const struct pjmedia_sdp_session *local,
|
||||
const struct pjmedia_sdp_session *remote, int index, struct ast_stream *asterisk_stream)
|
||||
{
|
||||
RAII_VAR(struct ast_sockaddr *, addrs, NULL, ast_free);
|
||||
pjmedia_sdp_media *remote_stream = remote->media[index];
|
||||
char host[NI_MAXHOST];
|
||||
struct t38_state *state;
|
||||
|
||||
@@ -858,6 +929,10 @@ static int apply_negotiated_sdp_stream(struct ast_sip_session *session, struct a
|
||||
|
||||
t38_interpret_sdp(state, session, session_media, remote_stream);
|
||||
|
||||
ast_sip_session_media_set_write_callback(session, session_media, media_session_udptl_write_callback);
|
||||
ast_sip_session_media_add_read_callback(session, session_media, ast_udptl_fd(session_media->udptl),
|
||||
media_session_udptl_read_callback);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user