snapshot of https://github.com/jitsi/jicofo.git @ 9ab59aa3d348e282da89da2c9f11ecbecd3f7b79 (2026-09-15)
Some checks failed
Java CI with Maven / Java 21 (push) Failing after 4m41s
Java CI with Maven / Java 17 (push) Failing after 4m42s

This commit is contained in:
upstream-mirror
2026-09-15 16:13:53 +05:00
commit fcbc274d01
275 changed files with 41230 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.impl.protocol.xmpp.log;
import java.util.logging.*;
/**
* Excludes logs coming from {@link PacketDebugger}.
*/
public class ExcludeXmppPackets implements Filter
{
/**
* {@inheritDoc}
*/
@Override
public boolean isLoggable(LogRecord record)
{
return !record.getLoggerName().equals(PacketDebugger.class.getName());
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.impl.protocol.xmpp.log;
import java.util.logging.*;
/**
* Allows only logs coming from {@link PacketDebugger}.
*/
public class IncludeXmppPackets implements Filter
{
/**
* {@inheritDoc}
*/
@Override
public boolean isLoggable(LogRecord record)
{
return record.getLoggerName().equals(PacketDebugger.class.getName());
}
}

View File

@@ -0,0 +1,104 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.impl.protocol.xmpp.log;
import edu.umd.cs.findbugs.annotations.*;
import org.jitsi.utils.logging2.*;
import org.jitsi.xmpp.util.RedactColibri;
import org.jivesoftware.smack.*;
import org.jivesoftware.smack.debugger.*;
import org.jivesoftware.smack.packet.*;
import java.lang.*;
/**
* Implements {@link SmackDebugger} in order to get info about XMPP traffic.
*/
public class PacketDebugger
extends AbstractDebugger
{
/**
* The logger used by this class.
*/
private final static Logger logger = new LoggerImpl(PacketDebugger.class.getName());
/**
* Whether XMPP logging is enabled. We don't want to insert a debugger into Smack when it's not going to actually
* log anything.
*/
public static boolean isEnabled()
{
return logger.isDebugEnabled();
}
/**
* An ID to log to identify the connection.
*/
@NonNull
private final String id;
/**
* Creates new {@link PacketDebugger}
* {@inheritDoc}
*/
@SuppressFBWarnings("ST_WRITE_TO_STATIC_FROM_INSTANCE_METHOD")
public PacketDebugger(XMPPConnection connection, @NonNull String id)
{
super(connection);
this.id = id;
// Change the static value only if an instance is created.
AbstractDebugger.printInterpreted = true;
}
@Override
public void onIncomingStreamElement(TopLevelStreamElement streamElement)
{
logger.debug(() -> "RCV PKT (" + id + "): " + redact(streamElement));
}
@Override
public void onOutgoingStreamElement(TopLevelStreamElement streamElement)
{
logger.debug(() -> "SENT PKT (" + id + "): " + redact(streamElement));
}
private static String redact(TopLevelStreamElement streamElement)
{
return RedactColibri.Companion.redactToken(streamElement.toXML().toString());
}
/**
* {@inheritDoc}
*/
@Override
protected void log(String logMessage)
{
if (logger.isDebugEnabled() && !logMessage.startsWith("RECV (") && !logMessage.startsWith("SENT ("))
{
logger.debug(logMessage);
}
}
/**
* {@inheritDoc}
*/
@Override
protected void log(String logMessage, Throwable throwable)
{
logger.warn("Smack: " + logMessage, throwable);
}
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.impl.protocol.xmpp.log;
import java.io.*;
import java.text.*;
import java.time.*;
import java.util.*;
import java.util.logging.*;
/**
* Logs XMPP traffic to a file if configured in the logging.properties file.
*/
public class XmppPacketsFileHandler extends FileHandler
{
/**
* A constructor.
*/
public XmppPacketsFileHandler()
throws IOException, SecurityException
{
setFilter(new IncludeXmppPackets());
setFormatter(new Formatter());
setLevel(Level.ALL);
}
/**
* The formatter used by {@link XmppPacketsFileHandler}.
*/
static class Formatter extends java.util.logging.Formatter
{
final SimpleDateFormat stampFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
@Override
public String format(LogRecord record)
{
Date when = Date.from(Instant.ofEpochMilli(record.getMillis()));
String msg = record.getMessage();
return String.format("%s: %s%n", stampFormat.format(when), msg);
}
}
}

View File

@@ -0,0 +1,425 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp;
import org.jetbrains.annotations.*;
import org.jitsi.jicofo.*;
import org.jitsi.jicofo.xmpp.muc.*;
import org.jitsi.utils.logging2.*;
import org.jivesoftware.smack.*;
import org.jivesoftware.smack.packet.*;
import org.jivesoftware.smack.tcp.*;
import org.jxmpp.jid.*;
import java.lang.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.*;
/**
* <tt>BaseBrewery</tt> manages the pool of service instances which
* exist in the current session. Does that by joining "brewery" room where
* instances connect to and publish their status in MUC presence.
* <tt>PacketExtension</tt> is the packet extension that will be used
* from that service to publish its presence.
*
* @author Pawel Domas
* @author Damian Minkov
*/
public abstract class BaseBrewery<T extends ExtensionElement>
implements XmppProvider.Listener
{
/**
* The logger
*/
private final Logger logger;
private final ChatRoomListener chatRoomListener = new ChatRoomListenerImpl();
/**
* The MUC JID of the room which this detector will join.
*/
private final EntityBareJid breweryJid;
private final XmppProvider xmppProvider;
/**
* The <tt>ChatRoom</tt> instance for the brewery.
*/
private ChatRoom chatRoom;
/**
* The list of all currently known instances.
*/
protected final List<BrewInstance> instances = new CopyOnWriteArrayList<>();
/**
* The presence extension element name.
*/
private final String extensionElementName;
/**
* The presence extension namespace.
*/
private final String extensionNamespace;
/**
* Reconnect timer. Used to stop the conference if XMPP connection is not restored in a given time.
*/
private Future<?> reconnectTimeout;
/**
* Creates new instance of <tt>BaseBrewery</tt>
* @param breweryJid the MUC JID of the room which this detector will join,
* e.g. {@code roomName@muc-servicename.jabserver.com}.
* @param presenceExtensionElementName the element name of the extension
* which this brewery will look for.
* @param presenceExtensionNamespace the namespace of the extension
* which this brewery will look for.
*/
public BaseBrewery(
@NotNull XmppProvider xmppProvider,
@NotNull EntityBareJid breweryJid,
String presenceExtensionElementName,
String presenceExtensionNamespace,
Logger parentLogger)
{
this.logger = parentLogger.createChildLogger(getClass().getName());
this.xmppProvider = xmppProvider;
this.breweryJid = breweryJid;
logger.addContext("brewery", breweryJid.getLocalpartOrThrow().toString());
this.extensionElementName = presenceExtensionElementName;
this.extensionNamespace = presenceExtensionNamespace;
logger.info("Initialized with JID=" + breweryJid);
}
/**
* Checks whether or not there are any service instances connected.
* @return <tt>true</tt> if there are any instances currently
* connected to the brewery room or <tt>false</tt> otherwise.
*/
public boolean isAnyInstanceConnected()
{
return instances.size() > 0;
}
/**
* Initializes this instance.
*/
public void init()
{
xmppProvider.addListener(this);
maybeStart();
}
/**
* Starts the whole thing if we have XMPP connection up and running.
*/
private void maybeStart()
{
if (chatRoom == null && xmppProvider.getRegistered())
{
start();
}
}
/**
* Stops and releases allocated resources.
*/
public void shutdown()
{
xmppProvider.removeListener(this);
stop();
}
/**
* {@inheritDoc}
*/
@Override
synchronized public void registrationChanged(boolean registered)
{
if (registered)
{
if (this.reconnectTimeout != null)
{
this.reconnectTimeout.cancel(true);
this.reconnectTimeout = null;
}
AbstractXMPPConnection connection = xmppProvider.getXmppConnection();
if (connection instanceof XMPPTCPConnection && !((XMPPTCPConnection) connection).streamWasResumed())
{
// We are not resuming the stream, so we need to stop and start clean
stop();
}
maybeStart();
}
else
{
reconnectTimeout = TaskPools.getScheduledPool().schedule(
this::stop,
XmppConfig.service.getReplyTimeout().toMillis(),
TimeUnit.MILLISECONDS);
}
}
@Override
public void componentsChanged(@NotNull Set<XmppProvider.Component> components)
{
}
/**
* Starts the detector by joining it to the brewery room.
*/
private void start()
{
try
{
chatRoom = xmppProvider.createRoom(breweryJid);
chatRoom.addListener(chatRoomListener);
chatRoom.join();
logger.info("Joined the room.");
}
catch (InterruptedException | SmackException | XMPPException | XmppProvider.RoomExistsException e)
{
logger.error("Failed to create room.", e);
// cleanup on failure so we can retry
if (chatRoom != null)
{
chatRoom.removeListener(chatRoomListener);
chatRoom.leave();
chatRoom = null;
}
// The XMPP connection is already authenticated at this point, so registrationChanged() won't
// fire again. Schedule an explicit retry so a transient failure (e.g. the server not responding
// to a stale MUC leave during reconnect) doesn't permanently break the brewery.
TaskPools.getScheduledPool().schedule(
this::maybeStart,
XmppConfig.service.getReplyTimeout().toMillis(),
TimeUnit.MILLISECONDS);
}
}
/**
* Stops detector by removing listeners and leaving brewery room.
*/
private void stop()
{
try
{
if (chatRoom != null)
{
chatRoom.removeListener(chatRoomListener);
chatRoom.leave();
logger.info("Left the room.");
}
}
finally
{
// even if leave fails we want to cleanup, so we can retry
chatRoom = null;
// Clean up the list of service instances
instances.forEach(this::removeInstance);
}
}
private class ChatRoomListenerImpl extends DefaultChatRoomListener
{
@Override
public void memberJoined(@NotNull ChatRoomMember member)
{
// Process idle or busy
processMemberPresence(member);
}
@Override
public void memberLeft(@NotNull ChatRoomMember member)
{
removeInstanceForMember(member);
}
@Override
public void memberKicked(@NotNull ChatRoomMember member)
{
removeInstanceForMember(member);
}
@Override
public void memberPresenceChanged(@NotNull ChatRoomMember member)
{
// Process idle or busy
processMemberPresence(member);
}
private void removeInstanceForMember(@NotNull ChatRoomMember member)
{
// Process offline status
BrewInstance instance = find(member.getOccupantJid());
if (instance != null)
{
removeInstance(instance);
}
}
}
/**
* Process chat room member status changed. Extract the appropriate
* presence extension and use it to process it further.
* @param member the chat member to process
*
* Temporarily exposed for testing.
*/
public void processMemberPresence(@NotNull ChatRoomMember member)
{
Presence presence = member.getPresence();
if (presence == null)
{
return;
}
@SuppressWarnings("unchecked")
T ext = (T) presence.getExtensionElement(extensionElementName, extensionNamespace);
// if the extension is missing skip processing
if (ext == null)
{
return;
}
processInstanceStatusChanged(member.getOccupantJid(), ext);
}
/**
* Process a MUC member status presence changed. Use the presence extension
* to notify implementors for the change. Stores instance if we do not
* have it cached locally, otherwise just update the new status.
* @param jid the occupant (MUC) JID of the member.
* @param extension the presence extension representing this brewing
* instance status.
*/
protected void processInstanceStatusChanged(@NotNull EntityFullJid jid, @NotNull T extension)
{
BrewInstance instance = find(jid);
if (instance == null)
{
instance = new BrewInstance(jid, extension);
addInstance(instance);
}
else
{
instance.status = extension;
}
logger.debug(() -> "New presence from " + jid + ": " + extension.toXML());
onInstanceStatusChanged(jid, extension);
}
public int getInstanceCount()
{
return instances.size();
}
public int getInstanceCount(@NotNull Predicate<? super BrewInstance> filter)
{
return (int) instances.stream().filter(filter) .count();
}
/**
* Notified for MUC service status update by providing its presence
* extension.
*
* @param jid the brewing instance muc address
* @param status the updated status for that instance
*/
abstract protected void onInstanceStatusChanged(@NotNull EntityFullJid jid, @NotNull T status);
/**
* Finds instance by muc address.
*
* @param jid the address to use while searching for muc instance.
* @return the brewing instance or null if not found.
*/
private BrewInstance find(Jid jid)
{
return instances.stream()
.filter(i -> i.jid.equals(jid))
.findFirst()
.orElse(null);
}
private void addInstance(BrewInstance i)
{
instances.add(i);
logger.info("Added brewery instance: " + i.jid);
}
/**
* Removes an instance from the cache and notifies that instance is going
* offline.
*
* @param i the brewing instance
*/
private void removeInstance(@NotNull BrewInstance i)
{
instances.remove(i);
logger.info("Removed brewery instance: " + i.jid);
notifyInstanceOffline(i.jid);
}
/**
* Notifies that a brewing instance is going offline.
* @param jid the instance muc address
*/
abstract protected void notifyInstanceOffline(@NotNull Jid jid);
/**
* Internal structure for storing information about brewing instances.
*/
@edu.umd.cs.findbugs.annotations.SuppressFBWarnings("URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD")
public class BrewInstance
{
/**
* Eg. "room@muc.server.net/nick"
*/
@NotNull
public final EntityFullJid jid;
/**
* One of {@link ExtensionElement}
*/
@NotNull
public T status;
BrewInstance(@NotNull EntityFullJid jid, @NotNull T status)
{
this.jid = jid;
this.status = status;
}
}
}

View File

@@ -0,0 +1,52 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2020 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo
import org.jitsi.config.JitsiConfig.Companion.legacyConfig
import org.jitsi.config.JitsiConfig.Companion.newConfig
import org.jitsi.metaconfig.config
import org.jitsi.metaconfig.optionalconfig
import java.time.Duration
class JicofoConfig private constructor() {
val localRegion: String? by optionalconfig {
"org.jitsi.jicofo.BridgeSelector.LOCAL_REGION".from(legacyConfig)
"$BASE.local-region".from(newConfig)
}
val enableSctp: Boolean by config {
"$BASE.sctp.enabled".from(newConfig)
}
fun enableSctp() = enableSctp
fun localRegion() = localRegion
// TODO this logically should be in VisitorsConfig, but that's in jicofo and this is needed by ChatRoom
// in jicofo-common
val vnodeJoinLatencyInterval: Duration by config {
"jicofo.visitors.vnode-join-latency-interval".from(newConfig)
}
companion object {
const val BASE = "jicofo"
@JvmField
val config = JicofoConfig()
}
}

View File

@@ -0,0 +1,24 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2025-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo
enum class MediaType {
AUDIO,
VIDEO,
DESKTOP
}

View File

@@ -0,0 +1,40 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2020 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo
import org.jitsi.config.JitsiConfig.Companion.newConfig
import org.jitsi.metaconfig.config
class OctoConfig {
val enabled: Boolean by config {
"jicofo.octo.enabled".from(newConfig)
}
val allowMixedVersions: Boolean by config {
"jicofo.octo.allow-mixed-versions".from(newConfig)
}
val sctpDatachannels: Boolean by config {
"jicofo.octo.sctp-datachannels".from(newConfig)
}
companion object {
@JvmField
val config = OctoConfig()
}
}

View File

@@ -0,0 +1,57 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2020 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings
import org.jitsi.utils.concurrent.CustomizableThreadFactory
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
@SuppressFBWarnings("NP_NONNULL_FIELD_NOT_INITIALIZED_IN_CONSTRUCTOR")
class TaskPools {
companion object {
private val defaultIoPool: ExecutorService =
Executors.newCachedThreadPool(CustomizableThreadFactory("Jicofo Global IO Pool", false))
@JvmStatic
var ioPool: ExecutorService = defaultIoPool
fun resetIoPool() {
ioPool = defaultIoPool
}
private val defaultScheduledPool: ScheduledExecutorService = Executors.newScheduledThreadPool(
3,
CustomizableThreadFactory("Jicofo Global Scheduled Pool", true)
)
@JvmStatic
var scheduledPool: ScheduledExecutorService = defaultScheduledPool
fun resetScheduledPool() {
scheduledPool = defaultScheduledPool
}
@JvmStatic
fun shutdown() {
ioPool.shutdown()
scheduledPool.shutdown()
}
}
}

View File

@@ -0,0 +1,183 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2020 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.codec
import org.jitsi.config.JitsiConfig
import org.jitsi.metaconfig.config
open class CodecConfig(protected val base: String, protected val name: String) {
protected open val enabled: Boolean by config {
"$base.enabled".from(JitsiConfig.newConfig)
}
protected open val pt: Int by config {
"$base.pt".from(JitsiConfig.newConfig)
}
fun enabled() = enabled && pt > 0
@Throws(IllegalStateException::class)
fun pt(): Int = if (enabled()) pt else throw IllegalStateException("$name is not enabled.")
open val enableRemb = false
}
open class RtxCodecConfig(base: String, name: String) : CodecConfig(base, name) {
protected open val rtxPt: Int by config {
"$base.rtx-pt".from(JitsiConfig.newConfig)
}
fun rtxEnabled(): Boolean = enabled() && rtxPt > 0
@Throws(IllegalStateException::class)
fun rtxPt(): Int = if (rtxEnabled()) rtxPt else throw IllegalStateException("RTX is not enabled for $name")
}
private class RtxCodecConfigWithLegacy(
legacyBase: String,
newBase: String,
name: String
) : RtxCodecConfig(newBase, name) {
override val enabled: Boolean by config {
"$legacyBase.ENABLE_$name".from(JitsiConfig.legacyConfig)
"$newBase.enabled".from(JitsiConfig.newConfig)
}
override val pt: Int by config {
"$legacyBase.${name}_PT".from(JitsiConfig.legacyConfig)
"$newBase.pt".from(JitsiConfig.newConfig)
}
override val rtxPt: Int by config {
"$legacyBase.${name}_RTX_PT".from(JitsiConfig.legacyConfig)
"$newBase.rtx-pt".from(JitsiConfig.newConfig)
}
override val enableRemb: Boolean by config {
"$newBase.enable-remb".from(JitsiConfig.newConfig)
}
}
class OpusConfig : CodecConfig("jicofo.codec.audio.opus", "opus") {
private val minptime: Int by config {
"$base.minptime".from(JitsiConfig.newConfig)
}
fun minptime() = minptime
private val useInbandFec: Boolean by config {
"$base.use-inband-fec".from(JitsiConfig.newConfig)
}
fun useInbandFec() = useInbandFec
@JvmField
val red = CodecConfig("$base.red", "red")
}
open class RtpExtensionConfig(base: String) {
open val enabled: Boolean by config {
"$base.enabled".from(JitsiConfig.newConfig)
}
fun enabled() = enabled
val id: Int by config {
"$base.id".from(JitsiConfig.newConfig)
}
fun id() = id
}
private class RtpExtensionConfigWithLegacy(
legacyEnabledName: String,
base: String
) : RtpExtensionConfig(base) {
override val enabled: Boolean by config {
legacyEnabledName.from(JitsiConfig.legacyConfig)
"$base.enabled".from(JitsiConfig.newConfig)
}
}
class Config {
@JvmField
val av1: RtxCodecConfig = RtxCodecConfigWithLegacy(LEGACY_BASE, "jicofo.codec.video.av1", "AV1")
@JvmField
val vp8: RtxCodecConfig = RtxCodecConfigWithLegacy(LEGACY_BASE, "jicofo.codec.video.vp8", "VP8")
@JvmField
val vp9: RtxCodecConfig = RtxCodecConfigWithLegacy(LEGACY_BASE, "jicofo.codec.video.vp9", "VP9")
@JvmField
val h264: RtxCodecConfig = RtxCodecConfigWithLegacy(LEGACY_BASE, "jicofo.codec.video.h264", "H264")
@JvmField
val opus = OpusConfig()
@JvmField
val telephoneEvent = CodecConfig("jicofo.codec.audio.telephone-event", "telephone-event")
@JvmField
val audioLevel = RtpExtensionConfig("jicofo.codec.rtp-extensions.audio-level")
@JvmField
val av1DependencyDescriptor: RtpExtensionConfig =
RtpExtensionConfig("jicofo.codec.rtp-extensions.av1-dependency-descriptor")
val videoLayersAllocation: RtpExtensionConfig =
RtpExtensionConfig("jicofo.codec.rtp-extensions.video-layers-allocation")
@JvmField
val tof: RtpExtensionConfig =
RtpExtensionConfigWithLegacy("$LEGACY_BASE.ENABLE_TOF", "jicofo.codec.rtp-extensions.tof")
@JvmField
val absSendTime: RtpExtensionConfig =
RtpExtensionConfigWithLegacy("$LEGACY_BASE.ENABLE_AST", "jicofo.codec.rtp-extensions.abs-send-time")
@JvmField
val rid: RtpExtensionConfig =
RtpExtensionConfigWithLegacy("$LEGACY_BASE.ENABLE_RID", "jicofo.codec.rtp-extensions.rid")
@JvmField
val tcc = RtpExtensionConfig("jicofo.codec.rtp-extensions.tcc")
@JvmField
val mid = RtpExtensionConfig("jicofo.codec.rtp-extensions.mid")
@JvmField
val videoContentType: RtpExtensionConfig =
RtpExtensionConfigWithLegacy(
"$LEGACY_BASE.ENABLE_VIDEO_CONTENT_TYPE",
"jicofo.codec.rtp-extensions.video-content-type"
)
@JvmField
val framemarking: RtpExtensionConfig =
RtpExtensionConfigWithLegacy("$LEGACY_BASE.ENABLE_FRAMEMARKING", "jicofo.codec.rtp-extensions.framemarking")
val extmapAllowMixed: Boolean by config {
"jicofo.codec.rtp-extensions.extmap-allow-mixed".from(JitsiConfig.newConfig)
}
companion object {
const val LEGACY_BASE = "org.jitsi.jicofo"
@JvmField
val config = Config()
}
}

View File

@@ -0,0 +1,326 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2022-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.codec
import org.jitsi.jicofo.codec.Config.Companion.config
import org.jitsi.xmpp.extensions.jingle.ParameterPacketExtension
import org.jitsi.xmpp.extensions.jingle.PayloadTypePacketExtension
import org.jitsi.xmpp.extensions.jingle.RTPHdrExtPacketExtension
import org.jitsi.xmpp.extensions.jingle.RtcpFbPacketExtension
import java.net.URI
class CodecUtil {
companion object {
fun createVideoPayloadTypeExtensions(
options: OfferOptions = OfferOptions()
): Collection<PayloadTypePacketExtension> = buildList {
if (config.av1.enabled()) {
// a:rtpmap:XXX AV1/90000
val av1 = createPayloadTypeExtension(config.av1.pt(), "AV1", 90000)
av1.addVideoExtensions(options, config.av1)
add(av1)
}
if (config.vp8.enabled()) {
// a=rtpmap:XXX VP8/90000
val vp8 = createPayloadTypeExtension(config.vp8.pt(), "VP8", 90000)
vp8.addVideoExtensions(options, config.vp8)
add(vp8)
}
if (config.h264.enabled()) {
// a=rtpmap:XXX H264/90000
// XXX(gp): older Chrome versions (users have reported 53/55/61)
// fail to enable h264, if the encoding name is in lower case.
val h264 = createPayloadTypeExtension(config.h264.pt(), "H264", 90000)
h264.addVideoExtensions(options, config.h264)
h264.addParameterExtension("profile-level-id", "42e01f;level-asymmetry-allowed=1;packetization-mode=1;")
add(h264)
}
if (config.vp9.enabled()) {
// a=rtpmap:XXX VP9/90000
val vp9 = createPayloadTypeExtension(config.vp9.pt(), "VP9", 90000)
vp9.addVideoExtensions(options, config.vp9)
add(vp9)
}
if (options.rtx) {
if (config.av1.rtxEnabled()) {
// a=rtpmap:XXX rtx/90000
val rtx = createPayloadTypeExtension(config.av1.rtxPt(), "rtx", 90000)
// a=fmtp:XXX apt=YYY (XXX = av1.rtxPt(), YYY = config.av1.pt())
rtx.addParameterExtension("apt", config.av1.pt().toString())
// a=rtcp-fb:XXX ccm fir
rtx.addRtcpFeedbackType(createRtcpFbPacketExtension("ccm", "fir"))
// a=rtcp-fb:XXX nack
rtx.addRtcpFeedbackType(createRtcpFbPacketExtension("nack", null))
// a=rtcp-fb:XXX nack pli
rtx.addRtcpFeedbackType(createRtcpFbPacketExtension("nack", "pli"))
add(rtx)
}
if (config.vp8.rtxEnabled()) {
// a=rtpmap:96 rtx/90000
val rtx = createPayloadTypeExtension(config.vp8.rtxPt(), "rtx", 90000)
// a=fmtp:96 apt=100
rtx.addParameterExtension("apt", config.vp8.pt().toString())
// Chrome doesn't have these when it creates an offer, but they were observed in a hangouts
// conference. Not sure whether they have any effect.
// a=rtcp-fb:96 ccm fir
rtx.addRtcpFeedbackType(createRtcpFbPacketExtension("ccm", "fir"))
// a=rtcp-fb:96 nack
rtx.addRtcpFeedbackType(createRtcpFbPacketExtension("nack", null))
// a=rtcp-fb:96 nack pli
rtx.addRtcpFeedbackType(createRtcpFbPacketExtension("nack", "pli"))
add(rtx)
}
if (config.vp9.rtxEnabled()) {
// a=rtpmap:97 rtx/90000
val rtxVP9 = createPayloadTypeExtension(config.vp9.rtxPt(), "rtx", 90000)
// a=fmtp:97 apt=101
rtxVP9.addParameterExtension("apt", config.vp9.pt().toString())
// Chrome doesn't have these when it creates an offer, but they were observed in a hangouts
// conference. Not sure whether they have any effect.
// a=rtcp-fb:96 ccm fir
rtxVP9.addRtcpFeedbackType(createRtcpFbPacketExtension("ccm", "fir"))
// a=rtcp-fb:96 nack
rtxVP9.addRtcpFeedbackType(createRtcpFbPacketExtension("nack", null))
// a=rtcp-fb:96 nack pli
rtxVP9.addRtcpFeedbackType(createRtcpFbPacketExtension("nack", "pli"))
add(rtxVP9)
}
if (config.h264.rtxEnabled()) {
// a=rtpmap:99 rtx/90000
val rtxH264 = createPayloadTypeExtension(config.h264.rtxPt(), "rtx", 90000)
// a=fmtp:99 apt=107
rtxH264.addParameterExtension("apt", config.h264.pt().toString())
add(rtxH264)
}
}
}
fun createAudioPayloadTypeExtensions(
options: OfferOptions = OfferOptions()
): Collection<PayloadTypePacketExtension> = buildList {
if (config.opus.enabled()) {
// Though RED has a payload type of its own and can be used to encode multiple other payload types, we need
// it to be advertised with the same clock rate as opus, so it's defined here.
// Add the RED payload type before Opus, so that it is the selected codec.
if (config.opus.red.enabled() && options.opusRed) {
val red = createPayloadTypeExtension(config.opus.red.pt(), "red", 48000)
red.channels = 2
// RFC 2198 fmtp line
// Indicates Opus payload type (111) as primary/secondary encoding for RED (112)
// fmtp:112 111/111
red.addParameterExtension(null, config.opus.pt().toString() + "/" + config.opus.pt())
add(red)
}
// a=rtpmap:111 opus/48000/2
val opus = createPayloadTypeExtension(config.opus.pt(), "opus", 48000)
add(opus)
// Opus is always signaled with 2 channels, regardless of 'stereo'
opus.channels = 2
// fmtp:111 minptime=10
opus.addParameterExtension("minptime", config.opus.minptime().toString())
// Avoid double FEC if RED is offered already.
if (config.opus.useInbandFec() && !config.opus.red.enabled()) {
// fmtp:111 useinbandfec=1
opus.addParameterExtension("useinbandfec", "1")
}
if (config.tcc.enabled() && options.tcc) {
// a=rtcp-fb:111 transport-cc
opus.addRtcpFeedbackType(createRtcpFbPacketExtension("transport-cc", null))
}
}
if (config.telephoneEvent.enabled()) {
// rtpmap:126 telephone-event/8000
add(createPayloadTypeExtension(config.telephoneEvent.pt(), "telephone-event", 8000))
}
}
/**
* Create [RTPHdrExtPacketExtension]s for audio based on jicofo's configuration and the given `options`.
*/
fun createAudioRtpHdrExtExtensions(
options: OfferOptions = OfferOptions()
): Collection<RTPHdrExtPacketExtension> = buildList {
if (config.audioLevel.enabled()) {
val ssrcAudioLevel = RTPHdrExtPacketExtension()
ssrcAudioLevel.id = config.audioLevel.id().toString()
ssrcAudioLevel.uri = URI.create("urn:ietf:params:rtp-hdrext:ssrc-audio-level")
add(ssrcAudioLevel)
}
if (config.mid.enabled()) {
// a=extmap:10 rn:ietf:params:rtp-hdrext:sdes:mid
val mid = RTPHdrExtPacketExtension()
mid.id = config.mid.id().toString()
mid.uri = URI.create("urn:ietf:params:rtp-hdrext:sdes:mid")
add(mid)
}
if (config.tcc.enabled() && options.tcc) {
// a=extmap:5 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
val tcc = RTPHdrExtPacketExtension()
tcc.id = config.tcc.id().toString()
tcc.uri = URI.create("http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01")
add(tcc)
}
}
fun createVideoRtpHdrExtExtensions(
options: OfferOptions = OfferOptions()
): Collection<RTPHdrExtPacketExtension> = buildList {
if (config.videoLayersAllocation.enabled) {
// a=extmap:12 http://www.webrtc.org/experiments/rtp-hdrext/video-layers-allocation00
add(
RTPHdrExtPacketExtension().apply {
id = config.videoLayersAllocation.id.toString()
uri = URI.create("http://www.webrtc.org/experiments/rtp-hdrext/video-layers-allocation00")
}
)
}
if (config.av1DependencyDescriptor.enabled()) {
// https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension
val dependencyDescriptorExt = RTPHdrExtPacketExtension()
dependencyDescriptorExt.id = config.av1DependencyDescriptor.id().toString()
dependencyDescriptorExt.uri = URI.create(
"https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension"
)
add(dependencyDescriptorExt)
}
if (config.tof.enabled()) {
// a=extmap:2 urn:ietf:params:rtp-hdrext:toffset
val toOffset = RTPHdrExtPacketExtension()
toOffset.id = config.tof.id().toString()
toOffset.uri = URI.create("urn:ietf:params:rtp-hdrext:toffset")
add(toOffset)
}
if (config.mid.enabled()) {
// a=extmap:10 rn:ietf:params:rtp-hdrext:sdes:mid
val mid = RTPHdrExtPacketExtension()
mid.id = config.mid.id().toString()
mid.uri = URI.create("urn:ietf:params:rtp-hdrext:sdes:mid")
add(mid)
}
if (config.absSendTime.enabled()) {
// a=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
val absSendTime = RTPHdrExtPacketExtension()
absSendTime.id = config.absSendTime.id().toString()
absSendTime.uri = URI.create("http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time")
add(absSendTime)
}
if (config.framemarking.enabled()) {
// a=extmap:XXX urn:ietf:params:rtp-hdrext:framemarking
val framemarking = RTPHdrExtPacketExtension()
framemarking.id = config.framemarking.id().toString()
framemarking.uri = URI.create("http://tools.ietf.org/html/draft-ietf-avtext-framemarking-07")
add(framemarking)
}
if (config.videoContentType.enabled()) {
// http://www.webrtc.org/experiments/rtp-hdrext/video-content-type
val videoContentType = RTPHdrExtPacketExtension()
videoContentType.id = config.videoContentType.id().toString()
videoContentType.uri = URI.create("http://www.webrtc.org/experiments/rtp-hdrext/video-content-type")
add(videoContentType)
}
if (config.rid.enabled()) {
val rtpStreamId = RTPHdrExtPacketExtension()
rtpStreamId.id = config.rid.id.toString()
rtpStreamId.uri = URI.create("urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id")
add(rtpStreamId)
}
if (config.tcc.enabled() && options.tcc) {
// a=extmap:5 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
val tcc = RTPHdrExtPacketExtension()
tcc.id = config.tcc.id().toString()
tcc.uri = URI.create("http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01")
add(tcc)
}
}
private fun createPayloadTypeExtension(id: Int, name: String, clockRate: Int) =
PayloadTypePacketExtension().apply {
setId(id)
this.name = name
clockrate = clockRate
}
fun PayloadTypePacketExtension.addVideoExtensions(options: OfferOptions, codecConfig: CodecConfig) {
// a=rtcp-fb:XXX ccm fir
addRtcpFeedbackType(createRtcpFbPacketExtension("ccm", "fir"))
// a=rtcp-fb:XXX nack
addRtcpFeedbackType(createRtcpFbPacketExtension("nack", null))
// a=rtcp-fb:XXX nack pli
addRtcpFeedbackType(createRtcpFbPacketExtension("nack", "pli"))
if (codecConfig.enableRemb && options.remb) {
// a=rtcp-fb:XXX goog-remb
addRtcpFeedbackType(createRtcpFbPacketExtension("goog-remb", null))
}
if (config.tcc.enabled() && options.tcc) {
// a=rtcp-fb:XXX transport-cc
addRtcpFeedbackType(createRtcpFbPacketExtension("transport-cc", null))
}
}
}
}
/**
* Adds a [ParameterPacketExtension] with a given name and value to this [PayloadTypePacketExtension].
* @return the added extension.
*/
private fun PayloadTypePacketExtension.addParameterExtension(name: String?, value: String) =
ParameterPacketExtension().apply {
this.name = name
this.value = value
addParameter(this)
}
private fun createRtcpFbPacketExtension(type: String, subtype: String?) = RtcpFbPacketExtension().apply {
feedbackType = type
subtype?.let { this.feedbackSubtype = subtype }
}

View File

@@ -0,0 +1,31 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.codec
/**
* Options for an offer that jicofo generates for a specific participant (or for an Octo link).
*/
data class OfferOptions(
var audio: Boolean = true,
var video: Boolean = true,
var sctp: Boolean = true,
var tcc: Boolean = true,
var remb: Boolean = false,
var rtx: Boolean = true,
var opusRed: Boolean = true
)

View File

@@ -0,0 +1,222 @@
/*
* Copyright @ 2021 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.databind.node.ObjectNode
import org.jitsi.utils.MediaType
import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension
import org.jitsi.xmpp.extensions.jingle.ContentPacketExtension
import org.jitsi.xmpp.extensions.jingle.SourceGroupPacketExtension
import java.lang.UnsupportedOperationException
import java.util.concurrent.ConcurrentHashMap
/**
* A container for sources from multiple endpoints, mapped by the ID of the endpoint. This could contain sources for
* an entire conference, or a subset.
*
* This map is thread safe. Reading and iteration are safe because the underlying map is a [ConcurrentHashMap]. The
* only mutating operations that are exposed require a lock on [syncRoot].
*
* Note that the [java.util.Map] mutating operations (e.g. [java.util.Map.put]) are still visible in Java, but they
* are not meant to be used and will result in an exception.
*/
open class ConferenceSourceMap(
/**
* The sources mapped by endpoint ID.
* Note that this primary constructor uses the provided [ConcurrentHashMap], and not a copy, as the underlying map.
*/
private val endpointSourceSets: ConcurrentHashMap<String, EndpointSourceSet> = ConcurrentHashMap()
) : Map<String, EndpointSourceSet> by endpointSourceSets {
/** Constructs a new [ConferenceSourceMap] from the entries in [map] ([map] itself is not reused). */
constructor(map: Map<String, EndpointSourceSet>) : this(ConcurrentHashMap(map))
constructor(vararg entries: Pair<String, EndpointSourceSet>) : this(entries.toMap())
constructor(owner: String, endpointSourceSet: EndpointSourceSet) : this(owner to endpointSourceSet)
constructor(owner: String, source: Source) : this(owner, EndpointSourceSet(source))
constructor(owner: String, contents: List<ContentPacketExtension>) :
this(owner, EndpointSourceSet.fromJingle(contents))
constructor(owner: String, sources: Set<Source>, groups: Set<SsrcGroup>) :
this(owner, EndpointSourceSet(sources, groups))
/** The lock used for write operations to the map. Can and should be used by extending classes. */
protected val syncRoot = Any()
/** Remove the entry associated with [owner]. */
open fun remove(owner: String) = synchronized(syncRoot) {
endpointSourceSets.remove(owner)
}
/** An unmodifiable view of this [ConferenceSourceMap]. */
val unmodifiable by lazy { UnmodifiableConferenceSourceMap(endpointSourceSets) }
fun unmodifiable() = unmodifiable
/** Create a new [ConferenceSourceMap] instance with the same entries as this one. */
fun copy(): ConferenceSourceMap = ConferenceSourceMap(ConcurrentHashMap(endpointSourceSets))
/** Adds the sources of another [ConferenceSourceMap] to this one. */
open fun add(other: ConferenceSourceMap) = synchronized(syncRoot) {
other.endpointSourceSets.forEach { (owner, endpointSourceSet) ->
endpointSourceSets[owner] += endpointSourceSet
}
}
/** Adds [endpointSourceSet] as sources owned by [owner]. */
open fun add(owner: String, endpointSourceSet: EndpointSourceSet) = synchronized(syncRoot) {
endpointSourceSets[owner] += endpointSourceSet
}
/**
* Create a compact JSON representation of this [ConferenceSourceMap]. The JSON is a map of an ID of the owner
* to the compact JSON of its [EndpointSourceSet] (see [EndpointSourceSet.compactJson]).
*/
fun compactJson(): String = synchronized(syncRoot) {
buildString {
append("{")
endpointSourceSets.entries.forEachIndexed { i, entry ->
if (i > 0) append(",")
append(""""${entry.key}":${entry.value.compactJson}""")
}
append("}")
}
}
/** Removes the sources of another [ConferenceSourceMap] from this one. */
open fun remove(other: ConferenceSourceMap) = synchronized(syncRoot) {
other.endpointSourceSets.forEach { (owner, endpointSourceSet) ->
val existing = endpointSourceSets[owner]
if (existing != null) {
val result = existing - endpointSourceSet
// TODO: do we want to allow lingering SsrcGroups? Should we actually remove SsrcGroups when their
// sources are removed?
if (result.isEmpty()) {
endpointSourceSets.remove(owner)
} else {
endpointSourceSets[owner] = result
}
}
}
}
operator fun plus(other: ConferenceSourceMap) = copy().apply { add(other) }
operator fun minus(other: ConferenceSourceMap) = copy().apply { remove(other) }
/**
* Creates a list of [ContentPacketExtension]s that describe the sources in this [ConferenceSourceMap].
*/
fun toJingle(): List<ContentPacketExtension> {
val contents = mutableMapOf<MediaType, ContentPacketExtension>()
forEach { (owner, sourceSet) -> sourceSet.toJingle(contents, owner) }
return contents.values.toList()
}
fun createSourcePacketExtensions(mediaType: MediaType): List<SourcePacketExtension> {
val extensions = mutableListOf<SourcePacketExtension>()
forEach { (owner, endpointSourceSet) ->
endpointSourceSet.sources.filter { it.mediaType == mediaType }.forEach { source ->
extensions.add(source.toPacketExtension(owner))
}
}
return extensions
}
fun createSourceGroupPacketExtensions(mediaType: MediaType): List<SourceGroupPacketExtension> {
val extensions = mutableListOf<SourceGroupPacketExtension>()
forEach { (_, endpointSourceSet) ->
endpointSourceSet.ssrcGroups.filter { it.mediaType == mediaType }.forEach { ssrcGroup ->
extensions.add(ssrcGroup.toPacketExtension())
}
}
return extensions
}
/** Use a kotlin map for easy pretty printing. Inefficient. */
override fun toString(): String = endpointSourceSets.toMap().toString()
/**
* Strip simulcast SSRCs from each entry in the map. Modifies the map in place.
*/
fun stripSimulcast() = map { it.stripSimulcast }
open fun map(transform: (EndpointSourceSet) -> EndpointSourceSet) = synchronized(syncRoot) {
endpointSourceSets.forEach { (owner, sources) ->
val transformed = transform(sources)
if (transformed.isEmpty()) {
endpointSourceSets.remove(owner)
} else {
endpointSourceSets[owner] = transformed
}
}
this
}
/**
* Remove all sources from this [ConferenceSourceMap] unless their media type is in [retain].
*/
fun stripByMediaType(
/** The set of media types to retain, all other media types will be removed */
retain: Set<MediaType>
) = synchronized(syncRoot) {
if (retain.contains(MediaType.AUDIO) && retain.contains(MediaType.VIDEO)) {
// Nothing to strip.
return this
}
map { sources ->
val strippedSources = sources.sources.filter { retain.contains(it.mediaType) }.toSet()
if (strippedSources.isEmpty()) {
EndpointSourceSet.EMPTY
} else {
val strippedSsrcGroups = sources.ssrcGroups.filter { retain.contains(it.mediaType) }.toSet()
EndpointSourceSet(strippedSources, strippedSsrcGroups)
}
}
}
/** Expanded JSON format used for debugging */
fun toJson(): ObjectNode = JsonNodeFactory.instance.objectNode().apply {
synchronized(syncRoot) {
endpointSourceSets.forEach { (owner, sourceSet) -> set<ObjectNode>(owner, sourceSet.toJson()) }
}
}
}
/**
* A read-only version of [ConferenceSourceMap]. Attempts to modify the map will via [add], [remove] or any of the
* standard [java.util.Map] mutating methods will result in an exception.
*/
class UnmodifiableConferenceSourceMap(
endpointSourceSets: ConcurrentHashMap<String, EndpointSourceSet>
) : ConferenceSourceMap(endpointSourceSets) {
constructor(map: Map<String, EndpointSourceSet>) : this(ConcurrentHashMap(map))
override fun add(other: ConferenceSourceMap) =
throw UnsupportedOperationException("add() not supported in unmodifiable view")
override fun add(owner: String, endpointSourceSet: EndpointSourceSet) =
throw UnsupportedOperationException("add() not supported in unmodifiable view")
override fun remove(other: ConferenceSourceMap) =
throw UnsupportedOperationException("remove() not supported in unmodifiable view")
override fun remove(owner: String) =
throw UnsupportedOperationException("remove() not supported in unmodifiable view")
override fun map(transform: (EndpointSourceSet) -> EndpointSourceSet) =
throw UnsupportedOperationException("map() not supported in unmodifiable view")
}

View File

@@ -0,0 +1,283 @@
/*
* Copyright @ 2021 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.databind.node.ObjectNode
import org.jitsi.utils.MediaType
import org.jitsi.utils.MediaType.AUDIO
import org.jitsi.utils.MediaType.VIDEO
import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension
import org.jitsi.xmpp.extensions.jingle.ContentPacketExtension
import org.jitsi.xmpp.extensions.jingle.RtpDescriptionPacketExtension
import org.jitsi.xmpp.extensions.jingle.SourceGroupPacketExtension
import java.lang.IllegalArgumentException
import kotlin.jvm.Throws
/** A set of [Source]s and [SsrcGroup]s, usually associated with an endpoint. */
data class EndpointSourceSet(
val sources: Set<Source> = emptySet(),
val ssrcGroups: Set<SsrcGroup> = emptySet(),
) {
constructor(source: Source) : this(setOf(source))
constructor(ssrcGroup: SsrcGroup) : this(ssrcGroups = setOf(ssrcGroup))
/**
* Whether this set of sources is empty.
* Note: it is not clear whether it makes sense to describe any [SsrcGroup]s without corresponding sources, but
* for this representation we allow it.
*/
fun isEmpty() = sources.isEmpty() && ssrcGroups.isEmpty()
/**
* Whether there are any audio sources in this set.
*/
val hasAudio: Boolean by lazy { sources.any { it.mediaType == AUDIO } }
/**
* Whether there are any video sources in this set.
*/
val hasVideo: Boolean by lazy { sources.any { it.mediaType == VIDEO } }
/**
* Creates a list of Jingle [ContentPacketExtension]s that describe the sources in this [EndpointSourceSet].
*/
fun toJingle(owner: String? = null): List<ContentPacketExtension> = toJingle(mutableMapOf(), owner)
val audioSsrcs: Set<Long> by lazy { getSsrcs(AUDIO) }
val videoSsrcs: Set<Long> by lazy { getSsrcs(VIDEO) }
private fun getSsrcs(mediaType: MediaType) = sources.filter { it.mediaType == mediaType }.map { it.ssrc }.toSet()
/**
* A concise string more suitable for logging. This may be slow for large sets.
*/
override fun toString() = "[audio=$audioSsrcs, video=$videoSsrcs, groups=$ssrcGroups]"
/**
* Get a new [EndpointSourceSet] by stripping all simulcast-related SSRCs and groups except for the first SSRC in
* a simulcast group. This assumes that the first SSRC in the simulcast group and its associated RTX SSRC are the only
* SSRCs that receivers of simulcast streams need to know about, i.e. that jitsi-videobridge uses that SSRC as the
* target SSRC when rewriting streams.
*/
val stripSimulcast: EndpointSourceSet by lazy {
doStripSimulcast()
}
/**
* A compact JSON representation of this [EndpointSourceSet] (optimized for size). This is done ad-hoc instead of
* using e.g. jackson because the format is substantially different than the natural representation of
* [EndpointSourceSet], we only need serialization support (no parsing) and to keep it separated from the main code.
*
* The JSON is an array of up to four elements, with index 0 encoding the video sources, index 1 encoding the video
* source groups, index 2 encoding the audio sources, and index 3 encoding the audio source groups. Trailing empty
* elements may be skipped, e.g. if there are no audio source groups the list will only have 3 elements.
*
* Each element is itself a JSON array of the `compactJson` representation of the sources or SSRC groups.
*
* For example, an [EndpointSourceSet] with video sources 1, 2 grouped in a Fid group and an audio source 3 may be
* encoded as:
* [
* // Array of video sources
* [
* {"s": 1},
* {"s": 2}
* ],
* // Array of video SSRC groups
* [
* ["f", 1, 2]
* ],
* // Array of audio sources
* [
* {"s": 3}
* ]
* // Empty array of audio SSRC groups not present.
* ]
*/
val compactJson: String by lazy {
buildString {
append("[[")
sources.filter { it.mediaType == VIDEO }.forEachIndexed { i, source ->
if (i > 0) append(",")
append(source.compactJson)
}
append("],[")
ssrcGroups.filter { it.mediaType == VIDEO }.forEachIndexed { i, ssrcGroup ->
if (i > 0) append(",")
append(ssrcGroup.compactJson)
}
append("],[")
sources.filter { it.mediaType == AUDIO }.forEachIndexed { i, source ->
if (i > 0) append(",")
append(source.compactJson)
}
append("]")
if (ssrcGroups.any { it.mediaType == AUDIO }) {
append(",[")
ssrcGroups.filter { it.mediaType == AUDIO }.forEachIndexed { i, ssrcGroup ->
if (i > 0) append(",")
append(ssrcGroup.compactJson)
}
append("]")
}
append("]")
}
}
/** Expanded JSON format used for debugging. */
fun toJson(): ObjectNode = JsonNodeFactory.instance.objectNode().apply {
set<ObjectNode>(
"sources",
JsonNodeFactory.instance.arrayNode().apply {
sources.forEach { add(it.toJson()) }
}
)
set<ObjectNode>(
"groups",
JsonNodeFactory.instance.arrayNode().apply {
ssrcGroups.forEach { add(it.toJson()) }
}
)
}
companion object {
/** An [EndpointSourceSet] instance with no sources or source groups */
@JvmField
val EMPTY = EndpointSourceSet()
/**
* Parses a list of Jingle [ContentPacketExtension]s into an [EndpointSourceSet].
*
* @throws IllegalArgumentException if the media type of any of the contents is invalid, or the semantics of
* any of the SSRC groups is invalid.
*/
@JvmStatic
@Throws(IllegalArgumentException::class)
fun fromJingle(contents: List<ContentPacketExtension>): EndpointSourceSet {
val sources = mutableSetOf<Source>()
val ssrcGroups = mutableSetOf<SsrcGroup>()
contents.forEach { content ->
val rtpDesc: RtpDescriptionPacketExtension? =
content.getFirstChildOfType(RtpDescriptionPacketExtension::class.java)
val mediaTypeString = if (rtpDesc != null) rtpDesc.media else content.name
val mediaType = MediaType.parseString(mediaTypeString)
// The previous code looked for [SourcePacketExtension] as children of both the "content" and
// "description" elements, so this is reproduced here. I don't know which one is correct and/or used.
rtpDesc?.let {
rtpDesc.getChildExtensionsOfType(SourcePacketExtension::class.java).forEach { spe ->
sources.add(Source(mediaType, spe))
}
rtpDesc.getChildExtensionsOfType(SourceGroupPacketExtension::class.java).forEach { sgpe ->
ssrcGroups.add(SsrcGroup.fromPacketExtension(sgpe, mediaType))
}
}
content.getChildExtensionsOfType(SourcePacketExtension::class.java).forEach { spe ->
sources.add(Source(mediaType, spe))
}
}
return EndpointSourceSet(sources, ssrcGroups)
}
}
}
/**
* Populates a list of Jingle [ContentPacketExtension]s with extensions that describe the sources in this
* [EndpointSourceSet]. Creates new [ContentPacketExtension] if necessary, returns the [ContentPacketExtension]s
* as a list.
*/
fun EndpointSourceSet.toJingle(
contentMap: MutableMap<MediaType, ContentPacketExtension>,
owner: String?
): List<ContentPacketExtension> {
sources.forEach { source ->
val content = contentMap.computeIfAbsent(source.mediaType) {
ContentPacketExtension().apply { name = source.mediaType.toString() }
}
val rtpDesc = content.getOrCreateRtpDescription()
rtpDesc.addChildExtension(source.toPacketExtension(owner))
}
ssrcGroups.forEach { ssrcGroup ->
val content = contentMap.computeIfAbsent(ssrcGroup.mediaType) {
ContentPacketExtension().apply { name = ssrcGroup.mediaType.toString() }
}
val rtpDesc = content.getOrCreateRtpDescription()
rtpDesc.addChildExtension(ssrcGroup.toPacketExtension())
}
return contentMap.values.toList()
}
/**
* Returns the [RtpDescriptionPacketExtension] child of this [ContentPacketExtension], creating it if it doesn't
* exist.
*/
private fun ContentPacketExtension.getOrCreateRtpDescription() =
getChildExtension(RtpDescriptionPacketExtension::class.java) ?: RtpDescriptionPacketExtension().apply {
media = name
this@getOrCreateRtpDescription.addChildExtension(this)
}
operator fun EndpointSourceSet?.plus(other: EndpointSourceSet?): EndpointSourceSet = when {
this == null && other == null -> EndpointSourceSet.EMPTY
this == null -> other!!
other == null -> this
else -> EndpointSourceSet(
sources + other.sources,
ssrcGroups + other.ssrcGroups
)
}
operator fun EndpointSourceSet.minus(other: EndpointSourceSet): EndpointSourceSet =
EndpointSourceSet(sources - other.sources, ssrcGroups - other.ssrcGroups)
operator fun EndpointSourceSet.plus(sources: Set<Source>) = EndpointSourceSet(this.sources + sources, this.ssrcGroups)
operator fun EndpointSourceSet.plus(source: Source) = EndpointSourceSet(this.sources + source, this.ssrcGroups)
operator fun EndpointSourceSet.plus(ssrcGroup: SsrcGroup) = EndpointSourceSet(this.sources, this.ssrcGroups + ssrcGroup)
private fun EndpointSourceSet.doStripSimulcast(): EndpointSourceSet {
val groupsToRemove = mutableSetOf<SsrcGroup>()
val ssrcsToRemove = mutableSetOf<Long>()
ssrcGroups.filter { it.semantics == SsrcGroupSemantics.Sim }.forEach { simGroup ->
groupsToRemove.add(simGroup)
simGroup.ssrcs.forEachIndexed { index: Int, ssrc: Long ->
if (index > 0) ssrcsToRemove.add(ssrc)
}
}
ssrcGroups.filter { it.semantics == SsrcGroupSemantics.Fid }.forEach { fidGroup ->
if (fidGroup.ssrcs.size != 2) {
throw IllegalArgumentException("Invalid FID group, has ${fidGroup.ssrcs.size} ssrcs.")
}
if (ssrcsToRemove.contains(fidGroup.ssrcs[0])) {
ssrcsToRemove.add(fidGroup.ssrcs[1])
groupsToRemove.add(fidGroup)
}
}
return EndpointSourceSet(
sources.filter { !ssrcsToRemove.contains(it.ssrc) }.toSet(),
ssrcGroups - groupsToRemove
)
}

View File

@@ -0,0 +1,120 @@
/*
* Copyright @ 2021 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import org.jitsi.utils.MediaType
import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension
import org.jitsi.xmpp.extensions.jingle.ParameterPacketExtension
import org.jitsi.xmpp.extensions.jitsimeet.SSRCInfoPacketExtension
import org.jxmpp.jid.impl.JidCreate
/**
* The description of a single source (i.e. an SSRC and its properties from "a=ssrc" lines in SDP).
*/
data class Source(
val ssrc: Long,
/** Audio or Video **/
val mediaType: MediaType,
/** Optional name */
val name: String? = null,
/** Optional msid */
val msid: String? = null,
/** Optional video type (camera or desktop) */
val videoType: VideoType? = null,
/**
* Whether this source is synthetic, i.e. injected by the server (e.g. a translated audio stream). Synthetic
* sources are created by jicofo (not parsed from client Jingle); the flag is carried to the bridge on the
* Colibri2 media-source element (see [org.jitsi.jicofo.bridge.colibri.toColibriMediaSources]).
*/
val synthetic: Boolean = false
) {
/** Create a [Source] from an XML extension. */
constructor(mediaType: MediaType, sourcePacketExtension: SourcePacketExtension) : this(
sourcePacketExtension.ssrc,
mediaType,
sourcePacketExtension.name,
sourcePacketExtension.getChildExtensionsOfType(ParameterPacketExtension::class.java)
.filter { it.name == "msid" }.map { it.value }.firstOrNull(),
if (sourcePacketExtension.videoType == null) null else VideoType.parseString(sourcePacketExtension.videoType)
)
/** Serializes this [Source] to XML */
@JvmOverloads
fun toPacketExtension(
/** An optional JID for the owner of this source to encode in the XML extension. */
owner: String? = null,
encodeMsid: Boolean = true
) = SourcePacketExtension().apply {
ssrc = this@Source.ssrc
name = this@Source.name
if (owner != null) {
addChildExtension(SSRCInfoPacketExtension().apply { this.owner = JidCreate.from(owner) })
}
if (encodeMsid && msid != null) {
addChildExtension(ParameterPacketExtension("msid", msid))
}
this@Source.videoType?.let {
videoType = it.toString()
}
}
/**
* A compact JSON representation of this [Source] (optimized for size). This is done ad-hoc instead of using e.g.
* jackson because we only need serialization support (no parsing) and to keep it separated from the main code.
* Note that we don't encode the media type.
*/
val compactJson: String by lazy {
buildString {
append("""{"s":$ssrc""")
name?.let {
append(""","n":"$it"""")
}
msid?.let {
append(""","m":"$it"""")
}
if (videoType == VideoType.Desktop) {
append(""","v":"d"""")
}
append("}")
}
}
/** Expanded JSON format used for debugging. */
fun toJson() = JsonNodeFactory.instance.objectNode().apply {
put("ssrc", ssrc)
put("media_type", mediaType.toString())
put("name", name ?: "null")
put("msid", msid ?: "null")
put("videoType", videoType.toString())
if (synthetic) {
put("synthetic", true)
}
}
companion object {
/**
* Generates a source name in deterministic format used in the jitsi-meet client. First part is the endpoint id,
* followed by "-", then the first letter of the media type and the index of the source (zero based).
* For example "endpointA-a0" is the first audio source of "endpointA" or "endpointA-v0" for the first video
* source.
*/
fun nameForIdAndMediaType(endpointId: String, mediaType: MediaType, idx: Int): String =
"$endpointId-${(mediaType.toString()[0])}$idx"
}
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright @ 2021 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.databind.node.ObjectNode
import org.jitsi.utils.MediaType
import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension
import org.jitsi.xmpp.extensions.jingle.SourceGroupPacketExtension
import java.lang.IllegalArgumentException
import kotlin.jvm.Throws
/** The description of an SSRC grouping (i.e. an ssrc-group line in SDP) */
data class SsrcGroup(
val semantics: SsrcGroupSemantics,
val ssrcs: List<Long>,
val mediaType: MediaType = MediaType.VIDEO
) {
/** Serializes this [SsrcGroup] to XML */
fun toPacketExtension(): SourceGroupPacketExtension = SourceGroupPacketExtension().apply {
semantics = this@SsrcGroup.semantics.toString()
addSources(
ssrcs.map { SourcePacketExtension().apply { ssrc = it } }.toList()
)
}
/** A concise string more suitable for logging. */
override fun toString(): String = "${semantics.toString().uppercase()}$ssrcs"
companion object {
/**
* Creates an [SsrcGroup] from an XML extension. The semantics is encoded as a string, so needs to be parsed
* into a [SsrcGroupSemantics] (which may fail).
*
* @throws IllegalArgumentException if the XML extension does not have a valid "semantics" field.
*/
@Throws(IllegalArgumentException::class)
fun fromPacketExtension(sgpe: SourceGroupPacketExtension, mediaType: MediaType = MediaType.VIDEO): SsrcGroup {
val semantics = try {
SsrcGroupSemantics.fromString(sgpe.semantics)
} catch (e: NoSuchElementException) {
throw IllegalArgumentException("Invalid ssrc-group semantics: ${sgpe.semantics}")
}
return SsrcGroup(semantics, sgpe.sources.map { it.ssrc }.toList(), mediaType)
}
}
/**
* A compact JSON representation of this [SsrcGroup] (optimized for size). This is done ad-hoc instead of using e.g.
* jackson because we only need serialization support (no parsing) and to keep it separated from the main code.
*
* The JSON contains a list with the first element encoding the semantics ("s" for Sim, "f" for Fid), and the rest
* of the elements encoding the SSRCs. For example a simulcast group for SSRCs 1, 2, 3 is encoded as:
* ["s", 1, 2, 3]
*/
val compactJson: String by lazy {
buildString {
append("[")
when (semantics) {
SsrcGroupSemantics.Sim -> append("\"s\"")
SsrcGroupSemantics.Fid -> append("\"f\"")
}
ssrcs.forEach { append(",$it") }
append("]")
}
}
/** Expanded JSON format used for debugging. */
fun toJson(): ObjectNode = JsonNodeFactory.instance.objectNode().apply {
put("semantics", semantics.toString())
put("media_type", mediaType.toString())
set<ObjectNode>("ssrcs", JsonNodeFactory.instance.arrayNode().apply { ssrcs.forEach { add(it) } })
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright @ 2021 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import kotlin.jvm.Throws
/** The supported semantics for [SsrcGroup] */
enum class SsrcGroupSemantics {
/** Simulcast. */
Sim,
/** Flow ID, used for RTX. */
Fid;
override fun toString() = super.toString().uppercase()
companion object {
/** Parse a string case-insensitively. */
@Throws(NoSuchElementException::class)
fun fromString(str: String): SsrcGroupSemantics =
values().first { it.toString().lowercase() == str.lowercase() }
}
}

View File

@@ -0,0 +1,358 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import org.jitsi.utils.MediaType
import org.jitsi.utils.logging2.createLogger
import java.lang.IllegalStateException
/**
* An extension of [ConferenceSourceMap] with an API for addition ([tryToAdd]) and removal ([tryToRemove]) of sources
* with validation. Note that the standard [add] and [remove] APIs are still available and do NOT perform validation.
*
* Validation means that the addition of sources does not lead to a conflict of SSRC or MSID with another endpoint
* in the conference, and that the addition/removal operations leave the endpoint's resulting set of sources valid.
*
* A set of sources is valid if there are no conflicts in SSRC or MSID, all sources within a group have the same MSID,
* all SSRCs within a group have a corresponding source, and other similar checks pass. See [validateEndpointSourceSet].
*
* This implementation inherits the thread safety characteristics of [ConferenceSourceMap].
*/
class ValidatingConferenceSourceMap(
private val maxSsrcsPerUser: Int,
private val maxSsrcGroupsPerUser: Int
) : ConferenceSourceMap() {
val logger = createLogger()
/**
* Maps an SSRC to the JID of the endpoint that owns it. Used to detect cross-endpoint conflicts efficiently.
*
* Note that since the value is nullable [Map.containsKey] must be used when checking for the existence of an entry.
*/
private val ssrcToOwnerMap = mutableMapOf<Long, String>()
/**
* Maps an MSID to the JID of the endpoint that owns it. Used to detect cross-endpoint conflicts efficiently.
*
* Note that since the value is nullable [Map.containsKey] must be used when checking for the existence of an entry.
*/
private val msidToOwnerMap = mutableMapOf<String, String>()
/**
* Attempts to add [sourcesToAdd] as sources owned by [owner]. The attempt is successful if the addition does not
* introduce any conflicts in SSRC/MSID and the resulting set of sources for [owner] is valid (see
* [validateEndpointSourceSet]).
*
* If the attempt is successful, the sources are added to [owner]'s sources and the accepted sources are returned.
* The accepted sources might differ from [sourcesToAdd] in their SSRC groups, because empty and duplicate groups
* are silently ignored.
*
* If the attempt is unsuccessful, a [ValidationFailedException] is thrown and the state of the map remains
* unchanged.
*
* @throws ValidationFailedException if the attempt to add sources is unsuccessful. The state of the map remains
* unchanged.
* @return The sources that have been accepted and added.
*/
@Throws(ValidationFailedException::class)
fun tryToAdd(owner: String, sourcesToAdd: EndpointSourceSet): EndpointSourceSet = synchronized(syncRoot) {
val existingSourceSet = this[owner] ?: EndpointSourceSet.EMPTY
// Check for validity of the new SSRCs, and conflicts with other endpoints.
sourcesToAdd.sources.forEach { source ->
if (source.ssrc <= 0 || source.ssrc >= 0x1_0000_0000) {
throw InvalidSsrcException(source.ssrc)
}
if (ssrcToOwnerMap.containsKey(source.ssrc)) {
// Adding the same source for the same endpoint is also invalid.
throw SsrcAlreadyUsedException(source.ssrc)
}
source.msid?.let { msid ->
if (msidToOwnerMap.containsKey(msid) && msidToOwnerMap[msid] != owner) {
throw MsidConflictException(msid)
}
}
if (existingSourceSet.sources.size + sourcesToAdd.sources.size > maxSsrcsPerUser) {
throw SsrcLimitExceededException(maxSsrcsPerUser)
}
}
// If we rejected any of the SSRCs we'd throw above. We accepted them all.
val acceptedSources = sourcesToAdd.sources
val resultingSources = existingSourceSet.sources + acceptedSources
val resultingSsrcs = resultingSources.map { it.ssrc }.toSet()
val acceptedGroups = mutableSetOf<SsrcGroup>()
sourcesToAdd.ssrcGroups.forEach {
// We just ignore empty groups and groups which have been signaled multiple times.
when {
it.ssrcs.isEmpty() -> logger.info("Empty group signaled, ignoring.")
existingSourceSet.ssrcGroups.contains(it) -> logger.info("Duplicate group signaled, ignoring.")
!resultingSsrcs.containsAll(it.ssrcs) -> throw GroupContainsUnknownSourceException(it.ssrcs)
else -> acceptedGroups.add(it)
}
}
if (existingSourceSet.ssrcGroups.size + acceptedGroups.size > maxSsrcGroupsPerUser) {
throw SsrcGroupLimitExceededException(maxSsrcGroupsPerUser)
}
val resultingSourceSet = EndpointSourceSet(resultingSources, existingSourceSet.ssrcGroups + acceptedGroups)
validateEndpointSourceSet(resultingSourceSet)
val acceptedSourceSet = EndpointSourceSet(acceptedSources, acceptedGroups)
add(ConferenceSourceMap(owner to acceptedSourceSet))
return acceptedSourceSet
}
/**
* Attempts to remove [sourcesToRemove] as sources owned by [owner]. The attempt is successful if all referenced
* sources and groups are indeed owned by [owner], and the resulting set of sources for [owner] is valid (see
* [validateEndpointSourceSet]).
*
* If the attempt is successful, the sources are removed from [owner]'s sources and the set of removed sources is
* returned. The set of removed sources might differ from [sourcesToRemove] because:
* 1. We only match a source's SSRC, so a source added with parameters (MSID, etc) can be removed by simply
* referencing the SSRC.
* 2. We automatically remove any groups that contain any of the removed sources.
*
* If the attempt is unsuccessful, a [ValidationFailedException] is thrown and the state of the map remains
* unchanged.
*
* @throws ValidationFailedException if the attempt to remove sources is unsuccessful. The state of the map remains
* unchanged.
* @return The sources that have been removed.
*/
@Throws(ValidationFailedException::class)
fun tryToRemove(owner: String, sourcesToRemove: EndpointSourceSet): EndpointSourceSet = synchronized(syncRoot) {
if (sourcesToRemove.isEmpty()) return EndpointSourceSet.EMPTY
val existingSources = this[owner]
if (existingSources == null || existingSources.isEmpty()) {
throw SourceDoesNotExistException()
}
val sourcesAcceptedToBeRemoved = mutableSetOf<Source>()
sourcesToRemove.sources.forEach { source ->
// Be lenient and allow sources to be removed without matching the original parameters (media type, msid).
val existingSource = existingSources.sources.find { it.ssrc == source.ssrc }
?: throw SourceDoesNotExistException(source.ssrc)
sourcesAcceptedToBeRemoved.add(existingSource)
}
val ssrcsAcceptedToRemove = sourcesAcceptedToBeRemoved.map { it.ssrc }
if (!existingSources.ssrcGroups.containsAll(sourcesToRemove.ssrcGroups)) {
throw SourceGroupDoesNotExistException()
}
val groupsAcceptedToBeRemoved = mutableSetOf(*sourcesToRemove.ssrcGroups.toTypedArray())
// Also automatically remove groups some of whose sources are removed.
existingSources.ssrcGroups.forEach { existingGroup ->
if (existingGroup.ssrcs.any { ssrcsAcceptedToRemove.contains(it) }) {
groupsAcceptedToBeRemoved.add(existingGroup)
}
}
val resultingEndpointSourceSet = EndpointSourceSet(
existingSources.sources - sourcesAcceptedToBeRemoved,
existingSources.ssrcGroups - groupsAcceptedToBeRemoved
)
validateEndpointSourceSet(resultingEndpointSourceSet)
val acceptedSourceSet = EndpointSourceSet(sourcesAcceptedToBeRemoved, groupsAcceptedToBeRemoved)
remove(ConferenceSourceMap(owner to acceptedSourceSet))
return acceptedSourceSet
}
/** Override [add] to keep the additional [ssrcToOwnerMap] and [msidToOwnerMap] maps updated. */
override fun add(other: ConferenceSourceMap) = synchronized(syncRoot) {
super.add(other).also {
other.forEach { (owner, endpointSourceSet) -> sourceSetAdded(owner, endpointSourceSet) }
}
}
/** Override [add] to keep the additional [ssrcToOwnerMap] and [msidToOwnerMap] maps updated. */
override fun add(owner: String, endpointSourceSet: EndpointSourceSet) = synchronized(syncRoot) {
super.add(owner, endpointSourceSet).also {
sourceSetAdded(owner, endpointSourceSet)
}
}
private fun sourceSetAdded(owner: String, endpointSourceSet: EndpointSourceSet) = synchronized(syncRoot) {
endpointSourceSet.sources.forEach { source ->
ssrcToOwnerMap[source.ssrc] = owner
source.msid?.let {
msidToOwnerMap[it] = owner
}
}
}
/** Override [remove] to keep the additional [ssrcToOwnerMap] and [msidToOwnerMap] maps updated. */
override fun remove(other: ConferenceSourceMap) = synchronized(syncRoot) {
super.remove(other).also {
other.forEach { (owner, ownerRemovedSourceSet) -> sourceSetRemoved(owner, ownerRemovedSourceSet) }
}
}
/** Override [remove] to keep the additional [ssrcToOwnerMap] and [msidToOwnerMap] maps updated. */
override fun remove(owner: String): EndpointSourceSet? = synchronized(syncRoot) {
val ownerRemovedSourceSet = super.remove(owner)
ownerRemovedSourceSet?.let {
sourceSetRemoved(owner, it)
}
return ownerRemovedSourceSet
}
/**
* Update the local maps ([ssrcToOwnerMap] and [msidToOwnerMap] after the removal of a source set.
* @param owner the owner of the removed source set.
* @param endpointSourceSet the source set which has already been removed.
*/
private fun sourceSetRemoved(owner: String, endpointSourceSet: EndpointSourceSet) = synchronized(syncRoot) {
val ownerRemainingSourceSet = this[owner]
endpointSourceSet.sources.forEach { source ->
ssrcToOwnerMap.remove(source.ssrc)
source.msid?.let { sourceMsid ->
if (ownerRemainingSourceSet == null ||
ownerRemainingSourceSet.sources.none { it.msid == sourceMsid }
) {
msidToOwnerMap.remove(sourceMsid)
}
}
}
}
companion object {
/**
* Checks if a given [EndpointSourceSet] is internally valid. The checks include:
* -- FID groups have exactly 2 SSRCs
* -- Grouped sources have an MSID
* -- Sources within a group have the same MSID
* -- There are no MSID conflicts
*/
@Throws(ValidationFailedException::class)
private fun validateEndpointSourceSet(endpointSourceSet: EndpointSourceSet) {
endpointSourceSet.ssrcGroups.forEach { group ->
if (group.ssrcs.isEmpty()) {
throw IllegalStateException("Empty group should have been filtered out.")
}
var groupMsid: String? = null
group.ssrcs.forEach { ssrc ->
// We are doing a linear search in a list, but the list only has sources for one endpoint, so
// its size is limited to [maxSsrcsPerUser].
val source = endpointSourceSet.sources.find { it.ssrc == ssrc }
?: throw IllegalStateException(
"Groups with SSRCs that have no corresponding source should have been filtered out."
)
if (group.semantics == SsrcGroupSemantics.Fid && group.ssrcs.size != 2) {
throw InvalidFidGroupException(group.ssrcs)
}
if (source.msid == null) {
throw RequiredParameterMissingException("msid")
}
if (groupMsid == null) {
groupMsid = source.msid
}
if (source.msid != groupMsid) {
throw GroupMsidMismatchException(group.ssrcs)
}
}
}
// Audio and video sources can have the same MSID.
listOf(MediaType.AUDIO, MediaType.VIDEO).forEach { mediaType ->
// This groups all sources according to the extended group that they belong to, i.e. a SIM group gets
// associated with all of its sources plus the sources in FID groups associated with the SIM group's
// sources. For example given these groups:
// SIM(1, 2, 3)
// FID(1, 4)
// FID(2, 5)
// FID(3, 6)
// FID(111, 222)
// And non-grouped source 333 this will associate the SIM group with sources 1..6, the lonely FID group
// with sources 111 and 222, and a dummy single-source group with source 333.
//
// This is technically O(s*g^2) where s is the number of sources and g is the number of groups. But in
// practice both s and g are very small (at most 20 and 4 respectively).
val grouped: Map<SsrcGroup, List<Source>> =
endpointSourceSet.sources
.filter { it.mediaType == mediaType && it.msid != null }
.groupBy { groupBySimulcastGroup(it.ssrc, endpointSourceSet.ssrcGroups) }
// Here we check for MSID conflicts, i.e. we make sure that each group has a unique MSID.
val msidsSeen = mutableSetOf<String?>()
grouped.values.map { it[0] }.forEach { source ->
if (msidsSeen.contains(source.msid)) throw MsidConflictException(source.msid ?: "NONE")
msidsSeen.add(source.msid)
}
}
}
/** Returns the extended group to which an SSRC belongs. See [validateEndpointSourceSet]. */
private fun groupBySimulcastGroup(ssrc: Long, ssrcGroups: Set<SsrcGroup>): SsrcGroup {
val simGroup = ssrcGroups.find {
it.semantics == SsrcGroupSemantics.Sim && it.ssrcs.contains(ssrc)
}
if (simGroup != null) {
return simGroup
}
val fidGroup = ssrcGroups.find {
it.semantics == SsrcGroupSemantics.Fid && it.ssrcs.contains(ssrc)
}
if (fidGroup != null) {
if (ssrc == fidGroup.ssrcs[1]) {
val simGroup2 = ssrcGroups.find {
it.semantics == SsrcGroupSemantics.Sim && it.ssrcs.contains(fidGroup.ssrcs[0])
}
if (simGroup2 != null) {
return simGroup2
}
}
return fidGroup
}
// Dummy group just for this source.
return SsrcGroup(SsrcGroupSemantics.Sim, listOf(ssrc))
}
}
}
sealed class ValidationFailedException(message: String?) : Exception(message)
class InvalidSsrcException(ssrc: Long) : ValidationFailedException("Invalid SSRC: $ssrc.")
class SsrcLimitExceededException(limit: Int) : ValidationFailedException("SSRC limit ($limit) exceeded.")
class SsrcGroupLimitExceededException(limit: Int) : ValidationFailedException("ssrc-group limit ($limit) exceeded.")
class SsrcAlreadyUsedException(ssrc: Long) : ValidationFailedException("SSRC is already used: $ssrc.")
class RequiredParameterMissingException(name: String) : ValidationFailedException(
"Required source parameter '$name' is not present."
)
class GroupMsidMismatchException(ssrcs: List<Long>) : ValidationFailedException(
"SsrcGroup contains sources with different MSIDs: $ssrcs."
)
class MsidConflictException(msid: String) : ValidationFailedException("MSID is already used: $msid.")
class GroupContainsUnknownSourceException(groupSsrcs: List<Long>) : ValidationFailedException(
"An SSRC group contains an SSRC, which hasn't been signaled as a source: $groupSsrcs."
)
class InvalidFidGroupException(groupSsrcs: List<Long>) : ValidationFailedException("Invalid FID group: $groupSsrcs.")
class SourceDoesNotExistException(ssrc: Long? = null) : ValidationFailedException(
"Source does not exist or is not owned by endpoint ${ssrc?.let { "(ssrc=$it)" }}."
)
class SourceGroupDoesNotExistException : ValidationFailedException("Source group does not exist")

View File

@@ -0,0 +1,27 @@
/*
* Copyright @ 2022 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
enum class VideoType {
Camera,
Desktop;
override fun toString() = super.toString().lowercase()
companion object {
fun parseString(s: String?): VideoType = VideoType.values().find { it.name.equals(s, ignoreCase = true) }
?: throw IllegalArgumentException("Invalid VideoType: $s")
}
}

View File

@@ -0,0 +1,40 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2023 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.metrics
import java.lang.management.ManagementFactory
import org.jitsi.jicofo.metrics.JicofoMetricsContainer.Companion.instance as metricsContainer
class GlobalMetrics {
companion object {
@JvmField
val threadCount = metricsContainer.registerLongGauge(
"threads",
"The current number of JVM threads"
)
val xmppDisconnects = metricsContainer.registerCounter(
"xmpp_disconnects",
"The number of times one of the XMPP connections has disconnected."
)
fun update() {
threadCount.set(ManagementFactory.getThreadMXBean().threadCount.toLong())
}
}
}

View File

@@ -0,0 +1,67 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2026 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.metrics
import org.jitsi.jicofo.metrics.JicofoMetricsContainer.Companion.instance as metricsContainer
/**
* Metrics for the in-place ICE restart flow. They live here rather than next to either end of the flow because it
* spans modules: the request comes in in the `jicofo` module (Participant/JitsiMeetConferenceImpl) while the colibri2
* exchange with the bridge happens in `jicofo-selector`.
*
* The naming matches jitsi-videobridge's `ice_restarts_*` metrics, which cover the other half of the same flow.
*/
class IceRestartMetrics {
companion object {
/** A participant asked for an in-place ICE restart via session-info. */
@JvmField
val requested = metricsContainer.registerCounter(
"ice_restarts_requested",
"Number of in-place ICE restarts requested by a participant."
)
/** The bridge's rotated transport was signaled back to the participant in a Jingle transport-info. */
@JvmField
val relayed = metricsContainer.registerCounter(
"ice_restarts_relayed",
"Number of in-place ICE restarts for which the bridge's rotated transport was signaled to the participant."
)
/**
* The bridge answered with the transport it already had, because it did not need to restart (its own
* transport has not connected yet). Nothing was relayed and the participant keeps the session it has, so
* this is neither a completed restart nor a failed one.
*/
@JvmField
val notNeeded = metricsContainer.registerCounter(
"ice_restarts_not_needed",
"Number of in-place ICE restarts for which the bridge kept its existing transport."
)
/**
* An ICE restart did not complete: the request was rejected (disabled, rate-limited, stale bridge-session ID)
* or the bridge's response could not be relayed (no transport in the response, stale generation, participant
* or Jingle session gone).
*/
@JvmField
val failed = metricsContainer.registerCounter(
"ice_restarts_failed",
"Number of in-place ICE restarts that were rejected or could not be completed."
)
}
}

View File

@@ -0,0 +1,38 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2022 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.metrics
import org.jitsi.config.JitsiConfig
import org.jitsi.jicofo.TaskPools
import org.jitsi.metaconfig.config
import org.jitsi.metrics.MetricsContainer
import org.jitsi.metrics.MetricsUpdater
import java.time.Duration
class JicofoMetricsContainer private constructor() : MetricsContainer(namespace = "jitsi_jicofo") {
val metricsUpdater = MetricsUpdater(TaskPools.scheduledPool, updateInterval)
companion object {
private val updateInterval: Duration by config {
"jicofo.metrics.update-interval".from(JitsiConfig.newConfig)
}
@JvmStatic
val instance = JicofoMetricsContainer()
}
}

View File

@@ -0,0 +1,53 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.util
import org.jitsi.utils.ms
import org.jitsi.utils.stats.RateTracker
import java.time.Clock
import java.time.Duration
/** Keep a count of some events which have been triggered to happen within some interval, but which
* may or may not happen, and thus should expire from the count after some time if they have not.
* The intended use case is visitors who have been invited to a visitor ChatRoom.
*/
class PendingCount(
timeout: Duration,
clock: Clock = Clock.systemUTC()
) {
private var occurredEvents = 0L
private var tracker = object : RateTracker(timeout, 100.ms, clock) {
override fun bucketExpired(count: Long) {
occurredEvents = (occurredEvents - count).coerceAtLeast(0)
}
}
@Synchronized
fun eventPending() {
tracker.update(1)
}
@Synchronized
fun eventOccurred() {
occurredEvents = (occurredEvents + 1).coerceAtMost(tracker.getAccumulatedCount())
}
@Synchronized
fun getCount(): Long = tracker.getAccumulatedCount() - occurredEvents
}

View File

@@ -0,0 +1,33 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2020 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.util
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
class SynchronizedDelegate<T>(defaultValue: T, private val syncRoot: Any) : ReadWriteProperty<Any, T> {
private var backingField = defaultValue
override fun getValue(thisRef: Any, property: KProperty<*>): T = synchronized(syncRoot) {
backingField
}
override fun setValue(thisRef: Any, property: KProperty<*>, value: T) = synchronized(syncRoot) {
backingField = value
}
}

View File

@@ -0,0 +1,25 @@
package org.jitsi.jicofo.util
import io.opentelemetry.api.common.Attributes
import org.jitsi.jicofo.xmpp.muc.ChatRoom
import org.jitsi.jicofo.xmpp.muc.ChatRoomMember
import java.util.Objects
class TracingUtil {
companion object {
@JvmStatic
fun memberAttributes(member: ChatRoomMember): Attributes = Attributes.builder()
.put("member.name", member.name)
.put("member.id", Objects.toString(member.jid))
.put("member.role", member.role.toString())
.put("member.region", Objects.toString(member.region))
.build()
@JvmStatic
fun roomAttributes(room: ChatRoom): Attributes = Attributes.builder()
.put("room.id", Objects.toString(room.roomJid))
.put("room.members", room.memberCount.toString())
.put("room.visitors", room.visitorCount.toString())
.build()
}
}

View File

@@ -0,0 +1,51 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2022 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.util
import java.lang.ref.WeakReference
import java.util.concurrent.ConcurrentHashMap
/**
* Self-cleaning map with weak values.
* TODO: maybe move to jitsi-utils
*/
class WeakValueMap<K, V>(
private val cleanInterval: Int = 100
) {
private val map: MutableMap<K, WeakReference<V>> = ConcurrentHashMap<K, WeakReference<V>>()
private var i = 0
fun get(key: K) = map[key]?.get().also {
maybeClean()
}
fun put(key: K, value: V) {
map[key] = WeakReference(value)
maybeClean()
}
fun containsKey(key: K): Boolean = (map[key]?.get() != null).also {
maybeClean()
}
fun remove(key: K) = map.remove(key)?.get()
fun values(): List<V> {
clean()
return map.values.mapNotNull { it.get() }.toList()
}
private fun maybeClean() = ((i++ % cleanInterval == 0)) && clean()
private fun clean() = map.values.removeIf { it.get() == null }
}

View File

@@ -0,0 +1,137 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp
import org.jitsi.jicofo.xmpp.IqProcessingResult.AcceptedWithResponse
import org.jivesoftware.smack.AbstractXMPPConnection
import org.jivesoftware.smack.XMPPConnection
import org.jivesoftware.smack.iqrequest.AbstractIqRequestHandler
import org.jivesoftware.smack.iqrequest.IQRequestHandler
import org.jivesoftware.smack.packet.ErrorIQ
import org.jivesoftware.smack.packet.IQ
import org.jivesoftware.smack.packet.StanzaError
import java.lang.IllegalArgumentException
/**
* Implements an IQ handler registered with a specific set of [XMPPConnection]s. Hides the Smack-level API and exposes
* just [handleRequest], which uses the specific [IQ] subtype ([T]) and provides the connection on which it was received
* as a parameter.
*/
abstract class AbstractIqHandler<T : IQ>(
/**
* The set of connections to which to register.
*/
connections: Set<AbstractXMPPConnection>,
/**
* The name of the IQ child element for this handler. It must match the element name returned by [T] instances.
*/
elementName: String,
/**
* The namespace of the IQ child element for this handler. It must match the namespace returned by [T] instances.
*/
elementNamespace: String,
iqTypes: Set<IQ.Type> = setOf(IQ.Type.set, IQ.Type.get),
mode: IQRequestHandler.Mode = IQRequestHandler.Mode.sync
) {
/**
* One handler for each (connection, iqType) pair. These are the actual instances registered with the
* [XMPPConnection]s.
*/
private val handlers: List<IQRequestHandlerImpl> = connections.flatMap { connection ->
iqTypes.map { iqType ->
IQRequestHandlerImpl(connection, elementName, elementNamespace, iqType, mode).also {
connection.registerIQRequestHandler(it)
}
}
}
fun shutdown() = handlers.forEach { it.connection.unregisterIQRequestHandler(it) }
/**
* Handle a request that was received on one of the [XMPPConnection]s.
*
* @return A non-null value to be sent as a response (should be either an IQ of type `result`, or an Error), or
* `null` if no response is to be sent immediately. If this returns `null`, the code handling the IQ takes the
* responsibility of (eventually) sending a response. The response, whether returned by the method or sent in
* another way, should have the correct properties for an XMPP response (`from` and `to` swapped, matching `id`,
* and type `result` if the response is an IQ).
*/
abstract fun handleRequest(request: IqRequest<T>): IqProcessingResult
private inner class IQRequestHandlerImpl(
val connection: AbstractXMPPConnection,
elementName: String,
elementNamespace: String,
iqType: IQ.Type,
mode: IQRequestHandler.Mode
) : AbstractIqRequestHandler(elementName, elementNamespace, iqType, mode) {
/**
* Handle a request. The cast can not be checked because the type is erased, but we perform the cast here, so
* any exceptions will occur early and not cause further problems.
*/
@Suppress("UNCHECKED_CAST")
override fun handleIQRequest(iq: IQ): IQ? {
val result = handleRequest(
IqRequest(
iq as? T ?: throw IllegalArgumentException("Unexpected IQ type: ${iq::class}"),
connection
)
)
return when (result) {
is AcceptedWithResponse -> result.response
is IqProcessingResult.AcceptedWithNoResponse -> null
is IqProcessingResult.RejectedWithError -> result.response
is IqProcessingResult.NotProcessed ->
IQ.createErrorResponse(iq, StanzaError.Condition.feature_not_implemented)
}
}
}
}
/** An IQ received on a specific [XMPPConnection] */
data class IqRequest<T : IQ>(val iq: T, val connection: AbstractXMPPConnection)
sealed class IqProcessingResult {
/** The IQ was accepted/handled. The given `response` should be sent as a response. */
class AcceptedWithResponse(val response: IQ) : IqProcessingResult()
/**
* The IQ was accepted/handled, but no response is available (yet). The handler is responsible for eventually
* sending a response by other means.
* */
class AcceptedWithNoResponse : IqProcessingResult()
/** The IQ was handled, but it resulted in an error. The given error `response` should be sent as a response. */
class RejectedWithError(val response: ErrorIQ) : IqProcessingResult() {
constructor(
request: IqRequest<*>,
condition: StanzaError.Condition
) : this(request.iq, condition)
constructor(
iq: IQ,
condition: StanzaError.Condition
) : this(IQ.createErrorResponse(iq, StanzaError.getBuilder(condition).build()))
}
/** The IQ was not handled. */
class NotProcessed : IqProcessingResult()
}

View File

@@ -0,0 +1,94 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2020 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp
enum class Features(val value: String) {
AUDIO("urn:xmpp:jingle:apps:rtp:audio"),
VIDEO("urn:xmpp:jingle:apps:rtp:video"),
/** DTLS/SCTP feature name. */
SCTP("urn:xmpp:jingle:transports:dtls-sctp:1"),
/** RTX (RFC4588) support. */
RTX("urn:ietf:rfc:4588"),
/** RTCP REMB. */
REMB("http://jitsi.org/remb"),
/** Transport-wide congestion control. */
TCC("http://jitsi.org/tcc"),
/** Source name signalling. */
SOURCE_NAMES("http://jitsi.org/source-name"),
SSRC_REWRITING_V1("http://jitsi.org/ssrc-rewriting-1"),
/** The endpoint can demux forwarded media by the RTP sdes:mid header extension (requires SSRC rewriting). */
RTP_MID_DEMUX("http://jitsi.org/rtp-mid-demux"),
RECEIVE_MULTIPLE_STREAMS("http://jitsi.org/receive-multiple-video-streams"),
/** Jingle sources encoded as JSON. */
JSON_SOURCES("http://jitsi.org/json-encoded-sources"),
OPUS_RED("http://jitsi.org/opus-red"),
AUDIO_MUTE("http://jitsi.org/protocol/audio-mute"),
AUDIO_TRANSLATION("http://jitsi.org/protocol/audio-translation"),
// The ones below are not used in jicofo, but are defined here to improve the xmpp-caps statistics and avoid logs
// about unknown features.
JIBRI("http://jitsi.org/protocol/jibri"),
JIGASI("http://jitsi.org/protocol/jigasi"),
LIPSYNC("http://jitsi.org/meet/lipsync"),
E2EE("https://jitsi.org/meet/e2ee"),
XMPP_CAPS("http://jabber.org/protocol/caps"),
JINGLE("urn:xmpp:jingle:1"),
JINGLE_RTP("urn:xmpp:jingle:apps:rtp:1"),
JINGLE_ICE("urn:xmpp:jingle:transports:ice-udp:1"),
DTLS("urn:xmpp:jingle:apps:dtls:0"),
RTCPMUX("urn:ietf:rfc:5761"),
BUNDLE("urn:ietf:rfc:5888"),
RAYO("urn:xmpp:rayo:client:1"),
REMOTECONTROL("http://jitsi.org/meet/remotecontrol"),
// Supports handling "start muted" via room metadata (instead of via jingle).
START_MUTED_RMD("http://jitsi.org/start-muted-room-metadata"),
VISITORS_V1("http://jitsi.org/visitors-1"),
/** The endpoint understands the "jitsi:client-requirements" IQ, used to signal missing capabilities. */
CLIENT_REQUIREMENTS_1("http://jitsi.org/client-requirements-1");
companion object {
val defaultFeatures = setOf(AUDIO, VIDEO, SCTP)
/** Parse a string and silently return null if not recognized. */
fun parseString(s: String): Features? = values().find { it.value == s }
}
}
/**
* The result of a feature discovery (disco#info) for an endpoint.
*/
data class FeatureDiscoveryResult(
val features: Set<Features>,
/**
* Whether the features were actually discovered. When this is false [features] is just an assumed default set,
* and the absence of a feature from it does not mean that the endpoint does not support the feature.
*/
val discovered: Boolean
)
/** Used when the features of an endpoint could not be discovered. */
val featuresNotDiscovered = FeatureDiscoveryResult(Features.defaultFeatures, false)

View File

@@ -0,0 +1,43 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp
import org.jitsi.jicofo.xmpp.jingle.JingleSession
import org.jitsi.xmpp.extensions.jingle.ContentPacketExtension
import org.jitsi.xmpp.extensions.jingle.JingleAction
import org.jitsi.xmpp.extensions.jingle.JingleIQ
import org.jivesoftware.smack.packet.IQ
import org.jxmpp.jid.Jid
fun createSessionInitiate(from: Jid, to: Jid, sid: String, contents: List<ContentPacketExtension>) =
JingleIQ(JingleAction.SESSION_INITIATE, sid).apply {
this.from = from
this.to = to
initiator = from
type = IQ.Type.set
contents.forEach { addContent(it) }
}
fun createTransportReplace(from: Jid, session: JingleSession, contents: List<ContentPacketExtension>) =
JingleIQ(JingleAction.TRANSPORT_REPLACE, session.sid).apply {
this.from = from
this.to = session.remoteJid
initiator = from
type = IQ.Type.set
contents.forEach { addContent(it) }
}

View File

@@ -0,0 +1,132 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2025-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.JsonMappingException
import com.fasterxml.jackson.databind.MapperFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import org.jitsi.jicofo.MediaType
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes(
JsonSubTypes.Type(value = AvModerationMessage::class, name = AvModerationMessage.TYPE),
JsonSubTypes.Type(value = RoomMetadata::class, name = RoomMetadata.TYPE)
)
@JsonIgnoreProperties(ignoreUnknown = true)
sealed class JsonMessage(val type: String) {
companion object {
private val mapper = jacksonObjectMapper().apply {
enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS)
enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION)
}
@JvmStatic
@Throws(JsonProcessingException::class, JsonMappingException::class)
fun parse(string: String): JsonMessage = mapper.readValue(string)
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class AvModerationMessage(
val room: String?,
val enabled: Boolean? = null,
val mediaType: MediaType? = null,
val actor: String? = null,
val whitelists: Map<MediaType, List<String>>? = null
) : JsonMessage(TYPE) {
companion object {
const val TYPE = "av_moderation"
}
}
/**
* The JSON structure included in the MUC config form from the room_metadata prosody module in jitsi-meet. Includes
* only the fields that we need here in jicofo.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
data class RoomMetadata(val metadata: Metadata?) : JsonMessage(TYPE) {
@JsonIgnoreProperties(ignoreUnknown = true)
data class Metadata(
val visitors: Visitors?,
val startMuted: StartMuted?,
val moderators: List<String>?,
val participants: List<String>?,
val recording: Recording?,
val asyncTranscription: Boolean? = null,
val participantsSoftLimit: Int? = null,
val visitorsEnabled: Boolean? = null,
val lobbyEnabled: Boolean? = null,
val transcription: Transcription? = null,
/**
* Per-room live-translation connect config (e.g. a per-customer usage token as an HTTP header,
* delivered to jicofo on the admin-only metadata path). Mirrors [transcription].
*/
val translation: Translation? = null,
/** Aggregated live-translation requests: sender endpoint id -> set of requested language codes. */
val audioTranslationRequests: Map<String, List<String>>? = null
) {
@JsonIgnoreProperties(ignoreUnknown = true)
data class Visitors(
val live: Boolean?,
/**
* The languages the visitors want transcriptions translated into, as a comma-separated list, or an empty
* string when they want none.
*
* Visitors are in a MUC on a visitor node, so their presence is not visible here. Instead each visitor
* node's mod_fmuc collects their requested languages and sends them to the main prosody, which aggregates
* across nodes into this field (mod_visitors_component). This is the same field jigasi reads.
*/
val transcribingLanguages: String? = null,
/** How many visitors are requesting transcription, across all visitor nodes. */
val transcribingCount: Long? = null
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class StartMuted(val audio: Boolean?, val video: Boolean?)
@JsonIgnoreProperties(ignoreUnknown = true)
data class Recording(val isTranscribingEnabled: Boolean?)
@JsonIgnoreProperties(ignoreUnknown = true)
data class Transcription(
val urlParams: Map<String, String>? = null,
val httpHeaders: Map<String, String>? = null
) {
override fun toString(): String = "Transcription(urlParams=${urlParams?.mapValues { "***" }}, " +
"httpHeaders=${httpHeaders?.mapValues { "***" }})"
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class Translation(
val httpHeaders: Map<String, String>? = null
) {
override fun toString(): String = "Translation(httpHeaders=${httpHeaders?.mapValues { "***" }})"
}
}
companion object {
const val TYPE = "room_metadata"
}
}

View File

@@ -0,0 +1,76 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp
import org.jitsi.utils.logging2.LoggerImpl
import org.jivesoftware.smack.AbstractXMPPConnection
import org.jivesoftware.smack.SmackException
import org.jivesoftware.smack.XMPPConnection
import org.jivesoftware.smack.packet.IQ
import org.jivesoftware.smack.packet.Stanza
import org.jxmpp.jid.DomainBareJid
import org.jxmpp.jid.Jid
import org.jxmpp.jid.impl.JidCreate
import org.jxmpp.stringprep.XmppStringprepException
private val logger = LoggerImpl("org.jitsi.jicofo.xmpp.Util")
/**
* Reads the original jid as encoded in the resource part by mod_client_proxy, returns the original jid if it format
* is not as expected.
*/
fun parseJidFromClientProxyJid(
/**
* The JID of the client_proxy component.
*/
clientProxy: DomainBareJid?,
/**
* The JID to parse.
*/
jid: Jid
): Jid {
clientProxy ?: return jid
if (clientProxy == jid.asDomainBareJid()) {
jid.resourceOrNull?.let { resource ->
return try {
JidCreate.from(resource.toString())
} catch (e: XmppStringprepException) {
jid
}
}
}
return jid
}
fun XMPPConnection.tryToSendStanza(stanza: Stanza) = try {
sendStanza(stanza)
} catch (e: SmackException.NotConnectedException) {
logger.error("No connection - unable to send packet: ${stanza.toXML()}", e)
} catch (e: InterruptedException) {
logger.error("Failed to send packet: ${stanza.toXML()}", e)
}
@Throws(SmackException.NotConnectedException::class)
fun AbstractXMPPConnection.sendIqAndGetResponse(iq: IQ): IQ? = createStanzaCollectorAndSend(iq).let {
try {
it.nextResult()
} finally {
it.cancel()
}
}

View File

@@ -0,0 +1,69 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2022-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.jitsi.utils.logging2.createLogger
private val jsonMapper = jacksonObjectMapper()
class XmppCapsStats {
companion object {
/**
* Maps a nodeVer string (the "node" and "ver" attributes from a caps extension (XEP-0115) joined by "#") to
* the associated set of features and a counter for the number of participants with that nodeVer.
*/
private val map: MutableMap<String, FeaturesAndCount> = mutableMapOf()
private const val MAX_ENTRIES = 1000
private val logger = createLogger()
@JvmStatic
val stats: ObjectNode
get() = JsonNodeFactory.instance.objectNode().apply {
synchronized(map) {
map.forEach { (nodeVer, e) ->
set<ObjectNode>(nodeVer, e.json())
}
}
}
fun update(nodeVer: String, features: Set<Features>) {
synchronized(map) {
if (map.size < MAX_ENTRIES) {
map.computeIfAbsent(nodeVer) { FeaturesAndCount(features) }.count++
} else {
map[nodeVer]?.let {
it.count++
} ?: logger.warn("Too many entries. Ignoring nodeVer=$nodeVer, features=$features")
}
}
}
}
private class FeaturesAndCount(val features: Set<Features>) {
/** The number of participants seen with this set of features. */
var count = 0
fun json(): ObjectNode = JsonNodeFactory.instance.objectNode().apply {
put("count", count)
set<ArrayNode>("features", jsonMapper.valueToTree(features.map { it.name }))
}
}
}

View File

@@ -0,0 +1,298 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2020 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp
import com.typesafe.config.ConfigObject
import com.typesafe.config.ConfigValue
import org.jitsi.config.JitsiConfig.Companion.legacyConfig
import org.jitsi.config.JitsiConfig.Companion.newConfig
import org.jitsi.metaconfig.ConfigException
import org.jitsi.metaconfig.ConfigException.UnableToRetrieve.NotFound
import org.jitsi.metaconfig.config
import org.jitsi.metaconfig.optionalconfig
import org.jitsi.utils.secs
import org.jxmpp.jid.DomainBareJid
import org.jxmpp.jid.impl.JidCreate
import org.jxmpp.jid.parts.Resourcepart
import java.time.Duration
class XmppConfig private constructor() {
val trustedDomains: List<DomainBareJid> by config {
"jicofo.xmpp.trusted-domains".from(newConfig)
.convertFrom<List<String>> { l -> l.map { JidCreate.domainBareFrom(it) } }
}
val useJitsiJidValidation: Boolean by config {
"jicofo.xmpp.use-jitsi-jid-validation".from(newConfig)
}
companion object {
@JvmField
val service = XmppServiceConnectionConfig()
@JvmField
val client = XmppClientConnectionConfig()
@JvmStatic
val visitors: Map<String, XmppVisitorConnectionConfig> by config {
"jicofo.xmpp.visitors".from(newConfig)
.convertFrom<ConfigObject> { cfg ->
cfg.entries.associate {
val xmppConfig = it.toXmppVisitorConnectionConfig()
Pair(xmppConfig.name, xmppConfig)
}
}
}
@JvmField
val config = XmppConfig()
}
}
interface XmppConnectionConfig {
val enabled: Boolean
val hostname: String
val port: Int
val domain: DomainBareJid
val username: Resourcepart
val resource: Resourcepart
val password: String?
val replyTimeout: Duration
val disableCertificateVerification: Boolean
val useTls: Boolean
val name: String
val xmppDomain: DomainBareJid?
val jid
get() = "$username@$domain"
}
class XmppVisitorConnectionConfig(
override val enabled: Boolean,
override val hostname: String,
override val port: Int,
override val domain: DomainBareJid,
override val username: Resourcepart,
override val resource: Resourcepart,
override val password: String?,
override val replyTimeout: Duration,
override val disableCertificateVerification: Boolean,
override val useTls: Boolean,
override val name: String,
override val xmppDomain: DomainBareJid?,
val conferenceService: DomainBareJid
) : XmppConnectionConfig
private fun MutableMap.MutableEntry<String, ConfigValue>.toXmppVisitorConnectionConfig(): XmppVisitorConnectionConfig {
val name = this.key
val c = value as? ConfigObject ?: throw ConfigException.UnsupportedType("visitors config must be an object")
val hostname = c["hostname"]?.unwrapped()?.toString()
?: throw NotFound("hostname required for visitors config $name")
val domain = c["domain"]?.unwrapped()?.toString() ?: hostname
val xmppDomain = c["xmpp-domain"]?.unwrapped()?.toString()
val username = c["username"]?.unwrapped()?.toString() ?: "focus"
val conferenceService = c["conference-service"]?.unwrapped()?.toString()
?: throw NotFound("conference-service required for visitors config $name")
return XmppVisitorConnectionConfig(
enabled = c["enabled"]?.let { it.unwrapped().toString().toBoolean() } ?: true,
hostname = c["hostname"]?.unwrapped()?.toString()
?: throw NotFound("hostname required for visitors config $name"),
port = c["port"]?.unwrapped()?.toString()?.toInt() ?: 5222,
domain = JidCreate.domainBareFrom(domain),
username = Resourcepart.from(username),
resource = Resourcepart.from(c["resource"]?.unwrapped()?.toString() ?: username),
password = c["password"]?.unwrapped()?.toString(),
replyTimeout = c["reply-timeout"]?.unwrapped() as? Duration ?: 15.secs,
disableCertificateVerification = c["disable-certificate-verification"]?.unwrapped()?.toString()?.toBoolean()
?: false,
useTls = c["use-tls"]?.unwrapped()?.toString()?.toBoolean() ?: true,
name = name,
conferenceService = JidCreate.domainBareFrom(conferenceService),
xmppDomain = if (xmppDomain != null) JidCreate.domainBareFrom(xmppDomain) else null
)
}
class XmppServiceConnectionConfig : XmppConnectionConfig {
override val enabled: Boolean by config {
// If the legacy host is set to anything, the connection is enabled.
"org.jitsi.jicofo.BRIDGE_MUC_XMPP_HOST".from(legacyConfig).convertFrom<String> { true }
"jicofo.xmpp.service.enabled".from(newConfig)
}
override val hostname: String by config {
"org.jitsi.jicofo.BRIDGE_MUC_XMPP_HOST".from(legacyConfig)
"jicofo.xmpp.service.hostname".from(newConfig)
}
override val port: Int by config {
"org.jitsi.jicofo.BRIDGE_MUC_XMPP_PORT".from(legacyConfig)
"jicofo.xmpp.service.port".from(newConfig)
}
override val domain: DomainBareJid by config {
"org.jitsi.jicofo.BRIDGE_MUC_XMPP_USER_DOMAIN".from(legacyConfig).convertFrom<String> {
JidCreate.domainBareFrom(it)
}
"jicofo.xmpp.service.domain".from(newConfig).convertFrom<String> {
JidCreate.domainBareFrom(it)
}
}
override val xmppDomain: DomainBareJid? by optionalconfig {
"jicofo.xmpp.service.xmpp-domain".from(newConfig)
}
override val username: Resourcepart by config {
"org.jitsi.jicofo.BRIDGE_MUC_XMPP_USER".from(legacyConfig).convertFrom<String> {
Resourcepart.from(it)
}
"jicofo.xmpp.service.username".from(newConfig).convertFrom<String> {
Resourcepart.from(it)
}
}
override val resource: Resourcepart by config {
"jicofo.xmpp.service.resource".from(newConfig).convertFrom<String> {
Resourcepart.from(it)
}
}
override val password: String? by optionalconfig {
"org.jitsi.jicofo.BRIDGE_MUC_XMPP_USER_PASS".from(legacyConfig)
"jicofo.xmpp.service.password".from(newConfig)
}
override val replyTimeout: Duration by config {
"jicofo.xmpp.service.reply-timeout".from(newConfig)
}
override val disableCertificateVerification: Boolean by config {
"org.jitsi.jicofo.ALWAYS_TRUST_MODE_ENABLED".from(legacyConfig)
"jicofo.xmpp.service.disable-certificate-verification".from(newConfig)
}
override val useTls: Boolean by config {
"jicofo.xmpp.service.use-tls".from(newConfig)
}
override fun toString(): String = "XmppServiceConnectionConfig[hostname=$hostname, port=$port, username=$username]"
override val name = "service"
}
class XmppClientConnectionConfig : XmppConnectionConfig {
override val enabled: Boolean by config {
LEGACY_HOSTNAME_PROPERTY_NAME.from(legacyConfig).convertFrom<String> { true }
"jicofo.xmpp.client.enabled".from(newConfig)
}
override val hostname: String by config {
LEGACY_HOSTNAME_PROPERTY_NAME.from(legacyConfig)
"jicofo.xmpp.client.hostname".from(newConfig)
}
override val port: Int by config {
"org.jitsi.jicofo.XMPP_PORT".from(legacyConfig)
"jicofo.xmpp.client.port".from(newConfig)
}
/**
* This is the domain used for login. Not necessarily the root XMPP domain.
*/
override val domain: DomainBareJid by config {
LEGACY_DOMAIN_PROPERTY_NAME.from(legacyConfig).convertFrom<String> {
JidCreate.domainBareFrom(it)
}
"jicofo.xmpp.client.domain".from(newConfig).convertFrom<String> {
JidCreate.domainBareFrom(it)
}
}
override val username: Resourcepart by config {
LEGACY_USERNAME_PROPERTY_NAME.from(legacyConfig).convertFrom<String> {
Resourcepart.from(it)
}
"jicofo.xmpp.client.username".from(newConfig).convertFrom<String> {
Resourcepart.from(it)
}
}
override val resource: Resourcepart by config {
"jicofo.xmpp.client.resource".from(newConfig).convertFrom<String> {
Resourcepart.from(it)
}
}
override val password: String? by optionalconfig {
LEGACY_PASSWORD_PROPERTY_NAME.from(legacyConfig)
"jicofo.xmpp.client.password".from(newConfig)
}
/**
* This is the top-level domain hosted by the XMPP server (not necessarily the one used for login).
*/
override val xmppDomain: DomainBareJid by config {
LEGACY_XMPP_DOMAIN_PROPERTY_NAME.from(legacyConfig).convertFrom<String> {
JidCreate.domainBareFrom(it)
}
"jicofo.xmpp.client.xmpp-domain".from(newConfig).convertFrom<String> {
JidCreate.domainBareFrom(it)
}
}
val conferenceMucJid: DomainBareJid by config {
"org.jitsi.jicofo.XMPP_MUC_COMPONENT_PREFIX".from(legacyConfig).convertFrom<String> {
JidCreate.domainBareFrom("$it.$xmppDomain")
}
"jicofo.xmpp.client.conference-muc-jid".from(newConfig).convertFrom<String> {
JidCreate.domainBareFrom(it)
}
"default" { JidCreate.domainBareFrom("conference.$xmppDomain") }
}
override val replyTimeout: Duration by config {
"jicofo.xmpp.client.reply-timeout".from(newConfig)
}
override val disableCertificateVerification: Boolean by config {
"org.jitsi.jicofo.ALWAYS_TRUST_MODE_ENABLED".from(legacyConfig)
"jicofo.xmpp.client.disable-certificate-verification".from(newConfig)
}
override val useTls: Boolean by config {
"jicofo.xmpp.client.use-tls".from(newConfig)
}
val clientProxy: DomainBareJid? by optionalconfig {
"jicofo.xmpp.client.client-proxy".from(newConfig).convertFrom<String> {
JidCreate.domainBareFrom(it)
}
}
override fun toString(): String = "XmppClientConnectionConfig[hostname=$hostname, port=$port, username=$username]"
override val name = "client"
companion object {
const val LEGACY_HOSTNAME_PROPERTY_NAME = "org.jitsi.jicofo.HOSTNAME"
const val LEGACY_DOMAIN_PROPERTY_NAME = "org.jitsi.jicofo.FOCUS_USER_DOMAIN"
const val LEGACY_USERNAME_PROPERTY_NAME = "org.jitsi.jicofo.FOCUS_USER_NAME"
const val LEGACY_PASSWORD_PROPERTY_NAME = "org.jitsi.jicofo.FOCUS_USER_PASSWORD"
const val LEGACY_XMPP_DOMAIN_PROPERTY_NAME = "org.jitsi.jicofo.XMPP_DOMAIN"
}
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp
enum class XmppConnectionEnum { Client, Service }

View File

@@ -0,0 +1,390 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2020 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp
import org.jitsi.impl.protocol.xmpp.log.PacketDebugger
import org.jitsi.jicofo.TaskPools
import org.jitsi.jicofo.metrics.GlobalMetrics
import org.jitsi.jicofo.xmpp.XmppProvider.RoomExistsException
import org.jitsi.jicofo.xmpp.muc.ChatRoom
import org.jitsi.jicofo.xmpp.muc.ChatRoomImpl
import org.jitsi.utils.logging2.Logger
import org.jitsi.utils.logging2.createChildLogger
import org.jivesoftware.smack.AbstractXMPPConnection
import org.jivesoftware.smack.ConnectionConfiguration
import org.jivesoftware.smack.ConnectionListener
import org.jivesoftware.smack.ReconnectionListener
import org.jivesoftware.smack.ReconnectionManager
import org.jivesoftware.smack.SmackException
import org.jivesoftware.smack.XMPPConnection
import org.jivesoftware.smack.tcp.XMPPTCPConnection
import org.jivesoftware.smack.tcp.XMPPTCPConnectionConfiguration
import org.jivesoftware.smackx.caps.EntityCapsManager
import org.jivesoftware.smackx.disco.ServiceDiscoveryManager
import org.jxmpp.jid.DomainBareJid
import org.jxmpp.jid.EntityBareJid
import org.jxmpp.jid.EntityFullJid
import java.security.cert.X509Certificate
import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.logging.Level
import javax.net.ssl.X509TrustManager
/** Wraps a Smack [XMPPConnection]. */
class XmppProvider(val config: XmppConnectionConfig, parentLogger: Logger) {
private val logger: Logger = createChildLogger(parentLogger).apply {
addContext("xmpp_connection", config.name)
}
/** A task which will keep trying to connect to the XMPP server at a fixed 1-second delay. Smack's
* ReconnectionManager only kicks in once a connection suceeds. */
private var connectTask: ScheduledFuture<*>? = null
private val connectSyncRoot = Any()
/** A list of all listeners registered with this instance. */
private val listeners = CopyOnWriteArraySet<Listener>()
/** The Smack [XMPPConnection] used by this instance. */
val xmppConnection = createXmppConnection(config, logger)
private val started = AtomicBoolean(false)
var registered = false
private set(value) {
if (value != field) {
field = value
fireRegistrationStateChanged(value)
}
}
var components: Set<Component> = emptySet()
private set(value) {
if (value != field) {
field = value
logger.warn("Discovered components: $field")
fireComponentsChanged(value)
}
}
private val muc = Muc(this)
/** Listens to re-connection status updates. */
private val reconnectionListener = object : ReconnectionListener {
override fun reconnectingIn(seconds: Int) {
logger.info("XMPP reconnecting in: $seconds seconds.")
}
override fun reconnectionFailed(e: Exception) {
logger.error("XMPP reconnection failed: ${e.message}", e)
if (xmppConnection.isConnected) {
xmppConnection.disconnect()
// In some after cases with Stream Management Smack's ReconnectionManager gives up. Make sure we keep
// trying.
scheduleConnectTask()
}
}
}
/** Start/restart the connect task. */
private fun scheduleConnectTask() = synchronized(connectSyncRoot) {
val delay = 1L
connectTask?.cancel(true)
connectTask = TaskPools.scheduledPool.scheduleAtFixedRate({
try {
if (doConnect()) {
logger.warn("Failed to connect, will re-try after $delay second")
} else {
logger.info("Connected.")
synchronized(connectSyncRoot) {
connectTask?.cancel(false)
connectTask = null
}
}
} catch (e: Exception) {
logger.error("Failed to connect: ${e.message}, will re-try after $delay second", e)
}
}, 0, delay, TimeUnit.SECONDS)
}
private val connectionListener = object : ConnectionListener {
override fun authenticated(connection: XMPPConnection?, resumed: Boolean) {
registered = true
logger.info(
"Registered (resumed=$resumed)." + if (connection is XMPPTCPConnection) {
" isSmEnabled:" + connection.isSmEnabled +
" isSmAvailable:" + connection.isSmAvailable +
" isSmResumptionPossible:" + connection.isSmResumptionPossible
} else {
""
}
)
config.xmppDomain?.let {
logger.info("Will discover components for $it")
TaskPools.ioPool.submit { discoverComponents(it) }
} ?: run {
logger.info("No xmpp-domain configured, will not discover components.")
}
}
override fun connectionClosed() {
logger.info("XMPP connection closed")
GlobalMetrics.xmppDisconnects.inc()
registered = false
}
override fun connectionClosedOnError(e: Exception) {
logger.error("XMPP connection closed on error: ${e.message}", e)
GlobalMetrics.xmppDisconnects.inc()
registered = false
}
}
override fun toString(): String = "XmppProvider[$config]"
fun start() {
if (!started.compareAndSet(false, true)) {
logger.info("Already started.")
} else {
scheduleConnectTask()
}
}
fun shutdown() {
if (!started.compareAndSet(true, false)) {
logger.info("Already stopped or not started.")
} else {
synchronized(this) {
xmppConnection.disconnect()
logger.info("Disconnected.")
xmppConnection.removeConnectionListener(connectionListener)
}
registered = false
}
}
/**
* Registers the specified listener with this provider so that it would receive notifications on changes of its
* state.
*/
fun addListener(listener: Listener) = listeners.add(listener)
/** Removes the specified listener. */
fun removeListener(listener: Listener) = listeners.remove(listener)
/**
* Tries to establish the XMPP connection.
*
* @return `true` if another attempt should be performed, and `false` otherwise. Returns `false` (i.e. success) if
* either the connection was successful or the provider hasn't been started.
*/
private fun doConnect(): Boolean {
if (!started.get()) {
return false
}
synchronized(this) {
return try {
xmppConnection.connect()
logger.info("Connected, JID=${xmppConnection.user}")
// XXX Is there a reason we add listeners *after* we call connect()?
xmppConnection.addConnectionListener(connectionListener)
ReconnectionManager.getInstanceFor(xmppConnection)?.addReconnectionListener(reconnectionListener)
if (config.password != null) {
val login = config.username.toString()
val pass = config.password
val resource = config.resource
xmppConnection.login(login, pass, resource)
}
false
} catch (e: java.lang.Exception) {
logger.error("Failed to connect/login: ${e.message}", e)
// If the connect part succeeded, but login failed we don't want to
// rely on Smack's built-in retries, as it will be handled by
// the RetryStrategy
xmppConnection.removeConnectionListener(connectionListener)
ReconnectionManager.getInstanceFor(xmppConnection)?.removeReconnectionListener(reconnectionListener)
if (xmppConnection.isConnected) {
xmppConnection.disconnect()
}
true
}
}
}
private fun fireComponentsChanged(components: Set<Component>) = listeners.forEach {
TaskPools.ioPool.submit {
try {
it.componentsChanged(components)
} catch (throwable: Throwable) {
logger.error("An error occurred while executing componentsChanged() on $it", throwable)
}
}
}
private fun fireRegistrationStateChanged(registered: Boolean) {
listeners.forEach {
TaskPools.ioPool.submit {
try {
it.registrationChanged(registered)
} catch (throwable: Throwable) {
logger.error("An error occurred while executing registrationStateChanged() on $it", throwable)
}
}
}
if (registered) {
xmppConnection.replyTimeout = config.replyTimeout.toMillis()
logger.info("Set replyTimeout=${config.replyTimeout}")
}
}
@Throws(RoomExistsException::class)
fun createRoom(name: EntityBareJid): ChatRoom = muc.createChatRoom(name, null)
fun findOrCreateRoom(name: EntityBareJid, logLevel: Level): ChatRoom = muc.findOrCreateRoom(name, logLevel)
fun discoverFeatures(jid: EntityFullJid): FeatureDiscoveryResult {
if (!xmppConnection.isConnected) {
logger.error("Can not discover features, not connected.")
return featuresNotDiscovered
}
val discoveryManager = ServiceDiscoveryManager.getInstanceFor(xmppConnection)
if (discoveryManager == null) {
logger.error("Can not discover features, no ServiceDiscoveryManager")
return featuresNotDiscovered
}
val start = System.currentTimeMillis()
val featureStrings: List<String> = try {
discoveryManager.discoverInfo(jid)?.features?.map { it.`var` }?.toList() ?: emptyList()
} catch (e: SmackException.NoResponseException) {
logger.info("No response for disco#info, assuming default features.")
return featuresNotDiscovered
} catch (e: Exception) {
logger.warn("Failed to discover features for $jid: ${e.message}, assuming default feature set.", e)
return featuresNotDiscovered
}
logger.info("Discovered features for $jid in ${System.currentTimeMillis() - start} ms.")
val features = featureStrings.mapNotNull { Features.parseString(it) }.toSet()
if (features.size != featureStrings.size) {
val unrecognizedFeatures = featureStrings - features.map { it.value }.toSet()
logger.info("Unrecognized features for $jid: $unrecognizedFeatures")
}
return FeatureDiscoveryResult(features, true)
}
private fun discoverComponents(domain: DomainBareJid) {
val discoveryManager = ServiceDiscoveryManager.getInstanceFor(xmppConnection)
val components: Set<Component> = if (discoveryManager == null) {
logger.info("Can not discover components, no ServiceDiscoveryManager")
emptySet()
} else {
try {
discoveryManager.discoverInfo(domain)?.identities
?.filter { it.category == "component" }
?.map { Component(it.type, it.name) }?.toSet() ?: emptySet()
} catch (e: Exception) {
logger.warn("Failed to discover info", e)
emptySet()
}
}
this.components = components
}
class RoomExistsException(message: String) : Exception(message)
interface Listener {
fun registrationChanged(registered: Boolean) { }
fun componentsChanged(components: Set<Component>) { }
}
data class Component(val type: String, val address: String)
}
/** Create the Smack [AbstractXMPPConnection] based on the specified config. */
private fun createXmppConnection(config: XmppConnectionConfig, logger: Logger): AbstractXMPPConnection {
val connConfig = XMPPTCPConnectionConfiguration.builder().apply {
setHost(config.hostname)
setPort(config.port)
setXmppDomain(config.domain)
if (PacketDebugger.isEnabled()) {
// If XMPP debug logging is enabled, insert our debugger.
setDebuggerFactory { PacketDebugger(it, config.name) }
}
if (!config.useTls) {
setSecurityMode(ConnectionConfiguration.SecurityMode.disabled)
} else {
/* TODO - make this required except on localhost. */
setSecurityMode(ConnectionConfiguration.SecurityMode.ifpossible)
}
if (config.password == null) {
performSaslAnonymousAuthentication()
}
if (config.disableCertificateVerification) {
logger.warn("Disabling TLS certificate verification!")
setCustomX509TrustManager(object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
})
setHostnameVerifier { _, _ -> true }
}
}
val connection = XMPPTCPConnection(connConfig.build())
// This can be removed once all clients are updated reading this from the presence conference property
ServiceDiscoveryManager.getInstanceFor(connection).addFeature("https://jitsi.org/meet/jicofo/terminate-restart")
EntityCapsManager.getInstanceFor(connection).enableEntityCaps()
return connection
}
private class Muc(val xmppProvider: XmppProvider) {
/** The active chat rooms mapped by their name. */
private val rooms: MutableMap<EntityBareJid, ChatRoomImpl> = HashMap()
@Throws(RoomExistsException::class)
fun createChatRoom(roomJid: EntityBareJid, logLevel: Level? = Level.ALL): ChatRoom {
synchronized(rooms) {
if (rooms.containsKey(roomJid)) {
throw RoomExistsException("Room '$roomJid' exists")
}
return ChatRoomImpl(xmppProvider, roomJid, logLevel) { removeRoom(it) }.also {
rooms[roomJid] = it
}
}
}
fun findOrCreateRoom(roomJid: EntityBareJid, logLevel: Level = Level.ALL): ChatRoom {
synchronized(rooms) {
try {
return rooms[roomJid] ?: createChatRoom(roomJid, logLevel)
} catch (e: RoomExistsException) {
throw RuntimeException("Unexpected RoomExistsException.")
}
}
}
fun removeRoom(chatRoom: ChatRoomImpl) = synchronized(rooms) {
rooms.remove(chatRoom.roomJid)
}
}

View File

@@ -0,0 +1,69 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.jingle
import org.jitsi.jicofo.util.WeakValueMap
import org.jitsi.jicofo.xmpp.AbstractIqHandler
import org.jitsi.jicofo.xmpp.IqProcessingResult
import org.jitsi.jicofo.xmpp.IqProcessingResult.RejectedWithError
import org.jitsi.jicofo.xmpp.IqRequest
import org.jitsi.utils.logging2.createLogger
import org.jitsi.xmpp.extensions.jingle.JingleIQ
import org.jivesoftware.smack.AbstractXMPPConnection
import org.jivesoftware.smack.packet.IQ
import org.jivesoftware.smack.packet.StanzaError
/**
* Maintain a weak map of [JingleSession]s and route incoming Jingle IQs to the associated session.
* @author Pawel Domas
*/
class JingleIqRequestHandler(
connections: Set<AbstractXMPPConnection>
) : AbstractIqHandler<JingleIQ>(connections, JingleIQ.ELEMENT, JingleIQ.NAMESPACE, setOf(IQ.Type.set)) {
private val logger = createLogger()
/** The list of active Jingle sessions. */
private val sessions = WeakValueMap<String, JingleSession>()
override fun handleRequest(request: IqRequest<JingleIQ>): IqProcessingResult {
val session = sessions.get(request.iq.sid)
if (session == null) {
logger.warn("No session found for SID ${request.iq.sid}")
return RejectedWithError(
IQ.createErrorResponse(
request.iq,
StanzaError.getBuilder(StanzaError.Condition.bad_request).build()
)
)
}
return session.processIq(request.iq)
}
fun registerSession(session: JingleSession) {
val existingSession = sessions.get(session.sid)
if (existingSession != null) {
logger.warn("Replacing existing session with SID ${session.sid}")
}
sessions.put(session.sid, session)
}
fun removeSession(session: JingleSession) {
sessions.remove(session.sid)
}
}

View File

@@ -0,0 +1,90 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.jingle
import org.jitsi.xmpp.extensions.jingle.ContentPacketExtension
import org.jitsi.xmpp.extensions.jingle.JingleIQ
import org.jivesoftware.smack.packet.StanzaError
/**
* Listener class notified about Jingle requests received during a session.
*
* @author Pawel Domas
*/
interface JingleRequestHandler {
/**
* A 'source-add' IQ was received.
*
* @return a [StanzaError] if an error should be returned as response to the original request or null if
* processing was successful.
*/
fun onAddSource(jingleSession: JingleSession, contents: List<ContentPacketExtension>): StanzaError? = null
/**
* A 'source-remove' IQ was received.
*
* @return a [StanzaError] if an error should be returned as response to the original request or null if
* processing was successful.
*/
fun onRemoveSource(jingleSession: JingleSession, contents: List<ContentPacketExtension>): StanzaError? = null
/**
* A 'session-accept' IQ was received.
*
* @return a [StanzaError] if an error should be returned as response to the original request or null if
* processing was successful.
*/
fun onSessionAccept(jingleSession: JingleSession, contents: List<ContentPacketExtension>): StanzaError? = null
/**
* A 'session-info' IQ was received.
*
* @return a [StanzaError] if an error should be returned as response to the original request or null if
* processing was successful.
*/
fun onSessionInfo(jingleSession: JingleSession, iq: JingleIQ): StanzaError? = null
/**
* A 'session-terminate' IQ was received.
*
* @return a [StanzaError] if an error should be returned as response to the original request or null if
* processing was successful.
*/
fun onSessionTerminate(jingleSession: JingleSession, iq: JingleIQ): StanzaError? = null
/**
* A 'transport-info' IQ was received.
*/
fun onTransportInfo(jingleSession: JingleSession, contents: List<ContentPacketExtension>): StanzaError? = null
/**
* A 'transport-accept' IQ was received.
*
* @return a [StanzaError] if an error should be returned as response to the original request or null if
* processing was successful.
*/
fun onTransportAccept(jingleSession: JingleSession, contents: List<ContentPacketExtension>): StanzaError? = null
/**
* A 'transport-reject' IQ was received.
*/
fun onTransportReject(jingleSession: JingleSession, iq: JingleIQ) { }
}
/** Export a default impl so it can be used in java without -Xjvm-default */
class NoOpJingleRequestHandler : JingleRequestHandler

View File

@@ -0,0 +1,472 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.jingle
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.databind.node.ObjectNode
import io.opentelemetry.api.trace.Span
import io.opentelemetry.api.trace.SpanContext
import io.opentelemetry.api.trace.StatusCode
import io.opentelemetry.api.trace.TraceFlags
import io.opentelemetry.api.trace.TraceState
import io.opentelemetry.api.trace.Tracer
import io.opentelemetry.context.Context
import org.jitsi.jicofo.TaskPools
import org.jitsi.jicofo.conference.source.ConferenceSourceMap
import org.jitsi.jicofo.xmpp.IqProcessingResult
import org.jitsi.jicofo.xmpp.createSessionInitiate
import org.jitsi.jicofo.xmpp.createTransportReplace
import org.jitsi.jicofo.xmpp.sendIqAndGetResponse
import org.jitsi.jicofo.xmpp.tryToSendStanza
import org.jitsi.tracing.TracingGlobal
import org.jitsi.utils.MediaType
import org.jitsi.utils.logging2.createLogger
import org.jitsi.utils.queue.PacketQueue
import org.jitsi.xmpp.extensions.TraceParent
import org.jitsi.xmpp.extensions.jingle.ContentPacketExtension
import org.jitsi.xmpp.extensions.jingle.GroupPacketExtension
import org.jitsi.xmpp.extensions.jingle.IceUdpTransportPacketExtension
import org.jitsi.xmpp.extensions.jingle.JingleAction
import org.jitsi.xmpp.extensions.jingle.JingleIQ
import org.jitsi.xmpp.extensions.jingle.JinglePacketFactory
import org.jitsi.xmpp.extensions.jingle.Reason
import org.jitsi.xmpp.extensions.jingle.RtpDescriptionPacketExtension
import org.jitsi.xmpp.extensions.jitsimeet.ClientVersionPacketExtension
import org.jitsi.xmpp.extensions.jitsimeet.JsonMessageExtension
import org.jivesoftware.smack.AbstractXMPPConnection
import org.jivesoftware.smack.SmackException
import org.jivesoftware.smack.packet.ExtensionElement
import org.jivesoftware.smack.packet.IQ
import org.jivesoftware.smack.packet.StanzaError
import org.jxmpp.jid.Jid
import java.util.Objects
/**
* Extracts a remote [Span] from the `traceparent` extension of an IQ, if present.
*
* Inlined from jicoco-tracing's TracingUtil, which was removed because it pulled in a Smack
* dependency that isn't available on Maven Central: https://github.com/jitsi/jicoco/pull/241
*/
private fun remoteSpanFromIq(iq: IQ): Span? {
val extension = iq.getExtension(TraceParent::class.java) ?: return null
return Span.wrap(
SpanContext.createFromRemoteParent(
extension.traceId,
extension.parentId,
TraceFlags.fromHex(extension.traceFlags, 0),
TraceState.getDefault()
)
)
}
/**
* Extracts a remote [Context] from the `traceparent` extension of an IQ, if present, or the root
* context otherwise.
*/
private fun remoteContextFromIq(iq: IQ): Context {
val root = Context.root()
val span = remoteSpanFromIq(iq) ?: return root
return root.with(span)
}
/**
* Class describes Jingle session.
*
* @author Pawel Domas
* @author Lyubomir Marinov
*/
class JingleSession(
/** Jingle session identifier. */
val sid: String,
/** Remote peer XMPP address. */
val remoteJid: Jid,
private val jingleIqRequestHandler: JingleIqRequestHandler,
private val connection: AbstractXMPPConnection,
private val requestHandler: JingleRequestHandler,
private val encodeSourcesAsJson: Boolean
) {
private var state = State.PENDING
fun isActive() = state == State.ACTIVE
val logger = createLogger().apply {
addContext("remoteJid", remoteJid.toString())
addContext("sid", sid)
}
val tracer: Tracer = TracingGlobal.sdk.getTracer("org.jitsi.jicofo.jingle")
private val incomingIqQueue = PacketQueue<JingleIQ>(
Integer.MAX_VALUE,
true,
"jingle-iq-queue",
{ iq ->
val span = tracer.spanBuilder("jingle.${iq.action}")
.setParent(remoteContextFromIq(iq))
.setAttribute("initiator.id", Objects.toString(iq.initiator))
.setAttribute("responder.id", Objects.toString(iq.responder))
.setAttribute("session.id", Objects.toString(iq.sid))
.startSpan()
try {
span.makeCurrent().use {
doProcessIq(iq)
}
} catch (e: Throwable) {
span.setStatus(StatusCode.ERROR, e.message ?: "")
throw e
} finally {
span.end()
}
return@PacketQueue true
},
TaskPools.ioPool
)
private val localJid: Jid = connection.user
fun processIq(iq: JingleIQ): IqProcessingResult {
val action = iq.action
?: return IqProcessingResult.RejectedWithError(
IQ.createErrorResponse(
iq,
StanzaError.getBuilder(StanzaError.Condition.bad_request)
.setConditionText("Missing 'action'").build()
)
)
JingleStats.stanzaReceived(action)
if (state == State.ENDED) {
return IqProcessingResult.RejectedWithError(
IQ.createErrorResponse(
iq,
StanzaError.getBuilder(StanzaError.Condition.gone).setConditionText("session ended").build()
)
)
}
incomingIqQueue.add(iq)
return IqProcessingResult.AcceptedWithNoResponse()
}
/** The version of the client, if it included one in its session-accept. */
var clientVersion: String? = null
private set
private fun doProcessIq(iq: JingleIQ) {
val error = when (iq.action) {
JingleAction.SESSION_ACCEPT -> {
// The client includes its version in the session-accept that it sends to us.
iq.getExtension(ClientVersionPacketExtension::class.java)?.version?.let {
clientVersion = it
logger.info("Client version: $it")
}
// The session needs to be marked as active early to allow code executing as part of onSessionAccept
// to proceed (e.g. to signal source updates).
state = State.ACTIVE
val error = requestHandler.onSessionAccept(this, iq.contentList)
if (error != null) state = State.ENDED
error
}
JingleAction.SESSION_INFO -> requestHandler.onSessionInfo(this, iq)
JingleAction.SESSION_TERMINATE -> requestHandler.onSessionTerminate(this, iq).also {
state = State.ENDED
}
JingleAction.TRANSPORT_ACCEPT -> requestHandler.onTransportAccept(this, iq.contentList)
JingleAction.TRANSPORT_INFO -> requestHandler.onTransportInfo(this, iq.contentList)
JingleAction.TRANSPORT_REJECT -> {
requestHandler.onTransportReject(this, iq)
null
}
JingleAction.ADDSOURCE, JingleAction.SOURCEADD -> requestHandler.onAddSource(this, iq.contentList)
JingleAction.REMOVESOURCE, JingleAction.SOURCEREMOVE -> requestHandler.onRemoveSource(
this,
iq.contentList
)
else -> {
logger.warn("unsupported action ${iq.action}")
StanzaError.getBuilder(StanzaError.Condition.feature_not_implemented)
.setConditionText("Unsupported 'action'").build()
}
}
val response = if (error == null) {
IQ.createResultIQ(iq)
} else {
logger.info("Returning error: request=${iq.toXML()}, error=${error.toXML()} ")
IQ.createErrorResponse(iq, error)
}
connection.tryToSendStanza(response)
}
fun terminate(
reason: Reason,
message: String?,
/** Whether to send a session-terminate IQ, or only terminate the session locally. */
sendIq: Boolean
) {
logger.debug("Terminating session with $remoteJid, reason=$reason, sendIq=$sendIq")
val oldState = state
state = State.ENDED
if (oldState == State.ENDED) logger.warn("Terminating session which is already in state ENDED")
if (sendIq) {
if (oldState == State.ENDED) {
logger.warn("Not sending session-terminate for session in state $state")
} else {
val terminate = JinglePacketFactory.createSessionTerminate(
localJid,
remoteJid,
sid,
reason,
message
)
connection.tryToSendStanza(terminate)
JingleStats.stanzaSent(JingleAction.SESSION_TERMINATE)
}
}
jingleIqRequestHandler.removeSession(this)
}
/**
* Send a transport-replace IQ and wait for a response. Return true if the response is successful, and false
* otherwise.
*/
@Throws(SmackException.NotConnectedException::class)
fun replaceTransport(
contents: List<ContentPacketExtension>,
additionalExtensions: List<ExtensionElement>,
sources: ConferenceSourceMap
): Boolean {
logger.info("Sending transport-replace, sources=$sources.")
if (state != State.ACTIVE) logger.error("Sending transport-replace for session in state $state")
val contentsWithSources = if (encodeSourcesAsJson) contents else sources.toContents(contents)
val jingleIq = createTransportReplace(localJid, this, contentsWithSources)
jingleIq.addExtension(GroupPacketExtension.createBundleGroup(jingleIq.contentList))
additionalExtensions.forEach { jingleIq.addExtension(it) }
if (encodeSourcesAsJson) {
jingleIq.addExtension(sources.toJsonMessageExtension())
}
JingleStats.stanzaSent(jingleIq.action)
val response = connection.sendIqAndGetResponse(jingleIq)
return if (response?.type == IQ.Type.result) {
true
} else {
logger.error("Unexpected response to transport-replace: ${response?.toXML()}")
false
}
}
/**
* Send a source-remove IQ with the specified sources. Returns immediately without waiting for a response.
*/
fun removeSource(sourcesToRemove: ConferenceSourceMap) {
val removeSourceIq = JingleIQ(JingleAction.SOURCEREMOVE, sid).apply {
from = localJid
type = IQ.Type.set
to = remoteJid
}
if (encodeSourcesAsJson) {
removeSourceIq.addExtension(sourcesToRemove.toJsonMessageExtension())
} else {
sourcesToRemove.toJingle().forEach { removeSourceIq.addContent(it) }
}
logger.debug { "Sending source-remove, sources=$sourcesToRemove" }
if (state != State.ACTIVE) logger.error("Sending source-remove for session in state $state")
connection.tryToSendStanza(removeSourceIq)
JingleStats.stanzaSent(JingleAction.SOURCEREMOVE)
}
/**
* Send a transport-info IQ carrying the bridge's transport. Used after the bridge performed an ICE restart
* and rotated its ICE credentials: the client's connectivity checks are addressed to those credentials, so
* it has to be told the new ones. Returns immediately without waiting for a response.
*
* The transport is sent on a single content; the client applies the credentials to its whole (bundled)
* transport, so which content carries them does not matter.
*/
fun sendTransportInfo(transport: IceUdpTransportPacketExtension) {
val transportInfoIq = JingleIQ(JingleAction.TRANSPORT_INFO, sid).apply {
from = localJid
type = IQ.Type.set
to = remoteJid
addContent(
ContentPacketExtension().apply {
creator = ContentPacketExtension.CreatorEnum.initiator
name = "audio"
addChildExtension(IceUdpTransportPacketExtension.cloneTransportAndCandidates(transport, true))
}
)
}
logger.info(
"ICE restart: sending transport-info with the bridge's transport, generation=${transport.iceGeneration}, " +
"ufrag=${transport.ufrag}"
)
if (state != State.ACTIVE) logger.error("Sending transport-info for session in state $state")
connection.tryToSendStanza(transportInfoIq)
JingleStats.stanzaSent(JingleAction.TRANSPORT_INFO)
}
/**
* Send a source-add IQ with the specified sources. Returns immediately without waiting for a response.
*/
fun addSource(sources: ConferenceSourceMap) {
logger.debug { "Sending source-add, sources=$sources" }
JingleStats.stanzaSent(JingleAction.SOURCEADD)
if (state != State.ACTIVE) logger.error("Sending source-add for session in state $state")
connection.tryToSendStanza(createAddSourceIq(sources))
}
@Throws(SmackException.NotConnectedException::class)
fun initiateSession(
contents: List<ContentPacketExtension>,
additionalExtensions: List<ExtensionElement>,
sources: ConferenceSourceMap,
): Boolean {
if (state != State.PENDING) logger.error("Sending session-initiate for session in state $state")
val contentsWithSources = if (encodeSourcesAsJson) contents else sources.toContents(contents)
val sessionInitiate = createSessionInitiate(localJid, remoteJid, sid, contentsWithSources).apply {
addExtension(GroupPacketExtension.createBundleGroup(contentList))
additionalExtensions.forEach { addExtension(it) }
if (encodeSourcesAsJson) {
addExtension(sources.toJsonMessageExtension())
}
Span.fromContextOrNull(Context.current())?.let {
addExtension(
TraceParent(
it.spanContext.traceId,
it.spanContext.spanId,
it.spanContext.traceFlags.asHex()
)
)
}
}
jingleIqRequestHandler.registerSession(this)
JingleStats.stanzaSent(sessionInitiate.action)
val response = connection.sendIqAndGetResponse(sessionInitiate)
// We treat a timeout (null) as success. This prevents failures in case the client delays processing, observed
// when joining a large conference. The session will be pending until we receive session-accept.
return if (response == null || response.type == IQ.Type.result) {
true
} else {
logger.error("Unexpected response to session-initiate: $response")
false
}
}
private fun createAddSourceIq(sources: ConferenceSourceMap) = JingleIQ(JingleAction.SOURCEADD, sid).apply {
from = localJid
type = IQ.Type.set
to = remoteJid
if (encodeSourcesAsJson) {
addExtension(sources.toJsonMessageExtension())
} else {
sources.toJingle().forEach { addContent(it) }
}
}
fun debugState(): ObjectNode = JsonNodeFactory.instance.objectNode().apply {
put("sid", sid)
put("remoteJid", remoteJid.toString())
put("state", state.toString())
put("client_version", clientVersion)
}
enum class State { PENDING, ACTIVE, ENDED }
}
/**
* Encodes the sources described in `sources` as a [JsonMessageExtension] in the compact JSON format
* (see [ConferenceSourceMap.compactJson]).
* @return the [JsonMessageExtension] encoding `sources`.
*/
fun ConferenceSourceMap.toJsonMessageExtension() = JsonMessageExtension("{\"sources\":${compactJson()}}")
/**
* Encodes the sources described in `sources` in the list of Jingle contents. If necessary, new
* [ContentPacketExtension]s are created. Returns the resulting list of [ContentPacketExtension] which
* contains the encoded sources.
*
* @return the resulting list of [ContentPacketExtension], which consists of `contents` plus any new
* [ContentPacketExtension]s that were created.
*/
private fun ConferenceSourceMap.toContents(
/** The list of existing [ContentPacketExtension] to which to add sources if possible. */
existingContents: List<ContentPacketExtension>
): List<ContentPacketExtension> {
val ret = mutableListOf<ContentPacketExtension>()
var audioContent = existingContents.find { it.name == "audio" }
var videoContent = existingContents.find { it.name == "video" }
audioContent?.let { ret.add(it) }
videoContent?.let { ret.add(it) }
val audioSourceExtensions = createSourcePacketExtensions(MediaType.AUDIO)
val audioSsrcGroupExtensions = createSourceGroupPacketExtensions(MediaType.AUDIO)
if (audioSourceExtensions.isNotEmpty() || audioSsrcGroupExtensions.isNotEmpty()) {
if (audioContent == null) {
audioContent = ContentPacketExtension().apply { name = "audio" }
ret.add(audioContent)
}
var audioDescription = audioContent.getFirstChildOfType(RtpDescriptionPacketExtension::class.java)
if (audioDescription == null) {
audioDescription = RtpDescriptionPacketExtension().apply { media = "audio" }
audioContent.addChildExtension(audioDescription)
}
for (extension in audioSourceExtensions) {
audioDescription.addChildExtension(extension)
}
for (extension in audioSsrcGroupExtensions) {
audioDescription.addChildExtension(extension)
}
}
val videoSourceExtensions = createSourcePacketExtensions(MediaType.VIDEO)
val videoSsrcGroupExtensions = createSourceGroupPacketExtensions(MediaType.VIDEO)
if (videoSourceExtensions.isNotEmpty() || videoSsrcGroupExtensions.isNotEmpty()) {
if (videoContent == null) {
videoContent = ContentPacketExtension().apply { name = "video" }
ret.add(videoContent)
}
var videoDescription = videoContent.getFirstChildOfType(RtpDescriptionPacketExtension::class.java)
if (videoDescription == null) {
videoDescription = RtpDescriptionPacketExtension().apply { media = "video" }
videoContent.addChildExtension(videoDescription)
}
for (extension in videoSourceExtensions) {
videoDescription.addChildExtension(extension)
}
for (extension in videoSsrcGroupExtensions) {
videoDescription.addChildExtension(extension)
}
}
return ret
}

View File

@@ -0,0 +1,68 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.jingle
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.jitsi.jicofo.metrics.JicofoMetricsContainer
import org.jitsi.metrics.CounterMetric
import org.jitsi.xmpp.extensions.jingle.JingleAction
import java.util.concurrent.ConcurrentHashMap
private val jsonMapper = jacksonObjectMapper()
class JingleStats {
companion object {
private val stanzasReceivedByAction: MutableMap<JingleAction, CounterMetric> = ConcurrentHashMap()
private val stanzasSentByAction: MutableMap<JingleAction, CounterMetric> = ConcurrentHashMap()
@JvmStatic
fun stanzaReceived(action: JingleAction) {
stanzasReceivedByAction.computeIfAbsent(action) {
JicofoMetricsContainer.instance.registerCounter(
"jingle_${action.name.lowercase()}_received",
"Number of ${action.name} stanzas received"
)
}.inc()
}
@JvmStatic
fun stanzaSent(action: JingleAction) {
stanzasSentByAction.computeIfAbsent(action) {
JicofoMetricsContainer.instance.registerCounter(
"jingle_${action.name.lowercase()}_sent",
"Number of ${action.name} stanzas sent."
)
}.inc()
}
@JvmStatic
fun toJson(): ObjectNode = JsonNodeFactory.instance.objectNode().apply {
set<ObjectNode>(
"sent",
jsonMapper.valueToTree(stanzasSentByAction.map { it.key to it.value.get() }.toMap())
)
set<ObjectNode>(
"received",
jsonMapper.valueToTree(stanzasReceivedByAction.map { it.key to it.value.get() }.toMap())
)
}
}
}

View File

@@ -0,0 +1,213 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.muc
import com.fasterxml.jackson.databind.node.ObjectNode
import org.jitsi.jicofo.MediaType
import org.jitsi.jicofo.xmpp.RoomMetadata
import org.jitsi.jicofo.xmpp.XmppProvider
import org.jivesoftware.smack.SmackException
import org.jivesoftware.smack.XMPPException
import org.jivesoftware.smack.packet.ExtensionElement
import org.jxmpp.jid.EntityBareJid
import org.jxmpp.jid.EntityFullJid
import org.jxmpp.jid.Jid
/** Wraps a Smack [MultiUserChat] for the purposes of jicofo. */
interface ChatRoom {
val xmppProvider: XmppProvider
/** The JID of the chat room. */
val roomJid: EntityBareJid
/** Whether the local user is currently in the multi user chat (after calling one of the [.join] methods). */
val isJoined: Boolean
/**
* Returns the list of members of this [ChatRoom]. Note that this does not include a representation of the local
* user as an occupant of the MUC.
*/
val members: List<ChatRoomMember>
/** The size of [members], exposed separately for performance (avoid creating a new list just to get the count) */
val memberCount: Int
/**
* The number of [members] with role VISITOR. Exposed separately for performance (avoid creating a new list
* just to get the count). Also includes visitors who have joined within the last vnode-join-latency-interval,
* as reported by [visitorInvited]. */
val visitorCount: Int
/** Whether a lobby is enabled for the room. Read from the MUC config form. */
val lobbyEnabled: Boolean
/** Whether the visitors feature is enabled for the room. Read from the MUC config form. */
val visitorsEnabled: Boolean?
/** Whether the visitorsLive flag is enabled for the room. Read from the MUC config form. */
val visitorsLive: Boolean
/** The number of participants in the room after which new endpoints should be redirected to visitors.
* Read from the MUC config form. */
val participantsSoftLimit: Int?
/** Transcription configuration from room metadata. */
val transcription: RoomMetadata.Metadata.Transcription?
/**
* Whether the room is configured for async (backend/proxy) transcription rather than a live Jigasi
* transcriber. Read from room metadata. When true, a client-initiated transcriber dial-out request
* should be rejected -- the client is expected to enable transcription via room metadata instead.
*/
val asyncTranscription: Boolean
/** Translation configuration from room metadata (e.g. per-customer connect headers). */
val translation: RoomMetadata.Metadata.Translation?
/**
* The languages the visitors want transcriptions translated into, from room metadata.
*
* Visitor presence is not visible in this room, so their requests arrive this way instead. Use
* [getTranslationLanguages] to get the whole room's set.
*/
val visitorTranslationLanguages: Set<String>
/**
* All the languages this room wants transcriptions translated into: the members' requested languages together
* with [visitorTranslationLanguages].
*/
fun getTranslationLanguages(): Set<String> = translationLanguages(members, visitorTranslationLanguages)
companion object {
/**
* The union of the languages [members] request and the [visitorLanguages] the visitors request. Split out
* from [getTranslationLanguages] so it can be tested without an implementation of this interface.
*/
fun translationLanguages(members: List<ChatRoomMember>, visitorLanguages: Set<String>): Set<String> =
members.mapNotNullTo(mutableSetOf()) { it.translationLanguage } + visitorLanguages
}
val debugState: ObjectNode
/** Returns the number of members that currently have their audio sources unmuted. */
var audioSendersCount: Int
/** Returns the number of members that currently have their video sources unmuted. */
var videoSendersCount: Int
/**
* Whether a user with a certain ID and a certain group ID would be allowed to join the main room. Note that this
* does not actually perform authentication (i.e. if the user has a valid claim for the user ID and group ID),
* just checks the MUC configuration.
*/
fun isAllowedInMainRoom(userId: String?, groupId: String?): Boolean
/**
* Whether a user with a certain ID and a certain group ID is preferred in the main room, i.e. is explicitly listed
* in the list of participants or moderators for the main room.
*/
fun isPreferredInMainRoom(userId: String?, groupId: String?): Boolean
/**
* Joins this chat room with the preconfigured nickname. Returns the fields read from the MUC config form after
* joining.
*/
@Throws(SmackException::class, XMPPException::class, InterruptedException::class)
fun join(): ChatRoomInfo
/** Leave the chat room. */
fun leave()
/**
* Grants ownership privileges to another user. Room owners may grant
* ownership privileges. Some room implementations will not allow to grant
* ownership privileges to other users. An owner is allowed to change
* defining room features as well as perform all administrative functions.
*
* @param member the member to grant ownership to.
*/
fun grantOwnership(member: ChatRoomMember)
/**
* Get the [ChatRoomMember] for a given MUC occupant jid.
*
* @param occupantJid full MUC jid of the user for whom we want to find chat
* member instance. Ex. chatroom1@muc.server.com/nick1234
*/
fun getChatMember(occupantJid: EntityFullJid): ChatRoomMember?
/** Add all of [extensions] to our presence. */
fun addPresenceExtensions(extensions: Collection<ExtensionElement>)
/** Add [extension] to our presence, no-op if we already have an extension with the same QName. */
fun addPresenceExtensionIfMissing(extension: ExtensionElement)
/** Remove presence extensions matching the predicate [pred]. */
fun removePresenceExtensions(pred: (ExtensionElement) -> Boolean)
/**
* Add [extension] to presence and remove any previous extensions with the same QName.
*
* Note that this always sends a presence stanza. If the intention is to only send a packet if the extension was
* modified use [addPresenceExtensionIfMissing].
*/
fun setPresenceExtension(extension: ExtensionElement)
/** Add a [ChatRoomListener] to the list of listeners to be notified of events from this [ChatRoom]. */
fun addListener(listener: ChatRoomListener)
/** Removes a [ChatRoomListener] from the list of listeners to be notified of events from this [ChatRoom]. */
fun removeListener(listener: ChatRoomListener)
/** Removes all [ChatRoomListener]s. */
fun removeAllListeners()
/** Whether A/V moderation is enabled for [mediaType] (audio or video). */
fun isAvModerationEnabled(mediaType: MediaType): Boolean
/** Enable or disable a/v moderation for [mediaType]. */
fun setAvModerationEnabled(mediaType: MediaType, value: Boolean)
/** Updates the list of members that are allowed to unmute audio or video. */
fun setAvModerationWhitelist(mediaType: MediaType, whitelist: List<String>)
/** Update the value in the room_metadata structure */
fun setRoomMetadata(roomMetadata: RoomMetadata)
/** whether the current A/V moderation setting allow the member [jid] to unmute (for a specific [mediaType]). */
fun isMemberAllowedToUnmute(jid: Jid, mediaType: MediaType): Boolean
/** Notify the chatroom that a visitor has been redirected to this room.
*/
fun visitorInvited()
/** Queue a task to be executed sequentially with respect to other tasks queued through this method. */
fun queueXmppTask(runnable: () -> Unit)
}
/** Holds fields read from the MUC config form at join time, which never change. */
data class ChatRoomInfo(
/** The meeting ID, or null if none is set. */
val meetingId: String?,
/** The JID of the main room if this is a breakout room, otherwise null. */
val mainRoomJid: EntityBareJid?
)
/** Whether a user with a certain JID is allowed to unmute with any of [mediaTypes]. */
fun ChatRoom.isMemberAllowedToUnmute(jid: Jid, mediaTypes: Set<MediaType>): Boolean =
mediaTypes.any { isMemberAllowedToUnmute(jid, it) }

View File

@@ -0,0 +1,835 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.muc
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings
import org.jitsi.jicofo.JicofoConfig
import org.jitsi.jicofo.MediaType
import org.jitsi.jicofo.TaskPools.Companion.ioPool
import org.jitsi.jicofo.util.PendingCount
import org.jitsi.jicofo.xmpp.RoomMetadata
import org.jitsi.jicofo.xmpp.XmppProvider
import org.jitsi.jicofo.xmpp.muc.MemberRole.Companion.fromSmack
import org.jitsi.jicofo.xmpp.sendIqAndGetResponse
import org.jitsi.jicofo.xmpp.tryToSendStanza
import org.jitsi.utils.event.EventEmitter
import org.jitsi.utils.event.SyncEventEmitter
import org.jitsi.utils.logging2.createLogger
import org.jitsi.utils.observableWhenChanged
import org.jitsi.utils.queue.PacketQueue
import org.jivesoftware.smack.PresenceListener
import org.jivesoftware.smack.SmackException
import org.jivesoftware.smack.XMPPConnection
import org.jivesoftware.smack.XMPPException
import org.jivesoftware.smack.packet.ExtensionElement
import org.jivesoftware.smack.packet.IQ
import org.jivesoftware.smack.packet.Presence
import org.jivesoftware.smack.packet.PresenceBuilder
import org.jivesoftware.smack.util.Consumer
import org.jivesoftware.smackx.muc.MUCAffiliation
import org.jivesoftware.smackx.muc.MUCRole
import org.jivesoftware.smackx.muc.MultiUserChat
import org.jivesoftware.smackx.muc.MultiUserChatManager
import org.jivesoftware.smackx.muc.Occupant
import org.jivesoftware.smackx.muc.UserStatusListener
import org.jivesoftware.smackx.muc.packet.MUCAdmin
import org.jivesoftware.smackx.muc.packet.MUCInitialPresence
import org.jivesoftware.smackx.muc.packet.MUCItem
import org.jivesoftware.smackx.muc.packet.MUCUser
import org.jxmpp.jid.EntityBareJid
import org.jxmpp.jid.EntityFullJid
import org.jxmpp.jid.Jid
import org.jxmpp.jid.impl.JidCreate
import org.jxmpp.jid.parts.Resourcepart
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.logging.Level
private val jsonMapper = jacksonObjectMapper()
@SuppressFBWarnings(
value = ["JLM_JSR166_UTILCONCURRENT_MONITORENTER"],
justification = "We intentionally synchronize on [members] (a ConcurrentHashMap)."
)
class ChatRoomImpl(
override val xmppProvider: XmppProvider,
override val roomJid: EntityBareJid,
logLevel: Level?,
/** Callback to call when the room is left. */
private val leaveCallback: (ChatRoomImpl) -> Unit
) : ChatRoom, PresenceListener {
private val logger = createLogger(minLogLevel = logLevel ?: Level.ALL).apply {
addContext("room", roomJid.toString())
}
/**
* Keep track of the recently added visitors.
*/
private val pendingVisitorsCounter = PendingCount(
JicofoConfig.config.vnodeJoinLatencyInterval
)
/**
* Latch that is counted down when the room metadata is set.
* This is used to block the join until the room metadata is available.
*/
private val roomMetadataLatch = CountDownLatch(1)
/**
* Latch that is counted down when the room is joined. Processing presence blocks until this latch is counted down.
*/
private val roomJoinedLatch = CountDownLatch(1)
override fun visitorInvited() {
pendingVisitorsCounter.eventPending()
}
/**
* A queue for tasks originating from XMPP for this room which need to be processed in order.
*/
private val xmppTaskQueue = PacketQueue<Runnable>(
Integer.MAX_VALUE,
false,
"ChatRoomImpl presence queue",
{
try {
it.run()
} catch (e: Exception) {
logger.warn("Error processing XMPP task", e)
}
true
},
ioPool
)
private val membersMap: MutableMap<EntityFullJid, ChatRoomMemberImpl> = ConcurrentHashMap()
override val members: List<ChatRoomMember>
get() = synchronized(membersMap) { return membersMap.values.toList() }
override val memberCount
get() = membersMap.size
private var visitorMemberCount: Int = 0
override val visitorCount: Int
get() = synchronized(membersMap) { visitorMemberCount } +
pendingVisitorsCounter.getCount().toInt()
/** Stores our last MUC presence packet for future update. */
private var lastPresenceSent: PresenceBuilder? = null
private val memberListener: MemberListener = MemberListener()
private val userListener = LocalUserStatusListener()
/** Listener for presence that smack sends on our behalf. */
private var presenceInterceptor = Consumer<PresenceBuilder> { presenceBuilder ->
// The initial presence sent by smack contains an empty "x"
// extension. If this extension is included in a subsequent stanza,
// it indicates that the client lost its synchronization and causes
// the MUC service to re-send the presence of each occupant in the
// room.
synchronized(this@ChatRoomImpl) {
val p = presenceBuilder.build()
p.removeExtension(
MUCInitialPresence.ELEMENT,
MUCInitialPresence.NAMESPACE
)
lastPresenceSent = p.asBuilder()
}
}
/** Smack multi user chat backend instance. */
private val muc: MultiUserChat =
MultiUserChatManager.getInstanceFor(xmppProvider.xmppConnection).getMultiUserChat(this.roomJid).apply {
addUserStatusListener(userListener)
addParticipantListener(this@ChatRoomImpl)
}
override val isJoined
get() = muc.isJoined && roomJoinedLatch.count <= 0
/** Our full Multi User Chat XMPP address. */
private var myOccupantJid: EntityFullJid? = null
override var lobbyEnabled: Boolean = false
private set(value) {
if (value != field) {
logger.info("Lobby is now ${if (value) "enabled" else "disabled"}.")
field = value
}
}
override var visitorsEnabled: Boolean? = null
private set(value) {
if (value != field) {
logger.info("Visitors is now: $value")
field = value
}
}
override var visitorsLive: Boolean = false
private set(value) {
if (value != field) {
logger.info("VisitorsLive is now: $value")
field = value
}
}
override var participantsSoftLimit: Int? = null
private set(value) {
if (value != field) {
logger.info("ParticipantsSoftLimit is now $value.")
field = value
}
}
/** Transcription configuration from room metadata. */
override var transcription: RoomMetadata.Metadata.Transcription? = null
private set
/** Translation configuration from room metadata. */
override var translation: RoomMetadata.Metadata.Translation? = null
private set
/** Whether the room is configured for async (backend/proxy) transcription. Read from room metadata. */
override var asyncTranscription: Boolean = false
private set
/**
* The languages the visitors want transcriptions translated into, from room metadata.
*
* Visitor presence is not visible in this room, so the visitors' requests reach us this way instead: each visitor
* node collects them and the main prosody aggregates them into the room metadata. Fires
* [ChatRoomListener.translationLanguagesChanged] on change, like the per-member languages do.
*/
override var visitorTranslationLanguages: Set<String> = emptySet()
private set(value) {
if (value != field) {
logger.info("Visitor translation languages are now: $value")
field = value
eventEmitter.fireEvent { translationLanguagesChanged() }
}
}
/**
* List of user IDs which the room is configured to allow to be moderators.
*/
private var moderators: List<String> = emptyList()
/**
* List of user IDs which the room is configured to allow to be moderators.
* When [null], the feature is not used, meaning that the room is open to participants.
* When empty, the feature is used and no participants are explicitly allowed (so the room requires a token,
* and users without a token should be redirected as visitors.
*/
private var participants: List<String>? = null
override fun isAllowedInMainRoom(userId: String?, groupId: String?): Boolean {
if (participants == null) {
// The room is open to participants.
return true
}
return isPreferredInMainRoom(userId, groupId)
}
override fun isPreferredInMainRoom(userId: String?, groupId: String?): Boolean {
if (userId != null && (moderators.contains(userId) || participants?.contains(userId) == true)) {
// The user is explicitly allowed to join the main room.
return true
}
if (groupId != null && (moderators.contains(groupId) || participants?.contains(groupId) == true)) {
// The user is explicitly allowed to join the main room based on their group ID.
return true
}
return false
}
private val avModerationByMediaType = ConcurrentHashMap<MediaType, AvModerationForMediaType>()
/** The emitter used to fire events. */
private val eventEmitter: EventEmitter<ChatRoomListener> = SyncEventEmitter()
/** The number of members that currently have their audio sources unmuted. */
override var audioSendersCount by observableWhenChanged(0) { _, _, newValue ->
logger.debug { "The number of audio senders has changed to $newValue." }
eventEmitter.fireEvent { numAudioSendersChanged(newValue) }
}
/** The number of members that currently have their video sources unmuted. */
override var videoSendersCount by observableWhenChanged(0) { _, _, newValue ->
logger.debug { "The number of video senders has changed to $newValue." }
eventEmitter.fireEvent { numVideoSendersChanged(newValue) }
}
override val debugState: ObjectNode
get() = JsonNodeFactory.instance.objectNode().apply {
put("room_jid", roomJid.toString())
put("my_occupant_jid", myOccupantJid.toString())
val membersJson = JsonNodeFactory.instance.objectNode()
membersMap.values.forEach {
membersJson.set<ObjectNode>(it.name, it.debugState)
}
set<ObjectNode>("members", membersJson)
put("audio_senders_count", audioSendersCount)
put("video_senders_count", videoSendersCount)
put("lobby_enabled", lobbyEnabled)
put("participants_soft_limit", participantsSoftLimit ?: -1)
participants?.let {
set<ArrayNode>("participants", jsonMapper.valueToTree(it))
}
set<ArrayNode>("moderators", jsonMapper.valueToTree(moderators))
put("visitors_enabled", visitorsEnabled?.toString() ?: "null")
put("visitors_live", visitorsLive)
put("async_transcription", asyncTranscription)
set<ObjectNode>(
"av_moderation",
JsonNodeFactory.instance.objectNode().apply {
avModerationByMediaType.forEach { (k, v) -> set<ObjectNode>(k.toString(), v.debugState) }
}
)
}
override fun addListener(listener: ChatRoomListener) = eventEmitter.addHandler(listener)
override fun removeListener(listener: ChatRoomListener) = eventEmitter.removeHandler(listener)
// Use toList to avoid concurrent modification. TODO: add a removeAll to EventEmitter.
override fun removeAllListeners() = eventEmitter.eventHandlers.toList().forEach { eventEmitter.removeHandler(it) }
private fun avModeration(mediaType: MediaType): AvModerationForMediaType =
avModerationByMediaType.computeIfAbsent(mediaType) { AvModerationForMediaType(mediaType) }
override fun isAvModerationEnabled(mediaType: MediaType) = avModeration(mediaType).enabled
override fun setAvModerationEnabled(mediaType: MediaType, value: Boolean) {
avModeration(mediaType).enabled = value
}
override fun setAvModerationWhitelist(mediaType: MediaType, whitelist: List<String>) {
avModeration(mediaType).whitelist = whitelist
}
override fun getChatMember(occupantJid: EntityFullJid) = membersMap[occupantJid]
override fun isMemberAllowedToUnmute(jid: Jid, mediaType: MediaType): Boolean =
avModeration(mediaType).isAllowedToUnmute(jid)
@Throws(SmackException::class, XMPPException::class, InterruptedException::class)
override fun join(): ChatRoomInfo {
// TODO: clean-up the way we figure out what nickname to use.
resetState()
return joinAs(xmppProvider.config.username)
}
/**
* Prepare this [ChatRoomImpl] for a call to [.joinAs], which sends initial presence to the MUC. Resets any state
* that might have been set the previous time the MUC was joined.
*/
private fun resetState() {
synchronized(membersMap) {
if (membersMap.isNotEmpty()) {
logger.warn("Removing ${membersMap.size} stale members ($visitorMemberCount stale visitors).")
membersMap.clear()
visitorMemberCount = 0
audioSendersCount = 0
videoSendersCount = 0
}
}
synchronized(this) {
lastPresenceSent = null
logger.removeContext("meeting_id")
avModerationByMediaType.values.forEach { it.reset() }
}
}
@Throws(SmackException::class, XMPPException::class, InterruptedException::class)
private fun joinAs(nickname: Resourcepart): ChatRoomInfo {
// The queue should block until the room is fully joined.
xmppTaskQueue.add { roomJoinedLatch.await() }
myOccupantJid = JidCreate.entityFullFrom(roomJid, nickname)
synchronized(muc) {
if (muc.isJoined) {
muc.leave()
}
}
muc.addPresenceInterceptor(presenceInterceptor)
muc.createOrJoin(nickname)
val config = muc.configurationForm
// Make the room non-anonymous, so that others can recognize focus JID
val answer = config.fillableForm
answer.setAnswer(MucConfigFields.WHOIS, "anyone")
muc.sendConfigurationForm(answer)
// Read the breakout room and meetingId.
val mainRoomStr = if (config.getField(MucConfigFields.IS_BREAKOUT_ROOM)?.firstValue?.toBoolean() == true) {
config.getField(MucConfigFields.MAIN_ROOM)?.firstValue
} else {
null
}
if (config.getField(MucConfigFields.CONFERENCE_PRESET_ENABLED)?.firstValue?.toBoolean() == true) {
logger.info("Conference presets service is enabled. Will block until RoomMetadata is set.")
if (roomMetadataLatch.await(10, TimeUnit.SECONDS)) {
logger.info("RoomMetadata is set, room is fully joined.")
} else {
logger.warn("Timed out waiting for RoomMetadata to be set. Will continue without it.")
}
}
roomJoinedLatch.countDown()
return ChatRoomInfo(
meetingId = config.getField(MucConfigFields.MEETING_ID)?.firstValue,
mainRoomJid = if (mainRoomStr == null) null else JidCreate.entityBareFrom(mainRoomStr)
)
}
override fun setRoomMetadata(roomMetadata: RoomMetadata) {
// The initial RoomMetadata is required to consider the room fully joined. The queue will block until that
// happens, so handle it outside the queue
if (roomJoinedLatch.count > 0L) {
ioPool.submit { doSetRoomMetadata(roomMetadata) }
} else {
queueXmppTask { doSetRoomMetadata(roomMetadata) }
}
}
private fun doSetRoomMetadata(roomMetadata: RoomMetadata) {
logger.info("Setting room metadata: $roomMetadata")
lobbyEnabled = roomMetadata.metadata?.lobbyEnabled == true
visitorsLive = roomMetadata.metadata?.visitors?.live == true
moderators = roomMetadata.metadata?.moderators ?: emptyList()
participants = roomMetadata.metadata?.participants
// We read these fields from both the config form (for backwards compatibility) and the room metadata. Only
// override if they are set.
roomMetadata.metadata?.visitorsEnabled?.let {
visitorsEnabled = it
}
roomMetadata.metadata?.participantsSoftLimit?.let {
participantsSoftLimit = it
}
roomMetadata.metadata?.startMuted?.let {
eventEmitter.fireEvent { startMutedChanged(it.audio == true, it.video == true) }
}
transcription = roomMetadata.metadata?.transcription
translation = roomMetadata.metadata?.translation
asyncTranscription = roomMetadata.metadata?.asyncTranscription == true
eventEmitter.fireEvent {
transcribingEnabledChanged(
roomMetadata.metadata?.recording?.isTranscribingEnabled == true &&
roomMetadata.metadata.asyncTranscription == true
)
}
eventEmitter.fireEvent {
audioTranslationRequestsChanged(roomMetadata.metadata?.audioTranslationRequests ?: emptyMap())
}
visitorTranslationLanguages = parseVisitorTranslationLanguages(roomMetadata)
roomMetadataLatch.countDown()
}
override fun leave() {
muc.removePresenceInterceptor(presenceInterceptor)
muc.removeUserStatusListener(userListener)
muc.removeParticipantListener(this)
leaveCallback(this)
// Unblock any threads waiting on the latches
xmppTaskQueue.close()
roomMetadataLatch.countDown()
roomJoinedLatch.countDown()
// Call MultiUserChat.leave() in an IO thread, because it now (with Smack 4.4.3) blocks waiting for a response
// from the XMPP server (and we want ChatRoom#leave to return immediately).
ioPool.execute {
val connection: XMPPConnection = xmppProvider.xmppConnection
try {
// FIXME smack4: there used to be a custom dispose() method if leave() fails, there might still be some
// listeners lingering around
if (muc.isJoined) {
muc.leave()
} else {
// If the join attempt timed out the XMPP server might have processed it and created the MUC, and
// since the XMPP connection is long-lived the MUC will leak.
val leavePresence = connection.stanzaFactory.buildPresenceStanza()
.ofType(Presence.Type.unavailable)
.to(myOccupantJid)
.build()
connection.sendStanza(leavePresence)
}
} catch (e: Exception) {
// when the connection is not connected or we get NotConnectedException, this is expected (skip log)
if (connection.isConnected || e !is SmackException.NotConnectedException) {
logger.error("Failed to properly leave $muc", e)
}
}
}
}
override fun grantOwnership(member: ChatRoomMember) {
logger.debug("Grant owner to $member")
// Have to construct the IQ manually as Smack version used here seems
// to be using wrong namespace(muc#owner instead of muc#admin)
// which does not work with the Prosody.
val jid = member.jid?.asBareJid() ?: run {
logger.warn("Can not grant ownership to ${member.name}, real JID unknown")
return
}
val item = MUCItem(MUCAffiliation.owner, jid)
val admin = MUCAdmin().apply {
type = IQ.Type.set
to = roomJid
addItem(item)
}
try {
val reply = xmppProvider.xmppConnection.sendIqAndGetResponse(admin)
if (reply == null || reply.type != IQ.Type.result) {
logger.warn("Failed to grant ownership: ${reply?.toString() ?: "timeout"}")
}
} catch (e: SmackException.NotConnectedException) {
logger.warn("Failed to grant ownership: XMPP disconnected")
}
}
fun getOccupant(chatMember: ChatRoomMemberImpl): Occupant? = muc.getOccupant(chatMember.occupantJid)
override fun setPresenceExtension(extension: ExtensionElement) {
val presenceToSend = synchronized(this) {
val presence = lastPresenceSent ?: run {
logger.error("No presence packet obtained yet")
return
}
presence.getExtensions(extension.qName).toList().forEach { existingExtension ->
presence.removeExtension(existingExtension)
}
presence.addExtension(extension)
presence.build()
}
presenceToSend?.let { xmppProvider.xmppConnection.tryToSendStanza(it) }
}
override fun addPresenceExtensionIfMissing(extension: ExtensionElement) {
val presenceToSend = synchronized(this) {
val presence = lastPresenceSent ?: run {
logger.error("No presence packet obtained yet")
return
}
if (presence.extensions?.any { it.qName == extension.qName } == true) {
null
} else {
presence.addExtension(extension).build()
}
}
presenceToSend?.let { xmppProvider.xmppConnection.tryToSendStanza(it) }
}
override fun removePresenceExtensions(pred: (ExtensionElement) -> Boolean) {
val presenceToSend = synchronized(this) {
lastPresenceSent?.extensions?.filter { pred(it) }?.let {
modifyPresenceExtensions(toRemove = it)
}
}
presenceToSend?.let { xmppProvider.xmppConnection.tryToSendStanza(it) }
}
override fun addPresenceExtensions(extensions: Collection<ExtensionElement>) {
val presenceToSend = synchronized(this) {
modifyPresenceExtensions(toAdd = extensions)
}
presenceToSend?.let { xmppProvider.xmppConnection.tryToSendStanza(it) }
}
/** Add/remove extensions to/from our presence and return the updated presence or null if it wasn't modified. */
private fun modifyPresenceExtensions(
toRemove: Collection<ExtensionElement> = emptyList(),
toAdd: Collection<ExtensionElement> = emptyList()
): Presence? = synchronized(this) {
val presence = lastPresenceSent ?: run {
logger.error("No presence packet obtained yet")
return null
}
var changed = false
toRemove.forEach {
presence.removeExtension(it)
// We don't have a good way to check if it was actually removed.
changed = true
}
toAdd.forEach {
presence.addExtension(it)
changed = true
}
return if (changed) presence.build() else null
}
/**
* Adds a new [ChatRoomMemberImpl] with the given JID to [.members].
* If a member with the given JID already exists, it returns the existing
* instance.
* @param jid the JID of the member.
* @return the [ChatRoomMemberImpl] for the member with the given JID.
*/
private fun addMember(jid: EntityFullJid): ChatRoomMemberImpl {
synchronized(membersMap) {
membersMap[jid]?.let { return it }
val newMember = ChatRoomMemberImpl(jid, this@ChatRoomImpl, logger)
membersMap[jid] = newMember
if (!newMember.isAudioMuted) audioSendersCount++
if (!newMember.isVideoMuted) videoSendersCount++
if (newMember.role == MemberRole.VISITOR) {
pendingVisitorsCounter.eventOccurred()
visitorMemberCount++
}
return newMember
}
}
/**
* Gets the "real" JID of an occupant of this room specified by its
* occupant JID.
* @param occupantJid the occupant JID.
* @return the "real" JID of the occupant, or `null`.
*/
fun getJid(occupantJid: EntityFullJid): Jid? = muc.getOccupant(occupantJid)?.jid ?: run {
logger.error("Unable to get occupant for $occupantJid")
null
}
/**
* Processes a <tt>Presence</tt> packet addressed to our own occupant
* JID.
* @param presence the packet to process.
*/
private fun processOwnPresence(presence: Presence) {
val mucUser = presence.getExtension(MUCUser::class.java)
if (mucUser != null) {
val affiliation = mucUser.item.affiliation
val role = mucUser.item.role
// This is our initial role and affiliation, as smack does not fire any initial events.
if (!presence.isAvailable && MUCAffiliation.none == affiliation && MUCRole.none == role) {
val destroy = mucUser.destroy
if (destroy == null) {
// the room is unavailable to us, there is no
// message we will just leave
leave()
} else {
logger.info("Leave, reason: ${destroy.reason} alt-jid: ${destroy.jid}")
leave()
}
} else {
eventEmitter.fireEvent { localRoleChanged(fromSmack(role, affiliation)) }
}
}
}
/**
* Process a <tt>Presence</tt> packet sent by one of the other room
* occupants.
* @param presence the presence.
*/
private fun processOtherPresence(presence: Presence) {
val jid = presence.from.asEntityFullJidIfPossible() ?: run {
logger.warn("Presence without a valid jid: ${presence.from}")
return
}
var memberJoined = false
var memberLeft = false
val member = synchronized(membersMap) {
val m = getChatMember(jid)
if (m == null) {
if (presence.type == Presence.Type.available) {
// This is how we detect that a new member has joined. We do not use the
// ParticipantStatusListener#joined callback.
logger.debug { "Joined $jid room: $roomJid" }
memberJoined = true
addMember(jid)
} else {
// We received presence from an unknown member which doesn't look like a new member's presence.
// Ignore it. The member might have been just removed via left(), which is fine.
null
}
} else {
memberLeft = presence.type == Presence.Type.unavailable
m
}
}
if (member != null) {
val previousTranslationLanguage = member.translationLanguage
member.processPresence(presence)
if (memberJoined) {
// Trigger member "joined"
eventEmitter.fireEvent { memberJoined(member) }
} else if (memberLeft) {
if (MUCUser.from(presence).status.contains(MUCUser.Status.KICKED_307)) {
memberListener.kicked(jid)
} else {
memberListener.left(jid)
}
}
if (!memberLeft) {
eventEmitter.fireEvent { memberPresenceChanged(member) }
}
// This member picked a translation language, changed it, or gave it up, so the room's set may have
// changed. A member joining with one set is covered too, because its language is null before its first
// presence is processed. A member leaving is handled by MemberListener instead, because it can also be
// removed without an unavailable presence.
if (member.translationLanguage != previousTranslationLanguage) {
eventEmitter.fireEvent { translationLanguagesChanged() }
}
}
}
/**
* Offload processing presence for the room from Smack's thread to a queue running in the jicofo IO pool.
*/
override fun processPresence(presence: Presence?) {
if (presence == null) {
logger.warn("Received null presence packet")
return
}
xmppTaskQueue.add { doProcessPresence(presence) }
}
private fun doProcessPresence(presence: Presence) {
if (presence.error != null) {
logger.warn("Received presence with error: ${presence.toXML()}")
return
}
logger.trace { "Presence received ${presence.toXML()}" }
// Should never happen, but log if something is broken
val myOccupantJid = this.myOccupantJid
if (myOccupantJid == null) {
logger.error("Processing presence when myOccupantJid is not set: ${presence.toXML()}")
}
if (myOccupantJid != null && myOccupantJid.equals(presence.from)) {
processOwnPresence(presence)
} else {
processOtherPresence(presence)
}
}
override fun queueXmppTask(runnable: () -> Unit) = xmppTaskQueue.add(runnable)
private object MucConfigFields {
const val IS_BREAKOUT_ROOM = "muc#roominfo_isbreakout"
const val MAIN_ROOM = "muc#roominfo_breakout_main_room"
const val MEETING_ID = "muc#roominfo_meetingId"
const val WHOIS = "muc#roomconfig_whois"
const val CONFERENCE_PRESET_ENABLED = "muc#roominfo_conference_presets_service_enabled"
}
private inner class MemberListener {
private fun removeMember(occupantJid: EntityFullJid?): ChatRoomMemberImpl? {
synchronized(membersMap) {
val removed = membersMap.remove(occupantJid)
if (removed == null) {
logger.error("$occupantJid not in room")
} else {
if (!removed.isAudioMuted) audioSendersCount--
if (!removed.isVideoMuted) videoSendersCount--
if (removed.role == MemberRole.VISITOR) {
visitorMemberCount--
}
}
return removed
}
}
/** This needs to be prepared to run twice for the same member. */
fun left(occupantJid: EntityFullJid) {
logger.debug { "Left $occupantJid room: $roomJid" }
val member = synchronized(membersMap) { removeMember(occupantJid) }
member?.let {
eventEmitter.fireEvent { memberLeft(it) }
translationLanguageReleased(it)
} ?: logger.info("Member left event for non-existing member: $occupantJid")
}
fun kicked(occupantJid: EntityFullJid) {
val member = synchronized(membersMap) { removeMember(occupantJid) }
member?.let {
eventEmitter.fireEvent { memberKicked(it) }
translationLanguageReleased(it)
} ?: logger.error("Kicked member does not exist: $occupantJid")
}
/**
* A member that was removed from the room may have been the only one wanting its translation language, so the
* room's set may have shrunk. Fired here rather than from presence handling, because a member can also be
* removed without an unavailable presence (e.g. the stale-member cleanup).
*/
private fun translationLanguageReleased(member: ChatRoomMemberImpl) {
if (member.translationLanguage != null) {
eventEmitter.fireEvent { translationLanguagesChanged() }
}
}
}
/**
* Listens for room destroyed and pass it to the conference.
*/
private inner class LocalUserStatusListener : UserStatusListener {
override fun roomDestroyed(alternateMUC: MultiUserChat?, reason: String?) {
eventEmitter.fireEvent { roomDestroyed(reason) }
}
}
@SuppressFBWarnings("RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT")
private inner class AvModerationForMediaType(mediaType: MediaType) {
var enabled: Boolean by observableWhenChanged(false) { _, _, newValue ->
logger.info("Setting enabled=$newValue for $mediaType")
}
var whitelist: List<String> by observableWhenChanged(emptyList()) { _, _, newValue ->
logger.info("Setting whitelist for $mediaType: $newValue")
}
val debugState: ObjectNode
get() = JsonNodeFactory.instance.objectNode().apply {
put("enabled", enabled)
set<ArrayNode>("whitelist", jsonMapper.valueToTree(whitelist))
}
fun isAllowedToUnmute(jid: Jid) = !enabled || whitelist.contains(jid.toString())
fun reset() {
enabled = false
whitelist = emptyList()
}
}
companion object {
/**
* The visitors' requested translation languages from [roomMetadata], as a set.
*
* Prosody sends them as one comma-separated string, and an empty string when there are none. Note that this
* must clear the set when the string is empty, so that the languages are released when the last visitor that
* wanted them goes away.
*/
private fun parseVisitorTranslationLanguages(roomMetadata: RoomMetadata): Set<String> =
roomMetadata.metadata?.visitors?.transcribingLanguages
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?.toSet()
?: emptySet()
}
}

View File

@@ -0,0 +1,50 @@
/*
* Jitsi, the OpenSource Java VoIP and Instant Messaging client.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.muc
/** Listener for events fired from a [org.jitsi.impl.protocol.xmpp.ChatRoom] **/
interface ChatRoomListener {
fun memberJoined(member: ChatRoomMember) {}
fun memberKicked(member: ChatRoomMember) {}
fun memberLeft(member: ChatRoomMember) {}
fun memberPresenceChanged(member: ChatRoomMember) {}
fun roomDestroyed(reason: String? = null) {}
fun startMutedChanged(startAudioMuted: Boolean, startVideoMuted: Boolean) {}
fun localRoleChanged(newRole: MemberRole) {}
fun numAudioSendersChanged(numAudioSenders: Int) {}
fun numVideoSendersChanged(numVideoSenders: Int) {}
fun transcribingEnabledChanged(enabled: Boolean) {}
/**
* The aggregated live-translation request map changed: sender endpoint id -> set of requested language codes.
*/
fun audioTranslationRequestsChanged(requests: Map<String, List<String>>) {}
/**
* The set of languages the room wants transcriptions translated into may have changed, because a member changed
* or gave up its language, a member holding one left, or the visitors' languages changed.
*
* The new set is not passed: read it from [ChatRoom.getTranslationLanguages], which unions the members' languages
* with the visitors'. The event only says "recompute" -- it can fire when the union is in fact unchanged.
*/
fun translationLanguagesChanged() {}
}
/** A class with the default kotlin method implementations (to avoid using @JvmDefault) **/
open class DefaultChatRoomListener : ChatRoomListener

View File

@@ -0,0 +1,96 @@
/*
* Jitsi, the OpenSource Java VoIP and Instant Messaging client.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.muc
import com.fasterxml.jackson.databind.node.ObjectNode
import org.jitsi.jicofo.xmpp.Features
import org.jivesoftware.smack.packet.Presence
import org.jxmpp.jid.EntityFullJid
import org.jxmpp.jid.Jid
/**
* This interface represents chat room participants. Instances are retrieved
* through implementations of the <tt>ChatRoom</tt> interface and offer methods
* that allow querying member properties, such as, moderation permissions,
* associated chat room and other.
*
* @author Emil Ivov
* @author Boris Grozev
*/
interface ChatRoomMember {
val chatRoom: ChatRoom
/** The ID of this member. Set to the resource part of the occupant JID. */
val name: String
/** The role of this chat room member in its containing room. */
val role: MemberRole
/** Returns the JID of the user (outside the MUC), i.e. the "real" JID. It may not always be known. */
val jid: Jid?
/** Get the latest [SourceInfo]s advertised by this chat member in presence. */
val sourceInfos: Set<SourceInfo>
/** The occupant JID of the member in the chat room */
val occupantJid: EntityFullJid
/** The last [Presence] packet received for this member (or null it if no presence has been received yet) */
val presence: Presence?
val isJigasi: Boolean
val isTranscriber: Boolean
val isJibri: Boolean
val isAudioMuted: Boolean
val isVideoMuted: Boolean
/** Whether this member requested per-endpoint diarization (signaled via the "diarize" participant property). */
val diarize: Boolean
/**
* The language this member wants transcriptions translated into, or null when it wants none or signaled one that
* does not look like a language code.
*
* Signaled via the "translation_language" participant property, the same presence element jigasi has always used.
* Whether transcription runs at all is a separate question, decided by the room metadata.
*/
val translationLanguage: String?
/** Gets the region (e.g. "us-east") of this [ChatRoomMember]. */
val region: String?
/** The statistics id if any. */
val statsId: String?
/** The supported video codecs if any */
val videoCodecs: List<String>?
/**
* The list of features advertised as XMPP capabilities. Note that although the features are cached (XEP-0115),
* the first time [features] is accessed it may block waiting for a disco#info response!
*/
val features: Set<Features>
/**
* Whether [features] were actually discovered. When this is false [features] is just an assumed default set, and
* the absence of a feature from it does not mean that the member does not support the feature.
*/
val featuresDiscovered: Boolean
val debugState: ObjectNode
}

View File

@@ -0,0 +1,325 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.muc
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.jitsi.jicofo.xmpp.FeatureDiscoveryResult
import org.jitsi.jicofo.xmpp.Features
import org.jitsi.jicofo.xmpp.XmppCapsStats
import org.jitsi.jicofo.xmpp.XmppConfig
import org.jitsi.jicofo.xmpp.muc.MemberRole.Companion.fromSmack
import org.jitsi.utils.MediaType
import org.jitsi.utils.logging2.Logger
import org.jitsi.utils.logging2.createChildLogger
import org.jitsi.xmpp.extensions.jitsimeet.AudioMutedExtension
import org.jitsi.xmpp.extensions.jitsimeet.FeaturesExtension
import org.jitsi.xmpp.extensions.jitsimeet.JitsiParticipantCodecList
import org.jitsi.xmpp.extensions.jitsimeet.JitsiParticipantRegionPacketExtension
import org.jitsi.xmpp.extensions.jitsimeet.StatsId
import org.jitsi.xmpp.extensions.jitsimeet.VideoMutedExtension
import org.jivesoftware.smack.packet.Presence
import org.jivesoftware.smack.packet.StandardExtensionElement
import org.jivesoftware.smackx.caps.packet.CapsExtension
import org.jxmpp.jid.EntityFullJid
import org.jxmpp.jid.Jid
private val jsonMapper = jacksonObjectMapper()
/**
* Stripped Smack implementation of [ChatRoomMember].
*
* @author Pawel Domas
*/
class ChatRoomMemberImpl(
/** The full MUC JID (room_name@muc.server.net/nickname) */
override val occupantJid: EntityFullJid,
override val chatRoom: ChatRoomImpl,
parentLogger: Logger
) : ChatRoomMember {
override val name: String = occupantJid.resourceOrThrow.toString()
private val logger: Logger = createChildLogger(parentLogger).apply {
addContext("id", name)
}
override var region: String? = null
private set
override var presence: Presence? = null
private set
override var isJigasi = false
private set
override var isTranscriber = false
private set
override var isJibri = false
private set
override var statsId: String? = null
private set
override var diarize: Boolean = false
override var translationLanguage: String? = null
private set
override var videoCodecs: List<String>? = null
private set
override var isAudioMuted = true
private set
override var isVideoMuted = true
private set
override var sourceInfos = emptySet<SourceInfo>()
private set
/** The node#ver advertised in a Caps extension. */
private var capsNodeVer: String? = null
override var role: MemberRole =
chatRoom.getOccupant(this)?.let { fromSmack(it.role, it.affiliation) } ?: MemberRole.VISITOR
private set
/**
* Caches user JID of the participant if we're able to see it.
*/
override var jid: Jid? = null
get() {
if (field == null) {
field = chatRoom.getJid(occupantJid)
}
return field
}
private set
private fun updateSourceInfo(presence: Presence) {
val sourceInfo = presence.getExtension<StandardExtensionElement>("SourceInfo", "jabber:client")
if (sourceInfo == null) {
sourceInfos = emptySet()
} else {
val sourceInfos: Set<SourceInfo> = try {
parseSourceInfoJson(sourceInfo.text)
} catch (e: Exception) {
logger.warn("Ignoring invalid SourceInfo JSON", e)
return
}
this.sourceInfos = sourceInfos
}
val wasAudioMuted = isAudioMuted
isAudioMuted = if (sourceInfo == null) {
// Support the old format, still used by jigasi
presence.getExtension(AudioMutedExtension::class.java)?.isAudioMuted ?: true
} else {
sourceInfos.filter { it.mediaType == MediaType.AUDIO }.none { !it.muted }
}
if (isAudioMuted != wasAudioMuted) {
logger.debug { "isAudioMuted = $isAudioMuted" }
if (isAudioMuted) chatRoom.audioSendersCount-- else chatRoom.audioSendersCount++
}
val wasVideoMuted = isVideoMuted
isVideoMuted = if (sourceInfo == null) {
// Support the old format, still used by jigasi
presence.getExtension(VideoMutedExtension::class.java)?.isVideoMuted ?: true
} else {
sourceInfos.filter { it.mediaType == MediaType.VIDEO }.none { !it.muted }
}
if (isVideoMuted != wasVideoMuted) {
logger.debug { "isVideoMuted = $isVideoMuted" }
if (isVideoMuted) chatRoom.videoSendersCount-- else chatRoom.videoSendersCount++
}
}
/**
* Process a new presence published by the member and update the mutable fields.
*
* @param presence the <tt>Presence</tt> which was sent by this chat member.
* @throws IllegalArgumentException if [presence] does not
* belong to this <tt>ChatMemberImpl</tt>.
*/
fun processPresence(presence: Presence) {
require(presence.from == occupantJid) { "Ignoring presence for a different member: ${presence.from}" }
val firstPresence = (this.presence == null)
this.presence = presence
presence.getExtension(CapsExtension::class.java)?.let {
capsNodeVer = "${it.node}#${it.ver}"
}
updateSourceInfo(presence)
// We recognize jigasi by the existence of a "feature" extension in its presence.
val features = presence.getExtension(FeaturesExtension::class.java)
if (features != null) {
val domain = jid?.asDomainBareJid()
val jidTrusted = domain != null && XmppConfig.config.trustedDomains.contains(domain)
val jibriSignaled = features.featureExtensions.any { it.`var` == "http://jitsi.org/protocol/jibri" }
val jigasiSignaled = features.featureExtensions.any { it.`var` == "http://jitsi.org/protocol/jigasi" }
val transcriberSignaled = features.featureExtensions.any {
it.`var` == "http://jitsi.org/protocol/transcriber"
}
isJibri = jibriSignaled && jidTrusted
isJigasi = jigasiSignaled && jidTrusted
isTranscriber = transcriberSignaled && jidTrusted
if (jibriSignaled && !jidTrusted) {
logger.warn(
"Jibri signaled from a non-trusted domain ($domain). The domain can be " +
"configured as trusted with the jicofo.xmpp.trusted-domains property."
)
}
if (jigasiSignaled && !jidTrusted) {
logger.warn(
"Jigasi signaled from a non-trusted domain ($domain). The domain can be " +
"configured as trusted with the jicofo.xmpp.trusted-domains property."
)
}
} else {
isJigasi = false
isJibri = false
isTranscriber = false
}
var newRole: MemberRole = MemberRole.VISITOR
chatRoom.getOccupant(this)?.let { newRole = fromSmack(it.role, it.affiliation) }
if (!firstPresence && (role == MemberRole.VISITOR) != (newRole == MemberRole.VISITOR)) {
// Allowing this to change would mess up various member counts, ignore the change
// TODO: Should we try to update them, instead?
if (presence.type != Presence.Type.unavailable) {
logger.warn("Member role changed from $role to $newRole - not supported!")
}
} else {
role = newRole
}
presence.getExtension(JitsiParticipantRegionPacketExtension::class.java)?.let {
region = it.regionId
}
presence.getExtension(StatsId::class.java)?.let {
statsId = it.statsId
}
val diarizeElement = presence.getExtensionElement("jitsi_participant_diarize", "jabber:client")
diarize = (diarizeElement as? StandardExtensionElement)?.text?.toBoolean() ?: false
translationLanguage = parseTranslationLanguage(presence)
val newVideoCodecs =
presence.getExtension(JitsiParticipantCodecList::class.java)?.let {
if (!it.codecs.contains("vp8")) {
if (firstPresence) {
logger.warn("Video codec list {${it.codecs}} does not contain vp8! Adding manually.")
}
it.codecs + "vp8"
} else {
it.codecs
}
} ?: // Older clients sent a single codec in codecType rather than all supported ones in codecList
presence.getExtensionElement("jitsi_participant_codecType", "jabber:client")?.let {
if (it is StandardExtensionElement) {
val codec = it.text.lowercase()
if (codec == "vp8") {
listOf(codec)
} else {
listOf(codec, "vp8")
}
} else {
null
}
}
if (!firstPresence && newVideoCodecs != videoCodecs) {
// Allowing this to change would mess up visitor codec preference counts, ignore the change
if (role == MemberRole.VISITOR && presence.type != Presence.Type.unavailable && newVideoCodecs != null) {
logger.warn("Visitor video codec list changed from $videoCodecs to $newVideoCodecs - not supported!")
}
} else {
videoCodecs = newVideoCodecs
}
}
override fun toString() = "ChatMember[id=$name role=$role]"
companion object {
/**
* The shape of a language code, as jitsi-meet sends them (the keys of its
* lang/translation-languages.json): a 2- or 3-letter primary subtag with optional region or script subtags,
* for example "fr", "ceb" or "zh-CN".
*/
private val LANGUAGE_CODE_REGEX = Regex("[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*")
/**
* The language a member wants transcriptions translated into, or null when it wants none. A blank value means
* the same as an absent one.
*
* The value is set by the participant, so it is validated rather than trusted. It ends up in a colibri2
* attribute, in the bridge's log lines, and (later) in a request to a translation provider. Smack escapes the
* attribute and Jackson escapes the JSON, so this is not about breaking out of the XML or the JSON. It keeps
* junk and control characters out of the signalling, and it bounds the length.
*/
private fun parseTranslationLanguage(presence: Presence): String? {
val language = presence
.getExtensionElement("jitsi_participant_translation_language", "jabber:client")
.let { (it as? StandardExtensionElement)?.text }
?.trim()
?: return null
if (language.isEmpty()) {
return null
}
return language.takeIf { LANGUAGE_CODE_REGEX.matches(it) }
}
}
private val featureDiscoveryResult: FeatureDiscoveryResult by lazy {
val result = chatRoom.xmppProvider.discoverFeatures(occupantJid)
// Update the stats once when the features are discovered.
capsNodeVer?.let {
XmppCapsStats.update(it, result.features)
} ?: logger.error("No caps nodeVer found")
result
}
override val features: Set<Features>
get() = featureDiscoveryResult.features
override val featuresDiscovered: Boolean
get() = featureDiscoveryResult.discovered
override val debugState: ObjectNode
get() = JsonNodeFactory.instance.objectNode().apply {
put("region", region.toString())
put("occupant_jid", occupantJid.toString())
put("jid", jid.toString())
put("is_jibri", isJibri)
put("is_jigasi", isJigasi)
put("is_transcriber", isTranscriber)
put("role", role.toString())
set<ObjectNode>(
"video_codecs",
JsonNodeFactory.instance.arrayNode().apply { videoCodecs?.forEach { add(it) } }
)
put("stats_id", statsId.toString())
put("is_audio_muted", isAudioMuted)
put("is_video_muted", isVideoMuted)
put("diarize", diarize)
put("translation_language", translationLanguage)
set<ObjectNode>("features", jsonMapper.valueToTree(features.map { it.name }))
put("features_discovered", featuresDiscovered)
put("capsNodeVer", capsNodeVer.toString())
}
}

View File

@@ -0,0 +1,55 @@
/*
* Jitsi, the OpenSource Java VoIP and Instant Messaging client.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.muc
import org.jivesoftware.smackx.muc.MUCAffiliation
import org.jivesoftware.smackx.muc.MUCRole
/** Indicates roles that a chat room member detains in its containing chat room. */
enum class MemberRole {
/** A role implying the full set of chat room permissions */
OWNER,
/** A role implying moderator permissions. */
MODERATOR,
/** A role implying the ability to send to a chat room */
PARTICIPANT,
/** A role implying only the ability to watch a chat room. */
VISITOR;
companion object {
@JvmStatic
fun fromSmack(mucRole: MUCRole?, mucAffiliation: MUCAffiliation?) = when (mucAffiliation) {
MUCAffiliation.admin -> MODERATOR
MUCAffiliation.owner -> OWNER
else -> when (mucRole) {
MUCRole.moderator -> MODERATOR
MUCRole.participant -> PARTICIPANT
else -> VISITOR
}
}
}
}
/** Has sufficient rights to moderate (i.e. is MODERATOR or OWNER). */
fun MemberRole?.hasModeratorRights() = this != null && this <= MemberRole.MODERATOR
fun MemberRole?.hasOwnerRights() = this != null && this <= MemberRole.OWNER

View File

@@ -0,0 +1,80 @@
/*
* Copyright @ 2021 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.muc
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.jitsi.jicofo.conference.source.VideoType
import org.jitsi.utils.MediaType
/** The information about a source contained in a jitsi-meet SourceInfo extension. */
data class SourceInfo(
val name: String,
val muted: Boolean,
val videoType: VideoType?,
val mediaType: MediaType?
)
/**
* Parse the JSON string encoded in a SourceInfo XML extension as a set of [SourceInfo]s.
* @throws IllegalArgumentException if the string is not valid JSON or not in the expected format.
*/
@kotlin.jvm.Throws(IllegalArgumentException::class)
fun parseSourceInfoJson(s: String): Set<SourceInfo> {
val json = try {
jacksonObjectMapper().readTree(s)
} catch (e: JsonProcessingException) {
throw IllegalArgumentException("Illegal SourceInfo JSON: $s", e)
}
if (json !is ObjectNode) throw IllegalArgumentException("Illegal SourceInfo JSON: $s")
return json.fields().asSequence().map { (name, sourceNode) ->
val sourceJson = sourceNode as? ObjectNode
?: throw IllegalArgumentException("Invalid source value: $sourceNode")
val muted = sourceJson.get("muted")?.asBoolean() ?: true
val videoTypeNode = sourceJson.get("videoType")
val videoType = when {
videoTypeNode == null || videoTypeNode.isNull -> null
!videoTypeNode.isTextual -> throw IllegalArgumentException("Invalid videoType: $videoTypeNode")
else -> VideoType.parseString(videoTypeNode.asText())
}
val mediaTypeField = sourceJson.get("mediaType")?.takeIf { it.isTextual }?.asText()
val mediaType = when {
"audio".equals(mediaTypeField, ignoreCase = true) -> MediaType.AUDIO
"video".equals(mediaTypeField, ignoreCase = true) -> MediaType.VIDEO
else -> parseMediaTypeFromName(name)
}
SourceInfo(name, muted, videoType, mediaType)
}.toSet()
}
/**
* If not explicitly signaled the media type is encoded in the name as in `abcd-a0` (an audio source) and `abcd-v5`
* (a video source).
*/
private fun parseMediaTypeFromName(name: String): MediaType? = name.indexOf('-').let { indexOfDash ->
if (indexOfDash < 0 || indexOfDash == name.length - 1) {
null
} else {
when (name[indexOfDash + 1]) {
'a' -> MediaType.AUDIO
'v' -> MediaType.VIDEO
else -> null
}
}
}

View File

@@ -0,0 +1,214 @@
// Default values for configuration read by classes in jicofo-common. Defaults for other jicofo.* sections live in
// the reference.conf of the module that owns them (jicofo-selector, jicofo).
jicofo {
// Configure the codecs and RTP extensions to be used in the offer sent to clients.
codec {
video {
av1 {
enabled = true
pt = 41
// Payload type for the associated RTX stream. Set to -1 to disable RTX.
rtx-pt = 42
enable-remb = true
}
vp8 {
enabled = true
pt = 100
// Payload type for the associated RTX stream. Set to -1 to disable RTX.
rtx-pt = 96
enable-remb = true
}
vp9 {
enabled = true
pt = 101
// Payload type for the associated RTX stream. Set to -1 to disable RTX.
rtx-pt = 97
enable-remb = true
}
h264 {
enabled = true
pt = 107
// Payload type for the associated RTX stream. Set to -1 to disable RTX.
rtx-pt = 99
enable-remb = true
}
}
audio {
opus {
enabled = true
pt = 111
minptime = 10
use-inband-fec = true
red {
enabled = false
pt = 112
}
}
telephone-event {
enabled = true
pt = 126
}
}
// RTP header extensions
rtp-extensions {
audio-level {
enabled = true
id = 1
}
av1-dependency-descriptor {
enabled = true
id = 11
}
video-layers-allocation {
enabled = false
id = 12
}
tof {
// TOF is currently disabled, because we don't support it in the bridge
// (and currently clients seem to not use it when abs-send-time is
// available).
enabled = false
id = 2
}
abs-send-time {
enabled = true
id = 3
}
rid {
enabled = false
id = 4
}
tcc {
enabled = true
id = 5
}
video-content-type {
enabled = false
id = 7
}
framemarking {
enabled = false
id = 9
}
mid {
enabled = true
id = 10
}
extmap-allow-mixed = true
}
}
// The region in which the machine is running.
#local-region="us-east-1"
// Controls the metrics accessible at /metrics (but not /stats).
metrics {
// A subset of the metrics are re-calculated and updated periodically. This controls how often.
update-interval = 30 seconds
}
octo {
// Whether or not to use Octo. Note that when enabled, its use will be determined by
// $jicofo.bridge.selection-strategy. There's a corresponding flag in the JVB and these
// two MUST be in sync (otherwise bridges will crash because they won't know how to
// deal with octo channels).
enabled = false
// Whether to allow bridges with different versions to be used in the same conference. Intended only for testing.
allow-mixed-versions = false
// Whether bridge-to-bridge communication should use SCTP datachannels (as opposed to websockets)
sctp-datachannels = ${jicofo.sctp.enabled}
}
sctp {
// Whether to allocate SCTP channels on the bridge (only when the client advertises support, and SCTP is
// enabled in the per-conference configuration).
enabled = true
}
visitors {
# After a visitor is redirected to a vnode, the amount of time to count it toward the vnode's visitor count
# even before its presence shows up. This stops vnodes from getting overloaded when many visitors join simultaneously.
# The rest of the visitors configuration is defined in the jicofo module.
vnode-join-latency-interval = 60 seconds
}
xmpp {
// Whether to use JitsiXmppStringprep to validate JIDs. If set to false uses the default validation in Smack.
use-jitsi-jid-validation = true
// The separate XMPP connection used for communication with clients (endpoints).
client {
enabled = true
hostname = "localhost"
port = 5222
# The domain to use for login.
#domain =
# The domain where XMPP pings will be sent and where components will be discovered.
#xmpp-domain =
username = "focus"
resource = ${jicofo.xmpp.client.username}
#password =
// How long to wait for a response to a stanza before giving up.
reply-timeout = 15 seconds
// The JID/domain of the MUC service used for conferencing.
# conference-muc-jid = conference.example.com
// A flag to suppress the TLS certificate verification.
disable-certificate-verification = false
// The JID of the mod_client_proxy component if used. It will be trusted to encode the JID of the original
// sender in the resource part of the JID.
#client-proxy = focus.example.com
// Use TLS between Jicofo and the XMPP server
// Only disable this if your xmpp connection is on loopback!
use-tls = true
}
// The separate XMPP connection used for internal services (currently only jitsi-videobridge).
service {
enabled = false
hostname = "localhost"
port = 6222
#domain =
# The domain where XMPP pings will be sent and where components will be discovered.
#xmpp-domain =
username = "focus"
resource = ${jicofo.xmpp.service.username}
#password =
// How long to wait for a response to a stanza before giving up.
reply-timeout = 15 seconds
// A flag to suppress the TLS certificate verification.
disable-certificate-verification = false
// Use TLS between Jicofo and the XMPP server
// Only disable this if your xmpp connection is on loopback!
use-tls = true
}
// Additional XMPP environments to use for visitor nodes, mapped by an id/name.
visitors {
#v1 {
#enabled=true
#conference-service = conference.v1.example.com
#hostname = 127.0.0.1
#domain = "auth.v1.example.com"
#port = 7222
#password = "changeme"
#disable-certificate-verification = true
# The domain where XMPP pings will be sent and where components will be discovered.
#xmpp-domain =
#}
}
// The list of domains with trusted services. Only members logged in to these domains can declare themselves to be
// Jibri instances.
trusted-domains = []
}
}

View File

@@ -0,0 +1,42 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2026 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.impl.protocol.xmpp.log
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import org.jitsi.xmpp.extensions.jitsimeet.ConferenceIq
import org.jitsi.xmpp.util.RedactColibri.Companion.redactToken
import org.jxmpp.jid.impl.JidCreate
class PacketDebuggerTest : ShouldSpec({
should("redact the token attribute of a logged ConferenceIq, as done by PacketDebugger") {
val token = "eyJhbGciOiJIUzI1NiJ9.super-secret-jwt"
val iq = ConferenceIq().apply {
room = JidCreate.entityBareFrom("room@conference.example.com")
this.token = token
stanzaId = "id1"
}
val redacted = redactToken(iq.toXML().toString())
redacted shouldNotContain token
redacted shouldContain "room=\"room@conference.example.com\""
redacted shouldContain "token=\"[redacted]\""
}
})

View File

@@ -0,0 +1,33 @@
import io.kotest.core.config.AbstractProjectConfig
import org.jitsi.jicofo.metrics.JicofoMetricsContainer
import org.jitsi.metaconfig.MetaconfigSettings
/*
* Copyright @ 2026 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Note: the jicofo modules share packages, so the class name is unique to avoid conflicts.
*/
class JicofoCommonKotestProjectConfig : AbstractProjectConfig() {
override suspend fun beforeProject() = super.beforeProject().also {
// The only purpose of config caching is performance. We always want caching disabled in tests (so we can
// freely modify the config without affecting other tests executing afterwards).
MetaconfigSettings.cacheEnabled = false
JicofoMetricsContainer.instance.checkForNameConflicts = false
JicofoMetricsContainer.instance.metricsUpdater.disablePeriodicUpdates = true
}
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright @ 2020 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import org.jitsi.config.withLegacyConfig
import org.jitsi.config.withNewConfig
import org.jitsi.jicofo.JicofoConfig.Companion.config
class JicofoConfigTest : ShouldSpec() {
init {
context("Local region config") {
context("With no config") {
config.localRegion shouldBe null
}
context("With legacy config") {
withLegacyConfig("org.jitsi.jicofo.BridgeSelector.LOCAL_REGION=legacy") {
config.localRegion shouldBe "legacy"
}
}
context("With new config") {
withNewConfig("jicofo { local-region=new }") {
config.localRegion shouldBe "new"
}
}
context("With both new and legacy") {
withNewConfig("jicofo { local-region=new }") {
withLegacyConfig("org.jitsi.jicofo.BridgeSelector.LOCAL_REGION=legacy") {
config.localRegion shouldBe "legacy"
}
}
}
}
context("SCTP") {
context("Should be enabled by default") {
config.enableSctp shouldBe true
}
context("Should be disabled with new config") {
withNewConfig("jicofo { sctp { enabled=false } }") {
config.enableSctp shouldBe false
}
}
}
}
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright @ 2020 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import org.jitsi.config.withNewConfig
class OctoConfigTest : ShouldSpec() {
init {
context("Enabled flag") {
// Note that there's a corresponding flag in the JVB and these two
// MUST be in sync (otherwise bridges will crash because they won't
// know how to deal with octo channels).
context("default") {
OctoConfig.config.enabled shouldBe false
}
context("disabled") {
withNewConfig("jicofo.octo.enabled=false") {
OctoConfig.config.enabled shouldBe false
}
}
context("enabled") {
withNewConfig("jicofo.octo.enabled=true") {
OctoConfig.config.enabled shouldBe true
}
}
}
}
}

View File

@@ -0,0 +1,288 @@
/*
* Copyright @ 2020 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.codec
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import org.jitsi.config.withLegacyConfig
import org.jitsi.config.withNewConfig
import org.jitsi.jicofo.codec.Config.Companion.config
class CodecConfigTest : ShouldSpec() {
init {
context("Default configuration") {
config.av1.enabled() shouldBe true
config.av1.pt() shouldBe 41
config.av1.rtxEnabled() shouldBe true
config.av1.rtxPt() shouldBe 42
config.vp8.enabled() shouldBe true
config.vp8.pt() shouldBe 100
config.vp8.rtxEnabled() shouldBe true
config.vp8.rtxPt() shouldBe 96
config.vp9.enabled() shouldBe true
config.vp9.pt() shouldBe 101
config.vp9.rtxEnabled() shouldBe true
config.vp9.rtxPt() shouldBe 97
config.h264.enabled() shouldBe true
config.h264.pt() shouldBe 107
config.h264.rtxEnabled() shouldBe true
config.h264.rtxPt() shouldBe 99
config.opus.enabled() shouldBe true
config.opus.pt() shouldBe 111
config.opus.minptime() shouldBe 10
config.opus.useInbandFec() shouldBe true
config.opus.red.enabled() shouldBe false
shouldThrow<Throwable> { config.opus.red.pt() }
config.framemarking.enabled shouldBe false
config.framemarking.id shouldBe 9
config.absSendTime.enabled shouldBe true
config.absSendTime.id shouldBe 3
config.tof.enabled shouldBe false
config.tof.id shouldBe 2
config.videoContentType.enabled shouldBe false
config.videoContentType.id shouldBe 7
config.rid.enabled shouldBe false
config.rid.id shouldBe 4
config.tcc.enabled shouldBe true
config.tcc.id shouldBe 5
config.audioLevel.enabled shouldBe true
config.audioLevel.id shouldBe 1
config.mid.enabled shouldBe true
config.mid.id shouldBe 10
config.av1DependencyDescriptor.enabled shouldBe true
config.av1DependencyDescriptor.id shouldBe 11
}
context("Legacy config") {
context("Disabling a codec") {
withLegacyConfig("org.jitsi.jicofo.ENABLE_VP8=false") {
config.vp8.enabled() shouldBe false
shouldThrow<Throwable> { config.vp8.pt() }
config.vp8.rtxEnabled() shouldBe false
shouldThrow<Throwable> { config.vp8.rtxPt() }
}
withLegacyConfig("org.jitsi.jicofo.VP8_PT=-1") {
config.vp8.enabled() shouldBe false
shouldThrow<Throwable> { config.vp8.pt() }
config.vp8.rtxEnabled() shouldBe false
shouldThrow<Throwable> { config.vp8.rtxPt() }
}
withLegacyConfig("org.jitsi.jicofo.VP8_RTX_PT=-1") {
config.vp8.enabled() shouldBe true
config.vp8.pt() shouldBe 100
config.vp8.rtxEnabled() shouldBe false
shouldThrow<Throwable> { config.vp8.rtxPt() }
}
withLegacyConfig(
"""
org.jitsi.jicofo.VP8_PT=-1
org.jitsi.jicofo.VP8_RTX_PT=111
""".trimIndent()
) {
config.vp8.enabled() shouldBe false
shouldThrow<Throwable> { config.vp8.pt() }
config.vp8.rtxEnabled() shouldBe false
shouldThrow<Throwable> { config.vp8.rtxPt() }
}
}
context("Changing the PT and RTX PT") {
withLegacyConfig("org.jitsi.jicofo.VP8_PT=111") {
config.vp8.enabled() shouldBe true
config.vp8.pt() shouldBe 111
}
withLegacyConfig("org.jitsi.jicofo.VP8_RTX_PT=112") {
config.vp8.enabled() shouldBe true
config.vp8.pt() shouldBe 100
config.vp8.rtxEnabled() shouldBe true
config.vp8.rtxPt() shouldBe 112
}
}
context("Disabling/enabling extensions") {
withLegacyConfig("org.jitsi.jicofo.ENABLE_FRAMEMARKING=false") {
config.framemarking.enabled shouldBe false
}
withLegacyConfig("org.jitsi.jicofo.ENABLE_FRAMEMARKING=true") {
config.framemarking.enabled shouldBe true
}
withLegacyConfig("org.jitsi.jicofo.ENABLE_AST=false") {
config.absSendTime.enabled shouldBe false
}
withLegacyConfig("org.jitsi.jicofo.ENABLE_AST=true") {
config.absSendTime.enabled shouldBe true
}
withLegacyConfig("org.jitsi.jicofo.ENABLE_RID=false") {
config.rid.enabled shouldBe false
}
withLegacyConfig("org.jitsi.jicofo.ENABLE_RID=true") {
config.rid.enabled shouldBe true
}
withLegacyConfig("org.jitsi.jicofo.ENABLE_TOF=false") {
config.tof.enabled shouldBe false
}
withLegacyConfig("org.jitsi.jicofo.ENABLE_TOF=true") {
config.tof.enabled shouldBe true
}
}
}
context("New config") {
context("Disabling a codec") {
withNewConfig("jicofo.codec.video.vp8.enabled=false") {
config.vp8.enabled() shouldBe false
shouldThrow<Throwable> { config.vp8.pt() }
config.vp8.rtxEnabled() shouldBe false
shouldThrow<Throwable> { config.vp8.rtxPt() }
}
withNewConfig("jicofo.codec.video.vp8.pt=-1") {
config.vp8.enabled() shouldBe false
shouldThrow<Throwable> { config.vp8.pt() }
config.vp8.rtxEnabled() shouldBe false
shouldThrow<Throwable> { config.vp8.rtxPt() }
}
withNewConfig("jicofo.codec.video.vp8.rtx-pt=-1") {
config.vp8.enabled() shouldBe true
config.vp8.pt() shouldBe 100
config.vp8.rtxEnabled() shouldBe false
shouldThrow<Throwable> { config.vp8.rtxPt() }
}
withNewConfig(
"""
jicofo {
codec {
video {
vp8 {
enabled=true
pt=-1
rtp-pt=111
}
}
}
}
""".trimIndent()
) {
config.vp8.enabled() shouldBe false
shouldThrow<Throwable> { config.vp8.pt() }
config.vp8.rtxEnabled() shouldBe false
shouldThrow<Throwable> { config.vp8.rtxPt() }
}
}
context("Changing the PT and RTX PT") {
withNewConfig("jicofo.codec.video.vp8.pt=111") {
config.vp8.enabled() shouldBe true
config.vp8.pt() shouldBe 111
}
withNewConfig("jicofo.codec.video.vp8.rtx-pt=112") {
config.vp8.enabled() shouldBe true
config.vp8.pt() shouldBe 100
config.vp8.rtxEnabled() shouldBe true
config.vp8.rtxPt() shouldBe 112
}
}
context("Disabling/enabling extensions") {
withNewConfig("jicofo.codec.rtp-extensions.framemarking.enabled=false") {
config.framemarking.enabled shouldBe false
}
withNewConfig("jicofo.codec.rtp-extensions.framemarking.enabled=true") {
config.framemarking.enabled shouldBe true
}
withNewConfig("jicofo.codec.rtp-extensions.abs-send-time.enabled=false") {
config.absSendTime.enabled shouldBe false
}
withNewConfig("jicofo.codec.rtp-extensions.abs-send-time.enabled=true") {
config.absSendTime.enabled shouldBe true
}
withNewConfig("jicofo.codec.rtp-extensions.rid.enabled=false") {
config.rid.enabled shouldBe false
}
withNewConfig("jicofo.codec.rtp-extensions.rid.enabled=true") {
config.rid.enabled shouldBe true
}
withNewConfig("jicofo.codec.rtp-extensions.tof.enabled=false") {
config.tof.enabled shouldBe false
}
withNewConfig("jicofo.codec.rtp-extensions.tof.enabled=true") {
config.tof.enabled shouldBe true
}
withNewConfig("jicofo.codec.rtp-extensions.tcc.enabled=false") {
config.tcc.enabled shouldBe false
}
withNewConfig("jicofo.codec.rtp-extensions.tcc.enabled=true") {
config.tcc.enabled shouldBe true
}
withNewConfig("jicofo.codec.rtp-extensions.mid.enabled=false") {
config.mid.enabled shouldBe false
}
withNewConfig("jicofo.codec.rtp-extensions.mid.enabled=true") {
config.mid.enabled shouldBe true
}
withNewConfig("jicofo.codec.rtp-extensions.av1-dependency-descriptor.enabled=true") {
config.av1DependencyDescriptor.enabled shouldBe true
}
withNewConfig("jicofo.codec.rtp-extensions.av1-dependency-descriptor.enabled=false") {
config.av1DependencyDescriptor.enabled shouldBe false
}
}
context("Changing extension IDs") {
withNewConfig("jicofo.codec.rtp-extensions.tcc.id=1") {
config.tcc.id shouldBe 1
}
withNewConfig("jicofo.codec.rtp-extensions.abs-send-time.id=1") {
config.absSendTime.id shouldBe 1
}
}
context("Opus config") {
withNewConfig("jicofo.codec.audio.opus.red.enabled=true") {
config.opus.red.enabled() shouldBe true
config.opus.red.pt() shouldBe 112
}
}
}
context("With both legacy and new config, legacy should take precedence") {
withLegacyConfig("org.jitsi.jicofo.ENABLE_VP8=false") {
withNewConfig("jicofo.codec.video.vp8.enabled=true") {
config.vp8.enabled() shouldBe false
}
}
withLegacyConfig("org.jitsi.jicofo.ENABLE_VP8=true") {
withNewConfig("jicofo.codec.video.vp8.enabled=false") {
config.vp8.enabled() shouldBe true
}
}
withLegacyConfig("org.jitsi.jicofo.ENABLE_FRAMEMARKING=true") {
withNewConfig("jicofo.codec.rtp-extensions.framemarking.enabled=false") {
config.framemarking.enabled() shouldBe true
}
}
withLegacyConfig("org.jitsi.jicofo.ENABLE_FRAMEMARKING=false") {
withNewConfig("jicofo.codec.rtp-extensions.framemarking.enabled=true") {
config.framemarking.enabled() shouldBe false
}
}
}
}
}

View File

@@ -0,0 +1,175 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2022-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.codec
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import org.jitsi.config.withNewConfig
import java.net.URI
class CodecUtilTest : ShouldSpec() {
override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf
init {
context("Video") {
context("VP8") {
context("Default config") {
val vp8 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "VP8" }
vp8 shouldNotBe null
vp8!!.clockrate shouldBe 90000
vp8.id shouldBe Config.config.vp8.pt()
vp8.rtcpFeedbackTypeList.any { it.feedbackType == "nack" } shouldBe true
vp8.rtcpFeedbackTypeList.any { it.feedbackType == "transport-cc" } shouldBe true
vp8.rtcpFeedbackTypeList.any { it.feedbackType == "goog-remb" } shouldBe false
val vp8Rtx = CodecUtil.createVideoPayloadTypeExtensions().find {
it.id == Config.config.vp8.rtxPt()
}
vp8Rtx shouldNotBe null
vp8Rtx!!.id shouldBe Config.config.vp8.rtxPt()
vp8Rtx.name shouldBe "rtx"
vp8Rtx.parameters.any {
it.name == "apt" && it.value == Config.config.vp8.pt().toString()
} shouldBe true
}
withNewConfig("jicofo.codec.video.vp8.enabled=false") {
val vp8 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "VP8" }
vp8 shouldBe null
}
withNewConfig("jicofo.codec.video.vp8.pt=123") {
val vp8 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "VP8" }
vp8!!.id shouldBe 123
}
}
context("VP9") {
context("Default config") {
val vp9 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "VP9" }
vp9 shouldNotBe null
vp9!!.clockrate shouldBe 90000
vp9.id shouldBe Config.config.vp9.pt()
vp9.rtcpFeedbackTypeList.any { it.feedbackType == "nack" } shouldBe true
vp9.rtcpFeedbackTypeList.any { it.feedbackType == "transport-cc" } shouldBe true
vp9.rtcpFeedbackTypeList.any { it.feedbackType == "goog-remb" } shouldBe false
val vp9Rtx = CodecUtil.createVideoPayloadTypeExtensions().find {
it.id == Config.config.vp9.rtxPt()
}
vp9Rtx shouldNotBe null
vp9Rtx!!.id shouldBe Config.config.vp9.rtxPt()
vp9Rtx.name shouldBe "rtx"
vp9Rtx.parameters.any {
it.name == "apt" && it.value == Config.config.vp9.pt().toString()
} shouldBe true
}
withNewConfig("jicofo.codec.video.vp9.enabled=false") {
val vp9 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "VP9" }
vp9 shouldBe null
}
withNewConfig("jicofo.codec.video.vp9.pt=123") {
val vp9 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "VP9" }
vp9!!.id shouldBe 123
}
}
context("H264") {
context("Default config") {
val h264 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "H264" }
h264 shouldNotBe null
h264!!.clockrate shouldBe 90000
h264.id shouldBe Config.config.h264.pt()
h264.rtcpFeedbackTypeList.any { it.feedbackType == "nack" } shouldBe true
h264.rtcpFeedbackTypeList.any { it.feedbackType == "transport-cc" } shouldBe true
h264.rtcpFeedbackTypeList.any { it.feedbackType == "goog-remb" } shouldBe false
val h264Rtx = CodecUtil.createVideoPayloadTypeExtensions().find {
it.id == Config.config.h264.rtxPt()
}
h264Rtx shouldNotBe null
h264Rtx!!.id shouldBe Config.config.h264.rtxPt()
h264Rtx.name shouldBe "rtx"
h264Rtx.parameters.any {
it.name == "apt" && it.value == Config.config.h264.pt().toString()
} shouldBe true
}
withNewConfig("jicofo.codec.video.h264.enabled=false") {
val h264 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "H264" }
h264 shouldBe null
}
withNewConfig("jicofo.codec.video.h264.pt=123") {
val h264 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "H264" }
h264!!.id shouldBe 123
}
}
}
context("Audio") {
context("Opus") {
val opus = CodecUtil.createAudioPayloadTypeExtensions().find { it.name == "opus" }
opus shouldNotBe null
opus!!.id shouldBe Config.config.opus.pt()
opus.clockrate shouldBe 48000
opus.channels shouldBe 2
}
context("RED") {
CodecUtil.createAudioPayloadTypeExtensions().any { it.name == "red" } shouldBe false
withNewConfig("jicofo.codec.audio.opus.red.enabled=true") {
val red = CodecUtil.createAudioPayloadTypeExtensions().find { it.name == "red" }
red shouldNotBe null
val opusPt = Config.config.opus.pt()
red!!.parameters.any { it.value == "$opusPt/$opusPt" } shouldBe true
}
}
}
context("RTP Header Extensions") {
context("SSRC Audio Level") {
val alUri = URI.create("urn:ietf:params:rtp-hdrext:ssrc-audio-level")
CodecUtil.createAudioRtpHdrExtExtensions().any { it.uri == alUri } shouldBe true
CodecUtil.createVideoRtpHdrExtExtensions().any { it.uri == alUri } shouldBe false
}
context("MID") {
val midUri = URI.create("urn:ietf:params:rtp-hdrext:sdes:mid")
CodecUtil.createAudioRtpHdrExtExtensions().any { it.uri == midUri } shouldBe true
CodecUtil.createVideoRtpHdrExtExtensions().any { it.uri == midUri } shouldBe true
withNewConfig("jicofo.codec.rtp-extensions.mid.enabled=false") {
CodecUtil.createAudioRtpHdrExtExtensions().any { it.uri == midUri } shouldBe false
CodecUtil.createVideoRtpHdrExtExtensions().any { it.uri == midUri } shouldBe false
}
}
context("TCC") {
val tccUri = URI.create("http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01")
CodecUtil.createAudioRtpHdrExtExtensions().any { it.uri == tccUri } shouldBe true
CodecUtil.createVideoRtpHdrExtExtensions().any { it.uri == tccUri } shouldBe true
CodecUtil.createAudioRtpHdrExtExtensions(OfferOptions(tcc = false)).any {
it.uri == tccUri
} shouldBe false
CodecUtil.createVideoRtpHdrExtExtensions(OfferOptions(tcc = false)).any {
it.uri == tccUri
} shouldBe false
withNewConfig("jicofo.codec.rtp-extensions.tcc.enabled=false") {
CodecUtil.createAudioRtpHdrExtExtensions().any { it.uri == tccUri } shouldBe false
CodecUtil.createVideoRtpHdrExtExtensions().any { it.uri == tccUri } shouldBe false
}
}
}
}
}

View File

@@ -0,0 +1,261 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import org.jitsi.jicofo.util.shouldBeValidJson
import org.jitsi.utils.MediaType
import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension
import org.jitsi.xmpp.extensions.jingle.RtpDescriptionPacketExtension
import org.jitsi.xmpp.extensions.jingle.SourceGroupPacketExtension
import org.jitsi.xmpp.extensions.jitsimeet.SSRCInfoPacketExtension
import java.lang.UnsupportedOperationException
@Suppress("NAME_SHADOWING")
@SuppressFBWarnings(
value = ["RCN_REDUNDANT_NULLCHECK_OF_NONNULL_VALUE"],
justification = "False positives."
)
class ConferenceSourceMapTest : ShouldSpec() {
override fun isolationMode() = IsolationMode.InstancePerLeaf
init {
val s7 = Source(7, MediaType.AUDIO)
val endpoint1SourceSet = EndpointSourceSet(
setOf(s1, s2, s7),
setOf(
SsrcGroup(SsrcGroupSemantics.Fid, listOf(1, 2))
)
)
val endpoint1AdditionalSourceSet = EndpointSourceSet(
setOf(
// The duplicates should be removed
s1,
s2,
s3,
s4,
s7
),
setOf(SsrcGroup(SsrcGroupSemantics.Fid, listOf(3, 4)))
)
val endpoint1CombinedSourceSet = endpoint1SourceSet + endpoint1AdditionalSourceSet
val endpoint2SourceSet = EndpointSourceSet(
setOf(
Source(101, MediaType.VIDEO),
Source(102, MediaType.VIDEO),
Source(103, MediaType.AUDIO)
),
setOf(
SsrcGroup(SsrcGroupSemantics.Fid, listOf(101, 102))
)
)
context("Constructors") {
context("Default") {
val conferenceSourceMap = ConferenceSourceMap(mutableMapOf(JID_1 to endpoint1SourceSet))
conferenceSourceMap.size shouldBe 1
conferenceSourceMap[JID_1] shouldBe endpoint1SourceSet
}
context("With a single EndpointSourceSet") {
val conferenceSourceMap = ConferenceSourceMap(JID_1 to endpoint1SourceSet)
conferenceSourceMap.size shouldBe 1
conferenceSourceMap[JID_1] shouldBe endpoint1SourceSet
}
context("With multiple EndpointSourceSets") {
val conferenceSourceMap = ConferenceSourceMap(
JID_1 to endpoint1SourceSet,
JID_2 to endpoint2SourceSet
)
conferenceSourceMap.size shouldBe 2
conferenceSourceMap[JID_1] shouldBe endpoint1SourceSet
conferenceSourceMap[JID_2] shouldBe endpoint2SourceSet
}
}
context("Add") {
val conferenceSourceMap = ConferenceSourceMap(JID_1 to endpoint1SourceSet)
context("Without overlap in endpoints") {
conferenceSourceMap.add(JID_2, endpoint2SourceSet)
conferenceSourceMap.size shouldBe 2
conferenceSourceMap[JID_1] shouldBe endpoint1SourceSet
conferenceSourceMap[JID_2] shouldBe endpoint2SourceSet
}
context("With overlap in endpoints") {
conferenceSourceMap.add(
ConferenceSourceMap(
JID_1 to endpoint1AdditionalSourceSet,
JID_2 to endpoint2SourceSet
)
)
conferenceSourceMap.size shouldBe 2
conferenceSourceMap[JID_1] shouldBe endpoint1CombinedSourceSet
conferenceSourceMap[JID_2] shouldBe endpoint2SourceSet
}
}
context("Remove") {
val conferenceSourceMap = ConferenceSourceMap(
JID_1 to endpoint1CombinedSourceSet,
JID_2 to endpoint2SourceSet
)
context("All of an endpoint's sources") {
conferenceSourceMap.remove(ConferenceSourceMap(JID_2 to endpoint2SourceSet))
conferenceSourceMap.size shouldBe 1
conferenceSourceMap[JID_1] shouldBe endpoint1CombinedSourceSet
conferenceSourceMap[JID_2] shouldBe null
}
context("Some of an endpoint's sources") {
conferenceSourceMap.remove(ConferenceSourceMap(JID_1 to endpoint1SourceSet))
conferenceSourceMap.size shouldBe 2
conferenceSourceMap[JID_1] shouldBe EndpointSourceSet(
setOf(s3, s4),
setOf(SsrcGroup(SsrcGroupSemantics.Fid, listOf(3, 4)))
)
conferenceSourceMap[JID_2] shouldBe endpoint2SourceSet
}
context("Nonexistent endpoint sources") {
conferenceSourceMap.remove(
ConferenceSourceMap(
JID_1 to EndpointSourceSet(
setOf(Source(12345, MediaType.VIDEO)),
setOf(SsrcGroup(SsrcGroupSemantics.Fid, listOf(12345)))
)
)
)
conferenceSourceMap.size shouldBe 2
conferenceSourceMap[JID_1] shouldBe endpoint1CombinedSourceSet
conferenceSourceMap[JID_2] shouldBe endpoint2SourceSet
}
context("Nonexistent endpoint") {
conferenceSourceMap.remove(ConferenceSourceMap("differentJid" to endpoint1CombinedSourceSet))
conferenceSourceMap.size shouldBe 2
conferenceSourceMap[JID_1] shouldBe endpoint1CombinedSourceSet
conferenceSourceMap[JID_2] shouldBe endpoint2SourceSet
}
}
context("Operator plus") {
val a = ConferenceSourceMap(JID_1 to endpoint1SourceSet)
val b = ConferenceSourceMap(JID_2 to endpoint2SourceSet)
(a + b) shouldBe ConferenceSourceMap(JID_1 to endpoint1SourceSet, JID_2 to endpoint2SourceSet)
}
context("Operator minus") {
val a = ConferenceSourceMap(JID_1 to endpoint1SourceSet, JID_2 to endpoint2SourceSet)
val b = ConferenceSourceMap(JID_1 to endpoint1SourceSet)
(a - b) shouldBe ConferenceSourceMap(JID_2 to endpoint2SourceSet)
}
context("To Jingle") {
val conferenceSourceMap = ConferenceSourceMap(JID_1 to endpoint1SourceSet)
val contents = conferenceSourceMap.toJingle()
contents.size shouldBe 2
val videoContent = contents.find { it.name == "video" }!!
val videoRtpDesc = videoContent.getFirstChildOfType(RtpDescriptionPacketExtension::class.java)!!
val videoSources =
videoRtpDesc.getChildExtensionsOfType(SourcePacketExtension::class.java)
videoSources.map { Source(MediaType.VIDEO, it) }.toSet() shouldBe setOf(
Source(1, MediaType.VIDEO),
Source(2, MediaType.VIDEO)
)
videoSources.forEach {
it.getFirstChildOfType(SSRCInfoPacketExtension::class.java).owner shouldBe JID_1
}
val sourceGroupPacketExtension =
videoRtpDesc.getFirstChildOfType(SourceGroupPacketExtension::class.java)!!
SsrcGroup.fromPacketExtension(sourceGroupPacketExtension) shouldBe
SsrcGroup(SsrcGroupSemantics.Fid, listOf(1, 2))
val audioContent = contents.find { it.name == "audio" }!!
val audioRtpDesc = audioContent.getFirstChildOfType(RtpDescriptionPacketExtension::class.java)!!
val audioSources = audioRtpDesc.getChildExtensionsOfType(SourcePacketExtension::class.java)
audioSources.map { Source(MediaType.AUDIO, it) } shouldBe listOf(s7)
audioSources.forEach {
it.getFirstChildOfType(SSRCInfoPacketExtension::class.java).owner shouldBe JID_1
}
}
context("unmodifiable") {
val conferenceSourceMap = ConferenceSourceMap(
JID_1 to endpoint1SourceSet,
JID_2 to endpoint2SourceSet
)
val unmodifiable = conferenceSourceMap.unmodifiable
unmodifiable[JID_1] shouldBe endpoint1SourceSet
conferenceSourceMap.add(JID_1, endpoint1AdditionalSourceSet)
conferenceSourceMap[JID_1] shouldBe endpoint1CombinedSourceSet
unmodifiable[JID_1] shouldBe endpoint1CombinedSourceSet
shouldThrow<UnsupportedOperationException> {
unmodifiable.add(ConferenceSourceMap())
}
shouldThrow<UnsupportedOperationException> {
unmodifiable.remove(ConferenceSourceMap())
}
}
context("Strip") {
val s7 = Source(7, MediaType.AUDIO)
val sourceSet = EndpointSourceSet(
setOf(s1, s2, s3, s4, s5, s6, s7),
setOf(sim, fid1, fid2, fid3)
)
val conferenceSourceMap = ConferenceSourceMap(JID_1 to sourceSet, JID_2 to e2sourceSet)
// Assume EndpointSourceSet.stripSimulcast works correctly, tested above.
context("Simulcast") {
conferenceSourceMap.stripSimulcast()
conferenceSourceMap[JID_1] shouldBe sourceSet.stripSimulcast
conferenceSourceMap[JID_2] shouldBe e2sourceSet.stripSimulcast
}
}
context("Compact JSON") {
val endpointId1 = "bebebebe"
val endpointId2 = "dadadada"
val jvb = "jvb"
val jvbEndpointSourceSet = EndpointSourceSet(Source(12345, MediaType.VIDEO))
val conferenceSourceMap = ConferenceSourceMap(
endpointId1 to sourceSet,
endpointId2 to e2sourceSet,
jvb to jvbEndpointSourceSet
)
val jsonString = conferenceSourceMap.compactJson()
val json = jacksonObjectMapper().readTree(jsonString)
json.shouldBeInstanceOf<ObjectNode>()
json.size() shouldBe 3
json.fieldNames().asSequence().toSet() shouldBe setOf(endpointId1, endpointId2, "jvb")
}
context("Debug json") {
val conferenceSourceMap = ConferenceSourceMap(
"jid1" to endpoint1CombinedSourceSet,
"jid2" to endpoint2SourceSet
)
conferenceSourceMap.toJson().shouldBeValidJson()
}
}
}

View File

@@ -0,0 +1,164 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import org.jitsi.utils.MediaType.AUDIO
import org.jitsi.utils.MediaType.VIDEO
import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension
import org.jitsi.xmpp.extensions.jingle.RtpDescriptionPacketExtension
import org.jitsi.xmpp.extensions.jingle.SourceGroupPacketExtension
@Suppress("NAME_SHADOWING")
class EndpointSourceSetTest : ShouldSpec() {
override fun isolationMode() = IsolationMode.InstancePerLeaf
init {
context("From XML") {
// Assume serializing works correctly -- it's tested below.
val contents = sourceSet.toJingle(JID_1)
val parsed = EndpointSourceSet.fromJingle(contents)
// Use the provided ownerJid, not the one encoded in XML.
parsed shouldBe sourceSet
}
context("To XML") {
val contents = sourceSet.toJingle()
contents.size shouldBe 2
val videoContent = contents.find { it.name == "video" }!!
val videoRtpDesc = videoContent.getFirstChildOfType(RtpDescriptionPacketExtension::class.java)!!
val videoSources = videoRtpDesc.getChildExtensionsOfType(SourcePacketExtension::class.java)
.map { Source(VIDEO, it) }.toSet()
videoSources shouldBe sourceSet.sources.filter { it.mediaType == VIDEO }.toSet()
val sourceGroupPacketExtensions =
videoRtpDesc.getChildExtensionsOfType(SourceGroupPacketExtension::class.java)
sourceGroupPacketExtensions.map { SsrcGroup.fromPacketExtension(it) }.toSet() shouldBe
sourceSet.ssrcGroups
val audioContent = contents.find { it.name == "audio" }!!
val audioRtpDesc = audioContent.getFirstChildOfType(RtpDescriptionPacketExtension::class.java)!!
val audioSources = audioRtpDesc.getChildExtensionsOfType(SourcePacketExtension::class.java)
.map { Source(AUDIO, it) }.toSet()
audioSources shouldBe sourceSet.sources.filter { it.mediaType == AUDIO }.toSet()
}
context("Strip simulcast") {
val s8 = Source(8, VIDEO)
context("Without RTX") {
EndpointSourceSet(
setOf(s1, s2, s3, s7, s8),
setOf(sim)
).stripSimulcast shouldBe EndpointSourceSet(setOf(s1, s7, s8))
}
context("With multiple SIM groups") {
EndpointSourceSet(
sourceSet.sources + s8,
setOf(
sim,
SsrcGroup(SsrcGroupSemantics.Sim, listOf(4, 5, 6))
)
).stripSimulcast shouldBe EndpointSourceSet(setOf(s1, s4, s7, s8))
}
context("With RTX") {
EndpointSourceSet(
sourceSet.sources + s8,
sourceSet.ssrcGroups
).stripSimulcast shouldBe EndpointSourceSet(
setOf(s1, s4, s7, s8),
setOf(fid1)
)
}
context("Compact JSON") {
// See the documentation of [EndpointSourceSet.compactJson] for the expected JSON format.
val json = jacksonObjectMapper().readTree(sourceSet.compactJson)
json.shouldBeInstanceOf<ArrayNode>()
val videoSourceList = json[0]
videoSourceList.shouldBeInstanceOf<ArrayNode>()
videoSourceList.map { node -> mapOf("s" to node["s"].asLong()) }.toSet() shouldBe setOf(
mapOf("s" to 1L),
mapOf("s" to 2L),
mapOf("s" to 3L),
mapOf("s" to 4L),
mapOf("s" to 5L),
mapOf("s" to 6L)
)
val videoSsrcGroups = json[1]
videoSsrcGroups.shouldBeInstanceOf<ArrayNode>()
videoSsrcGroups.map { groupNode ->
groupNode.map { if (it.isTextual) it.asText() else it.asInt() }
}.toSet() shouldBe setOf(
listOf("s", 1, 2, 3),
listOf("f", 1, 4),
listOf("f", 2, 5),
listOf("f", 3, 6)
)
val audioSourceList = json[2]
audioSourceList.shouldBeInstanceOf<ArrayNode>()
audioSourceList.map { node -> mapOf("s" to node["s"].asLong()) }.toSet() shouldBe setOf(
mapOf("s" to 7L)
)
// No audio source groups encoded.
json.size() shouldBe 3
}
}
}
}
const val JID_1 = "jid1"
const val JID_2 = "jid2"
val s1 = Source(1, VIDEO)
val s2 = Source(2, VIDEO)
val s3 = Source(3, VIDEO)
val s4 = Source(4, VIDEO)
val s5 = Source(5, VIDEO)
val s6 = Source(6, VIDEO)
val s7 = Source(7, AUDIO)
val sim = SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2, 3))
val fid1 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(1, 4))
val fid2 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(2, 5))
val fid3 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(3, 6))
val sourceSet = EndpointSourceSet(
setOf(s1, s2, s3, s4, s5, s6, s7),
setOf(sim, fid1, fid2, fid3)
)
val e2s1 = Source(11, VIDEO)
val e2s2 = Source(12, VIDEO)
val e2s3 = Source(13, VIDEO)
val e2s4 = Source(14, VIDEO)
val e2s5 = Source(15, VIDEO)
val e2s6 = Source(16, VIDEO)
val e2s7 = Source(17, AUDIO)
val e2sim = SsrcGroup(SsrcGroupSemantics.Sim, listOf(11, 12, 13))
val e2fid1 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(11, 14))
val e2fid2 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(12, 15))
val e2fid3 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(13, 16))
val e2sourceSet = EndpointSourceSet(
setOf(e2s1, e2s2, e2s3, e2s4, e2s5, e2s6, e2s7),
setOf(e2sim, e2fid1, e2fid2, e2fid3)
)

View File

@@ -0,0 +1,79 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import org.jitsi.utils.MediaType
import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension
import org.jitsi.xmpp.extensions.jingle.ParameterPacketExtension
import org.jitsi.xmpp.extensions.jitsimeet.SSRCInfoPacketExtension
class SourceTest : ShouldSpec() {
init {
context("From XML") {
val packetExtension = SourcePacketExtension().apply {
ssrc = 1
name = "name-1"
videoType = "camera"
addChildExtension(ParameterPacketExtension("msid", "msid"))
}
Source(MediaType.VIDEO, packetExtension) shouldBe
Source(1, MediaType.VIDEO, name = "name-1", msid = "msid", videoType = VideoType.Camera)
}
context("To XML") {
val msidValue = "msid-value"
val nameValue = "source-name-value"
val videoType = VideoType.Desktop
val source = Source(1, MediaType.VIDEO, name = nameValue, msid = msidValue, videoType = videoType)
val owner = "abcdabcd"
val extension = source.toPacketExtension(owner = owner)
extension.ssrc shouldBe 1
extension.name shouldBe nameValue
extension.videoType shouldBe videoType.toString()
val parameters = extension.getChildExtensionsOfType(ParameterPacketExtension::class.java)
parameters.filter { it.name == "msid" && it.value == msidValue }.size shouldBe 1
val ssrcInfo = extension.getFirstChildOfType(SSRCInfoPacketExtension::class.java)
ssrcInfo shouldNotBe null
ssrcInfo.owner.toString() shouldBe owner
}
context("Compact JSON") {
Source(1, MediaType.VIDEO, name = "test-name", msid = "msid").compactJson shouldBe
"""
{"s":1,"n":"test-name","m":"msid"}
""".trimIndent()
Source(
1,
MediaType.VIDEO,
videoType = VideoType.Desktop,
name = "test-name",
msid = "msid"
).compactJson shouldBe
"""
{"s":1,"n":"test-name","m":"msid","v":"d"}
""".trimIndent()
Source(1, MediaType.AUDIO).compactJson shouldBe """
{"s":1}
""".trimIndent()
}
}
}

View File

@@ -0,0 +1,77 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import org.jitsi.utils.MediaType
import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension
import org.jitsi.xmpp.extensions.jingle.SourceGroupPacketExtension
class SsrcGroupTest : ShouldSpec() {
init {
context("SsrcGroupSemantics") {
context("Parsing") {
SsrcGroupSemantics.fromString("sim") shouldBe SsrcGroupSemantics.Sim
SsrcGroupSemantics.fromString("SIM") shouldBe SsrcGroupSemantics.Sim
SsrcGroupSemantics.fromString("sIM") shouldBe SsrcGroupSemantics.Sim
SsrcGroupSemantics.fromString("fiD") shouldBe SsrcGroupSemantics.Fid
shouldThrow<NoSuchElementException> {
SsrcGroupSemantics.fromString("invalid")
}
}
context("To string") {
SsrcGroupSemantics.Sim.toString() shouldBe "SIM"
SsrcGroupSemantics.Fid.toString() shouldBe "FID"
}
}
context("From XML") {
val packetExtension = SourceGroupPacketExtension().apply {
semantics = "sim"
addSources(
listOf(
SourcePacketExtension().apply { ssrc = 1 },
SourcePacketExtension().apply { ssrc = 2 },
SourcePacketExtension().apply { ssrc = 3 }
)
)
}
val ssrcGroup = SsrcGroup.fromPacketExtension(packetExtension)
ssrcGroup shouldBe SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2, 3))
}
context("To XML") {
val ssrcGroup = SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2, 3))
val packetExtension = ssrcGroup.toPacketExtension()
packetExtension.semantics shouldBe "SIM"
packetExtension.sources.size shouldBe 3
packetExtension.sources.map { Source(MediaType.VIDEO, it) }.toList() shouldBe listOf(
Source(1, MediaType.VIDEO),
Source(2, MediaType.VIDEO),
Source(3, MediaType.VIDEO)
)
}
context("Compact JSON") {
SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2, 3)).compactJson shouldBe """
["s",1,2,3]
""".trimIndent()
}
}
}

View File

@@ -0,0 +1,129 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import org.jitsi.utils.MediaType
import org.jitsi.utils.logging2.createLogger
import java.time.Clock
import java.time.Duration
class SsrcValidationPerfTest : ShouldSpec() {
init {
val numEndpoints = 500
xcontext("ValidatingConferenceSourceMap") {
context("Add/Remove a source to a large conference") {
val conferenceSources = createConferenceSourceMap(numEndpoints)
conferenceSources.size shouldBe numEndpoints
val newEndpointSourceSet = createEndpointSourceSet("new-endpoint", ssrcCount)
val newEndpointId = "new-endpoint"
ssrcCount += newEndpointSourceSet.sources.size
var added: EndpointSourceSet? = null
measureAndLog("Single add") {
added = conferenceSources.tryToAdd(newEndpointId, newEndpointSourceSet)
}
added shouldBe newEndpointSourceSet
conferenceSources.size shouldBe numEndpoints + 1
var removed: EndpointSourceSet? = null
measureAndLog("Single remove") {
removed = conferenceSources.tryToRemove(newEndpointId, newEndpointSourceSet)
}
conferenceSources.size shouldBe numEndpoints
removed shouldBe newEndpointSourceSet
}
context("Sequential add/remove") {
val conferenceSources = ValidatingConferenceSourceMap(
MAX_SSRCS_PER_USER,
MAX_SSRC_GROUPS_PER_USER
)
measureAndLog("Adding all endpoints") {
for (i in 0 until numEndpoints) {
val endpointId = "endpoint-$i"
val sourceSet = createEndpointSourceSet(endpointId, ssrcCount)
ssrcCount += sourceSet.sources.size
measureAndLog("Adding endpoint $i") {
conferenceSources.tryToAdd(endpointId, sourceSet)
}
}
}
conferenceSources.size shouldBe numEndpoints
measureAndLog("Removing all endpoints") {
var i = 0
while (conferenceSources.isNotEmpty()) {
val sourceToRemove = conferenceSources.entries.first()
measureAndLog("Removing endpoint $i") {
conferenceSources.tryToRemove(sourceToRemove.key, sourceToRemove.value)
}
i++
}
}
conferenceSources.size shouldBe 0
}
}
}
private var ssrcCount = 1L
private fun createEndpointSourceSet(endpointId: String, ssrcBase: Long) = EndpointSourceSet(
setOf(
Source(ssrcBase, MediaType.VIDEO, msid = "msid-$endpointId"),
Source(ssrcBase + 1, MediaType.VIDEO, msid = "msid-$endpointId"),
Source(ssrcBase + 2, MediaType.VIDEO, msid = "msid-$endpointId"),
Source(ssrcBase + 3, MediaType.VIDEO, msid = "msid-$endpointId"),
Source(ssrcBase + 4, MediaType.VIDEO, msid = "msid-$endpointId"),
Source(ssrcBase + 5, MediaType.VIDEO, msid = "msid-$endpointId"),
Source(ssrcBase + 6, MediaType.AUDIO, msid = "msid-$endpointId")
),
setOf(
SsrcGroup(SsrcGroupSemantics.Sim, listOf(ssrcBase, ssrcBase + 1, ssrcBase + 2)),
SsrcGroup(SsrcGroupSemantics.Fid, listOf(ssrcBase, ssrcBase + 3)),
SsrcGroup(SsrcGroupSemantics.Fid, listOf(ssrcBase + 1, ssrcBase + 4)),
SsrcGroup(SsrcGroupSemantics.Fid, listOf(ssrcBase + 2, ssrcBase + 5)),
)
)
private fun createConferenceSourceMap(numEndpoints: Int) = ValidatingConferenceSourceMap(
MAX_SSRCS_PER_USER,
MAX_SSRC_GROUPS_PER_USER
).apply {
for (i in 0 until numEndpoints) {
val endpointSourceSet = createEndpointSourceSet(i.toString(), ssrcCount)
ssrcCount += endpointSourceSet.sources.size
add("endpoint$i", endpointSourceSet)
}
}
private val logger = createLogger()
private fun measureAndLog(name: String, clock: Clock = Clock.systemUTC(), block: () -> Unit) {
logger.info("$name took ${measure(clock, block).toMillis()}")
}
}
fun measure(clock: Clock = Clock.systemUTC(), block: () -> Unit): Duration {
val start = clock.instant()
block()
return Duration.between(start, clock.instant())
}
// Default values from jicofo.conference.max-ssrcs-per-user / max-ssrc-groups-per-user.
private const val MAX_SSRCS_PER_USER = 20
private const val MAX_SSRC_GROUPS_PER_USER = 20

View File

@@ -0,0 +1,417 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import org.jitsi.utils.MediaType.AUDIO
import org.jitsi.utils.MediaType.VIDEO
@Suppress("NAME_SHADOWING")
class ValidatingConferenceSourceMapTest : ShouldSpec() {
override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf
init {
val jid1 = "jid1"
val jid2 = "jid2"
val msid = "msid"
val s1 = Source(1, VIDEO, msid = msid)
val s2 = Source(2, VIDEO, msid = msid)
val s3 = Source(3, VIDEO, msid = msid)
val s4 = Source(4, VIDEO, msid = msid)
val s5 = Source(5, VIDEO, msid = msid)
val s6 = Source(6, VIDEO, msid = msid)
val s7 = Source(7, AUDIO)
val videoSources = setOf(s1, s2, s3, s4, s5, s6)
val sources = setOf(s1, s2, s3, s4, s5, s6, s7)
val sim = SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2, 3))
val fid1 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(1, 4))
val fid2 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(2, 5))
val fid3 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(3, 6))
val groups = setOf(sim, fid1, fid2, fid3)
val sourceSet = EndpointSourceSet(sources, groups)
val conferenceSources = ValidatingConferenceSourceMap(
MAX_SSRCS_PER_USER,
MAX_SSRC_GROUPS_PER_USER
)
context("Adding sources.") {
context("Empty sources") {
conferenceSources.tryToAdd(jid1, EndpointSourceSet()) shouldBe EndpointSourceSet.EMPTY
}
context("Standard VP8 signaling with simulcast and RTX") {
context("Signaled all together") {
conferenceSources.tryToAdd(jid1, sourceSet) shouldBe sourceSet
}
context("Signaled with video first") {
conferenceSources.tryToAdd(jid1, EndpointSourceSet(videoSources, groups)) shouldBe
EndpointSourceSet(videoSources, groups)
conferenceSources.tryToAdd(jid1, EndpointSourceSet(s7)) shouldBe EndpointSourceSet(s7)
}
context("Signaled with audio first") {
conferenceSources.tryToAdd(jid1, EndpointSourceSet(s7)) shouldBe EndpointSourceSet(s7)
conferenceSources.tryToAdd(jid1, EndpointSourceSet(videoSources, groups)) shouldBe
EndpointSourceSet(videoSources, groups)
}
context("Adding a second endpoint") {
val sourceSet2 = EndpointSourceSet(
setOf(
Source(101, VIDEO, msid = "msid2"),
Source(102, VIDEO, msid = "msid2"),
Source(103, AUDIO)
),
setOf(SsrcGroup(SsrcGroupSemantics.Fid, listOf(101, 102)))
)
conferenceSources.tryToAdd(jid1, sourceSet) shouldBe sourceSet
conferenceSources.tryToAdd(jid2, sourceSet2) shouldBe sourceSet2
}
}
context("Invalid SSRCs") {
val invalidSsrcs = listOf(-1, 0, 0x1_0000_0000)
invalidSsrcs.forEach {
val sourcesWithInvalidSsrc = EndpointSourceSet(setOf(Source(it, AUDIO)))
shouldThrow<InvalidSsrcException> {
conferenceSources.tryToAdd(jid1, sourcesWithInvalidSsrc)
}
}
}
context("Max SSRC count limit") {
val conferenceSources = ValidatingConferenceSourceMap(
maxSsrcsPerUser = 4,
MAX_SSRC_GROUPS_PER_USER
)
context("At once") {
shouldThrow<SsrcLimitExceededException> {
conferenceSources.tryToAdd(jid1, sourceSet)
}
}
context("With multiple adds") {
val sourceSet1 = EndpointSourceSet(
setOf(s1, s2, s3),
setOf(sim)
)
val sourceSet2 = EndpointSourceSet(
setOf(s4, s5, s6),
setOf(fid1, fid2, fid3)
)
conferenceSources.tryToAdd(jid1, sourceSet1) shouldBe sourceSet1
shouldThrow<SsrcLimitExceededException> {
conferenceSources.tryToAdd(jid1, sourceSet2)
}
}
}
context("Max ssrc-group count limit") {
val conferenceSources = ValidatingConferenceSourceMap(
MAX_SSRCS_PER_USER,
maxSsrcGroupsPerUser = 2
)
context("At once") {
shouldThrow<SsrcGroupLimitExceededException> {
conferenceSources.tryToAdd(jid1, sourceSet)
}
}
context("With multiple adds") {
val sourceSet1 = EndpointSourceSet(
setOf(s1, s2, s3),
setOf(sim)
)
val sourceSet2 = EndpointSourceSet(
setOf(s4, s5, s6),
setOf(fid1, fid2, fid3)
)
conferenceSources.tryToAdd(jid1, sourceSet1) shouldBe sourceSet1
shouldThrow<SsrcGroupLimitExceededException> {
conferenceSources.tryToAdd(jid1, sourceSet2)
}
}
}
context("Duplicate SSRCs") {
val source = EndpointSourceSet(setOf(Source(1, AUDIO)))
val duplicateSourceAudio = EndpointSourceSet(
setOf(Source(1, AUDIO, msid = "differentMsid"))
)
val duplicateSourceVideo = EndpointSourceSet(
setOf(Source(1, VIDEO, msid = "differentMsid"))
)
conferenceSources.tryToAdd(jid1, source) shouldBe source
context("Advertised by the same endpoint.") {
shouldThrow<SsrcAlreadyUsedException> {
conferenceSources.tryToAdd(jid1, duplicateSourceAudio)
}
shouldThrow<SsrcAlreadyUsedException> {
conferenceSources.tryToAdd(jid1, duplicateSourceVideo)
}
}
context("Advertised by another endpoint.") {
shouldThrow<SsrcAlreadyUsedException> {
conferenceSources.tryToAdd(jid2, duplicateSourceAudio)
}
shouldThrow<SsrcAlreadyUsedException> {
conferenceSources.tryToAdd(jid2, duplicateSourceVideo)
}
}
}
context("SSRC group with missing MSID") {
val sources = setOf(
Source(1, VIDEO, msid = null),
Source(2, VIDEO, msid = null),
Source(3, VIDEO, msid = null)
)
val groups = setOf(SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2, 3)))
shouldThrow<RequiredParameterMissingException> {
conferenceSources.tryToAdd(jid1, EndpointSourceSet(sources, groups))
}
}
context("SSRC group with different MSID values") {
val sources = setOf(
Source(1, VIDEO, msid = msid),
Source(2, VIDEO, msid = msid),
Source(3, VIDEO, msid = "differentMsid")
)
val groups = setOf(SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2, 3)))
shouldThrow<GroupMsidMismatchException> {
conferenceSources.tryToAdd(jid1, EndpointSourceSet(sources, groups))
}
}
context("Group with an SSRC that has no source signaled.") {
shouldThrow<ValidationFailedException> {
conferenceSources.tryToAdd(
jid1,
EndpointSourceSet(
// No source for ssrc 2
setOf(Source(1, VIDEO, msid = "msid")),
setOf(SsrcGroup(SsrcGroupSemantics.Fid, listOf(1, 2)))
)
)
}
}
context("FID groups") {
// Fid groups must have exactly 2 ssrcs
shouldThrow<InvalidFidGroupException> {
conferenceSources.tryToAdd(
jid1,
EndpointSourceSet(
setOf(s1, s2, s3),
setOf(SsrcGroup(SsrcGroupSemantics.Fid, listOf(1, 2, 3)))
)
)
}
shouldThrow<InvalidFidGroupException> {
conferenceSources.tryToAdd(
jid1,
EndpointSourceSet(
setOf(s1),
setOf(SsrcGroup(SsrcGroupSemantics.Fid, listOf(1)))
)
)
}
}
context("MSID Conflicts") {
val sourceSet1 = EndpointSourceSet(setOf(s1))
val sourceSet2 = EndpointSourceSet(setOf(s2))
val combinedSourceSet = EndpointSourceSet(setOf(s1, s2))
context("With another endpoint") {
conferenceSources.tryToAdd(jid1, sourceSet1)
shouldThrow<MsidConflictException> {
// jid2 tries to use an MSID already used by jid1
conferenceSources.tryToAdd(jid2, sourceSet2)
}
}
context("Multiple non-grouped SSRCs can have MSID=null") {
// I don't know if this is actually desired and/or used by clients. It is existing behavior that
// AdvertiseSSRCsTest depends upon.
val sourceSet = EndpointSourceSet(
setOf(
Source(1, VIDEO),
Source(2, VIDEO),
Source(3, VIDEO),
Source(4, AUDIO),
Source(5, AUDIO)
)
)
conferenceSources.tryToAdd(jid1, sourceSet) shouldBe sourceSet
}
context("Within the sources of the same endpoint.") {
context("Ungrouped") {
context("Added separately") {
conferenceSources.tryToAdd(jid1, sourceSet1)
// s1 and s2 have the same MSID
shouldThrow<MsidConflictException> {
conferenceSources.tryToAdd(jid1, sourceSet2)
}
}
context("Added together") {
shouldThrow<MsidConflictException> {
conferenceSources.tryToAdd(jid1, combinedSourceSet)
}
}
}
context("In independent FID groups") {
val endpointSourceSet = EndpointSourceSet(
setOf(s1, s2, s4, s5),
setOf(fid1, fid2)
)
shouldThrow<MsidConflictException> {
conferenceSources.tryToAdd(jid1, endpointSourceSet)
}
}
context("In independent SIM groups") {
val endpointSourceSet = EndpointSourceSet(
setOf(s1, s2, s3, s4),
setOf(
SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2)),
SsrcGroup(SsrcGroupSemantics.Sim, listOf(3, 4))
)
)
shouldThrow<MsidConflictException> {
conferenceSources.tryToAdd(jid1, endpointSourceSet)
}
}
}
context("Audio and video can have the same MSID") {
context("With no MSID") {
val sourceSet = EndpointSourceSet(
setOf(
Source(1, AUDIO),
Source(2, VIDEO)
)
)
conferenceSources.tryToAdd(jid1, sourceSet) shouldBe sourceSet
}
context("With MSID and no groups") {
val sourceSet = EndpointSourceSet(setOf(s1, Source(111, AUDIO, msid = msid)))
conferenceSources.tryToAdd(jid1, sourceSet) shouldBe sourceSet
}
context("With MSID and groups") {
val sourceSet = EndpointSourceSet(
sources + Source(111, AUDIO, msid = msid),
groups
)
conferenceSources.tryToAdd(jid1, sourceSet) shouldBe sourceSet
}
}
}
context("Adding an empty group") {
val accepted = conferenceSources.tryToAdd(
jid1,
EndpointSourceSet(
setOf(s1, s2, s3),
setOf(
sim,
SsrcGroup(SsrcGroupSemantics.Sim, emptyList()),
SsrcGroup(SsrcGroupSemantics.Fid, emptyList()),
)
)
)
// The empty groups should be silently ignored.
accepted shouldBe EndpointSourceSet(setOf(s1, s2, s3), setOf(sim))
}
}
context("Removing sources") {
conferenceSources.tryToAdd(jid1, sourceSet)
context("Empty sources") {
conferenceSources.tryToRemove(jid1, EndpointSourceSet()) shouldBe EndpointSourceSet.EMPTY
}
context("Successful removal") {
context("Of all sources and groups") {
conferenceSources.tryToRemove(jid1, sourceSet) shouldBe sourceSet
}
context("Of all sources, groups assumed") {
conferenceSources.tryToRemove(jid1, EndpointSourceSet(sources)) shouldBe sourceSet
}
context("Of a subset of sources") {
// s1 remains, with no associated groups.
val toRemove = EndpointSourceSet(sources - s1, groups)
conferenceSources.tryToRemove(jid1, toRemove) shouldBe toRemove
}
}
context("Removing non-signaled sources") {
context("Another endpoint's sources") {
shouldThrow<SourceDoesNotExistException> {
conferenceSources.tryToRemove(jid2, sourceSet)
}
}
context("A non-signaled source") {
shouldThrow<SourceDoesNotExistException> {
conferenceSources.tryToRemove(jid1, EndpointSourceSet(Source(1111, AUDIO)))
}
}
context("A non-signaled ssrc-group") {
shouldThrow<SourceGroupDoesNotExistException> {
conferenceSources.tryToRemove(
jid1,
EndpointSourceSet(
sources,
groups + SsrcGroup(SsrcGroupSemantics.Fid, listOf(1, 2))
)
)
}
}
}
context("Removing a subset of sources") {
context("Leaving two FID groups") {
shouldThrow<MsidConflictException> {
conferenceSources.tryToRemove(
jid1,
EndpointSourceSet(
setOf(s1, s4),
setOf(sim, fid1)
)
)
}
}
context("Removing only part of a simulcast group's sources") {
// This matches multiple failure conditions (MSIF conflict, missing source from a group)
shouldThrow<ValidationFailedException> {
conferenceSources.tryToRemove(jid1, EndpointSourceSet(s1))
}
}
context("Removing a group, but not its sources") {
shouldThrow<MsidConflictException> {
conferenceSources.tryToRemove(jid1, EndpointSourceSet(sim))
}
shouldThrow<MsidConflictException> {
conferenceSources.tryToRemove(jid1, EndpointSourceSet(fid1))
}
shouldThrow<MsidConflictException> {
conferenceSources.tryToRemove(jid1, EndpointSourceSet(ssrcGroups = groups))
}
}
}
}
}
}
// Default values from jicofo.conference.max-ssrcs-per-user / max-ssrc-groups-per-user.
private const val MAX_SSRCS_PER_USER = 20
private const val MAX_SSRC_GROUPS_PER_USER = 20

View File

@@ -0,0 +1,44 @@
/*
* Copyright @ 2026 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.mock
import io.mockk.every
import io.mockk.mockk
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ExecutorService
import java.util.concurrent.ScheduledExecutorService
// Execute tasks in place (in the current thread, blocking)
val inPlaceExecutor: ExecutorService = mockk {
every { submit(any<Runnable>()) } answers {
firstArg<Runnable>().run()
CompletableFuture<Unit>().apply {
complete(Unit)
}
}
every { execute(any()) } answers {
firstArg<Runnable>().run()
}
}
val inPlaceScheduledExecutor: ScheduledExecutorService = mockk {
every { schedule(any(), any(), any()) } answers {
firstArg<Runnable>().run()
mockk(relaxed = true) {
every { isDone } returns true
}
}
}

View File

@@ -0,0 +1,103 @@
/*
* Copyright @ 2022 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.mock
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import io.mockk.every
import io.mockk.mockk
import org.jitsi.jicofo.xmpp.Features
import org.jitsi.jicofo.xmpp.XmppProvider
import org.jitsi.jicofo.xmpp.muc.ChatRoom
import org.jitsi.jicofo.xmpp.muc.ChatRoomInfo
import org.jitsi.jicofo.xmpp.muc.ChatRoomListener
import org.jitsi.jicofo.xmpp.muc.ChatRoomMember
import org.jitsi.jicofo.xmpp.muc.MemberRole
import org.jivesoftware.smack.packet.ExtensionElement
import org.jxmpp.jid.EntityBareJid
import org.jxmpp.jid.impl.JidCreate
import java.lang.IllegalArgumentException
import javax.xml.namespace.QName
class MockChatRoom(
val xmppProvider: XmppProvider,
val roomJid: EntityBareJid = JidCreate.entityBareFrom("room@conference.example.com")
) {
val chatRoomListeners = mutableListOf<ChatRoomListener>()
val memberList = mutableListOf<ChatRoomMember>()
/** Settable audio/video sender counts (the real ChatRoom derives them from member presence). */
var audioSenders = 0
var videoSenders = 0
/** Settable visitor count (the real ChatRoom derives it from member presence). */
var visitors = 0
val chatRoom = mockk<ChatRoom>(relaxed = true) {
every { addListener(capture(chatRoomListeners)) } returns Unit
every { roomJid } returns this@MockChatRoom.roomJid
every { members } returns memberList
every { memberCount } answers { memberList.size }
every { audioSendersCount } answers { audioSenders }
every { videoSendersCount } answers { videoSenders }
every { visitorCount } answers { visitors }
// Without this a relaxed mock returns a ChatRoomInfo with a non-null mainRoomJid, i.e. the room looks like a
// breakout room. Let jicofo generate the meeting ID, as it does when the MUC does not advertise one.
every { join() } returns ChatRoomInfo(meetingId = null, mainRoomJid = null)
every { xmppProvider } returns this@MockChatRoom.xmppProvider
every { debugState } returns JsonNodeFactory.instance.objectNode()
every { getChatMember(any()) } answers { memberList.find { it.occupantJid == arg(0) } }
}
fun addMember(
id: String,
role: MemberRole = MemberRole.PARTICIPANT,
jibri: Boolean = false,
jigasi: Boolean = false
): ChatRoomMember {
val occupant = JidCreate.entityFullFrom("$roomJid/$id")
val member = mockk<ChatRoomMember>(relaxed = true) {
every { name } returns id
every { occupantJid } returns occupant
every { chatRoom } returns this@MockChatRoom.chatRoom
every { features } returns Features.defaultFeatures
every { isJibri } returns jibri
every { isJigasi } returns jigasi
every { debugState } returns JsonNodeFactory.instance.objectNode()
every { presence } returns mockk {
every { status } returns null
every { getExtension(any<String>()) } returns null
every { getExtension(any<QName>()) } returns null
every { getExtension(any<Class<out ExtensionElement>>()) } returns null
}
}
every { member.role } returns role
memberList.add(member)
chatRoomListeners.forEach { it.memberJoined(member) }
return member
}
/** Change the role of [member] and notify listeners, as if a presence update was received. */
fun setRole(member: ChatRoomMember, role: MemberRole) {
every { member.role } returns role
chatRoomListeners.forEach { it.memberPresenceChanged(member) }
}
fun removeMember(member: ChatRoomMember) {
if (!memberList.contains(member)) throw IllegalArgumentException("not a member")
memberList.remove(member)
chatRoomListeners.forEach { it.memberLeft(member) }
}
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright @ 2022 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.mock
import io.mockk.every
import io.mockk.mockk
import org.jivesoftware.smack.AbstractXMPPConnection
import org.jivesoftware.smack.packet.IQ
import org.jivesoftware.smack.packet.Stanza
import org.jivesoftware.smack.packet.StanzaFactory
import org.jivesoftware.smack.packet.id.StanzaIdSource
open class MockXmppConnection {
val xmppConnection: AbstractXMPPConnection = mockk(relaxed = true) {
every { createStanzaCollectorAndSend(any()) } answers {
val request = arg<IQ>(0)
mockk(relaxed = true) {
every { nextResult<IQ>() } answers { handleIq(request) }
}
}
every { trySendStanza(any()) } answers {
val request = arg<Stanza>(0)
if (request is IQ) handleIq(request)
true
}
every { sendStanza(any()) } answers {
val request = arg<Stanza>(0)
if (request is IQ) handleIq(request)
}
val stanzaIdSource = object : StanzaIdSource {
private var stanzaId = 0
override fun getNewStanzaId() = "${stanzaId++}"
}
every { stanzaFactory } returns StanzaFactory(stanzaIdSource)
}
open fun handleIq(iq: IQ): IQ? = null
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright @ 2022 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.mock
import io.mockk.every
import io.mockk.mockk
import org.jitsi.jicofo.xmpp.XmppProvider
import org.jivesoftware.smack.AbstractXMPPConnection
import org.jivesoftware.smack.ConnectionListener
import org.jxmpp.jid.EntityBareJid
import org.jxmpp.jid.impl.JidCreate
class MockXmppProvider(
val xmppConnection: AbstractXMPPConnection = MockXmppConnection().xmppConnection,
/** The name of the connection, as it appears in [XmppProvider.getConfig]. */
val name: String = "mock",
/** The XMPP domain of the connection, needed to map a main room JID to a visitor room JID. */
xmppDomain: String? = null
) {
val chatRooms = mutableMapOf<EntityBareJid, MockChatRoom>()
/** Settable registration state, to simulate the XMPP connection going down and coming back up. */
var registered = true
val xmppProvider = mockk<XmppProvider>(relaxed = true) {
every { registered } answers { this@MockXmppProvider.registered }
every { findOrCreateRoom(any(), any()) } answers { getRoom(arg(0)).chatRoom }
every { xmppConnection } returns this@MockXmppProvider.xmppConnection
every { config } returns mockk(relaxed = true) {
every { this@mockk.name } returns this@MockXmppProvider.name
every { this@mockk.xmppDomain } returns xmppDomain?.let { JidCreate.domainBareFrom(it) }
}
}
/**
* The Smack connection listeners that were registered on [xmppConnection]. Note that when instances share an
* [xmppConnection] only the instance that was created last captures the listeners.
*/
val connectionListeners = mutableListOf<ConnectionListener>()
init {
every { xmppConnection.addConnectionListener(capture(connectionListeners)) } returns Unit
every { xmppConnection.removeConnectionListener(any()) } answers {
connectionListeners.remove(arg(0))
Unit
}
}
/** Simulate the connection authenticating, i.e. coming up or coming back up after a disconnect. */
fun authenticated(resumed: Boolean) {
registered = true
connectionListeners.toList().forEach { it.authenticated(xmppConnection, resumed) }
}
fun getRoom(jid: EntityBareJid): MockChatRoom =
chatRooms.computeIfAbsent(jid) { MockChatRoom(this.xmppProvider, jid) }
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright @ 2026 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.mock
import io.mockk.every
import io.mockk.mockk
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ExecutorService
/**
* An executor that queues tasks until [runAll] is called, so tests can control when "async" work executes. Unlike
* [inPlaceExecutor] it does not run tasks re-entrantly, which matters for code that assumes responses are processed
* on a separate thread (e.g. after a lock has been released).
*/
class PendingExecutor {
private val pending = ArrayDeque<Runnable>()
val executor: ExecutorService = mockk {
every { submit(any<Runnable>()) } answers {
pending.add(firstArg<Runnable>())
CompletableFuture<Unit>().apply { complete(Unit) }
}
every { execute(any()) } answers {
pending.add(firstArg<Runnable>())
}
}
/** Run all queued tasks, including any that get queued while draining. */
fun runAll() {
while (pending.isNotEmpty()) {
pending.removeFirst().run()
}
}
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright @ 2020 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.util
import io.kotest.core.spec.style.ShouldSpec
import org.jitsi.config.withNewConfig
fun ShouldSpec.context(config: String, name: String, block: () -> Unit) = context(name) {
withNewConfig(config) {
block()
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright @ 2026 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.util
import com.fasterxml.jackson.databind.JsonNode
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings
@SuppressFBWarnings(
value = ["RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT"],
justification = "The point is to exercise serialization; a thrown exception is the failure signal."
)
fun JsonNode.shouldBeValidJson() {
this.toString()
}

View File

@@ -0,0 +1,100 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.util
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import org.jitsi.utils.time.FakeClock
import java.time.Duration
class PendingCountTest : ShouldSpec() {
override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf
init {
context("Pending count") {
val clock = FakeClock()
val pendingCount = PendingCount(Duration.ofSeconds(15), clock)
should("Correctly count events that are pending") {
pendingCount.getCount() shouldBe 0
pendingCount.eventPending()
pendingCount.getCount() shouldBe 1
pendingCount.eventPending()
pendingCount.getCount() shouldBe 2
}
should("Correctly count down when events occur") {
pendingCount.eventPending()
pendingCount.eventPending()
pendingCount.getCount() shouldBe 2
pendingCount.eventOccurred()
pendingCount.getCount() shouldBe 1
}
should("Expire pending events after the expiration time") {
pendingCount.eventPending()
clock.elapse(Duration.ofSeconds(10))
pendingCount.eventPending()
pendingCount.getCount() shouldBe 2
clock.elapse(Duration.ofSeconds(10))
pendingCount.getCount() shouldBe 1
pendingCount.eventOccurred()
pendingCount.getCount() shouldBe 0
}
should("Correctly handle count when occurred events expire") {
pendingCount.eventPending()
clock.elapse(Duration.ofSeconds(10))
pendingCount.eventPending()
pendingCount.eventOccurred()
pendingCount.getCount() shouldBe 1
clock.elapse(Duration.ofSeconds(10))
pendingCount.getCount() shouldBe 1
clock.elapse(Duration.ofSeconds(10))
pendingCount.getCount() shouldBe 0
}
should("Not let the count go negative even if more events occur than are pending") {
pendingCount.eventPending()
pendingCount.eventOccurred()
pendingCount.getCount() shouldBe 0
pendingCount.eventOccurred()
pendingCount.getCount() shouldBe 0
clock.elapse(Duration.ofSeconds(10))
pendingCount.eventPending()
pendingCount.getCount() shouldBe 1
clock.elapse(Duration.ofSeconds(10))
pendingCount.getCount() shouldBe 1
clock.elapse(Duration.ofSeconds(10))
pendingCount.getCount() shouldBe 0
}
}
}
}

View File

@@ -0,0 +1,86 @@
/*
* Copyright @ 2026 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.util
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import java.lang.ref.WeakReference
class WeakValueMapTest : ShouldSpec() {
override fun isolationMode() = IsolationMode.InstancePerLeaf
// Note the test values must not have references held elsewhere (e.g. interned strings or boxed small integers).
private class Value(val v: Int)
private fun addCollectableValue(map: WeakValueMap<String, Value>): WeakReference<Value> {
val value = Value(2)
map.put("weak", value)
return WeakReference(value)
}
init {
val map = WeakValueMap<String, Value>(cleanInterval = 2)
context("Basic operations") {
val value = Value(1)
map.put("one", value)
should("get") {
map.get("one")?.v shouldBe 1
map.get("two") shouldBe null
}
should("containsKey") {
map.containsKey("one") shouldBe true
map.containsKey("two") shouldBe false
}
should("values") {
map.values().map { it.v } shouldBe listOf(1)
}
should("remove") {
map.remove("one")?.v shouldBe 1
map.containsKey("one") shouldBe false
map.values() shouldBe emptyList()
}
}
context("Weak semantics") {
val strong = Value(1)
map.put("strong", strong)
// The only strong reference to the value is held inside addCollectableValue, so it is collectable once
// the function returns. The returned WeakReference tells us when it has actually been collected.
val canary = addCollectableValue(map)
map.containsKey("weak") shouldBe true
var collected = false
for (i in 0..500) {
System.gc()
if (canary.get() == null) {
collected = true
break
}
Thread.sleep(10)
}
should("expire entries once the value is collected") {
collected shouldBe true
map.get("weak") shouldBe null
map.containsKey("weak") shouldBe false
map.values().map { it.v } shouldBe listOf(1)
map.containsKey("strong") shouldBe true
}
}
}
}

View File

@@ -0,0 +1,117 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import io.mockk.mockk
import org.jivesoftware.smack.AbstractXMPPConnection
import org.jivesoftware.smack.packet.IQ
import org.jivesoftware.smack.packet.Nonza
import org.jivesoftware.smack.packet.Stanza
import org.jxmpp.jid.impl.JidCreate
import org.jxmpp.jid.parts.Resourcepart
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* This tests the bridge between Smack and our [AbstractIqHandler]. [AbstractXMPPConnection] uses its own executors
* to process stanzas, and since it's impossible to mock them we're forced to just wait.
*
* The intention is to leave this as the only test that requires blindly waiting for other threads, while the rest
* of the tests can assume that the Smack layer works and can pass IQs directly and synchronously to the intended
* IQ handler.
*/
class AbstractIqHandlerTest : ShouldSpec() {
init {
context("Receiving IQs of both types, on both connections") {
val jid1 = JidCreate.from("jid1@example.com")
val jid2 = JidCreate.from("jid2@example.com")
val connection1 = TestXmppConnection().apply { connect() }
val connection2 = TestXmppConnection().apply { connect() }
val senderJid = JidCreate.from("sender@example.com")
// Pre-construct the IQs we'll send
val requests = buildList<IQ> {
listOf(IQ.Type.get, IQ.Type.set).forEach { type ->
listOf(jid1, jid2).forEach { to ->
add(
DummyIq().apply {
this.from = senderJid
this.to = to
this.type = type
}
)
}
}
}
// We expect to receive each request from each of the 2 connections
val latch = CountDownLatch(2 * requests.size)
val handler = DummyIqHandler(setOf(connection1, connection2), latch)
requests.forEach {
connection1.mockProcessStanza(it)
connection2.mockProcessStanza(it)
}
should("Receive all requests") {
// This is a very simple test and should complete quickly unless something is completely broken. Use
// a very long timeout to prevent false-positives coming from e.g. the test machines not being scheduled
// enough CPU cycles.
latch.await(1, TimeUnit.MINUTES) shouldBe true
}
handler.shutdown()
}
}
private class DummyIq : IQ(ELEMENT, NAMESPACE) {
override fun getIQChildElementBuilder(p0: IQChildElementXmlStringBuilder): IQChildElementXmlStringBuilder =
mockk()
companion object {
const val ELEMENT = "dummy-name"
const val NAMESPACE = "dummy-namespace"
}
}
private class DummyIqHandler(
connections: Set<AbstractXMPPConnection>,
private val latch: CountDownLatch
) : AbstractIqHandler<DummyIq>(connections, DummyIq.ELEMENT, DummyIq.NAMESPACE) {
override fun handleRequest(request: IqRequest<DummyIq>): IqProcessingResult =
IqProcessingResult.AcceptedWithNoResponse().also { latch.countDown() }
}
}
private class TestXmppConnection : AbstractXMPPConnection(mockk(relaxed = true)) {
override fun isSecureConnection(): Boolean = true
override fun isUsingCompression(): Boolean = false
override fun sendNonza(p0: Nonza?) { }
override fun sendStanzaInternal(p0: Stanza?) { }
override fun connectInternal() {
connected = true
}
override fun loginInternal(p0: String?, p1: String?, p2: Resourcepart?) { }
override fun instantShutdown() { }
override fun shutdown() { }
/** Expose as public. */
fun mockProcessStanza(stanza: Stanza) = invokeStanzaCollectorsAndNotifyRecvListeners(stanza)
}

View File

@@ -0,0 +1,30 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
class FeaturesTest : ShouldSpec() {
init {
context("Parsing") {
Features.parseString(Features.AUDIO.value) shouldBe Features.AUDIO
Features.parseString("something else") shouldBe null
}
}
}

View File

@@ -0,0 +1,326 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2024-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import org.jitsi.jicofo.MediaType
class JsonMessageTest : ShouldSpec() {
init {
context("RoomMetadata") {
context("Valid") {
context("With visitors.live set") {
val parsed = JsonMessage.parse(
"""
{
"type": "room_metadata",
"metadata": {
"visitors": {
"live": true,
"anotherField": 123
}
},
"anotherField": {}
}
}
""".trimIndent()
)
parsed.shouldBeInstanceOf<RoomMetadata>()
parsed.metadata!!.visitors!!.live shouldBe true
}
context("With no visitors included") {
val parsed = JsonMessage.parse(
"""
{
"type": "room_metadata",
"metadata": {
"key": {
"key2": "value2"
},
"anotherField": {}
}
}
""".trimIndent()
)
parsed.shouldBeInstanceOf<RoomMetadata>()
parsed.metadata!!.visitors shouldBe null
}
context("With visitors, mainMeetingParticipants, and startMuted") {
JsonMessage.parse(
"""
{
"metadata": {
"transcriberType": "EGHT_WHISPER",
"visitors": {
"live": true
},
"moderators": [
"user_id_1",
"user_id_2"
],
"participants": [
"user_id_3",
"user_id_4"
],
"startMuted": {
"audio": true
}
},
"type": "room_metadata"
}
""".trimIndent()
).apply {
shouldBeInstanceOf<RoomMetadata>()
metadata.apply {
shouldNotBeNull()
visitors.apply {
shouldNotBeNull()
live shouldBe true
}
moderators shouldBe listOf("user_id_1", "user_id_2")
participants shouldBe listOf("user_id_3", "user_id_4")
startMuted.apply {
shouldNotBeNull()
audio shouldBe true
video shouldBe null
}
}
type shouldBe "room_metadata"
}
}
context("With audioTranslationRequests") {
JsonMessage.parse(
"""
{
"type": "room_metadata",
"metadata": {
"audioTranslationRequests": {
"aaaaaaaa": ["en", "es"],
"bbbbbbbb": ["fr"]
}
}
}
""".trimIndent()
).apply {
shouldBeInstanceOf<RoomMetadata>()
metadata!!.audioTranslationRequests.apply {
shouldNotBeNull()
this!!["aaaaaaaa"] shouldBe listOf("en", "es")
this["bbbbbbbb"] shouldBe listOf("fr")
}
}
}
context("Without audioTranslationRequests") {
JsonMessage.parse(
"""
{ "type": "room_metadata", "metadata": {} }
""".trimIndent()
).apply {
shouldBeInstanceOf<RoomMetadata>()
metadata!!.audioTranslationRequests shouldBe null
}
}
context("With visitor transcribing languages") {
JsonMessage.parse(
"""
{
"type": "room_metadata",
"metadata": {
"visitors": {
"live": true,
"transcribingLanguages": "de,fr",
"transcribingCount": 3
}
}
}
""".trimIndent()
).apply {
shouldBeInstanceOf<RoomMetadata>()
metadata!!.visitors.apply {
shouldNotBeNull()
live shouldBe true
transcribingLanguages shouldBe "de,fr"
transcribingCount shouldBe 3L
}
}
}
context("With no visitor transcribing languages") {
// Prosody sends an empty string once the last visitor gives up its language.
JsonMessage.parse(
"""
{
"type": "room_metadata",
"metadata": {
"visitors": { "transcribingLanguages": "", "transcribingCount": 0 }
}
}
""".trimIndent()
).apply {
shouldBeInstanceOf<RoomMetadata>()
metadata!!.visitors.apply {
shouldNotBeNull()
live shouldBe null
transcribingLanguages shouldBe ""
transcribingCount shouldBe 0L
}
}
}
context("With a visitors block from an older prosody") {
JsonMessage.parse(
"""
{ "type": "room_metadata", "metadata": { "visitors": { "live": true } } }
""".trimIndent()
).apply {
shouldBeInstanceOf<RoomMetadata>()
metadata!!.visitors.apply {
shouldNotBeNull()
transcribingLanguages shouldBe null
transcribingCount shouldBe null
}
}
}
}
context("Invalid") {
context("Missing type") {
shouldThrow<Exception> {
JsonMessage.parse(
"""
{ "key": 123 }
""".trimIndent()
)
}
}
context("Invalid JSON") {
shouldThrow<Exception> {
JsonMessage.parse("{")
}
}
}
}
context("AvModerationMessage") {
context("Valid") {
context("With minimal fields") {
val parsed = JsonMessage.parse(
"""
{
"type": "av_moderation",
"room": "room1@conference.example.com"
}
""".trimIndent()
)
parsed.shouldBeInstanceOf<AvModerationMessage>()
parsed.room shouldBe "room1@conference.example.com"
parsed.enabled shouldBe null
parsed.mediaType shouldBe null
parsed.actor shouldBe null
parsed.whitelists shouldBe null
}
context("With all fields") {
val parsed = JsonMessage.parse(
"""
{
"type": "av_moderation",
"room": "room1@conference.example.com",
"enabled": true,
"mediaType": "AUDIO",
"actor": "user1@example.com",
"whitelists": {
"AUDIO": ["user2@example.com", "user3@example.com"],
"VIDEO": ["user2@example.com"]
}
}
""".trimIndent()
)
parsed.shouldBeInstanceOf<AvModerationMessage>()
parsed.room shouldBe "room1@conference.example.com"
parsed.enabled shouldBe true
parsed.mediaType shouldBe MediaType.AUDIO
parsed.actor shouldBe "user1@example.com"
parsed.whitelists.shouldNotBeNull()
parsed.whitelists!![MediaType.AUDIO] shouldBe listOf("user2@example.com", "user3@example.com")
parsed.whitelists!![MediaType.VIDEO] shouldBe listOf("user2@example.com")
// parsed.whitelists!![MediaType.DESKTOP] shouldBe null
}
context("With lowercase enum values") {
val parsed = JsonMessage.parse(
"""
{
"type": "av_moderation",
"room": "room1@conference.example.com",
"mediaType": "video",
"whitelists": {
"audio": ["user1@example.com"]
}
}
""".trimIndent()
)
parsed.shouldBeInstanceOf<AvModerationMessage>()
parsed.mediaType shouldBe MediaType.VIDEO
parsed.whitelists.shouldNotBeNull()
parsed.whitelists!![MediaType.AUDIO] shouldBe listOf("user1@example.com")
// parsed.whitelists!![AvModerationMessage.MediaType.DESKTOP] shouldBe listOf("user2@example.com")
}
context("With mixed case enum values") {
val parsed = JsonMessage.parse(
"""
{
"type": "av_moderation",
"room": "room1@conference.example.com",
"mediaType": "vIdEo",
"whitelists": {
"AuDiO": ["user1@example.com"],
"ViDeO": ["user2@example.com"]
}
}
""".trimIndent()
)
parsed.shouldBeInstanceOf<AvModerationMessage>()
parsed.mediaType shouldBe MediaType.VIDEO
parsed.whitelists.shouldNotBeNull()
parsed.whitelists!![MediaType.AUDIO] shouldBe listOf("user1@example.com")
parsed.whitelists!![MediaType.VIDEO] shouldBe listOf("user2@example.com")
}
}
context("Invalid") {
context("Invalid media type") {
shouldThrow<Exception> {
JsonMessage.parse(
"""
{
"type": "av_moderation",
"room": "room1@conference.example.com",
"mediaType": "INVALID_TYPE"
}
""".trimIndent()
)
}
}
}
}
}
}

View File

@@ -0,0 +1,189 @@
/*
* Copyright @ 2020 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.maps.shouldBeEmpty
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import org.jitsi.config.withLegacyConfig
import org.jitsi.config.withNewConfig
import org.jitsi.metaconfig.ConfigException
import org.jxmpp.jid.impl.JidCreate
import org.jxmpp.jid.parts.Resourcepart
class XmppConfigTest : ShouldSpec() {
init {
context("XMPP client connection config") {
context("Default values") {
val config = XmppClientConnectionConfig()
config.enabled shouldBe true
config.hostname shouldBe "localhost"
config.port shouldBe 5222
shouldThrow<ConfigException> {
config.domain
}
config.username shouldBe Resourcepart.from("focus")
config.password shouldBe null
}
context("Legacy config") {
withLegacyConfig(
"""
org.jitsi.jicofo.HOSTNAME=hostname
org.jitsi.jicofo.XMPP_PORT=5223
org.jitsi.jicofo.FOCUS_USER_DOMAIN=domain
org.jitsi.jicofo.FOCUS_USER_NAME=user
org.jitsi.jicofo.FOCUS_USER_PASSWORD=pass
"""
) {
val config = XmppClientConnectionConfig()
config.enabled shouldBe true
config.hostname shouldBe "hostname"
config.port shouldBe 5223
config.domain shouldBe JidCreate.domainBareFrom("domain")
config.username shouldBe Resourcepart.from("user")
config.password shouldBe "pass"
}
}
context("New config") {
withNewConfig(
"""
jicofo.xmpp.client {
hostname = "hostname2"
port = 5224
domain = "domain2"
username = "user2"
password = "pass2"
}
"""
) {
val config = XmppClientConnectionConfig()
config.enabled shouldBe true
config.hostname shouldBe "hostname2"
config.port shouldBe 5224
config.domain shouldBe JidCreate.domainBareFrom("domain2")
config.username shouldBe Resourcepart.from("user2")
config.password shouldBe "pass2"
}
}
}
context("XMPP service connection config") {
context("Default values") {
val config = XmppServiceConnectionConfig()
config.enabled shouldBe false
config.hostname shouldBe "localhost"
config.port shouldBe 6222
shouldThrow<ConfigException> {
config.domain
}
config.username shouldBe Resourcepart.from("focus")
config.password shouldBe null
}
context("Legacy config") {
withLegacyConfig(
"""
org.jitsi.jicofo.BRIDGE_MUC_XMPP_HOST=hostname
org.jitsi.jicofo.BRIDGE_MUC_XMPP_PORT=6223
org.jitsi.jicofo.BRIDGE_MUC_XMPP_USER_DOMAIN=domain
org.jitsi.jicofo.BRIDGE_MUC_XMPP_USER=user
org.jitsi.jicofo.BRIDGE_MUC_XMPP_USER_PASS=pass
"""
) {
val config = XmppServiceConnectionConfig()
config.enabled shouldBe true
config.hostname shouldBe "hostname"
config.port shouldBe 6223
config.domain shouldBe JidCreate.domainBareFrom("domain")
config.username shouldBe Resourcepart.from("user")
config.password shouldBe "pass"
}
}
context("New config") {
withNewConfig(
"""
jicofo.xmpp.service {
enabled = true
hostname = "hostname2"
port = 6224
domain = "domain2"
username = "user2"
password = "pass2"
}
"""
) {
val config = XmppServiceConnectionConfig()
config.enabled shouldBe true
config.hostname shouldBe "hostname2"
config.port shouldBe 6224
config.domain shouldBe JidCreate.domainBareFrom("domain2")
config.username shouldBe Resourcepart.from("user2")
config.password shouldBe "pass2"
}
}
}
context("Visitor connections config") {
context("Default") {
XmppConfig.visitors.shouldBeEmpty()
}
context("With visitors configured") {
withNewConfig(
"""
jicofo.xmpp.visitors {
v1 {
hostname = "hostname1"
port = 5223
domain = "domain1"
username = "user1"
password = "pass1"
conference-service = conference.v1.example.com
}
v2 {
hostname = "hostname2"
port = 5224
domain = "domain2"
username = "user2"
password = "pass2"
conference-service = conference.v2.example.com
}
}
"""
) {
XmppConfig.visitors.size shouldBe 2
XmppConfig.visitors.keys shouldBe setOf("v1", "v2")
XmppConfig.visitors["v1"].let {
it shouldNotBe null
it!!.hostname shouldBe "hostname1"
it.port shouldBe 5223
it.domain shouldBe "domain1"
it.username shouldBe Resourcepart.from("user1")
it.password shouldBe "pass1"
it.conferenceService shouldBe JidCreate.domainBareFrom("conference.v1.example.com")
}
XmppConfig.visitors["v2"].let {
it shouldNotBe null
it!!.hostname shouldBe "hostname2"
it.port shouldBe 5224
it.domain shouldBe "domain2"
it.username shouldBe Resourcepart.from("user2")
it.password shouldBe "pass2"
it.conferenceService shouldBe JidCreate.domainBareFrom("conference.v2.example.com")
}
}
}
}
}
}

View File

@@ -0,0 +1,233 @@
/*
* Copyright @ 2026 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.muc
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.core.test.TestCase
import io.kotest.core.test.TestResult
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import org.jitsi.jicofo.MediaType
import org.jitsi.jicofo.TaskPools
import org.jitsi.jicofo.mock.MockXmppConnection
import org.jitsi.jicofo.mock.inPlaceExecutor
import org.jitsi.jicofo.xmpp.RoomMetadata
import org.jitsi.jicofo.xmpp.XmppProvider
import org.jivesoftware.smack.packet.ExtensionElement
import org.jivesoftware.smack.packet.Presence
import org.jivesoftware.smack.packet.StandardExtensionElement
import org.jivesoftware.smack.packet.StanzaBuilder
import org.jivesoftware.smackx.muc.MUCAffiliation
import org.jivesoftware.smackx.muc.MUCRole
import org.jivesoftware.smackx.muc.MultiUserChat
import org.jivesoftware.smackx.muc.MultiUserChatManager
import org.jivesoftware.smackx.muc.Occupant
import org.jivesoftware.smackx.muc.packet.MUCUser
import org.jxmpp.jid.EntityFullJid
import org.jxmpp.jid.impl.JidCreate
import java.util.logging.Level
class ChatRoomImplTest : ShouldSpec() {
override fun isolationMode() = IsolationMode.InstancePerLeaf
private val roomJid = JidCreate.entityBareFrom("room@conference.example.com")
private val xmppConnection = MockXmppConnection().xmppConnection
private val xmppProvider: XmppProvider = mockk(relaxed = true) {
every { xmppConnection } returns this@ChatRoomImplTest.xmppConnection
}
/** The role/affiliation of each occupant, as would be reported by the MUC. */
private val occupantMap = mutableMapOf<EntityFullJid, Occupant>()
private val muc: MultiUserChat = mockk(relaxed = true) {
every { getOccupant(any()) } answers { occupantMap[firstArg<EntityFullJid>()] }
}
private var left = false
private val chatRoom by lazy {
mockkStatic(MultiUserChatManager::class)
val manager = mockk<MultiUserChatManager> {
every { getMultiUserChat(any()) } returns muc
}
every { MultiUserChatManager.getInstanceFor(any()) } returns manager
try {
ChatRoomImpl(xmppProvider, roomJid, Level.INFO) { left = true }.apply {
addListener(
object : ChatRoomListener {
override fun memberJoined(member: ChatRoomMember) {
joined.add(member)
}
override fun memberLeft(member: ChatRoomMember) {
leftMembers.add(member)
}
override fun memberKicked(member: ChatRoomMember) {
kicked.add(member)
}
}
)
}
} finally {
unmockkStatic(MultiUserChatManager::class)
}
}
private val joined = mutableListOf<ChatRoomMember>()
private val leftMembers = mutableListOf<ChatRoomMember>()
private val kicked = mutableListOf<ChatRoomMember>()
private fun addOccupant(jid: EntityFullJid, mucRole: MUCRole, mucAffiliation: MUCAffiliation) {
occupantMap[jid] = mockk<Occupant> {
every { role } returns mucRole
every { affiliation } returns mucAffiliation
every { this@mockk.jid } returns JidCreate.from("user@example.com")
}
}
private fun memberPresence(
nick: String,
type: Presence.Type = Presence.Type.available,
vararg extensions: ExtensionElement
): Presence = StanzaBuilder.buildPresence()
.from(JidCreate.entityFullFrom("$roomJid/$nick"))
.ofType(type)
.build().apply {
addExtension(MUCUser())
extensions.forEach { addExtension(it) }
}
private fun join(nick: String, role: MUCRole = MUCRole.participant, vararg extensions: ExtensionElement) {
addOccupant(JidCreate.entityFullFrom("$roomJid/$nick"), role, MUCAffiliation.none)
chatRoom.processPresence(memberPresence(nick, Presence.Type.available, *extensions))
}
override suspend fun beforeAny(testCase: TestCase) = super.beforeAny(testCase).also {
TaskPools.ioPool = inPlaceExecutor
}
override suspend fun afterAny(testCase: TestCase, result: TestResult) = super.afterAny(testCase, result).also {
TaskPools.resetIoPool()
}
private fun sourceInfo(nick: String, audioMuted: Boolean) = StandardExtensionElement
.builder("SourceInfo", "jabber:client")
.setText("""{"$nick-a0": {"muted": $audioMuted}}""")
.build()
init {
context("Members joining") {
join("member1")
chatRoom.memberCount shouldBe 1
joined.size shouldBe 1
val member = chatRoom.getChatMember(JidCreate.entityFullFrom("$roomJid/member1"))!!
member.name shouldBe "member1"
member.role shouldBe MemberRole.PARTICIPANT
context("A second member") {
join("member2")
chatRoom.memberCount shouldBe 2
joined.size shouldBe 2
}
context("Repeated presence from the same member") {
chatRoom.processPresence(memberPresence("member1"))
chatRoom.memberCount shouldBe 1
joined.size shouldBe 1
}
}
context("Members leaving") {
join("member1")
join("member2")
context("Normally") {
chatRoom.processPresence(memberPresence("member1", Presence.Type.unavailable))
chatRoom.memberCount shouldBe 1
leftMembers.map { it.name } shouldBe listOf("member1")
kicked.size shouldBe 0
}
context("Kicked") {
chatRoom.processPresence(
memberPresence("member1", Presence.Type.unavailable).apply {
getExtension(MUCUser::class.java).addStatusCode(MUCUser.Status.KICKED_307)
}
)
chatRoom.memberCount shouldBe 1
kicked.map { it.name } shouldBe listOf("member1")
leftMembers.size shouldBe 0
}
}
context("Visitors") {
join("visitor1", role = MUCRole.visitor)
join("member1")
chatRoom.memberCount shouldBe 2
chatRoom.visitorCount shouldBe 1
chatRoom.getChatMember(JidCreate.entityFullFrom("$roomJid/visitor1"))!!.role shouldBe MemberRole.VISITOR
chatRoom.processPresence(memberPresence("visitor1", Presence.Type.unavailable))
chatRoom.visitorCount shouldBe 0
}
context("Sender counts") {
chatRoom.audioSendersCount shouldBe 0
join("member1", MUCRole.participant, sourceInfo("member1", audioMuted = false))
chatRoom.audioSendersCount shouldBe 1
context("Muting") {
chatRoom.processPresence(memberPresence("member1", extensions = arrayOf(sourceInfo("member1", true))))
chatRoom.audioSendersCount shouldBe 0
}
context("Leaving while unmuted") {
chatRoom.processPresence(memberPresence("member1", Presence.Type.unavailable))
chatRoom.audioSendersCount shouldBe 0
}
}
context("AV moderation") {
val jid = JidCreate.from("someone@example.com")
chatRoom.isAvModerationEnabled(MediaType.AUDIO) shouldBe false
chatRoom.isMemberAllowedToUnmute(jid, MediaType.AUDIO) shouldBe true
chatRoom.setAvModerationEnabled(MediaType.AUDIO, true)
chatRoom.isAvModerationEnabled(MediaType.AUDIO) shouldBe true
chatRoom.isAvModerationEnabled(MediaType.VIDEO) shouldBe false
chatRoom.isMemberAllowedToUnmute(jid, MediaType.AUDIO) shouldBe false
chatRoom.setAvModerationWhitelist(MediaType.AUDIO, listOf(jid.toString()))
chatRoom.isMemberAllowedToUnmute(jid, MediaType.AUDIO) shouldBe true
chatRoom.isMemberAllowedToUnmute(JidCreate.from("other@example.com"), MediaType.AUDIO) shouldBe false
}
context("Leaving the room") {
chatRoom.leave()
left shouldBe true
}
context("Room metadata") {
chatRoom.setRoomMetadata(
RoomMetadata(
metadata = RoomMetadata.Metadata(
visitors = RoomMetadata.Metadata.Visitors(live = true),
startMuted = null,
moderators = null,
participants = null,
recording = null,
participantsSoftLimit = 30
)
)
)
chatRoom.visitorsLive shouldBe true
chatRoom.participantsSoftLimit shouldBe 30
}
}
}

View File

@@ -0,0 +1,187 @@
/*
* Copyright @ 2026 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.muc
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import org.jitsi.config.withNewConfig
import org.jitsi.utils.logging2.LoggerImpl
import org.jitsi.xmpp.extensions.jitsimeet.AudioMutedExtension
import org.jitsi.xmpp.extensions.jitsimeet.FeatureExtension
import org.jitsi.xmpp.extensions.jitsimeet.FeaturesExtension
import org.jitsi.xmpp.extensions.jitsimeet.JitsiParticipantCodecList
import org.jitsi.xmpp.extensions.jitsimeet.JitsiParticipantRegionPacketExtension
import org.jitsi.xmpp.extensions.jitsimeet.StatsId
import org.jitsi.xmpp.extensions.jitsimeet.VideoMutedExtension
import org.jivesoftware.smack.packet.ExtensionElement
import org.jivesoftware.smack.packet.Presence
import org.jivesoftware.smack.packet.StandardExtensionElement
import org.jivesoftware.smack.packet.StanzaBuilder
import org.jivesoftware.smackx.muc.MUCAffiliation
import org.jivesoftware.smackx.muc.MUCRole
import org.jivesoftware.smackx.muc.Occupant
import org.jxmpp.jid.impl.JidCreate
class ChatRoomMemberImplTest : ShouldSpec() {
override fun isolationMode() = IsolationMode.InstancePerLeaf
private val occupantJid = JidCreate.entityFullFrom("room@conference.example.com/nick")
private var occupant: Occupant? = mockk<Occupant> {
every { role } returns MUCRole.participant
every { affiliation } returns MUCAffiliation.none
}
private val chatRoom: ChatRoomImpl = mockk(relaxed = true) {
every { getOccupant(any()) } answers { occupant }
every { getJid(any()) } returns JidCreate.from("user@example.com")
}
private val member = ChatRoomMemberImpl(occupantJid, chatRoom, LoggerImpl("test"))
private fun presence(vararg extensions: ExtensionElement): Presence =
StanzaBuilder.buildPresence().from(occupantJid).build().apply {
extensions.forEach { addExtension(it) }
}
private fun sourceInfo(json: String) =
StandardExtensionElement.builder("SourceInfo", "jabber:client").setText(json).build()
init {
context("Presence from another occupant") {
shouldThrow<IllegalArgumentException> {
member.processPresence(
StanzaBuilder.buildPresence().from(JidCreate.from("room@conference.example.com/other")).build()
)
}
}
context("Defaults") {
member.processPresence(presence())
member.role shouldBe MemberRole.PARTICIPANT
member.isAudioMuted shouldBe true
member.isVideoMuted shouldBe true
member.isJigasi shouldBe false
member.isJibri shouldBe false
member.sourceInfos shouldBe emptySet()
member.region shouldBe null
member.statsId shouldBe null
}
context("Mute state") {
context("Via SourceInfo") {
member.processPresence(
presence(sourceInfo("""{"nick-a0": {"muted": false}, "nick-v0": {"muted": false}}"""))
)
member.isAudioMuted shouldBe false
member.isVideoMuted shouldBe false
member.sourceInfos.size shouldBe 2
member.processPresence(presence(sourceInfo("""{"nick-a0": {"muted": true}}""")))
member.isAudioMuted shouldBe true
member.isVideoMuted shouldBe true
}
context("Via the legacy extensions") {
member.processPresence(
presence(
AudioMutedExtension().apply { setAudioMuted(false) },
VideoMutedExtension().apply { setVideoMuted(false) }
)
)
member.isAudioMuted shouldBe false
member.isVideoMuted shouldBe false
}
context("With invalid SourceInfo JSON") {
member.processPresence(presence(sourceInfo("invalid json")))
member.isAudioMuted shouldBe true
member.sourceInfos shouldBe emptySet()
}
}
context("Region and stats ID") {
member.processPresence(
presence(
JitsiParticipantRegionPacketExtension().apply { regionId = "us-east" },
StatsId("stats-id-123")
)
)
member.region shouldBe "us-east"
member.statsId shouldBe "stats-id-123"
}
context("Video codecs") {
should("use the signaled codec list") {
member.processPresence(
presence(JitsiParticipantCodecList().apply { codecs = listOf("av1", "vp9", "vp8") })
)
member.videoCodecs shouldBe listOf("av1", "vp9", "vp8")
}
should("add vp8 when missing from the codec list") {
member.processPresence(presence(JitsiParticipantCodecList().apply { codecs = listOf("h264") }))
member.videoCodecs shouldBe listOf("h264", "vp8")
}
should("support the legacy codecType extension") {
member.processPresence(
presence(
StandardExtensionElement.builder("jitsi_participant_codecType", "jabber:client")
.setText("h264").build()
)
)
member.videoCodecs shouldBe listOf("h264", "vp8")
}
}
context("Jigasi and jibri detection") {
val jigasiFeature = FeaturesExtension().apply {
addChildExtension(
FeatureExtension().apply { setAttribute("var", "http://jitsi.org/protocol/jigasi") }
)
}
context("From a trusted domain") {
withNewConfig("jicofo.xmpp.trusted-domains = [ \"example.com\" ]") {
member.processPresence(presence(jigasiFeature))
member.isJigasi shouldBe true
member.isJibri shouldBe false
// A presence without the extension resets the flag.
member.processPresence(presence())
member.isJigasi shouldBe false
}
}
context("From an untrusted domain") {
member.processPresence(presence(jigasiFeature))
member.isJigasi shouldBe false
}
}
context("Role changes") {
member.role shouldBe MemberRole.PARTICIPANT
member.processPresence(presence())
context("To another non-visitor role") {
occupant = mockk<Occupant> {
every { role } returns MUCRole.moderator
every { affiliation } returns MUCAffiliation.owner
}
member.processPresence(presence())
member.role shouldBe MemberRole.OWNER
}
context("To visitor (not supported)") {
occupant = null
member.processPresence(presence())
member.role shouldBe MemberRole.PARTICIPANT
}
}
}
}

View File

@@ -0,0 +1,77 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.muc
import io.kotest.assertions.withClue
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.matchers.ints.shouldBeGreaterThan
import io.kotest.matchers.shouldBe
import org.jitsi.jicofo.xmpp.muc.MemberRole.MODERATOR
import org.jitsi.jicofo.xmpp.muc.MemberRole.OWNER
import org.jitsi.jicofo.xmpp.muc.MemberRole.PARTICIPANT
import org.jitsi.jicofo.xmpp.muc.MemberRole.VISITOR
import org.jivesoftware.smackx.muc.MUCAffiliation
import org.jivesoftware.smackx.muc.MUCRole
class MemberRoleTest : ShouldSpec() {
init {
context("Order") {
(VISITOR > PARTICIPANT) shouldBe true
(PARTICIPANT > MODERATOR) shouldBe true
VISITOR.compareTo(PARTICIPANT) shouldBeGreaterThan 0
PARTICIPANT.compareTo(MODERATOR) shouldBeGreaterThan 0
}
context("hasModeratorRights") {
VISITOR.hasModeratorRights().shouldBeFalse()
PARTICIPANT.hasModeratorRights().shouldBeFalse()
MODERATOR.hasModeratorRights().shouldBeTrue()
OWNER.hasModeratorRights().shouldBeTrue()
}
context("hasOwnerRights") {
VISITOR.hasOwnerRights().shouldBeFalse()
PARTICIPANT.hasOwnerRights().shouldBeFalse()
MODERATOR.hasOwnerRights().shouldBeFalse()
OWNER.hasOwnerRights().shouldBeTrue()
}
context("From Smack role and affiliation") {
enumPairsWithNull<MUCRole, MUCAffiliation>().forEach { (mucRole, mucAffiliation) ->
withClue("mucRole=$mucRole, mucAffiliation=$mucAffiliation") {
MemberRole.fromSmack(mucRole, mucAffiliation) shouldBe when (mucAffiliation) {
MUCAffiliation.admin -> MODERATOR
MUCAffiliation.owner -> OWNER
else -> when (mucRole) {
MUCRole.moderator -> MODERATOR
MUCRole.participant -> PARTICIPANT
else -> VISITOR
}
}
}
}
}
}
private inline fun <reified T : Enum<T>, reified U : Enum<U>> enumPairsWithNull(): List<Pair<T?, U?>> =
enumValuesAndNull<T>().flatMap { l -> enumValuesAndNull<U>().map { r -> l to r } }
private inline fun <reified T : Enum<T>> enumValuesAndNull(): List<T?> =
mutableListOf<T?>(null).apply { addAll(enumValues<T>()) }
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright @ 2021 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.muc
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import org.jitsi.jicofo.conference.source.VideoType
import org.jitsi.utils.MediaType
class SourceInfoTest : ShouldSpec() {
init {
context("Parsing invalid strings") {
shouldThrow<IllegalArgumentException> {
parseSourceInfoJson("")
}
shouldThrow<IllegalArgumentException> {
parseSourceInfoJson("{")
}
shouldThrow<IllegalArgumentException> {
parseSourceInfoJson(
"""
{ "a" : { "videoType": 5 } }
""".trimIndent()
)
}
shouldThrow<IllegalArgumentException> {
parseSourceInfoJson(
"""
{ "a" : { "videoType": "x" } }
""".trimIndent()
)
}
}
context("Parsing valid strings") {
parseSourceInfoJson("{}") shouldBe emptySet()
parseSourceInfoJson(
"""
{
"3b554cf4-a0": {
"muted": false
},
"3b554cf4-v0": {
},
"3b554cf4-v1": {
"muted": true,
"videoType": "desktop"
},
"3b554cf4-v3": {
"mediaType": "audio"
}
}
""".trimIndent()
).shouldBe(
setOf(
SourceInfo("3b554cf4-a0", false, null, MediaType.AUDIO),
SourceInfo("3b554cf4-v0", true, null, MediaType.VIDEO),
SourceInfo("3b554cf4-v1", true, VideoType.Desktop, MediaType.VIDEO),
// The explicitly signaled "audio" precedes over "video" inferred from "-v3" in the name.
SourceInfo("3b554cf4-v3", true, null, MediaType.AUDIO),
)
)
}
context("Parsing media type") {
parseSourceInfoJson("""{ "abc-a0": {} }""".trimIndent()).first().mediaType shouldBe MediaType.AUDIO
parseSourceInfoJson("""{ "abc-v0": {} }""".trimIndent()).first().mediaType shouldBe MediaType.VIDEO
parseSourceInfoJson("""{ "abc-d0": {} }""".trimIndent()).first().mediaType shouldBe null
parseSourceInfoJson("""{ "abc0": {} }""".trimIndent()).first().mediaType shouldBe null
parseSourceInfoJson("""{ "abc0": { "mediaType": "audio" } }""".trimIndent()).first().mediaType shouldBe
MediaType.AUDIO
parseSourceInfoJson("""{ "abc0": { "mediaType": "VIDEO" } }""".trimIndent()).first().mediaType shouldBe
MediaType.VIDEO
}
}
}