diff --git a/jigasi-home/sip-communicator.properties b/jigasi-home/sip-communicator.properties index 957087a54..7c6799607 100644 --- a/jigasi-home/sip-communicator.properties +++ b/jigasi-home/sip-communicator.properties @@ -126,6 +126,9 @@ net.java.sip.communicator.impl.protocol.jabber.acc-xmpp-1.DOMAIN_BASE=<> +# when checking other participants whether they are jibri/jigasi we can also check the the domain they use for connecting +#org.jitsi.jigasi.TRUSTED_DOMAINS=["recorder.<>", "sipjibri.<>", "jigasi.<>"] + org.jitsi.jigasi.BREWERY_ENABLED=true # We can use the prefix org.jitsi.jigasi.xmpp.acc to override any of the diff --git a/src/main/java/org/jitsi/jigasi/AudioModeration.java b/src/main/java/org/jitsi/jigasi/AudioModeration.java index addc1b196..a0939e3d4 100644 --- a/src/main/java/org/jitsi/jigasi/AudioModeration.java +++ b/src/main/java/org/jitsi/jigasi/AudioModeration.java @@ -131,7 +131,7 @@ public AudioModeration(JvbConference jvbConference, SipGatewaySession gatewaySes * @param meetTools the OperationSetJitsiMeetTools instance. * @return Returns the features extension element that can be added to presence. */ - static ExtensionElement addSupportedFeatures(OperationSetJitsiMeetToolsJabber meetTools) + static ExtensionElement getSupportedFeatures(OperationSetJitsiMeetToolsJabber meetTools) { if (isMutingSupported()) { diff --git a/src/main/java/org/jitsi/jigasi/JigasiBundleActivator.java b/src/main/java/org/jitsi/jigasi/JigasiBundleActivator.java index e77886ced..e14536d4c 100644 --- a/src/main/java/org/jitsi/jigasi/JigasiBundleActivator.java +++ b/src/main/java/org/jitsi/jigasi/JigasiBundleActivator.java @@ -196,6 +196,17 @@ public void startWithServices(final BundleContext bundleContext) StartMutedProvider.registerStartMutedProvider(); + ProviderManager.addExtensionProvider( + FeaturesExtension.ELEMENT, + FeaturesExtension.NAMESPACE, + new DefaultPacketExtensionProvider<>(FeaturesExtension.class) + ); + ProviderManager.addExtensionProvider( + FeatureExtension.ELEMENT, + FeatureExtension.NAMESPACE, + new DefaultPacketExtensionProvider<>(FeatureExtension.class) + ); + if (isSipEnabled()) { if (isSipStartMutedEnabled()) diff --git a/src/main/java/org/jitsi/jigasi/JvbConference.java b/src/main/java/org/jitsi/jigasi/JvbConference.java index 4166d490a..7c7476d22 100644 --- a/src/main/java/org/jitsi/jigasi/JvbConference.java +++ b/src/main/java/org/jitsi/jigasi/JvbConference.java @@ -22,12 +22,12 @@ import net.java.sip.communicator.service.protocol.event.*; import net.java.sip.communicator.service.protocol.jabber.*; import net.java.sip.communicator.service.protocol.media.*; -import net.java.sip.communicator.util.DataObject; -import net.java.sip.communicator.util.osgi.ServiceUtils; +import net.java.sip.communicator.util.*; +import net.java.sip.communicator.util.osgi.*; import org.apache.commons.lang3.StringUtils; import org.apache.http.client.utils.*; import org.jitsi.impl.neomedia.*; -import org.jitsi.jigasi.lobby.Lobby; +import org.jitsi.jigasi.lobby.*; import org.jitsi.jigasi.stats.*; import org.jitsi.jigasi.util.*; import org.jitsi.jigasi.version.*; @@ -66,6 +66,11 @@ import static net.java.sip.communicator.service.protocol.event.LocalUserChatRoomPresenceChangeEvent.*; import static org.jivesoftware.smack.packet.StanzaError.Condition.*; +import static org.jitsi.jigasi.lobby.Lobby.*; +import static org.jitsi.jigasi.TranscriptionGatewaySession.*; +import static org.jitsi.jigasi.util.Util.*; + + /** * Class takes care of handling Jitsi Videobridge conference. Currently, it waits * for the first XMPP provider service to be registered and uses it to join the @@ -90,13 +95,6 @@ public class JvbConference */ private final static Logger logger = Logger.getLogger(JvbConference.class); - /** - * The name of XMPP feature which states for Jigasi SIP Gateway and can be - * used to recognize gateway client. - */ - public static final String SIP_GATEWAY_FEATURE_NAME - = "http://jitsi.org/protocol/jigasi"; - /** * The name of XMPP feature for Jingle/DTMF feature (XEP-0181). */ @@ -192,29 +190,37 @@ public class JvbConference * OperationSetJitsiMeetTools instance. * @return Returns the 'features' extension element that can be added to presence. */ - private static ExtensionElement addSupportedFeatures( + private ExtensionElement addSupportedFeatures( OperationSetJitsiMeetToolsJabber meetTools) { FeaturesExtension features = new FeaturesExtension(); - meetTools.addSupportedFeature(SIP_GATEWAY_FEATURE_NAME); - features.addChildExtension(Util.createFeature(SIP_GATEWAY_FEATURE_NAME)); - meetTools.addSupportedFeature(DTMF_FEATURE_NAME); - features.addChildExtension(Util.createFeature(DTMF_FEATURE_NAME)); + meetTools.addSupportedFeature(JIGASI_FEATURE_NAME); + features.addChildExtension(Util.createFeature(JIGASI_FEATURE_NAME)); + + if (this.isTranscriber) + { + meetTools.addSupportedFeature(TRANSCRIBER_FEATURE_NAME); + features.addChildExtension(Util.createFeature(TRANSCRIBER_FEATURE_NAME)); + } + else + { + // dtmf is used only when sip calling + meetTools.addSupportedFeature(DTMF_FEATURE_NAME); + features.addChildExtension(Util.createFeature(DTMF_FEATURE_NAME)); + } - ConfigurationService cfg - = JigasiBundleActivator.getConfigurationService(); + ConfigurationService cfg = JigasiBundleActivator.getConfigurationService(); // Remove ICE support from features list ? if (cfg.getBoolean(SipGateway.P_NAME_DISABLE_ICE, false)) { - meetTools.removeSupportedFeature( - "urn:xmpp:jingle:transports:ice-udp:1"); + meetTools.removeSupportedFeature("urn:xmpp:jingle:transports:ice-udp:1"); logger.info("ICE feature will not be advertised"); } - ExtensionElement audioMuteFeature = AudioModeration.addSupportedFeatures(meetTools); + ExtensionElement audioMuteFeature = AudioModeration.getSupportedFeatures(meetTools); if (audioMuteFeature != null) { features.addChildExtension(audioMuteFeature); @@ -229,6 +235,11 @@ private static ExtensionElement addSupportedFeatures( */ private final AbstractGatewaySession gatewaySession; + /** + * Whether Jigasi will join as transcriber. + */ + private final boolean isTranscriber; + /** * Whether to auto stop when only jigasi are left in the room. */ @@ -372,11 +383,6 @@ private static ExtensionElement addSupportedFeatures( */ private final RoomMetadataListener roomMetadataListener = new RoomMetadataListener(); - /** - * Up-to-date list of participants in the room that are jigasi. - */ - private final List jigasiChatRoomMembers = Collections.synchronizedList(new ArrayList<>()); - /** * The features for the current xmpp provider we will use later adding to the room presence we send. */ @@ -391,17 +397,18 @@ private static ExtensionElement addSupportedFeatures( public JvbConference(AbstractGatewaySession gatewaySession, CallContext ctx) { this.gatewaySession = gatewaySession; + this.isTranscriber = this.gatewaySession instanceof TranscriptionGatewaySession; this.callContext = ctx; this.allowOnlyJigasiInRoom = JigasiBundleActivator.getConfigurationService() .getBoolean(P_NAME_ALLOW_ONLY_JIGASIS_IN_ROOM, true); - if (this.gatewaySession instanceof SipGatewaySession) + if (this.isTranscriber) { - this.audioModeration = new AudioModeration(this, (SipGatewaySession)this.gatewaySession, this.callContext); + this.audioModeration = null; } else { - this.audioModeration = null; + this.audioModeration = new AudioModeration(this, (SipGatewaySession)this.gatewaySession, this.callContext); } } @@ -772,7 +779,7 @@ private void discoverComponentAddresses() .findFirst().orElse(null); // we process room metadata messages only when we are transcribing - if (roomMetadataIdentity != null && this.gatewaySession instanceof TranscriptionGatewaySession) + if (roomMetadataIdentity != null && this.isTranscriber) { getConnection().addAsyncStanzaListener(roomMetadataListener, new AndFilter( @@ -874,9 +881,7 @@ public void joinConferenceRoom() // creates an extension to hold all headers, as when using // addPresencePacketExtensions it requires unique extensions // otherwise overrides them - AbstractPacketExtension initiator - = new AbstractPacketExtension( - SIP_GATEWAY_FEATURE_NAME, "initiator"){}; + AbstractPacketExtension initiator = new AbstractPacketExtension(JIGASI_FEATURE_NAME, "initiator"){}; // let's add all extra headers from the context callContext.getExtraHeaders().forEach( @@ -1206,18 +1211,8 @@ else if (ChatRoomMemberPresenceChangeEvent.MEMBER_UPDATED { if (member instanceof ChatRoomMemberJabberImpl) { - Presence presence = ((ChatRoomMemberJabberImpl) member).getLastPresence(); - - gatewaySession.notifyChatRoomMemberUpdated(member, presence); - - // let's check and whether it is a jigasi participant - // we use initiator as its easier for checking/parsing - if (presence != null - && !jigasiChatRoomMembers.contains(member.getName()) - && presence.hasExtension("initiator", SIP_GATEWAY_FEATURE_NAME)) - { - jigasiChatRoomMembers.add(member.getName()); - } + gatewaySession.notifyChatRoomMemberUpdated(member, + ((ChatRoomMemberJabberImpl) member).getLastPresence()); } } @@ -1230,8 +1225,6 @@ else if (ChatRoomMemberPresenceChangeEvent.MEMBER_UPDATED this.callContext + " Member left : " + member.getRole() + " " + member.getContactAddress()); - jigasiChatRoomMembers.remove(member.getName()); - CallPeer peer; if (jvbCall != null && (peer = jvbCall.getCallPeers().next()) instanceof MediaAwareCallPeer) { @@ -1301,7 +1294,8 @@ private void processChatRoomMemberLeft(ChatRoomMember member) boolean onlyJigasisInRoom = this.mucRoom.getMembers().stream().allMatch(m -> m.getName().equals(getResourceIdentifier().toString()) // ignore if it is us || m.getName().equals(gatewaySession.getFocusResourceAddr()) // ignore if it is jicofo - || jigasiChatRoomMembers.contains(m.getName())); + || (Util.isJigasi((ChatRoomMemberJabberImpl) m) + && !Util.isTranscriberJigasi((ChatRoomMemberJabberImpl)m))); if (onlyJigasisInRoom) { @@ -1327,6 +1321,25 @@ private void processChatRoomMemberLeft(ChatRoomMember member) // transcriber case stop(); } + + return; + } + } + + if (JvbConference.this.isTranscriber) + { + // make sure we hangup transcriber if only backend services are in the room - Jibri/Jigasi + // (maybe a second transcriber if there is some glitch in the system). + boolean onlyBotsInRoom = this.mucRoom.getMembers().stream().allMatch(m -> + m.getName().equals(getResourceIdentifier().toString()) // ignore if it is us + || m.getName().equals(gatewaySession.getFocusResourceAddr()) // ignore if it is jicofo + || Util.isTranscriberJigasi((ChatRoomMemberJabberImpl)m) + || Util.isJibri((ChatRoomMemberJabberImpl)m)); + + if (onlyBotsInRoom) + { + logger.info(this.callContext + " Leaving room only bots in the room!"); + stop(); } } } @@ -2012,13 +2025,12 @@ private void updateFromRoomConfiguration() discoverInfo(((ChatRoomJabberImpl)this.mucRoom).getIdentifierAsJid()); DataForm df = (DataForm) info.getExtension(DataForm.NAMESPACE); - boolean lobbyEnabled = df.getField(Lobby.DATA_FORM_LOBBY_ROOM_FIELD) != null; - boolean singleModeratorEnabled = df.getField(Lobby.DATA_FORM_SINGLE_MODERATOR_FIELD) != null; + boolean lobbyEnabled = df.getField(DATA_FORM_LOBBY_ROOM_FIELD) != null; + boolean singleModeratorEnabled = df.getField(DATA_FORM_SINGLE_MODERATOR_FIELD) != null; setLobbyEnabled(lobbyEnabled); this.singleModeratorEnabled = singleModeratorEnabled; - List roomMetadataValues - = df.getField(TranscriptionGatewaySession.DATA_FORM_ROOM_METADATA_FIELD).getValuesAsString(); + List roomMetadataValues = df.getField(DATA_FORM_ROOM_METADATA_FIELD).getValuesAsString(); if (roomMetadataValues != null && !roomMetadataValues.isEmpty()) { // it is supposed to have a single value @@ -2033,7 +2045,7 @@ private void updateFromRoomConfiguration() private void processRoomMetadataJson(String json) { - if (!(this.gatewaySession instanceof TranscriptionGatewaySession)) + if (!this.isTranscriber) { return; } @@ -2267,9 +2279,9 @@ private class MediaActivityChecker public void run() { // if the call was stopped before we check ignore - if (!started) + if (!started || jvbCall == null) { - logger.warn("Media activity checker exiting early as call is not started!"); + logger.warn("Media activity checker exiting early as call is not started or jvbCall is stopped!"); return; } diff --git a/src/main/java/org/jitsi/jigasi/TranscriptionGateway.java b/src/main/java/org/jitsi/jigasi/TranscriptionGateway.java index 66eef3eab..9cc87c934 100644 --- a/src/main/java/org/jitsi/jigasi/TranscriptionGateway.java +++ b/src/main/java/org/jitsi/jigasi/TranscriptionGateway.java @@ -165,15 +165,14 @@ private String getTranscriberFromRemote(String remoteTsConfigUrl) @Override public TranscriptionGatewaySession createOutgoingCall(CallContext ctx) { - String customTranscriptionServiceClass - = getCustomTranscriptionServiceClass(ctx.getTenant()); + String customTranscriptionServiceClass = getCustomTranscriptionServiceClass(ctx.getTenant()); AbstractTranscriptionService service = null; if (customTranscriptionServiceClass != null) { try { service = (AbstractTranscriptionService)Class.forName( - customTranscriptionServiceClass).getDeclaredConstructor().newInstance(); + customTranscriptionServiceClass).getDeclaredConstructor().newInstance(); } catch(Exception e) { diff --git a/src/main/java/org/jitsi/jigasi/util/Util.java b/src/main/java/org/jitsi/jigasi/util/Util.java index 66fb5e4b3..f480569c3 100644 --- a/src/main/java/org/jitsi/jigasi/util/Util.java +++ b/src/main/java/org/jitsi/jigasi/util/Util.java @@ -17,8 +17,10 @@ */ package org.jitsi.jigasi.util; +import net.java.sip.communicator.impl.protocol.jabber.*; import net.java.sip.communicator.service.protocol.*; import net.java.sip.communicator.service.protocol.media.*; +import org.jitsi.jigasi.*; import org.jitsi.service.neomedia.*; import org.jitsi.service.neomedia.format.*; import org.jitsi.utils.*; @@ -27,6 +29,8 @@ import org.jitsi.xmpp.extensions.jitsimeet.*; import org.jivesoftware.smack.bosh.*; import org.jivesoftware.smack.packet.*; +import org.json.simple.*; +import org.json.simple.parser.*; import java.lang.reflect.*; import java.util.*; @@ -42,6 +46,38 @@ */ public class Util { + /** + * The name of XMPP feature which is used to recognize jibri participants. + */ + public static final String JIBRI_FEATURE_NAME = "http://jitsi.org/protocol/jibri"; + + /** + * The name of XMPP feature which states for Jigasi SIP Gateway and can be + * used to recognize gateway client. + */ + public static final String JIGASI_FEATURE_NAME = "http://jitsi.org/protocol/jigasi"; + + /** + * The name of XMPP feature which states this Jigasi is participating as transcriber. + */ + public static final String TRANSCRIBER_FEATURE_NAME = "http://jitsi.org/protocol/transcriber"; + + /** + * The name of the property to get the array of trusted domains. To be used when checking + * presences for jibri/jigasi features. + */ + public static final String P_NAME_TRUSTED_DOMAINS = "org.jitsi.jigasi.TRUSTED_DOMAINS"; + + /** + * List of trusted domains to check when checking the presence for jigasi/jibri features. + */ + private static List trustedDomains = null; + + /** + * The logger. + */ + private final static Logger logger = Logger.getLogger(Util.class); + /** * Returns MediaFormat of the first {@link CallPeer} that belongs * to given {@link Call}(if peer and formats are available). @@ -226,4 +262,81 @@ public static ExtensionElement createFeature(String var) return feature; } + + @SuppressWarnings("unchecked") + private static List getTrustedDomains() + { + if (Util.trustedDomains == null) + { + String trustedDomainsStr + = JigasiBundleActivator.getConfigurationService().getString(P_NAME_TRUSTED_DOMAINS); + + if (trustedDomainsStr != null) + { + JSONParser parser = new JSONParser(); + try + { + logger.info("Initialized trusted domains: " + trustedDomainsStr); + JSONArray trustedArray = (JSONArray) parser.parse(trustedDomainsStr); + + Util.trustedDomains = new ArrayList(trustedArray); + } + catch (ParseException e) + { + logger.error("Cannot parse trusted domains:" + trustedDomainsStr, e); + Util.trustedDomains = new ArrayList<>(); + } + } + else + { + Util.trustedDomains = new ArrayList<>(); + } + } + + return Util.trustedDomains; + } + + private static boolean checkForFeature(ChatRoomMemberJabberImpl member, String feature) + { + Presence presence = member.getLastPresence(); + + FeaturesExtension features = presence.getExtension(FeaturesExtension.class); + + if (features == null || !getTrustedDomains().contains(member.getJabberID().asDomainBareJid().toString())) + { + return false; + } + + return features.getFeatureExtensions().stream().anyMatch(f -> f.getVar().equals(feature)); + } + + /** + * Checks for the transcriber feature in presence. + * @param member the member to check + * @return true when the presence is from a transcriber. + */ + public static boolean isTranscriberJigasi(ChatRoomMemberJabberImpl member) + { + return checkForFeature(member, TRANSCRIBER_FEATURE_NAME); + } + + /** + * Checks for the jigasi feature in presence. + * @param member the member to check + * @return true when the presence is from a jigasi. + */ + public static boolean isJigasi(ChatRoomMemberJabberImpl member) + { + return checkForFeature(member, JIGASI_FEATURE_NAME); + } + + /** + * Checks for the jibri feature in presence. + * @param member the member to check + * @return true when the presence is from a jibri. + */ + public static boolean isJibri(ChatRoomMemberJabberImpl member) + { + return checkForFeature(member, JIBRI_FEATURE_NAME); + } } diff --git a/src/test/java/org/jitsi/jigasi/CallsHandlingTest.java b/src/test/java/org/jitsi/jigasi/CallsHandlingTest.java index 3a49162c8..9b81a755b 100644 --- a/src/test/java/org/jitsi/jigasi/CallsHandlingTest.java +++ b/src/test/java/org/jitsi/jigasi/CallsHandlingTest.java @@ -24,6 +24,7 @@ import net.java.sip.communicator.util.osgi.ServiceUtils; import org.jitsi.jigasi.xmpp.*; import org.jitsi.service.configuration.*; +import org.jitsi.utils.logging.Logger; import org.jitsi.xmpp.extensions.rayo.*; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.*; @@ -45,6 +46,11 @@ */ public class CallsHandlingTest { + /** + * The logger. + */ + private final static Logger logger = Logger.getLogger(CallsHandlingTest.class); + private static OSGiHandler osgi; private MockProtocolProvider sipProvider; @@ -234,6 +240,7 @@ public void testOutgoingSipCall() public void testMultipleTime() throws Exception { + logger.info("Starting testMultipleTime"); testIncomingSipCall(); tearDown(); @@ -255,6 +262,7 @@ public void testMultipleTime() setUp(); testOutgoingSipCall(); + logger.info("Finished testMultipleTime"); } @Test @@ -262,6 +270,8 @@ public void testFocusLeftTheRoomWithNoResume() throws OperationFailedException, OperationNotSupportedException, InterruptedException { + logger.info("Starting testFocusLeftTheRoomWithNoResume"); + long origValue = AbstractGateway.getJvbInviteTimeout(); AbstractGateway.setJvbInviteTimeout(-1); @@ -282,6 +292,8 @@ public void testFocusLeftTheRoomWithNoResume() assertNull(focus.getChatRoom()); AbstractGateway.setJvbInviteTimeout(origValue); + + logger.info("Finished testFocusLeftTheRoomWithNoResume"); } @Test @@ -289,6 +301,8 @@ public void testFocusLeftTheRoomWithResume() throws OperationFailedException, OperationNotSupportedException, InterruptedException { + logger.info("Starting testFocusLeftTheRoomWithResume"); + long origValue = AbstractGateway.getJvbInviteTimeout(); AbstractGateway.setJvbInviteTimeout(AbstractGateway.DEFAULT_JVB_INVITE_TIMEOUT); @@ -312,12 +326,16 @@ public void testFocusLeftTheRoomWithResume() // clear CallManager.hangupCall(sipCall); + + logger.info("Finished testFocusLeftTheRoomWithResume"); } @Test public void testCallControl() throws Exception { + logger.info("Starting testCallControl"); + String serverName = "conference.net"; CallControl callControl = new CallControl(JigasiBundleActivator.getConfigurationService()); @@ -392,6 +410,8 @@ public void testCallControl() callStateWatch.waitForState(xmppCall, CallState.CALL_ENDED, 1000); callStateWatch.waitForState(sipCall, CallState.CALL_ENDED, 1000); assertFalse(conferenceChatRoom.isJoined()); + + logger.info("Finished testCallControl"); } /** @@ -401,6 +421,8 @@ public void testCallControl() public void testDefaultJVbRoomProperty() throws Exception { + logger.info("Starting testDefaultJvbRoomProperty"); + // Once the chat room is joined the focus sends // session-initiate request to new participant. focus.setup(); @@ -438,12 +460,16 @@ public void testDefaultJVbRoomProperty() callStateWatch.waitForState(xmppCall, CallState.CALL_ENDED, 1000); callStateWatch.waitForState(sipCall, CallState.CALL_ENDED, 1000); assertFalse(jvbRoom.isJoined()); + + logger.info("Finished testDefaultJvbRoomProperty"); } @Test public void testSimultaneousCalls() throws Exception { + logger.info("Starting testSimultaneousCalls"); + // Once the chat room is joined the focus sends // session-initiate request to new participant. focus.setup(); @@ -503,12 +529,16 @@ public void testSimultaneousCalls() assertFalse(jvbRoom1.isJoined()); assertFalse(jvbRoom2.isJoined()); assertFalse(jvbRoom3.isJoined()); + + logger.info("Finished testSimultaneousCalls"); } @Test public void testNoFocusInTheRoom() throws Exception { + logger.info("Starting testNoFocusInTheRoom"); + // Set wait for JVB invite timeout long jvbInviteTimeout = 200; AbstractGateway.setJvbInviteTimeout(jvbInviteTimeout); @@ -557,6 +587,7 @@ public void testNoFocusInTheRoom() AbstractGateway.setJvbInviteTimeout( AbstractGateway.DEFAULT_JVB_INVITE_TIMEOUT); - } + logger.info("Finished testNoFocusInTheRoom"); + } } diff --git a/src/test/java/org/jitsi/jigasi/MockJvbConferenceFocus.java b/src/test/java/org/jitsi/jigasi/MockJvbConferenceFocus.java index 3123c0bbb..42b4b7976 100644 --- a/src/test/java/org/jitsi/jigasi/MockJvbConferenceFocus.java +++ b/src/test/java/org/jitsi/jigasi/MockJvbConferenceFocus.java @@ -143,11 +143,25 @@ private void inviteToConference(ChatRoomMember member) if (leaveRoomAfterInvite) { logger.info(myName + " invited peer will leave the room"); + + // let's leave after the xmpp call is in progress, as ended and connected will race for the call new Thread(new Runnable() { @Override public void run() { + try + { + logger.info("waiting for in progress on " + xmppCall); + CallStateListener callStateWatch = new CallStateListener(); + callStateWatch.waitForState(xmppCall, CallState.CALL_IN_PROGRESS, 2000); + logger.info("done waiting for in progress on " + xmppCall); + } + catch (InterruptedException e) + { + throw new RuntimeException(e); + } + logger.info(myName + " leaving the room"); tearDown(); }