cz.cesnet.shongo.connector.device.AdobeConnectConnector.java Source code

Java tutorial

Introduction

Here is the source code for cz.cesnet.shongo.connector.device.AdobeConnectConnector.java

Source

package cz.cesnet.shongo.connector.device;

import cz.cesnet.shongo.*;
import cz.cesnet.shongo.api.*;
import cz.cesnet.shongo.api.jade.CommandException;
import cz.cesnet.shongo.api.jade.CommandUnsupportedException;
import cz.cesnet.shongo.api.util.DeviceAddress;
import cz.cesnet.shongo.connector.common.AbstractMultipointConnector;
import cz.cesnet.shongo.connector.api.*;
import cz.cesnet.shongo.controller.api.jade.NotifyTarget;
import cz.cesnet.shongo.controller.api.jade.Service;
import org.jdom2.Attribute;
import org.jdom2.Document;
import org.jdom2.Element;
import org.jdom2.JDOMException;
import org.jdom2.input.JDOMParseException;
import org.jdom2.input.SAXBuilder;
import org.jdom2.output.Format;
import org.jdom2.output.XMLOutputter;
import org.joda.time.DateTime;
import org.joda.time.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.InputStream;
import java.io.StringReader;
import java.io.UnsupportedEncodingException;
import java.net.SocketException;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLEncoder;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * {@link cz.cesnet.shongo.connector.common.AbstractConnector} for Adobe Connect.
 *
 * @author opicak <pavelka@cesnet.cz>
 */
public class AdobeConnectConnector extends AbstractMultipointConnector implements RecordingService {
    private static Logger logger = LoggerFactory.getLogger(AdobeConnectConnector.class);

    /**
     * Options for the {@link AdobeConnectConnector}.
     */
    public static final String CAPACITY_CHECK_PERIOD = "capacity-check-period";
    public static final Duration CAPACITY_CHECK_PERIOD_DEFAULT = Duration.standardMinutes(5);
    public static final String MEETINGS_FOLDER_NAME = "meetings-folder-name";
    public static final String RECORDINGS_FOLDER_NAME = "recordings-folder-name";
    public static final String RECORDINGS_PREFIX = "recordings-prefix";
    public static final String RECORDINGS_CHECK_PERIOD = "recordings-check-period";
    public static final Duration RECORDINGS_CHECK_PERIOD_DEFAULT = Duration.standardMinutes(5);
    public static final String URL_PATH_EXTRACTION_FROM_URI = "url-path-extraction-from-uri";

    /**
     * Small timeout used between some AC request
     */
    public final static long REQUEST_DELAY = 100;

    /**
     * The Java session ID that is generated upon successful login.  All calls
     * except login must provide this ID for authentication.
     */
    private String connectionSession;

    /**
     * @see ConnectionState
     */
    private ConnectionState connectionState;

    /**
     * Patterns for options.
     */
    private Pattern urlPathExtractionFromUri = null;

    /**
     * Name of folder for meetings
     */
    protected String meetingsFolderName;

    /**
     * Root folder ID for meetings
     */
    protected String meetingsFolderId;

    /**
     * This is the user log in name, typically the user email address.
     */
    private String login;

    /**
     * The password of the user.
     */
    private String password;

    /**
     * Timeout for checking room capacity.
     */
    private int capacityCheckTimeout;

    /**
     * Thread for capacity checking.
     */
    private Thread capacityCheckThread;

    /**
     * If capacity check is running.
     */
    private volatile boolean capacityChecking = false;

    /**
     * @see AdobeConnectRecordingManager
     */
    private AdobeConnectRecordingManager recordingManager;

    @Override
    public void connect(DeviceAddress deviceAddress, String username, String password) throws CommandException {
        this.login = username;
        this.password = password;

        // Setup options
        this.capacityCheckTimeout = (int) configuration
                .getOptionDuration(CAPACITY_CHECK_PERIOD, CAPACITY_CHECK_PERIOD_DEFAULT).getMillis();
        this.urlPathExtractionFromUri = configuration.getOptionPattern(URL_PATH_EXTRACTION_FROM_URI);
        this.meetingsFolderName = configuration.getOptionString(MEETINGS_FOLDER_NAME);

        this.login();

        this.recordingManager = new AdobeConnectRecordingManager(this);
    }

    @Override
    public ConnectionState getConnectionState() {
        try {
            execApi(getActionUrl("common-info"), CONNECTION_STATE_TIMEOUT);
            return ConnectionState.CONNECTED;
        } catch (Exception exception) {
            logger.warn("Not connected", exception);
            return ConnectionState.DISCONNECTED;
        }
    }

    @Override
    public void disconnect() throws CommandException {
        this.capacityCheckThread = null;
        this.recordingManager.destroy();
        this.connectionState = ConnectionState.DISCONNECTED;
        this.logout();
    }

    /**
     * Creates Adobe Connect user.
     *
     * @return user identification (principal-id)
     */
    protected String createAdobeConnectUser(String principalName, UserInformation userInformation)
            throws CommandException {
        if (principalName == null) {
            throw new IllegalArgumentException("Principal mustn't be null.");
        }

        RequestAttributeList userSearchAttributes = new RequestAttributeList();
        userSearchAttributes.add("filter-login", principalName);
        Element principalList = execApi("principal-list", userSearchAttributes);

        if (principalList.getChild("principal-list").getChild("principal") != null)
            return principalList.getChild("principal-list").getChild("principal").getAttributeValue("principal-id");

        RequestAttributeList newUserAttributes = new RequestAttributeList();
        newUserAttributes.add("first-name", userInformation.getFirstName());
        newUserAttributes.add("last-name", userInformation.getLastName());
        newUserAttributes.add("login", principalName);
        newUserAttributes.add("email", userInformation.getPrimaryEmail());
        newUserAttributes.add("type", "user");
        newUserAttributes.add("has-children", "false");

        Element response = execApi("principal-update", newUserAttributes);
        return response.getChild("principal").getAttributeValue("principal-id");
    }

    /**
     * Set session state
     *
     * @param roomId identifier of the room
     * @param state  state of session; true for end, false for start session
     */
    protected void endMeetingUpdate(String roomId, Boolean state, String message, Boolean redirect, String url)
            throws CommandException {
        RequestAttributeList sessionsAttributes = new RequestAttributeList();
        sessionsAttributes.add("sco-id", roomId);
        Element sessionsResponse = execApi("report-meeting-sessions", sessionsAttributes);

        if (sessionsResponse.getChild("report-meeting-sessions").getChildren().size() == 0) {
            return;
        }

        RequestAttributeList endMeetingAttributes = new RequestAttributeList();
        endMeetingAttributes.add("sco-id", roomId);
        endMeetingAttributes.add("state", state.toString());

        // Not working on connect.cesnet.cz
        /*if (message != null) {
        // Replace all sequences of " " and "." by single space
        message = message.replaceAll("[ \\.]+", " ");
        try {
            endMeetingAttributes.add("message",URLEncoder.encode(message,"UTF8"));
        }
        catch (UnsupportedEncodingException e) {
            throw new CommandException("Error while message encoding.", e);
        }
        }*/

        if (redirect == true && url != null) {
            endMeetingAttributes.add("redirect", redirect.toString());
            endMeetingAttributes.add("url", url);
        }

        try {
            logger.debug("{} meeting (sco-ID: {}) session.", (state ? "Starting" : "Ending"), roomId);
            execApi("meeting-roommanager-endmeeting-update", endMeetingAttributes);
        } catch (CommandException exception) {
            logger.warn(
                    "Failed to end/start meeting. Probably just AC error, everything should be working properly.",
                    exception);
        }
    }

    /**
     * End current session.
     *
     * @param roomId identifier of the room
     * @throws CommandException
     */
    protected void endMeeting(String roomId) throws CommandException {
        String message = "The room is currently unavailable for joining / Do mistnosti se aktualne neni mozne pripojit";

        endMeeting(roomId, message, false, null);
    }

    /**
     * End current session, set message show after stopping meeting, set url to be redirect (for recreating rooms)
     *
     * @param roomId identifier of the room
     * @param message message shown after ending meeting session
     * @param redirect boolean value for redirecting after ending meeting session
     * @param url url to redirect if redirect == true
     * @throws CommandException
     */
    protected void endMeeting(String roomId, String message, Boolean redirect, String url) throws CommandException {
        endMeetingUpdate(roomId, true, message, redirect, url);
    }

    /**
     * Start new session. Host can do it from AC.
     *
     * @param roomId identifier of the room
     * @throws CommandException
     */
    protected void startMeeting(String roomId) throws CommandException {
        endMeetingUpdate(roomId, false, null, false, null);
    }

    /**
     * Returns room access mode (public, protected, private). Mode in AC v8.*, v9.0, v9.1 are "view-hidden" (public), "remove" (protected), "denied" (private)
     *
     * @param roomId
     * @return
     * @throws CommandException
     */
    protected AdobeConnectAccessMode getRoomAccessMode(String roomId) throws CommandException {
        RequestAttributeList attributes = new RequestAttributeList();
        attributes.add("acl-id", roomId);
        attributes.add("filter-principal-id", "public-access");

        String accessMode = execApi("permissions-info", attributes).getChild("permissions").getChild("principal")
                .getAttributeValue("permissions-id");

        AdobeConnectAccessMode adobeConnectAccessMode = AdobeConnectAccessMode.PROTECTED;

        if (AdobeConnectAccessMode.PRIVATE.getPermissionId().equals(accessMode)) {
            adobeConnectAccessMode = AdobeConnectAccessMode.PRIVATE;
        } else if (AdobeConnectAccessMode.PROTECTED.getPermissionId().equals(accessMode)) {
            adobeConnectAccessMode = AdobeConnectAccessMode.PROTECTED;
        } else if (AdobeConnectAccessMode.PUBLIC.getPermissionId().equals(accessMode)) {
            adobeConnectAccessMode = AdobeConnectAccessMode.PUBLIC;
        }

        return adobeConnectAccessMode;
    }

    /**
     * Set room access mode (public, protected, private). Mode in AC v8.*, v9.0, v9.1 are "view-hidden" (public), "remove" (protected), "denied" (private)
     * Default access mode (when param mode is null) is AdobeConnectAccessMode.PROTECTED
     *
     * @param roomId
     * @param mode
     */
    protected void setRoomAccessMode(String roomId, AdobeConnectAccessMode mode) throws CommandException {
        RequestAttributeList accessModeAttributes = new RequestAttributeList();
        accessModeAttributes.add("acl-id", roomId);
        accessModeAttributes.add("principal-id", "public-access");
        if (mode == null) {
            accessModeAttributes.add("permission-id", AdobeConnectAccessMode.PROTECTED.getPermissionId());
        } else {
            accessModeAttributes.add("permission-id", mode.getPermissionId());
        }

        execApi("permissions-update", accessModeAttributes);
    }

    /**
     * Make room public (Anyone who has the URL for the meeting can enter the ro
     om).
     * @param roomId
     */
    public void makeRoomPublic(String roomId) throws CommandException {
        setRoomAccessMode(roomId, AdobeConnectAccessMode.PUBLIC);
    }

    /**
     * Make room protected (Only registered users and accepted guests can enter the room).
     * @param roomId
     */
    public void makeRoomProtected(String roomId) throws CommandException {
        setRoomAccessMode(roomId, AdobeConnectAccessMode.PROTECTED);
    }

    /**
     * Make room private (Only registered users and participants can enter).
     * @param roomId
     */
    public void makeRoomPrivate(String roomId) throws CommandException {
        setRoomAccessMode(roomId, AdobeConnectAccessMode.PRIVATE);
    }

    @Override
    public DeviceLoadInfo getDeviceLoadInfo() throws CommandException, CommandUnsupportedException {
        return null; // TODO
    }

    @Override
    public UsageStats getUsageStats() throws CommandException, CommandUnsupportedException {
        //report-bulk-consolidated-transactions
        //report-meeting....
        return null; //TODO
    }

    @Override
    public String createRecordingFolder(RecordingFolder recordingFolder) throws CommandException {
        return recordingManager.createRecordingFolder(recordingFolder);
    }

    @Override
    public void modifyRecordingFolder(RecordingFolder recordingFolder) throws CommandException {
        recordingManager.modifyRecordingFolder(recordingFolder);
    }

    @Override
    public void deleteRecordingFolder(String recordingFolderId) throws CommandException {
        recordingManager.deleteRecordingFolder(recordingFolderId);
    }

    @Override
    public Collection<Recording> listRecordings(String recordingFolderId) throws CommandException {
        return recordingManager.listRecordings(recordingFolderId);
    }

    @Override
    public Recording getRecording(String recordingId) throws CommandException {
        return recordingManager.getRecording(recordingId);
    }

    @Override
    public Recording getActiveRecording(Alias alias) throws CommandException {
        return recordingManager.getActiveRecording(alias);
    }

    @Override
    public boolean isRecordingActive(String recordingId) throws CommandException {
        return recordingManager.isRecordingActive(recordingId);
    }

    @Override
    public String startRecording(String recordingFolderId, Alias alias, RecordingSettings recordingSettings)
            throws CommandException {
        return recordingManager.startRecording(recordingFolderId, alias, recordingSettings);
    }

    @Override
    public void stopRecording(String recordingId) throws CommandException {
        recordingManager.stopRecording(recordingId);
    }

    @Override
    public void deleteRecording(String recordingId) throws CommandException {
        recordingManager.deleteRecording(recordingId);
    }

    @Override
    public void checkRecording(String recordingId) throws CommandException {
        recordingManager.checkRecording(recordingId);
    }

    @Override
    public void checkRecordings() throws CommandException {
        recordingManager.checkRecordings();
    }

    @Override
    public MediaData getRoomContent(String roomId) throws CommandException, CommandUnsupportedException {
        RequestAttributeList attributes = new RequestAttributeList();
        attributes.add("sco-id", roomId);

        Element response = execApi("sco-contents", attributes);

        for (Element child : response.getChild("scos").getChildren("sco")) {
            //TODO: archive all
        }
        return null; //To change body of implemented methods use File | Settings | File Templates.
    }

    @Override
    public void addRoomContent(String roomId, String name, MediaData data)
            throws CommandException, CommandUnsupportedException {
        throw new CommandUnsupportedException("Adobe Connect does not support this function.");
        //To change body of implemented methods use File | Settings | File Templates.
    }

    @Override
    public void removeRoomContentFile(String roomId, String name) throws CommandException {
        RequestAttributeList attributes = new RequestAttributeList();
        attributes.add("sco-id", roomId);
        attributes.add("filter-name", name);

        Element response = execApi("sco-contents", attributes);

        if (response.getChild("scos").getChild("sco") != null) {
            deleteSCO(response.getChild("scos").getChild("sco").getAttributeValue("sco-id"));
        }
    }

    @Override
    public void clearRoomContent(String roomId) throws CommandException, CommandUnsupportedException {
        // TODO: erase content and re-create room?
    }

    @Override
    public Collection<RoomSummary> listRooms() throws CommandException {
        RequestAttributeList attributes = new RequestAttributeList();
        attributes.add("filter-type", "meeting");

        Element response = execApi("report-bulk-objects", attributes);

        List<RoomSummary> meetings = new ArrayList<RoomSummary>();

        for (Element room : response.getChild("report-bulk-objects").getChildren("row")) {
            if (room.getChildText("name").matches("(?i).*Template")) {
                continue;
            }

            RoomSummary roomSummary = new RoomSummary();
            roomSummary.setId(room.getAttributeValue("sco-id"));
            roomSummary.setName(room.getChildText("name"));
            roomSummary.setDescription(room.getChildText("description"));
            roomSummary.setAlias(room.getChildText("url"));

            String dateCreated = room.getChildText("date-created");
            if (dateCreated != null) {
                roomSummary.setStartDateTime(DateTime.parse(dateCreated));
            }

            meetings.add(roomSummary);
        }

        return Collections.unmodifiableList(meetings);
    }

    @Override
    public Room getRoom(String roomId) throws CommandException {
        Room room = new Room();
        try {
            Element sco = getScoInfo(roomId);

            room.setId(roomId);
            room.addAlias(AliasType.ROOM_NAME, sco.getChildText("name"));
            room.setDescription(sco.getChildText("description"));
            if (sco.getChildText("sco-tag") != null) {
                room.setLicenseCount(Integer.valueOf(sco.getChildText("sco-tag")));
            } else {
                logger.warn("Licence count not set for room " + roomId + " (using 0 licenses).");
                room.setLicenseCount(0);
            }
            room.addTechnology(Technology.ADOBE_CONNECT);

            String uri = "https://" + deviceAddress + sco.getChildText("url-path");
            room.addAlias(new Alias(AliasType.ADOBE_CONNECT_URI, uri));

            // options
            AdobeConnectRoomSetting adobeConnectRoomSetting = new AdobeConnectRoomSetting();
            String pin = sco.getChildText("meeting-passcode");
            if (pin != null) {
                adobeConnectRoomSetting.setPin(pin);
            }

            adobeConnectRoomSetting.setAccessMode(getRoomAccessMode(roomId));
            room.addRoomSetting(adobeConnectRoomSetting);

        } catch (RequestFailedCommandException exception) {
            if ("no-data".equals(exception.getCode())) {
                return null;
            }
        }
        return room;
    }

    private void setRoomAttributes(RequestAttributeList attributes, Room room) throws CommandException {
        try {
            // Set the description
            if (room.getDescription() != null) {
                attributes.add("description", URLEncoder.encode(room.getDescription(), "UTF8"));
            }

            // Set capacity
            attributes.add("sco-tag", String.valueOf(room.getLicenseCount()));

            // Create/Update aliases
            if (room.getAliases() != null) {
                for (Alias alias : room.getAliases()) {
                    switch (alias.getType()) {
                    case ROOM_NAME:
                        attributes.add("name", URLEncoder.encode(alias.getValue(), "UTF8"));
                        break;
                    case ADOBE_CONNECT_URI:
                        if (urlPathExtractionFromUri == null) {
                            throw new CommandException(String.format(
                                    "Cannot set Adobe Connect Url Path - missing connector device option '%s'",
                                    URL_PATH_EXTRACTION_FROM_URI));
                        }
                        Matcher matcher = urlPathExtractionFromUri.matcher(alias.getValue());
                        if (!matcher.find()) {
                            throw new CommandException("Invalid Adobe Connect URI: " + alias.getValue());
                        }
                        attributes.add("url-path", matcher.group(1));
                        break;
                    default:
                        throw new RuntimeException("Unrecognized alias: " + alias.toString());
                    }
                }
            }
        } catch (UnsupportedEncodingException ex) {
            throw new CommandException("Error while URL encoding.", ex);
        }
    }

    /**
     * Sets participants roles in room.
     *
     * @param roomId Identifier of the room
     * @param participants Collection of participants
     * @throws CommandException
     */
    protected void addRoomParticipants(String roomId, List<RoomParticipantRole> participants)
            throws CommandException {
        if (participants.size() == 0) {
            return;
        }
        RequestAttributeList userAttributes = new RequestAttributeList();
        userAttributes.add("acl-id", roomId);

        for (RoomParticipantRole participant : participants) {
            UserInformation userInformation = getUserInformationById(participant.getUserId());
            if (userInformation == null) {
                throw new CommandException("User " + participant.getUserId() + " doesn't exist.");
            }

            // Configure all principal names for participant
            Set<String> principalNames = userInformation.getPrincipalNames();
            if (principalNames.size() == 0) {
                throw new CommandException("User " + userInformation.getFullName() + " has no principal names.");
            }
            for (String principalName : principalNames) {
                String principalId = createAdobeConnectUser(principalName, userInformation);
                String role = "remove";
                switch (participant.getRole()) {
                case PARTICIPANT:
                    role = "view";
                    break;
                case PRESENTER:
                    role = "mini-host";
                    break;
                case ADMINISTRATOR:
                    role = "host";
                    break;
                }
                userAttributes.add("principal-id", principalId);
                userAttributes.add("permission-id", role);
                logger.debug("Configuring participant '{}' in the room (principal-id: {}, role: {}).",
                        new Object[] { userInformation.getFullName(), principalId, role });
            }
        }

        execApi("permissions-update", userAttributes);
    }

    /**
     * Return permissions of the SCO.
     *
     * @param ScoId identifier of the room
     * @return XML Element from API call "permissions-info"
     * @throws CommandException
     */
    protected Element getSCOPermissions(String ScoId) throws CommandException {
        RequestAttributeList permissionsAttributes = new RequestAttributeList();
        permissionsAttributes.add("acl-id", ScoId);
        permissionsAttributes.add("filter-out-permission-id", "null");

        return execApi("permissions-info", permissionsAttributes);
    }

    protected void resetPermissions(String roomId) throws CommandException {
        RequestAttributeList permissionsResetAttributes = new RequestAttributeList();
        permissionsResetAttributes.add("acl-id", roomId);

        execApi("permissions-reset", permissionsResetAttributes);
    }

    @Override
    public String createRoom(Room room) throws CommandException {
        try {
            RequestAttributeList attributes = new RequestAttributeList();
            attributes.add("folder-id",
                    (this.meetingsFolderId != null ? this.meetingsFolderId : this.getMeetingsFolderId()));
            attributes.add("type", "meeting");
            attributes.add("date-begin", URLEncoder.encode(DateTime.now().toString(), "UTF8"));

            // Set room attributes
            setRoomAttributes(attributes, room);

            // Room name must be filled
            if (attributes.getValue("name") == null) {
                throw new RuntimeException("Room name must be filled for the new room.");
            }

            Element response = execApi("sco-update", attributes);
            String roomId = response.getChild("sco").getAttributeValue("sco-id");

            // Add room participants
            if (room.getLicenseCount() > 0) {
                startMeeting(roomId);
                addRoomParticipants(roomId, room.getParticipantRoles());
            } else if (room.getLicenseCount() == 0) {
                endMeeting(roomId);
            }

            // Set passcode (pin), since Adobe Connect 9.0
            RequestAttributeList passcodeAttributes = new RequestAttributeList();
            passcodeAttributes.add("acl-id", roomId);
            passcodeAttributes.add("field-id", "meeting-passcode");

            String pin = "";
            AdobeConnectAccessMode accessMode = null;
            AdobeConnectRoomSetting adobeConnectRoomSetting = room.getRoomSetting(AdobeConnectRoomSetting.class);
            if (adobeConnectRoomSetting != null) {
                pin = adobeConnectRoomSetting.getPin() == null ? "" : adobeConnectRoomSetting.getPin();
                accessMode = adobeConnectRoomSetting.getAccessMode();
            }

            // Set pin for room if set
            passcodeAttributes.add("value", pin);
            execApi("acl-field-update", passcodeAttributes);

            // Set room access mode, when null setRoomAccessMode set default value {@link AdobeConnectAccessMode.PROTECTED}
            setRoomAccessMode(roomId, accessMode);

            //importRoomSettings(response.getChild("sco").getAttributeValue("sco-id"),room.getConfiguration());

            return roomId;

        } catch (UnsupportedEncodingException ex) {
            throw new CommandException("Error while encoding date: ", ex);
        }
    }

    @Override
    protected boolean isRecreateNeeded(Room oldRoom, Room newRoom) throws CommandException {
        String roomId = oldRoom.getId();
        RequestAttributeList attributes = new RequestAttributeList();
        attributes.add("sco-id", roomId);
        attributes.add("type", "meeting");
        setRoomAttributes(attributes, newRoom);
        String newRoomUrl = attributes.getValue("url-path");
        String oldRoomUrl = oldRoom.getAlias(AliasType.ADOBE_CONNECT_URI).getValue();
        return !getLastPathSegmentFromURI(oldRoomUrl).equalsIgnoreCase(newRoomUrl);
    }

    @Override
    public void onModifyRoom(Room room) throws CommandException {
        String roomId = room.getId();

        getRoom(roomId);
        RequestAttributeList attributes = new RequestAttributeList();
        attributes.add("sco-id", roomId);
        attributes.add("type", "meeting");

        // Set room attributes
        setRoomAttributes(attributes, room);

        // Add/modify participants
        if (room.getLicenseCount() > 0) {
            //TODO: set permisions for recordings
            resetPermissions(roomId);
            startMeeting(roomId);
            addRoomParticipants(roomId, room.getParticipantRoles());
        } else if (room.getLicenseCount() == 0) {
            recordingManager.setRecordingPermissionsAsMeetings(roomId);
            resetPermissions(roomId);
            endMeeting(roomId);
        }

        execApi("sco-update", attributes);

        // Set passcode (pin), since Adobe Connect 9.0
        RequestAttributeList passcodeAttributes = new RequestAttributeList();
        passcodeAttributes.add("acl-id", roomId);
        passcodeAttributes.add("field-id", "meeting-passcode");

        String pin = "";
        AdobeConnectAccessMode accessMode = null;
        AdobeConnectRoomSetting adobeConnectRoomSetting = room.getRoomSetting(AdobeConnectRoomSetting.class);
        if (adobeConnectRoomSetting != null) {
            pin = adobeConnectRoomSetting.getPin() == null ? "" : adobeConnectRoomSetting.getPin();
            accessMode = adobeConnectRoomSetting.getAccessMode();
        }

        // Set pin for room if set
        passcodeAttributes.add("value", pin);
        execApi("acl-field-update", passcodeAttributes);

        // Set room access mode, when null setRoomAccessMode set default value {@link AdobeConnectAccessMode.PROTECTED}
        setRoomAccessMode(roomId, accessMode);
    }

    @Override
    protected String recreateRoom(Room oldRoom, Room newRoom) throws CommandException {
        // Create new room
        String oldRoomId = oldRoom.getId();
        String newRoomId = createRoom(newRoom);

        // Redirect from old room to new room
        try {
            //TODO: manage creating new Room with same name
            //TODO: backup recordings ???

            String newRoomUrl = newRoom.getAlias(AliasType.ADOBE_CONNECT_URI).getValue();
            String msg = "Room has been modified, you have been redirected to the new one (" + newRoomUrl + ").";
            endMeeting(oldRoomId, URLEncoder.encode(msg, "UTF8"), true, URLEncoder.encode(newRoomUrl, "UTF8"));
        } catch (UnsupportedEncodingException ex) {
            deleteRoom(newRoomId);
            throw new CommandException("Error while encoding URL. ", ex);
        } catch (CommandException exception) {
            deleteRoom(newRoomId);
            throw exception;
        }

        // Delete old room
        deleteRoom(oldRoomId);

        return newRoomId;
    }

    /**
     * Returns last segment of URI. For http://example.com/test/myPage returns myPage.
     *
     * @param uri given URI
     * @return last segment or null if URI has only domain
     */
    protected String getLastPathSegmentFromURI(String uri) {
        String[] uriArray = uri.split("/");

        return (uriArray.length > 1 ? uriArray[uriArray.length - 1] : null);
    }

    @Override
    public void deleteRoom(String roomId) throws CommandException {
        endMeeting(roomId);

        // Backup content
        recordingManager.backupRoomRecordings(roomId);

        deleteSCO(roomId);
    }

    @Override
    public String exportRoomSettings(String roomId) throws CommandException {
        Element scoInfo = getScoInfo(roomId);
        Document document = scoInfo.getDocument();
        XMLOutputter xmlOutput = new XMLOutputter(Format.getPrettyFormat());
        return xmlOutput.outputString(document);
    }

    @Override
    public void importRoomSettings(String roomId, String settings) throws CommandException {
        SAXBuilder saxBuilder = new SAXBuilder();
        Document document;
        try {
            document = saxBuilder.build(new StringReader(settings));
        } catch (Exception exception) {
            throw new CommandException(exception.getMessage(), exception);
        }

        XMLOutputter outputter = new XMLOutputter(Format.getPrettyFormat());
        String xmlString = outputter.outputString(document);

        RequestAttributeList attributes = new RequestAttributeList();

        attributes.add("sco-id", roomId);
        //        attributes.add("date-begin", document.getRootElement().getChild("sco").getChild("date-begin").getText());
        //        attributes.add("date-end", document.getRootElement().getChild("sco").getChild("date-end").getText());
        if (document.getRootElement().getChild("sco").getChild("description") != null) {
            attributes.add("description",
                    document.getRootElement().getChild("sco").getChild("description").getText());
        }
        attributes.add("url-path", document.getRootElement().getChild("sco").getChild("url-path").getText());
        attributes.add("name", document.getRootElement().getChild("sco").getChild("name").getText());

        execApi("sco-update", attributes);
    }

    /**
     * @param userPrincipalId principal-id of an user
     * @return user-original-id (EPPN) for given {@code userPrincipalId} or null when user was not found
     * @throws CommandException
     */
    public String getUserPrincipalNameByPrincipalId(String userPrincipalId) throws CommandException {
        String userPrincipalName;
        if (cachedPrincipalNameByPrincipalId.contains(userPrincipalId)) {
            logger.debug("Using cached user-original-id by principal-id '{}'...", userPrincipalId);
            userPrincipalName = cachedPrincipalNameByPrincipalId.get(userPrincipalId);

        } else {
            logger.debug("Fetching user-original-id by principal-id '{}'...", userPrincipalId);
            RequestAttributeList userAttributes = new RequestAttributeList();
            userAttributes.add("filter-principal-id", userPrincipalId);
            Element userResponse = execApi("principal-list", userAttributes);

            if (userResponse.getChild("principal-list").getChild("principal") == null) {
                return null;
            }

            userPrincipalName = userResponse.getChild("principal-list").getChild("principal").getChildText("login");
            cachedPrincipalNameByPrincipalId.put(userPrincipalId, userPrincipalName);
        }
        return userPrincipalName;
    }

    @Override
    public Collection<RoomParticipant> listRoomParticipants(String roomId) throws CommandException {
        RequestAttributeList attributes = new RequestAttributeList();
        attributes.add("sco-id", roomId);

        ArrayList<RoomParticipant> participantList = new ArrayList<RoomParticipant>();

        Element response;
        try {
            response = execApi("meeting-usermanager-user-list", attributes);
        } catch (RequestFailedCommandException exception) {
            // Participant list is not available, so return empty list
            if ("no-access".equals(exception.getCode()) && "not-available".equals(exception.getSubCode())) {
                return participantList;
            }
            // Participant list cannot be retrieved because of internal error
            else if ("internal-error".equals(exception.getCode())) {
                logger.debug(
                        "Adobe Connect issued internal error while getting meeting participants (UNSAFE API CALL)."
                                + " This should just mean, that there is no participants.",
                        exception);
                return participantList;
            }
            throw exception;
        }

        for (Element userDetails : response.getChild("meeting-usermanager-user-list").getChildren()) {
            RoomParticipant roomParticipant = new RoomParticipant();
            roomParticipant.setId(userDetails.getChildText("user-id"));
            roomParticipant.setRoomId(roomId);
            roomParticipant.setDisplayName(userDetails.getChildText("username"));

            String role = userDetails.getChildText("role");
            if ("participant".equals(role)) {
                roomParticipant.setRole(ParticipantRole.PARTICIPANT);
            } else if ("presenter".equals(role)) {
                roomParticipant.setRole(ParticipantRole.PRESENTER);
            } else if ("host".equals(role)) {
                roomParticipant.setRole(ParticipantRole.ADMINISTRATOR);
            }

            String userPrincipalName = getUserPrincipalNameByPrincipalId(userDetails.getChildText("principal-id"));

            // If participant is registered (is not guest)
            if (userPrincipalName != null) {
                UserInformation userInformation = getUserInformationByPrincipalName(userPrincipalName);
                if (userInformation != null) {
                    roomParticipant.setUserId(userInformation.getUserId());
                }
            }

            participantList.add(roomParticipant);
        }

        return Collections.unmodifiableList(participantList);
    }

    @Override
    public RoomParticipant getRoomParticipant(String roomId, String roomParticipantId) throws CommandException {
        Collection<RoomParticipant> participants = this.listRoomParticipants(roomId);
        for (RoomParticipant participant : participants) {
            if (participant.getId().equals(roomParticipantId)) {
                return participant;
            }
        }
        return null;
    }

    @Override
    public Map<String, MediaData> getRoomParticipantSnapshots(String roomId, Set<String> roomParticipantIds)
            throws CommandUnsupportedException {
        throw new CommandUnsupportedException("Adobe Connect does not support this function.");
    }

    @Override
    public void modifyRoomParticipant(RoomParticipant roomParticipant) throws CommandUnsupportedException {
        throw new CommandUnsupportedException("Adobe Connect does not support this function.");
    }

    @Override
    public void modifyRoomParticipants(RoomParticipant roomParticipantConfiguration)
            throws CommandException, CommandUnsupportedException {
        throw new CommandUnsupportedException("Adobe Connect does not support this function.");
    }

    @Override
    public String dialRoomParticipant(String roomId, Alias alias) throws CommandUnsupportedException {
        throw new CommandUnsupportedException("Adobe Connect does not support this function.");
    }

    @Override
    public void disconnectRoomParticipant(String roomId, String roomParticipantId) throws CommandException {
        RequestAttributeList attributes = new RequestAttributeList();
        attributes.add("sco-id", roomId);
        attributes.add("user-id", roomParticipantId);

        execApi("meeting-usermanager-remove-user", attributes);
    }

    protected Element getScoInfo(String scoId) throws CommandException {
        try {
            RequestAttributeList attributes = new RequestAttributeList();
            attributes.add("sco-id", scoId);

            return execApi("sco-info", attributes).getChild("sco");
        } catch (RequestFailedCommandException ex) {
            if ("no-access".equals(ex.getCode()) && "denied".equals(ex.getSubCode())) {
                throw new CommandException("SCO-ID '" + scoId + "' doesn't exist.", ex);
            } else {
                throw ex;
            }
        }
    }

    protected void renameSco(String scoId, String name) throws CommandException {
        RequestAttributeList attributes = new RequestAttributeList();
        attributes.add("sco-id", scoId);
        attributes.add("name", name);

        execApi("sco-update", attributes);
    }

    protected String getScoByUrl(String url) throws CommandException {
        RequestAttributeList attributes = new RequestAttributeList();
        attributes.add("url-path", url);

        return execApi("sco-by-url", attributes).getChild("sco").getAttributeValue("sco-id");
    }

    protected void deleteSCO(String scoId) throws CommandException {
        RequestAttributeList attributes = new RequestAttributeList();
        attributes.add("sco-id", scoId);

        execApi("sco-delete", attributes);
    }

    /**
     * Retrieves the appropriate Breeze URL.
     *
     * @param action    the action to perform
     * @return the URL to perform the action
     */
    protected String getActionUrl(String action) throws CommandException {
        if (action == null || action.isEmpty()) {
            throw new CommandException("Action of AC call cannot be empty.");
        }
        return deviceAddress.getUrl() + "/api/xml?" + "action=" + action;
    }

    /**
     * Retrieves the appropriate Breeze URL.
     *
     * @param action    the action to perform
     * @param attributes the map os parameters for the action
     * @return the URL to perform the action
     */
    protected String getActionUrl(String action, RequestAttributeList attributes) throws CommandException {
        String queryString = "";
        if (attributes != null) {
            for (Entry entry : attributes) {
                queryString += '&' + entry.getKey() + '=' + entry.getValue();
            }
        }
        return getActionUrl(action) + queryString;
    }

    protected String getConnectionSession() throws Exception {

        if (connectionSession == null) {
            login();
        }
        return connectionSession;
    }

    /**
     * Sets and returns SCO-ID of folder for meetings.
     *
     * @return meeting folder SCO-ID
     * @throws CommandException
     */
    protected String getMeetingsFolderId() throws CommandException {
        if (meetingsFolderId == null) {
            Element response = execApi("sco-shortcuts", null);
            for (Element sco : response.getChild("shortcuts").getChildren("sco")) {
                if ("meetings".equals(sco.getAttributeValue("type"))) {
                    // Find sco-id of meetings folder
                    RequestAttributeList searchAttributes = new RequestAttributeList();
                    searchAttributes.add("sco-id", sco.getAttributeValue("sco-id"));
                    searchAttributes.add("filter-is-folder", "1");

                    Element shongoFolder = execApi("sco-contents", searchAttributes);

                    for (Element folder : shongoFolder.getChild("scos").getChildren("sco")) {
                        if (meetingsFolderName.equals(folder.getChildText("name"))) {
                            meetingsFolderId = folder.getAttributeValue("sco-id");
                        }
                    }

                    // Creates meetings folder if not exists
                    if (meetingsFolderId == null) {
                        logger.debug("Folder /" + meetingsFolderName
                                + " for shongo meetings does not exists, creating...");

                        RequestAttributeList folderAttributes = new RequestAttributeList();
                        folderAttributes.add("folder-id", sco.getAttributeValue("sco-id"));
                        folderAttributes.add("name", meetingsFolderName);
                        folderAttributes.add("type", "folder");

                        Element folder = execApi("sco-update", folderAttributes);

                        meetingsFolderId = folder.getChild("sco").getAttributeValue("sco-id");

                        logger.debug("Folder /" + meetingsFolderName + " for meetings created with sco-id: "
                                + meetingsFolderId);
                    }

                    break;
                }
            }
        }

        return meetingsFolderId;
    }

    /**
     * Performs the action to log into Adobe Connect server. Stores the breezeseession ID.
     */
    protected void login() throws CommandException {
        if (this.connectionSession != null) {
            logout();
        }

        RequestAttributeList loginAttributes = new RequestAttributeList();
        loginAttributes.add("login", this.login);
        loginAttributes.add("password", this.password);

        URLConnection connection;
        try {
            String loginUrl = getActionUrl("login", loginAttributes);
            connection = new URL(loginUrl).openConnection();
            connection.connect();

            InputStream resultStream = connection.getInputStream();
            Document resultDocument = new SAXBuilder().build(resultStream);
            if (this.isError(resultDocument)) {
                throw new CommandException("Login to server " + deviceAddress + " failed");
            } else {
                logger.debug(String.format("Login to server %s succeeded", deviceAddress));
            }
        } catch (Exception exception) {
            throw new CommandException(exception.getMessage(), exception);
        }

        String connectionSessionString = connection.getHeaderField("Set-Cookie");
        StringTokenizer st = new StringTokenizer(connectionSessionString, "=");
        String sessionName = null;
        if (st.countTokens() > 1) {
            sessionName = st.nextToken();
        }

        if (sessionName != null && (sessionName.equals("JSESSIONID") || sessionName.equals("BREEZESESSION"))) {
            String connectionSessionNext = st.nextToken();
            int separatorIndex = connectionSessionNext.indexOf(';');
            this.connectionSession = connectionSessionNext.substring(0, separatorIndex);
            this.meetingsFolderId = this.getMeetingsFolderId();
        }

        if (connectionSession == null) {
            throw new CommandException("Could not log in to Adobe Connect server: " + deviceAddress);
        }
        this.connectionState = ConnectionState.LOOSELY_CONNECTED;

        this.capacityCheckThread = new Thread() {
            private Logger logger = LoggerFactory.getLogger(AdobeConnectConnector.class);

            @Override
            public void run() {
                setCapacityChecking(true);
                logger.info("Checking of rooms capacity - starting...");
                while (capacityCheckThread != null && isConnected()) {
                    try {
                        Thread.sleep(capacityCheckTimeout);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                        continue;
                    }

                    try {
                        checkAllRoomsCapacity();
                    } catch (Exception exception) {
                        logger.warn("Capacity check failed", exception);
                    }
                }
                logger.info("Checking of rooms capacity - exiting...");
                setCapacityChecking(false);
            }
        };
        this.capacityCheckThread.setName(Thread.currentThread().getName() + "-capacities");
        synchronized (this) {
            if (!this.capacityChecking) {
                this.capacityCheckThread.start();
            }
        }
    }

    private synchronized void setCapacityChecking(boolean value) {
        this.capacityChecking = value;
    }

    /**
     *
     * @throws CommandException
     */
    protected void checkAllRoomsCapacity() throws CommandException {
        Element response = execApi("report-active-meetings", new RequestAttributeList());

        RequestAttributeList attributes = new RequestAttributeList();
        attributes.add("sco-id", getMeetingsFolderId());
        attributes.add("type", "meeting");
        Element shongoRoomsElement = execApi("sco-contents", attributes);

        List<String> shongoRooms = new ArrayList<String>();
        for (Element sco : shongoRoomsElement.getChild("scos").getChildren()) {
            shongoRooms.add(sco.getAttributeValue("sco-id"));
        }

        for (Element sco : response.getChild("report-active-meetings").getChildren()) {
            String scoId = sco.getAttributeValue("sco-id");
            if (shongoRooms.contains(scoId)) {
                checkRoomCapacity(scoId);
            } else {
                logger.debug("There is active room (sco-id: " + scoId + ", url: " + sco.getChildText("url-path")
                        + ", name: " + sco.getChildText("name") + "), which was not created by shongo.");
            }
        }
    }

    protected void checkRoomCapacity(String roomId) throws CommandException {
        Room room;
        int participants = 0;

        try {
            participants = countRoomParticipants(roomId);
        } catch (RequestFailedCommandException ex) {
            if ("no-access".equals(ex.getCode()) && "not-available".equals(ex.getSubCode())) {
                logger.warn("Can't get number of room participants! Skipping capacity check for room " + roomId
                        + ". This may be normal behavior.");
                return;
            } else {
                throw ex;
            }
        }

        try {
            room = getRoom(roomId);
        } catch (RequestFailedCommandException ex) {
            if ("no-data".equals(ex.getCode())) {
                logger.warn("Can't get room capacity! Skipping capacity check for room " + roomId
                        + ". This may be normal behavior.");
                return;
            } else {
                throw ex;
            }
        }

        if (room == null) {
            logger.warn("Can't get room info (room ID: " + roomId + "), skipping capacity check.");
            return;
        }

        logger.debug("Checking capacity for room " + roomId + " with capacity " + room.getLicenseCount() + " and "
                + participants + " participants.");
        if (participants > room.getLicenseCount()) {
            logger.warn("Capacity has been exceeded in room " + room.getName() + " (room ID: " + roomId + ").");

            NotifyTarget notifyTarget = new NotifyTarget(Service.NotifyTargetType.ROOM_OWNERS, roomId);
            notifyTarget.addMessage("en", "Room capacity exceeded: " + room.getName(),
                    "Capacity has been exceeded in your room \"" + room.getName() + "\".\n\n"
                            + "Booked licence count: " + room.getLicenseCount() + "\n"
                            + "Number of connected participants: " + participants + "\n\n"
                            + "Please use only the booked license count and disconnect participants who exceed it.");
            notifyTarget.addMessage("cs", "Kapacita mstnosti pekro?ena: " + room.getName(),
                    "Kapacita va mstnosti \"" + room.getName() + "\" byla pekro?ena.\n\n"
                            + "Po?et zarezervovanch licenc: " + room.getLicenseCount() + "\n"
                            + "Po?et pipojench ?astnk: " + participants + "\n\n"
                            + "Prosme dodrujte zarezervovan po?et licenc a odpojte ?astnky, kte jej pekra?uj.");

            performControllerAction(notifyTarget);
        }
    }

    /**
     * Returns number of participants.
     * @param roomId sco-id
     * @return number of participants
     */
    protected int countRoomParticipants(String roomId) throws CommandException {
        RequestAttributeList scoInfoAttributes = new RequestAttributeList();
        scoInfoAttributes.add("sco-id", roomId);

        Element response = execApi("meeting-usermanager-user-list", scoInfoAttributes);

        return response.getChild("meeting-usermanager-user-list").getChildren("userdetails").size();
    }

    /**
     * Logout of the server, clearing the session as well.
     */
    public void logout() throws CommandException {
        execApi("logout", null);
        this.connectionSession = null;
    }

    /**
     * Execute command on Adobe Connect server and returns XML response. Throws CommandException when some error on Adobe Connect server occured or some parser error occured.
     *
     * @param action     name of Adobe Connect action
     * @param attributes attributes of action
     * @return XML action response
     * @throws CommandException
     */
    protected Element execApi(String action, RequestAttributeList attributes) throws CommandException {
        try {
            if (this.connectionSession == null) {
                if (action.equals("logout")) {
                    return null;
                } else {
                    login();
                }
            }

            String actionUrl = getActionUrl(action, attributes);
            logger.debug(String.format("Calling action %s on %s", actionUrl, deviceAddress));

            int retryCount = 5;
            while (retryCount > 0) {
                // Read result from url
                Document result;
                try {
                    // Read result
                    InputStream resultStream = execApi(actionUrl, requestTimeout);
                    result = new SAXBuilder().build(resultStream);
                } catch (IOException exception) {
                    if (isRequestApiRetryPossible(exception)) {
                        retryCount--;
                        logger.warn("{}: Trying again...", exception.getMessage());
                        continue;
                    } else {
                        throw exception;
                    }
                }

                // Check for error and reconnect if login is needed
                if (isError(result)) {
                    if (isLoginNeeded(result)) {
                        retryCount--;
                        logger.debug(String.format("Reconnecting to server %s", deviceAddress));
                        this.connectionState = ConnectionState.RECONNECTING;
                        connectionSession = null;
                        login();
                        continue;
                    }
                    throw new RequestFailedCommandException(actionUrl, result);
                } else {
                    logger.debug(String.format("Command %s succeeded on %s", action, deviceAddress));
                    return result.getRootElement();
                }
            }
            throw new CommandException(String.format("Command %s failed.", action));
        } catch (IOException e) {
            throw new RuntimeException("Command issuing error", e);
        } catch (JDOMParseException e) {
            throw new RuntimeException("Command result parsing error", e);
        } catch (JDOMException e) {
            throw new RuntimeException("Error initializing parser", e);
        } catch (RequestFailedCommandException exception) {
            logger.warn(String.format("Command %s has failed on %s: %s", action, deviceAddress, exception));
            throw exception;
        }
    }

    /**
     * Execute command on Adobe Connect server and returns {@link InputStream} with response.
     *
     * @param actionUrl
     * @param timeout
     * @return response in {@link InputStream}
     * @throws IOException
     */
    protected InputStream execApi(String actionUrl, int timeout) throws IOException {
        // Send request
        URL url = new URL(actionUrl);
        URLConnection connection = url.openConnection();
        connection.setConnectTimeout(timeout);
        connection.setReadTimeout(timeout);
        connection.setRequestProperty("Cookie", "BREEZESESSION=" + this.connectionSession);
        connection.connect();
        return connection.getInputStream();
    }

    /**
     * @param exception
     * @return true whether given {@code exception} allows to retry the API request, false otherwise
     */
    protected boolean isRequestApiRetryPossible(Exception exception) {
        Throwable cause = exception.getCause();
        return (cause instanceof SocketException && cause.getMessage().equals("Connection reset"));
    }

    /**
     * @param result Document returned by AC API call
     * @return true if the given {@code result} represents and error,
     *         false otherwise
     */
    private boolean isError(Document result) {
        return isError(result.getRootElement());
    }

    /**
     * @param response Element returned by AC API call
     * @return true if the given {@code result} represents and error,
     *         false otherwise
     */
    private boolean isError(Element response) {
        Element status = response.getChild("status");
        if (status != null) {
            String code = status.getAttributeValue("code");
            if ("ok".equals(code)) {
                return false;
            }
        }
        return true;
    }

    /**
     * @param result XML result to parse for error
     * @return true if the given {@code result} saying that login is needed,
     *         false otherwise
     */
    private boolean isLoginNeeded(Document result) {
        Element status = result.getRootElement().getChild("status");
        if (status != null) {
            String code = status.getAttributeValue("code");
            if ("no-access".equals(code)) {
                String subCode = status.getAttributeValue("subcode");
                if ("no-login".equals(subCode)) {
                    return true;
                }
            }
        }
        return false;
    }

    public static void main(String[] args) throws Exception {
        try {
            /* Testovaci AC server */
            String server = "tconn.cesnet.cz";

            AdobeConnectConnector acc = new AdobeConnectConnector();
            DeviceAddress deviceAddress = new DeviceAddress(server, 443);

            acc.connect(deviceAddress, "admin", "<password>");

            /************************/

            /************************/

            acc.disconnect();

        } catch (ExceptionInInitializerError exception) {
            logger.error("Cannot initialize adobe connect", exception);
        }
    }

    public static class RequestFailedCommandException extends CommandException {
        private String requestUrl;

        private Document requestResult;

        public RequestFailedCommandException(String requestUrl, Document requestResult) {
            this.requestUrl = requestUrl;
            this.requestResult = requestResult;
        }

        @Override
        public String getMessage() {
            return String.format("%s. URL: %s", getError(), requestUrl);
        }

        public String getError() {
            Element status = requestResult.getRootElement().getChild("status");

            List<Attribute> statusAttributes = status.getAttributes();
            StringBuilder errorMsg = new StringBuilder();
            for (Attribute attribute : statusAttributes) {
                String attributeName = attribute.getName();
                String attributeValue = attribute.getValue();
                errorMsg.append(" ");
                errorMsg.append(attributeName);
                errorMsg.append(": ");
                errorMsg.append(attributeValue);

            }

            String code = status.getAttributeValue("code");
            if (status.getChild(code) != null) {
                List<Attribute> childAttributes = status.getChild(code).getAttributes();
                for (Attribute attribute : childAttributes) {
                    errorMsg.append(", ");
                    errorMsg.append(attribute.getName());
                    errorMsg.append(": ");
                    errorMsg.append(attribute.getValue());
                }
            }
            return errorMsg.toString();
        }

        public String getCode() {
            Element status = requestResult.getRootElement().getChild("status");
            return status.getAttributeValue("code");
        }

        public String getSubCode() {
            Element status = requestResult.getRootElement().getChild("status");
            return status.getAttributeValue("subcode");
        }

        public Element getRequestResult() {
            return this.requestResult.getRootElement();
        }
    }

    public static class RequestAttributeList extends LinkedList<Entry> {
        public void add(String key, String value) throws CommandException {
            if (!add(new Entry(key, value))) {
                throw new CommandException("Failed to add attribute to attribute list.");
            }
        }

        public String getValue(String key) {
            for (Entry entry : this) {
                if (entry.getKey().equals(key)) {
                    return entry.getValue();
                }
            }

            return null;
        }

        public Entry getEntry(String key) {
            for (Entry entry : this) {
                if (entry.getKey().equals(key)) {
                    return entry;
                }
            }

            return null;
        }
    }

    public static class Entry {
        private String key;
        private String value;

        public Entry(String key, String value) {
            setKey(key);
            setValue(value);
        }

        public String getKey() {
            return key;
        }

        public void setKey(String key) {
            this.key = key;
        }

        public String getValue() {
            return value;
        }

        public void setValue(String value) {
            this.value = value;
        }
    }
}