/** * * Copyright 2017 Paul Schaub * * 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.jivesoftware.smackx.jingle.component; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Level; import java.util.logging.Logger; import org.jivesoftware.smack.SmackException; import org.jivesoftware.smack.XMPPConnection; import org.jivesoftware.smack.XMPPException; import org.jivesoftware.smack.packet.IQ; import org.jivesoftware.smack.packet.XMPPError; import org.jivesoftware.smack.util.Async; import org.jivesoftware.smackx.jingle.JingleDescriptionManager; import org.jivesoftware.smackx.jingle.JingleManager; import org.jivesoftware.smackx.jingle.element.JingleAction; import org.jivesoftware.smackx.jingle.element.JingleContentElement; import org.jivesoftware.smackx.jingle.element.JingleElement; import org.jivesoftware.smackx.jingle.element.JingleReasonElement; import org.jivesoftware.smackx.jingle.exception.UnsupportedDescriptionException; import org.jivesoftware.smackx.jingle.exception.UnsupportedSecurityException; import org.jivesoftware.smackx.jingle.exception.UnsupportedTransportException; import org.jivesoftware.smackx.jingle.util.Role; import org.jxmpp.jid.FullJid; /** * Class that represents a Jingle session. */ public class JingleSession { private static final Logger LOGGER = Logger.getLogger(JingleSession.class.getName()); /** * Map of contents in this session. */ private final ConcurrentHashMap contents = new ConcurrentHashMap<>(); /** * Map of proposed (added, but not yet accepted contents) in this session. */ private final ConcurrentHashMap proposedContents = new ConcurrentHashMap<>(); /** * Reference to jingleManager. */ private final JingleManager jingleManager; /** * Initiator and responder of the session. */ private final FullJid initiator, responder; /** * Our role in the session (initiator or responder). */ private final Role role; private final String sessionId; public enum SessionState { fresh, //pre-session-inititate pending, //pre-session-accept active, //pre-session-terminate ended //post-session-terminate } /** * Current state of the session. */ private SessionState sessionState; /** * Create a new JingleSession. * @param manager jingleManager. * @param initiator initiator of the session. * @param responder responder of the session. * @param role our role in the session. * @param sessionId session id. */ public JingleSession(JingleManager manager, FullJid initiator, FullJid responder, Role role, String sessionId) { this.jingleManager = manager; this.initiator = initiator; this.responder = responder; this.role = role; this.sessionId = sessionId; this.sessionState = SessionState.fresh; } /** * Parse a {@link JingleSession} from a {@link JingleElement} with action session-initiate. * @param manager jingleManager. * @param initiate {@link JingleElement} with session-initiate action. * @return jingleSession. * TODO: Throw exceptions. * @throws UnsupportedSecurityException * @throws UnsupportedDescriptionException * @throws UnsupportedTransportException */ public static JingleSession fromSessionInitiate(JingleManager manager, JingleElement initiate) throws UnsupportedSecurityException, UnsupportedDescriptionException, UnsupportedTransportException { if (initiate.getAction() != JingleAction.session_initiate) { throw new IllegalArgumentException("Jingle-Action MUST be 'session-initiate'."); } JingleSession session = new JingleSession(manager, initiate.getInitiator(), manager.getConnection().getUser().asFullJidOrThrow(), Role.responder, initiate.getSid()); List initiateContents = initiate.getContents(); for (JingleContentElement content : initiateContents) { session.addContent(content, manager); } session.sessionState = SessionState.pending; return session; } /** * Send a session-initiate request to the responder. * This sets the state from fresh to pending. * @param connection connection. * @throws SmackException.NotConnectedException * @throws InterruptedException * @throws XMPPException.XMPPErrorException * @throws SmackException.NoResponseException */ public void sendInitiate(XMPPConnection connection) throws SmackException.NotConnectedException, InterruptedException, XMPPException.XMPPErrorException, SmackException.NoResponseException { if (this.sessionState != SessionState.fresh) { throw new IllegalStateException("Session is not in fresh state."); } if (!isInitiator()) { throw new IllegalStateException("We are not the initiator."); } connection.createStanzaCollectorAndSend(createSessionInitiate()).nextResultOrThrow(); this.sessionState = SessionState.pending; } /** * Send a session-accept to the initiator. * This sets the state from pending to active. * @param connection connection. * @throws SmackException.NotConnectedException * @throws InterruptedException * @throws XMPPException.XMPPErrorException * @throws SmackException.NoResponseException */ public void sendAccept(XMPPConnection connection) throws SmackException.NotConnectedException, InterruptedException, XMPPException.XMPPErrorException, SmackException.NoResponseException { LOGGER.log(Level.FINE, "Accepted session."); if (this.sessionState != SessionState.pending) { throw new IllegalStateException("Session is not in pending state."); } if (!isResponder()) { throw new IllegalStateException("We are not the responder."); } if (contents.values().size() == 0) { LOGGER.log(Level.WARNING, "0 contents!"); } for (JingleContent content : contents.values()) { content.start(connection); } connection.createStanzaCollectorAndSend(createSessionAccept()).nextResultOrThrow(); this.sessionState = SessionState.active; } /** * Create a session-initiate request. * @return request. */ public JingleElement createSessionInitiate() { if (role != Role.initiator) { throw new IllegalStateException("Sessions role is not initiator."); } List contentElements = new ArrayList<>(); for (JingleContent c : contents.values()) { contentElements.add(c.getElement()); } return JingleElement.createSessionInitiate(getInitiator(), getResponder(), getSessionId(), contentElements); } /** * Create a session-accept request. * @return request. */ public JingleElement createSessionAccept() { if (role != Role.responder) { throw new IllegalStateException("Sessions role is not responder."); } List contentElements = new ArrayList<>(); for (JingleContent c : contents.values()) { contentElements.add(c.getElement()); } return JingleElement.createSessionAccept(getInitiator(), getResponder(), getSessionId(), contentElements); } /** * Handle local content finished event. This includes terminating the session. * @param jingleContent content which finished. */ void onContentFinished(JingleContent jingleContent) { if (contents.get(jingleContent.getName()) == null) { LOGGER.log(Level.WARNING, "Session does not contain content " + jingleContent.getName() + ". Ignore contentFinished."); return; } if (contents.size() == 1) { //Only content has finished. End session. terminateSession(JingleReasonElement.Reason.success); return; } } /** * Handle local content cancel event. This happens when the local user cancels a content. * If there is only one content in the session, terminate the session, otherwise just cancel the one content. * @param jingleContent content that gets cancelled. */ void onContentCancel(JingleContent jingleContent) { if (contents.get(jingleContent.getName()) == null) { LOGGER.log(Level.WARNING, "Session does not contain content " + jingleContent.getName() + ". Ignore onContentCancel."); return; } if (contents.size() == 1) { terminateSession(JingleReasonElement.Reason.cancel); jingleManager.removeSession(this); } else { JingleElement cancel = JingleElement.createSessionTerminateContentCancel( getPeer(), getSessionId(), jingleContent.getCreator(), jingleContent.getName()); try { jingleManager.getConnection().createStanzaCollectorAndSend(cancel).nextResultOrThrow(); } catch (SmackException.NotConnectedException | InterruptedException | XMPPException.XMPPErrorException | SmackException.NoResponseException e) { LOGGER.log(Level.SEVERE, "Could not send content-cancel.", e); } contents.remove(jingleContent.getName()); } } /** * Send a session terminate and remove the session from the list of active sessions. * @param reason reason of termination. */ public void terminateSession(JingleReasonElement.Reason reason) { JingleElement terminate = JingleElement.createSessionTerminate(getPeer(), getSessionId(), reason); try { jingleManager.getConnection().sendStanza(terminate); } catch (SmackException.NotConnectedException | InterruptedException e) { LOGGER.log(Level.SEVERE, "Could not send session-terminate.", e); } this.sessionState = SessionState.ended; jingleManager.removeSession(this); } /** * Handle incoming jingle request. * This is a routing function which routes the request to the suitable method based on the value of the action field. * @param request incoming request. * @return result. */ public IQ handleJingleRequest(JingleElement request) { switch (request.getAction()) { case content_modify: case description_info: case security_info: case session_info: case transport_accept: case transport_info: case transport_reject: case transport_replace: return getSoleAffectedContentOrThrow(request).handleJingleRequest(request, jingleManager.getConnection()); case content_accept: return handleContentAccept(request); case content_add: return handleContentAdd(request); case content_reject: return handleContentReject(request); case content_remove: return handleContentRemove(request); case session_accept: return handleSessionAccept(request); case session_initiate: return handleSessionInitiate(request); case session_terminate: return handleSessionTerminate(request); default: throw new AssertionError("Illegal jingle action: " + request.getAction()); } } /* ############## Processed in this class ############## */ /** * Handle incoming session-accept request. * This passes the session-accept to all contents. * @param request session-accept request. * @return result. */ private IQ handleSessionAccept(final JingleElement request) { this.sessionState = SessionState.active; for (final JingleContent content : contents.values()) { Async.go(new Runnable() { @Override public void run() { content.handleSessionAccept(request, jingleManager.getConnection()); } }); } return IQ.createResultIQ(request); } /** * Handle incoming session-initiate request. * Notifies content listeners of respective descriptions about incoming requests. * @param request request. * @return result. */ private IQ handleSessionInitiate(JingleElement request) { final JingleDescription description = getSoleContentOrThrow().getDescription(); final JingleDescriptionManager descriptionManager = jingleManager.getDescriptionManager(description.getNamespace()); sessionState = SessionState.pending; Async.go(new Runnable() { @Override public void run() { if (descriptionManager == null) { LOGGER.log(Level.WARNING, "Unsupported description type: " + description.getNamespace()); JingleElement terminate = JingleElement.createSessionTerminate(getPeer(), getSessionId(), JingleReasonElement.Reason.unsupported_applications); try { jingleManager.getConnection().createStanzaCollectorAndSend(terminate).nextResultOrThrow(); } catch (SmackException.NotConnectedException | InterruptedException | XMPPException.XMPPErrorException | SmackException.NoResponseException e) { LOGGER.log(Level.SEVERE, "Could not send session-terminate.", e); } } else { descriptionManager.notifySessionInitiate(JingleSession.this); } } }); return IQ.createResultIQ(request); } /** * Handle incoming session-terminate request. * This includes passing down the request to child contents, setting the sessionState to ended and removing the session * from the {@link JingleManager}. * @param request request. * @return result. */ private IQ handleSessionTerminate(JingleElement request) { this.sessionState = SessionState.ended; JingleReasonElement reason = request.getReason(); if (reason == null) { throw new AssertionError("Reason MUST not be null! (I guess)..."); } JingleReasonElement.Reason r = reason.asEnum(); for (JingleContent content : contents.values()) { content.handleContentTerminate(r); } sessionState = SessionState.ended; jingleManager.removeSession(this); return IQ.createResultIQ(request); } /** * Handle incoming content-accept request. * This includes moving affected contents from proposedContents to contents and notifying them. * @param request request. * @return result. */ private IQ handleContentAccept(final JingleElement request) { for (JingleContentElement a : request.getContents()) { final JingleContent accepted = proposedContents.get(a.getName()); if (accepted == null) { throw new AssertionError("Illegal content name!"); //TODO: Throw other exception? } proposedContents.remove(accepted.getName()); contents.put(accepted.getName(), accepted); Async.go(new Runnable() { @Override public void run() { accepted.handleContentAccept(request, jingleManager.getConnection()); } }); } return IQ.createResultIQ(request); } /** * Handle a content-add request. * This includes notifying respective {@link JingleDescriptionManager} about the request. * @param request request. * @return result. */ private IQ handleContentAdd(JingleElement request) { final JingleContent proposed = getSoleProposedContentOrThrow(request); final JingleDescriptionManager descriptionManager = jingleManager.getDescriptionManager(proposed.getDescription() .getNamespace()); if (descriptionManager == null) { throw new AssertionError("DescriptionManager is null: " + proposed.getDescription().getNamespace()); } Async.go(new Runnable() { @Override public void run() { descriptionManager.notifyContentAdd(JingleSession.this, proposed); } }); return IQ.createResultIQ(request); } /** * Handle incoming content-reject requests. * That includes removing the affected contents from the proposedContents map. * @param request request. * @return result. */ private IQ handleContentReject(JingleElement request) { for (JingleContentElement r : request.getContents()) { final JingleContent rejected = proposedContents.get(r.getName()); if (rejected == null) { throw new AssertionError("Illegal content name!"); } proposedContents.remove(rejected.getName()); /* Async.go(new Runnable() { @Override public void run() { rejected.handleContentReject(request, jingleManager.getConnection()); } }); */ } return IQ.createResultIQ(request); } /** * Handle incoming content-remove requests. * TODO: Implement. * @param request request. * @return result. */ private IQ handleContentRemove(final JingleElement request) { return IQ.createErrorResponse(request, XMPPError.Condition.feature_not_implemented); } /** * Return the {@link FullJid} of the initiator. * @return initiators {@link FullJid} */ public FullJid getInitiator() { return initiator; } /** * Return the {@link FullJid} of the responder. * @return responders {@link FullJid} */ public FullJid getResponder() { return responder; } /** * Return the {@link FullJid} of the peer (the other party of the session). * @return peers {@link FullJid} */ public FullJid getPeer() { return role == Role.initiator ? responder : initiator; } /** * Return our {@link FullJid}. * @return our {@link FullJid}. */ public FullJid getOurJid() { return role == Role.initiator ? initiator : responder; } /** * Return true, if we are the initiator. * @return initiator? */ public boolean isInitiator() { return role == Role.initiator; } /** * Return true, if we are the responder. * @return responder? */ public boolean isResponder() { return role == Role.responder; } /** * Return the SID of this session. * @return sessionId. */ public String getSessionId() { return sessionId; } /** * Return the {@link JingleManager} of this session. * @return jingleManager. */ public JingleManager getJingleManager() { return jingleManager; } private HashMap getAffectedContents(JingleElement request) { HashMap map = new HashMap<>(); for (JingleContentElement e : request.getContents()) { JingleContent c = contents.get(e.getName()); if (c == null) { throw new AssertionError("Unknown content: " + e.getName()); } map.put(e, c); } return map; } /** * If the request contains only one {@link JingleContentElement} and this session contains the * related {@link JingleContent}, return that {@link JingleContent}. * If the request contains more than one {@link JingleContentElement}, throw an AssertionError. * If the session does not contain the {@link JingleContent} related to the {@link JingleContentElement} from the * request, throw an AssertionError. * @param request request. * @return the only affected content, or throw. */ private JingleContent getSoleAffectedContentOrThrow(JingleElement request) { if (request.getContents().size() != 1) { throw new AssertionError("More/less than 1 content in request!"); } JingleContent content = contents.get(request.getContents().get(0).getName()); if (content == null) { throw new AssertionError("Illegal content name!"); } return content; } /** * If the request cotains only one {@link JingleContentElement}, parse it in a {@link JingleContent} and return it. * Otherwise throw an AssertionError. * @param request request. * @return sole proposed content or throw. */ private static JingleContent getSoleProposedContentOrThrow(JingleElement request) { if (request.getContents().size() != 1) { throw new AssertionError("More/less than 1 content in request!"); } return JingleContent.fromElement(request.getContents().get(0)); } /** * Add a {@link JingleContent} to the session. * @throws IllegalArgumentException if the session already contains the content. * @param content content. */ public void addContent(JingleContent content) { if (contents.get(content.getName()) != null) { throw new IllegalArgumentException("Session already contains a content with the name " + content.getName()); } contents.put(content.getName(), content); content.setParent(this); } /** * Add a {@link JingleContent}, which gets parsed from the given {@link JingleContentElement} to the session. * @param content contentElement. * @param manager JingleManager. * @throws UnsupportedSecurityException * @throws UnsupportedTransportException * @throws UnsupportedDescriptionException */ public void addContent(JingleContentElement content, JingleManager manager) throws UnsupportedSecurityException, UnsupportedTransportException, UnsupportedDescriptionException { addContent(JingleContent.fromElement(content)); } /** * Return the map of {@link JingleContent}s of this session. * @return contents. */ public ConcurrentHashMap getContents() { return contents; } /** * Return the {@link JingleContent} with the given name, or null if the session does not contain that content. * @param name name. * @return content or null. */ public JingleContent getContent(String name) { return contents.get(name); } /** * Get the only jingle content if one exists, or null. This method will throw an * {@link IllegalStateException} if there is more than one jingle content. * * @return a JingleContent instance or null. * @throws IllegalStateException if there is more than one jingle content. */ public JingleContent getSoleContentOrThrow() { if (contents.isEmpty()) { return null; } if (contents.size() > 1) { throw new IllegalStateException(); } return contents.values().iterator().next(); } /** * Return the state of the session. * @return state. */ public SessionState getSessionState() { return sessionState; } }