ctrl k
  • halcyon-core/src/commonMain/kotlin/tigase/halcyon/core/builder/ConfigurationBuilder.kt
    ■ ■ ■ ■
    skipped 24 lines
    25 25  import tigase.halcyon.core.xmpp.modules.receipts.DeliveryReceiptsModule
    26 26  import tigase.halcyon.core.xmpp.modules.roster.RosterModule
    27 27  import tigase.halcyon.core.xmpp.modules.serviceFinder.ServiceFinderModule
    28  -import tigase.halcyon.core.xmpp.modules.sm.StreamManagementModule
    29 28  import tigase.halcyon.core.xmpp.modules.spam.BlockingCommandModule
    30 29  import tigase.halcyon.core.xmpp.modules.uniqueId.UniqueStableStanzaIdModule
    31 30  import tigase.halcyon.core.xmpp.modules.vcard.VCardModule
    skipped 177 lines
    209 208   this.install(InBandRegistrationModule)
    210 209   this.install(FileUploadModule)
    211 210   this.install(ServiceFinderModule)
     211 + this.install(ExternalServiceDiscoveryModule)
    212 212  }
  • halcyon-core/src/commonMain/kotlin/tigase/halcyon/core/xmpp/modules/ExternalServiceDiscoveryModule.kt
    ■ ■ ■ ■ ■ ■
     1 +/*
     2 + * halcyon-core
     3 + * Copyright (C) 2018 Tigase, Inc. (office@tigase.com)
     4 + *
     5 + * This program is free software: you can redistribute it and/or modify
     6 + * it under the terms of the GNU Affero General Public License as published by
     7 + * the Free Software Foundation, version 3 of the License.
     8 + *
     9 + * This program is distributed in the hope that it will be useful,
     10 + * but WITHOUT ANY WARRANTY; without even the implied warranty of
     11 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
     12 + * GNU Affero General Public License for more details.
     13 + *
     14 + * You should have received a copy of the GNU Affero General Public License
     15 + * along with this program. Look for COPYING file in the top folder.
     16 + * If not, see http://www.gnu.org/licenses/.
     17 + */
     18 +package tigase.halcyon.core.xmpp.modules
     19 + 
     20 +import tigase.halcyon.core.Context
     21 +import tigase.halcyon.core.builder.HalcyonConfigDsl
     22 +import tigase.halcyon.core.modules.AbstractXmppIQModule
     23 +import tigase.halcyon.core.modules.Criterion
     24 +import tigase.halcyon.core.modules.XmppModuleProvider
     25 +import tigase.halcyon.core.requests.RequestBuilder
     26 +import tigase.halcyon.core.xml.Element
     27 +import tigase.halcyon.core.xmpp.JID
     28 +import tigase.halcyon.core.xmpp.stanzas.IQ
     29 +import tigase.halcyon.core.xmpp.stanzas.IQType
     30 +import tigase.halcyon.core.xmpp.stanzas.iq
     31 +import tigase.halcyon.core.xmpp.toBareJID
     32 + 
     33 +/**
     34 + * Configuration of [ExternalServiceDiscoveryModule].
     35 + */
     36 +@HalcyonConfigDsl
     37 +interface ExternalServiceDiscoveryModuleConfig
     38 + 
     39 +/**
     40 + * Module is implementing External Service Discovery ([XEP-0215](https://xmpp.org/extensions/xep-0215.html)).
     41 + *
     42 + */
     43 +class ExternalServiceDiscoveryModule(context: Context): ExternalServiceDiscoveryModuleConfig, AbstractXmppIQModule(
     44 + context, TYPE, emptyArray(), Criterion.chain()
     45 +) {
     46 + companion object : XmppModuleProvider<ExternalServiceDiscoveryModule, ExternalServiceDiscoveryModuleConfig> {
     47 + val XMLNS = "urn:xmpp:extdisco:2";
     48 + override val TYPE = XMLNS;
     49 + override fun configure(module: ExternalServiceDiscoveryModule, cfg: ExternalServiceDiscoveryModuleConfig.() -> Unit) = module.cfg()
     50 + override fun instance(context: Context): ExternalServiceDiscoveryModule = ExternalServiceDiscoveryModule(context)
     51 + }
     52 + 
     53 + data class Service(
     54 + val expires: String?,
     55 + val host: String,
     56 + val name: String?,
     57 + val password: String?,
     58 + val port: Int?,
     59 + val restricted: Boolean,
     60 + val transport: Transport?,
     61 + val type: String,
     62 + val username: String?
     63 + ) {
     64 + 
     65 + enum class Transport {
     66 + tcp, udp
     67 + }
     68 + 
     69 + companion object {
     70 + fun parse(el: Element): Service? {
     71 + if (el.name != "service") return null;
     72 + val type = el.attributes["type"] ?: return null
     73 + val host = el.attributes["host"] ?: return null
     74 + val name = el.attributes["name"]
     75 + val port = el.attributes["port"]?.toInt()
     76 + val transport = el.attributes["transport"]?.lowercase()?.let(Transport::valueOf)
     77 + val username = el.attributes["username"]
     78 + val password = el.attributes["password"];
     79 + val restricted = el.attributes["restricted"]?.let { v -> v == "1" || v == "true" } ?: false
     80 + 
     81 + return Service(
     82 + type = type,
     83 + name = name,
     84 + host = host,
     85 + port = port,
     86 + transport = transport,
     87 + username = username,
     88 + password = password,
     89 + restricted = restricted,
     90 + expires = el.attributes["expires"]
     91 + )
     92 + }
     93 + }
     94 + 
     95 + }
     96 + 
     97 + fun discover(jid: JID? = null, type: String?): RequestBuilder<List<Service>, IQ> {
     98 + val stanza = iq {
     99 + this.type = IQType.Get
     100 + this.to = jid ?: context.boundJID?.domain?.toBareJID()
     101 + "services" {
     102 + xmlns = XMLNS
     103 + type?.let {
     104 + attribute("type", it)
     105 + }
     106 + }
     107 + }
     108 + return context.request.iq(stanza).map {
     109 + it.getChildrenNS("serivces", XMLNS)?.children?.map(Service::parse)?.filterNotNull() ?: emptyList()
     110 + }
     111 + }
     112 + 
     113 + 
     114 + override fun processGet(element: IQ) {
     115 + // nothing to do...
     116 + }
     117 + 
     118 + override fun processSet(element: IQ) {
     119 + // nothing to do...
     120 + }
     121 +}
  • halcyon-core/src/commonMain/kotlin/tigase/halcyon/core/xmpp/modules/jingle/AbstractJingleSessionManager.kt
    ■ ■ ■ ■ ■ ■
    skipped 17 lines
    18 18  package tigase.halcyon.core.xmpp.modules.jingle
    19 19   
    20 20  import tigase.halcyon.core.AbstractHalcyon
     21 +import tigase.halcyon.core.Context
    21 22  import tigase.halcyon.core.eventbus.Event
    22 23  import tigase.halcyon.core.eventbus.EventDefinition
    23 24  import tigase.halcyon.core.eventbus.handler
    24 25  import tigase.halcyon.core.logger.LoggerFactory
    25  -import tigase.halcyon.core.xmpp.BareJID
    26  -import tigase.halcyon.core.xmpp.JID
    27  -import tigase.halcyon.core.xmpp.bareJID
     26 +import tigase.halcyon.core.utils.Lock
     27 +import tigase.halcyon.core.xmpp.*
    28 28  import tigase.halcyon.core.xmpp.modules.presence.ContactChangeStatusEvent
    29  -import tigase.halcyon.core.xmpp.resource
    30 29  import tigase.halcyon.core.xmpp.stanzas.PresenceType
    31 30   
    32 31  abstract class AbstractJingleSessionManager<S : AbstractJingleSession>(
    33  - name: String, private val sessionFactory: SessionFactory<S>,
     32 + name: String
    34 33  ) : Jingle.SessionManager {
     34 + 
     35 + abstract fun createSession(context: Context, jid: JID, sid: String, role: Content.Creator, initiationType: InitiationType): S
     36 + abstract fun reportIncomingCall(session: S, media: List<Media>)
    35 37   
    36 38   private val log = LoggerFactory.logger(name)
    37 39   
    38 40   protected var sessions: List<S> = emptyList()
     41 + private val lock = Lock();
    39 42  
    40  - private val jingleEventHandler = JingleEvent.handler { event ->
    41  - when (event.action) {
    42  - Action.SessionInitiate -> sessionInitiated(event)
    43  - Action.SessionAccept -> sessionAccepted(event)
    44  - Action.TransportInfo -> transportInfo(event)
    45  - Action.SessionTerminate -> sessionTerminated(event)
    46  - else -> log.warning { "unsupported event: " + event.action.name }
    47  - }
    48  - }
    49 43   private val contactChangeStatusEventHandler = handler<ContactChangeStatusEvent> { event ->
    50 44   if (event.lastReceivedPresence.type == PresenceType.Unavailable) {
    51 45   val toClose =
    52 46   sessions.filter { it.jid == event.presence.from && it.account == event.context.boundJID?.bareJID }
    53 47   toClose.forEach { it.terminate(TerminateReason.Success) }
    54  - }
    55  - }
    56  - private val jingleMessageInitiationEvent = JingleMessageInitiationEvent.handler { event ->
    57  - val account = event.context.boundJID!!.bareJID
    58  - when (event.action) {
    59  - is MessageInitiationAction.Propose -> {
    60  - if (session(account, event.jid, event.action.id) == null) {
    61  - val session = open(
    62  - event.context.getModule(JingleModule.TYPE),
    63  - account,
    64  - event.jid,
    65  - event.action.id,
    66  - Content.Creator.Responder,
    67  - InitiationType.Message
    68  - )
    69  - val media = event.action.descriptions.filter { isDesciptionSupported(it) }.map { it.media }
    70  - fireIncomingSessionEvent(event.context, session, media)
    71  - }
    72  - }
    73  - 
    74  - is MessageInitiationAction.Retract -> sessionTerminated(account, event.jid, event.action.id)
    75  - is MessageInitiationAction.Accept -> sessionTerminated(account, event.action.id)
    76  - is MessageInitiationAction.Reject -> sessionTerminated(account, event.jid, event.action.id)
    77  - is MessageInitiationAction.Proceed -> session(account, event.jid, event.action.id)?.accepted(event.jid)
    78 48   }
    79 49   }
    80 50   
    81  - abstract fun isDesciptionSupported(descrition: MessageInitiationDescription): Boolean
     51 + abstract fun isDescriptionSupported(descrition: MessageInitiationDescription): Boolean
    82 52   
    83 53   fun register(halcyon: AbstractHalcyon) {
    84  - halcyon.eventBus.register(JingleEvent, this.jingleEventHandler)
    85 54   halcyon.eventBus.register(ContactChangeStatusEvent, this.contactChangeStatusEventHandler)
    86  - halcyon.eventBus.register(JingleMessageInitiationEvent, this.jingleMessageInitiationEvent)
    87 55   }
    88 56   
    89 57   fun unregister(halcyon: AbstractHalcyon) {
    90  - halcyon.eventBus.unregister(JingleEvent, this.jingleEventHandler)
    91 58   halcyon.eventBus.unregister(ContactChangeStatusEvent, this.contactChangeStatusEventHandler)
    92  - halcyon.eventBus.unregister(JingleMessageInitiationEvent, this.jingleMessageInitiationEvent)
    93 59   }
    94 60   
    95  - override fun activateSessionSid(account: BareJID, with: JID): String? {
    96  - return session(account, with, null)?.sid
     61 + fun session(context: Context, jid: JID, sid: String?): S? {
     62 + return context.boundJID?.bareJID?.let { account ->
     63 + session(account, jid, sid);
     64 + }
    97 65   }
    98 66   
    99 67   fun session(account: BareJID, jid: JID, sid: String?): S? =
    100  - sessions.firstOrNull { it.account == account && (sid == null || it.sid == sid) && (it.jid == jid || (it.jid.resource == null && it.jid.bareJID == jid.bareJID)) }
     68 + lock.withLock {
     69 + sessions.firstOrNull { it.account == account && (sid == null || it.sid == sid) && (it.jid == jid || (it.jid.resource == null && it.jid.bareJID == jid.bareJID)) }
     70 + }
    101 71   
    102 72   fun open(
    103  - jingleModule: JingleModule,
    104  - account: BareJID,
     73 + context: Context,
    105 74   jid: JID,
    106 75   sid: String,
    107 76   role: Content.Creator,
    108 77   initiationType: InitiationType,
    109 78   ): S {
    110  - val session = sessionFactory.createSession(this, jingleModule, account, jid, sid, role, initiationType)
    111  - sessions = sessions + session
    112  - return session
     79 + return lock.withLock {
     80 + val session = this.createSession(context, jid, sid, role, initiationType);
     81 + sessions = sessions + session
     82 + return@withLock session
     83 + }
    113 84   }
    114 85   
    115  - fun close(account: BareJID, jid: JID, sid: String): S? = session(account, jid, sid)?.let { session ->
    116  - sessions = sessions - session
    117  - return session
     86 + fun close(account: BareJID, jid: JID, sid: String): S? = lock.withLock {
     87 + return@withLock session(account, jid, sid)?.let { session ->
     88 + sessions = sessions - session
     89 + return@let session
     90 + }
    118 91   }
    119 92   
    120  - fun close(session: S) {
     93 + fun close(session: AbstractJingleSession) {
    121 94   close(session.account, session.jid, session.sid)
    122 95   }
    123 96   
    124  - protected fun sessionInitiated(event: JingleEvent) {
    125  - val account = event.context.boundJID!!.bareJID
    126  - session(account, event.jid, event.sid)?.accepted(event.contents, event.bundle) ?: run {
    127  - val session = open(
    128  - event.context.getModule(JingleModule.TYPE),
    129  - account,
    130  - event.jid,
    131  - event.sid,
    132  - Content.Creator.Responder,
    133  - InitiationType.Iq
    134  - )
    135  - session.initiated(event.contents, event.bundle)
    136  - fireIncomingSessionEvent(event.context,
    137  - session,
    138  - event.contents.map { it.description?.media }.filterNotNull())
    139  - }
     97 + enum class ContentType {
     98 + audio,
     99 + video,
     100 + filetransfer
     101 + }
     102 + 
     103 + enum class Media {
     104 + audio,
     105 + video
    140 106   }
    141 107   
    142  - protected fun sessionAccepted(event: JingleEvent) {
    143  - val account = event.context.boundJID!!.bareJID
    144  - session(account, event.jid, event.sid)?.accepted(event.contents, event.bundle)
     108 + override fun messageInitiation(context: Context, fromJid: JID, action: MessageInitiationAction) {
     109 + when (action) {
     110 + is MessageInitiationAction.Propose -> {
     111 + if (this.session(context, fromJid, action.id) != null) {
     112 + return;
     113 + }
     114 + val session = open(context, fromJid, action.id, Content.Creator.responder, InitiationType.Message);
     115 + val media = action.descriptions.map { Media.valueOf(it.media) };
     116 + reportIncomingCall(session, media);
     117 + }
     118 + is MessageInitiationAction.Retract -> sessionTerminated(context, fromJid, action.id);
     119 + is MessageInitiationAction.Accept, is MessageInitiationAction.Reject -> sessionTerminated(context.boundJID!!.bareJID, action.id);
     120 + is MessageInitiationAction.Proceed -> {
     121 + val session = session(context, fromJid, action.id) ?: return;
     122 + session.accepted(fromJid);
     123 + }
     124 + }
    145 125   }
    146 126   
    147  - protected fun sessionTerminated(event: JingleEvent) {
    148  - val account = event.context.boundJID!!.bareJID
    149  - sessionTerminated(account, event.jid, event.sid)
     127 + override fun sessionInitiated(context: Context, jid: JID, sid: String, contents: List<Content>, bundle: List<String>?) {
     128 + val sdp = SDP(contents, bundle ?: emptyList());
     129 + val media = sdp.contents.map { it.description?.media?.let { Media.valueOf(it)} }.filterNotNull()
     130 + 
     131 + session(context, jid, sid)?.let { session -> session.initiated(contents, bundle) } ?: {
     132 + val session = open(context, jid, sid, Content.Creator.responder, InitiationType.Iq);
     133 + session.initiated(contents, bundle)
     134 + reportIncomingCall(session, media);
     135 + }
    150 136   }
    151 137   
    152  - protected fun sessionTerminated(account: BareJID, sid: String) {
    153  - val toTerminate = sessions.filter { it.account == account && it.sid == sid }
    154  - toTerminate.forEach { it.terminated() }
     138 + @Throws(XMPPException::class)
     139 + override fun sessionAccepted(
     140 + context: Context,
     141 + jid: JID,
     142 + sid: String,
     143 + contents: List<Content>,
     144 + bundle: List<String>?
     145 + ) {
     146 + val session = session(context, jid, sid) ?: throw XMPPException(ErrorCondition.ItemNotFound);
    155 147   }
    156 148   
    157  - protected fun sessionTerminated(account: BareJID, jid: JID, sid: String) {
    158  - session(account, jid, sid)?.terminated()
     149 + override fun sessionTerminated(context: Context, jid: JID, sid: String) {
     150 + session(context, jid, sid)?.terminated()
    159 151   }
    160 152   
    161  - protected fun transportInfo(event: JingleEvent) {
    162  - val account = event.context.boundJID!!.bareJID
    163  - session(account, event.jid, event.sid)?.let { session ->
    164  - for (content in event.contents) {
    165  - content.transports.flatMap { it.candidates }.forEach { session.addCandidate(it, content.name) }
    166  - }
     153 + fun sessionTerminated(account: BareJID, sid: String) {
     154 + val toTerminate = lock.withLock {
     155 + return@withLock sessions.filter { it.account == account && it.sid == sid }
    167 156   }
     157 + toTerminate.forEach { it.terminated() }
    168 158   }
    169 159   
    170  - interface SessionFactory<S : AbstractJingleSession> {
    171  - 
    172  - fun createSession(
    173  - jingleSessionManager: AbstractJingleSessionManager<S>,
    174  - jingleModule: JingleModule,
    175  - account: BareJID,
    176  - jid: JID,
    177  - sid: String,
    178  - role: Content.Creator,
    179  - initiationType: InitiationType,
    180  - ): S
     160 + @Throws(XMPPException::class)
     161 + override fun transportInfo(context: Context, jid: JID, sid: String, contents: List<Content>) {
     162 + val session = session(context, jid, sid) ?: throw XMPPException(ErrorCondition.ItemNotFound);
     163 + for (content in contents) {
     164 + content.transports.flatMap { it.candidates }.forEach { session.addCandidate(it, content.name) }
     165 + }
    181 166   }
    182 167   
    183 168   protected fun fireIncomingSessionEvent(context: AbstractHalcyon, session: S, media: List<String>) {
    skipped 12 lines
  • halcyon-core/src/commonMain/kotlin/tigase/halcyon/core/xmpp/modules/jingle/Action.kt
    ■ ■ ■ ■ ■
    skipped 22 lines
    23 23   ContentAdd("content-add"),
    24 24   ContentModify("content-modify"),
    25 25   ContentReject("content-reject"),
     26 + ContentRemove("content-remove"),
    26 27   DescriptionInfo("description-info"),
    27 28   SecurityInfo("security-info"),
    28 29   SessionAccept("session-accept"),
    skipped 13 lines
  • halcyon-core/src/commonMain/kotlin/tigase/halcyon/core/xmpp/modules/jingle/AstractJingleSession.kt
    ■ ■ ■ ■ ■
    skipped 17 lines
    18 18  package tigase.halcyon.core.xmpp.modules.jingle
    19 19   
    20 20  import tigase.halcyon.core.AsyncResult
     21 +import tigase.halcyon.core.Context
     22 +import tigase.halcyon.core.ReflectionModuleManager
     23 +import tigase.halcyon.core.utils.Lock
    21 24  import tigase.halcyon.core.xmpp.BareJID
    22 25  import tigase.halcyon.core.xmpp.JID
    23 26  import tigase.halcyon.core.xmpp.modules.jingle.Jingle.Session.State
    24  -import kotlin.properties.Delegates
    25 27   
     28 +@OptIn(ReflectionModuleManager::class)
    26 29  abstract class AbstractJingleSession(
    27  - private val sessionManager: AbstractJingleSessionManager<AbstractJingleSession>,
    28  - private val jingleModule: JingleModule,
    29  - override val account: BareJID,
     30 + private val terminateFunction: (AbstractJingleSession)->Unit,
     31 + private val context: Context,
    30 32   jid: JID,
    31 33   override val sid: String,
    32 34   val role: Content.Creator,
    33  - private val initiationType: InitiationType,
     35 + val initiationType: InitiationType,
    34 36  ) : Jingle.Session {
    35 37   
    36  - override var state: State by Delegates.observable(State.Created) { _, _, newValue ->
    37  - stateChanged(newValue)
    38  - }
    39  - protected set
     38 + final override val account: BareJID
     39 + private val jingleModule: JingleModule
     40 + override var state: State = State.Created
     41 + get() {
     42 + return lock.withLock {
     43 + field;
     44 + }
     45 + }
     46 + protected set(value) {
     47 + lock.withLock {
     48 + if (field != value) {
     49 + field = value
     50 + delegate
     51 + } else {
     52 + null
     53 + }
     54 + }?.stateChanged(value);
     55 + }
    40 56   override var jid: JID = jid
    41 57   protected set
    42 58   
    43 59   private var remoteContents: List<Content>? = null
    44 60   private var remoteBundles: List<String>? = null
    45 61   
    46  - protected abstract fun stateChanged(state: State)
    47  - protected abstract fun setRemoteDescription(contents: List<Content>, bundle: List<String>?)
    48  - abstract fun addCandidate(candidate: Candidate, contentName: String)
     62 + init {
     63 + this.account = context.boundJID!!.bareJID;
     64 + this.jingleModule = context.modules.getModule<JingleModule>()
     65 + }
     66 + 
     67 + interface Delegate {
     68 + fun stateChanged(state: State);
     69 + fun received(action: Action)
     70 + }
     71 + 
     72 + var delegate: Delegate? = null
     73 + set(value) {
     74 + lock.withLock {
     75 + field = value;
     76 + value?.let { delegate ->
     77 + delegate.stateChanged(state)
     78 + while (!actionsQueue.isEmpty()) {
     79 + val action = actionsQueue.removeFirst()
     80 + delegate.received(action);
     81 + }
     82 + }
     83 + }
     84 + }
     85 + 
     86 + private var actionsQueue: ArrayDeque<Action> = ArrayDeque<Action>()
     87 + 
     88 + sealed class Action: Comparable<Action> {
     89 + class ContentSet(val sdp: SDP): Action() {}
     90 + class ContentApply(val contentAction: Jingle.ContentAction, val sdp: SDP): Action() {}
     91 + class TransportAdd(val candidate: Candidate, val contentName: String): Action() {}
     92 + class SessionInfo(val infos: List<Jingle.SessionInfo>): Action() {}
     93 + 
     94 + var order: Int = when(this) {
     95 + is ContentSet -> 0
     96 + is ContentApply -> 0
     97 + is TransportAdd -> 1
     98 + is SessionInfo -> 2
     99 + }
     100 + 
     101 + override fun compareTo(other: Action): Int {
     102 + val x = this.order;
     103 + val y = other.order;
     104 + return if ((x < y)) -1 else (if ((x == y)) 0 else 1)
     105 + }
     106 + }
     107 + 
     108 + private fun received(action: Action) {
     109 + lock.withLock {
     110 + delegate?.let { it.received(action) } ?: {
     111 + val idx = actionsQueue.indexOfFirst { it.order > action.order };
     112 + if (idx < 0) {
     113 + actionsQueue.add(action)
     114 + } else {
     115 + actionsQueue.add(idx, action)
     116 + }
     117 + }
     118 + }
     119 + }
    49 120   
    50 121   fun initiate(contents: List<Content>, bundle: List<String>?, completionHandler: AsyncResult<Unit>) {
    51 122   jingleModule.initiateSession(jid, sid, contents, bundle)
    skipped 17 lines
    69 140   .send()
    70 141   }
    71 142   
     143 + private val lock = Lock();
     144 + private var contentCreators = HashMap<String, Content.Creator>();
     145 + fun contentCreator(contentName: String): Content.Creator {
     146 + return lock.withLock {
     147 + return@withLock contentCreators.get(contentName) ?: this.role;
     148 + }
     149 + }
     150 + 
     151 + private fun updateCreators(contents: List<Content>) {
     152 + lock.withLock {
     153 + for (content in contents) {
     154 + if (!contentCreators.containsKey(content.name)) {
     155 + contentCreators.put(content.name, content.creator);
     156 + }
     157 + }
     158 + }
     159 + }
     160 + 
    72 161   fun initiated(contents: List<Content>, bundle: List<String>?) {
    73  - state = State.Initiating
    74  - remoteContents = contents
    75  - remoteBundles = bundle
     162 + lock.withLock {
     163 + updateCreators(contents);
     164 + state = State.Initiating
     165 + remoteContents = contents
     166 + remoteBundles = bundle
     167 + received(Action.ContentSet(SDP(contents, bundle ?: emptyList())))
     168 + }
    76 169   }
    77 170   
    78 171   fun accept() {
    79  - state = State.Accepted
    80  - remoteContents?.let { contents ->
    81  - setRemoteDescription(contents, remoteBundles)
    82  - } ?: jingleModule.sendMessageInitiation(MessageInitiationAction.Proceed(sid), jid)
     172 + lock.withLock {
     173 + state = State.Accepted
     174 + if (initiationType == InitiationType.Message) {
     175 + jingleModule.sendMessageInitiation(MessageInitiationAction.Proceed(sid), jid)
     176 + }
     177 + }
    83 178   }
    84 179   
    85 180   fun accept(contents: List<Content>, bundle: List<String>?, completionHandler: AsyncResult<Unit>) {
     181 + updateCreators(contents);
    86 182   jingleModule.acceptSession(jid, sid, contents, bundle)
    87 183   .response { result ->
    88 184   when {
    skipped 6 lines
    95 191   }
    96 192   
    97 193   fun accepted(by: JID) {
    98  - this.state = State.Accepted
    99  - this.jid = by
     194 + lock.withLock {
     195 + this.state = State.Accepted
     196 + this.jid = by
     197 + }
    100 198   }
    101 199   
    102 200   fun accepted(contents: List<Content>, bundle: List<String>?) {
    103  - this.state = State.Accepted
    104  - remoteContents = contents
    105  - remoteBundles = bundle
    106  - setRemoteDescription(contents, bundle)
     201 + lock.withLock {
     202 + this.state = State.Accepted
     203 + remoteContents = contents
     204 + remoteBundles = bundle
     205 + received(Action.ContentSet(SDP(contents, bundle ?: emptyList())))
     206 + }
     207 + }
     208 + 
     209 + fun sessionInfo(actions: List<Jingle.SessionInfo>) {
     210 + jingleModule.sessionInfo(jid, sid, actions, creatorProvider = this::contentCreator).send();
     211 + }
     212 + 
     213 + fun transportInfo(contentName: String, transport: Transport) {
     214 + val creator = contentCreator(contentName);
     215 + jingleModule.transportInfo(jid, sid, listOf(Content(creator, null, contentName, null, listOf(transport)))).send()
     216 + }
     217 + 
     218 + fun contentModified(action: Jingle.ContentAction, contents: List<Content>, bundle: List<String>?) {
     219 + val sdp = SDP(contents, bundle ?: emptyList());
     220 + received(Action.ContentApply(action, sdp));
    107 221   }
    108 222   
    109  - @Suppress("unused")
    110  - fun decline() {
    111  - terminate(reason = TerminateReason.Decline)
     223 + fun sessionInfoReceived(info: List<Jingle.SessionInfo>) {
     224 + received(Action.SessionInfo(info));
     225 + }
     226 + 
     227 + fun addCandidate(candidate: Candidate, contentName: String) {
     228 + received(Action.TransportAdd(candidate, contentName));
    112 229   }
    113 230   
    114 231   override fun terminate(reason: TerminateReason) {
    115  - val oldState = state
    116  - if (oldState == State.Terminated) {
    117  - return
     232 + var oldState = State.Terminated;
     233 + if (!lock.withLock {
     234 + oldState = state
     235 + if (oldState == State.Terminated) {
     236 + return@withLock false;
     237 + }
     238 + state = State.Terminated
     239 + return@withLock true;
     240 + }) {
     241 + return;
    118 242   }
    119  - state = State.Terminated
    120 243   if (initiationType == InitiationType.Iq || oldState == State.Accepted) {
    121 244   jingleModule.terminateSession(jid, sid, reason)
    122 245   .send()
    skipped 5 lines
    128 251   }
    129 252   
    130 253   fun terminated() {
    131  - if (state == State.Terminated) {
    132  - return
     254 + if (!lock.withLock {
     255 + if (state == State.Terminated) {
     256 + return@withLock false
     257 + }
     258 + state = State.Terminated
     259 + return@withLock true;
     260 + }
     261 + ) {
     262 + return;
    133 263   }
    134  - state = State.Terminated
    135 264   terminateSession()
    136 265   }
    137 266   
    138 267   @Suppress("MemberVisibilityCanBePrivate")
    139 268   protected fun terminateSession() {
    140  - sessionManager.close(this)
     269 + terminateFunction(this);
     270 + //sessionManager.close(this)
    141 271   }
    142 272  
    143  - @Suppress("unused")
    144  - fun sendCandidate(contentName: String, creator: Content.Creator, transport: Transport) {
    145  - jingleModule.transportInfo(jid, sid, listOf(Content(creator, contentName, null, listOf(transport))))
    146  - .send()
    147  - }
    148 273  }
  • halcyon-core/src/commonMain/kotlin/tigase/halcyon/core/xmpp/modules/jingle/Content.kt
    ■ ■ ■ ■ ■ ■
    skipped 21 lines
    22 22  import kotlin.jvm.JvmStatic
    23 23   
    24 24  class Content(
    25  - val creator: Creator, val name: String, val description: Description?, val transports: List<Transport>,
     25 + val creator: Creator, val senders: Senders?, val name: String, val description: Description?, val transports: List<Transport>,
    26 26  ) {
    27 27   
    28 28   enum class Creator {
    29 29   
    30  - Initiator,
    31  - Responder
     30 + initiator,
     31 + responder;
     32 + 
     33 + }
     34 + 
     35 + enum class Senders {
     36 + none,
     37 + both,
     38 + initiator,
     39 + responder;
     40 + 
     41 + fun streamType(localRole: Creator, direction: SDPDirection): SDP.StreamType {
     42 + return when (this) {
     43 + none -> SDP.StreamType.inactive
     44 + both -> SDP.StreamType.sendrecv
     45 + initiator -> when(direction) {
     46 + SDPDirection.outgoing -> if (localRole == Creator.initiator) {
     47 + SDP.StreamType.sendonly
     48 + } else {
     49 + SDP.StreamType.recvonly
     50 + }
     51 + SDPDirection.incoming -> if (localRole == Creator.responder) {
     52 + SDP.StreamType.sendonly
     53 + } else {
     54 + SDP.StreamType.recvonly
     55 + }
     56 + }
     57 + responder -> when(direction) {
     58 + SDPDirection.outgoing -> if (localRole == Creator.responder) {
     59 + SDP.StreamType.sendonly
     60 + } else {
     61 + SDP.StreamType.recvonly
     62 + }
     63 + SDPDirection.incoming -> if (localRole == Creator.initiator) {
     64 + SDP.StreamType.sendonly
     65 + } else {
     66 + SDP.StreamType.recvonly
     67 + }
     68 + }
     69 + }
     70 + }
    32 71   }
    33 72   
    34 73   fun toElement(): Element {
    35 74   return element("content") {
    36 75   attribute("name", name)
    37 76   attribute("creator", creator.name)
     77 + senders?.let { attribute("senders", it.name) }
    38 78   description?.let { addChild(it.toElement()) }
    39 79   transports.forEach { addChild(it.toElement()) }
    40 80   }
    skipped 10 lines
    51 91   ?.let { Description.parse(it) }
    52 92   val transports = el.children.map { Transport.parse(it) }
    53 93   .filterNotNull()
    54  - return Content(creator, name, description, transports)
     94 + val senders = el.attributes["senders"]?.let { Senders.valueOf(it) }
     95 + return Content(creator, senders, name, description, transports)
    55 96   }
    56 97   return null
    57 98   }
    skipped 2 lines
  • halcyon-core/src/commonMain/kotlin/tigase/halcyon/core/xmpp/modules/jingle/JingleModule.kt
    ■ ■ ■ ■ ■ ■
    skipped 25 lines
    26 26  import tigase.halcyon.core.modules.XmppModule
    27 27  import tigase.halcyon.core.requests.RequestBuilder
    28 28  import tigase.halcyon.core.xml.Element
     29 +import tigase.halcyon.core.xml.ElementBuilder
    29 30  import tigase.halcyon.core.xmpp.*
    30 31  import tigase.halcyon.core.xmpp.stanzas.IQ
    31 32  import tigase.halcyon.core.xmpp.stanzas.IQType
    skipped 2 lines
    34 35   
    35 36  class Jingle {
    36 37   
     38 + enum class ContentAction {
     39 + Add,
     40 + Accept,
     41 + Remove,
     42 + Modify;
     43 + 
     44 + companion object {
     45 + fun from(action: Action): ContentAction? = when (action) {
     46 + Action.ContentAdd -> Add
     47 + Action.ContentAccept -> Accept
     48 + Action.ContentRemove -> Remove
     49 + Action.ContentModify -> Modify
     50 + else -> null;
     51 + }
     52 + }
     53 + 
     54 + fun jingleAction(): Action = when (this) {
     55 + Add -> Action.ContentAdd
     56 + Accept -> Action.ContentAccept
     57 + Remove -> Action.ContentRemove
     58 + Modify -> Action.ContentModify
     59 + }
     60 + }
     61 + 
    37 62   interface Session {
    38 63   
    39 64   enum class State {
    skipped 11 lines
    51 76   
    52 77   interface SessionManager {
    53 78   
    54  - fun activateSessionSid(account: BareJID, with: JID): String?
     79 + fun sessionInitiated(context: Context, jid: JID, sid: String, contents: List<Content>, bundle: List<String>?)
     80 + 
     81 + @Throws(XMPPException::class)
     82 + fun sessionAccepted(context: Context, jid: JID, sid: String, contents: List<Content>, bundle: List<String>?)
     83 + 
     84 + fun sessionTerminated(context: Context, jid: JID, sid: String)
     85 + 
     86 + @Throws(XMPPException::class)
     87 + fun transportInfo(context: Context, jid: JID, sid: String, contents: List<Content>)
     88 + 
     89 + fun messageInitiation(context: Context, fromJid: JID, action: MessageInitiationAction)
     90 + 
     91 + fun contentModified(context: Context, jid: JID, sid: String, action: ContentAction, contents: List<Content>, bundle: List<String>?)
     92 + 
     93 + fun sessionInfo(context: Context, jid: JID, sid: String, info: List<SessionInfo>)
    55 94   }
     95 + 
     96 + sealed class SessionInfo {
     97 + class Active(): SessionInfo()
     98 + class Hold(): SessionInfo()
     99 + class Unhold(): SessionInfo()
     100 + class Mute(val contentName: String?): SessionInfo()
     101 + class Unmute(val contentName: String?): SessionInfo()
     102 + class Ringing(): SessionInfo()
     103 + 
     104 + companion object {
     105 + val XMLNS = "urn:xmpp:jingle:apps:rtp:info:1";
     106 + fun parse(el: Element): SessionInfo? {
     107 + if (!XMLNS.equals(el.xmlns)) return null;
     108 + return when (el.name) {
     109 + "active" -> Active()
     110 + "hold" -> Hold()
     111 + "unhold" -> Unhold()
     112 + "mute" -> Mute(el.attributes.get("name"))
     113 + "unmute" -> Unmute(el.attributes.get("name"))
     114 + "ringing" -> Ringing()
     115 + else -> null
     116 + }
     117 + }
     118 + }
     119 + 
     120 + val name: String = when (this) {
     121 + is Active -> "active"
     122 + is Hold -> "hold"
     123 + is Unhold -> "unhold"
     124 + is Mute -> "mute"
     125 + is Unmute -> "unmute"
     126 + is Ringing -> "ringing"
     127 + }
     128 + 
     129 + fun element(creatorProvider: (String)->Content.Creator): Element {
     130 + val el = ElementBuilder.create(name, XMLNS);
     131 + when (this) {
     132 + is Mute -> {
     133 + this.contentName?.let {
     134 + el.attribute("creator", creatorProvider(it).name)
     135 + el.attribute("name", it)
     136 + }
     137 + }
     138 + is Unmute -> {
     139 + this.contentName?.let {
     140 + el.attribute("creator", creatorProvider(it).name)
     141 + el.attribute("name", it)
     142 + }
     143 + }
     144 + else -> {}
     145 + }
     146 + return el.build();
     147 + }
     148 + }
     149 + 
    56 150  }
    57 151   
    58 152  @HalcyonConfigDsl
    skipped 38 lines
    97 191   
    98 192   private fun processIq(iq: Element) {
    99 193   if (iq.attributes["type"] != "set") {
    100  - throw XMPPException(ErrorCondition.FeatureNotImplemented)
     194 + throw XMPPException(ErrorCondition.FeatureNotImplemented, "All messages should be of type 'set'")
    101 195   }
    102 196   
    103  - val jingle = iq.getChildrenNS("jingle", XMLNS) ?: throw XMPPException(ErrorCondition.BadRequest)
    104  - val action = Action.fromValue(iq.attributes["action"] ?: throw XMPPException(ErrorCondition.BadRequest))
    105  - ?: throw XMPPException(ErrorCondition.BadRequest)
    106  - val from = iq.attributes["from"]?.toJID() ?: throw XMPPException(ErrorCondition.BadRequest)
    107  - val sid = (jingle.attributes["sid"] ?: sessionManager.activateSessionSid(context.boundJID!!.bareJID, from))
    108  - ?: throw XMPPException(ErrorCondition.BadRequest)
     197 + val jingle = iq.getChildrenNS("jingle", XMLNS) ?: throw XMPPException(ErrorCondition.BadRequest, "Missing 'jingle' element")
     198 + val action = Action.fromValue(iq.attributes["action"] ?: "") ?: throw XMPPException(
     199 + ErrorCondition.BadRequest,
     200 + "Missing or invalid action attribute"
     201 + )
     202 + 
     203 + val sid = jingle.attributes["sid"] ?: throw XMPPException(ErrorCondition.BadRequest, "Missing sid attribute");
    109 204   
    110  - val initiator = jingle.attributes["initiator"]?.toJID() ?: from
     205 + val from = iq.attributes["from"]?.toJID() ?: throw XMPPException(ErrorCondition.BadRequest, "Missing 'from' attribute")
     206 + val initiator = (jingle.attributes["initiator"]?.toJID() ?: from) ?: throw XMPPException(ErrorCondition.BadRequest, "Missing 'initiator' attribute");
    111 207   
    112 208   val contents = jingle.children.map { Content.parse(it) }.filterNotNull()
    113 209   val bundle =
    skipped 1 lines
    115 211   ?.map { it.attributes["name"] }?.filterNotNull()
    116 212   
    117 213   context.eventBus.fire(JingleEvent(from, action, initiator, sid, contents, bundle))
     214 + 
     215 + when (action) {
     216 + Action.SessionInitiate -> sessionManager.sessionInitiated(this.context, from, sid, contents, bundle);
     217 + Action.SessionAccept -> sessionManager.sessionAccepted(this.context, from, sid, contents, bundle);
     218 + Action.SessionTerminate -> sessionManager.sessionTerminated(this.context, from, sid)
     219 + Action.TransportInfo -> sessionManager.transportInfo(this.context, from, sid, contents);
     220 + Action.ContentAccept, Action.ContentModify, Action.ContentRemove -> {
     221 + val contentAction = Jingle.ContentAction.from(action) ?: throw XMPPException(ErrorCondition.BadRequest, "Invalid action");
     222 + sessionManager.contentModified(context, from, sid, contentAction, contents, bundle);
     223 + }
     224 + Action.SessionInfo -> {
     225 + val infos = jingle.getChildrenNS(Jingle.SessionInfo.XMLNS).map(Jingle.SessionInfo::parse).filterNotNull();
     226 + sessionManager.sessionInfo(context, from, sid, infos);
     227 + }
     228 + else -> throw XMPPException(ErrorCondition.FeatureNotImplemented);
     229 + }
     230 + context.request.iq {
     231 + to = from
     232 + type = IQType.Result
     233 + }
    118 234   }
    119 235   
    120 236   private fun processMessage(message: Element) {
    skipped 70 lines
    191 307   )
    192 308   
    193 309   contents.map { it.toElement() }.forEach { contentEl -> addChild(contentEl) }
    194  - bundle?.let { bundle ->
     310 + bundle?.takeIf { it.isNotEmpty() }?.let { bundle ->
    195 311   addChild(element("group") {
    196 312   xmlns = "urn:xmpp:jingle:apps:grouping:0"
    197 313   attribute("semantics", "BUNDLE")
    skipped 24 lines
    222 338   )
    223 339   
    224 340   contents.map { it.toElement() }.forEach { contentEl -> addChild(contentEl) }
    225  - bundle?.let { bundle ->
     341 + bundle?.takeIf { it.isNotEmpty() }?.let { bundle ->
    226 342   addChild(element("group") {
    227 343   xmlns = "urn:xmpp:jingle:apps:grouping:0"
    228 344   attribute("semantics", "BUNDLE")
    skipped 8 lines
    237 353   }.map { }
    238 354   }
    239 355   
     356 + fun sessionInfo(jid: JID, sid: String, actions: List<Jingle.SessionInfo>, creatorProvider: (String) -> Content.Creator): RequestBuilder<Unit,IQ> {
     357 + return context.request.iq {
     358 + to = jid
     359 + type = IQType.Set
     360 + 
     361 + addChild(element("jingle") {
     362 + xmlns = XMLNS
     363 + attribute("action", Action.SessionInfo.value)
     364 + attribute("sid", sid)
     365 + 
     366 + actions.map { it.element(creatorProvider) }.forEach { this.addChild(it) }
     367 + })
     368 + }.map {}
     369 + }
     370 + 
    240 371   fun terminateSession(jid: JID, sid: String, reason: TerminateReason): RequestBuilder<Unit, IQ> {
    241 372   return context.request.iq {
    242 373   to = jid
    skipped 19 lines
    262 393   attribute("sid", sid)
    263 394   
    264 395   contents.map { it.toElement() }.forEach { contentEl -> addChild(contentEl) }
     396 + })
     397 + }.map { }
     398 + }
     399 + 
     400 + fun contentModify(jid: JID, sid: String, action: Jingle.ContentAction, contents: List<Content>, bundle: List<String>?): RequestBuilder<Unit, IQ> {
     401 + return context.request.iq {
     402 + to = jid
     403 + type = IQType.Set
     404 + 
     405 + addChild(element("jingle") {
     406 + xmlns = XMLNS
     407 + attribute("action", action.jingleAction().value)
     408 + attribute("sid", sid)
     409 + 
     410 + contents.map { it.toElement() }.forEach { contentEl -> addChild(contentEl) }
     411 + 
     412 + bundle?.takeIf { it.isNotEmpty() }?.let {
     413 + addChild(element("group") {
     414 + xmlns = "urn:xmpp:jingle:apps:grouping:0"
     415 + attribute("semantics", "BUNDLE")
     416 + it.forEach {
     417 + this.addChild(element("content") {
     418 + attribute("name", it)
     419 + })
     420 + }
     421 + })
     422 + }
    265 423   })
    266 424   }.map { }
    267 425   }
    skipped 25 lines
  • halcyon-core/src/commonMain/kotlin/tigase/halcyon/core/xmpp/modules/jingle/SDP.kt
    ■ ■ ■ ■ ■
    skipped 16 lines
    17 17   */
    18 18  package tigase.halcyon.core.xmpp.modules.jingle
    19 19   
     20 +import kotlinx.datetime.Clock
    20 21  import tigase.halcyon.core.logger.LoggerFactory
    21 22  import tigase.halcyon.core.xmpp.nextUIDLongs
    22 23  import kotlin.jvm.JvmStatic
    23 24   
    24  -class SDP(val id: String, val contents: List<Content>, private val bundle: List<String>) {
     25 +enum class SDPDirection {
     26 + incoming,
     27 + outgoing
     28 +}
     29 + 
     30 + 
     31 +class SDP(val id: String, val contents: List<Content>, val bundle: List<String>) {
     32 + 
     33 + enum class StreamType {
     34 + inactive,
     35 + sendonly,
     36 + recvonly,
     37 + sendrecv;
     38 + 
     39 + companion object {
     40 + val values = enumValues<StreamType>();
     41 + val SDP_LINES = run {
     42 + val result = HashMap<String, StreamType>()
     43 + for (v in values) {
     44 + result.put("a=${v.name}", v);
     45 + }
     46 + result
     47 + }
     48 + fun from(lines: List<String>): StreamType? {
     49 + return lines.firstOrNull { SDP_LINES.containsKey(it) }?.let { return SDP_LINES[it] }
     50 + }
     51 + }
    25 52   
    26  - fun toString(sid: String): String {
     53 + fun senders(localRole: Content.Creator): Content.Senders = when (this) {
     54 + inactive -> Content.Senders.none
     55 + sendrecv -> Content.Senders.both
     56 + sendonly -> when (localRole) {
     57 + Content.Creator.initiator -> Content.Senders.initiator
     58 + Content.Creator.responder -> Content.Senders.responder
     59 + }
     60 + recvonly -> when (localRole) {
     61 + Content.Creator.responder -> Content.Senders.initiator
     62 + Content.Creator.initiator -> Content.Senders.responder
     63 + }
     64 + }
     65 + }
     66 + 
     67 + constructor(contents: List<Content>, bundle: List<String>) : this("${Clock.System.now().epochSeconds}", contents, bundle) {
     68 + }
     69 + 
     70 + fun toString(sid: String, localRole: Content.Creator, direction: SDPDirection): String {
    27 71   val lines: MutableList<String> = mutableListOf("v=0", "o=- $sid $id IN IP4 0.0.0.0", "s=-", "t=0 0")
    28 72   if (bundle.isNotEmpty()) {
    29 73   val t = listOf("a=group:BUNDLE")
    30 74   lines += ((t + bundle).joinToString(" "))
    31 75   }
    32 76   
    33  - lines += contents.map { it.toSDP() }
     77 + lines += contents.map { it.toSDP(localRole, direction) }
    34 78   
    35 79   return lines.joinToString("\r\n") + "\r\n"
    36 80   }
    skipped 3 lines
    40 84   val log = LoggerFactory.logger("tigase.halcyon.core.xmpp.modules.jingle.SDP")
    41 85   
    42 86   @JvmStatic
    43  - fun parse(sdp: String, creator: Content.Creator): Pair<SDP, String>? {
     87 + fun parse(sdp: String, creatorProvider: (String) -> Content.Creator, localRole: Content.Creator): Pair<SDP, String>? {
    44 88   val parts = sdp.dropLast(2)
    45 89   .split("\r\nm=")
    46 90   val media = parts.drop(1)
    skipped 16 lines
    63 107   
    64 108   log.finest("got session with id=$id and sid=$sid and bundle=$bundle")
    65 109   
    66  - val contents = media.map { Content.parse(it, creator) }
     110 + val contents = media.map { Content.parse(it, creatorProvider, localRole) }
    67 111   log.finest("contents: $contents")
    68 112   
    69 113   return Pair(SDP(id, contents, bundle), sid)
    skipped 4 lines
    74 118   
    75 119  }
    76 120   
    77  -fun Content.Companion.parse(sdp: String, creator: Content.Creator): Content {
     121 +fun Content.Companion.parse(sdp: String, creatorProvider: (String) -> Content.Creator, localRole: Content.Creator): Content {
    78 122   val log = LoggerFactory.logger("tigase.halcyon.core.xmpp.modules.jingle.Content")
    79 123   log.finest("parsing sdp: $sdp")
    80 124   
    skipped 14 lines
    95 139   ?.drop("a=ice-pwd:".length)
    96 140   val ufrag = lines.firstOrNull { it.startsWith("a=ice-ufrag:") }
    97 141   ?.drop("a=ice-ufrag:".length)
     142 + 
     143 + val creator = creatorProvider(name)
    98 144   
    99 145   val payloads = line.subList(3, line.size - 1)
    100 146   .map { id ->
    skipped 60 lines
    161 207   val rtcpMux = lines.indexOf("a=rtcp-mux") >= 0
    162 208   val description = Description(mediaName, null, payloads, null, encryptions, rtcpMux, ssrcs, ssrcGroups, hdrExts)
    163 209   
     210 + val senders: Content.Senders? = SDP.StreamType.from(lines)?.senders(localRole);
     211 + 
    164 212   val candidates = lines.filter { it.startsWith("a=candidate:") }
    165 213   .map { Candidate.parse(it) }
    166 214   .filterNotNull()
    skipped 12 lines
    179 227   ?.firstOrNull()
    180 228   val transport = Transport(ufrag, pwd, candidates, fingerprint)
    181 229   
    182  - return Content(creator, name, description, listOf(transport))
     230 + return Content(creator, senders, name, description, listOf(transport))
    183 231  }
    184 232   
    185  -fun Content.toSDP(): String {
     233 +fun Content.toSDP(localRole: Content.Creator, direction: SDPDirection): String {
    186 234   val lines = mutableListOf<String>()
    187 235   description?.let {
    188 236   lines += "m=${it.media} 1 ${
    skipped 23 lines
    212 260   }
    213 261   }
    214 262   
    215  - lines += "a=sendrecv"
     263 + lines += "a=${(senders ?: Content.Senders.both).streamType(localRole, direction).name}"
    216 264   lines += "a=mid:$name"
    217 265   lines += "a=ice-options:trickle"
    218 266   
    skipped 173 lines
Please wait...
Page is in error, reload to recover