/* * Jicofo, the Jitsi Conference Focus. * * Distributable under LGPL license. * See terms of license at gnu.org. */ package org.jitsi.jicofo; import net.java.sip.communicator.impl.protocol.jabber.extensions.colibri.*; import net.java.sip.communicator.impl.protocol.jabber.extensions.colibri.ColibriConferenceIQ.Recording.*; import net.java.sip.communicator.impl.protocol.jabber.extensions.jingle.*; import net.java.sip.communicator.impl.protocol.jabber.jinglesdp.*; import net.java.sip.communicator.service.protocol.*; import net.java.sip.communicator.service.protocol.event.*; import net.java.sip.communicator.util.*; import net.java.sip.communicator.util.Logger; import org.jitsi.jicofo.log.*; import org.jitsi.jicofo.recording.*; import org.jitsi.jicofo.util.*; import org.jitsi.protocol.*; import org.jitsi.protocol.xmpp.*; import org.jitsi.protocol.xmpp.extensions.*; import org.jitsi.protocol.xmpp.util.*; import org.jitsi.service.neomedia.*; import org.jitsi.util.*; import org.jitsi.videobridge.log.*; import org.jitsi.videobridge.*; import org.jitsi.videobridge.osgi.*; import org.jitsi.videobridge.openfire.PluginImpl; import org.jivesoftware.smack.*; import org.jivesoftware.smack.filter.*; import org.jivesoftware.smack.packet.*; import org.jivesoftware.smack.packet.Message; import java.io.*; import java.util.*; import java.util.concurrent.*; import java.util.zip.*; import org.jivesoftware.util.JiveGlobals; /** * Class represents the focus of Jitsi Meet conference. Responsibilities: * a) Invites peers to the conference once they join multi user chat room * (establishes Jingle session with peer). * b) Manages colibri channels per peer. * c) Advertisement of changes in peer's SSRCs. When new peer joins the * 'add-source' notification is being sent, on leave: 'remove-source' * and a combination of add/remove on stream switch(desktop sharing). * * @author Pawel Domas */ public class JitsiMeetConference implements RegistrationStateChangeListener, JingleRequestHandler { /** * The logger instance used by this class. */ private final static Logger logger = Logger.getLogger(JitsiMeetConference.class); /** * FIXME: remove and replace with focusUserName which is already available * The constant describes focus MUC nickname */ private final static String FOCUS_NICK = "focus"; /** * Error code used in {@link OperationFailedException} when there are no * working videobridge bridges. * FIXME: consider moving to OperationFailedException ? */ private final static int BRIDGE_FAILURE_ERR_CODE = 20; /** * Name of MUC room that is hosting Jitsi Meet conference. */ private final String roomName; /** * The address of XMPP server to which the focus user will connect to. */ private final String serverAddress; /** * The name of XMPP domain used by the focus user to login. */ private final String xmppDomain; /** * The password user by the focus to login * (if null then will login anonymously). */ private final String xmppLoginPassword; /** * {@link ConferenceListener} that will be notified about conference events. */ private final ConferenceListener listener; /** * The instance of conference configuration. */ private final JitsiMeetConfig config; /** * XMPP protocol provider handler used by the focus. */ private ProtocolProviderHandler protocolProviderHandler = new ProtocolProviderHandler(); /** * Chat room operation set used to handle MUC stuff. */ private OperationSetMultiUserChat chatOpSet; /** * Conference room chat instance. */ private ChatRoom chatRoom; /** * Operation set used to handle Jingle sessions with conference peers. */ private OperationSetJingle jingle; /** * Colibri operation set used to manage videobridge channels allocations. */ private OperationSetColibriConference colibri; /** * Jitsi Meet tool used for specific operations like adding presence * extensions. */ private OperationSetJitsiMeetTools meetTools; /** * The list of active conference participants. */ private final List<Participant> participants = new CopyOnWriteArrayList<Participant>(); /** * Operation set used for service discovery. */ private OperationSetSimpleCaps disco; /** * Information about Jitsi Meet conference services like videobridge, * SIP gateway, Jirecon. */ private JitsiMeetServices services; /** * Handler that takes care of pre-processing various Jitsi Meet extensions * IQs sent from conference participants to the focus. */ private MeetExtensionsHandler meetExtensionsHandler; /** * Recording functionality implementation. */ private Recorder recorder; /** * Chat room roles and presence handler. */ private ChatRoomRoleAndPresence presenceHandler; /** * Indicates if this instance has been started(initialized). */ private boolean started; /** * Idle timestamp for this focus, -1 means active, otherwise * System.currentTimeMillis() is set when focus becomes idle. * Used to detect idle session and expire it if idle time limit is exceeded. */ private long idleTimestamp = -1; /** * The <tt>PacketListener</tt> which we use to handle incoming * <tt>message</tt> stanzas. */ private final PacketListener messageListener = new MessageListener(); /** * Keeps a record whether user has activated recording before other * participants has joined and the actual conference has been created. */ private RecordingState earlyRecordingState = null; /** * Creates new instance of {@link JitsiMeetConference}. * * @param roomName name of MUC room that is hosting the conference. * @param serverAddress the address of the XMPP server. * @param xmppDomain optional XMPP domain if different than * <tt>serverAddress</tt> * @param xmppLoginPassword optional XMPP focus user password * (if <tt>null</tt> then focus user will connect anonymously). * @param listener the listener that will be notified about this instance * events. * @param config the conference configuration instance. */ public JitsiMeetConference(String roomName, String serverAddress, String xmppDomain, String xmppLoginPassword, ConferenceListener listener, JitsiMeetConfig config) { this.roomName = roomName; this.serverAddress = serverAddress; this.xmppDomain = xmppDomain != null ? xmppDomain : serverAddress; this.xmppLoginPassword = xmppLoginPassword; this.listener = listener; this.config = config; } /** * Creates new instance of {@link JitsiMeetConference}. * * @param roomName name of MUC room that is hosting the conference. * @param serverAddress name of the XMPP server. * @param listener the listener that will be notified about this instance * events. */ // FIXME: why is not used now ? remove eventually public JitsiMeetConference(String roomName, String serverAddress, ConferenceListener listener) { this(roomName, serverAddress, null, null, listener, new JitsiMeetConfig(new HashMap<String, String>())); } /** * Starts conference focus processing, bind listeners and so on... * * @throws Exception if error occurs during initialization. Instance is * considered broken in that case. */ public synchronized void start() throws Exception { if (started) return; protocolProviderHandler.start( serverAddress, xmppDomain, xmppLoginPassword, FOCUS_NICK, this); colibri = protocolProviderHandler.getOperationSet( OperationSetColibriConference.class); colibri.setJitsiMeetConfig(config); jingle = protocolProviderHandler.getOperationSet( OperationSetJingle.class); jingle.setRequestHandler(this); chatOpSet = protocolProviderHandler.getOperationSet( OperationSetMultiUserChat.class); disco = protocolProviderHandler.getOperationSet( OperationSetSimpleCaps.class); meetTools = protocolProviderHandler.getOperationSet( OperationSetJitsiMeetTools.class); meetExtensionsHandler = new MeetExtensionsHandler(this); services = ServiceUtils2.getService(FocusBundleActivator.bundleContext, JitsiMeetServices.class); // Set pre-configured videobridge services.getBridgeSelector() .setPreConfiguredBridge(config.getPreConfiguredVideobridge()); if (!protocolProviderHandler.isRegistered()) { protocolProviderHandler.register(); } else { joinTheRoom(); } idleTimestamp = System.currentTimeMillis(); started = true; } /** * Returns <tt>true</tt> if focus has joined the conference room. */ public boolean isInTheRoom() { return chatRoom != null && chatRoom.isJoined(); } /** * Checks if it's the right time to join the room and does it eventually. */ private void maybeJoinTheRoom() { if (chatRoom == null && protocolProviderHandler.isRegistered()) { logger.info("Registered: " + protocolProviderHandler); joinTheRoom(); } } /** * Joins the conference room. */ private void joinTheRoom() { logger.info("Joining the room: " + roomName); try { chatRoom = chatOpSet.findRoom(roomName); presenceHandler = new ChatRoomRoleAndPresence(this, chatRoom); presenceHandler.init(); chatRoom.join(); meetExtensionsHandler.init(); } catch (Exception e) { logger.error(e, e); stop(); } } private OperationSetDirectSmackXmpp getDirectXmppOpSet() { return protocolProviderHandler.getOperationSet( OperationSetDirectSmackXmpp.class); } /** * Lazy initializer for {@link #recorder}. If there is Jirecon component * service available then {@link JireconRecorder} is used. Otherwise we fall * back to direct videobridge communication through {@link JvbRecorder}. * * @return {@link Recorder} implementation used by this instance. */ private Recorder getRecorder() { if (recorder == null) { OperationSetDirectSmackXmpp xmppOpSet = protocolProviderHandler.getOperationSet( OperationSetDirectSmackXmpp.class); String recorderService = services.getJireconRecorder(); if (!StringUtils.isNullOrEmpty(recorderService)) { recorder = new JireconRecorder( getFocusJid(), services.getJireconRecorder(), xmppOpSet); } else { logger.warn("No recorder service discovered - using JVB"); recorder = new JvbRecorder( colibri.getConferenceId(), services.getVideobridge(), xmppOpSet); } } return recorder; } /** * Leaves the conference room. */ private void leaveTheRoom() { if (chatRoom == null) { logger.error("Chat room already left!"); return; } if (presenceHandler != null) { presenceHandler.dispose(); presenceHandler = null; } chatRoom.leave(); chatRoom = null; } /** * Method called by {@link #presenceHandler} when new member joins * the conference room. * * @param chatRoomMember the new member that has just joined the room. */ protected void onMemberJoined(final ChatRoomMember chatRoomMember) { logger.info("Member " + chatRoomMember.getName() + " joined " + chatRoom.getName()); idleTimestamp = -1; if (!initConference()) return; // Invite peer takes time because of channel allocation, so schedule // this on separate thread FocusBundleActivator .getSharedThreadPool() .submit(new Runnable() { @Override public void run() { inviteChatMember(chatRoomMember); } }); } /** * Invites new member to the conference which means new Jingle session * established and videobridge channels being allocated. * * @param chatRoomMember the chat member to be invited into the conference. */ private void inviteChatMember(ChatRoomMember chatRoomMember) { if (isFocusMember(chatRoomMember)) return; if (findParticipantForChatMember(chatRoomMember) != null) return; logger.info("Inviting " + chatRoomMember.getContactAddress()); String address = chatRoomMember.getContactAddress(); Participant newParticipant = new Participant( (XmppChatMember) chatRoomMember); participants.add(newParticipant); /* BAO // Detect bundle support newParticipant.setHasBundleSupport( disco.hasFeatureSupport( address, new String[]{ "urn:ietf:rfc:5761", // rtcp-mux "urn:ietf:rfc:5888" // bundle })); // Is it SIP gateway ? newParticipant.setIsSipGateway( disco.hasFeatureSupport( address, new String[] { "http://jitsi.org/protocol/jigasi" })); logger.info( chatRoomMember.getContactAddress() + " has bundle ? " + newParticipant.hasBundleSupport()); */ try { List<ContentPacketExtension> offer = createOffer(newParticipant); jingle.initiateSession( newParticipant.hasBundleSupport(), address, offer); } catch (OperationFailedException e) { //FIXME: retry ? sometimes it's just timeout logger.error( "Failed to invite " + chatRoomMember.getContactAddress(), e); participants.remove(newParticipant); // Notify users about bridge is down event if (BRIDGE_FAILURE_ERR_CODE == e.getErrorCode()) { meetTools.sendPresenceExtension( chatRoom, new BridgeIsDownPacketExt()); } } } /** * Allocates Colibri channels for given {@link Participant} by trying all * available bridges returned by {@link BridgeSelector}. * * @param peer the for whom Colibri channel are to be allocated. * @param contents the media offer description passed to the bridge. * * @return {@link ColibriConferenceIQ} that describes channels allocated for * given <tt>peer</tt>. * * @throws OperationFailedException if we have failed to allocate channels * using existing bridge and we can not switch to another bridge. */ private ColibriConferenceIQ allocateChannels( Participant peer, List<ContentPacketExtension> contents) throws OperationFailedException { // Allocate by trying all bridges on prioritized list BridgeSelector bridgeSelector = services.getBridgeSelector(); Iterator<String> bridgesIterator = bridgeSelector.getPrioritizedBridgesList().iterator(); // Set initial bridge if we haven't used any yet if (StringUtils.isNullOrEmpty(colibri.getJitsiVideobridge())) { if (!bridgesIterator.hasNext()) { throw new OperationFailedException( "Failed to allocate channels - no bridge configured", OperationFailedException.GENERAL_ERROR); } colibri.setJitsiVideobridge( bridgesIterator.next()); } boolean conferenceExists = colibri.getConferenceId() != null; while (true) { try { ColibriConferenceIQ peerChannels = colibri.createColibriChannels( peer.hasBundleSupport(), peer.getChatMember().getName(), true, contents); bridgeSelector.updateBridgeOperationalStatus( colibri.getJitsiVideobridge(), true); if (!conferenceExists) { // If conferenceId is returned at this point it means that // the conference has just been created, so we log it. String conferenceId = colibri.getConferenceId(); if (conferenceId != null) { LoggingService loggingService = FocusBundleActivator.getLoggingService(); if (loggingService != null) { loggingService.logEvent( LogEventFactory.conferenceRoom( conferenceId, roomName)); } Videobridge videobridge = PluginImpl.component.getVideobridge(); Conference conference = videobridge.getConference(conferenceId, null); if (conference != null) { String confName = roomName.split("@")[0]; conference.setName(confName); // BAO logger.info("Conference mapping beteeen bridge and room " + confName + " " + conferenceId); if (JiveGlobals.getProperty("org.jitsi.videobridge.ofmeet.media.record", "false").equals("true") && JiveGlobals.getProperty("ofmeet.autorecord.enabled", "false").equals("true") && !conference.isRecording()) { conference.setRecording(true); logger.info("Auto recording set for " + confName + " " + conferenceId); } } } } return peerChannels; } catch(OperationFailedException exc) { String faultyBridge = colibri.getJitsiVideobridge(); logger.error( "Failed to allocate channels using bridge: " + colibri.getJitsiVideobridge(), exc); bridgeSelector.updateBridgeOperationalStatus( faultyBridge, false); // Check if the conference is in progress if (!StringUtils.isNullOrEmpty(colibri.getConferenceId())) { // Restart logger.error("Bridge failure - stopping the conference"); stop(); } else if (!bridgesIterator.hasNext()) { // No more bridges to try throw new OperationFailedException( "Failed to allocate channels - all bridges are faulty", BRIDGE_FAILURE_ERR_CODE); } else { // Try next bridge colibri.setJitsiVideobridge(bridgesIterator.next()); } } } } /** * Creates Jingle offer for given {@link Participant}. * * @param peer the participant for whom Jingle offer will be created. * * @return the list of contents describing conference Jingle offer. * * @throws OperationFailedException if focus fails to allocate channels * or something goes wrong. */ private List<ContentPacketExtension> createOffer(Participant peer) throws OperationFailedException { List<ContentPacketExtension> contents = new ArrayList<ContentPacketExtension>(); boolean enableFirefoxHacks = config == null || config.enableFirefoxHacks() == null ? false : config.enableFirefoxHacks(); contents.add( JingleOfferFactory.createContentForMedia(MediaType.AUDIO, enableFirefoxHacks)); contents.add( JingleOfferFactory.createContentForMedia(MediaType.VIDEO, enableFirefoxHacks)); boolean openSctp = config == null || config.openSctp() == null ? true : config.openSctp(); if (openSctp) { contents.add( JingleOfferFactory.createContentForMedia(MediaType.DATA, enableFirefoxHacks)); } boolean useBundle = peer.hasBundleSupport(); ColibriConferenceIQ peerChannels = allocateChannels(peer, contents); if (peerChannels == null) return null; peer.setColibriChannelsInfo(peerChannels); for (ContentPacketExtension cpe : contents) { ColibriConferenceIQ.Content colibriContent = peerChannels.getContent(cpe.getName()); if (colibriContent == null) continue; // Channels for (ColibriConferenceIQ.Channel channel : colibriContent.getChannels()) { IceUdpTransportPacketExtension transport; if (useBundle) { ColibriConferenceIQ.ChannelBundle bundle = peerChannels.getChannelBundle( channel.getChannelBundleId()); if (bundle == null) { logger.error( "No bundle for " + channel.getChannelBundleId()); continue; } transport = bundle.getTransport(); if (!transport.isRtcpMux()) { transport.addChildExtension( new RtcpmuxPacketExtension()); } } else { transport = channel.getTransport(); } try { // Remove empty transport IceUdpTransportPacketExtension empty = cpe.getFirstChildOfType( IceUdpTransportPacketExtension.class); cpe.getChildExtensions().remove(empty); cpe.addChildExtension( IceUdpTransportPacketExtension .cloneTransportAndCandidates(transport, true)); } catch (Exception e) { logger.error(e, e); } } // SCTP connections for (ColibriConferenceIQ.SctpConnection sctpConn : colibriContent.getSctpConnections()) { IceUdpTransportPacketExtension transport; if (useBundle) { ColibriConferenceIQ.ChannelBundle bundle = peerChannels.getChannelBundle( sctpConn.getChannelBundleId()); if (bundle == null) { logger.error( "No bundle for " + sctpConn.getChannelBundleId()); continue; } transport = bundle.getTransport(); } else { transport = sctpConn.getTransport(); } try { // Remove empty transport IceUdpTransportPacketExtension empty = cpe.getFirstChildOfType( IceUdpTransportPacketExtension.class); cpe.getChildExtensions().remove(empty); IceUdpTransportPacketExtension copy = IceUdpTransportPacketExtension .cloneTransportAndCandidates(transport, true); // FIXME: hardcoded SctpMapExtension sctpMap = new SctpMapExtension(); sctpMap.setPort(5000); sctpMap.setProtocol( SctpMapExtension.Protocol.WEBRTC_CHANNEL); sctpMap.setStreams(1024); copy.addChildExtension(sctpMap); cpe.addChildExtension(copy); } catch (Exception e) { logger.error(e, e); } } // Existing peers SSRCs RtpDescriptionPacketExtension rtpDescPe = JingleUtils.getRtpDescription(cpe); if (rtpDescPe != null) { if (useBundle) { // rtcp-mux rtpDescPe.addChildExtension( new RtcpmuxPacketExtension()); } // Include all peers SSRCs List<SourcePacketExtension> mediaSources = getAllSSRCs(cpe.getName()); for (SourcePacketExtension ssrc : mediaSources) { try { rtpDescPe.addChildExtension( ssrc.copy()); } catch (Exception e) { logger.error("Copy SSRC error", e); } } // Include SSRC groups List<SourceGroupPacketExtension> sourceGroups = getAllSSRCGroups(cpe.getName()); for(SourceGroupPacketExtension ssrcGroup : sourceGroups) { rtpDescPe.addChildExtension(ssrcGroup); } // Copy SSRC sent from the bridge(only the first one) for (ColibriConferenceIQ.Channel channel : colibriContent.getChannels()) { SourcePacketExtension ssrcPe = channel.getSources().size() > 0 ? channel.getSources().get(0) : null; if (ssrcPe != null) { try { String contentName = colibriContent.getName(); SourcePacketExtension ssrcCopy = ssrcPe.copy(); // FIXME: not all parameters are used currently ssrcCopy.addParameter( new ParameterPacketExtension( "cname","mixed")); ssrcCopy.addParameter( new ParameterPacketExtension( "label", "mixedlabel" + contentName + "0")); ssrcCopy.addParameter( new ParameterPacketExtension( "msid", "mixedmslabel mixedlabel" + contentName + "0")); ssrcCopy.addParameter( new ParameterPacketExtension( "mslabel","mixedmslabel")); rtpDescPe.addChildExtension(ssrcCopy); } catch (Exception e) { logger.error("Copy SSRC error", e); } } } } } return contents; } /** * Initializes the conference by inviting first participants. * * @return <tt>false</tt> if it's too early to start, or <tt>true</tt> * if the conference has started. */ private boolean initConference() { if (!checkAtLeastTwoParticipants()) return false; for (ChatRoomMember member : chatRoom.getMembers()) { inviteChatMember(member); } return true; } /** * Counts the number of non-focus chat room members and returns * <tt>true</tt> if there are at least two of them. * * @return <tt>true</tt> if we have at least two non-focus participants. */ private boolean checkAtLeastTwoParticipants() { // 2 + 1 focus if (chatRoom.getMembersCount() >= (2 + 1)) return true; int realCount = 0; for (ChatRoomMember member : chatRoom.getMembers()) { if (!isFocusMember(member)) realCount++; } return realCount >= 2; } /** * Counts human participants in the conference by excluding focus, SIP * gateway and other service participants in future. * * // TODO: also exclude Jirecon participant * * @return the number of human participants in the conference excluding * service participants like the focus or SIP gateway. */ private boolean checkAtLeastOneHumanParticipants() { int humanCount = 0; for (Participant participant : participants) { if (!isFocusMember(participant.getChatMember()) && !participant.isSipGateway()) humanCount++; } return humanCount > 0; } /** * Check if given chat member is a focus. * * @param member the member to check. * * @return <tt>true</tt> if given {@link ChatRoomMember} is a focus * participant. */ static boolean isFocusMember(ChatRoomMember member) { return member.getName().equals("focus"); } /** * Check if given member represent SIP gateway participant. * @param member the chat member to be checked. * * @return <tt>true</tt> if given <tt>member</tt> represents the SIP gateway */ boolean isSipGateway(ChatRoomMember member) { Participant participant = findParticipantForChatMember(member); return participant != null && participant.isSipGateway(); } /** * Expires the conference on the bridge and other stuff realted to it. */ private void disposeConference() { // FIXME: Does it make sense to put recorder here ? if (recorder != null) { recorder.dispose(); recorder = null; } meetExtensionsHandler.dispose(); colibri.expireConference(); } /** * Method called by {@link #presenceHandler} when one of the members has * been kicked out of the conference room. * * @param chatRoomMember kicked chat room member. */ protected void onMemberKicked(ChatRoomMember chatRoomMember) { logger.info("Member " + chatRoomMember.getName() + " kicked !!! " + chatRoom.getName()); /* FIXME: terminate will have no effect, as peer's MUC address will be no longer active. Participant session = findParticipantForChatMember(chatRoomMember); if (session != null) { jingle.terminateSession( session.getJingleSession(), Reason.EXPIRED); } else { logger.warn("No active session with " + chatRoomMember.getContactAddress()); }*/ onMemberLeft(chatRoomMember); } /** * Method called by {@link #presenceHandler} when someone leave conference * chat room. * * @param chatRoomMember the member that has left the room. */ synchronized protected void onMemberLeft(ChatRoomMember chatRoomMember) { logger.info("Member " + chatRoomMember.getName() + " left " + chatRoom.getName()); Participant leftPeer = findParticipantForChatMember(chatRoomMember); if (leftPeer != null) { JingleSession peerJingleSession = leftPeer.getJingleSession(); if (peerJingleSession != null) { logger.info( "Hanging up member " + chatRoomMember.getContactAddress()); removeSSRCs(peerJingleSession, leftPeer.getSSRCsCopy(), leftPeer.getSSRCGroupsCopy()); ColibriConferenceIQ peerChannels = leftPeer.getColibriChannelsInfo(); if (peerChannels != null) { colibri.expireChannels(leftPeer.getColibriChannelsInfo()); } //jingle.terminateSession(session.getJingleSession()); } participants.remove(leftPeer); } if (!checkAtLeastOneHumanParticipants()) { // Terminate all other participants for (Participant participant : participants) { try { terminateParticipant(participant); } catch (OperationFailedException e) { logger.error(e, e); // Dispose the focus on failure (we would have done this // anyway later after "member left" events are handled) stop(); break; } } } if (participants.size() == 0) { stop(); } } /** * Stops the conference, disposes colibri channels and releases all * resources used by the focus. */ synchronized void stop() { if (!started) return; getDirectXmppOpSet().removePacketHandler(messageListener); disposeConference(); leaveTheRoom(); disposeAccount(); listener.conferenceEnded(this); started = false; } /** * Destroys focus XMPP account. */ private void disposeAccount() { jingle.setRequestHandler(null); protocolProviderHandler.stop(); } @Override public void registrationStateChanged(RegistrationStateChangeEvent evt) { logger.info("Reg state changed: " + evt); if (RegistrationState.REGISTERED.equals(evt.getNewState())) { getDirectXmppOpSet().addPacketHandler( messageListener, new MessageTypeFilter(Message.Type.normal)); } maybeJoinTheRoom(); } private Participant findParticipantForJingleSession( JingleSession jingleSession) { for (Participant participant : participants) { if (participant.getChatMember() .getContactAddress().equals(jingleSession.getAddress())) return participant; } return null; } private Participant findParticipantForChatMember(ChatRoomMember chatMember) { for (Participant participant : participants) { if (participant.getChatMember().equals(chatMember)) return participant; } return null; } private Participant findParticipantForJabberId(String jid) { for (Participant participant : participants) { String peerJid = participant.getChatMember().getJabberID(); if (peerJid != null && peerJid.equals(jid)) { return participant; } } return null; } Participant findParticipantForRoomJid(String roomJid) { for (Participant participant : participants) { String peerRoomJid = participant.getJingleSession().getAddress(); if (peerRoomJid != null && peerRoomJid.equals(roomJid)) { return participant; } } return null; } private Participant findParticipantForMucAddress(String mucAddress) { for (Participant participant : participants) { if (participant.getChatMember() .getContactAddress().equals(mucAddress)) { return participant; } } return null; } private void terminateParticipant(Participant participant) throws OperationFailedException { JingleSession session = participant.getJingleSession(); if (session != null) { jingle.terminateSession(session, Reason.EXPIRED); } // Kick out of the room chatRoom.kickParticipant( participant.getChatMember(), "End of the conference"); } /** * Callback called when 'session-accept' is received from invited * participant. * * {@inheritDoc} */ @Override public void onSessionAccept( JingleSession peerJingleSession, List<ContentPacketExtension> answer) { Participant participant = findParticipantForJingleSession(peerJingleSession); if (participant.getJingleSession() != null) { //FIXME: we should reject it ? logger.error( "Reassigning jingle session for participant: " + peerJingleSession.getAddress()); } participant.setJingleSession(peerJingleSession); participant.addSSRCsFromContent(answer); participant.addSSRCGroupsFromContent(answer); // Update SSRC groups colibri.updateSsrcGroupsInfo( participant.getSSRCGroupsCopy(), participant.getColibriChannelsInfo()); logger.info("Got SSRCs from " + peerJingleSession.getAddress()); for (Participant peerToNotify : participants) { JingleSession jingleSessionToNotify = peerToNotify.getJingleSession(); if (jingleSessionToNotify == null) { logger.warn( "No jingle session yet for " + peerToNotify.getChatMember().getContactAddress()); peerToNotify.scheduleSSRCsToAdd(participant.getSSRCS()); peerToNotify.scheduleSSRCGroupsToAdd( participant.getSSRCGroups()); continue; } // Skip origin if (peerJingleSession.equals(jingleSessionToNotify)) continue; jingle.sendAddSourceIQ( participant.getSSRCS(), participant.getSSRCGroups(), jingleSessionToNotify); } // Notify the peer itself since it is now stable if (participant.hasSsrcsToAdd()) { jingle.sendAddSourceIQ( participant.getSsrcsToAdd(), participant.getSSRCGroupsToAdd(), peerJingleSession); participant.clearSsrcsToAdd(); } if (participant.hasSsrcsToRemove()) { jingle.sendRemoveSourceIQ( participant.getSsrcsToRemove(), participant.getSsrcGroupsToRemove(), peerJingleSession); participant.clearSsrcsToRemove(); } // Notify the bridge about eventual transport included onTransportInfo(peerJingleSession, answer); } /** * Callback called when we receive 'transport-info' from conference * participant. The info is forwarded to the videobridge at this point. * * {@inheritDoc} */ @Override public void onTransportInfo(JingleSession session, List<ContentPacketExtension> contentList) { Participant participant = findParticipantForJingleSession(session); if (participant == null) { logger.error("Failed to process transport-info," + " no session for: " + session.getAddress()); return; } if (participant.hasBundleSupport()) { // Select first transport IceUdpTransportPacketExtension transport = null; for (ContentPacketExtension cpe : contentList) { IceUdpTransportPacketExtension contentTransport = cpe.getFirstChildOfType( IceUdpTransportPacketExtension.class); if (contentTransport != null) { transport = contentTransport; break; } } if (transport == null) { logger.error( "No valid transport suppied in transport-update from " + participant.getChatMember().getName()); return; } transport.addChildExtension( new RtcpmuxPacketExtension()); // FIXME: initiator boolean initiator = true; colibri.updateBundleTransportInfo( initiator, transport, participant.getColibriChannelsInfo()); } else { Map<String, IceUdpTransportPacketExtension> transportMap = new HashMap<String, IceUdpTransportPacketExtension>(); for (ContentPacketExtension cpe : contentList) { IceUdpTransportPacketExtension transport = cpe.getFirstChildOfType( IceUdpTransportPacketExtension.class); if (transport != null) { transportMap.put(cpe.getName(), transport); } } // FIXME: initiator boolean initiator = true; colibri.updateTransportInfo( initiator, transportMap, participant.getColibriChannelsInfo()); } } /** * Callback called when we receive 'source-add' notification from conference * participant. New SSRCs received are advertised to active participants. * If some participant does not have Jingle session established yet then * those SSRCs are scheduled for future update. * * {@inheritDoc} */ @Override public void onAddSource(JingleSession jingleSession, List<ContentPacketExtension> contents) { Participant participant = findParticipantForJingleSession(jingleSession); if (participant == null) { logger.error("Add-source: no peer state for " + jingleSession.getAddress()); return; } participant.addSSRCsFromContent(contents); participant.addSSRCGroupsFromContent(contents); MediaSSRCMap ssrcsToAdd = MediaSSRCMap.getSSRCsFromContent(contents); MediaSSRCGroupMap ssrcGroupsToAdd = MediaSSRCGroupMap.getSSRCGroupsForContents(contents); // Updates SSRC Groups on the bridge colibri.updateSsrcGroupsInfo( participant.getSSRCGroupsCopy(), participant.getColibriChannelsInfo()); for (Participant peerToNotify : participants) { if (peerToNotify == participant) continue; JingleSession peerJingleSession = peerToNotify.getJingleSession(); if (peerJingleSession == null) { logger.warn( "Add source: no call for " + peerToNotify.getChatMember().getContactAddress()); peerToNotify.scheduleSSRCsToAdd(ssrcsToAdd); peerToNotify.scheduleSSRCGroupsToAdd(ssrcGroupsToAdd); continue; } jingle.sendAddSourceIQ( ssrcsToAdd, ssrcGroupsToAdd, peerJingleSession); } } /** * Callback called when we receive 'source-remove' notification from * conference participant. New SSRCs received are advertised to active * participants. If some participant does not have Jingle session * established yet then those SSRCs are scheduled for future update. * * {@inheritDoc} */ @Override public void onRemoveSource(JingleSession sourceJingleSession, List<ContentPacketExtension> contents) { MediaSSRCMap ssrcsToRemove = MediaSSRCMap.getSSRCsFromContent(contents); MediaSSRCGroupMap ssrcGroupsToRemove = MediaSSRCGroupMap.getSSRCGroupsForContents(contents); removeSSRCs(sourceJingleSession, ssrcsToRemove, ssrcGroupsToRemove); } /** * Removes SSRCs from the conference and notifies other participants. * * @param sourceJingleSession source Jingle session from which SSRCs are * being removed. * @param ssrcsToRemove the {@link MediaSSRCMap} of SSRCs to be removed from * the conference. */ private void removeSSRCs(JingleSession sourceJingleSession, MediaSSRCMap ssrcsToRemove, MediaSSRCGroupMap ssrcGroupsToRemove) { Participant sourcePeer = findParticipantForJingleSession(sourceJingleSession); if (sourcePeer == null) { logger.error("Remove-source: no session for " + sourceJingleSession.getAddress()); return; } sourcePeer.removeSSRCs(ssrcsToRemove); sourcePeer.removeSSRCGroups(ssrcGroupsToRemove); // Updates SSRC Groups on the bridge colibri.updateSsrcGroupsInfo( ssrcGroupsToRemove, sourcePeer.getColibriChannelsInfo()); logger.info("Remove SSRC " + sourceJingleSession.getAddress()); for (Participant peer : participants) { if (peer == sourcePeer) continue; JingleSession jingleSessionToNotify = peer.getJingleSession(); if (jingleSessionToNotify == null) { logger.warn( "Remove source: no jingle session for " + peer.getChatMember().getContactAddress()); peer.scheduleSSRCsToRemove(ssrcsToRemove); peer.scheduleSSRCGroupsToRemove(ssrcGroupsToRemove); continue; } jingle.sendRemoveSourceIQ( ssrcsToRemove, ssrcGroupsToRemove, jingleSessionToNotify); } } /** * Gathers the list of all SSRCs of given media type that exist in current * conference state. * * @param media the media type of SSRCs that are being returned. * * @return the list of all SSRCs of given media type that exist in current * conference state. */ private List<SourcePacketExtension> getAllSSRCs(String media) { List<SourcePacketExtension> mediaSSRCs = new ArrayList<SourcePacketExtension>(); for (Participant peer : participants) { List<SourcePacketExtension> peerSSRC = peer.getSSRCS().getSSRCsForMedia(media); if (peerSSRC != null) mediaSSRCs.addAll(peerSSRC); } return mediaSSRCs; } /** * Gathers the list of all SSRC groups of given media type that exist in * current conference state. * * @param media the media type of SSRC groups that are being returned. * * @return the list of all SSRC groups of given media type that exist in * current conference state. */ private List<SourceGroupPacketExtension> getAllSSRCGroups(String media) { List<SourceGroupPacketExtension> ssrcGroups = new ArrayList<SourceGroupPacketExtension>(); for (Participant peer : participants) { List<SSRCGroup> peerSSRCGroups = peer.getSSRCGroupsForMedia(media); for (SSRCGroup ssrcGroup : peerSSRCGroups) { try { ssrcGroups.add(ssrcGroup.getExtensionCopy()); } catch (Exception e) { logger.error("Error copying source group extension"); } } } return ssrcGroups; } /** * Returns the name of conference multi-user chat room. */ public String getRoomName() { return roomName; } /** * Returns XMPP protocol provider of the focus account. */ public ProtocolProviderService getXmppProvider() { return protocolProviderHandler.getProtocolProvider(); } /** * Attempts to modify conference recording state. * * @param from JID of the participant that wants to modify recording state. * @param token recording security token that will be verified on modify * attempt. * @param state the new recording state to set. * @param path output recording path(recorder implementation and deployment * dependent). * @param to the received colibri packet destination. * @return new recording state(unchanged if modify attempt has failed). */ public State modifyRecordingState( String from, String token, State state, String path, String to) { ChatRoomMember member = findMember(from); if (member == null) { logger.error("No member found for address: " + from); return State.OFF; } if (ChatRoomMemberRole.MODERATOR.compareTo(member.getRole()) < 0) { logger.info("Recording - request denied, not a moderator: " + from); return State.OFF; } Recorder recorder = getRecorder(); if (recorder == null) { if(state.equals(State.OFF)) { earlyRecordingState = null; return State.OFF; } // save for later dispatching earlyRecordingState = new RecordingState( from, token, state, path, to); return State.PENDING; } boolean isTokenCorrect = recorder.setRecording(from, token, state.equals(State.ON), path); if (!isTokenCorrect) { logger.info( "Incorrect recording token received ! Session: " + chatRoom.getName()); } return recorder.isRecording() ? State.ON : State.OFF; } private ChatRoomMember findMember(String from) { for (ChatRoomMember member : chatRoom.getMembers()) { if (member.getContactAddress().equals(from)) { return member; } } return null; } /** * Returns {@link System#currentTimeMillis()} timestamp indicating the time * when this conference has become idle(we can measure how long is it). * -1 is returned if this conference is considered active. * */ public long getIdleTimestamp() { return idleTimestamp; } /** * Returns focus MUC JID if it is in the room or <tt>null</tt> otherwise. * JID example: room_name@muc.server.com/focus_nickname. */ public String getFocusJid() { return chatRoom != null ? chatRoom.getName() + "/" + FOCUS_NICK : null; } /** * Returns {@link JitsiMeetServices} instance used in this conference. */ public JitsiMeetServices getServices() { return services; } /** * Handles mute request sent from participants. * @param fromJid MUC jid of the participant that requested mute status * change. * @param toBeMutedJid MUC jid of the participant whose mute status will be * changed(eventually). * @param doMute the new audio mute status to set. * @return <tt>true</tt> if status has been set successfully. */ boolean handleMuteRequest(String fromJid, String toBeMutedJid, boolean doMute) { Participant principal = findParticipantForMucAddress(fromJid); if (principal == null) { logger.error( "Failed to perform mute operation - " + fromJid +" not exists in the conference."); return false; } // Only moderators can mute others if (!fromJid.equals(toBeMutedJid) && ChatRoomMemberRole.MODERATOR.compareTo( principal.getChatMember().getRole()) < 0) { logger.error( "Permission denied for mute operation from " + fromJid); return false; } Participant participant = findParticipantForMucAddress(toBeMutedJid); if (participant == null) { logger.error("Participant for jid: " + toBeMutedJid + " not found"); return false; } logger.info( "Will " + (doMute ? "mute" : "unmute") + " " + toBeMutedJid + " on behalf of " + fromJid); boolean succeeded = colibri.muteParticipant( participant.getColibriChannelsInfo(), doMute); if (succeeded) { participant.setMuted(doMute); } return succeeded; } /** * Called by {@link FocusManager} when the user identified by given * <tt>realJid</tt> gets confirmed <tt>identity</tt> by authentication * component. * * @param realJid the real user JID(not MUC JID which can be faked). * @param identity the identity of the user confirmed by authetication * component. */ void userAuthenticated(String realJid, String identity) { // FIXME: consider changing to debug log level once tested logger.info("Authenticate request for: " + realJid + " as " + identity); Participant participant = findParticipantForJabberId(realJid); if (participant == null) { logger.error("Auth request - no member found for JID: " + realJid); return; } ChatRoomMember chatMember = participant.getChatMember(); if (chatMember == null) { logger.error("No chat member for JID: " + realJid); return; } if (participant.getAuthenticatedIdentity() != null) { logger.error(realJid + " already authenticated"); return; } // Sets authenticated ID participant.setAuthenticatedIdentity(identity); // Grants moderator rights chatRoom.grantModerator(chatMember.getName()); } /** * The interface used to listen for conference events. */ public interface ConferenceListener { /** * Event fired when conference has ended. * @param conference the conference instance that has ended. */ void conferenceEnded(JitsiMeetConference conference); } /** * Handles <tt>message</tt> stanzas addressed to the focus JID for this * conference. Note that there are no filters, and we only depend on the * fact that each <tt>JitsiMeetConference</tt> has its own * <tt>XMPPConnection</tt>. * * Currently we only support XEP-0337 "log" messages with a limited set of * IDs and predefined format. Specifically, we always expect the text in the * message to be base64-encoded and, if the "delfated" tag is set, * compressed in the RFC1951 raw DEFLATE format. * * @author Boris Grozev */ private class MessageListener implements PacketListener { /** * The <tt>LoggingService</tt> which will be used to log the events * (usually to an InfluxDB instance). */ private LoggingService loggingService = null; /** * Whether {@link #loggingService} has been initialized or not. */ private boolean loggingServiceSet = false; /** * The string which identifies the contents of a log message as containg * PeerConnection statistics. */ private static final String LOG_ID_PC_STATS = "PeerConnectionStats"; /** * Processes a packet. Looks for known extensions (XEP-0337 "log" * extensions) and handles them. * @param packet the packet to process. */ @Override public void processPacket(Packet packet) { if (!(packet instanceof Message)) return; Message message = (Message) packet; LogPacketExtension log = null; for (PacketExtension ext : message.getExtensions()) { if (ext instanceof LogPacketExtension) { log = (LogPacketExtension) ext; break; } } if (log != null) { Participant participant = findParticipantForRoomJid(message.getFrom()); if (participant != null) { handleLogRequest(log, participant); } else { logger.info("Ignoring log request from unknown JID: " + message.getFrom()); } } } /** * Handles a <tt>LogPacketExtension</tt> which represents a request * from a specific <tt>Participant</tt> to log a message. * @param log the <tt>LogPacketExtension</tt> to handle. * @param participant the <tt>Participant</tt> which sent the request. */ private void handleLogRequest( LogPacketExtension log, Participant participant) { LoggingService loggingService = getLoggingService(); if (loggingService != null) { if (LOG_ID_PC_STATS.equals(log.getID())) { String content = getContent(log); if (content != null) { loggingService.logEvent( LogEventFactory.peerConnectionStats( colibri.getConferenceId(), participant.getChatMember().getName(), content)); } } else { if (logger.isInfoEnabled()) logger.info("Ignoring log request with an unknown ID:" + log.getID()); } } } /** * Gets the <tt>LoggingService</tt>. * @return the <tt>LoggingService</tt>. */ private LoggingService getLoggingService() { if (!loggingServiceSet) { loggingServiceSet = true; loggingService = FocusBundleActivator.getLoggingService(); } return loggingService; } /** * Extracts the message to be logged from a <tt>LogPacketExtension</tt>. * Takes care of base64 decoding and (optionally) decompression. * @param log the <tt>LogPacketExtension</tt> to handle. * @return the decoded message contained in <tt>log</tt>. */ private String getContent(LogPacketExtension log) { String messageBase64 = log.getMessage(); byte[] messageBytes = net.java.sip.communicator.util.Base64.decode(messageBase64); if (Boolean.parseBoolean(log.getTagValue("deflated"))) { // nowrap=true, because we expect "raw" deflate Inflater inflater = new Inflater(true); ByteArrayOutputStream result = new ByteArrayOutputStream(); inflater.setInput(messageBytes); byte[] buf = new byte[10000]; do { try { int len = inflater.inflate(buf); result.write(buf, 0, len); } catch (DataFormatException dfe) { if (logger.isInfoEnabled()) logger.info( "Failed to inflate log request content:" + dfe); return null; } } while (!inflater.finished()); return result.toString(); } else { return new String(messageBytes); } } } /** * Saves early recording requests by user. Dispatched when new participant * joins. */ private class RecordingState { /** * JID of the participant that wants to modify recording state. */ String from; /** * Recording security token that will be verified on modify attempt. */ String token; /** * The new recording state to set. */ State state; /** * Output recording path(recorder implementation * and deployment dependent). */ String path; /** * The received colibri packet destination. */ String to; public RecordingState(String from, String token, State state, String path, String to) { this.from = from; this.token = token; this.state = state; this.path = path; this.to = to; } } }