SEBSERV-148 GUI impl
This commit is contained in:
parent
70064206cf
commit
ffe4b6301a
15 changed files with 505 additions and 162 deletions
|
@ -27,6 +27,7 @@ public class ProctoringRoomConnection {
|
||||||
public static final String ATTR_USER_NAME = "userName";
|
public static final String ATTR_USER_NAME = "userName";
|
||||||
public static final String ATTR_ROOM_KEY = "roomKey";
|
public static final String ATTR_ROOM_KEY = "roomKey";
|
||||||
public static final String ATTR_API_KEY = "apiKey";
|
public static final String ATTR_API_KEY = "apiKey";
|
||||||
|
public static final String ATTR_MEETING_ID = "meetingId";
|
||||||
|
|
||||||
@JsonProperty(ProctoringServiceSettings.ATTR_SERVER_TYPE)
|
@JsonProperty(ProctoringServiceSettings.ATTR_SERVER_TYPE)
|
||||||
public final ProctoringServerType proctoringServerType;
|
public final ProctoringServerType proctoringServerType;
|
||||||
|
@ -55,6 +56,9 @@ public class ProctoringRoomConnection {
|
||||||
@JsonProperty(ATTR_API_KEY)
|
@JsonProperty(ATTR_API_KEY)
|
||||||
public final CharSequence apiKey;
|
public final CharSequence apiKey;
|
||||||
|
|
||||||
|
@JsonProperty(ATTR_MEETING_ID)
|
||||||
|
public final String meetingId;
|
||||||
|
|
||||||
@JsonProperty(ATTR_USER_NAME)
|
@JsonProperty(ATTR_USER_NAME)
|
||||||
public final String userName;
|
public final String userName;
|
||||||
|
|
||||||
|
@ -69,6 +73,7 @@ public class ProctoringRoomConnection {
|
||||||
@JsonProperty(ATTR_ACCESS_TOKEN) final CharSequence accessToken,
|
@JsonProperty(ATTR_ACCESS_TOKEN) final CharSequence accessToken,
|
||||||
@JsonProperty(ATTR_ROOM_KEY) final CharSequence roomKey,
|
@JsonProperty(ATTR_ROOM_KEY) final CharSequence roomKey,
|
||||||
@JsonProperty(ATTR_API_KEY) final CharSequence apiKey,
|
@JsonProperty(ATTR_API_KEY) final CharSequence apiKey,
|
||||||
|
@JsonProperty(ATTR_MEETING_ID) final String meetingId,
|
||||||
@JsonProperty(ATTR_USER_NAME) final String userName) {
|
@JsonProperty(ATTR_USER_NAME) final String userName) {
|
||||||
|
|
||||||
this.proctoringServerType = proctoringServerType;
|
this.proctoringServerType = proctoringServerType;
|
||||||
|
@ -80,6 +85,7 @@ public class ProctoringRoomConnection {
|
||||||
this.accessToken = accessToken;
|
this.accessToken = accessToken;
|
||||||
this.roomKey = roomKey;
|
this.roomKey = roomKey;
|
||||||
this.apiKey = apiKey;
|
this.apiKey = apiKey;
|
||||||
|
this.meetingId = meetingId;
|
||||||
this.userName = userName;
|
this.userName = userName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -123,6 +129,10 @@ public class ProctoringRoomConnection {
|
||||||
return this.subject;
|
return this.subject;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getMeetingId() {
|
||||||
|
return this.meetingId;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
final StringBuilder builder = new StringBuilder();
|
final StringBuilder builder = new StringBuilder();
|
||||||
|
|
|
@ -16,4 +16,5 @@ public interface RemoteProctoringView extends TemplateComposer {
|
||||||
*
|
*
|
||||||
* @return the remote proctoring server type this remote proctoring view can handle. */
|
* @return the remote proctoring server type this remote proctoring view can handle. */
|
||||||
ProctoringServerType serverType();
|
ProctoringServerType serverType();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -75,6 +75,11 @@ public class JitsiMeetProctoringView implements RemoteProctoringView {
|
||||||
this.remoteProctoringViewServletEndpoint = remoteProctoringViewServletEndpoint;
|
this.remoteProctoringViewServletEndpoint = remoteProctoringViewServletEndpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ProctoringServerType serverType() {
|
||||||
|
return ProctoringServerType.JITSI_MEET;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void compose(final PageContext pageContext) {
|
public void compose(final PageContext pageContext) {
|
||||||
|
|
||||||
|
@ -227,18 +232,6 @@ public class JitsiMeetProctoringView implements RemoteProctoringView {
|
||||||
sendReconfigurationAttributes(examId, roomName, state);
|
sendReconfigurationAttributes(examId, roomName, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public ProctoringServerType serverType() {
|
|
||||||
return ProctoringServerType.JITSI_MEET;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class BroadcastActionState {
|
|
||||||
public static final String KEY_NAME = "BroadcastActionState";
|
|
||||||
boolean audio = false;
|
|
||||||
boolean video = false;
|
|
||||||
boolean chat = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void closeRoom(final ProctoringWindowData proctoringWindowData) {
|
private void closeRoom(final ProctoringWindowData proctoringWindowData) {
|
||||||
this.pageService
|
this.pageService
|
||||||
.getCurrentUser()
|
.getCurrentUser()
|
||||||
|
@ -246,4 +239,11 @@ public class JitsiMeetProctoringView implements RemoteProctoringView {
|
||||||
.closeRoomWindow(proctoringWindowData.windowName);
|
.closeRoomWindow(proctoringWindowData.windowName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static final class BroadcastActionState {
|
||||||
|
public static final String KEY_NAME = "BroadcastActionState";
|
||||||
|
boolean audio = false;
|
||||||
|
boolean video = false;
|
||||||
|
boolean chat = false;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,249 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2021 ETH Zürich, Educational Development and Technology (LET)
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package ch.ethz.seb.sebserver.gui.service.page.impl;
|
||||||
|
|
||||||
|
import org.eclipse.swt.SWT;
|
||||||
|
import org.eclipse.swt.browser.Browser;
|
||||||
|
import org.eclipse.swt.graphics.Color;
|
||||||
|
import org.eclipse.swt.layout.GridData;
|
||||||
|
import org.eclipse.swt.layout.GridLayout;
|
||||||
|
import org.eclipse.swt.layout.RowData;
|
||||||
|
import org.eclipse.swt.layout.RowLayout;
|
||||||
|
import org.eclipse.swt.widgets.Button;
|
||||||
|
import org.eclipse.swt.widgets.Composite;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import ch.ethz.seb.sebserver.gbl.Constants;
|
||||||
|
import ch.ethz.seb.sebserver.gbl.api.API;
|
||||||
|
import ch.ethz.seb.sebserver.gbl.model.Domain;
|
||||||
|
import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringServiceSettings.ProctoringServerType;
|
||||||
|
import ch.ethz.seb.sebserver.gbl.profile.GuiProfile;
|
||||||
|
import ch.ethz.seb.sebserver.gui.GuiServiceInfo;
|
||||||
|
import ch.ethz.seb.sebserver.gui.service.i18n.LocTextKey;
|
||||||
|
import ch.ethz.seb.sebserver.gui.service.page.PageContext;
|
||||||
|
import ch.ethz.seb.sebserver.gui.service.page.PageService;
|
||||||
|
import ch.ethz.seb.sebserver.gui.service.page.RemoteProctoringView;
|
||||||
|
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.session.SendProctoringReconfigurationAttributes;
|
||||||
|
import ch.ethz.seb.sebserver.gui.service.session.proctoring.ProctoringGUIService;
|
||||||
|
import ch.ethz.seb.sebserver.gui.service.session.proctoring.ProctoringGUIService.ProctoringWindowData;
|
||||||
|
import ch.ethz.seb.sebserver.gui.widget.WidgetFactory;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@GuiProfile
|
||||||
|
public class ZoomProctoringView implements RemoteProctoringView {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ZoomProctoringView.class);
|
||||||
|
|
||||||
|
private static final LocTextKey CLOSE_WINDOW_TEXT_KEY =
|
||||||
|
new LocTextKey("sebserver.monitoring.exam.proctoring.action.close");
|
||||||
|
private static final LocTextKey BROADCAST_AUDIO_ON_TEXT_KEY =
|
||||||
|
new LocTextKey("sebserver.monitoring.exam.proctoring.action.broadcaston.audio");
|
||||||
|
private static final LocTextKey BROADCAST_AUDIO_OFF_TEXT_KEY =
|
||||||
|
new LocTextKey("sebserver.monitoring.exam.proctoring.action.broadcastoff.audio");
|
||||||
|
private static final LocTextKey BROADCAST_VIDEO_ON_TEXT_KEY =
|
||||||
|
new LocTextKey("sebserver.monitoring.exam.proctoring.action.broadcaston.video");
|
||||||
|
private static final LocTextKey BROADCAST_VIDEO_OFF_TEXT_KEY =
|
||||||
|
new LocTextKey("sebserver.monitoring.exam.proctoring.action.broadcastoff.video");
|
||||||
|
private static final LocTextKey CHAT_ON_TEXT_KEY =
|
||||||
|
new LocTextKey("sebserver.monitoring.exam.proctoring.action.broadcaston.chat");
|
||||||
|
private static final LocTextKey CHAT_OFF_TEXT_KEY =
|
||||||
|
new LocTextKey("sebserver.monitoring.exam.proctoring.action.broadcastoff.chat");
|
||||||
|
|
||||||
|
private final PageService pageService;
|
||||||
|
private final GuiServiceInfo guiServiceInfo;
|
||||||
|
private final String remoteProctoringEndpoint;
|
||||||
|
private final String remoteProctoringViewServletEndpoint;
|
||||||
|
|
||||||
|
public ZoomProctoringView(
|
||||||
|
final PageService pageService,
|
||||||
|
final GuiServiceInfo guiServiceInfo,
|
||||||
|
@Value("${sebserver.gui.remote.proctoring.entrypoint:/remote-proctoring}") final String remoteProctoringEndpoint,
|
||||||
|
@Value("${sebserver.gui.remote.proctoring.api-servler.endpoint:/remote-view-servlet}") final String remoteProctoringViewServletEndpoint) {
|
||||||
|
|
||||||
|
this.pageService = pageService;
|
||||||
|
this.guiServiceInfo = guiServiceInfo;
|
||||||
|
this.remoteProctoringEndpoint = remoteProctoringEndpoint;
|
||||||
|
this.remoteProctoringViewServletEndpoint = remoteProctoringViewServletEndpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ProctoringServerType serverType() {
|
||||||
|
return ProctoringServerType.ZOOM;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void compose(final PageContext pageContext) {
|
||||||
|
final ProctoringWindowData proctoringWindowData = ProctoringGUIService.getCurrentProctoringWindowData();
|
||||||
|
|
||||||
|
final Composite parent = pageContext.getParent();
|
||||||
|
|
||||||
|
final Composite content = new Composite(parent, SWT.NONE | SWT.NO_SCROLL);
|
||||||
|
final GridLayout gridLayout = new GridLayout();
|
||||||
|
|
||||||
|
content.setLayout(gridLayout);
|
||||||
|
final GridData headerCell = new GridData(SWT.FILL, SWT.FILL, true, true);
|
||||||
|
content.setLayoutData(headerCell);
|
||||||
|
|
||||||
|
parent.addListener(SWT.Dispose, event -> closeRoom(proctoringWindowData));
|
||||||
|
|
||||||
|
final String url = this.guiServiceInfo
|
||||||
|
.getExternalServerURIBuilder()
|
||||||
|
.toUriString()
|
||||||
|
+ this.remoteProctoringEndpoint
|
||||||
|
+ this.remoteProctoringViewServletEndpoint
|
||||||
|
+ Constants.SLASH;
|
||||||
|
|
||||||
|
if (log.isDebugEnabled()) {
|
||||||
|
log.debug("Open proctoring Servlet in IFrame with URL: {}", url);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Browser browser = new Browser(content, SWT.NONE | SWT.NO_SCROLL);
|
||||||
|
browser.setLayout(new GridLayout());
|
||||||
|
final GridData gridData = new GridData(SWT.FILL, SWT.FILL, true, true);
|
||||||
|
browser.setLayoutData(gridData);
|
||||||
|
browser.setUrl(url);
|
||||||
|
browser.setBackground(new Color(parent.getDisplay(), 100, 100, 100));
|
||||||
|
|
||||||
|
final Composite footer = new Composite(content, SWT.NONE | SWT.NO_SCROLL);
|
||||||
|
footer.setLayout(new RowLayout());
|
||||||
|
final GridData footerLayout = new GridData(SWT.CENTER, SWT.BOTTOM, true, false);
|
||||||
|
footerLayout.heightHint = 40;
|
||||||
|
footer.setLayoutData(footerLayout);
|
||||||
|
|
||||||
|
final WidgetFactory widgetFactory = this.pageService.getWidgetFactory();
|
||||||
|
|
||||||
|
final Button closeAction = widgetFactory.buttonLocalized(footer, CLOSE_WINDOW_TEXT_KEY);
|
||||||
|
closeAction.setLayoutData(new RowData(150, 30));
|
||||||
|
closeAction.addListener(SWT.Selection, event -> closeRoom(proctoringWindowData));
|
||||||
|
|
||||||
|
final BroadcastActionState broadcastActionState = new BroadcastActionState();
|
||||||
|
|
||||||
|
final Button broadcastAudioAction = widgetFactory.buttonLocalized(footer, BROADCAST_AUDIO_ON_TEXT_KEY);
|
||||||
|
broadcastAudioAction.setLayoutData(new RowData(150, 30));
|
||||||
|
broadcastAudioAction.addListener(SWT.Selection, event -> toggleBroadcastAudio(
|
||||||
|
proctoringWindowData.examId,
|
||||||
|
proctoringWindowData.connectionData.roomName,
|
||||||
|
broadcastAudioAction));
|
||||||
|
broadcastAudioAction.setData(BroadcastActionState.KEY_NAME, broadcastActionState);
|
||||||
|
|
||||||
|
final Button broadcastVideoAction = widgetFactory.buttonLocalized(footer, BROADCAST_VIDEO_ON_TEXT_KEY);
|
||||||
|
broadcastVideoAction.setLayoutData(new RowData(150, 30));
|
||||||
|
broadcastVideoAction.addListener(SWT.Selection, event -> toggleBroadcastVideo(
|
||||||
|
proctoringWindowData.examId,
|
||||||
|
proctoringWindowData.connectionData.roomName,
|
||||||
|
broadcastVideoAction,
|
||||||
|
broadcastAudioAction));
|
||||||
|
broadcastVideoAction.setData(BroadcastActionState.KEY_NAME, broadcastActionState);
|
||||||
|
|
||||||
|
final Button chatAction = widgetFactory.buttonLocalized(footer, CHAT_ON_TEXT_KEY);
|
||||||
|
chatAction.setLayoutData(new RowData(150, 30));
|
||||||
|
chatAction.addListener(SWT.Selection, event -> toggleChat(
|
||||||
|
proctoringWindowData.examId,
|
||||||
|
proctoringWindowData.connectionData.roomName,
|
||||||
|
chatAction));
|
||||||
|
chatAction.setData(BroadcastActionState.KEY_NAME, broadcastActionState);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendReconfigurationAttributes(
|
||||||
|
final String examId,
|
||||||
|
final String roomName,
|
||||||
|
final BroadcastActionState state) {
|
||||||
|
|
||||||
|
this.pageService.getRestService().getBuilder(SendProctoringReconfigurationAttributes.class)
|
||||||
|
.withURIVariable(API.PARAM_MODEL_ID, examId)
|
||||||
|
.withFormParam(Domain.REMOTE_PROCTORING_ROOM.ATTR_ID, roomName)
|
||||||
|
.withFormParam(
|
||||||
|
|
||||||
|
API.EXAM_PROCTORING_ATTR_RECEIVE_AUDIO,
|
||||||
|
state.audio ? Constants.TRUE_STRING : Constants.FALSE_STRING)
|
||||||
|
.withFormParam(
|
||||||
|
API.EXAM_PROCTORING_ATTR_RECEIVE_VIDEO,
|
||||||
|
state.video ? Constants.TRUE_STRING : Constants.FALSE_STRING)
|
||||||
|
.withFormParam(
|
||||||
|
API.EXAM_PROCTORING_ATTR_ALLOW_CHAT,
|
||||||
|
state.chat ? Constants.TRUE_STRING : Constants.FALSE_STRING)
|
||||||
|
.call()
|
||||||
|
.onError(error -> log.error("Failed to send broadcast attributes to clients in room: {} cause: {}",
|
||||||
|
roomName,
|
||||||
|
error.getMessage()));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private void toggleBroadcastAudio(
|
||||||
|
final String examId,
|
||||||
|
final String roomName,
|
||||||
|
final Button broadcastAction) {
|
||||||
|
|
||||||
|
final BroadcastActionState state =
|
||||||
|
(BroadcastActionState) broadcastAction.getData(BroadcastActionState.KEY_NAME);
|
||||||
|
|
||||||
|
this.pageService.getPolyglotPageService().injectI18n(
|
||||||
|
broadcastAction,
|
||||||
|
state.audio ? BROADCAST_AUDIO_ON_TEXT_KEY : BROADCAST_AUDIO_OFF_TEXT_KEY);
|
||||||
|
|
||||||
|
state.audio = !state.audio;
|
||||||
|
sendReconfigurationAttributes(examId, roomName, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void toggleBroadcastVideo(
|
||||||
|
final String examId,
|
||||||
|
final String roomName,
|
||||||
|
final Button videoAction,
|
||||||
|
final Button audioAction) {
|
||||||
|
|
||||||
|
final BroadcastActionState state =
|
||||||
|
(BroadcastActionState) videoAction.getData(BroadcastActionState.KEY_NAME);
|
||||||
|
|
||||||
|
this.pageService.getPolyglotPageService().injectI18n(
|
||||||
|
audioAction,
|
||||||
|
state.video ? BROADCAST_AUDIO_ON_TEXT_KEY : BROADCAST_AUDIO_OFF_TEXT_KEY);
|
||||||
|
this.pageService.getPolyglotPageService().injectI18n(
|
||||||
|
videoAction,
|
||||||
|
state.video ? BROADCAST_VIDEO_ON_TEXT_KEY : BROADCAST_VIDEO_OFF_TEXT_KEY);
|
||||||
|
|
||||||
|
state.video = !state.video;
|
||||||
|
state.audio = state.video;
|
||||||
|
sendReconfigurationAttributes(examId, roomName, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void toggleChat(
|
||||||
|
final String examId,
|
||||||
|
final String roomName,
|
||||||
|
final Button broadcastAction) {
|
||||||
|
|
||||||
|
final BroadcastActionState state =
|
||||||
|
(BroadcastActionState) broadcastAction.getData(BroadcastActionState.KEY_NAME);
|
||||||
|
|
||||||
|
this.pageService.getPolyglotPageService().injectI18n(
|
||||||
|
broadcastAction,
|
||||||
|
state.chat ? CHAT_ON_TEXT_KEY : CHAT_OFF_TEXT_KEY);
|
||||||
|
|
||||||
|
state.chat = !state.chat;
|
||||||
|
sendReconfigurationAttributes(examId, roomName, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeRoom(final ProctoringWindowData proctoringWindowData) {
|
||||||
|
this.pageService
|
||||||
|
.getCurrentUser()
|
||||||
|
.getProctoringGUIService()
|
||||||
|
.closeRoomWindow(proctoringWindowData.windowName);
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class BroadcastActionState {
|
||||||
|
public static final String KEY_NAME = "BroadcastActionState";
|
||||||
|
boolean audio = false;
|
||||||
|
boolean video = false;
|
||||||
|
boolean chat = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -64,16 +64,16 @@ public class ZoomWindowScriptResolver implements ProctoringWindowScriptResolver
|
||||||
+ " ZoomMtg.preLoadWasm();\n"
|
+ " ZoomMtg.preLoadWasm();\n"
|
||||||
+ " ZoomMtg.prepareJssdk();\n"
|
+ " ZoomMtg.prepareJssdk();\n"
|
||||||
+ "\n"
|
+ "\n"
|
||||||
+ " const API_KEY = \"%%_\" + ATTR_API_KEY + \"_%%\";\n"
|
+ " const API_KEY = \"%%_" + ATTR_API_KEY + "_%%\";\n"
|
||||||
+ " const config = {\n"
|
+ " const config = {\n"
|
||||||
+ " meetingNumber: %%_\" + ATTR_ROOM_NAME + \"_%%,\n"
|
+ " meetingNumber: %%_" + ATTR_ROOM_NAME + "_%%,\n"
|
||||||
+ " leaveUrl: '%%_\" + ATTR_HOST + \"_%%',\n"
|
+ " leaveUrl: '%%_" + ATTR_HOST + "_%%',\n"
|
||||||
+ " userName: '%%_\" + ATTR_USER_NAME + \"_%%',\n"
|
+ " userName: '%%_" + ATTR_USER_NAME + "_%%',\n"
|
||||||
+ " passWord: '%%_\" + ATTR_ROOM_KEY + \"_%%',\n"
|
+ " passWord: '%%_" + ATTR_ROOM_KEY + "_%%',\n"
|
||||||
+ " role: 0 // 1 for host; 0 for attendee\n"
|
+ " role: 1 // 1 for host; 0 for attendee\n"
|
||||||
+ " };\n"
|
+ " };\n"
|
||||||
+ "\n"
|
+ "\n"
|
||||||
+ " const signature = '%%_\" + ATTR_ACCESS_TOKEN + \"_%%';\n"
|
+ " const signature = '%%_" + ATTR_ACCESS_TOKEN + "_%%';\n"
|
||||||
+ "\n"
|
+ "\n"
|
||||||
+ " console.log(\"Initializing meeting...\");\n"
|
+ " console.log(\"Initializing meeting...\");\n"
|
||||||
+ "\n"
|
+ "\n"
|
||||||
|
@ -155,7 +155,7 @@ public class ZoomWindowScriptResolver implements ProctoringWindowScriptResolver
|
||||||
public String getProctoringWindowScript(final ProctoringWindowData data) {
|
public String getProctoringWindowScript(final ProctoringWindowData data) {
|
||||||
final Map<String, String> args = new HashMap<>();
|
final Map<String, String> args = new HashMap<>();
|
||||||
args.put(ATTR_HOST, data.connectionData.serverHost);
|
args.put(ATTR_HOST, data.connectionData.serverHost);
|
||||||
args.put(ATTR_ROOM_NAME, data.connectionData.roomName);
|
args.put(ATTR_ROOM_NAME, data.connectionData.meetingId);
|
||||||
args.put(ATTR_ACCESS_TOKEN, String.valueOf(data.connectionData.accessToken));
|
args.put(ATTR_ACCESS_TOKEN, String.valueOf(data.connectionData.accessToken));
|
||||||
args.put(ATTR_API_KEY, String.valueOf(data.connectionData.apiKey));
|
args.put(ATTR_API_KEY, String.valueOf(data.connectionData.apiKey));
|
||||||
if (StringUtils.isNotBlank(data.connectionData.roomKey)) {
|
if (StringUtils.isNotBlank(data.connectionData.roomKey)) {
|
||||||
|
|
|
@ -41,16 +41,16 @@ public interface ExamAdminService {
|
||||||
* @return Result refer to the restriction flag or to an error when happened */
|
* @return Result refer to the restriction flag or to an error when happened */
|
||||||
Result<Boolean> isRestricted(Exam exam);
|
Result<Boolean> isRestricted(Exam exam);
|
||||||
|
|
||||||
/** Get the proctoring service settings for a certain exam to an error when happened.
|
// /** Get the proctoring service settings for a certain exam to an error when happened.
|
||||||
*
|
// *
|
||||||
* @param examId the exam instance
|
// * @param examId the exam instance
|
||||||
* @return Result refer to proctoring service settings for the exam. */
|
// * @return Result refer to proctoring service settings for the exam. */
|
||||||
default Result<ProctoringServiceSettings> getProctoringServiceSettings(final Exam exam) {
|
// default Result<ProctoringServiceSettings> getProctoringServiceSettings(final Exam exam) {
|
||||||
if (exam == null || exam.id == null) {
|
// if (exam == null || exam.id == null) {
|
||||||
return Result.ofRuntimeError("Invalid Exam model");
|
// return Result.ofRuntimeError("Invalid Exam model");
|
||||||
}
|
// }
|
||||||
return getProctoringServiceSettings(exam.id);
|
// return getProctoringServiceSettings(exam.id);
|
||||||
}
|
// }
|
||||||
|
|
||||||
/** Get proctoring service settings for a certain exam to an error when happened.
|
/** Get proctoring service settings for a certain exam to an error when happened.
|
||||||
*
|
*
|
||||||
|
@ -90,29 +90,38 @@ public interface ExamAdminService {
|
||||||
* @return ExamProctoringService instance */
|
* @return ExamProctoringService instance */
|
||||||
Result<ExamProctoringService> getExamProctoringService(final ProctoringServerType type);
|
Result<ExamProctoringService> getExamProctoringService(final ProctoringServerType type);
|
||||||
|
|
||||||
/** Get the exam proctoring service implementation of specified type.
|
|
||||||
*
|
|
||||||
* @param settings the ProctoringSettings that defines the ProctoringServerType
|
|
||||||
* @return ExamProctoringService instance */
|
|
||||||
default Result<ExamProctoringService> getExamProctoringService(final ProctoringServiceSettings settings) {
|
|
||||||
return Result.tryCatch(() -> getExamProctoringService(settings.serverType).getOrThrow());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the exam proctoring service implementation for specified exam.
|
|
||||||
*
|
|
||||||
* @param exam the exam instance
|
|
||||||
* @return ExamProctoringService instance */
|
|
||||||
default Result<ExamProctoringService> getExamProctoringService(final Exam exam) {
|
|
||||||
return Result.tryCatch(() -> getExamProctoringService(exam.id).getOrThrow());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the exam proctoring service implementation for specified exam.
|
/** Get the exam proctoring service implementation for specified exam.
|
||||||
*
|
*
|
||||||
* @param examId the exam identifier
|
* @param examId the exam identifier
|
||||||
* @return ExamProctoringService instance */
|
* @return ExamProctoringService instance */
|
||||||
default Result<ExamProctoringService> getExamProctoringService(final Long examId) {
|
default Result<ExamProctoringService> getExamProctoringService(final Long examId) {
|
||||||
return getProctoringServiceSettings(examId)
|
return getProctoringServiceSettings(examId)
|
||||||
.flatMap(this::getExamProctoringService);
|
.flatMap(settings -> getExamProctoringService(settings.serverType));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /** Get the exam proctoring service implementation of specified type.
|
||||||
|
// *
|
||||||
|
// * @param settings the ProctoringSettings that defines the ProctoringServerType
|
||||||
|
// * @return ExamProctoringService instance */
|
||||||
|
// default Result<ExamProctoringService> getExamProctoringService(final ProctoringServiceSettings settings) {
|
||||||
|
// return Result.tryCatch(() -> getExamProctoringService(settings.serverType).getOrThrow());
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// /** Get the exam proctoring service implementation for specified exam.
|
||||||
|
// *
|
||||||
|
// * @param exam the exam instance
|
||||||
|
// * @return ExamProctoringService instance */
|
||||||
|
// default Result<ExamProctoringService> getExamProctoringService(final Exam exam) {
|
||||||
|
// return Result.tryCatch(() -> getExamProctoringService(exam.id).getOrThrow());
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// /** Get the exam proctoring service implementation for specified exam.
|
||||||
|
// *
|
||||||
|
// * @param examId the exam identifier
|
||||||
|
// * @return ExamProctoringService instance */
|
||||||
|
// default Result<ExamProctoringService> getExamProctoringService(final Long examId) {
|
||||||
|
// return getProctoringServiceSettings(examId)
|
||||||
|
// .flatMap(this::getExamProctoringService);
|
||||||
|
// }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,6 @@ import java.util.Map;
|
||||||
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
import ch.ethz.seb.sebserver.gbl.model.exam.Exam;
|
|
||||||
import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringRoomConnection;
|
import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringRoomConnection;
|
||||||
import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringServiceSettings;
|
import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringServiceSettings;
|
||||||
import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringServiceSettings.ProctoringServerType;
|
import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringServiceSettings.ProctoringServerType;
|
||||||
|
@ -52,7 +51,7 @@ public interface ExamProctoringService {
|
||||||
|
|
||||||
Map<String, String> createJoinInstructionAttributes(ProctoringRoomConnection proctoringConnection);
|
Map<String, String> createJoinInstructionAttributes(ProctoringRoomConnection proctoringConnection);
|
||||||
|
|
||||||
Result<Void> disposeServiceRoomsForExam(ProctoringServiceSettings proctoringSettings, Exam exam);
|
Result<Void> disposeServiceRoomsForExam(Long examId, ProctoringServiceSettings proctoringSettings);
|
||||||
|
|
||||||
default String verifyRoomName(final String requestedRoomName, final String connectionToken) {
|
default String verifyRoomName(final String requestedRoomName, final String connectionToken) {
|
||||||
if (StringUtils.isNotBlank(requestedRoomName)) {
|
if (StringUtils.isNotBlank(requestedRoomName)) {
|
||||||
|
|
|
@ -115,14 +115,13 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService
|
||||||
|
|
||||||
return Result.tryCatch(() -> {
|
return Result.tryCatch(() -> {
|
||||||
|
|
||||||
final ProctoringServiceSettings settings = this.examSessionService
|
final ProctoringServiceSettings proctoringSettings = this.examAdminService
|
||||||
.getRunningExam(exam.id)
|
.getProctoringServiceSettings(exam.id)
|
||||||
.flatMap(this.examAdminService::getProctoringServiceSettings)
|
|
||||||
.getOrThrow();
|
.getOrThrow();
|
||||||
|
|
||||||
this.examAdminService
|
this.examAdminService
|
||||||
.getExamProctoringService(exam)
|
.getExamProctoringService(proctoringSettings.serverType)
|
||||||
.flatMap(service -> service.disposeServiceRoomsForExam(settings, exam))
|
.flatMap(service -> service.disposeServiceRoomsForExam(exam.id, proctoringSettings))
|
||||||
.onError(error -> log.error("Failed to dispose proctoring service rooms for exam: {} / {}",
|
.onError(error -> log.error("Failed to dispose proctoring service rooms for exam: {} / {}",
|
||||||
exam.name,
|
exam.name,
|
||||||
exam.externalId,
|
exam.externalId,
|
||||||
|
@ -143,9 +142,9 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.tryCatch(() -> {
|
return Result.tryCatch(() -> {
|
||||||
final ProctoringServiceSettings settings = this.examSessionService
|
|
||||||
.getRunningExam(examId)
|
final ProctoringServiceSettings settings = this.examAdminService
|
||||||
.flatMap(this.examAdminService::getProctoringServiceSettings)
|
.getProctoringServiceSettings(examId)
|
||||||
.getOrThrow();
|
.getOrThrow();
|
||||||
|
|
||||||
final ExamProctoringService examProctoringService = this.examAdminService
|
final ExamProctoringService examProctoringService = this.examAdminService
|
||||||
|
@ -182,9 +181,8 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService
|
||||||
|
|
||||||
return Result.tryCatch(() -> {
|
return Result.tryCatch(() -> {
|
||||||
|
|
||||||
final ProctoringServiceSettings settings = this.examSessionService
|
final ProctoringServiceSettings settings = this.examAdminService
|
||||||
.getRunningExam(examId)
|
.getProctoringServiceSettings(examId)
|
||||||
.flatMap(this.examAdminService::getProctoringServiceSettings)
|
|
||||||
.getOrThrow();
|
.getOrThrow();
|
||||||
|
|
||||||
final ExamProctoringService examProctoringService = this.examAdminService
|
final ExamProctoringService examProctoringService = this.examAdminService
|
||||||
|
@ -213,13 +211,12 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService
|
||||||
public Result<Void> closeProctoringRoom(final Long examId, final String roomName) {
|
public Result<Void> closeProctoringRoom(final Long examId, final String roomName) {
|
||||||
return Result.tryCatch(() -> {
|
return Result.tryCatch(() -> {
|
||||||
|
|
||||||
final ProctoringServiceSettings proctoringSettings = this.examSessionService
|
final ProctoringServiceSettings settings = this.examAdminService
|
||||||
.getRunningExam(examId)
|
.getProctoringServiceSettings(examId)
|
||||||
.flatMap(this.examAdminService::getProctoringServiceSettings)
|
|
||||||
.getOrThrow();
|
.getOrThrow();
|
||||||
|
|
||||||
final ExamProctoringService examProctoringService = this.examAdminService
|
final ExamProctoringService examProctoringService = this.examAdminService
|
||||||
.getExamProctoringService(proctoringSettings.serverType)
|
.getExamProctoringService(settings.serverType)
|
||||||
.getOrThrow();
|
.getOrThrow();
|
||||||
|
|
||||||
// Get room
|
// Get room
|
||||||
|
@ -228,9 +225,9 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService
|
||||||
.getOrThrow();
|
.getOrThrow();
|
||||||
|
|
||||||
if (!remoteProctoringRoom.breakOutConnections.isEmpty()) {
|
if (!remoteProctoringRoom.breakOutConnections.isEmpty()) {
|
||||||
closeBreakOutRoom(examId, proctoringSettings, examProctoringService, remoteProctoringRoom);
|
closeBreakOutRoom(examId, settings, examProctoringService, remoteProctoringRoom);
|
||||||
} else if (remoteProctoringRoom.townhallRoom) {
|
} else if (remoteProctoringRoom.townhallRoom) {
|
||||||
closeTownhall(examId, proctoringSettings, examProctoringService);
|
closeTownhall(examId, settings, examProctoringService);
|
||||||
} else {
|
} else {
|
||||||
closeCollectingRoom(examId, roomName, examProctoringService);
|
closeCollectingRoom(examId, roomName, examProctoringService);
|
||||||
}
|
}
|
||||||
|
@ -301,7 +298,7 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService
|
||||||
.getOrThrow();
|
.getOrThrow();
|
||||||
|
|
||||||
final ExamProctoringService examProctoringService = this.examAdminService
|
final ExamProctoringService examProctoringService = this.examAdminService
|
||||||
.getExamProctoringService(examId)
|
.getExamProctoringService(proctoringSettings.serverType)
|
||||||
.getOrThrow();
|
.getOrThrow();
|
||||||
|
|
||||||
return this.remoteProctoringRoomDAO.reservePlaceInCollectingRoom(
|
return this.remoteProctoringRoomDAO.reservePlaceInCollectingRoom(
|
||||||
|
@ -415,13 +412,12 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService
|
||||||
|
|
||||||
return Result.tryCatch(() -> {
|
return Result.tryCatch(() -> {
|
||||||
|
|
||||||
final ProctoringServiceSettings proctoringSettings = this.examSessionService
|
final ProctoringServiceSettings settings = this.examAdminService
|
||||||
.getRunningExam(examId)
|
.getProctoringServiceSettings(examId)
|
||||||
.flatMap(this.examAdminService::getProctoringServiceSettings)
|
|
||||||
.getOrThrow();
|
.getOrThrow();
|
||||||
|
|
||||||
final ExamProctoringService examProctoringService = this.examAdminService
|
final ExamProctoringService examProctoringService = this.examAdminService
|
||||||
.getExamProctoringService(proctoringSettings.serverType)
|
.getExamProctoringService(settings.serverType)
|
||||||
.getOrThrow();
|
.getOrThrow();
|
||||||
|
|
||||||
final RemoteProctoringRoom room = this.remoteProctoringRoomDAO
|
final RemoteProctoringRoom room = this.remoteProctoringRoomDAO
|
||||||
|
|
|
@ -165,9 +165,8 @@ public class JitsiProctoringService implements ExamProctoringService {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Result<Void> disposeServiceRoomsForExam(
|
public Result<Void> disposeServiceRoomsForExam(
|
||||||
final ProctoringServiceSettings proctoringSettings,
|
final Long examId,
|
||||||
final Exam exam) {
|
final ProctoringServiceSettings proctoringSettings) {
|
||||||
|
|
||||||
// NOTE: Since Jitsi rooms are generated and disposed automatically we don't need to do anything here
|
// NOTE: Since Jitsi rooms are generated and disposed automatically we don't need to do anything here
|
||||||
return Result.EMPTY;
|
return Result.EMPTY;
|
||||||
}
|
}
|
||||||
|
@ -329,6 +328,7 @@ public class JitsiProctoringService implements ExamProctoringService {
|
||||||
token,
|
token,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
clientName);
|
clientName);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,7 @@ import java.util.stream.Collectors;
|
||||||
|
|
||||||
import javax.crypto.Mac;
|
import javax.crypto.Mac;
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import javax.xml.bind.DatatypeConverter;
|
||||||
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
|
@ -49,7 +50,6 @@ import ch.ethz.seb.sebserver.gbl.api.JSONMapper;
|
||||||
import ch.ethz.seb.sebserver.gbl.async.AsyncService;
|
import ch.ethz.seb.sebserver.gbl.async.AsyncService;
|
||||||
import ch.ethz.seb.sebserver.gbl.async.CircuitBreaker;
|
import ch.ethz.seb.sebserver.gbl.async.CircuitBreaker;
|
||||||
import ch.ethz.seb.sebserver.gbl.client.ClientCredentials;
|
import ch.ethz.seb.sebserver.gbl.client.ClientCredentials;
|
||||||
import ch.ethz.seb.sebserver.gbl.model.exam.Exam;
|
|
||||||
import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringRoomConnection;
|
import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringRoomConnection;
|
||||||
import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringServiceSettings;
|
import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringServiceSettings;
|
||||||
import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringServiceSettings.ProctoringServerType;
|
import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringServiceSettings.ProctoringServerType;
|
||||||
|
@ -167,19 +167,17 @@ public class ZoomProctoringService implements ExamProctoringService {
|
||||||
throw new APIMessageException(
|
throw new APIMessageException(
|
||||||
APIMessage.ErrorMessage.BINDING_ERROR,
|
APIMessage.ErrorMessage.BINDING_ERROR,
|
||||||
String.valueOf(result.getStatusCode()));
|
String.valueOf(result.getStatusCode()));
|
||||||
|
} else {
|
||||||
|
// TODO this is just for cleaning up along development process.
|
||||||
|
// Remove this before finish up the Zoom integration
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
} catch (final Exception e) {
|
||||||
|
log.error("Failed to dev-cleanup rooms: ", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// else {
|
|
||||||
// final UserPageResponse response = this.jsonMapper.readValue(
|
|
||||||
// result.getBody(),
|
|
||||||
// UserPageResponse.class);
|
|
||||||
//
|
|
||||||
// System.out.println(response);
|
|
||||||
//
|
|
||||||
// final ResponseEntity<String> createUser = this.zoomRestTemplate
|
|
||||||
// .createUser(credentials, "TestRoom");
|
|
||||||
//
|
|
||||||
// System.out.println(response);
|
|
||||||
// }
|
|
||||||
} catch (final Exception e) {
|
} catch (final Exception e) {
|
||||||
log.error("Failed to access Zoom service at: {}", proctoringSettings.serverURL, e);
|
log.error("Failed to access Zoom service at: {}", proctoringSettings.serverURL, e);
|
||||||
throw new APIMessageException(APIMessage.ErrorMessage.BINDING_ERROR, e.getMessage());
|
throw new APIMessageException(APIMessage.ErrorMessage.BINDING_ERROR, e.getMessage());
|
||||||
|
@ -247,10 +245,13 @@ public class ZoomProctoringService implements ExamProctoringService {
|
||||||
|
|
||||||
final ClientCredentials credentials = new ClientCredentials(
|
final ClientCredentials credentials = new ClientCredentials(
|
||||||
proctoringSettings.appKey,
|
proctoringSettings.appKey,
|
||||||
this.cryptor.decrypt(proctoringSettings.appSecret),
|
proctoringSettings.appSecret,
|
||||||
this.cryptor.decrypt(remoteProctoringRoom.joinKey));
|
remoteProctoringRoom.joinKey);
|
||||||
|
|
||||||
final String jwt = this.createJWTForMeetingAccess(credentials, subject);
|
final String jwt = this.createJWTForMeetingAccess(
|
||||||
|
credentials,
|
||||||
|
String.valueOf(additionalZoomRoomData.meeting_id),
|
||||||
|
true);
|
||||||
|
|
||||||
return new ProctoringRoomConnection(
|
return new ProctoringRoomConnection(
|
||||||
ProctoringServerType.ZOOM,
|
ProctoringServerType.ZOOM,
|
||||||
|
@ -260,8 +261,9 @@ public class ZoomProctoringService implements ExamProctoringService {
|
||||||
roomName,
|
roomName,
|
||||||
subject,
|
subject,
|
||||||
jwt,
|
jwt,
|
||||||
credentials.accessToken,
|
this.cryptor.decrypt(credentials.accessToken),
|
||||||
credentials.clientId,
|
credentials.clientId,
|
||||||
|
String.valueOf(additionalZoomRoomData.meeting_id),
|
||||||
this.authorizationService.getUserService().getCurrentUser().getUsername());
|
this.authorizationService.getUserService().getCurrentUser().getUsername());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -285,10 +287,13 @@ public class ZoomProctoringService implements ExamProctoringService {
|
||||||
|
|
||||||
final ClientCredentials credentials = new ClientCredentials(
|
final ClientCredentials credentials = new ClientCredentials(
|
||||||
proctoringSettings.appKey,
|
proctoringSettings.appKey,
|
||||||
this.cryptor.decrypt(proctoringSettings.appSecret),
|
proctoringSettings.appSecret,
|
||||||
this.cryptor.decrypt(remoteProctoringRoom.joinKey));
|
remoteProctoringRoom.joinKey);
|
||||||
|
|
||||||
final String jwt = this.createJWTForMeetingAccess(credentials, subject);
|
final String jwt = this.createJWTForMeetingAccess(
|
||||||
|
credentials,
|
||||||
|
String.valueOf(additionalZoomRoomData.meeting_id),
|
||||||
|
false);
|
||||||
|
|
||||||
final ClientConnectionData clientConnection = this.examSessionService
|
final ClientConnectionData clientConnection = this.examSessionService
|
||||||
.getConnectionData(connectionToken)
|
.getConnectionData(connectionToken)
|
||||||
|
@ -302,20 +307,22 @@ public class ZoomProctoringService implements ExamProctoringService {
|
||||||
roomName,
|
roomName,
|
||||||
subject,
|
subject,
|
||||||
jwt,
|
jwt,
|
||||||
credentials.accessToken,
|
this.cryptor.decrypt(credentials.accessToken),
|
||||||
credentials.clientId,
|
credentials.clientId,
|
||||||
|
String.valueOf(additionalZoomRoomData.meeting_id),
|
||||||
clientConnection.clientConnection.userSessionId);
|
clientConnection.clientConnection.userSessionId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Result<Void> disposeServiceRoomsForExam(
|
public Result<Void> disposeServiceRoomsForExam(
|
||||||
final ProctoringServiceSettings proctoringSettings,
|
final Long examId,
|
||||||
final Exam exam) {
|
final ProctoringServiceSettings proctoringSettings) {
|
||||||
|
|
||||||
return Result.tryCatch(() -> {
|
return Result.tryCatch(() -> {
|
||||||
|
|
||||||
this.remoteProctoringRoomDAO
|
this.remoteProctoringRoomDAO
|
||||||
.getRooms(exam.id)
|
.getRooms(examId)
|
||||||
.getOrThrow()
|
.getOrThrow()
|
||||||
.stream()
|
.stream()
|
||||||
.forEach(room -> {
|
.forEach(room -> {
|
||||||
|
@ -361,9 +368,7 @@ public class ZoomProctoringService implements ExamProctoringService {
|
||||||
.getRoom(proctoringSettings.examId, roomName)
|
.getRoom(proctoringSettings.examId, roomName)
|
||||||
.getOrThrow();
|
.getOrThrow();
|
||||||
|
|
||||||
AdditionalZoomRoomData additionalZoomRoomData;
|
final AdditionalZoomRoomData additionalZoomRoomData = this.jsonMapper.readValue(
|
||||||
|
|
||||||
additionalZoomRoomData = this.jsonMapper.readValue(
|
|
||||||
roomData.getAdditionalRoomData(),
|
roomData.getAdditionalRoomData(),
|
||||||
AdditionalZoomRoomData.class);
|
AdditionalZoomRoomData.class);
|
||||||
|
|
||||||
|
@ -414,9 +419,9 @@ public class ZoomProctoringService implements ExamProctoringService {
|
||||||
UserResponse.class);
|
UserResponse.class);
|
||||||
|
|
||||||
// Then create new meeting with the ad-hoc user/host
|
// Then create new meeting with the ad-hoc user/host
|
||||||
final CharSequence meetingPwd = UUID.randomUUID().toString();
|
final CharSequence meetingPwd = UUID.randomUUID().toString().subSequence(0, 9);
|
||||||
final ResponseEntity<String> createMeeting = this.zoomRestTemplate.createMeeting(
|
final ResponseEntity<String> createMeeting = this.zoomRestTemplate.createMeeting(
|
||||||
roomName,
|
proctoringSettings.serverURL,
|
||||||
credentials,
|
credentials,
|
||||||
userResponse.id,
|
userResponse.id,
|
||||||
subject,
|
subject,
|
||||||
|
@ -429,6 +434,7 @@ public class ZoomProctoringService implements ExamProctoringService {
|
||||||
|
|
||||||
// Create NewRoom data with all needed information to store persistent
|
// Create NewRoom data with all needed information to store persistent
|
||||||
final AdditionalZoomRoomData additionalZoomRoomData = new AdditionalZoomRoomData(
|
final AdditionalZoomRoomData additionalZoomRoomData = new AdditionalZoomRoomData(
|
||||||
|
meetingResponse.id,
|
||||||
userResponse.id,
|
userResponse.id,
|
||||||
meetingResponse.start_url,
|
meetingResponse.start_url,
|
||||||
meetingResponse.join_url);
|
meetingResponse.join_url);
|
||||||
|
@ -438,7 +444,7 @@ public class ZoomProctoringService implements ExamProctoringService {
|
||||||
return new NewRoom(
|
return new NewRoom(
|
||||||
roomName,
|
roomName,
|
||||||
subject,
|
subject,
|
||||||
this.cryptor.encrypt(meetingPwd),
|
this.cryptor.encrypt(meetingResponse.meetingPwd),
|
||||||
additionalZoomRoomDataString);
|
additionalZoomRoomDataString);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -506,43 +512,63 @@ public class ZoomProctoringService implements ExamProctoringService {
|
||||||
|
|
||||||
private String createJWTForMeetingAccess(
|
private String createJWTForMeetingAccess(
|
||||||
final ClientCredentials credentials,
|
final ClientCredentials credentials,
|
||||||
final String subject) {
|
final String meetingId,
|
||||||
|
final boolean host) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final String apiKey = credentials.clientIdAsString();
|
||||||
final long iat = Utils.getMillisecondsNow() / 1000;
|
final int status = host ? 1 : 0;
|
||||||
final long exp = iat + 7200;
|
|
||||||
|
|
||||||
final CharSequence decryptedSecret = this.cryptor.decrypt(credentials.secret);
|
final CharSequence decryptedSecret = this.cryptor.decrypt(credentials.secret);
|
||||||
final StringBuilder builder = new StringBuilder();
|
|
||||||
final Encoder urlEncoder = Base64.getUrlEncoder().withoutPadding();
|
|
||||||
|
|
||||||
final String jwtHeaderPart = urlEncoder.encodeToString(
|
final Mac hasher = Mac.getInstance("HmacSHA256");
|
||||||
ZOOM_ACCESS_TOKEN_HEADER.getBytes(StandardCharsets.UTF_8));
|
final String ts = Long.toString(System.currentTimeMillis() - 30000);
|
||||||
final String jwtPayload = String.format(
|
final String msg = String.format("%s%s%s%d", apiKey, meetingId, ts, status);
|
||||||
ZOOM_MEETING_ACCESS_TOKEN_PAYLOAD.replaceAll(" ", "").replaceAll("\n", ""),
|
|
||||||
credentials.clientIdAsString(),
|
|
||||||
iat,
|
|
||||||
exp,
|
|
||||||
subject,
|
|
||||||
credentials.accessTokenAsString());
|
|
||||||
final String jwtPayloadPart = urlEncoder.encodeToString(
|
|
||||||
jwtPayload.getBytes(StandardCharsets.UTF_8));
|
|
||||||
final String message = jwtHeaderPart + "." + jwtPayloadPart;
|
|
||||||
|
|
||||||
final Mac sha256_HMAC = Mac.getInstance(TOKEN_ENCODE_ALG);
|
hasher.init(new SecretKeySpec(decryptedSecret.toString().getBytes(), "HmacSHA256"));
|
||||||
final SecretKeySpec secret_key = new SecretKeySpec(
|
|
||||||
Utils.toByteArray(decryptedSecret),
|
|
||||||
TOKEN_ENCODE_ALG);
|
|
||||||
sha256_HMAC.init(secret_key);
|
|
||||||
final String hash = urlEncoder.encodeToString(
|
|
||||||
sha256_HMAC.doFinal(Utils.toByteArray(message)));
|
|
||||||
|
|
||||||
builder.append(message)
|
final String message = Base64.getEncoder().encodeToString(msg.getBytes());
|
||||||
.append(".")
|
final byte[] hash = hasher.doFinal(message.getBytes());
|
||||||
.append(hash);
|
|
||||||
|
|
||||||
return builder.toString();
|
final String hashBase64Str = DatatypeConverter.printBase64Binary(hash);
|
||||||
|
final String tmpString = String.format("%s.%s.%s.%d.%s", apiKey, meetingId, ts, status, hashBase64Str);
|
||||||
|
final String encodedString = Base64.getEncoder().encodeToString(tmpString.getBytes());
|
||||||
|
|
||||||
|
return encodedString.replaceAll("\\=+$", "");
|
||||||
|
|
||||||
|
// final long iat = Utils.getMillisecondsNow() / 1000;
|
||||||
|
// final long exp = iat + 7200;
|
||||||
|
//
|
||||||
|
// final CharSequence decryptedSecret = this.cryptor.decrypt(credentials.secret);
|
||||||
|
// final CharSequence decryptedMeetingPWD = this.cryptor.decrypt(credentials.secret);
|
||||||
|
// final StringBuilder builder = new StringBuilder();
|
||||||
|
// final Encoder urlEncoder = Base64.getUrlEncoder().withoutPadding();
|
||||||
|
//
|
||||||
|
// final String jwtHeaderPart = urlEncoder.encodeToString(
|
||||||
|
// ZOOM_ACCESS_TOKEN_HEADER.getBytes(StandardCharsets.UTF_8));
|
||||||
|
// final String jwtPayload = String.format(
|
||||||
|
// ZOOM_MEETING_ACCESS_TOKEN_PAYLOAD.replaceAll(" ", "").replaceAll("\n", ""),
|
||||||
|
// credentials.clientIdAsString(),
|
||||||
|
// iat,
|
||||||
|
// exp,
|
||||||
|
// subject,
|
||||||
|
// decryptedMeetingPWD);
|
||||||
|
// final String jwtPayloadPart = urlEncoder.encodeToString(
|
||||||
|
// jwtPayload.getBytes(StandardCharsets.UTF_8));
|
||||||
|
// final String message = jwtHeaderPart + "." + jwtPayloadPart;
|
||||||
|
//
|
||||||
|
// final Mac sha256_HMAC = Mac.getInstance(TOKEN_ENCODE_ALG);
|
||||||
|
// final SecretKeySpec secret_key = new SecretKeySpec(
|
||||||
|
// Utils.toByteArray(decryptedSecret),
|
||||||
|
// TOKEN_ENCODE_ALG);
|
||||||
|
// sha256_HMAC.init(secret_key);
|
||||||
|
// final String hash = urlEncoder.encodeToString(
|
||||||
|
// sha256_HMAC.doFinal(Utils.toByteArray(message)));
|
||||||
|
//
|
||||||
|
// builder.append(message)
|
||||||
|
// .append(".")
|
||||||
|
// .append(hash);
|
||||||
|
//
|
||||||
|
// return builder.toString();
|
||||||
} catch (final Exception e) {
|
} catch (final Exception e) {
|
||||||
throw new RuntimeException("Failed to create JWT for Zoom meeting access: ", e);
|
throw new RuntimeException("Failed to create JWT for Zoom meeting access: ", e);
|
||||||
}
|
}
|
||||||
|
@ -554,7 +580,7 @@ public class ZoomProctoringService implements ExamProctoringService {
|
||||||
private static final String API_CREATE_USER_ENDPOINT = "v2/users";
|
private static final String API_CREATE_USER_ENDPOINT = "v2/users";
|
||||||
private static final String API_DELETE_USER_ENDPOINT = "v2/users/{userid}?action=delete";
|
private static final String API_DELETE_USER_ENDPOINT = "v2/users/{userid}?action=delete";
|
||||||
private static final String API_USER_CUST_CREATE = "custCreate";
|
private static final String API_USER_CUST_CREATE = "custCreate";
|
||||||
private static final String API_ZOOM_ROOM_USER = "ZoomRoomUser";
|
private static final String API_ZOOM_ROOM_USER = "SEBProctoringRoomUser";
|
||||||
private static final String API_CREATE_MEETING_ENDPOINT = "v2/users/{userid}/meetings";
|
private static final String API_CREATE_MEETING_ENDPOINT = "v2/users/{userid}/meetings";
|
||||||
private static final String API_DELETE_MEETING_ENDPOINT = "v2/meetings/{meetingid}";
|
private static final String API_DELETE_MEETING_ENDPOINT = "v2/meetings/{meetingid}";
|
||||||
|
|
||||||
|
@ -579,16 +605,23 @@ public class ZoomProctoringService implements ExamProctoringService {
|
||||||
final String zoomServerUrl,
|
final String zoomServerUrl,
|
||||||
final ClientCredentials credentials) {
|
final ClientCredentials credentials) {
|
||||||
|
|
||||||
final String url = UriComponentsBuilder
|
try {
|
||||||
.fromUriString(zoomServerUrl)
|
|
||||||
.path(API_TEST_ENDPOINT)
|
final String url = UriComponentsBuilder
|
||||||
.queryParam("status", "active")
|
.fromUriString(zoomServerUrl)
|
||||||
.queryParam("page_size", "10")
|
.path(API_TEST_ENDPOINT)
|
||||||
.queryParam("page_number", "1")
|
.queryParam("status", "active")
|
||||||
.queryParam("data_type", "Json")
|
.queryParam("page_size", "10")
|
||||||
.build()
|
.queryParam("page_number", "1")
|
||||||
.toUriString();
|
.queryParam("data_type", "Json")
|
||||||
return exchange(url, HttpMethod.GET, credentials);
|
.build()
|
||||||
|
.toUriString();
|
||||||
|
return exchange(url, HttpMethod.GET, credentials);
|
||||||
|
|
||||||
|
} catch (final Exception e) {
|
||||||
|
log.error("Failed to test zoom service connection: ", e);
|
||||||
|
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public ResponseEntity<String> createUser(
|
public ResponseEntity<String> createUser(
|
||||||
|
@ -617,7 +650,7 @@ public class ZoomProctoringService implements ExamProctoringService {
|
||||||
|
|
||||||
} catch (final Exception e) {
|
} catch (final Exception e) {
|
||||||
log.error("Failed to create Zoom ad-hoc user for room: {}", roomName, e);
|
log.error("Failed to create Zoom ad-hoc user for room: {}", roomName, e);
|
||||||
throw new RuntimeException("Failed to create Zoom ad-hoc user", e);
|
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -645,7 +678,7 @@ public class ZoomProctoringService implements ExamProctoringService {
|
||||||
|
|
||||||
} catch (final Exception e) {
|
} catch (final Exception e) {
|
||||||
log.error("Failed to create Zoom ad-hoc meeting: {}", topic, e);
|
log.error("Failed to create Zoom ad-hoc meeting: {}", topic, e);
|
||||||
throw new RuntimeException("Failed to create Zoom ad-hoc meeting", e);
|
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -666,7 +699,7 @@ public class ZoomProctoringService implements ExamProctoringService {
|
||||||
|
|
||||||
} catch (final Exception e) {
|
} catch (final Exception e) {
|
||||||
log.error("Failed to delete Zoom ad-hoc meeting: {}", meetingId, e);
|
log.error("Failed to delete Zoom ad-hoc meeting: {}", meetingId, e);
|
||||||
throw new RuntimeException("Failed to delete Zoom ad-hoc meeting", e);
|
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -687,7 +720,7 @@ public class ZoomProctoringService implements ExamProctoringService {
|
||||||
|
|
||||||
} catch (final Exception e) {
|
} catch (final Exception e) {
|
||||||
log.error("Failed to delete Zoom ad-hoc user with id: {}", userId, e);
|
log.error("Failed to delete Zoom ad-hoc user with id: {}", userId, e);
|
||||||
throw new RuntimeException("Failed to delete Zoom ad-hoc user", e);
|
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -728,9 +761,8 @@ public class ZoomProctoringService implements ExamProctoringService {
|
||||||
httpEntity,
|
httpEntity,
|
||||||
String.class);
|
String.class);
|
||||||
|
|
||||||
if (result.getStatusCode() != HttpStatus.OK) {
|
if (result.getStatusCode() != HttpStatus.OK && result.getStatusCode() != HttpStatus.CREATED) {
|
||||||
log.warn("Zoom API call to {} respond not 200 -> {}", url, result.getStatusCode());
|
log.warn("Error response on Zoom API call to {} response status: {}", url, result.getStatusCode());
|
||||||
throw new RuntimeException("Error Response: " + result.getStatusCode());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
@ -743,6 +775,8 @@ public class ZoomProctoringService implements ExamProctoringService {
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public static final class AdditionalZoomRoomData {
|
public static final class AdditionalZoomRoomData {
|
||||||
|
|
||||||
|
@JsonProperty("meeting_id")
|
||||||
|
private final Long meeting_id;
|
||||||
@JsonProperty("user_id")
|
@JsonProperty("user_id")
|
||||||
public final String user_id;
|
public final String user_id;
|
||||||
@JsonProperty("start_url")
|
@JsonProperty("start_url")
|
||||||
|
@ -752,15 +786,16 @@ public class ZoomProctoringService implements ExamProctoringService {
|
||||||
|
|
||||||
@JsonCreator
|
@JsonCreator
|
||||||
public AdditionalZoomRoomData(
|
public AdditionalZoomRoomData(
|
||||||
|
@JsonProperty("meeting_id") final Long meeting_id,
|
||||||
@JsonProperty("user_id") final String user_id,
|
@JsonProperty("user_id") final String user_id,
|
||||||
@JsonProperty("start_url") final String start_url,
|
@JsonProperty("start_url") final String start_url,
|
||||||
@JsonProperty("join_url") final String join_url) {
|
@JsonProperty("join_url") final String join_url) {
|
||||||
|
|
||||||
|
this.meeting_id = meeting_id;
|
||||||
this.user_id = user_id;
|
this.user_id = user_id;
|
||||||
this.start_url = start_url;
|
this.start_url = start_url;
|
||||||
this.join_url = join_url;
|
this.join_url = join_url;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -77,12 +77,12 @@ public interface ZoomRoomRequestResponse {
|
||||||
@JsonProperty final String email;
|
@JsonProperty final String email;
|
||||||
@JsonProperty final int type;
|
@JsonProperty final int type;
|
||||||
@JsonProperty final String first_name;
|
@JsonProperty final String first_name;
|
||||||
@JsonProperty final String lasr_name;
|
@JsonProperty final String last_name;
|
||||||
public UserInfo(final String email, final int type, final String first_name, final String lasr_name) {
|
public UserInfo(final String email, final int type, final String first_name, final String last_name) {
|
||||||
this.email = email;
|
this.email = email;
|
||||||
this.type = type;
|
this.type = type;
|
||||||
this.first_name = first_name;
|
this.first_name = first_name;
|
||||||
this.lasr_name = lasr_name;
|
this.last_name = last_name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -113,8 +113,9 @@ public interface ZoomRoomRequestResponse {
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
static class CreateMeetingRequest {
|
static class CreateMeetingRequest {
|
||||||
@JsonProperty final String topic;
|
@JsonProperty final String topic;
|
||||||
@JsonProperty final int type = 1; // Instant meeting
|
@JsonProperty final int type = 2; // Scheduled Meeting
|
||||||
@JsonProperty final String start_time = DateTime.now(DateTimeZone.UTC).toString("yyyy-MM-dd`T`HH:mm:ssZ");
|
@JsonProperty final String start_time = DateTime.now(DateTimeZone.UTC).toString("yyyy-MM-dd'T'HH:mm:ss");
|
||||||
|
@JsonProperty final int duration = 60;
|
||||||
@JsonProperty final CharSequence password;
|
@JsonProperty final CharSequence password;
|
||||||
@JsonProperty final Settings settings;
|
@JsonProperty final Settings settings;
|
||||||
|
|
||||||
|
@ -146,6 +147,8 @@ public interface ZoomRoomRequestResponse {
|
||||||
final String status;
|
final String status;
|
||||||
final String uuid;
|
final String uuid;
|
||||||
final String host_id;
|
final String host_id;
|
||||||
|
final CharSequence meetingPwd;
|
||||||
|
final CharSequence encryptedPwd;
|
||||||
|
|
||||||
@JsonCreator
|
@JsonCreator
|
||||||
public MeetingResponse(
|
public MeetingResponse(
|
||||||
|
@ -156,7 +159,10 @@ public interface ZoomRoomRequestResponse {
|
||||||
@JsonProperty("duration") final Integer duration,
|
@JsonProperty("duration") final Integer duration,
|
||||||
@JsonProperty("status") final String status,
|
@JsonProperty("status") final String status,
|
||||||
@JsonProperty("uuid") final String uuid,
|
@JsonProperty("uuid") final String uuid,
|
||||||
@JsonProperty("host_id") final String host_id) {
|
@JsonProperty("host_id") final String host_id,
|
||||||
|
@JsonProperty("password") final CharSequence meetingPwd,
|
||||||
|
@JsonProperty("encrypted_password") final CharSequence encryptedPwd) {
|
||||||
|
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.join_url = join_url;
|
this.join_url = join_url;
|
||||||
this.start_url = start_url;
|
this.start_url = start_url;
|
||||||
|
@ -165,6 +171,8 @@ public interface ZoomRoomRequestResponse {
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.uuid = uuid;
|
this.uuid = uuid;
|
||||||
this.host_id = host_id;
|
this.host_id = host_id;
|
||||||
|
this.meetingPwd = meetingPwd;
|
||||||
|
this.encryptedPwd = encryptedPwd;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -396,7 +396,7 @@ public class ExamAdministrationController extends EntityController<Exam, Exam> {
|
||||||
checkReadPrivilege(institutionId);
|
checkReadPrivilege(institutionId);
|
||||||
return this.entityDAO.byPK(modelId)
|
return this.entityDAO.byPK(modelId)
|
||||||
.flatMap(this.authorization::checkRead)
|
.flatMap(this.authorization::checkRead)
|
||||||
.flatMap(this.examAdminService::getProctoringServiceSettings)
|
.flatMap(exam -> this.examAdminService.getProctoringServiceSettings(exam.id))
|
||||||
.getOrThrow();
|
.getOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -111,7 +111,7 @@ public class ExamProctoringController {
|
||||||
checkAccess(institutionId, examId);
|
checkAccess(institutionId, examId);
|
||||||
return this.examSessionService.getRunningExam(examId)
|
return this.examSessionService.getRunningExam(examId)
|
||||||
.flatMap(this.authorization::checkRead)
|
.flatMap(this.authorization::checkRead)
|
||||||
.flatMap(this.examAdminService::getExamProctoringService)
|
.flatMap(exam -> this.examAdminService.getExamProctoringService(exam.id))
|
||||||
.flatMap(service -> service.getProctorRoomConnection(
|
.flatMap(service -> service.getProctorRoomConnection(
|
||||||
this.examAdminService.getProctoringServiceSettings(examId).getOrThrow(),
|
this.examAdminService.getProctoringServiceSettings(examId).getOrThrow(),
|
||||||
roomName,
|
roomName,
|
||||||
|
|
|
@ -36,7 +36,7 @@ logging.level.ch=INFO
|
||||||
|
|
||||||
### spring actuator configuration
|
### spring actuator configuration
|
||||||
management.endpoints.web.base-path=/mprofile
|
management.endpoints.web.base-path=/mprofile
|
||||||
management.endpoints.web.exposure.include=metrics,logfile,loggers,heapdump
|
management.endpoints.web.exposure.include=metrics,logfile,loggers,heapdump,health
|
||||||
|
|
||||||
##########################################################
|
##########################################################
|
||||||
### Overall Security Settings
|
### Overall Security Settings
|
||||||
|
|
36
src/test/java/ch/ethz/seb/sebserver/gbl/util/ReplTest.java
Normal file
36
src/test/java/ch/ethz/seb/sebserver/gbl/util/ReplTest.java
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2021 ETH Zürich, Educational Development and Technology (LET)
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package ch.ethz.seb.sebserver.gbl.util;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import org.joda.time.DateTime;
|
||||||
|
import org.joda.time.DateTimeZone;
|
||||||
|
import org.junit.Ignore;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
public class ReplTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Ignore
|
||||||
|
public void testDateFormatting() {
|
||||||
|
final String datestring = DateTime.now(DateTimeZone.UTC).toString("yyyy-MM-dd'T'HH:mm:ss");
|
||||||
|
assertEquals("", datestring);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Ignore
|
||||||
|
public void testGenPwd() {
|
||||||
|
final CharSequence meetingPwd = UUID.randomUUID().toString().subSequence(0, 9);
|
||||||
|
assertEquals("", meetingPwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in a new issue