snapshot of https://github.com/jitsi/jicofo.git @ 9ab59aa3d348e282da89da2c9f11ecbecd3f7b79 (2026-09-15)
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
24
jicofo-common/src/main/kotlin/org/jitsi/jicofo/MediaType.kt
Normal file
24
jicofo-common/src/main/kotlin/org/jitsi/jicofo/MediaType.kt
Normal 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
|
||||
}
|
||||
40
jicofo-common/src/main/kotlin/org/jitsi/jicofo/OctoConfig.kt
Normal file
40
jicofo-common/src/main/kotlin/org/jitsi/jicofo/OctoConfig.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
57
jicofo-common/src/main/kotlin/org/jitsi/jicofo/TaskPools.kt
Normal file
57
jicofo-common/src/main/kotlin/org/jitsi/jicofo/TaskPools.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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) } })
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
76
jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/Util.kt
Normal file
76
jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/Util.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
214
jicofo-common/src/main/resources/reference.conf
Normal file
214
jicofo-common/src/main/resources/reference.conf
Normal 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 = []
|
||||
}
|
||||
}
|
||||
@@ -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]\""
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>()) }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user