Merge remote-tracking branch 'upstream/develop' into connection-manager
This commit is contained in:
commit
be8421db3c
@ -37,6 +37,7 @@ trait SystemConfiguration {
|
||||
lazy val fromAkkaAppsJsonChannel = Try(config.getString("eventBus.fromAkkaAppsChannel")).getOrElse("from-akka-apps-json-channel")
|
||||
|
||||
lazy val applyPermissionCheck = Try(config.getBoolean("apps.checkPermissions")).getOrElse(false)
|
||||
lazy val ejectOnViolation = Try(config.getBoolean("apps.ejectOnViolation")).getOrElse(false)
|
||||
|
||||
lazy val voiceConfRecordPath = Try(config.getString("voiceConf.recordPath")).getOrElse("/var/freeswitch/meetings")
|
||||
lazy val voiceConfRecordCodec = Try(config.getString("voiceConf.recordCodec")).getOrElse("wav")
|
||||
|
@ -37,7 +37,7 @@ trait RightsManagementTrait extends SystemConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
object PermissionCheck {
|
||||
object PermissionCheck extends SystemConfiguration {
|
||||
|
||||
val MOD_LEVEL = 100
|
||||
val AUTHED_LEVEL = 50
|
||||
@ -83,12 +83,17 @@ object PermissionCheck {
|
||||
|
||||
def ejectUserForFailedPermission(meetingId: String, userId: String, reason: String,
|
||||
outGW: OutMsgRouter, liveMeeting: LiveMeeting): Unit = {
|
||||
val ejectedBy = SystemUser.ID
|
||||
if (ejectOnViolation) {
|
||||
val ejectedBy = SystemUser.ID
|
||||
|
||||
UsersApp.ejectUserFromMeeting(outGW, liveMeeting, userId, ejectedBy, reason, EjectReasonCode.PERMISSION_FAILED, ban = false)
|
||||
UsersApp.ejectUserFromMeeting(outGW, liveMeeting, userId, ejectedBy, reason, EjectReasonCode.PERMISSION_FAILED, ban = false)
|
||||
|
||||
// send a system message to force disconnection
|
||||
Sender.sendDisconnectClientSysMsg(meetingId, userId, ejectedBy, reason, outGW)
|
||||
// send a system message to force disconnection
|
||||
Sender.sendDisconnectClientSysMsg(meetingId, userId, ejectedBy, reason, outGW)
|
||||
} else {
|
||||
// TODO: get this object a context so it can use the akka logging system
|
||||
println(s"Skipping violation ejection of ${userId} trying to ${reason} in ${meetingId}")
|
||||
}
|
||||
}
|
||||
|
||||
def addOldPresenter(users: Users2x, userId: String): OldPresenter = {
|
||||
|
@ -71,6 +71,7 @@ services {
|
||||
|
||||
apps {
|
||||
checkPermissions = true
|
||||
ejectOnViolation = false
|
||||
endMeetingWhenNoMoreAuthedUsers = false
|
||||
endMeetingWhenNoMoreAuthedUsersAfterMinutes = 2
|
||||
}
|
||||
|
@ -82,8 +82,9 @@ import org.bigbluebutton.api2.IBbbWebApiGWApp;
|
||||
import org.bigbluebutton.api2.domain.UploadedTrack;
|
||||
import org.bigbluebutton.common2.redis.RedisStorageService;
|
||||
import org.bigbluebutton.presentation.PresentationUrlDownloadService;
|
||||
import org.bigbluebutton.web.services.RegisteredUserCleanupTimerTask;
|
||||
import org.bigbluebutton.web.services.WaitingGuestCleanupTimerTask;
|
||||
import org.bigbluebutton.web.services.UserCleanupTimerTask;
|
||||
import org.bigbluebutton.web.services.EnteredUserCleanupTimerTask;
|
||||
import org.bigbluebutton.web.services.callback.CallbackUrlService;
|
||||
import org.bigbluebutton.web.services.callback.MeetingEndedEvent;
|
||||
import org.bigbluebutton.web.services.turn.StunTurnService;
|
||||
@ -108,13 +109,17 @@ public class MeetingService implements MessageListener {
|
||||
private final ConcurrentMap<String, UserSession> sessions;
|
||||
|
||||
private RecordingService recordingService;
|
||||
private RegisteredUserCleanupTimerTask registeredUserCleaner;
|
||||
private WaitingGuestCleanupTimerTask waitingGuestCleaner;
|
||||
private UserCleanupTimerTask userCleaner;
|
||||
private EnteredUserCleanupTimerTask enteredUserCleaner;
|
||||
private StunTurnService stunTurnService;
|
||||
private RedisStorageService storeService;
|
||||
private CallbackUrlService callbackUrlService;
|
||||
private boolean keepEvents;
|
||||
|
||||
private long usersTimeout;
|
||||
private long enteredUsersTimeout;
|
||||
|
||||
private ParamsProcessorUtil paramsProcessorUtil;
|
||||
private PresentationUrlDownloadService presDownloadService;
|
||||
|
||||
@ -181,22 +186,63 @@ public class MeetingService implements MessageListener {
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove registered users who did not successfully joined the meeting.
|
||||
* Remove users who did not successfully reconnected to the meeting.
|
||||
*/
|
||||
public void purgeRegisteredUsers() {
|
||||
public void purgeUsers() {
|
||||
for (AbstractMap.Entry<String, Meeting> entry : this.meetings.entrySet()) {
|
||||
Long now = System.currentTimeMillis();
|
||||
Meeting meeting = entry.getValue();
|
||||
|
||||
ConcurrentMap<String, User> users = meeting.getUsersMap();
|
||||
for (AbstractMap.Entry<String, User> userEntry : meeting.getUsersMap().entrySet()) {
|
||||
String userId = userEntry.getKey();
|
||||
User user = userEntry.getValue();
|
||||
|
||||
for (AbstractMap.Entry<String, RegisteredUser> registeredUser : meeting.getRegisteredUsers().entrySet()) {
|
||||
String registeredUserID = registeredUser.getKey();
|
||||
RegisteredUser registeredUserDate = registeredUser.getValue();
|
||||
if (!user.hasLeft()) continue;
|
||||
|
||||
long elapsedTime = now - registeredUserDate.registeredOn;
|
||||
if (elapsedTime >= 60000 && !users.containsKey(registeredUserID)) {
|
||||
meeting.userUnregistered(registeredUserID);
|
||||
long elapsedTime = now - user.getLeftOn();
|
||||
if (elapsedTime >= usersTimeout) {
|
||||
meeting.removeUser(userId);
|
||||
|
||||
Map<String, Object> logData = new HashMap<>();
|
||||
logData.put("meetingId", meeting.getInternalId());
|
||||
logData.put("userId", userId);
|
||||
logData.put("logCode", "removed_user");
|
||||
logData.put("description", "User left and was removed from the meeting.");
|
||||
|
||||
Gson gson = new Gson();
|
||||
String logStr = gson.toJson(logData);
|
||||
|
||||
log.info(" --analytics-- data={}", logStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove entered users who did not join.
|
||||
*/
|
||||
public void purgeEnteredUsers() {
|
||||
for (AbstractMap.Entry<String, Meeting> entry : this.meetings.entrySet()) {
|
||||
Long now = System.currentTimeMillis();
|
||||
Meeting meeting = entry.getValue();
|
||||
|
||||
for (AbstractMap.Entry<String, Long> enteredUser : meeting.getEnteredUsers().entrySet()) {
|
||||
String userId = enteredUser.getKey();
|
||||
|
||||
long elapsedTime = now - enteredUser.getValue();
|
||||
if (elapsedTime >= enteredUsersTimeout) {
|
||||
meeting.removeEnteredUser(userId);
|
||||
|
||||
Map<String, Object> logData = new HashMap<>();
|
||||
logData.put("meetingId", meeting.getInternalId());
|
||||
logData.put("userId", userId);
|
||||
logData.put("logCode", "purged_entered_user");
|
||||
logData.put("description", "Purged user that called ENTER from the API but never joined");
|
||||
|
||||
Gson gson = new Gson();
|
||||
String logStr = gson.toJson(logData);
|
||||
|
||||
log.info(" --analytics-- data={}", logStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -879,13 +925,6 @@ public class MeetingService implements MessageListener {
|
||||
// the meeting ended.
|
||||
m.setEndTime(System.currentTimeMillis());
|
||||
}
|
||||
|
||||
RegisteredUser userRegistered = m.userUnregistered(message.userId);
|
||||
if (userRegistered != null) {
|
||||
log.info("User unregistered from meeting");
|
||||
} else {
|
||||
log.info("User was not unregistered from meeting because it was not found");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1098,8 +1137,9 @@ public class MeetingService implements MessageListener {
|
||||
|
||||
public void stop() {
|
||||
processMessage = false;
|
||||
registeredUserCleaner.stop();
|
||||
waitingGuestCleaner.stop();
|
||||
userCleaner.stop();
|
||||
enteredUserCleaner.stop();
|
||||
}
|
||||
|
||||
public void setRecordingService(RecordingService s) {
|
||||
@ -1124,11 +1164,16 @@ public class MeetingService implements MessageListener {
|
||||
waitingGuestCleaner.start();
|
||||
}
|
||||
|
||||
public void setRegisteredUserCleanupTimerTask(
|
||||
RegisteredUserCleanupTimerTask c) {
|
||||
registeredUserCleaner = c;
|
||||
registeredUserCleaner.setMeetingService(this);
|
||||
registeredUserCleaner.start();
|
||||
public void setEnteredUserCleanupTimerTask(EnteredUserCleanupTimerTask c) {
|
||||
enteredUserCleaner = c;
|
||||
enteredUserCleaner.setMeetingService(this);
|
||||
enteredUserCleaner.start();
|
||||
}
|
||||
|
||||
public void setUserCleanupTimerTask(UserCleanupTimerTask c) {
|
||||
userCleaner = c;
|
||||
userCleaner.setMeetingService(this);
|
||||
userCleaner.start();
|
||||
}
|
||||
|
||||
public void setStunTurnService(StunTurnService s) {
|
||||
@ -1138,4 +1183,12 @@ public class MeetingService implements MessageListener {
|
||||
public void setKeepEvents(boolean value) {
|
||||
keepEvents = value;
|
||||
}
|
||||
|
||||
public void setUsersTimeout(long value) {
|
||||
usersTimeout = value;
|
||||
}
|
||||
|
||||
public void setEnteredUsersTimeout(long value) {
|
||||
enteredUsersTimeout = value;
|
||||
}
|
||||
}
|
||||
|
@ -73,6 +73,7 @@ public class Meeting {
|
||||
private Map<String, Object> userCustomData;
|
||||
private final ConcurrentMap<String, User> users;
|
||||
private final ConcurrentMap<String, RegisteredUser> registeredUsers;
|
||||
private final ConcurrentMap<String, Long> enteredUsers;
|
||||
private final ConcurrentMap<String, Config> configs;
|
||||
private final Boolean isBreakout;
|
||||
private final List<String> breakoutRooms = new ArrayList<>();
|
||||
@ -132,6 +133,7 @@ public class Meeting {
|
||||
|
||||
users = new ConcurrentHashMap<>();
|
||||
registeredUsers = new ConcurrentHashMap<>();
|
||||
enteredUsers = new ConcurrentHashMap<>();;
|
||||
|
||||
configs = new ConcurrentHashMap<>();
|
||||
}
|
||||
@ -454,12 +456,28 @@ public class Meeting {
|
||||
}
|
||||
|
||||
public void userJoined(User user) {
|
||||
userHasJoined = true;
|
||||
this.users.put(user.getInternalUserId(), user);
|
||||
User u = getUserById(user.getInternalUserId());
|
||||
if (u != null) {
|
||||
u.joined();
|
||||
} else {
|
||||
if (!userHasJoined) userHasJoined = true;
|
||||
this.users.put(user.getInternalUserId(), user);
|
||||
// Clean this user up from the entered user's list
|
||||
removeEnteredUser(user.getInternalUserId());
|
||||
}
|
||||
}
|
||||
|
||||
public User userLeft(String userid){
|
||||
return users.remove(userid);
|
||||
public User userLeft(String userId) {
|
||||
User user = getUserById(userId);
|
||||
if (user != null) {
|
||||
user.left();
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
public User removeUser(String userId) {
|
||||
return this.users.remove(userId);
|
||||
}
|
||||
|
||||
public User getUserById(String id){
|
||||
@ -593,6 +611,29 @@ public class Meeting {
|
||||
return registeredUsers;
|
||||
}
|
||||
|
||||
public ConcurrentMap<String, Long> getEnteredUsers() {
|
||||
return this.enteredUsers;
|
||||
}
|
||||
|
||||
public void userEntered(String userId) {
|
||||
// Skip if user already joined
|
||||
User u = getUserById(userId);
|
||||
if (u != null) return;
|
||||
|
||||
if (!enteredUsers.containsKey(userId)) {
|
||||
Long time = System.currentTimeMillis();
|
||||
this.enteredUsers.put(userId, time);
|
||||
}
|
||||
}
|
||||
|
||||
public Long removeEnteredUser(String userId) {
|
||||
return this.enteredUsers.remove(userId);
|
||||
}
|
||||
|
||||
public Long getEnteredUserById(String userId) {
|
||||
return this.enteredUsers.get(userId);
|
||||
}
|
||||
|
||||
/***
|
||||
* Meeting Builder
|
||||
*
|
||||
|
@ -38,6 +38,7 @@ public class User {
|
||||
private Boolean voiceJoined = false;
|
||||
private String clientType;
|
||||
private List<String> streams;
|
||||
private Long leftOn = null;
|
||||
|
||||
public User(String internalUserId,
|
||||
String externalUserId,
|
||||
@ -90,6 +91,22 @@ public class User {
|
||||
return this.guestStatus;
|
||||
}
|
||||
|
||||
public Boolean hasLeft() {
|
||||
return leftOn != null;
|
||||
}
|
||||
|
||||
public void joined() {
|
||||
this.leftOn = null;
|
||||
}
|
||||
|
||||
public void left() {
|
||||
this.leftOn = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
public Long getLeftOn() {
|
||||
return this.leftOn;
|
||||
}
|
||||
|
||||
public String getFullname() {
|
||||
return fullname;
|
||||
}
|
||||
|
@ -1,231 +1,224 @@
|
||||
/**
|
||||
* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
|
||||
*
|
||||
* Copyright (c) 2012 BigBlueButton Inc. and by respective authors (see below).
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the
|
||||
* terms of the GNU Lesser General Public License as published by the Free Software
|
||||
* Foundation; either version 3.0 of the License, or (at your option) any later
|
||||
* version.
|
||||
*
|
||||
* BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||||
* PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License along
|
||||
* with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.bigbluebutton.presentation.imp;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.bigbluebutton.presentation.ConversionMessageConstants;
|
||||
import org.bigbluebutton.presentation.SupportedFileTypes;
|
||||
import org.bigbluebutton.presentation.UploadedPresentation;
|
||||
import org.jodconverter.core.office.OfficeException;
|
||||
import org.jodconverter.core.office.OfficeUtils;
|
||||
import org.jodconverter.local.LocalConverter;
|
||||
import org.jodconverter.local.office.ExternalOfficeManager;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
|
||||
public class OfficeToPdfConversionService {
|
||||
private static Logger log = LoggerFactory.getLogger(OfficeToPdfConversionService.class);
|
||||
|
||||
private OfficeDocumentValidator2 officeDocumentValidator;
|
||||
private final ArrayList<ExternalOfficeManager> officeManagers;
|
||||
private ExternalOfficeManager currentManager = null;
|
||||
private boolean skipOfficePrecheck = false;
|
||||
private int sofficeBasePort = 0;
|
||||
private int sofficeManagers = 0;
|
||||
private String sofficeWorkingDirBase = null;
|
||||
|
||||
public OfficeToPdfConversionService() throws OfficeException {
|
||||
officeManagers = new ArrayList<>();
|
||||
}
|
||||
|
||||
/*
|
||||
* Convert the Office document to PDF. If successful, update
|
||||
* UploadPresentation.uploadedFile with the new PDF out and
|
||||
* UploadPresentation.lastStepSuccessful to TRUE.
|
||||
*/
|
||||
public UploadedPresentation convertOfficeToPdf(UploadedPresentation pres) {
|
||||
initialize(pres);
|
||||
if (SupportedFileTypes.isOfficeFile(pres.getFileType())) {
|
||||
// Check if we need to precheck office document
|
||||
if (!skipOfficePrecheck && officeDocumentValidator.isValid(pres)) {
|
||||
Map<String, Object> logData = new HashMap<>();
|
||||
logData.put("meetingId", pres.getMeetingId());
|
||||
logData.put("presId", pres.getId());
|
||||
logData.put("filename", pres.getName());
|
||||
logData.put("logCode", "problems_office_to_pdf_validation");
|
||||
logData.put("message", "Problems detected prior to converting the file to PDF.");
|
||||
Gson gson = new Gson();
|
||||
String logStr = gson.toJson(logData);
|
||||
log.warn(" --analytics-- data={}", logStr);
|
||||
|
||||
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_INVALID_KEY);
|
||||
return pres;
|
||||
}
|
||||
File pdfOutput = setupOutputPdfFile(pres);
|
||||
if (convertOfficeDocToPdf(pres, pdfOutput)) {
|
||||
Map<String, Object> logData = new HashMap<>();
|
||||
logData.put("meetingId", pres.getMeetingId());
|
||||
logData.put("presId", pres.getId());
|
||||
logData.put("filename", pres.getName());
|
||||
logData.put("logCode", "office_to_pdf_success");
|
||||
logData.put("message", "Successfully converted office file to pdf.");
|
||||
Gson gson = new Gson();
|
||||
String logStr = gson.toJson(logData);
|
||||
log.info(" --analytics-- data={}", logStr);
|
||||
|
||||
makePdfTheUploadedFileAndSetStepAsSuccess(pres, pdfOutput);
|
||||
} else {
|
||||
Map<String, Object> logData = new HashMap<>();
|
||||
logData.put("meetingId", pres.getMeetingId());
|
||||
logData.put("presId", pres.getId());
|
||||
logData.put("filename", pres.getName());
|
||||
logData.put("logCode", "office_to_pdf_failed");
|
||||
logData.put("message", "Failed to convert " + pres.getUploadedFile().getAbsolutePath() + " to Pdf.");
|
||||
Gson gson = new Gson();
|
||||
String logStr = gson.toJson(logData);
|
||||
log.warn(" --analytics-- data={}", logStr);
|
||||
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_FAILED_KEY);
|
||||
return pres;
|
||||
}
|
||||
}
|
||||
return pres;
|
||||
}
|
||||
|
||||
public void initialize(UploadedPresentation pres) {
|
||||
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_FAILED_KEY);
|
||||
}
|
||||
|
||||
private File setupOutputPdfFile(UploadedPresentation pres) {
|
||||
File presentationFile = pres.getUploadedFile();
|
||||
String filenameWithoutExt = presentationFile.getAbsolutePath().substring(0,
|
||||
presentationFile.getAbsolutePath().lastIndexOf('.'));
|
||||
return new File(filenameWithoutExt + ".pdf");
|
||||
}
|
||||
|
||||
private boolean convertOfficeDocToPdf(UploadedPresentation pres,
|
||||
File pdfOutput) {
|
||||
boolean success = false;
|
||||
int attempts = 0;
|
||||
|
||||
while(!success) {
|
||||
LocalConverter documentConverter = LocalConverter
|
||||
.builder()
|
||||
.officeManager(currentManager)
|
||||
.filterChain(new OfficeDocumentConversionFilter())
|
||||
.build();
|
||||
|
||||
success = Office2PdfPageConverter.convert(pres.getUploadedFile(), pdfOutput, 0, pres, documentConverter);
|
||||
|
||||
if(!success) {
|
||||
// In case of failure, try with other open Office Manager
|
||||
|
||||
if(++attempts != officeManagers.size()) {
|
||||
// Go to next Office Manager ( if the last retry with the first one )
|
||||
int currentManagerIndex = officeManagers.indexOf(currentManager);
|
||||
|
||||
boolean isLastManager = ( currentManagerIndex == officeManagers.size()-1 );
|
||||
if(isLastManager) {
|
||||
currentManager = officeManagers.get(0);
|
||||
} else {
|
||||
currentManager = officeManagers.get(currentManagerIndex+1);
|
||||
}
|
||||
} else {
|
||||
// We tried to use all our office managers and it's still failing
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
private void makePdfTheUploadedFileAndSetStepAsSuccess(UploadedPresentation pres, File pdf) {
|
||||
pres.setUploadedFile(pdf);
|
||||
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_SUCCESS_KEY);
|
||||
}
|
||||
|
||||
public void setOfficeDocumentValidator(OfficeDocumentValidator2 v) {
|
||||
officeDocumentValidator = v;
|
||||
}
|
||||
|
||||
public void setSkipOfficePrecheck(boolean skipOfficePrecheck) {
|
||||
this.skipOfficePrecheck = skipOfficePrecheck;
|
||||
}
|
||||
|
||||
public void setSofficeBasePort(int sofficeBasePort) {
|
||||
this.sofficeBasePort = sofficeBasePort;
|
||||
}
|
||||
|
||||
public void setSofficeManagers(int sofficeServiceManagers) {
|
||||
this.sofficeManagers = sofficeServiceManagers;
|
||||
}
|
||||
|
||||
public void setSofficeWorkingDirBase(String sofficeWorkingDirBase) {
|
||||
this.sofficeWorkingDirBase = sofficeWorkingDirBase;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
log.info("Starting LibreOffice pool with " + sofficeManagers + " managers, starting from port " + sofficeBasePort);
|
||||
|
||||
for(int managerIndex = 0; managerIndex < sofficeManagers; managerIndex ++) {
|
||||
Integer instanceNumber = managerIndex + 1; // starts at 1
|
||||
|
||||
try {
|
||||
final File workingDir = new File(sofficeWorkingDirBase + String.format("%02d", instanceNumber));
|
||||
|
||||
if(!workingDir.exists()) {
|
||||
workingDir.mkdir();
|
||||
}
|
||||
|
||||
ExternalOfficeManager officeManager = ExternalOfficeManager
|
||||
.builder()
|
||||
.connectTimeout(2000L)
|
||||
.retryInterval(500L)
|
||||
.portNumber(sofficeBasePort + managerIndex)
|
||||
.connectOnStart(false) // If it's true and soffice is not available, exception is thrown here ( we don't want exception here - we want the manager alive trying to reconnect )
|
||||
.workingDir(workingDir)
|
||||
.build();
|
||||
|
||||
// Workaround for jodconverter not calling makeTempDir when connectOnStart=false (issue 211)
|
||||
Method method = officeManager.getClass().getSuperclass().getDeclaredMethod("makeTempDir");
|
||||
method.setAccessible(true);
|
||||
method.invoke(officeManager);
|
||||
// End of workaround for jodconverter not calling makeTempDir
|
||||
|
||||
officeManager.start();
|
||||
officeManagers.add(officeManager);
|
||||
} catch (Exception e) {
|
||||
log.error("Could not start Office Manager " + instanceNumber + ". Details: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (officeManagers.size() == 0) {
|
||||
log.error("No office managers could be started");
|
||||
return;
|
||||
}
|
||||
|
||||
currentManager = officeManagers.get(0);
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
try {
|
||||
officeManagers.forEach(officeManager -> officeManager.stop() );
|
||||
} catch (Exception e) {
|
||||
log.error("Could not stop Office Manager", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
|
||||
*
|
||||
* Copyright (c) 2012 BigBlueButton Inc. and by respective authors (see below).
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the
|
||||
* terms of the GNU Lesser General Public License as published by the Free Software
|
||||
* Foundation; either version 3.0 of the License, or (at your option) any later
|
||||
* version.
|
||||
*
|
||||
* BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||||
* PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License along
|
||||
* with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
package org.bigbluebutton.presentation.imp;
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.bigbluebutton.presentation.ConversionMessageConstants;
|
||||
import org.bigbluebutton.presentation.SupportedFileTypes;
|
||||
import org.bigbluebutton.presentation.UploadedPresentation;
|
||||
import org.jodconverter.core.office.OfficeException;
|
||||
import org.jodconverter.core.office.OfficeUtils;
|
||||
import org.jodconverter.local.LocalConverter;
|
||||
import org.jodconverter.local.office.ExternalOfficeManager;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import com.sun.star.document.UpdateDocMode;
|
||||
import com.google.gson.Gson;
|
||||
public class OfficeToPdfConversionService {
|
||||
private static Logger log = LoggerFactory.getLogger(OfficeToPdfConversionService.class);
|
||||
private OfficeDocumentValidator2 officeDocumentValidator;
|
||||
private final ArrayList<ExternalOfficeManager> officeManagers;
|
||||
private ExternalOfficeManager currentManager = null;
|
||||
private boolean skipOfficePrecheck = false;
|
||||
private int sofficeBasePort = 0;
|
||||
private int sofficeManagers = 0;
|
||||
private String sofficeWorkingDirBase = null;
|
||||
public OfficeToPdfConversionService() throws OfficeException {
|
||||
officeManagers = new ArrayList<>();
|
||||
}
|
||||
/*
|
||||
* Convert the Office document to PDF. If successful, update
|
||||
* UploadPresentation.uploadedFile with the new PDF out and
|
||||
* UploadPresentation.lastStepSuccessful to TRUE.
|
||||
*/
|
||||
public UploadedPresentation convertOfficeToPdf(UploadedPresentation pres) {
|
||||
initialize(pres);
|
||||
if (SupportedFileTypes.isOfficeFile(pres.getFileType())) {
|
||||
// Check if we need to precheck office document
|
||||
if (!skipOfficePrecheck && officeDocumentValidator.isValid(pres)) {
|
||||
Map<String, Object> logData = new HashMap<>();
|
||||
logData.put("meetingId", pres.getMeetingId());
|
||||
logData.put("presId", pres.getId());
|
||||
logData.put("filename", pres.getName());
|
||||
logData.put("logCode", "problems_office_to_pdf_validation");
|
||||
logData.put("message", "Problems detected prior to converting the file to PDF.");
|
||||
Gson gson = new Gson();
|
||||
String logStr = gson.toJson(logData);
|
||||
log.warn(" --analytics-- data={}", logStr);
|
||||
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_INVALID_KEY);
|
||||
return pres;
|
||||
}
|
||||
File pdfOutput = setupOutputPdfFile(pres);
|
||||
if (convertOfficeDocToPdf(pres, pdfOutput)) {
|
||||
Map<String, Object> logData = new HashMap<>();
|
||||
logData.put("meetingId", pres.getMeetingId());
|
||||
logData.put("presId", pres.getId());
|
||||
logData.put("filename", pres.getName());
|
||||
logData.put("logCode", "office_to_pdf_success");
|
||||
logData.put("message", "Successfully converted office file to pdf.");
|
||||
Gson gson = new Gson();
|
||||
String logStr = gson.toJson(logData);
|
||||
log.info(" --analytics-- data={}", logStr);
|
||||
makePdfTheUploadedFileAndSetStepAsSuccess(pres, pdfOutput);
|
||||
} else {
|
||||
Map<String, Object> logData = new HashMap<>();
|
||||
logData.put("meetingId", pres.getMeetingId());
|
||||
logData.put("presId", pres.getId());
|
||||
logData.put("filename", pres.getName());
|
||||
logData.put("logCode", "office_to_pdf_failed");
|
||||
logData.put("message", "Failed to convert " + pres.getUploadedFile().getAbsolutePath() + " to Pdf.");
|
||||
Gson gson = new Gson();
|
||||
String logStr = gson.toJson(logData);
|
||||
log.warn(" --analytics-- data={}", logStr);
|
||||
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_FAILED_KEY);
|
||||
return pres;
|
||||
}
|
||||
}
|
||||
return pres;
|
||||
}
|
||||
public void initialize(UploadedPresentation pres) {
|
||||
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_FAILED_KEY);
|
||||
}
|
||||
private File setupOutputPdfFile(UploadedPresentation pres) {
|
||||
File presentationFile = pres.getUploadedFile();
|
||||
String filenameWithoutExt = presentationFile.getAbsolutePath().substring(0,
|
||||
presentationFile.getAbsolutePath().lastIndexOf('.'));
|
||||
return new File(filenameWithoutExt + ".pdf");
|
||||
}
|
||||
private boolean convertOfficeDocToPdf(UploadedPresentation pres,
|
||||
File pdfOutput) {
|
||||
boolean success = false;
|
||||
int attempts = 0;
|
||||
while(!success) {
|
||||
final Map<String, Object> loadProperties = new HashMap<>();
|
||||
loadProperties.put("Hidden", true);
|
||||
loadProperties.put("ReadOnly", true);
|
||||
loadProperties.put("UpdateDocMode", UpdateDocMode.NO_UPDATE);
|
||||
LocalConverter documentConverter = LocalConverter
|
||||
.builder()
|
||||
.officeManager(currentManager)
|
||||
.loadProperties(loadProperties)
|
||||
.filterChain(new OfficeDocumentConversionFilter())
|
||||
.build();
|
||||
|
||||
success = Office2PdfPageConverter.convert(pres.getUploadedFile(), pdfOutput, 0, pres, documentConverter);
|
||||
|
||||
if(!success) {
|
||||
// In case of failure, try with other open Office Manager
|
||||
|
||||
if(++attempts != officeManagers.size()) {
|
||||
// Go to next Office Manager ( if the last retry with the first one )
|
||||
int currentManagerIndex = officeManagers.indexOf(currentManager);
|
||||
|
||||
boolean isLastManager = ( currentManagerIndex == officeManagers.size()-1 );
|
||||
if(isLastManager) {
|
||||
currentManager = officeManagers.get(0);
|
||||
} else {
|
||||
currentManager = officeManagers.get(currentManagerIndex+1);
|
||||
}
|
||||
} else {
|
||||
// We tried to use all our office managers and it's still failing
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
private void makePdfTheUploadedFileAndSetStepAsSuccess(UploadedPresentation pres, File pdf) {
|
||||
pres.setUploadedFile(pdf);
|
||||
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_SUCCESS_KEY);
|
||||
}
|
||||
|
||||
public void setOfficeDocumentValidator(OfficeDocumentValidator2 v) {
|
||||
officeDocumentValidator = v;
|
||||
}
|
||||
|
||||
public void setSkipOfficePrecheck(boolean skipOfficePrecheck) {
|
||||
this.skipOfficePrecheck = skipOfficePrecheck;
|
||||
}
|
||||
|
||||
public void setSofficeBasePort(int sofficeBasePort) {
|
||||
this.sofficeBasePort = sofficeBasePort;
|
||||
}
|
||||
|
||||
public void setSofficeManagers(int sofficeServiceManagers) {
|
||||
this.sofficeManagers = sofficeServiceManagers;
|
||||
}
|
||||
|
||||
public void setSofficeWorkingDirBase(String sofficeWorkingDirBase) {
|
||||
this.sofficeWorkingDirBase = sofficeWorkingDirBase;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
log.info("Starting LibreOffice pool with " + sofficeManagers + " managers, starting from port " + sofficeBasePort);
|
||||
|
||||
for(int managerIndex = 0; managerIndex < sofficeManagers; managerIndex ++) {
|
||||
Integer instanceNumber = managerIndex + 1; // starts at 1
|
||||
|
||||
try {
|
||||
final File workingDir = new File(sofficeWorkingDirBase + String.format("%02d", instanceNumber));
|
||||
|
||||
if(!workingDir.exists()) {
|
||||
workingDir.mkdir();
|
||||
}
|
||||
|
||||
ExternalOfficeManager officeManager = ExternalOfficeManager
|
||||
.builder()
|
||||
.connectTimeout(2000L)
|
||||
.retryInterval(500L)
|
||||
.portNumber(sofficeBasePort + managerIndex)
|
||||
.connectOnStart(false) // If it's true and soffice is not available, exception is thrown here ( we don't want exception here - we want the manager alive trying to reconnect )
|
||||
.workingDir(workingDir)
|
||||
.build();
|
||||
|
||||
// Workaround for jodconverter not calling makeTempDir when connectOnStart=false (issue 211)
|
||||
Method method = officeManager.getClass().getSuperclass().getDeclaredMethod("makeTempDir");
|
||||
method.setAccessible(true);
|
||||
method.invoke(officeManager);
|
||||
// End of workaround for jodconverter not calling makeTempDir
|
||||
|
||||
officeManager.start();
|
||||
officeManagers.add(officeManager);
|
||||
} catch (Exception e) {
|
||||
log.error("Could not start Office Manager " + instanceNumber + ". Details: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (officeManagers.size() == 0) {
|
||||
log.error("No office managers could be started");
|
||||
return;
|
||||
}
|
||||
|
||||
currentManager = officeManagers.get(0);
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
try {
|
||||
officeManagers.forEach(officeManager -> officeManager.stop() );
|
||||
} catch (Exception e) {
|
||||
log.error("Could not stop Office Manager", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
|
||||
*
|
||||
* Copyright (c) 2012 BigBlueButton Inc. and by respective authors (see below).
|
||||
* Copyright (c) 2020 BigBlueButton Inc. and by respective authors (see below).
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the
|
||||
* terms of the GNU Lesser General Public License as published by the Free Software
|
||||
@ -25,11 +25,11 @@ import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.bigbluebutton.api.MeetingService;
|
||||
|
||||
public class RegisteredUserCleanupTimerTask {
|
||||
public class EnteredUserCleanupTimerTask {
|
||||
|
||||
private MeetingService service;
|
||||
private ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(1);
|
||||
private long runEvery = 60000;
|
||||
private long runEvery = 30000;
|
||||
|
||||
public void setMeetingService(MeetingService svc) {
|
||||
this.service = svc;
|
||||
@ -50,7 +50,7 @@ public class RegisteredUserCleanupTimerTask {
|
||||
private class CleanupTask implements Runnable {
|
||||
@Override
|
||||
public void run() {
|
||||
service.purgeRegisteredUsers();
|
||||
service.purgeEnteredUsers();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
/**
|
||||
* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
|
||||
*
|
||||
* Copyright (c) 2020 BigBlueButton Inc. and by respective authors (see below).
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the
|
||||
* terms of the GNU Lesser General Public License as published by the Free Software
|
||||
* Foundation; either version 3.0 of the License, or (at your option) any later
|
||||
* version.
|
||||
*
|
||||
* BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||||
* PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License along
|
||||
* with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.bigbluebutton.web.services;
|
||||
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.bigbluebutton.api.MeetingService;
|
||||
|
||||
public class UserCleanupTimerTask {
|
||||
|
||||
private MeetingService service;
|
||||
private ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(1);
|
||||
private long runEvery = 15000;
|
||||
|
||||
public void setMeetingService(MeetingService svc) {
|
||||
this.service = svc;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
scheduledThreadPool.scheduleWithFixedDelay(new CleanupTask(), 60000, runEvery, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
scheduledThreadPool.shutdownNow();
|
||||
}
|
||||
|
||||
public void setRunEvery(long v) {
|
||||
runEvery = v;
|
||||
}
|
||||
|
||||
private class CleanupTask implements Runnable {
|
||||
@Override
|
||||
public void run() {
|
||||
service.purgeUsers();
|
||||
}
|
||||
}
|
||||
}
|
@ -81,7 +81,6 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
|
||||
});
|
||||
</script>
|
||||
<script src="compatibility/adapter.js?v=VERSION" language="javascript"></script>
|
||||
<script src="compatibility/bowser.js?v=VERSION" language="javascript"></script>
|
||||
<script src="compatibility/sip.js?v=VERSION" language="javascript"></script>
|
||||
<script src="compatibility/kurento-extension.js?v=VERSION" language="javascript"></script>
|
||||
<script src="compatibility/kurento-utils.js?v=VERSION" language="javascript"></script>
|
||||
@ -89,7 +88,6 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
|
||||
<body style="background-color: #06172A">
|
||||
<div id="app" role="document"></div>
|
||||
<span id="destination"></span>
|
||||
<audio id="remote-media" autoPlay="autoplay">
|
||||
<track kind="captions" /> {/* These captions are brought to you by eslint */}
|
||||
<audio id="remote-media" autoplay>
|
||||
</audio>
|
||||
</body>
|
||||
|
@ -2,8 +2,6 @@ import { check } from 'meteor/check';
|
||||
|
||||
const ANNOTATION_TYPE_TEXT = 'text';
|
||||
const ANNOTATION_TYPE_PENCIL = 'pencil';
|
||||
const DEFAULT_TEXT_WIDTH = 30;
|
||||
const DEFAULT_TEXT_HEIGHT = 20;
|
||||
|
||||
// line, triangle, ellipse, rectangle
|
||||
function handleCommonAnnotation(meetingId, whiteboardId, userId, annotation) {
|
||||
@ -41,23 +39,6 @@ function handleTextUpdate(meetingId, whiteboardId, userId, annotation) {
|
||||
id, status, annotationType, annotationInfo, wbId, position,
|
||||
} = annotation;
|
||||
|
||||
const { textBoxWidth, textBoxHeight, calcedFontSize } = annotationInfo;
|
||||
const useDefaultSize = (textBoxWidth === 0 && textBoxHeight === 0)
|
||||
|| textBoxWidth < calcedFontSize
|
||||
|| textBoxHeight < calcedFontSize;
|
||||
|
||||
if (useDefaultSize) {
|
||||
annotationInfo.textBoxWidth = DEFAULT_TEXT_WIDTH;
|
||||
annotationInfo.textBoxHeight = DEFAULT_TEXT_HEIGHT;
|
||||
|
||||
if (100 - annotationInfo.x < DEFAULT_TEXT_WIDTH) {
|
||||
annotationInfo.textBoxWidth = 100 - annotationInfo.x;
|
||||
}
|
||||
if (100 - annotationInfo.y < DEFAULT_TEXT_HEIGHT) {
|
||||
annotationInfo.textBoxHeight = 100 - annotationInfo.y;
|
||||
}
|
||||
}
|
||||
|
||||
const selector = {
|
||||
meetingId,
|
||||
id,
|
||||
|
@ -60,14 +60,22 @@ export default class KurentoAudioBridge extends BaseAudioBridge {
|
||||
let iceServers = [];
|
||||
|
||||
try {
|
||||
logger.info({
|
||||
logCode: 'sfuaudiobridge_stunturn_fetch_start',
|
||||
extraInfo: { iceServers },
|
||||
}, 'SFU audio bridge starting STUN/TURN fetch');
|
||||
|
||||
iceServers = await fetchWebRTCMappedStunTurnServers(this.user.sessionToken);
|
||||
} catch (error) {
|
||||
logger.error({ logCode: 'sfuaudiobridge_stunturn_fetch_failed' },
|
||||
'SFU audio bridge failed to fetch STUN/TURN info, using default servers');
|
||||
iceServers = getMappedFallbackStun();
|
||||
} finally {
|
||||
logger.debug({ logCode: 'sfuaudiobridge_stunturn_fetch_sucess', extraInfo: { iceServers } },
|
||||
'SFU audio bridge got STUN/TURN servers');
|
||||
logger.info({
|
||||
logCode: 'sfuaudiobridge_stunturn_fetch_sucess',
|
||||
extraInfo: { iceServers },
|
||||
}, 'SFU audio bridge got STUN/TURN servers');
|
||||
|
||||
const options = {
|
||||
wsUrl: Auth.authenticateURL(SFU_URL),
|
||||
userName: this.user.name,
|
||||
@ -131,12 +139,25 @@ export default class KurentoAudioBridge extends BaseAudioBridge {
|
||||
|
||||
this.hasSuccessfullyStarted = true;
|
||||
if (webRtcPeer) {
|
||||
logger.info({
|
||||
logCode: 'sfuaudiobridge_audio_negotiation_success',
|
||||
}, 'SFU audio bridge negotiated audio with success');
|
||||
|
||||
const stream = webRtcPeer.getRemoteStream();
|
||||
|
||||
audioTag.pause();
|
||||
audioTag.srcObject = stream;
|
||||
audioTag.muted = false;
|
||||
logger.info({
|
||||
logCode: 'sfuaudiobridge_audio_ready_to_play',
|
||||
}, 'SFU audio bridge is ready to play');
|
||||
|
||||
playElement();
|
||||
} else {
|
||||
logger.info({
|
||||
logCode: 'sfuaudiobridge_audio_negotiation_failed',
|
||||
}, 'SFU audio bridge failed to negotiate audio');
|
||||
|
||||
this.callback({
|
||||
status: this.baseCallStates.failed,
|
||||
error: this.baseErrorCodes.CONNECTION_ERROR,
|
||||
@ -218,6 +239,9 @@ export default class KurentoAudioBridge extends BaseAudioBridge {
|
||||
return reject(new Error('Invalid bridge option'));
|
||||
}
|
||||
|
||||
logger.info({
|
||||
logCode: 'sfuaudiobridge_ready_to_join_audio',
|
||||
}, 'SFU audio bridge is ready to join audio');
|
||||
window.kurentoJoinAudio(
|
||||
MEDIA_TAG,
|
||||
this.voiceBridge,
|
||||
|
@ -1,7 +1,10 @@
|
||||
import browser from 'browser-detect';
|
||||
import BaseAudioBridge from './base';
|
||||
import logger from '/imports/startup/client/logger';
|
||||
import { fetchStunTurnServers, getFallbackStun } from '/imports/utils/fetchStunTurnServers';
|
||||
import {
|
||||
fetchWebRTCMappedStunTurnServers,
|
||||
getMappedFallbackStun,
|
||||
} from '/imports/utils/fetchStunTurnServers';
|
||||
import {
|
||||
isUnifiedPlan,
|
||||
toUnifiedPlan,
|
||||
@ -20,13 +23,13 @@ const MEDIA_TAG = MEDIA.mediaTag;
|
||||
const CALL_TRANSFER_TIMEOUT = MEDIA.callTransferTimeout;
|
||||
const CALL_HANGUP_TIMEOUT = MEDIA.callHangupTimeout;
|
||||
const CALL_HANGUP_MAX_RETRIES = MEDIA.callHangupMaximumRetries;
|
||||
const RELAY_ONLY_ON_RECONNECT = MEDIA.relayOnlyOnReconnect;
|
||||
const IPV4_FALLBACK_DOMAIN = Meteor.settings.public.app.ipv4FallbackDomain;
|
||||
const ICE_NEGOTIATION_FAILED = ['iceConnectionFailed'];
|
||||
const CALL_CONNECT_TIMEOUT = 20000;
|
||||
const ICE_NEGOTIATION_TIMEOUT = 20000;
|
||||
const AUDIO_SESSION_NUM_KEY = 'AudioSessionNumber';
|
||||
|
||||
const USER_AGENT_RECONNECTION_ATTEMPTS = 3;
|
||||
const USER_AGENT_RECONNECTION_DELAY_MS = 5000;
|
||||
const USER_AGENT_CONNECTION_TIMEOUT_MS = 5000;
|
||||
|
||||
const getAudioSessionNumber = () => {
|
||||
let currItem = parseInt(sessionStorage.getItem(AUDIO_SESSION_NUM_KEY), 10);
|
||||
@ -39,6 +42,24 @@ const getAudioSessionNumber = () => {
|
||||
return currItem;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Get error code from SIP.js websocket messages.
|
||||
*/
|
||||
const getErrorCode = (error) => {
|
||||
try {
|
||||
if (!error) return error;
|
||||
|
||||
const match = error.message.match(/code: \d+/g);
|
||||
|
||||
const _codeArray = match[0].split(':');
|
||||
|
||||
return parseInt(_codeArray[1].trim(), 10);
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
class SIPSession {
|
||||
constructor(user, userData, protocol, hostname,
|
||||
baseCallStates, baseErrorCodes, reconnectAttempt) {
|
||||
@ -49,9 +70,15 @@ class SIPSession {
|
||||
this.baseCallStates = baseCallStates;
|
||||
this.baseErrorCodes = baseErrorCodes;
|
||||
this.reconnectAttempt = reconnectAttempt;
|
||||
this.currentSession = null;
|
||||
this.remoteStream = null;
|
||||
this.inputDeviceId = null;
|
||||
this._hangupFlag = false;
|
||||
this._reconnecting = false;
|
||||
this._currentSessionState = null;
|
||||
}
|
||||
|
||||
joinAudio({ isListenOnly, extension, inputStream }, managerCallback) {
|
||||
joinAudio({ isListenOnly, extension, inputDeviceId }, managerCallback) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const callExtension = extension ? `${extension}${this.userData.voiceBridge}` : this.userData.voiceBridge;
|
||||
|
||||
@ -78,7 +105,7 @@ class SIPSession {
|
||||
// If there's an extension passed it means that we're joining the echo test first
|
||||
this.inEchoTest = !!extension;
|
||||
|
||||
return this.doCall({ callExtension, isListenOnly, inputStream })
|
||||
return this.doCall({ callExtension, isListenOnly, inputDeviceId })
|
||||
.catch((reason) => {
|
||||
reject(reason);
|
||||
});
|
||||
@ -87,7 +114,7 @@ class SIPSession {
|
||||
|
||||
async getIceServers(sessionToken) {
|
||||
try {
|
||||
const iceServers = await fetchStunTurnServers(sessionToken);
|
||||
const iceServers = await fetchWebRTCMappedStunTurnServers(sessionToken);
|
||||
return iceServers;
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
@ -98,15 +125,18 @@ class SIPSession {
|
||||
callerIdName: this.user.callerIdName,
|
||||
},
|
||||
}, 'Full audio bridge failed to fetch STUN/TURN info');
|
||||
return getFallbackStun();
|
||||
return getMappedFallbackStun();
|
||||
}
|
||||
}
|
||||
|
||||
doCall(options) {
|
||||
const {
|
||||
isListenOnly,
|
||||
inputDeviceId,
|
||||
} = options;
|
||||
|
||||
this.inputDeviceId = inputDeviceId;
|
||||
|
||||
const {
|
||||
userId,
|
||||
name,
|
||||
@ -124,8 +154,7 @@ class SIPSession {
|
||||
|
||||
return this.getIceServers(sessionToken)
|
||||
.then(this.createUserAgent.bind(this))
|
||||
.then(this.inviteUserAgent.bind(this))
|
||||
.then(this.setupEventHandlers.bind(this));
|
||||
.then(this.inviteUserAgent.bind(this));
|
||||
}
|
||||
|
||||
transferCall(onTransferSuccess) {
|
||||
@ -149,7 +178,18 @@ class SIPSession {
|
||||
}, CALL_TRANSFER_TIMEOUT);
|
||||
|
||||
// This is is the call transfer code ask @chadpilkey
|
||||
this.currentSession.dtmf(1);
|
||||
if (this.sessionSupportRTPPayloadDtmf(this.currentSession)) {
|
||||
this.currentSession.sessionDescriptionHandler.sendDtmf(1);
|
||||
} else {
|
||||
// RFC4733 not supported , sending DTMF through INFO
|
||||
logger.debug({
|
||||
logCode: 'sip_js_rtp_payload_dtmf_not_supported',
|
||||
extraInfo: {
|
||||
callerIdName: this.user.callerIdName,
|
||||
},
|
||||
}, 'Browser do not support payload dtmf, using INFO instead');
|
||||
this.sendDtmf(1);
|
||||
}
|
||||
|
||||
Tracker.autorun((c) => {
|
||||
trackerControl = c;
|
||||
@ -171,28 +211,82 @@ class SIPSession {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* sessionSupportRTPPayloadDtmf
|
||||
* tells if browser support RFC4733 DTMF.
|
||||
* Safari 13 doens't support it yet
|
||||
*/
|
||||
sessionSupportRTPPayloadDtmf(session) {
|
||||
try {
|
||||
const sessionDescriptionHandler = session
|
||||
? session.sessionDescriptionHandler
|
||||
: this.currentSession.sessionDescriptionHandler;
|
||||
|
||||
const senders = sessionDescriptionHandler.peerConnection.getSenders();
|
||||
return !!(senders[0].dtmf);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* sendDtmf - send DTMF Tones using INFO message
|
||||
*
|
||||
* same as SimpleUser's dtmf
|
||||
*/
|
||||
sendDtmf(tone) {
|
||||
const dtmf = tone;
|
||||
const duration = 2000;
|
||||
const body = {
|
||||
contentDisposition: 'render',
|
||||
contentType: 'application/dtmf-relay',
|
||||
content: `Signal=${dtmf}\r\nDuration=${duration}`,
|
||||
};
|
||||
const requestOptions = { body };
|
||||
return this.currentSession.info({ requestOptions });
|
||||
}
|
||||
|
||||
exitAudio() {
|
||||
return new Promise((resolve, reject) => {
|
||||
let hangupRetries = 0;
|
||||
let hangup = false;
|
||||
this._hangupFlag = false;
|
||||
|
||||
this.userRequestedHangup = true;
|
||||
|
||||
if (this.currentSession) {
|
||||
const { mediaHandler } = this.currentSession;
|
||||
|
||||
// Removing termination events to avoid triggering an error
|
||||
ICE_NEGOTIATION_FAILED.forEach(e => mediaHandler.off(e));
|
||||
}
|
||||
const tryHangup = () => {
|
||||
if ((this.currentSession && this.currentSession.endTime)
|
||||
|| (this.userAgent && this.userAgent.status === SIP.UA.C.STATUS_USER_CLOSED)) {
|
||||
hangup = true;
|
||||
if (this._hangupFlag) {
|
||||
resolve();
|
||||
}
|
||||
|
||||
if ((this.currentSession
|
||||
&& (this.currentSession.state === SIP.SessionState.Terminated))
|
||||
|| (this.userAgent && (!this.userAgent.isConnected()))) {
|
||||
this._hangupFlag = true;
|
||||
return resolve();
|
||||
}
|
||||
|
||||
if (this.currentSession) this.currentSession.bye();
|
||||
if (this.userAgent) this.userAgent.stop();
|
||||
if (this.currentSession
|
||||
&& ((this.currentSession.state === SIP.SessionState.Establishing))) {
|
||||
this.currentSession.cancel().then(() => {
|
||||
this._hangupFlag = true;
|
||||
return resolve();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.currentSession
|
||||
&& ((this.currentSession.state === SIP.SessionState.Established))) {
|
||||
this.currentSession.bye().then(() => {
|
||||
this._hangupFlag = true;
|
||||
return resolve();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.userAgent && this.userAgent.isConnected()) {
|
||||
this.userAgent.stop();
|
||||
window.removeEventListener('beforeunload', this.onBeforeUnload);
|
||||
}
|
||||
|
||||
|
||||
hangupRetries += 1;
|
||||
|
||||
@ -206,23 +300,24 @@ class SIPSession {
|
||||
return reject(this.baseErrorCodes.REQUEST_TIMEOUT);
|
||||
}
|
||||
|
||||
if (!hangup) return tryHangup();
|
||||
if (!this._hangupFlag) return tryHangup();
|
||||
return resolve();
|
||||
}, CALL_HANGUP_TIMEOUT);
|
||||
};
|
||||
|
||||
if (this.currentSession) {
|
||||
this.currentSession.on('bye', () => {
|
||||
hangup = true;
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
return tryHangup();
|
||||
});
|
||||
}
|
||||
|
||||
createUserAgent({ stun, turn }) {
|
||||
onBeforeUnload() {
|
||||
if (this.userAgent) {
|
||||
return this.userAgent.stop();
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
createUserAgent(iceServers) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.userRequestedHangup === true) reject();
|
||||
|
||||
@ -236,17 +331,6 @@ class SIPSession {
|
||||
sessionToken,
|
||||
} = this.user;
|
||||
|
||||
// WebView safari needs a transceiver to be added. Made it a SIP.js hack.
|
||||
// Don't like the UA picking though, we should straighten everything to user
|
||||
// transceivers - prlanzarin 2019/05/21
|
||||
const browserUA = window.navigator.userAgent.toLocaleLowerCase();
|
||||
const isSafariWebview = ((browserUA.indexOf('iphone') > -1
|
||||
|| browserUA.indexOf('ipad') > -1) && browserUA.indexOf('safari') === -1);
|
||||
|
||||
// Second UA check to get all Safari browsers to enable Unified Plan <-> PlanB
|
||||
// translation
|
||||
const isSafari = browser().name === 'safari';
|
||||
|
||||
logger.debug({ logCode: 'sip_js_creating_user_agent', extraInfo: { callerIdName } }, 'Creating the user agent');
|
||||
|
||||
if (this.userAgent && this.userAgent.isConnected()) {
|
||||
@ -275,112 +359,261 @@ class SIPSession {
|
||||
let userAgentConnected = false;
|
||||
const token = `sessionToken=${sessionToken}`;
|
||||
|
||||
this.userAgent = new window.SIP.UA({
|
||||
uri: `sip:${encodeURIComponent(callerIdName)}@${hostname}`,
|
||||
wsServers: `${(protocol === 'https:' ? 'wss://' : 'ws://')}${hostname}/ws?${token}`,
|
||||
this.userAgent = new SIP.UserAgent({
|
||||
uri: SIP.UserAgent.makeURI(`sip:${encodeURIComponent(callerIdName)}@${hostname}`),
|
||||
transportOptions: {
|
||||
server: `${(protocol === 'https:' ? 'wss://' : 'ws://')}${hostname}/ws?${token}`,
|
||||
connectionTimeout: USER_AGENT_CONNECTION_TIMEOUT_MS,
|
||||
},
|
||||
sessionDescriptionHandlerFactoryOptions: {
|
||||
peerConnectionConfiguration: {
|
||||
iceServers,
|
||||
},
|
||||
},
|
||||
displayName: callerIdName,
|
||||
register: false,
|
||||
traceSip: true,
|
||||
autostart: false,
|
||||
userAgentString: 'BigBlueButton',
|
||||
stunServers: stun,
|
||||
turnServers: turn,
|
||||
hackPlanBUnifiedPlanTranslation: isSafari,
|
||||
hackAddAudioTransceiver: isSafariWebview,
|
||||
relayOnlyOnReconnect: this.reconnectAttempt && RELAY_ONLY_ON_RECONNECT,
|
||||
localSdpCallback,
|
||||
remoteSdpCallback,
|
||||
});
|
||||
|
||||
const handleUserAgentConnection = () => {
|
||||
userAgentConnected = true;
|
||||
resolve(this.userAgent);
|
||||
if (!userAgentConnected) {
|
||||
userAgentConnected = true;
|
||||
resolve(this.userAgent);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserAgentDisconnection = () => {
|
||||
if (this.userAgent) {
|
||||
this.userAgent.removeAllListeners();
|
||||
this.userAgent.stop();
|
||||
if (this.userRequestedHangup) {
|
||||
userAgentConnected = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let error;
|
||||
let bridgeError;
|
||||
|
||||
if (!this._reconnecting) {
|
||||
|
||||
logger.info({
|
||||
logCode: 'sip_js_session_ua_disconnected',
|
||||
extraInfo: {
|
||||
callerIdName: this.user.callerIdName,
|
||||
},
|
||||
}, 'User agent disconnected: trying to reconnect...'
|
||||
+ ` (userHangup = ${!!this.userRequestedHangup})`);
|
||||
|
||||
logger.info({
|
||||
logCode: 'sip_js_session_ua_reconnecting',
|
||||
extraInfo: {
|
||||
callerIdName: this.user.callerIdName,
|
||||
},
|
||||
}, 'User agent disconnected, reconnecting');
|
||||
|
||||
this.reconnect().then(() => {
|
||||
logger.info({
|
||||
logCode: 'sip_js_session_ua_reconnected',
|
||||
extraInfo: {
|
||||
callerIdName: this.user.callerIdName,
|
||||
},
|
||||
}, 'User agent succesfully reconnected');
|
||||
}).catch(() => {
|
||||
if (userAgentConnected) {
|
||||
error = 1001;
|
||||
bridgeError = 'Websocket disconnected';
|
||||
} else {
|
||||
error = 1002;
|
||||
bridgeError = 'Websocket failed to connect';
|
||||
}
|
||||
this.callback({
|
||||
status: this.baseCallStates.failed,
|
||||
error,
|
||||
bridgeError,
|
||||
});
|
||||
reject(this.baseErrorCodes.CONNECTION_ERROR);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let error;
|
||||
let bridgeError;
|
||||
|
||||
if (this.userRequestedHangup) return;
|
||||
|
||||
if (userAgentConnected) {
|
||||
error = 1001;
|
||||
bridgeError = 'Websocket disconnected';
|
||||
} else {
|
||||
error = 1002;
|
||||
bridgeError = 'Websocket failed to connect';
|
||||
}
|
||||
|
||||
this.callback({
|
||||
status: this.baseCallStates.failed,
|
||||
error,
|
||||
bridgeError,
|
||||
});
|
||||
reject(this.baseErrorCodes.CONNECTION_ERROR);
|
||||
};
|
||||
|
||||
this.userAgent.on('connected', handleUserAgentConnection);
|
||||
this.userAgent.on('disconnected', handleUserAgentDisconnection);
|
||||
this.userAgent.transport.onConnect = handleUserAgentConnection;
|
||||
this.userAgent.transport.onDisconnect = handleUserAgentDisconnection;
|
||||
|
||||
this.userAgent.start();
|
||||
const preturn = this.userAgent.start().then(() => {
|
||||
logger.info({
|
||||
logCode: 'sip_js_session_ua_connected',
|
||||
extraInfo: {
|
||||
callerIdName: this.user.callerIdName,
|
||||
},
|
||||
}, 'User agent succesfully connected');
|
||||
|
||||
window.addEventListener('beforeunload', this.onBeforeUnload.bind(this));
|
||||
|
||||
resolve();
|
||||
}).catch((error) => {
|
||||
logger.info({
|
||||
logCode: 'sip_js_session_ua_reconnecting',
|
||||
extraInfo: {
|
||||
callerIdName: this.user.callerIdName,
|
||||
},
|
||||
}, 'User agent failed to connect, reconnecting');
|
||||
|
||||
const code = getErrorCode(error);
|
||||
|
||||
|
||||
if (code === 1006) {
|
||||
this.callback({
|
||||
status: this.baseCallStates.failed,
|
||||
error: 1006,
|
||||
bridgeError: 'Websocket failed to connect',
|
||||
});
|
||||
return reject({
|
||||
type: this.baseErrorCodes.CONNECTION_ERROR,
|
||||
});
|
||||
}
|
||||
|
||||
this.reconnect().then(() => {
|
||||
logger.info({
|
||||
logCode: 'sip_js_session_ua_reconnected',
|
||||
extraInfo: {
|
||||
callerIdName: this.user.callerIdName,
|
||||
},
|
||||
}, 'User agent succesfully reconnected');
|
||||
|
||||
resolve();
|
||||
}).catch(() => {
|
||||
logger.info({
|
||||
logCode: 'sip_js_session_ua_disconnected',
|
||||
extraInfo: {
|
||||
callerIdName: this.user.callerIdName,
|
||||
},
|
||||
}, 'User agent failed to reconnect after'
|
||||
+ ` ${USER_AGENT_RECONNECTION_ATTEMPTS} attemps`);
|
||||
|
||||
this.callback({
|
||||
status: this.baseCallStates.failed,
|
||||
error: 1002,
|
||||
bridgeError: 'Websocket failed to connect',
|
||||
});
|
||||
|
||||
reject({
|
||||
type: this.baseErrorCodes.CONNECTION_ERROR,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return preturn;
|
||||
});
|
||||
}
|
||||
|
||||
reconnect(attempts = 1) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this._reconnecting) {
|
||||
return resolve();
|
||||
}
|
||||
|
||||
if (attempts > USER_AGENT_RECONNECTION_ATTEMPTS) {
|
||||
return reject({
|
||||
type: this.baseErrorCodes.CONNECTION_ERROR,
|
||||
});
|
||||
}
|
||||
|
||||
this._reconnecting = true;
|
||||
|
||||
setTimeout(() => {
|
||||
this.userAgent.reconnect().then(() => {
|
||||
this._reconnecting = false;
|
||||
resolve();
|
||||
}).catch(() => {
|
||||
this._reconnecting = false;
|
||||
this.reconnect(++attempts).then(() => {
|
||||
resolve();
|
||||
}).catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}, USER_AGENT_RECONNECTION_DELAY_MS);
|
||||
});
|
||||
}
|
||||
|
||||
inviteUserAgent(userAgent) {
|
||||
if (this.userRequestedHangup === true) Promise.reject();
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.userRequestedHangup === true) reject();
|
||||
const {
|
||||
hostname,
|
||||
} = this;
|
||||
|
||||
const {
|
||||
hostname,
|
||||
} = this;
|
||||
const {
|
||||
callExtension,
|
||||
isListenOnly,
|
||||
} = this.callOptions;
|
||||
|
||||
const {
|
||||
inputStream,
|
||||
callExtension,
|
||||
} = this.callOptions;
|
||||
|
||||
const options = {
|
||||
media: {
|
||||
stream: inputStream,
|
||||
constraints: {
|
||||
audio: true,
|
||||
video: false,
|
||||
const target = SIP.UserAgent.makeURI(`sip:${callExtension}@${hostname}`);
|
||||
|
||||
const audioDeviceConstraint = this.inputDeviceId
|
||||
? { deviceId: { exact: this.inputDeviceId } }
|
||||
: true;
|
||||
|
||||
const inviterOptions = {
|
||||
sessionDescriptionHandlerOptions: {
|
||||
constraints: {
|
||||
audio: isListenOnly
|
||||
? false
|
||||
: audioDeviceConstraint,
|
||||
video: false,
|
||||
},
|
||||
},
|
||||
render: {
|
||||
remote: document.querySelector(MEDIA_TAG),
|
||||
},
|
||||
},
|
||||
RTCConstraints: {
|
||||
offerToReceiveAudio: true,
|
||||
offerToReceiveVideo: false,
|
||||
},
|
||||
};
|
||||
sessionDescriptionHandlerModifiersPostICEGathering:
|
||||
[stripMDnsCandidates],
|
||||
};
|
||||
|
||||
return userAgent.invite(`sip:${callExtension}@${hostname}`, options);
|
||||
|
||||
if (isListenOnly) {
|
||||
inviterOptions.sessionDescriptionHandlerOptions.offerOptions = {
|
||||
offerToReceiveAudio: true,
|
||||
};
|
||||
}
|
||||
|
||||
const inviter = new SIP.Inviter(userAgent, target, inviterOptions);
|
||||
this.currentSession = inviter;
|
||||
|
||||
this.setupEventHandlers(inviter).then(() => {
|
||||
inviter.invite().then(() => {
|
||||
resolve();
|
||||
}).catch(e => reject(e));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setupEventHandlers(currentSession) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.userRequestedHangup === true) reject();
|
||||
|
||||
const { mediaHandler } = currentSession;
|
||||
|
||||
let iceCompleted = false;
|
||||
let fsReady = false;
|
||||
|
||||
this.currentSession = currentSession;
|
||||
const setupRemoteMedia = () => {
|
||||
const mediaElement = document.querySelector(MEDIA_TAG);
|
||||
|
||||
let connectionCompletedEvents = ['iceConnectionCompleted', 'iceConnectionConnected'];
|
||||
// Edge sends a connected first and then a completed, but the call isn't ready until
|
||||
// the completed comes in. Due to the way that we have the listeners set up, the only
|
||||
// way to ignore one status is to not listen for it.
|
||||
if (browser().name === 'edge') {
|
||||
connectionCompletedEvents = ['iceConnectionCompleted'];
|
||||
}
|
||||
this.remoteStream = new MediaStream();
|
||||
|
||||
this.currentSession.sessionDescriptionHandler
|
||||
.peerConnection.getReceivers().forEach((receiver) => {
|
||||
if (receiver.track) {
|
||||
this.remoteStream.addTrack(receiver.track);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info({
|
||||
logCode: 'sip_js_session_playing_remote_media',
|
||||
extraInfo: {
|
||||
callerIdName: this.user.callerIdName,
|
||||
},
|
||||
}, 'Audio call - playing remote media');
|
||||
|
||||
mediaElement.srcObject = this.remoteStream;
|
||||
mediaElement.play();
|
||||
};
|
||||
|
||||
const checkIfCallReady = () => {
|
||||
if (this.userRequestedHangup === true) {
|
||||
@ -388,8 +621,28 @@ class SIPSession {
|
||||
resolve();
|
||||
}
|
||||
|
||||
logger.info({
|
||||
logCode: 'sip_js_session_check_if_call_ready',
|
||||
extraInfo: {
|
||||
iceCompleted,
|
||||
fsReady,
|
||||
},
|
||||
}, 'Audio call - check if ICE is finished and FreeSWITCH is ready');
|
||||
if (iceCompleted && fsReady) {
|
||||
this.webrtcConnected = true;
|
||||
setupRemoteMedia();
|
||||
|
||||
const { sdp } = this.currentSession.sessionDescriptionHandler
|
||||
.peerConnection.remoteDescription;
|
||||
|
||||
logger.info({
|
||||
logCode: 'sip_js_session_setup_remote_media',
|
||||
extraInfo: {
|
||||
callerIdName: this.user.callerIdName,
|
||||
sdp,
|
||||
},
|
||||
}, 'Audio call - setup remote media');
|
||||
|
||||
this.callback({ status: this.baseCallStates.started });
|
||||
resolve();
|
||||
}
|
||||
@ -412,7 +665,6 @@ class SIPSession {
|
||||
const handleSessionAccepted = () => {
|
||||
logger.info({ logCode: 'sip_js_session_accepted', extraInfo: { callerIdName: this.user.callerIdName } }, 'Audio call session accepted');
|
||||
clearTimeout(callTimeout);
|
||||
currentSession.off('accepted', handleSessionAccepted);
|
||||
|
||||
// If ICE isn't connected yet then start timeout waiting for ICE to finish
|
||||
if (!iceCompleted) {
|
||||
@ -420,45 +672,114 @@ class SIPSession {
|
||||
this.callback({
|
||||
status: this.baseCallStates.failed,
|
||||
error: 1010,
|
||||
bridgeError: `ICE negotiation timeout after ${ICE_NEGOTIATION_TIMEOUT / 1000}s`,
|
||||
bridgeError: 'ICE negotiation timeout after '
|
||||
+ `${ICE_NEGOTIATION_TIMEOUT / 1000}s`,
|
||||
});
|
||||
|
||||
this.exitAudio();
|
||||
|
||||
reject({
|
||||
type: this.baseErrorCodes.CONNECTION_ERROR,
|
||||
});
|
||||
}, ICE_NEGOTIATION_TIMEOUT);
|
||||
}
|
||||
};
|
||||
currentSession.on('accepted', handleSessionAccepted);
|
||||
|
||||
const handleSessionProgress = (update) => {
|
||||
logger.info({ logCode: 'sip_js_session_progress', extraInfo: { callerIdName: this.user.callerIdName } }, 'Audio call session progress update');
|
||||
clearTimeout(callTimeout);
|
||||
currentSession.off('progress', handleSessionProgress);
|
||||
};
|
||||
currentSession.on('progress', handleSessionProgress);
|
||||
|
||||
const handleConnectionCompleted = (peer) => {
|
||||
logger.info({
|
||||
logCode: 'sip_js_ice_connection_success',
|
||||
extraInfo: {
|
||||
currentState: peer.iceConnectionState,
|
||||
callerIdName: this.user.callerIdName,
|
||||
},
|
||||
}, `ICE connection success. Current state - ${peer.iceConnectionState}`);
|
||||
clearTimeout(callTimeout);
|
||||
clearTimeout(iceNegotiationTimeout);
|
||||
connectionCompletedEvents.forEach(e => mediaHandler.off(e, handleConnectionCompleted));
|
||||
iceCompleted = true;
|
||||
|
||||
logSelectedCandidate(peer, this.protocolIsIpv6);
|
||||
|
||||
checkIfCallReady();
|
||||
};
|
||||
connectionCompletedEvents.forEach(e => mediaHandler.on(e, handleConnectionCompleted));
|
||||
|
||||
const handleIceNegotiationFailed = (peer) => {
|
||||
if (iceCompleted) {
|
||||
logger.error({
|
||||
logCode: 'sipjs_ice_failed_after',
|
||||
extraInfo: {
|
||||
callerIdName: this.user.callerIdName,
|
||||
},
|
||||
}, 'ICE connection failed after success');
|
||||
} else {
|
||||
logger.error({
|
||||
logCode: 'sipjs_ice_failed_before',
|
||||
extraInfo: {
|
||||
callerIdName: this.user.callerIdName,
|
||||
},
|
||||
}, 'ICE connection failed before success');
|
||||
}
|
||||
clearTimeout(callTimeout);
|
||||
clearTimeout(iceNegotiationTimeout);
|
||||
this.callback({
|
||||
status: this.baseCallStates.failed,
|
||||
error: 1007,
|
||||
bridgeError: 'ICE negotiation failed. Current state '
|
||||
+ `- ${peer.iceConnectionState}`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleIceConnectionTerminated = (peer) => {
|
||||
if (!this.userRequestedHangup) {
|
||||
logger.error({
|
||||
logCode: 'sipjs_ice_closed',
|
||||
extraInfo: {
|
||||
callerIdName: this.user.callerIdName,
|
||||
},
|
||||
}, 'ICE connection closed');
|
||||
}
|
||||
|
||||
this.callback({
|
||||
status: this.baseCallStates.failed,
|
||||
error: 1012,
|
||||
bridgeError: 'ICE connection closed. Current state -'
|
||||
+ `${peer.iceConnectionState}`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSessionProgress = (update) => {
|
||||
logger.info({
|
||||
logCode: 'sip_js_session_progress',
|
||||
extraInfo: {
|
||||
callerIdName: this.user.callerIdName,
|
||||
update,
|
||||
},
|
||||
}, 'Audio call session progress update');
|
||||
|
||||
this.currentSession.sessionDescriptionHandler.peerConnectionDelegate = {
|
||||
onconnectionstatechange: (event) => {
|
||||
const peer = event.target;
|
||||
|
||||
switch (peer.connectionState) {
|
||||
case 'connected':
|
||||
logger.info({
|
||||
logCode: 'sip_js_ice_connection_success',
|
||||
extraInfo: {
|
||||
currentState: peer.connectionState,
|
||||
callerIdName: this.user.callerIdName,
|
||||
},
|
||||
}, 'ICE connection success. Current state - '
|
||||
+ `${peer.iceConnectionState}`);
|
||||
|
||||
clearTimeout(callTimeout);
|
||||
clearTimeout(iceNegotiationTimeout);
|
||||
|
||||
iceCompleted = true;
|
||||
|
||||
logSelectedCandidate(peer, this.protocolIsIpv6);
|
||||
|
||||
checkIfCallReady();
|
||||
break;
|
||||
case 'failed':
|
||||
handleIceNegotiationFailed(peer);
|
||||
break;
|
||||
|
||||
case 'closed':
|
||||
handleIceConnectionTerminated(peer);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const handleSessionTerminated = (message, cause) => {
|
||||
clearTimeout(callTimeout);
|
||||
clearTimeout(iceNegotiationTimeout);
|
||||
currentSession.off('terminated', handleSessionTerminated);
|
||||
|
||||
if (!message && !cause && !!this.userRequestedHangup) {
|
||||
return this.callback({
|
||||
@ -466,6 +787,10 @@ class SIPSession {
|
||||
});
|
||||
}
|
||||
|
||||
// if session hasn't even started, we let audio-modal to handle
|
||||
// any possile errors
|
||||
if (!this._currentSessionState) return false;
|
||||
|
||||
logger.error({
|
||||
logCode: 'sip_js_call_terminated',
|
||||
extraInfo: { cause, callerIdName: this.user.callerIdName },
|
||||
@ -484,39 +809,33 @@ class SIPSession {
|
||||
bridgeError: cause,
|
||||
});
|
||||
};
|
||||
currentSession.on('terminated', handleSessionTerminated);
|
||||
|
||||
const handleIceNegotiationFailed = (peer) => {
|
||||
if (iceCompleted) {
|
||||
logger.error({ logCode: 'sipjs_ice_failed_after', extraInfo: { callerIdName: this.user.callerIdName } }, 'ICE connection failed after success');
|
||||
} else {
|
||||
logger.error({ logCode: 'sipjs_ice_failed_before', extraInfo: { callerIdName: this.user.callerIdName } }, 'ICE connection failed before success');
|
||||
currentSession.stateChange.addListener((state) => {
|
||||
switch (state) {
|
||||
case SIP.SessionState.Initial:
|
||||
break;
|
||||
case SIP.SessionState.Establishing:
|
||||
handleSessionProgress();
|
||||
break;
|
||||
case SIP.SessionState.Established:
|
||||
handleSessionAccepted();
|
||||
break;
|
||||
case SIP.SessionState.Terminating:
|
||||
break;
|
||||
case SIP.SessionState.Terminated:
|
||||
handleSessionTerminated();
|
||||
break;
|
||||
default:
|
||||
logger.error({
|
||||
logCode: 'sipjs_ice_session_unknown_state',
|
||||
extraInfo: {
|
||||
callerIdName: this.user.callerIdName,
|
||||
},
|
||||
}, 'SIP.js unknown session state');
|
||||
break;
|
||||
}
|
||||
clearTimeout(callTimeout);
|
||||
clearTimeout(iceNegotiationTimeout);
|
||||
ICE_NEGOTIATION_FAILED.forEach(e => mediaHandler.off(e, handleIceNegotiationFailed));
|
||||
this.callback({
|
||||
status: this.baseCallStates.failed,
|
||||
error: 1007,
|
||||
bridgeError: `ICE negotiation failed. Current state - ${peer.iceConnectionState}`,
|
||||
});
|
||||
};
|
||||
ICE_NEGOTIATION_FAILED.forEach(e => mediaHandler.on(e, handleIceNegotiationFailed));
|
||||
|
||||
const handleIceConnectionTerminated = (peer) => {
|
||||
['iceConnectionClosed'].forEach(e => mediaHandler.off(e, handleIceConnectionTerminated));
|
||||
if (!this.userRequestedHangup) {
|
||||
logger.error({ logCode: 'sipjs_ice_closed', extraInfo: { callerIdName: this.user.callerIdName } }, 'ICE connection closed');
|
||||
}
|
||||
/*
|
||||
this.callback({
|
||||
status: this.baseCallStates.failed,
|
||||
error: 1012,
|
||||
bridgeError: "ICE connection closed. Current state - " + peer.iceConnectionState,
|
||||
});
|
||||
*/
|
||||
};
|
||||
['iceConnectionClosed'].forEach(e => mediaHandler.on(e, handleIceConnectionTerminated));
|
||||
this._currentSessionState = state;
|
||||
});
|
||||
|
||||
Tracker.autorun((c) => {
|
||||
const selector = { meetingId: Auth.meetingID, userId: Auth.userID };
|
||||
@ -534,6 +853,8 @@ class SIPSession {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -571,7 +892,11 @@ export default class SIPBridge extends BaseAudioBridge {
|
||||
window.clientLogger = logger;
|
||||
}
|
||||
|
||||
joinAudio({ isListenOnly, extension, inputStream }, managerCallback) {
|
||||
get inputDeviceId () {
|
||||
return this.media.inputDevice ? this.media.inputDevice.inputDeviceId : null;
|
||||
}
|
||||
|
||||
joinAudio({ isListenOnly, extension }, managerCallback) {
|
||||
const hasFallbackDomain = typeof IPV4_FALLBACK_DOMAIN === 'string' && IPV4_FALLBACK_DOMAIN !== '';
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@ -603,7 +928,12 @@ export default class SIPBridge extends BaseAudioBridge {
|
||||
const fallbackExtension = this.activeSession.inEchoTest ? extension : undefined;
|
||||
this.activeSession = new SIPSession(this.user, this.userData, this.protocol,
|
||||
hostname, this.baseCallStates, this.baseErrorCodes, true);
|
||||
this.activeSession.joinAudio({ isListenOnly, extension: fallbackExtension, inputStream }, callback)
|
||||
const { inputDeviceId } = this.media.inputDevice;
|
||||
this.activeSession.joinAudio({
|
||||
isListenOnly,
|
||||
extension: fallbackExtension,
|
||||
inputDeviceId,
|
||||
}, callback)
|
||||
.then((value) => {
|
||||
resolve(value);
|
||||
}).catch((reason) => {
|
||||
@ -615,7 +945,12 @@ export default class SIPBridge extends BaseAudioBridge {
|
||||
return managerCallback(message);
|
||||
};
|
||||
|
||||
this.activeSession.joinAudio({ isListenOnly, extension, inputStream }, callback)
|
||||
const { inputDeviceId } = this.media.inputDevice;
|
||||
this.activeSession.joinAudio({
|
||||
isListenOnly,
|
||||
extension,
|
||||
inputDeviceId,
|
||||
}, callback)
|
||||
.then((value) => {
|
||||
resolve(value);
|
||||
}).catch((reason) => {
|
||||
@ -630,8 +965,8 @@ export default class SIPBridge extends BaseAudioBridge {
|
||||
|
||||
getPeerConnection() {
|
||||
const { currentSession } = this.activeSession;
|
||||
if (currentSession && currentSession.mediaHandler) {
|
||||
return currentSession.mediaHandler.peerConnection;
|
||||
if (currentSession && currentSession.sessionDescriptionHandler) {
|
||||
return currentSession.sessionDescriptionHandler.peerConnection;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -641,62 +976,16 @@ export default class SIPBridge extends BaseAudioBridge {
|
||||
}
|
||||
|
||||
setDefaultInputDevice() {
|
||||
const handleMediaSuccess = (mediaStream) => {
|
||||
const deviceLabel = mediaStream.getAudioTracks()[0].label;
|
||||
window.defaultInputStream = mediaStream.getTracks();
|
||||
return navigator.mediaDevices.enumerateDevices().then((mediaDevices) => {
|
||||
const device = mediaDevices.find(d => d.label === deviceLabel);
|
||||
return this.changeInputDevice(device.deviceId, deviceLabel);
|
||||
});
|
||||
};
|
||||
|
||||
return navigator.mediaDevices.getUserMedia({ audio: true }).then(handleMediaSuccess);
|
||||
this.media.inputDevice.inputDeviceId = DEFAULT_INPUT_DEVICE_ID;
|
||||
}
|
||||
|
||||
changeInputDevice(deviceId, deviceLabel) {
|
||||
const {
|
||||
media,
|
||||
} = this;
|
||||
if (media.inputDevice.audioContext) {
|
||||
const handleAudioContextCloseSuccess = () => {
|
||||
media.inputDevice.audioContext = null;
|
||||
media.inputDevice.scriptProcessor = null;
|
||||
media.inputDevice.source = null;
|
||||
return this.changeInputDevice(deviceId);
|
||||
};
|
||||
|
||||
return media.inputDevice.audioContext.close().then(handleAudioContextCloseSuccess);
|
||||
async changeInputDeviceId(inputDeviceId) {
|
||||
if (!inputDeviceId) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
if ('AudioContext' in window) {
|
||||
media.inputDevice.audioContext = new window.AudioContext();
|
||||
} else {
|
||||
media.inputDevice.audioContext = new window.webkitAudioContext();
|
||||
}
|
||||
|
||||
media.inputDevice.id = deviceId;
|
||||
media.inputDevice.label = deviceLabel;
|
||||
media.inputDevice.scriptProcessor = media.inputDevice.audioContext
|
||||
.createScriptProcessor(2048, 1, 1);
|
||||
media.inputDevice.source = null;
|
||||
|
||||
const constraints = {
|
||||
audio: {
|
||||
deviceId,
|
||||
},
|
||||
};
|
||||
|
||||
const handleMediaSuccess = (mediaStream) => {
|
||||
media.inputDevice.stream = mediaStream;
|
||||
media.inputDevice.source = media.inputDevice.audioContext
|
||||
.createMediaStreamSource(mediaStream);
|
||||
media.inputDevice.source.connect(media.inputDevice.scriptProcessor);
|
||||
media.inputDevice.scriptProcessor.connect(media.inputDevice.audioContext.destination);
|
||||
|
||||
return this.media.inputDevice;
|
||||
};
|
||||
|
||||
return navigator.mediaDevices.getUserMedia(constraints).then(handleMediaSuccess);
|
||||
this.media.inputDevice.inputDeviceId = inputDeviceId;
|
||||
return inputDeviceId;
|
||||
}
|
||||
|
||||
async changeOutputDevice(value) {
|
||||
|
@ -12,7 +12,7 @@ export default function endMeeting() {
|
||||
const payload = {
|
||||
userId: requesterUserId,
|
||||
};
|
||||
Logger.verbose(`Meeting '${meetingId}' is destroyed by '${requesterUserId}'`);
|
||||
Logger.warn(`Meeting '${meetingId}' is destroyed by '${requesterUserId}'`);
|
||||
|
||||
return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
|
||||
}
|
||||
|
@ -4,34 +4,15 @@ import Polls from '/imports/api/polls';
|
||||
import Logger from '/imports/startup/server/logger';
|
||||
import { extractCredentials } from '/imports/api/common/server/helpers';
|
||||
|
||||
export default function publishVote(id, pollAnswerId) { // TODO discuss location
|
||||
export default function publishVote(pollId, pollAnswerId) {
|
||||
const REDIS_CONFIG = Meteor.settings.private.redis;
|
||||
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
|
||||
const EVENT_NAME = 'RespondToPollReqMsg';
|
||||
|
||||
const { meetingId, requesterUserId } = extractCredentials(this.userId);
|
||||
/*
|
||||
We keep an array of people who were in the meeting at the time the poll
|
||||
was started. The poll is published to them only.
|
||||
Once they vote - their ID is removed and they cannot see the poll anymore
|
||||
*/
|
||||
const currentPoll = Polls.findOne({
|
||||
users: requesterUserId,
|
||||
meetingId,
|
||||
'answers.id': pollAnswerId,
|
||||
id,
|
||||
});
|
||||
|
||||
check(pollAnswerId, Number);
|
||||
check(currentPoll, Object);
|
||||
check(currentPoll.meetingId, String);
|
||||
|
||||
const payload = {
|
||||
requesterId: requesterUserId,
|
||||
pollId: currentPoll.id,
|
||||
questionId: 0,
|
||||
answerId: pollAnswerId,
|
||||
};
|
||||
check(pollId, String);
|
||||
|
||||
const selector = {
|
||||
users: requesterUserId,
|
||||
@ -39,6 +20,18 @@ export default function publishVote(id, pollAnswerId) { // TODO discuss location
|
||||
'answers.id': pollAnswerId,
|
||||
};
|
||||
|
||||
const payload = {
|
||||
requesterId: requesterUserId,
|
||||
pollId,
|
||||
questionId: 0,
|
||||
answerId: pollAnswerId,
|
||||
};
|
||||
|
||||
/*
|
||||
We keep an array of people who were in the meeting at the time the poll
|
||||
was started. The poll is published to them only.
|
||||
Once they vote - their ID is removed and they cannot see the poll anymore
|
||||
*/
|
||||
const modifier = {
|
||||
$pull: {
|
||||
users: requesterUserId,
|
||||
@ -47,11 +40,11 @@ export default function publishVote(id, pollAnswerId) { // TODO discuss location
|
||||
|
||||
const cb = (err) => {
|
||||
if (err) {
|
||||
return Logger.error(`Updating Polls collection: ${err}`);
|
||||
return Logger.error(`Removing responded user from Polls collection: ${err}`);
|
||||
}
|
||||
|
||||
return Logger.info(`Updating Polls collection (meetingId: ${meetingId}, `
|
||||
+ `pollId: ${currentPoll.id}!)`);
|
||||
return Logger.info(`Removed responded user=${requesterUserId} from poll (meetingId: ${meetingId}, `
|
||||
+ `pollId: ${pollId}!)`);
|
||||
};
|
||||
|
||||
Polls.update(selector, modifier, cb);
|
||||
|
@ -63,6 +63,7 @@ export default function handleValidateAuthToken({ body }, meetingId) {
|
||||
|
||||
/* Logic migrated from validateAuthToken method ( postponed to only run in case of success response ) - Begin */
|
||||
const sessionId = `${meetingId}--${userId}`;
|
||||
|
||||
methodInvocationObject.setUserId(sessionId);
|
||||
|
||||
const User = Users.findOne({
|
||||
|
@ -30,6 +30,7 @@ export default function userLeaving(meetingId, userId, connectionId) {
|
||||
|
||||
// If the current user connection is not the same that triggered the leave we skip
|
||||
if (auth?.connectionId !== connectionId) {
|
||||
Logger.info(`Skipping userLeaving. User connectionId=${User.connectionId} is different from requester connectionId=${connectionId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -6,6 +6,7 @@ import upsertValidationState from '/imports/api/auth-token-validation/server/mod
|
||||
import { ValidationStates } from '/imports/api/auth-token-validation';
|
||||
import pendingAuthenticationsStore from '../store/pendingAuthentications';
|
||||
import BannedUsers from '../store/bannedUsers';
|
||||
import Users from '/imports/api/users';
|
||||
|
||||
export default function validateAuthToken(meetingId, requesterUserId, requesterToken, externalId) {
|
||||
const REDIS_CONFIG = Meteor.settings.private.redis;
|
||||
@ -16,10 +17,27 @@ export default function validateAuthToken(meetingId, requesterUserId, requesterT
|
||||
if (externalId) {
|
||||
if (BannedUsers.has(meetingId, externalId)) {
|
||||
Logger.warn(`A banned user with extId ${externalId} tried to enter in meeting ${meetingId}`);
|
||||
return;
|
||||
return { invalid: true, reason: 'User has been banned', error_type: 'user_banned' };
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent users who have left or been ejected to use the same sessionToken again.
|
||||
const isUserInvalid = Users.findOne({
|
||||
meetingId,
|
||||
userId: requesterUserId,
|
||||
authToken: requesterToken,
|
||||
$or: [{ ejected: true }, { loggedOut: true }],
|
||||
});
|
||||
|
||||
if (isUserInvalid) {
|
||||
Logger.warn(`An invalid sessionToken tried to validateAuthToken meetingId=${meetingId} authToken=${requesterToken}`);
|
||||
return {
|
||||
invalid: true,
|
||||
reason: `User has an invalid sessionToken due to ${isUserInvalid.ejected ? 'ejection' : 'log out'}`,
|
||||
error_type: `invalid_session_token_due_to_${isUserInvalid.ejected ? 'eject' : 'log_out'}`,
|
||||
};
|
||||
}
|
||||
|
||||
ClientConnections.add(`${meetingId}--${requesterUserId}`, this.connection);
|
||||
|
||||
// Store reference of methodInvocationObject ( to postpone the connection userId definition )
|
||||
|
@ -359,8 +359,9 @@ const BaseContainer = withTracker(() => {
|
||||
changed: (newDocument) => {
|
||||
if (newDocument.validated && newDocument.name && newDocument.userId !== localUserId) {
|
||||
if (userJoinAudioAlerts) {
|
||||
const audio = new Audio(`${Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename}/resources/sounds/userJoin.mp3`);
|
||||
audio.play();
|
||||
AudioService.playAlertSound(`${Meteor.settings.public.app.cdn
|
||||
+ Meteor.settings.public.app.basename}`
|
||||
+ '/resources/sounds/userJoin.mp3');
|
||||
}
|
||||
|
||||
if (userJoinPushAlerts) {
|
||||
|
@ -71,7 +71,8 @@ class MeteorStream {
|
||||
'logClient',
|
||||
nameFromLevel[this.rec.level],
|
||||
this.rec.msg,
|
||||
{ clientURL },
|
||||
this.rec.logCode,
|
||||
{ ...rec.extraInfo, clientURL },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -118,7 +118,7 @@ class ActionsBar extends PureComponent {
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className={cx(styles.button, autoArrangeLayout || styles.btn)}
|
||||
className={cx(styles.btn, autoArrangeLayout || styles.btn)}
|
||||
icon={autoArrangeLayout ? 'lock' : 'unlock'}
|
||||
color={autoArrangeLayout ? 'primary' : 'default'}
|
||||
ghost={!autoArrangeLayout}
|
||||
|
@ -13,6 +13,7 @@ import getFromUserSettings from '/imports/ui/services/users-settings';
|
||||
import deviceInfo from '/imports/utils/deviceInfo';
|
||||
import UserInfos from '/imports/api/users-infos';
|
||||
import { startBandwidthMonitoring, updateNavigatorConnection } from '/imports/ui/services/network-information/index';
|
||||
import logger from '/imports/startup/client/logger';
|
||||
|
||||
import {
|
||||
getFontSize,
|
||||
@ -73,9 +74,9 @@ const currentUserEmoji = currentUser => (currentUser ? {
|
||||
status: currentUser.emoji,
|
||||
changedAt: currentUser.emojiTime,
|
||||
} : {
|
||||
status: 'none',
|
||||
changedAt: null,
|
||||
});
|
||||
status: 'none',
|
||||
changedAt: null,
|
||||
});
|
||||
|
||||
export default injectIntl(withModalMounter(withTracker(({ intl, baseControls }) => {
|
||||
const authTokenValidation = AuthTokenValidation.findOne({}, { sort: { updatedAt: -1 } });
|
||||
|
@ -245,8 +245,6 @@ class AudioModal extends Component {
|
||||
}
|
||||
|
||||
const {
|
||||
inputDeviceId,
|
||||
outputDeviceId,
|
||||
joinEchoTest,
|
||||
} = this.props;
|
||||
|
||||
@ -268,12 +266,27 @@ class AudioModal extends Component {
|
||||
disableActions: false,
|
||||
});
|
||||
}).catch((err) => {
|
||||
if (err.type === 'MEDIA_ERROR') {
|
||||
this.setState({
|
||||
content: 'help',
|
||||
errCode: err.code,
|
||||
disableActions: false,
|
||||
});
|
||||
const { type } = err;
|
||||
switch (type) {
|
||||
case 'MEDIA_ERROR':
|
||||
this.setState({
|
||||
content: 'help',
|
||||
errCode: 0,
|
||||
disableActions: false,
|
||||
});
|
||||
break;
|
||||
case 'CONNECTION_ERROR':
|
||||
this.setState({
|
||||
errCode: 0,
|
||||
disableActions: false,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
this.setState({
|
||||
errCode: 0,
|
||||
disableActions: false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -88,4 +88,5 @@ export default {
|
||||
isVoiceUser,
|
||||
autoplayBlocked: () => AudioManager.autoplayBlocked,
|
||||
handleAllowAutoplay: () => AudioManager.handleAllowAutoplay(),
|
||||
playAlertSound: url => AudioManager.playAlertSound(url),
|
||||
};
|
||||
|
@ -224,13 +224,12 @@ class BreakoutRoom extends PureComponent {
|
||||
(
|
||||
<Button
|
||||
label={
|
||||
moderatorJoinedAudio
|
||||
&& stateBreakoutId === breakoutId
|
||||
&& joinedAudioOnly
|
||||
stateBreakoutId === breakoutId && joinedAudioOnly
|
||||
? intl.formatMessage(intlMessages.breakoutReturnAudio)
|
||||
: intl.formatMessage(intlMessages.breakoutJoinAudio)
|
||||
}
|
||||
className={styles.button}
|
||||
disabled={stateBreakoutId !== breakoutId && joinedAudioOnly}
|
||||
key={`join-audio-${breakoutId}`}
|
||||
onClick={audioAction}
|
||||
/>
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import AudioService from '/imports/ui/components/audio/service';
|
||||
|
||||
const propTypes = {
|
||||
play: PropTypes.bool.isRequired,
|
||||
@ -8,17 +9,16 @@ const propTypes = {
|
||||
class ChatAudioAlert extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.audio = new Audio(`${Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename}/resources/sounds/notify.mp3`);
|
||||
this.handleAudioLoaded = this.handleAudioLoaded.bind(this);
|
||||
this.playAudio = this.playAudio.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.audio.addEventListener('loadedmetadata', this.handleAudioLoaded);
|
||||
this.handleAudioLoaded();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.audio.removeEventListener('loadedmetadata', this.handleAudioLoaded);
|
||||
this.handleAudioLoaded();
|
||||
}
|
||||
|
||||
handleAudioLoaded() {
|
||||
@ -28,7 +28,9 @@ class ChatAudioAlert extends Component {
|
||||
playAudio() {
|
||||
const { play } = this.props;
|
||||
if (!play) return;
|
||||
this.audio.play();
|
||||
AudioService.playAlertSound(`${Meteor.settings.public.app.cdn
|
||||
+ Meteor.settings.public.app.basename}`
|
||||
+ '/resources/sounds/notify.mp3');
|
||||
}
|
||||
|
||||
render() {
|
||||
|
@ -35,10 +35,7 @@ export function publishCursorUpdate(payload) {
|
||||
}
|
||||
|
||||
export function initCursorStreamListener() {
|
||||
logger.info({
|
||||
logCode: 'init_cursor_stream_listener',
|
||||
extraInfo: { meetingId: Auth.meetingID, userId: Auth.userID },
|
||||
}, 'initCursorStreamListener called');
|
||||
logger.info({ logCode: 'init_cursor_stream_listener' }, 'initCursorStreamListener called');
|
||||
|
||||
/**
|
||||
* We create a promise to add the handlers after a ddp subscription stop.
|
||||
@ -60,9 +57,7 @@ export function initCursorStreamListener() {
|
||||
});
|
||||
|
||||
startStreamHandlersPromise.then(() => {
|
||||
logger.debug({
|
||||
logCode: 'init_cursor_stream_listener',
|
||||
}, 'initCursorStreamListener called');
|
||||
logger.debug({ logCode: 'cursor_stream_handler_attach' }, 'Attaching handlers for cursor stream');
|
||||
|
||||
cursorStreamListener.on('message', ({ cursors }) => {
|
||||
Object.keys(cursors).forEach((userId) => {
|
||||
|
@ -3,6 +3,7 @@ import { withTracker } from 'meteor/react-meteor-data';
|
||||
import { withModalMounter } from '/imports/ui/components/modal/service';
|
||||
import { makeCall } from '/imports/ui/services/api';
|
||||
import EndMeetingComponent from './component';
|
||||
import logger from '/imports/startup/client/logger';
|
||||
|
||||
const EndMeetingContainer = props => <EndMeetingComponent {...props} />;
|
||||
|
||||
@ -12,6 +13,10 @@ export default withModalMounter(withTracker(({ mountModal }) => ({
|
||||
},
|
||||
|
||||
endMeeting: () => {
|
||||
logger.warn({
|
||||
logCode: 'moderator_forcing_end_meeting',
|
||||
extraInfo: { logType: 'user_action' },
|
||||
}, 'this user clicked on EndMeeting and confirmed, removing everybody from the meeting');
|
||||
makeCall('endMeeting');
|
||||
mountModal(null);
|
||||
},
|
||||
|
@ -15,6 +15,9 @@ const intlMessages = defineMessages({
|
||||
410: {
|
||||
id: 'app.error.410',
|
||||
},
|
||||
408: {
|
||||
id: 'app.error.408',
|
||||
},
|
||||
404: {
|
||||
id: 'app.error.404',
|
||||
defaultMessage: 'Not found',
|
||||
|
@ -1,7 +1,7 @@
|
||||
import loadScript from 'load-script';
|
||||
import React, { Component } from 'react'
|
||||
|
||||
const MATCH_URL = new RegExp("https?:\/\/(\\w+)[.](instructuremedia.com)(\/embed)?\/([-abcdef0-9]+)");
|
||||
const MATCH_URL = new RegExp("https?:\/\/(.*)(instructuremedia.com)(\/embed)?\/([-abcdef0-9]+)");
|
||||
|
||||
const SDK_URL = 'https://files.instructuremedia.com/instructure-media-script/instructure-media-1.1.0.js';
|
||||
|
||||
|
@ -181,6 +181,7 @@ class JoinHandler extends Component {
|
||||
const { response } = parseToJson;
|
||||
|
||||
setLogoutURL(response);
|
||||
logUserInfo();
|
||||
|
||||
if (response.returncode !== 'FAILED') {
|
||||
await setAuth(response);
|
||||
@ -188,7 +189,6 @@ class JoinHandler extends Component {
|
||||
setBannerProps(response);
|
||||
setLogoURL(response);
|
||||
setModOnlyMessage(response);
|
||||
logUserInfo();
|
||||
|
||||
Tracker.autorun(async (cd) => {
|
||||
const user = Users.findOne({ userId: Auth.userID, approved: true }, { fields: { _id: 1 } });
|
||||
|
@ -68,7 +68,7 @@ import './styles.css';
|
||||
const FETCHING = 'fetching';
|
||||
const FALLBACK = 'fallback';
|
||||
const READY = 'ready';
|
||||
const supportedBrowsers = ['chrome', 'firefox', 'safari', 'opera', 'edge'];
|
||||
const supportedBrowsers = ['chrome', 'firefox', 'safari', 'opera', 'edge', 'yandex'];
|
||||
|
||||
export default class Legacy extends Component {
|
||||
constructor(props) {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import cx from 'classnames';
|
||||
import Settings from '/imports/ui/services/settings';
|
||||
import { isMobile, isIPad13 } from 'react-device-detect';
|
||||
import WebcamDraggable from './webcam-draggable-overlay/component';
|
||||
import { styles } from './styles';
|
||||
@ -65,6 +66,9 @@ export default class Media extends Component {
|
||||
[styles.containerV]: webcamsPlacement === 'top' || webcamsPlacement === 'bottom' || webcamsPlacement === 'floating',
|
||||
[styles.containerH]: webcamsPlacement === 'left' || webcamsPlacement === 'right',
|
||||
});
|
||||
const { viewParticipantsWebcams } = Settings.dataSaving;
|
||||
const showVideo = usersVideo.length > 0 && viewParticipantsWebcams;
|
||||
const fullHeight = !showVideo || (webcamsPlacement === 'floating');
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -103,22 +107,18 @@ export default class Media extends Component {
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{
|
||||
usersVideo.length > 0
|
||||
? (
|
||||
<WebcamDraggable
|
||||
refMediaContainer={this.refContainer}
|
||||
swapLayout={swapLayout}
|
||||
singleWebcam={singleWebcam}
|
||||
usersVideoLenght={usersVideo.length}
|
||||
hideOverlay={hideOverlay}
|
||||
disableVideo={disableVideo}
|
||||
audioModalIsOpen={audioModalIsOpen}
|
||||
usersVideo={usersVideo}
|
||||
/>
|
||||
)
|
||||
: null
|
||||
}
|
||||
{showVideo ? (
|
||||
<WebcamDraggable
|
||||
refMediaContainer={this.refContainer}
|
||||
swapLayout={swapLayout}
|
||||
singleWebcam={singleWebcam}
|
||||
usersVideoLenght={usersVideo.length}
|
||||
hideOverlay={hideOverlay}
|
||||
disableVideo={disableVideo}
|
||||
audioModalIsOpen={audioModalIsOpen}
|
||||
usersVideo={usersVideo}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import Auth from '/imports/ui/services/auth';
|
||||
import Button from '/imports/ui/components/button/component';
|
||||
import allowRedirectToLogoutURL from './service';
|
||||
import getFromUserSettings from '/imports/ui/services/users-settings';
|
||||
import logoutRouteHandler from '/imports/utils/logoutRouteHandler';
|
||||
import Rating from './rating/component';
|
||||
@ -103,6 +104,7 @@ class MeetingEnded extends PureComponent {
|
||||
super(props);
|
||||
this.state = {
|
||||
selected: 0,
|
||||
dispatched: false,
|
||||
};
|
||||
|
||||
const user = Users.findOne({ userId: Auth.userID });
|
||||
@ -111,8 +113,9 @@ class MeetingEnded extends PureComponent {
|
||||
}
|
||||
|
||||
this.setSelectedStar = this.setSelectedStar.bind(this);
|
||||
this.confirmRedirect = this.confirmRedirect.bind(this);
|
||||
this.sendFeedback = this.sendFeedback.bind(this);
|
||||
this.shouldShowFeedback = getFromUserSettings('bbb_ask_for_feedback_on_logout', Meteor.settings.public.app.askForFeedbackOnLogout);
|
||||
this.shouldShowFeedback = this.shouldShowFeedback.bind(this);
|
||||
|
||||
AudioManager.exitAudio();
|
||||
Meteor.disconnect();
|
||||
@ -124,16 +127,26 @@ class MeetingEnded extends PureComponent {
|
||||
});
|
||||
}
|
||||
|
||||
sendFeedback() {
|
||||
shouldShowFeedback() {
|
||||
const { dispatched } = this.state;
|
||||
return getFromUserSettings('bbb_ask_for_feedback_on_logout', Meteor.settings.public.app.askForFeedbackOnLogout) && !dispatched;
|
||||
}
|
||||
|
||||
confirmRedirect() {
|
||||
const {
|
||||
selected,
|
||||
} = this.state;
|
||||
|
||||
if (selected <= 0) {
|
||||
if (meetingIsBreakout()) window.close();
|
||||
logoutRouteHandler();
|
||||
return;
|
||||
if (allowRedirectToLogoutURL()) logoutRouteHandler();
|
||||
}
|
||||
}
|
||||
|
||||
sendFeedback() {
|
||||
const {
|
||||
selected,
|
||||
} = this.state;
|
||||
|
||||
const { fullname } = Auth.credentials;
|
||||
|
||||
@ -158,25 +171,70 @@ class MeetingEnded extends PureComponent {
|
||||
// client logger
|
||||
logger.info({ logCode: 'feedback_functionality', extraInfo: { feedback: message } }, 'Feedback component');
|
||||
|
||||
const FEEDBACK_WAIT_TIME = 500;
|
||||
setTimeout(() => {
|
||||
fetch(url, options)
|
||||
.then(() => {
|
||||
logoutRouteHandler();
|
||||
})
|
||||
.catch(() => {
|
||||
logoutRouteHandler();
|
||||
});
|
||||
}, FEEDBACK_WAIT_TIME);
|
||||
this.setState({
|
||||
dispatched: true,
|
||||
});
|
||||
|
||||
if (allowRedirectToLogoutURL()) {
|
||||
const FEEDBACK_WAIT_TIME = 500;
|
||||
setTimeout(() => {
|
||||
fetch(url, options)
|
||||
.then(() => {
|
||||
logoutRouteHandler();
|
||||
})
|
||||
.catch(() => {
|
||||
logoutRouteHandler();
|
||||
});
|
||||
}, FEEDBACK_WAIT_TIME);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { code, intl, reason } = this.props;
|
||||
const { selected } = this.state;
|
||||
renderNoFeedback() {
|
||||
const { intl, code, reason } = this.props;
|
||||
|
||||
logger.info({ logCode: 'meeting_ended_code', extraInfo: { endedCode: code, reason } }, 'Meeting ended component, no feedback configured');
|
||||
|
||||
return (
|
||||
<div className={styles.parent}>
|
||||
<div className={styles.modal}>
|
||||
<div className={styles.content}>
|
||||
<h1 className={styles.title} data-test="meetingEndedModalTitle">
|
||||
{
|
||||
intl.formatMessage(intlMessage[code] || intlMessage[430])
|
||||
}
|
||||
</h1>
|
||||
{!allowRedirectToLogoutURL() ? null : (
|
||||
<div>
|
||||
<div className={styles.text}>
|
||||
{intl.formatMessage(intlMessage.messageEnded)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={this.confirmRedirect}
|
||||
className={styles.button}
|
||||
label={intl.formatMessage(intlMessage.buttonOkay)}
|
||||
description={intl.formatMessage(intlMessage.confirmDesc)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderFeedback() {
|
||||
const { intl, code, reason } = this.props;
|
||||
const {
|
||||
selected,
|
||||
dispatched,
|
||||
} = this.state;
|
||||
|
||||
const noRating = selected <= 0;
|
||||
|
||||
logger.info({ logCode: 'meeting_ended_code', extraInfo: { endedCode: code, reason } }, 'Meeting ended component');
|
||||
logger.info({ logCode: 'meeting_ended_code', extraInfo: { endedCode: code, reason } }, 'Meeting ended component, feedback allowed');
|
||||
|
||||
return (
|
||||
<div className={styles.parent}>
|
||||
@ -188,11 +246,12 @@ class MeetingEnded extends PureComponent {
|
||||
}
|
||||
</h1>
|
||||
<div className={styles.text}>
|
||||
{this.shouldShowFeedback
|
||||
{this.shouldShowFeedback()
|
||||
? intl.formatMessage(intlMessage.subtitle)
|
||||
: intl.formatMessage(intlMessage.messageEnded)}
|
||||
</div>
|
||||
{this.shouldShowFeedback ? (
|
||||
|
||||
{this.shouldShowFeedback() ? (
|
||||
<div data-test="rating">
|
||||
<Rating
|
||||
total="5"
|
||||
@ -209,22 +268,35 @@ class MeetingEnded extends PureComponent {
|
||||
) : null}
|
||||
</div>
|
||||
) : null }
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={this.sendFeedback}
|
||||
className={styles.button}
|
||||
label={noRating
|
||||
? intl.formatMessage(intlMessage.buttonOkay)
|
||||
: intl.formatMessage(intlMessage.sendLabel)}
|
||||
description={noRating
|
||||
? intl.formatMessage(intlMessage.confirmDesc)
|
||||
: intl.formatMessage(intlMessage.sendDesc)}
|
||||
/>
|
||||
{noRating && allowRedirectToLogoutURL() ? (
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={this.confirmRedirect}
|
||||
className={styles.button}
|
||||
label={intl.formatMessage(intlMessage.buttonOkay)}
|
||||
description={intl.formatMessage(intlMessage.confirmDesc)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!noRating && !dispatched ? (
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={this.sendFeedback}
|
||||
className={styles.button}
|
||||
label={intl.formatMessage(intlMessage.sendLabel)}
|
||||
description={intl.formatMessage(intlMessage.sendDesc)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.shouldShowFeedback()) return this.renderFeedback();
|
||||
return this.renderNoFeedback();
|
||||
}
|
||||
}
|
||||
|
||||
MeetingEnded.propTypes = propTypes;
|
||||
|
@ -0,0 +1,22 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import Auth from '/imports/ui/services/auth';
|
||||
|
||||
|
||||
export default function allowRedirectToLogoutURL() {
|
||||
const ALLOW_DEFAULT_LOGOUT_URL = Meteor.settings.public.app.allowDefaultLogoutUrl;
|
||||
const protocolPattern = /^((http|https):\/\/)/;
|
||||
if (Auth.logoutURL) {
|
||||
// default logoutURL
|
||||
// compare only the host to ignore protocols
|
||||
const urlWithoutProtocolForAuthLogout = Auth.logoutURL.replace(protocolPattern, '');
|
||||
const urlWithoutProtocolForLocationOrigin = window.location.origin.replace(protocolPattern, '');
|
||||
if (urlWithoutProtocolForAuthLogout === urlWithoutProtocolForLocationOrigin) {
|
||||
return ALLOW_DEFAULT_LOGOUT_URL;
|
||||
}
|
||||
|
||||
// custom logoutURL
|
||||
return true;
|
||||
}
|
||||
// no logout url
|
||||
return false;
|
||||
}
|
@ -5,6 +5,7 @@ import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrap
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import cx from 'classnames';
|
||||
import { styles } from './styles.scss';
|
||||
import AudioService from '/imports/ui/components/audio/service';
|
||||
|
||||
const intlMessages = defineMessages({
|
||||
pollingTitleLabel: {
|
||||
@ -30,8 +31,9 @@ class Polling extends Component {
|
||||
}
|
||||
|
||||
play() {
|
||||
this.alert = new Audio(`${Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename}/resources/sounds/Poll.mp3`);
|
||||
this.alert.play();
|
||||
AudioService.playAlertSound(`${Meteor.settings.public.app.cdn
|
||||
+ Meteor.settings.public.app.basename}`
|
||||
+ '/resources/sounds/Poll.mp3');
|
||||
}
|
||||
|
||||
render() {
|
||||
@ -42,6 +44,9 @@ class Polling extends Component {
|
||||
handleVote,
|
||||
pollAnswerIds,
|
||||
} = this.props;
|
||||
|
||||
if (!poll) return null;
|
||||
|
||||
const { stackOptions, answers } = poll;
|
||||
const pollAnswerStyles = {
|
||||
[styles.pollingAnswers]: true,
|
||||
|
@ -8,6 +8,7 @@ import { stopWatching } from '/imports/ui/components/external-video-player/servi
|
||||
import Meetings from '/imports/api/meetings';
|
||||
import Auth from '/imports/ui/services/auth';
|
||||
import UserListService from '/imports/ui/components/user-list/service';
|
||||
import AudioService from '/imports/ui/components/audio/service';
|
||||
|
||||
// when the meeting information has been updated check to see if it was
|
||||
// screensharing. If it has changed either trigger a call to receive video
|
||||
@ -70,7 +71,10 @@ const shareScreen = (onFail) => {
|
||||
}).catch(onFail);
|
||||
};
|
||||
|
||||
const screenShareEndAlert = () => new Audio(`${Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename}/resources/sounds/ScreenshareOff.mp3`).play();
|
||||
const screenShareEndAlert = () => AudioService
|
||||
.playAlertSound(`${Meteor.settings.public.app.cdn
|
||||
+ Meteor.settings.public.app.basename}`
|
||||
+ '/resources/sounds/ScreenshareOff.mp3');
|
||||
|
||||
const unshareScreen = () => {
|
||||
KurentoBridge.kurentoExitScreenShare();
|
||||
|
@ -187,7 +187,7 @@ const userFindSorting = {
|
||||
emojiTime: 1,
|
||||
role: 1,
|
||||
phoneUser: 1,
|
||||
sortName: 1,
|
||||
name: 1,
|
||||
userId: 1,
|
||||
};
|
||||
|
||||
@ -534,6 +534,7 @@ const sortUsersByFirstName = (a, b) => {
|
||||
};
|
||||
|
||||
const sortUsersByLastName = (a, b) => {
|
||||
if (!a.lastName && !b.lastName) return 0;
|
||||
if (a.lastName && !b.lastName) return -1;
|
||||
if (!a.lastName && b.lastName) return 1;
|
||||
|
||||
@ -555,7 +556,7 @@ export const getUserNamesLink = (docTitle, fnSortedLabel, lnSortedLabel) => {
|
||||
const mimeType = 'text/plain';
|
||||
const userNamesObj = getUsers()
|
||||
.map((u) => {
|
||||
const name = u.sortName.split(' ');
|
||||
const name = u.name.split(' ');
|
||||
return ({
|
||||
firstName: name[0],
|
||||
middleNames: name.length > 2 ? name.slice(1, name.length - 1) : null,
|
||||
|
@ -86,6 +86,8 @@ class UserNotes extends Component {
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={intl.formatMessage(intlMessages.sharedNotes)}
|
||||
aria-describedby="lockedNote"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={styles.listItem}
|
||||
@ -100,7 +102,7 @@ class UserNotes extends Component {
|
||||
? (
|
||||
<div className={styles.noteLock}>
|
||||
<Icon iconName="lock" />
|
||||
<span>{`${intl.formatMessage(intlMessages.locked)} ${intl.formatMessage(intlMessages.byModerator)}`}</span>
|
||||
<span id="lockedNote">{`${intl.formatMessage(intlMessages.locked)} ${intl.formatMessage(intlMessages.byModerator)}`}</span>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
@ -269,8 +269,8 @@ class VideoPreview extends Component {
|
||||
if (device.kind === 'videoinput' && !found) {
|
||||
webcams.push(device);
|
||||
if (!initialDeviceId
|
||||
|| (webcamDeviceId && webcamDeviceId === device.deviceId)
|
||||
|| device.deviceId === firstAllowedDeviceId) {
|
||||
|| (webcamDeviceId && webcamDeviceId === device.deviceId)
|
||||
|| device.deviceId === firstAllowedDeviceId) {
|
||||
initialDeviceId = device.deviceId;
|
||||
}
|
||||
}
|
||||
@ -310,18 +310,18 @@ class VideoPreview extends Component {
|
||||
});
|
||||
});
|
||||
}).catch((error) => {
|
||||
logger.warn({
|
||||
logCode: 'video_preview_initial_device_error',
|
||||
extraInfo: {
|
||||
errorName: error.name,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
}, 'Error getting initial device');
|
||||
this.setState({
|
||||
viewState: VIEW_STATES.error,
|
||||
deviceError: VideoPreview.handleGUMError(error),
|
||||
});
|
||||
logger.warn({
|
||||
logCode: 'video_preview_initial_device_error',
|
||||
extraInfo: {
|
||||
errorName: error.name,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
}, 'Error getting initial device');
|
||||
this.setState({
|
||||
viewState: VIEW_STATES.error,
|
||||
deviceError: VideoPreview.handleGUMError(error),
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn({
|
||||
logCode: 'video_preview_grabbing_error',
|
||||
@ -539,14 +539,14 @@ class VideoPreview extends Component {
|
||||
{ shared
|
||||
? (
|
||||
<span className={styles.label}>
|
||||
{intl.formatMessage(intlMessages.sharedCameraLabel)}
|
||||
</span>
|
||||
{intl.formatMessage(intlMessages.sharedCameraLabel)}
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<span>
|
||||
<label className={styles.label} htmlFor="setQuality">
|
||||
{intl.formatMessage(intlMessages.qualityLabel)}
|
||||
</label>
|
||||
<label className={styles.label} htmlFor="setQuality">
|
||||
{intl.formatMessage(intlMessages.qualityLabel)}
|
||||
</label>
|
||||
{ availableProfiles && availableProfiles.length > 0
|
||||
? (
|
||||
<select
|
||||
@ -557,23 +557,23 @@ class VideoPreview extends Component {
|
||||
disabled={skipVideoPreview}
|
||||
>
|
||||
{availableProfiles.map(profile => {
|
||||
const label = intlMessages[`${profile.id}`]
|
||||
? intl.formatMessage(intlMessages[`${profile.id}`])
|
||||
: profile.name;
|
||||
const label = intlMessages[`${profile.id}`]
|
||||
? intl.formatMessage(intlMessages[`${profile.id}`])
|
||||
: profile.name;
|
||||
|
||||
return (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{`${label} ${profile.id === 'hd' ? '' : intl.formatMessage(intlMessages.qualityLabel).toLowerCase()}`}
|
||||
</option>
|
||||
)})}
|
||||
return (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{`${label}`}
|
||||
</option>
|
||||
)})}
|
||||
</select>
|
||||
)
|
||||
: (
|
||||
<span>
|
||||
{intl.formatMessage(intlMessages.profileNotFoundLabel)}
|
||||
</span>
|
||||
{intl.formatMessage(intlMessages.profileNotFoundLabel)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -616,25 +616,25 @@ class VideoPreview extends Component {
|
||||
<div className={styles.content}>
|
||||
<div className={styles.videoCol}>
|
||||
{
|
||||
previewError
|
||||
? (
|
||||
<div>{previewError}</div>
|
||||
)
|
||||
: (
|
||||
<video
|
||||
id="preview"
|
||||
data-test={this.mirrorOwnWebcam ? 'mirroredVideoPreview' : 'videoPreview'}
|
||||
className={cx({
|
||||
[styles.preview]: true,
|
||||
[styles.mirroredVideo]: this.mirrorOwnWebcam,
|
||||
})}
|
||||
ref={(ref) => { this.video = ref; }}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
/>
|
||||
)
|
||||
}
|
||||
previewError
|
||||
? (
|
||||
<div>{previewError}</div>
|
||||
)
|
||||
: (
|
||||
<video
|
||||
id="preview"
|
||||
data-test={this.mirrorOwnWebcam ? 'mirroredVideoPreview' : 'videoPreview'}
|
||||
className={cx({
|
||||
[styles.preview]: true,
|
||||
[styles.mirroredVideo]: this.mirrorOwnWebcam,
|
||||
})}
|
||||
ref={(ref) => { this.video = ref; }}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{this.renderDeviceSelectors()}
|
||||
</div>
|
||||
|
@ -16,10 +16,6 @@ const intlMessages = defineMessages({
|
||||
id: 'app.video.leaveVideo',
|
||||
description: 'Leave video button label',
|
||||
},
|
||||
videoButtonDesc: {
|
||||
id: 'app.video.videoButtonDesc',
|
||||
description: 'video button description',
|
||||
},
|
||||
videoLocked: {
|
||||
id: 'app.video.videoLocked',
|
||||
description: 'video disabled label',
|
||||
@ -68,18 +64,19 @@ const JoinVideoButton = ({
|
||||
}
|
||||
};
|
||||
|
||||
const label = exitVideo()
|
||||
let label = exitVideo()
|
||||
? intl.formatMessage(intlMessages.leaveVideo)
|
||||
: intl.formatMessage(intlMessages.joinVideo);
|
||||
|
||||
if (disableReason) label = intl.formatMessage(intlMessages[disableReason]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
label={label}
|
||||
data-test="joinVideo"
|
||||
label={disableReason ? intl.formatMessage(intlMessages[disableReason]) : label}
|
||||
className={cx(styles.button, hasVideoStream || styles.btn)}
|
||||
onClick={handleOnClick}
|
||||
hideLabel
|
||||
aria-label={intl.formatMessage(intlMessages.videoButtonDesc)}
|
||||
color={hasVideoStream ? 'primary' : 'default'}
|
||||
icon={hasVideoStream ? 'video' : 'video_off'}
|
||||
ghost={!hasVideoStream}
|
||||
|
@ -3,6 +3,7 @@ import Auth from '/imports/ui/services/auth';
|
||||
import WhiteboardMultiUser from '/imports/api/whiteboard-multi-user/';
|
||||
import addAnnotationQuery from '/imports/api/annotations/addAnnotation';
|
||||
import { makeCall } from '/imports/ui/services/api';
|
||||
import logger from '/imports/startup/client/logger';
|
||||
|
||||
const Annotations = new Mongo.Collection(null);
|
||||
const UnsentAnnotations = new Mongo.Collection(null);
|
||||
@ -53,6 +54,7 @@ function handleRemovedAnnotation({
|
||||
}
|
||||
|
||||
export function initAnnotationsStreamListener() {
|
||||
logger.info({ logCode: 'init_annotations_stream_listener' }, 'initAnnotationsStreamListener called');
|
||||
/**
|
||||
* We create a promise to add the handlers after a ddp subscription stop.
|
||||
* The problem was caused because we add handlers to stream before the onStop event happens,
|
||||
@ -73,6 +75,8 @@ export function initAnnotationsStreamListener() {
|
||||
});
|
||||
|
||||
startStreamHandlersPromise.then(() => {
|
||||
logger.debug({ logCode: 'annotations_stream_handler_attach' }, 'Attaching handlers for annotations stream');
|
||||
|
||||
annotationsStreamListener.on('removed', handleRemovedAnnotation);
|
||||
|
||||
annotationsStreamListener.on('added', ({ annotations }) => {
|
||||
|
@ -8,10 +8,19 @@ import CursorListener from './cursor-listener/component';
|
||||
export default class WhiteboardOverlay extends Component {
|
||||
// a function to transform a screen point to svg point
|
||||
// accepts and returns a point of type SvgPoint and an svg object
|
||||
// if unable to get the screen CTM, returns an out
|
||||
// of bounds (-1, -1) svg point
|
||||
static coordinateTransform(screenPoint, someSvgObject) {
|
||||
const CTM = someSvgObject.getScreenCTM();
|
||||
if (!CTM) return {};
|
||||
return screenPoint.matrixTransform(CTM.inverse());
|
||||
if (CTM !== null) {
|
||||
return screenPoint.matrixTransform(CTM.inverse());
|
||||
}
|
||||
|
||||
const outOfBounds = someSvgObject.createSVGPoint();
|
||||
outOfBounds.x = -1;
|
||||
outOfBounds.y = -1;
|
||||
|
||||
return outOfBounds;
|
||||
}
|
||||
|
||||
// Removes selection from all selected elements
|
||||
|
@ -5,6 +5,8 @@ const ANNOTATION_CONFIG = Meteor.settings.public.whiteboard.annotations;
|
||||
const DRAW_START = ANNOTATION_CONFIG.status.start;
|
||||
const DRAW_UPDATE = ANNOTATION_CONFIG.status.update;
|
||||
const DRAW_END = ANNOTATION_CONFIG.status.end;
|
||||
const DEFAULT_TEXT_WIDTH = 30;
|
||||
const DEFAULT_TEXT_HEIGHT = 20;
|
||||
|
||||
// maximum value of z-index to prevent other things from overlapping
|
||||
const MAX_Z_INDEX = (2 ** 31) - 1;
|
||||
@ -362,6 +364,7 @@ export default class TextDrawListener extends Component {
|
||||
actions,
|
||||
slideWidth,
|
||||
slideHeight,
|
||||
drawSettings,
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
@ -382,14 +385,34 @@ export default class TextDrawListener extends Component {
|
||||
generateNewShapeId,
|
||||
getCurrentShapeId,
|
||||
setTextShapeActiveId,
|
||||
normalizeFont,
|
||||
} = actions;
|
||||
|
||||
const {
|
||||
textFontSize,
|
||||
} = drawSettings;
|
||||
|
||||
const calcedFontSize = normalizeFont(textFontSize);
|
||||
let calcedTextBoxWidth = (textBoxWidth / slideWidth) * 100;
|
||||
let calcedTextBoxHeight = (textBoxHeight / slideHeight) * 100;
|
||||
const useDefaultSize = (textBoxWidth === 0 && textBoxHeight === 0)
|
||||
|| calcedTextBoxWidth < calcedFontSize
|
||||
|| calcedTextBoxHeight < calcedFontSize;
|
||||
|
||||
// coordinates and width/height of the textarea in percentages of the current slide
|
||||
// saving them in the class since they will be used during all updates
|
||||
this.currentX = (textBoxX / slideWidth) * 100;
|
||||
this.currentY = (textBoxY / slideHeight) * 100;
|
||||
this.currentWidth = (textBoxWidth / slideWidth) * 100;
|
||||
this.currentHeight = (textBoxHeight / slideHeight) * 100;
|
||||
|
||||
if (useDefaultSize) {
|
||||
calcedTextBoxWidth = DEFAULT_TEXT_WIDTH;
|
||||
calcedTextBoxHeight = DEFAULT_TEXT_HEIGHT;
|
||||
if (100 - this.currentX < calcedTextBoxWidth) calcedTextBoxWidth = 100 - this.currentX;
|
||||
if (100 - this.currentY < calcedTextBoxHeight) calcedTextBoxHeight = 100 - this.currentY;
|
||||
}
|
||||
|
||||
this.currentWidth = calcedTextBoxWidth;
|
||||
this.currentHeight = calcedTextBoxHeight;
|
||||
this.currentStatus = DRAW_START;
|
||||
this.handleDrawText(
|
||||
{ x: this.currentX, y: this.currentY },
|
||||
|
@ -16,7 +16,9 @@ const MEDIA = Meteor.settings.public.media;
|
||||
const MEDIA_TAG = MEDIA.mediaTag;
|
||||
const ECHO_TEST_NUMBER = MEDIA.echoTestNumber;
|
||||
const MAX_LISTEN_ONLY_RETRIES = 1;
|
||||
const LISTEN_ONLY_CALL_TIMEOUT_MS = MEDIA.listenOnlyCallTimeout || 15000;
|
||||
const LISTEN_ONLY_CALL_TIMEOUT_MS = MEDIA.listenOnlyCallTimeout || 25000;
|
||||
const DEFAULT_INPUT_DEVICE_ID = 'default';
|
||||
const DEFAULT_OUTPUT_DEVICE_ID = 'default';
|
||||
|
||||
const CALL_STATES = {
|
||||
STARTED: 'started',
|
||||
@ -29,7 +31,7 @@ const CALL_STATES = {
|
||||
class AudioManager {
|
||||
constructor() {
|
||||
this._inputDevice = {
|
||||
value: 'default',
|
||||
value: DEFAULT_INPUT_DEVICE_ID,
|
||||
tracker: new Tracker.Dependency(),
|
||||
};
|
||||
|
||||
@ -89,48 +91,18 @@ class AudioManager {
|
||||
});
|
||||
}
|
||||
|
||||
askDevicesPermissions() {
|
||||
// Check to see if the stream has already been retrieved becasue then we don't need to
|
||||
// request. This is a fix for an issue with the input device selector.
|
||||
if (this.inputStream) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Only change the isWaitingPermissions for the case where the user didnt allowed it yet
|
||||
const permTimeout = setTimeout(() => {
|
||||
if (!this.devicesInitialized) { this.isWaitingPermissions = true; }
|
||||
}, 100);
|
||||
|
||||
this.isWaitingPermissions = false;
|
||||
this.devicesInitialized = false;
|
||||
|
||||
return Promise.all([
|
||||
this.setDefaultInputDevice(),
|
||||
this.setDefaultOutputDevice(),
|
||||
]).then(() => {
|
||||
this.devicesInitialized = true;
|
||||
this.isWaitingPermissions = false;
|
||||
}).catch((err) => {
|
||||
clearTimeout(permTimeout);
|
||||
this.isConnecting = false;
|
||||
this.isWaitingPermissions = false;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
joinMicrophone() {
|
||||
this.isListenOnly = false;
|
||||
this.isEchoTest = false;
|
||||
|
||||
return this.askDevicesPermissions()
|
||||
.then(this.onAudioJoining.bind(this))
|
||||
return this.onAudioJoining.bind(this)()
|
||||
.then(() => {
|
||||
const callOptions = {
|
||||
isListenOnly: false,
|
||||
extension: null,
|
||||
inputStream: this.inputStream,
|
||||
};
|
||||
return this.bridge.joinAudio(callOptions, this.callStateCallback.bind(this));
|
||||
return this.joinAudio(callOptions, this.callStateCallback.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
@ -138,8 +110,7 @@ class AudioManager {
|
||||
this.isListenOnly = false;
|
||||
this.isEchoTest = true;
|
||||
|
||||
return this.askDevicesPermissions()
|
||||
.then(this.onAudioJoining.bind(this))
|
||||
return this.onAudioJoining.bind(this)()
|
||||
.then(() => {
|
||||
const callOptions = {
|
||||
isListenOnly: false,
|
||||
@ -147,10 +118,52 @@ class AudioManager {
|
||||
inputStream: this.inputStream,
|
||||
};
|
||||
logger.info({ logCode: 'audiomanager_join_echotest', extraInfo: { logType: 'user_action' } }, 'User requested to join audio conference with mic');
|
||||
return this.bridge.joinAudio(callOptions, this.callStateCallback.bind(this));
|
||||
return this.joinAudio(callOptions, this.callStateCallback.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
joinAudio(callOptions, callStateCallback) {
|
||||
return this.bridge.joinAudio(callOptions,
|
||||
callStateCallback.bind(this)).catch((error) => {
|
||||
const { name } = error;
|
||||
|
||||
if (!name) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case 'NotAllowedError':
|
||||
logger.error({
|
||||
logCode: 'audiomanager_error_getting_device',
|
||||
extraInfo: {
|
||||
errorName: error.name,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
}, `Error getting microphone - {${error.name}: ${error.message}}`);
|
||||
break;
|
||||
case 'NotFoundError':
|
||||
logger.error({
|
||||
logCode: 'audiomanager_error_device_not_found',
|
||||
extraInfo: {
|
||||
errorName: error.name,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
}, `Error getting microphone - {${error.name}: ${error.message}}`);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
this.isConnecting = false;
|
||||
this.isWaitingPermissions = false;
|
||||
|
||||
throw {
|
||||
type: 'MEDIA_ERROR',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async joinListenOnly(r = 0) {
|
||||
let retries = r;
|
||||
this.isListenOnly = true;
|
||||
@ -407,31 +420,25 @@ class AudioManager {
|
||||
}
|
||||
|
||||
createListenOnlyStream() {
|
||||
if (this.listenOnlyAudioContext) {
|
||||
this.listenOnlyAudioContext.close();
|
||||
}
|
||||
|
||||
const { AudioContext, webkitAudioContext } = window;
|
||||
|
||||
this.listenOnlyAudioContext = AudioContext
|
||||
? new AudioContext()
|
||||
: new webkitAudioContext();
|
||||
|
||||
const dest = this.listenOnlyAudioContext.createMediaStreamDestination();
|
||||
|
||||
const audio = document.querySelector(MEDIA_TAG);
|
||||
|
||||
// Play bogus silent audio to try to circumvent autoplay policy on Safari
|
||||
audio.src = 'resources/sounds/silence.mp3';
|
||||
if (!audio.src) {
|
||||
audio.src = 'resources/sounds/silence.mp3';
|
||||
}
|
||||
|
||||
audio.play().catch((e) => {
|
||||
if (e.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn({
|
||||
logCode: 'audiomanager_error_test_audio',
|
||||
extraInfo: { error: e },
|
||||
}, 'Error on playing test audio');
|
||||
});
|
||||
|
||||
return dest.stream;
|
||||
return {};
|
||||
}
|
||||
|
||||
isUsingAudio() {
|
||||
@ -448,9 +455,13 @@ class AudioManager {
|
||||
}
|
||||
|
||||
changeInputDevice(deviceId) {
|
||||
const handleChangeInputDeviceSuccess = (inputDevice) => {
|
||||
this.inputDevice = inputDevice;
|
||||
return Promise.resolve(inputDevice);
|
||||
if (!deviceId) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const handleChangeInputDeviceSuccess = (inputDeviceId) => {
|
||||
this.inputDevice.id = inputDeviceId;
|
||||
return Promise.resolve(inputDeviceId);
|
||||
};
|
||||
|
||||
const handleChangeInputDeviceError = (error) => {
|
||||
@ -476,19 +487,15 @@ class AudioManager {
|
||||
});
|
||||
};
|
||||
|
||||
if (!deviceId) {
|
||||
return this.bridge.setDefaultInputDevice()
|
||||
.then(handleChangeInputDeviceSuccess)
|
||||
.catch(handleChangeInputDeviceError);
|
||||
}
|
||||
|
||||
return this.bridge.changeInputDevice(deviceId)
|
||||
return this.bridge.changeInputDeviceId(deviceId)
|
||||
.then(handleChangeInputDeviceSuccess)
|
||||
.catch(handleChangeInputDeviceError);
|
||||
}
|
||||
|
||||
async changeOutputDevice(deviceId) {
|
||||
this.outputDeviceId = await this.bridge.changeOutputDevice(deviceId);
|
||||
this.outputDeviceId = await this
|
||||
.bridge
|
||||
.changeOutputDevice(deviceId || DEFAULT_OUTPUT_DEVICE_ID);
|
||||
}
|
||||
|
||||
set inputDevice(value) {
|
||||
@ -501,9 +508,13 @@ class AudioManager {
|
||||
return this._inputDevice.value.stream;
|
||||
}
|
||||
|
||||
get inputDevice() {
|
||||
return this._inputDevice;
|
||||
}
|
||||
|
||||
get inputDeviceId() {
|
||||
this._inputDevice.tracker.depend();
|
||||
return this._inputDevice.value.id;
|
||||
return (this.bridge && this.bridge.inputDeviceId)
|
||||
? this.bridge.inputDeviceId : DEFAULT_INPUT_DEVICE_ID;
|
||||
}
|
||||
|
||||
set userData(value) {
|
||||
@ -515,8 +526,9 @@ class AudioManager {
|
||||
}
|
||||
|
||||
playHangUpSound() {
|
||||
this.alert = new Audio(`${Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename}/resources/sounds/LeftCall.mp3`);
|
||||
this.alert.play();
|
||||
this.playAlertSound(`${Meteor.settings.public.app.cdn
|
||||
+ Meteor.settings.public.app.basename}`
|
||||
+ '/resources/sounds/LeftCall.mp3');
|
||||
}
|
||||
|
||||
notify(message, error = false, icon = 'unmute') {
|
||||
@ -582,7 +594,12 @@ class AudioManager {
|
||||
|
||||
// Bridge -> SIP.js bridge, the only full audio capable one right now
|
||||
const peer = this.bridge.getPeerConnection();
|
||||
peer.getSenders().forEach((sender) => {
|
||||
|
||||
if (!peer) {
|
||||
return;
|
||||
}
|
||||
|
||||
peer.getSenders().forEach(sender => {
|
||||
const { track } = sender;
|
||||
if (track && track.kind === 'audio') {
|
||||
track.enabled = shouldEnable;
|
||||
@ -597,6 +614,22 @@ class AudioManager {
|
||||
unmute() {
|
||||
this.setSenderTrackEnabled(true);
|
||||
}
|
||||
|
||||
playAlertSound (url) {
|
||||
if (!url) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const audioAlert = new Audio(url);
|
||||
|
||||
if (this.outputDeviceId && (typeof audioAlert.setSinkId === 'function')) {
|
||||
return audioAlert
|
||||
.setSinkId(this.outputDeviceId)
|
||||
.then(() => audioAlert.play());
|
||||
}
|
||||
|
||||
return audioAlert.play();
|
||||
}
|
||||
}
|
||||
|
||||
const audioManager = new AudioManager();
|
||||
|
@ -7,6 +7,7 @@ import Users from '/imports/api/users';
|
||||
import logger from '/imports/startup/client/logger';
|
||||
import { makeCall } from '/imports/ui/services/api';
|
||||
import { initAnnotationsStreamListener } from '/imports/ui/components/whiteboard/service';
|
||||
import allowRedirectToLogoutURL from '/imports/ui/components/meeting-ended/service';
|
||||
import { initCursorStreamListener } from '/imports/ui/components/cursor/service';
|
||||
import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
|
||||
|
||||
@ -183,7 +184,12 @@ class Auth {
|
||||
|
||||
|
||||
return new Promise((resolve) => {
|
||||
resolve(this._logoutURL);
|
||||
if (allowRedirectToLogoutURL()) {
|
||||
resolve(this._logoutURL);
|
||||
}
|
||||
|
||||
// do not redirect
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
@ -195,7 +201,7 @@ class Auth {
|
||||
if (!(this.meetingID && this.userID && this.token)) {
|
||||
return Promise.reject({
|
||||
error: 401,
|
||||
description: 'Authentication failed due to missing credentials.',
|
||||
description: Session.get('errorMessageDescription') ? Session.get('errorMessageDescription') : 'Authentication failed due to missing credentials',
|
||||
});
|
||||
}
|
||||
|
||||
@ -214,8 +220,8 @@ class Auth {
|
||||
const validationTimeout = setTimeout(() => {
|
||||
computation.stop();
|
||||
reject({
|
||||
error: 401,
|
||||
description: 'Authentication timeout.',
|
||||
error: 408,
|
||||
description: 'Authentication timeout',
|
||||
});
|
||||
}, CONNECTION_TIMEOUT);
|
||||
|
||||
@ -223,18 +229,20 @@ class Auth {
|
||||
|
||||
const result = await makeCall('validateAuthToken', this.meetingID, this.userID, this.token, this.externUserID);
|
||||
|
||||
if (!result) {
|
||||
if (result && result.invalid) {
|
||||
clearTimeout(validationTimeout);
|
||||
reject({
|
||||
error: 401,
|
||||
description: 'User has been banned.',
|
||||
error: 403,
|
||||
description: result.reason,
|
||||
type: result.error_type,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Meteor.subscribe('current-user');
|
||||
|
||||
Tracker.autorun((c) => {
|
||||
computation = c;
|
||||
Meteor.subscribe('current-user');
|
||||
|
||||
const selector = { meetingId: this.meetingID, userId: this.userID };
|
||||
const fields = {
|
||||
@ -252,7 +260,7 @@ class Auth {
|
||||
if (User.ejected) {
|
||||
computation.stop();
|
||||
reject({
|
||||
error: 401,
|
||||
error: 403,
|
||||
description: 'User has been ejected.',
|
||||
});
|
||||
return;
|
||||
|
@ -2,13 +2,11 @@ import Auth from '/imports/ui/services/auth';
|
||||
|
||||
const logoutRouteHandler = () => {
|
||||
Auth.logout()
|
||||
.then((logoutURL = window.location.origin) => {
|
||||
const protocolPattern = /^((http|https):\/\/)/;
|
||||
|
||||
window.location.href =
|
||||
protocolPattern.test(logoutURL) ?
|
||||
logoutURL :
|
||||
`http://${logoutURL}`;
|
||||
.then((logoutURL) => {
|
||||
if (logoutURL) {
|
||||
const protocolPattern = /^((http|https):\/\/)/;
|
||||
window.location.href = protocolPattern.test(logoutURL) ? logoutURL : `http://${logoutURL}`;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -46,7 +46,7 @@ const toUnifiedPlan = (planBSDP) => {
|
||||
};
|
||||
|
||||
const stripMDnsCandidates = (sdp) => {
|
||||
const parsedSDP = transform.parse(sdp);
|
||||
const parsedSDP = transform.parse(sdp.sdp);
|
||||
let strippedCandidates = 0;
|
||||
parsedSDP.media.forEach((media) => {
|
||||
if (media.candidates) {
|
||||
@ -62,7 +62,7 @@ const stripMDnsCandidates = (sdp) => {
|
||||
if (strippedCandidates > 0) {
|
||||
logger.info({ logCode: 'sdp_utils_mdns_candidate_strip' }, `Stripped ${strippedCandidates} mDNS candidates`);
|
||||
}
|
||||
return transform.write(parsedSDP);
|
||||
return { sdp: transform.write(parsedSDP), type: sdp.type };
|
||||
};
|
||||
|
||||
const isPublicIpv4 = (ip) => {
|
||||
|
@ -17,6 +17,9 @@ public:
|
||||
cdn: ""
|
||||
basename: "/html5client"
|
||||
askForFeedbackOnLogout: false
|
||||
# the default logoutUrl matches window.location.origin i.e. bigbluebutton.org for demo.bigbluebutton.org
|
||||
# in some cases we want only custom logoutUrl to be used when provided on meeting create. Default value: true
|
||||
allowDefaultLogoutUrl: true
|
||||
allowUserLookup: false
|
||||
enableNetworkInformation: false
|
||||
enableLimitOfViewersInWebcam: false
|
||||
@ -306,7 +309,7 @@ public:
|
||||
callHangupMaximumRetries: 10
|
||||
echoTestNumber: 'echo'
|
||||
relayOnlyOnReconnect: false
|
||||
listenOnlyCallTimeout: 15000
|
||||
listenOnlyCallTimeout: 25000
|
||||
stats:
|
||||
enabled: true
|
||||
interval: 2000
|
||||
|
@ -1,601 +0,0 @@
|
||||
/*!
|
||||
* Bowser - a browser detector
|
||||
* https://github.com/ded/bowser
|
||||
* MIT License | (c) Dustin Diaz 2015
|
||||
*/
|
||||
|
||||
!function (root, name, definition) {
|
||||
if (typeof module != 'undefined' && module.exports) module.exports = definition()
|
||||
else if (typeof define == 'function' && define.amd) define(name, definition)
|
||||
else root[name] = definition()
|
||||
}(this, 'bowser', function () {
|
||||
/**
|
||||
* See useragents.js for examples of navigator.userAgent
|
||||
*/
|
||||
|
||||
var t = true
|
||||
|
||||
function detect(ua) {
|
||||
|
||||
function getFirstMatch(regex) {
|
||||
var match = ua.match(regex);
|
||||
return (match && match.length > 1 && match[1]) || '';
|
||||
}
|
||||
|
||||
function getSecondMatch(regex) {
|
||||
var match = ua.match(regex);
|
||||
return (match && match.length > 1 && match[2]) || '';
|
||||
}
|
||||
|
||||
var iosdevice = getFirstMatch(/(ipod|iphone|ipad)/i).toLowerCase()
|
||||
, likeAndroid = /like android/i.test(ua)
|
||||
, android = !likeAndroid && /android/i.test(ua)
|
||||
, nexusMobile = /nexus\s*[0-6]\s*/i.test(ua)
|
||||
, nexusTablet = !nexusMobile && /nexus\s*[0-9]+/i.test(ua)
|
||||
, chromeos = /CrOS/.test(ua)
|
||||
, silk = /silk/i.test(ua)
|
||||
, sailfish = /sailfish/i.test(ua)
|
||||
, tizen = /tizen/i.test(ua)
|
||||
, webos = /(web|hpw)os/i.test(ua)
|
||||
, windowsphone = /windows phone/i.test(ua)
|
||||
, samsungBrowser = /SamsungBrowser/i.test(ua)
|
||||
, windows = !windowsphone && /windows/i.test(ua)
|
||||
, mac = !iosdevice && !silk && /macintosh/i.test(ua)
|
||||
, linux = !android && !sailfish && !tizen && !webos && /linux/i.test(ua)
|
||||
, edgeVersion = getFirstMatch(/edge\/(\d+(\.\d+)?)/i)
|
||||
, versionIdentifier = getFirstMatch(/version\/(\d+(\.\d+)?)/i)
|
||||
, tablet = /tablet/i.test(ua) && !/tablet pc/i.test(ua)
|
||||
, mobile = !tablet && /[^-]mobi/i.test(ua)
|
||||
, xbox = /xbox/i.test(ua)
|
||||
, result
|
||||
|
||||
if (/opera/i.test(ua)) {
|
||||
// an old Opera
|
||||
result = {
|
||||
name: 'Opera'
|
||||
, opera: t
|
||||
, version: versionIdentifier || getFirstMatch(/(?:opera|opr|opios)[\s\/](\d+(\.\d+)?)/i)
|
||||
}
|
||||
} else if (/opr\/|opios/i.test(ua)) {
|
||||
// a new Opera
|
||||
result = {
|
||||
name: 'Opera'
|
||||
, opera: t
|
||||
, version: getFirstMatch(/(?:opr|opios)[\s\/](\d+(\.\d+)?)/i) || versionIdentifier
|
||||
}
|
||||
}
|
||||
else if (/SamsungBrowser/i.test(ua)) {
|
||||
result = {
|
||||
name: 'Samsung Internet for Android'
|
||||
, samsungBrowser: t
|
||||
, version: versionIdentifier || getFirstMatch(/(?:SamsungBrowser)[\s\/](\d+(\.\d+)?)/i)
|
||||
}
|
||||
}
|
||||
else if (/coast/i.test(ua)) {
|
||||
result = {
|
||||
name: 'Opera Coast'
|
||||
, coast: t
|
||||
, version: versionIdentifier || getFirstMatch(/(?:coast)[\s\/](\d+(\.\d+)?)/i)
|
||||
}
|
||||
}
|
||||
else if (/yabrowser/i.test(ua)) {
|
||||
result = {
|
||||
name: 'Yandex Browser'
|
||||
, yandexbrowser: t
|
||||
, version: versionIdentifier || getFirstMatch(/(?:yabrowser)[\s\/](\d+(\.\d+)?)/i)
|
||||
}
|
||||
}
|
||||
else if (/ucbrowser/i.test(ua)) {
|
||||
result = {
|
||||
name: 'UC Browser'
|
||||
, ucbrowser: t
|
||||
, version: getFirstMatch(/(?:ucbrowser)[\s\/](\d+(?:\.\d+)+)/i)
|
||||
}
|
||||
}
|
||||
else if (/mxios/i.test(ua)) {
|
||||
result = {
|
||||
name: 'Maxthon'
|
||||
, maxthon: t
|
||||
, version: getFirstMatch(/(?:mxios)[\s\/](\d+(?:\.\d+)+)/i)
|
||||
}
|
||||
}
|
||||
else if (/epiphany/i.test(ua)) {
|
||||
result = {
|
||||
name: 'Epiphany'
|
||||
, epiphany: t
|
||||
, version: getFirstMatch(/(?:epiphany)[\s\/](\d+(?:\.\d+)+)/i)
|
||||
}
|
||||
}
|
||||
else if (/puffin/i.test(ua)) {
|
||||
result = {
|
||||
name: 'Puffin'
|
||||
, puffin: t
|
||||
, version: getFirstMatch(/(?:puffin)[\s\/](\d+(?:\.\d+)?)/i)
|
||||
}
|
||||
}
|
||||
else if (/sleipnir/i.test(ua)) {
|
||||
result = {
|
||||
name: 'Sleipnir'
|
||||
, sleipnir: t
|
||||
, version: getFirstMatch(/(?:sleipnir)[\s\/](\d+(?:\.\d+)+)/i)
|
||||
}
|
||||
}
|
||||
else if (/k-meleon/i.test(ua)) {
|
||||
result = {
|
||||
name: 'K-Meleon'
|
||||
, kMeleon: t
|
||||
, version: getFirstMatch(/(?:k-meleon)[\s\/](\d+(?:\.\d+)+)/i)
|
||||
}
|
||||
}
|
||||
else if (windowsphone) {
|
||||
result = {
|
||||
name: 'Windows Phone'
|
||||
, windowsphone: t
|
||||
}
|
||||
if (edgeVersion) {
|
||||
result.msedge = t
|
||||
result.version = edgeVersion
|
||||
}
|
||||
else {
|
||||
result.msie = t
|
||||
result.version = getFirstMatch(/iemobile\/(\d+(\.\d+)?)/i)
|
||||
}
|
||||
}
|
||||
else if (/msie|trident/i.test(ua)) {
|
||||
result = {
|
||||
name: 'Internet Explorer'
|
||||
, msie: t
|
||||
, version: getFirstMatch(/(?:msie |rv:)(\d+(\.\d+)?)/i)
|
||||
}
|
||||
} else if (chromeos) {
|
||||
result = {
|
||||
name: 'Chrome'
|
||||
, chromeos: t
|
||||
, chromeBook: t
|
||||
, chrome: t
|
||||
, version: getFirstMatch(/(?:chrome|crios|crmo)\/(\d+(\.\d+)?)/i)
|
||||
}
|
||||
} else if (/chrome.+? edge/i.test(ua)) {
|
||||
result = {
|
||||
name: 'Microsoft Edge'
|
||||
, msedge: t
|
||||
, version: edgeVersion
|
||||
}
|
||||
}
|
||||
else if (/vivaldi/i.test(ua)) {
|
||||
result = {
|
||||
name: 'Vivaldi'
|
||||
, vivaldi: t
|
||||
, version: getFirstMatch(/vivaldi\/(\d+(\.\d+)?)/i) || versionIdentifier
|
||||
}
|
||||
}
|
||||
else if (sailfish) {
|
||||
result = {
|
||||
name: 'Sailfish'
|
||||
, sailfish: t
|
||||
, version: getFirstMatch(/sailfish\s?browser\/(\d+(\.\d+)?)/i)
|
||||
}
|
||||
}
|
||||
else if (/seamonkey\//i.test(ua)) {
|
||||
result = {
|
||||
name: 'SeaMonkey'
|
||||
, seamonkey: t
|
||||
, version: getFirstMatch(/seamonkey\/(\d+(\.\d+)?)/i)
|
||||
}
|
||||
}
|
||||
else if (/firefox|iceweasel|fxios/i.test(ua)) {
|
||||
result = {
|
||||
name: 'Firefox'
|
||||
, firefox: t
|
||||
, version: getFirstMatch(/(?:firefox|iceweasel|fxios)[ \/](\d+(\.\d+)?)/i)
|
||||
}
|
||||
if (/\((mobile|tablet);[^\)]*rv:[\d\.]+\)/i.test(ua)) {
|
||||
result.firefoxos = t
|
||||
}
|
||||
}
|
||||
else if (silk) {
|
||||
result = {
|
||||
name: 'Amazon Silk'
|
||||
, silk: t
|
||||
, version : getFirstMatch(/silk\/(\d+(\.\d+)?)/i)
|
||||
}
|
||||
}
|
||||
else if (/phantom/i.test(ua)) {
|
||||
result = {
|
||||
name: 'PhantomJS'
|
||||
, phantom: t
|
||||
, version: getFirstMatch(/phantomjs\/(\d+(\.\d+)?)/i)
|
||||
}
|
||||
}
|
||||
else if (/slimerjs/i.test(ua)) {
|
||||
result = {
|
||||
name: 'SlimerJS'
|
||||
, slimer: t
|
||||
, version: getFirstMatch(/slimerjs\/(\d+(\.\d+)?)/i)
|
||||
}
|
||||
}
|
||||
else if (/blackberry|\bbb\d+/i.test(ua) || /rim\stablet/i.test(ua)) {
|
||||
result = {
|
||||
name: 'BlackBerry'
|
||||
, blackberry: t
|
||||
, version: versionIdentifier || getFirstMatch(/blackberry[\d]+\/(\d+(\.\d+)?)/i)
|
||||
}
|
||||
}
|
||||
else if (webos) {
|
||||
result = {
|
||||
name: 'WebOS'
|
||||
, webos: t
|
||||
, version: versionIdentifier || getFirstMatch(/w(?:eb)?osbrowser\/(\d+(\.\d+)?)/i)
|
||||
};
|
||||
/touchpad\//i.test(ua) && (result.touchpad = t)
|
||||
}
|
||||
else if (/bada/i.test(ua)) {
|
||||
result = {
|
||||
name: 'Bada'
|
||||
, bada: t
|
||||
, version: getFirstMatch(/dolfin\/(\d+(\.\d+)?)/i)
|
||||
};
|
||||
}
|
||||
else if (tizen) {
|
||||
result = {
|
||||
name: 'Tizen'
|
||||
, tizen: t
|
||||
, version: getFirstMatch(/(?:tizen\s?)?browser\/(\d+(\.\d+)?)/i) || versionIdentifier
|
||||
};
|
||||
}
|
||||
else if (/qupzilla/i.test(ua)) {
|
||||
result = {
|
||||
name: 'QupZilla'
|
||||
, qupzilla: t
|
||||
, version: getFirstMatch(/(?:qupzilla)[\s\/](\d+(?:\.\d+)+)/i) || versionIdentifier
|
||||
}
|
||||
}
|
||||
else if (/chromium/i.test(ua)) {
|
||||
result = {
|
||||
name: 'Chromium'
|
||||
, chromium: t
|
||||
, version: getFirstMatch(/(?:chromium)[\s\/](\d+(?:\.\d+)?)/i) || versionIdentifier
|
||||
}
|
||||
}
|
||||
else if (/chrome|crios|crmo/i.test(ua)) {
|
||||
result = {
|
||||
name: 'Chrome'
|
||||
, chrome: t
|
||||
, version: getFirstMatch(/(?:chrome|crios|crmo)\/(\d+(\.\d+)?)/i)
|
||||
}
|
||||
}
|
||||
else if (android) {
|
||||
result = {
|
||||
name: 'Android'
|
||||
, version: versionIdentifier
|
||||
}
|
||||
}
|
||||
else if (/safari|applewebkit/i.test(ua)) {
|
||||
result = {
|
||||
name: 'Safari'
|
||||
, safari: t
|
||||
}
|
||||
if (versionIdentifier) {
|
||||
result.version = versionIdentifier
|
||||
}
|
||||
}
|
||||
else if (iosdevice) {
|
||||
result = {
|
||||
name : iosdevice == 'iphone' ? 'iPhone' : iosdevice == 'ipad' ? 'iPad' : 'iPod'
|
||||
}
|
||||
// WTF: version is not part of user agent in web apps
|
||||
if (versionIdentifier) {
|
||||
result.version = versionIdentifier
|
||||
}
|
||||
}
|
||||
else if(/googlebot/i.test(ua)) {
|
||||
result = {
|
||||
name: 'Googlebot'
|
||||
, googlebot: t
|
||||
, version: getFirstMatch(/googlebot\/(\d+(\.\d+))/i) || versionIdentifier
|
||||
}
|
||||
}
|
||||
else {
|
||||
result = {
|
||||
name: getFirstMatch(/^(.*)\/(.*) /),
|
||||
version: getSecondMatch(/^(.*)\/(.*) /)
|
||||
};
|
||||
}
|
||||
|
||||
// set webkit or gecko flag for browsers based on these engines
|
||||
if (!result.msedge && /(apple)?webkit/i.test(ua)) {
|
||||
if (/(apple)?webkit\/537\.36/i.test(ua)) {
|
||||
result.name = result.name || "Blink"
|
||||
result.blink = t
|
||||
} else {
|
||||
result.name = result.name || "Webkit"
|
||||
result.webkit = t
|
||||
}
|
||||
if (!result.version && versionIdentifier) {
|
||||
result.version = versionIdentifier
|
||||
}
|
||||
} else if (!result.opera && /gecko\//i.test(ua)) {
|
||||
result.name = result.name || "Gecko"
|
||||
result.gecko = t
|
||||
result.version = result.version || getFirstMatch(/gecko\/(\d+(\.\d+)?)/i)
|
||||
}
|
||||
|
||||
// set OS flags for platforms that have multiple browsers
|
||||
if (!result.windowsphone && !result.msedge && (android || result.silk)) {
|
||||
result.android = t
|
||||
} else if (!result.windowsphone && !result.msedge && iosdevice) {
|
||||
result[iosdevice] = t
|
||||
result.ios = t
|
||||
} else if (mac) {
|
||||
result.mac = t
|
||||
} else if (xbox) {
|
||||
result.xbox = t
|
||||
} else if (windows) {
|
||||
result.windows = t
|
||||
} else if (linux) {
|
||||
result.linux = t
|
||||
}
|
||||
|
||||
function getWindowsVersion (s) {
|
||||
switch (s) {
|
||||
case 'NT': return 'NT'
|
||||
case 'XP': return 'XP'
|
||||
case 'NT 5.0': return '2000'
|
||||
case 'NT 5.1': return 'XP'
|
||||
case 'NT 5.2': return '2003'
|
||||
case 'NT 6.0': return 'Vista'
|
||||
case 'NT 6.1': return '7'
|
||||
case 'NT 6.2': return '8'
|
||||
case 'NT 6.3': return '8.1'
|
||||
case 'NT 10.0': return '10'
|
||||
default: return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// OS version extraction
|
||||
var osVersion = '';
|
||||
if (result.windows) {
|
||||
osVersion = getWindowsVersion(getFirstMatch(/Windows ((NT|XP)( \d\d?.\d)?)/i))
|
||||
} else if (result.windowsphone) {
|
||||
osVersion = getFirstMatch(/windows phone (?:os)?\s?(\d+(\.\d+)*)/i);
|
||||
} else if (result.mac) {
|
||||
osVersion = getFirstMatch(/Mac OS X (\d+([_\.\s]\d+)*)/i);
|
||||
osVersion = osVersion.replace(/[_\s]/g, '.');
|
||||
} else if (iosdevice) {
|
||||
osVersion = getFirstMatch(/os (\d+([_\s]\d+)*) like mac os x/i);
|
||||
osVersion = osVersion.replace(/[_\s]/g, '.');
|
||||
} else if (android) {
|
||||
osVersion = getFirstMatch(/android[ \/-](\d+(\.\d+)*)/i);
|
||||
} else if (result.webos) {
|
||||
osVersion = getFirstMatch(/(?:web|hpw)os\/(\d+(\.\d+)*)/i);
|
||||
} else if (result.blackberry) {
|
||||
osVersion = getFirstMatch(/rim\stablet\sos\s(\d+(\.\d+)*)/i);
|
||||
} else if (result.bada) {
|
||||
osVersion = getFirstMatch(/bada\/(\d+(\.\d+)*)/i);
|
||||
} else if (result.tizen) {
|
||||
osVersion = getFirstMatch(/tizen[\/\s](\d+(\.\d+)*)/i);
|
||||
}
|
||||
if (osVersion) {
|
||||
result.osversion = osVersion;
|
||||
}
|
||||
|
||||
// device type extraction
|
||||
var osMajorVersion = !result.windows && osVersion.split('.')[0];
|
||||
if (
|
||||
tablet
|
||||
|| nexusTablet
|
||||
|| iosdevice == 'ipad'
|
||||
|| (android && (osMajorVersion == 3 || (osMajorVersion >= 4 && !mobile)))
|
||||
|| result.silk
|
||||
) {
|
||||
result.tablet = t
|
||||
} else if (
|
||||
mobile
|
||||
|| iosdevice == 'iphone'
|
||||
|| iosdevice == 'ipod'
|
||||
|| android
|
||||
|| nexusMobile
|
||||
|| result.blackberry
|
||||
|| result.webos
|
||||
|| result.bada
|
||||
) {
|
||||
result.mobile = t
|
||||
}
|
||||
|
||||
// Graded Browser Support
|
||||
// http://developer.yahoo.com/yui/articles/gbs
|
||||
if (result.msedge ||
|
||||
(result.msie && result.version >= 10) ||
|
||||
(result.yandexbrowser && result.version >= 15) ||
|
||||
(result.vivaldi && result.version >= 1.0) ||
|
||||
(result.chrome && result.version >= 20) ||
|
||||
(result.samsungBrowser && result.version >= 4) ||
|
||||
(result.firefox && result.version >= 20.0) ||
|
||||
(result.safari && result.version >= 6) ||
|
||||
(result.opera && result.version >= 10.0) ||
|
||||
(result.ios && result.osversion && result.osversion.split(".")[0] >= 6) ||
|
||||
(result.blackberry && result.version >= 10.1)
|
||||
|| (result.chromium && result.version >= 20)
|
||||
) {
|
||||
result.a = t;
|
||||
}
|
||||
else if ((result.msie && result.version < 10) ||
|
||||
(result.chrome && result.version < 20) ||
|
||||
(result.firefox && result.version < 20.0) ||
|
||||
(result.safari && result.version < 6) ||
|
||||
(result.opera && result.version < 10.0) ||
|
||||
(result.ios && result.osversion && result.osversion.split(".")[0] < 6)
|
||||
|| (result.chromium && result.version < 20)
|
||||
) {
|
||||
result.c = t
|
||||
} else result.x = t
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
var bowser = detect(typeof navigator !== 'undefined' ? navigator.userAgent || '' : '')
|
||||
|
||||
bowser.test = function (browserList) {
|
||||
for (var i = 0; i < browserList.length; ++i) {
|
||||
var browserItem = browserList[i];
|
||||
if (typeof browserItem=== 'string') {
|
||||
if (browserItem in bowser) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version precisions count
|
||||
*
|
||||
* @example
|
||||
* getVersionPrecision("1.10.3") // 3
|
||||
*
|
||||
* @param {string} version
|
||||
* @return {number}
|
||||
*/
|
||||
function getVersionPrecision(version) {
|
||||
return version.split(".").length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Array::map polyfill
|
||||
*
|
||||
* @param {Array} arr
|
||||
* @param {Function} iterator
|
||||
* @return {Array}
|
||||
*/
|
||||
function map(arr, iterator) {
|
||||
var result = [], i;
|
||||
if (Array.prototype.map) {
|
||||
return Array.prototype.map.call(arr, iterator);
|
||||
}
|
||||
for (i = 0; i < arr.length; i++) {
|
||||
result.push(iterator(arr[i]));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate browser version weight
|
||||
*
|
||||
* @example
|
||||
* compareVersions(['1.10.2.1', '1.8.2.1.90']) // 1
|
||||
* compareVersions(['1.010.2.1', '1.09.2.1.90']); // 1
|
||||
* compareVersions(['1.10.2.1', '1.10.2.1']); // 0
|
||||
* compareVersions(['1.10.2.1', '1.0800.2']); // -1
|
||||
*
|
||||
* @param {Array<String>} versions versions to compare
|
||||
* @return {Number} comparison result
|
||||
*/
|
||||
function compareVersions(versions) {
|
||||
// 1) get common precision for both versions, for example for "10.0" and "9" it should be 2
|
||||
var precision = Math.max(getVersionPrecision(versions[0]), getVersionPrecision(versions[1]));
|
||||
var chunks = map(versions, function (version) {
|
||||
var delta = precision - getVersionPrecision(version);
|
||||
|
||||
// 2) "9" -> "9.0" (for precision = 2)
|
||||
version = version + new Array(delta + 1).join(".0");
|
||||
|
||||
// 3) "9.0" -> ["000000000"", "000000009"]
|
||||
return map(version.split("."), function (chunk) {
|
||||
return new Array(20 - chunk.length).join("0") + chunk;
|
||||
}).reverse();
|
||||
});
|
||||
|
||||
// iterate in reverse order by reversed chunks array
|
||||
while (--precision >= 0) {
|
||||
// 4) compare: "000000009" > "000000010" = false (but "9" > "10" = true)
|
||||
if (chunks[0][precision] > chunks[1][precision]) {
|
||||
return 1;
|
||||
}
|
||||
else if (chunks[0][precision] === chunks[1][precision]) {
|
||||
if (precision === 0) {
|
||||
// all version chunks are same
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if browser is unsupported
|
||||
*
|
||||
* @example
|
||||
* bowser.isUnsupportedBrowser({
|
||||
* msie: "10",
|
||||
* firefox: "23",
|
||||
* chrome: "29",
|
||||
* safari: "5.1",
|
||||
* opera: "16",
|
||||
* phantom: "534"
|
||||
* });
|
||||
*
|
||||
* @param {Object} minVersions map of minimal version to browser
|
||||
* @param {Boolean} [strictMode = false] flag to return false if browser wasn't found in map
|
||||
* @param {String} [ua] user agent string
|
||||
* @return {Boolean}
|
||||
*/
|
||||
function isUnsupportedBrowser(minVersions, strictMode, ua) {
|
||||
var _bowser = bowser;
|
||||
|
||||
// make strictMode param optional with ua param usage
|
||||
if (typeof strictMode === 'string') {
|
||||
ua = strictMode;
|
||||
strictMode = void(0);
|
||||
}
|
||||
|
||||
if (strictMode === void(0)) {
|
||||
strictMode = false;
|
||||
}
|
||||
if (ua) {
|
||||
_bowser = detect(ua);
|
||||
}
|
||||
|
||||
var version = "" + _bowser.version;
|
||||
for (var browser in minVersions) {
|
||||
if (minVersions.hasOwnProperty(browser)) {
|
||||
if (_bowser[browser]) {
|
||||
if (typeof minVersions[browser] !== 'string') {
|
||||
throw new Error('Browser version in the minVersion map should be a string: ' + browser + ': ' + String(minVersions));
|
||||
}
|
||||
|
||||
// browser version and min supported version.
|
||||
return compareVersions([version, minVersions[browser]]) < 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strictMode; // not found
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if browser is supported
|
||||
*
|
||||
* @param {Object} minVersions map of minimal version to browser
|
||||
* @param {Boolean} [strictMode = false] flag to return false if browser wasn't found in map
|
||||
* @param {String} [ua] user agent string
|
||||
* @return {Boolean}
|
||||
*/
|
||||
function check(minVersions, strictMode, ua) {
|
||||
return !isUnsupportedBrowser(minVersions, strictMode, ua);
|
||||
}
|
||||
|
||||
bowser.isUnsupportedBrowser = isUnsupportedBrowser;
|
||||
bowser.compareVersions = compareVersions;
|
||||
bowser.check = check;
|
||||
|
||||
/*
|
||||
* Set our detect method to the main bowser object so we can
|
||||
* reuse it to test other user agents.
|
||||
* This is needed to implement future tests.
|
||||
*/
|
||||
bowser._detect = detect;
|
||||
|
||||
return bowser
|
||||
});
|
@ -357,6 +357,11 @@ Kurento.prototype.startResponse = function (message) {
|
||||
|
||||
return this.onFail(error);
|
||||
}
|
||||
|
||||
this.logger.info({
|
||||
logCode: 'kurentoextension_process_answer',
|
||||
}, `Answer processed with success`);
|
||||
|
||||
// Mark the peer as negotiated and flush the ICE queue
|
||||
this.webRtcPeer.negotiated = true;
|
||||
this.processIceQueue();
|
||||
|
32326
bigbluebutton-html5/public/compatibility/sip.js
Executable file → Normal file
32326
bigbluebutton-html5/public/compatibility/sip.js
Executable file → Normal file
File diff suppressed because it is too large
Load Diff
@ -26,6 +26,11 @@ endpoints:
|
||||
jmx:
|
||||
enabled: true
|
||||
|
||||
server:
|
||||
session:
|
||||
cookie:
|
||||
secure: true
|
||||
|
||||
---
|
||||
grails:
|
||||
mime:
|
||||
|
@ -221,6 +221,16 @@ allowModsToUnmuteUsers=false
|
||||
# Saves meeting events even if the meeting is not recorded
|
||||
keepEvents=true
|
||||
|
||||
# Timeout (millis) to remove a joined user after her/his left event without a rejoin
|
||||
# e.g. regular user left event
|
||||
# Default 60s
|
||||
usersTimeout=60000
|
||||
|
||||
# Timeout (millis) to remove users that called the enter API but did not join
|
||||
# e.g. user's client hanged between the enter call and join event
|
||||
# Default 45s
|
||||
enteredUsersTimeout=45000
|
||||
|
||||
#----------------------------------------------------
|
||||
# This URL is where the BBB client is accessible. When a user sucessfully
|
||||
# enters a name and password, she is redirected here to load the client.
|
||||
|
@ -33,8 +33,9 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
|
||||
</property>
|
||||
</bean>
|
||||
|
||||
<bean id="registeredUserCleanupTimerTask" class="org.bigbluebutton.web.services.RegisteredUserCleanupTimerTask"/>
|
||||
<bean id="waitingGuestCleanupTimerTask" class="org.bigbluebutton.web.services.WaitingGuestCleanupTimerTask"/>
|
||||
<bean id="userCleanupTimerTask" class="org.bigbluebutton.web.services.UserCleanupTimerTask"/>
|
||||
<bean id="enteredUserCleanupTimerTask" class="org.bigbluebutton.web.services.EnteredUserCleanupTimerTask"/>
|
||||
|
||||
<bean id="keepAliveService" class="org.bigbluebutton.web.services.KeepAliveService"
|
||||
init-method="start" destroy-method="stop">
|
||||
@ -48,11 +49,14 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
|
||||
<property name="presDownloadService" ref="presDownloadService"/>
|
||||
<property name="paramsProcessorUtil" ref="paramsProcessorUtil"/>
|
||||
<property name="stunTurnService" ref="stunTurnService"/>
|
||||
<property name="registeredUserCleanupTimerTask" ref="registeredUserCleanupTimerTask"/>
|
||||
<property name="waitingGuestCleanupTimerTask" ref="waitingGuestCleanupTimerTask"/>
|
||||
<property name="userCleanupTimerTask" ref="userCleanupTimerTask"/>
|
||||
<property name="enteredUserCleanupTimerTask" ref="enteredUserCleanupTimerTask"/>
|
||||
<property name="gw" ref="bbbWebApiGWApp"/>
|
||||
<property name="callbackUrlService" ref="callbackUrlService"/>
|
||||
<property name="keepEvents" value="${keepEvents}"/>
|
||||
<property name="usersTimeout" value="${usersTimeout}"/>
|
||||
<property name="enteredUsersTimeout" value="${enteredUsersTimeout}"/>
|
||||
</bean>
|
||||
|
||||
<bean id="oldMessageReceivedGW" class="org.bigbluebutton.api2.bus.OldMessageReceivedGW">
|
||||
|
@ -453,10 +453,9 @@ class ApiController {
|
||||
us.avatarURL = meeting.defaultAvatarURL
|
||||
}
|
||||
|
||||
// Validate if the maxParticipants limit has been reached based on registeredUsers. If so, complain.
|
||||
// when maxUsers is set to 0, the validation is ignored
|
||||
int maxUsers = meeting.getMaxUsers();
|
||||
if (maxUsers > 0 && meeting.getRegisteredUsers().size() >= maxUsers) {
|
||||
String meetingId = meeting.getInternalId()
|
||||
|
||||
if (hasReachedMaxParticipants(meeting, us)) {
|
||||
// BEGIN - backward compatibility
|
||||
invalid("maxParticipantsReached", "The number of participants allowed for this meeting has been reached.", REDIRECT_RESPONSE);
|
||||
return
|
||||
@ -468,8 +467,18 @@ class ApiController {
|
||||
}
|
||||
|
||||
// Register user into the meeting.
|
||||
meetingService.registerUser(us.meetingID, us.internalUserId, us.fullname, us.role, us.externUserID,
|
||||
us.authToken, us.avatarURL, us.guest, us.authed, guestStatusVal)
|
||||
meetingService.registerUser(
|
||||
us.meetingID,
|
||||
us.internalUserId,
|
||||
us.fullname,
|
||||
us.role,
|
||||
us.externUserID,
|
||||
us.authToken,
|
||||
us.avatarURL,
|
||||
us.guest,
|
||||
us.authed,
|
||||
guestStatusVal
|
||||
)
|
||||
|
||||
//Identify which of these to logs should be used. sessionToken or user-token
|
||||
log.info("Session sessionToken for " + us.fullname + " [" + session[sessionToken] + "]")
|
||||
@ -1192,24 +1201,8 @@ class ApiController {
|
||||
|
||||
String logoutUrl = paramsProcessorUtil.getDefaultLogoutUrl()
|
||||
boolean reject = false
|
||||
String sessionToken = null
|
||||
UserSession us = null
|
||||
|
||||
if (StringUtils.isEmpty(params.sessionToken)) {
|
||||
log.info("No session for user in conference.")
|
||||
reject = true
|
||||
} else {
|
||||
sessionToken = StringUtils.strip(params.sessionToken)
|
||||
log.info("Getting ConfigXml for SessionToken = " + sessionToken)
|
||||
if (!session[sessionToken]) {
|
||||
reject = true
|
||||
} else {
|
||||
us = meetingService.getUserSessionWithAuthToken(sessionToken);
|
||||
if (us == null) reject = true
|
||||
}
|
||||
}
|
||||
|
||||
if (reject) {
|
||||
String sessionToken = sanitizeSessionToken(params.sessionToken)
|
||||
if (!hasValidSession(sessionToken)) {
|
||||
response.addHeader("Cache-Control", "no-cache")
|
||||
withFormat {
|
||||
xml {
|
||||
@ -1217,6 +1210,7 @@ class ApiController {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
UserSession us = getUserSession(sessionToken)
|
||||
if (StringUtils.isEmpty(us.configXML)) {
|
||||
// BEGIN - backward compatibility
|
||||
invalid("noConfigFound", "We could not find a config for this request.", REDIRECT_RESPONSE);
|
||||
@ -1254,44 +1248,26 @@ class ApiController {
|
||||
log.debug CONTROLLER_NAME + "#${API_CALL}"
|
||||
ApiErrors errors = new ApiErrors()
|
||||
boolean reject = false;
|
||||
String sessionToken = sanitizeSessionToken(params.sessionToken)
|
||||
|
||||
if (StringUtils.isEmpty(params.sessionToken)) {
|
||||
log.debug("SessionToken is missing.")
|
||||
}
|
||||
|
||||
String sessionToken = StringUtils.strip(params.sessionToken)
|
||||
|
||||
UserSession us = null;
|
||||
UserSession us = getUserSession(sessionToken);
|
||||
Meeting meeting = null;
|
||||
UserSession userSession = null;
|
||||
|
||||
if (sessionToken == null || meetingService.getUserSessionWithAuthToken(sessionToken) == null) {
|
||||
if (us == null) {
|
||||
log.debug("No user with session token.")
|
||||
reject = true;
|
||||
} else {
|
||||
us = meetingService.getUserSessionWithAuthToken(sessionToken);
|
||||
meeting = meetingService.getMeeting(us.meetingID);
|
||||
if (meeting == null || meeting.isForciblyEnded()) {
|
||||
log.debug("Meeting not found.")
|
||||
reject = true
|
||||
}
|
||||
userSession = meetingService.getUserSessionWithAuthToken(sessionToken)
|
||||
if (userSession == null) {
|
||||
log.debug("Session with user not found.")
|
||||
reject = true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Determine the logout url so we can send the user there.
|
||||
String logoutUrl = paramsProcessorUtil.getDefaultLogoutUrl()
|
||||
|
||||
if (us != null) {
|
||||
logoutUrl = us.logoutUrl
|
||||
}
|
||||
String logoutUrl = us != null ? us.logoutUrl : paramsProcessorUtil.getDefaultLogoutUrl()
|
||||
|
||||
if (reject) {
|
||||
log.info("No session for user in conference.")
|
||||
response.addHeader("Cache-Control", "no-cache")
|
||||
withFormat {
|
||||
json {
|
||||
@ -1327,7 +1303,7 @@ class ApiController {
|
||||
clientURL = params.clientURL;
|
||||
}
|
||||
|
||||
String guestWaitStatus = userSession.guestStatus
|
||||
String guestWaitStatus = us.guestStatus
|
||||
|
||||
log.debug("GuestWaitStatus = " + guestWaitStatus)
|
||||
|
||||
@ -1416,48 +1392,34 @@ class ApiController {
|
||||
def enter = {
|
||||
boolean reject = false;
|
||||
|
||||
if (StringUtils.isEmpty(params.sessionToken)) {
|
||||
println("SessionToken is missing.")
|
||||
}
|
||||
|
||||
String sessionToken = StringUtils.strip(params.sessionToken)
|
||||
|
||||
UserSession us = null;
|
||||
String sessionToken = sanitizeSessionToken(params.sessionToken)
|
||||
UserSession us = getUserSession(sessionToken);
|
||||
Meeting meeting = null;
|
||||
UserSession userSession = null;
|
||||
|
||||
Boolean allowEnterWithoutSession = false;
|
||||
// Depending on configuration, allow ENTER requests to proceed without session
|
||||
if (paramsProcessorUtil.getAllowRequestsWithoutSession()) {
|
||||
allowEnterWithoutSession = paramsProcessorUtil.getAllowRequestsWithoutSession();
|
||||
}
|
||||
|
||||
String respMessage = "Session " + sessionToken + " not found."
|
||||
|
||||
if (!sessionToken || meetingService.getUserSessionWithAuthToken(sessionToken) == null || (!allowEnterWithoutSession && !session[sessionToken])) {
|
||||
if (!hasValidSession(sessionToken)) {
|
||||
reject = true;
|
||||
respMessage = "Session " + sessionToken + " not found."
|
||||
} else {
|
||||
us = meetingService.getUserSessionWithAuthToken(sessionToken);
|
||||
if (us == null) {
|
||||
respMessage = "Session " + sessionToken + " not found."
|
||||
meeting = meetingService.getMeeting(us.meetingID);
|
||||
if (meeting == null || meeting.isForciblyEnded()) {
|
||||
reject = true
|
||||
respMessage = "Meeting not found or ended for session " + sessionToken + "."
|
||||
} else {
|
||||
meeting = meetingService.getMeeting(us.meetingID);
|
||||
if (meeting == null || meeting.isForciblyEnded()) {
|
||||
reject = true
|
||||
respMessage = "Meeting not found or ended for session " + sessionToken + "."
|
||||
}
|
||||
if (us.guestStatus.equals(GuestPolicy.DENY)) {
|
||||
respMessage = "User denied for user with session " + sessionToken + "."
|
||||
reject = true
|
||||
if (hasReachedMaxParticipants(meeting, us)) {
|
||||
reject = true;
|
||||
respMessage = "The number of participants allowed for this meeting has been reached.";
|
||||
} else {
|
||||
meeting.userEntered(us.internalUserId);
|
||||
}
|
||||
}
|
||||
if (us.guestStatus.equals(GuestPolicy.DENY)) {
|
||||
respMessage = "User denied for user with session " + sessionToken + "."
|
||||
reject = true
|
||||
}
|
||||
}
|
||||
|
||||
if (reject) {
|
||||
log.info("No session for user in conference.")
|
||||
|
||||
// Determine the logout url so we can send the user there.
|
||||
String logoutUrl = paramsProcessorUtil.getDefaultLogoutUrl()
|
||||
|
||||
@ -1572,25 +1534,13 @@ class ApiController {
|
||||
def stuns = {
|
||||
boolean reject = false;
|
||||
|
||||
UserSession us = null;
|
||||
String sessionToken = sanitizeSessionToken(params.sessionToken)
|
||||
UserSession us = getUserSession(sessionToken);
|
||||
Meeting meeting = null;
|
||||
String sessionToken = null
|
||||
|
||||
if (!StringUtils.isEmpty(params.sessionToken)) {
|
||||
sessionToken = StringUtils.strip(params.sessionToken)
|
||||
println("Session token = [" + sessionToken + "]")
|
||||
}
|
||||
|
||||
Boolean allowStunsWithoutSession = false;
|
||||
// Depending on configuration, allow STUNS requests to proceed without session
|
||||
if (paramsProcessorUtil.getAllowRequestsWithoutSession()) {
|
||||
allowStunsWithoutSession = paramsProcessorUtil.getAllowRequestsWithoutSession();
|
||||
}
|
||||
|
||||
if (sessionToken == null || meetingService.getUserSessionWithAuthToken(sessionToken) == null || (!allowStunsWithoutSession && !session[sessionToken])) {
|
||||
if (!hasValidSession(sessionToken)) {
|
||||
reject = true;
|
||||
} else {
|
||||
us = meetingService.getUserSessionWithAuthToken(sessionToken);
|
||||
meeting = meetingService.getMeeting(us.meetingID);
|
||||
if (meeting == null || meeting.isForciblyEnded()) {
|
||||
reject = true
|
||||
@ -1598,8 +1548,6 @@ class ApiController {
|
||||
}
|
||||
|
||||
if (reject) {
|
||||
log.info("No session for user in conference.")
|
||||
|
||||
String logoutUrl = paramsProcessorUtil.getDefaultLogoutUrl()
|
||||
|
||||
response.addHeader("Cache-Control", "no-cache")
|
||||
@ -1656,12 +1604,7 @@ class ApiController {
|
||||
*************************************************/
|
||||
def signOut = {
|
||||
|
||||
String sessionToken = null
|
||||
|
||||
if (!StringUtils.isEmpty(params.sessionToken)) {
|
||||
sessionToken = StringUtils.strip(params.sessionToken)
|
||||
println("SessionToken = " + sessionToken)
|
||||
}
|
||||
String sessionToken = sanitizeSessionToken(params.sessionToken)
|
||||
|
||||
Meeting meeting = null;
|
||||
|
||||
@ -2182,6 +2125,76 @@ class ApiController {
|
||||
}
|
||||
}
|
||||
|
||||
def getUserSession(token) {
|
||||
if (token == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
UserSession us = meetingService.getUserSessionWithAuthToken(token)
|
||||
if (us == null) {
|
||||
log.info("Cannot find UserSession for token ${token}")
|
||||
}
|
||||
|
||||
return us
|
||||
}
|
||||
|
||||
def sanitizeSessionToken(param) {
|
||||
if (param == null) {
|
||||
log.info("sanitizeSessionToken: token is null")
|
||||
return null
|
||||
}
|
||||
|
||||
if (StringUtils.isEmpty(param)) {
|
||||
log.info("sanitizeSessionToken: token is empty")
|
||||
return null
|
||||
}
|
||||
|
||||
return StringUtils.strip(param)
|
||||
}
|
||||
|
||||
private Boolean hasValidSession(token) {
|
||||
UserSession us = getUserSession(token)
|
||||
if (us == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!session[token]) {
|
||||
log.info("Session for token ${token} not found")
|
||||
|
||||
Boolean allowRequestsWithoutSession = paramsProcessorUtil.getAllowRequestsWithoutSession()
|
||||
if (!allowRequestsWithoutSession) {
|
||||
log.info("Meeting related to ${token} doesn't allow requests without session")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Token ${token} is valid")
|
||||
return true
|
||||
}
|
||||
|
||||
// Validate maxParticipants constraint
|
||||
private Boolean hasReachedMaxParticipants(meeting, us) {
|
||||
// Meeting object calls it maxUsers to build up the drama
|
||||
int maxParticipants = meeting.getMaxUsers();
|
||||
// When is set to 0, the validation is ignored
|
||||
Boolean enabled = maxParticipants > 0;
|
||||
// Users refreshing page or reconnecting must be identified
|
||||
Boolean rejoin = meeting.getUserById(us.internalUserId) != null;
|
||||
// Users that passed enter once, still not joined but somehow re-entered
|
||||
Boolean reenter = meeting.getEnteredUserById(us.internalUserId) != null;
|
||||
// Users that already joined the meeting
|
||||
int joinedUsers = meeting.getUsers().size()
|
||||
// Users that are entering the meeting
|
||||
int enteredUsers = meeting.getEnteredUsers().size()
|
||||
|
||||
Boolean reachedMax = (joinedUsers + enteredUsers) >= maxParticipants;
|
||||
if (enabled && !rejoin && !reenter && reachedMax) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void respondWithErrors(errorList, redirectResponse = false) {
|
||||
log.debug CONTROLLER_NAME + "#invalid"
|
||||
if (redirectResponse) {
|
||||
|
@ -36,6 +36,8 @@ require 'rubygems'
|
||||
require 'net/http'
|
||||
require 'journald/logger'
|
||||
require 'fnv'
|
||||
require 'shellwords'
|
||||
require 'English'
|
||||
|
||||
module BigBlueButton
|
||||
class MissingDirectoryException < RuntimeError
|
||||
@ -97,40 +99,28 @@ module BigBlueButton
|
||||
end
|
||||
|
||||
def self.execute(command, fail_on_error = true)
|
||||
status = ExecutionStatus.new
|
||||
status.detailedStatus = Open4::popen4(command) do |pid, stdin, stdout, stderr|
|
||||
BigBlueButton.logger.info("Executing: #{command}")
|
||||
|
||||
status.output = stdout.readlines
|
||||
BigBlueButton.logger.info("Output: #{Array(status.output).join} ") unless status.output.empty?
|
||||
|
||||
status.errors = stderr.readlines
|
||||
unless status.errors.empty?
|
||||
BigBlueButton.logger.error("Error: stderr: #{Array(status.errors).join}")
|
||||
BigBlueButton.logger.info("Executing: #{command.respond_to?(:to_ary) ? Shellwords.join(command) : command}")
|
||||
IO.popen(command, err: %i[child out]) do |io|
|
||||
io.each_line do |line|
|
||||
BigBlueButton.logger.info(line.chomp)
|
||||
end
|
||||
end
|
||||
status = $CHILD_STATUS
|
||||
|
||||
BigBlueButton.logger.info("Success?: #{status.success?}")
|
||||
BigBlueButton.logger.info("Process exited? #{status.exited?}")
|
||||
BigBlueButton.logger.info("Exit status: #{status.exitstatus}")
|
||||
if status.success? == false and fail_on_error
|
||||
raise "Execution failed"
|
||||
end
|
||||
raise 'Execution failed' if status.success? == false && fail_on_error
|
||||
|
||||
status
|
||||
end
|
||||
|
||||
def self.exec_ret(*command)
|
||||
BigBlueButton.logger.info "Executing: #{command.join(' ')}"
|
||||
IO.popen([*command, :err => [:child, :out]]) do |io|
|
||||
io.each_line do |line|
|
||||
BigBlueButton.logger.info line.chomp
|
||||
end
|
||||
end
|
||||
BigBlueButton.logger.info "Exit status: #{$?.exitstatus}"
|
||||
return $?.exitstatus
|
||||
execute(command, false).exitstatus
|
||||
end
|
||||
|
||||
def self.exec_redirect_ret(outio, *command)
|
||||
BigBlueButton.logger.info "Executing: #{command.join(' ')}"
|
||||
BigBlueButton.logger.info "Executing: #{Shellwords.join(command)}"
|
||||
BigBlueButton.logger.info "Sending output to #{outio}"
|
||||
IO.pipe do |r, w|
|
||||
pid = spawn(*command, :out => outio, :err => w)
|
||||
|
@ -22,7 +22,7 @@ from lxml import etree
|
||||
from collections import deque
|
||||
from fractions import Fraction
|
||||
import io
|
||||
from icu import Locale, BreakIterator
|
||||
from icu import Locale, BreakIterator, UnicodeString
|
||||
import unicodedata
|
||||
import html
|
||||
import logging
|
||||
@ -35,12 +35,14 @@ logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def webvtt_timestamp(ms):
|
||||
frac_s = int(ms % 1000)
|
||||
s = int(ms / 1000 % 60)
|
||||
m = int(ms / 1000 / 60 % 60)
|
||||
h = int(ms / 1000 / 60 / 60)
|
||||
return '{:02}:{:02}:{:02}.{:03}'.format(h, m, s, frac_s)
|
||||
return "{:02}:{:02}:{:02}.{:03}".format(h, m, s, frac_s)
|
||||
|
||||
|
||||
class CaptionLine:
|
||||
def __init__(self):
|
||||
@ -48,10 +50,11 @@ class CaptionLine:
|
||||
self.start_time = 0
|
||||
self.end_time = 0
|
||||
|
||||
|
||||
class Caption:
|
||||
def __init__(self, locale):
|
||||
self.locale = locale
|
||||
self.text = list()
|
||||
self.text = UnicodeString()
|
||||
self.timestamps = list()
|
||||
self._del_timestamps = list()
|
||||
|
||||
@ -63,24 +66,30 @@ class Caption:
|
||||
else:
|
||||
del_timestamp = self.timestamps[i]
|
||||
self._del_timestamps[i] = del_timestamp
|
||||
logger.debug("Removing text %s at %d:%d, del_ts: %d",
|
||||
repr(''.join(self.text[i:j])), i, j, del_timestamp)
|
||||
logger.debug(
|
||||
"Removing text %s at %d:%d, del_ts: %d",
|
||||
repr(str(self.text[i:j])),
|
||||
i,
|
||||
j,
|
||||
del_timestamp,
|
||||
)
|
||||
|
||||
if len(text) > 0:
|
||||
logger.debug("Inserting text %s at %d:%d, ts: %d",
|
||||
repr(''.join(text)), i, j, timestamp)
|
||||
logger.debug(
|
||||
"Inserting text %s at %d:%d, ts: %d", repr(str(text)), i, j, timestamp
|
||||
)
|
||||
|
||||
if i < len(self.timestamps) and timestamp > self.timestamps[i]:
|
||||
timestamp = self._del_timestamps[i]
|
||||
if timestamp is None:
|
||||
if i > 0:
|
||||
timestamp = self.timestamps[i-1]
|
||||
timestamp = self.timestamps[i - 1]
|
||||
else:
|
||||
timestamp = self.timestamps[i]
|
||||
logger.debug("Out of order timestamps, using ts: %d", timestamp)
|
||||
|
||||
self._del_timestamps[i:j] = [del_timestamp] * len(text)
|
||||
if (i < len(self._del_timestamps)):
|
||||
if i < len(self._del_timestamps):
|
||||
self._del_timestamps[i] = del_timestamp
|
||||
|
||||
self.text[i:j] = text
|
||||
@ -94,9 +103,9 @@ class Caption:
|
||||
stop_pos = 0
|
||||
start_pos = None
|
||||
for event in events:
|
||||
if event['name'] == 'record_status':
|
||||
status = event['status']
|
||||
timestamp = event['timestamp']
|
||||
if event["name"] == "record_status":
|
||||
status = event["status"]
|
||||
timestamp = event["timestamp"]
|
||||
|
||||
if status and not record:
|
||||
record = True
|
||||
@ -106,13 +115,14 @@ class Caption:
|
||||
# Find the position of the first character after recording
|
||||
# started
|
||||
start_pos = stop_pos
|
||||
while start_pos < len(self.timestamps) and \
|
||||
self.timestamps[start_pos] < start_ts:
|
||||
while (
|
||||
start_pos < len(self.timestamps)
|
||||
and self.timestamps[start_pos] < start_ts
|
||||
):
|
||||
start_pos += 1
|
||||
|
||||
logger.debug("Replacing characters %d:%d",
|
||||
stop_pos, start_pos)
|
||||
self.text[stop_pos:start_pos] = ["\n"]
|
||||
logger.debug("Replacing characters %d:%d", stop_pos, start_pos)
|
||||
self.text[stop_pos:start_pos] = "\n"
|
||||
self.timestamps[stop_pos:start_pos] = [stop_ts - ts_offset]
|
||||
|
||||
start_pos = stop_pos + 1
|
||||
@ -130,8 +140,10 @@ class Caption:
|
||||
# Find the position of the first character after recording
|
||||
# stopped, and apply ts offsets
|
||||
stop_pos = start_pos
|
||||
while stop_pos < len(self.timestamps) and \
|
||||
self.timestamps[stop_pos] < stop_ts:
|
||||
while (
|
||||
stop_pos < len(self.timestamps)
|
||||
and self.timestamps[stop_pos] < stop_ts
|
||||
):
|
||||
self.timestamps[stop_pos] -= ts_offset
|
||||
stop_pos += 1
|
||||
|
||||
@ -149,17 +161,16 @@ class Caption:
|
||||
# Apply all of the caption events to generate the full text
|
||||
# with per-character timestamps
|
||||
for event in events:
|
||||
if event['name'] == 'edit_caption_history':
|
||||
locale = event['locale']
|
||||
i = event['start_index']
|
||||
j = event['end_index']
|
||||
timestamp = event['timestamp']
|
||||
text = event['text']
|
||||
if event["name"] == "edit_caption_history":
|
||||
locale = event["locale"]
|
||||
i = event["start_index"]
|
||||
j = event["end_index"]
|
||||
timestamp = event["timestamp"]
|
||||
text = UnicodeString(event["text"])
|
||||
|
||||
caption = captions.get(locale)
|
||||
if caption is None:
|
||||
logger.info("Started caption stream for locale '%s'",
|
||||
locale)
|
||||
logger.info("Started caption stream for locale '%s'", locale)
|
||||
captions[locale] = caption = cls(locale)
|
||||
|
||||
caption.apply_edit(i, j, timestamp, text)
|
||||
@ -175,15 +186,12 @@ class Caption:
|
||||
def split_lines(self, max_length=32):
|
||||
lines = list()
|
||||
|
||||
str_text = "".join(self.text)
|
||||
|
||||
locale = Locale(self.locale)
|
||||
logger.debug("Using locale %s for word-wrapping",
|
||||
locale.getDisplayName(locale))
|
||||
logger.debug("Using locale %s for word-wrapping", locale.getDisplayName(locale))
|
||||
|
||||
break_iter = BreakIterator.createLineInstance(locale)
|
||||
break_iter.setText(str_text)
|
||||
|
||||
break_iter.setText(self.text)
|
||||
|
||||
line = CaptionLine()
|
||||
line_start = 0
|
||||
prev_break = 0
|
||||
@ -194,39 +202,45 @@ class Caption:
|
||||
status = break_iter.getRuleStatus()
|
||||
|
||||
line_end = next_break
|
||||
while line_end > line_start and ( \
|
||||
self.text[line_end-1].isspace() or \
|
||||
unicodedata.category(self.text[line_end-1]) in ['Cc', 'Mn']
|
||||
):
|
||||
logger.debug("text len: %d, line end: %d", len(self.text), line_end)
|
||||
while line_end > line_start and (
|
||||
self.text[line_end - 1].isspace()
|
||||
or unicodedata.category(self.text[line_end - 1]) in ["Cc", "Mn"]
|
||||
):
|
||||
line_end -= 1
|
||||
|
||||
do_break = False
|
||||
text_section = unicodedata.normalize(
|
||||
'NFC', "".join(self.text[line_start:line_end]))
|
||||
"NFC", str(self.text[line_start:line_end])
|
||||
)
|
||||
timestamps_section = self.timestamps[line_start:next_break]
|
||||
start_time = min(timestamps_section)
|
||||
end_time = max(timestamps_section)
|
||||
if len(text_section) > max_length:
|
||||
if prev_break == line_start:
|
||||
# Over-long string. Just chop it into bits
|
||||
line_end = next_break = prev_break + max_length
|
||||
next_break = prev_break + max_length
|
||||
continue
|
||||
else:
|
||||
next_break = prev_break
|
||||
do_break = True
|
||||
|
||||
else:
|
||||
# Status [100,200) indicates a required (hard) line break
|
||||
if next_break >= len(self.text) or \
|
||||
(status >= 100 and status < 200):
|
||||
if next_break >= len(self.text) or (status >= 100 and status < 200):
|
||||
line.text = text_section
|
||||
line.start_time = start_time
|
||||
line.end_time = end_time
|
||||
do_break = True
|
||||
|
||||
if do_break:
|
||||
logger.debug("text section %d -> %d (%d): %s",
|
||||
line.start_time, line.end_time,
|
||||
len(line.text), repr(line.text))
|
||||
logger.debug(
|
||||
"text section %d -> %d (%d): %s",
|
||||
line.start_time,
|
||||
line.end_time,
|
||||
len(line.text),
|
||||
repr(line.text),
|
||||
)
|
||||
lines.append(line)
|
||||
line = CaptionLine()
|
||||
line_start = next_break
|
||||
@ -242,7 +256,7 @@ class Caption:
|
||||
|
||||
def write_webvtt(self, f):
|
||||
# Write magic
|
||||
f.write("WEBVTT\n\n".encode('utf-8'))
|
||||
f.write("WEBVTT\n\n".encode("utf-8"))
|
||||
|
||||
lines = self.split_lines()
|
||||
|
||||
@ -297,49 +311,48 @@ class Caption:
|
||||
if next_start_time - end_time < 500:
|
||||
end_time = next_start_time
|
||||
|
||||
f.write("{} --> {}\n".format(
|
||||
webvtt_timestamp(start_time),
|
||||
webvtt_timestamp(end_time)
|
||||
).encode('utf-8'))
|
||||
f.write(html.escape(text, quote=False).encode('utf-8'))
|
||||
f.write("\n\n".encode('utf-8'))
|
||||
f.write(
|
||||
"{} --> {}\n".format(
|
||||
webvtt_timestamp(start_time), webvtt_timestamp(end_time)
|
||||
).encode("utf-8")
|
||||
)
|
||||
f.write(html.escape(text, quote=False).encode("utf-8"))
|
||||
f.write("\n\n".encode("utf-8"))
|
||||
|
||||
def caption_desc(self):
|
||||
locale = Locale(self.locale)
|
||||
return {
|
||||
"locale": self.locale,
|
||||
"localeName": locale.getDisplayName(locale)
|
||||
}
|
||||
return {"locale": self.locale, "localeName": locale.getDisplayName(locale)}
|
||||
|
||||
|
||||
def parse_record_status(event, element):
|
||||
userId = element.find('userId')
|
||||
status = element.find('status')
|
||||
userId = element.find("userId")
|
||||
status = element.find("status")
|
||||
|
||||
event["name"] = "record_status"
|
||||
event["user_id"] = userId.text
|
||||
event["status"] = status.text == "true"
|
||||
|
||||
event['name'] = 'record_status'
|
||||
event['user_id'] = userId.text
|
||||
event['status'] = (status.text == 'true')
|
||||
|
||||
def parse_caption_edit(event, element):
|
||||
locale = element.find('locale')
|
||||
text = element.find('text')
|
||||
startIndex = element.find('startIndex')
|
||||
endIndex = element.find('endIndex')
|
||||
localeCode = element.find('localeCode')
|
||||
locale = element.find("locale")
|
||||
text = element.find("text")
|
||||
startIndex = element.find("startIndex")
|
||||
endIndex = element.find("endIndex")
|
||||
localeCode = element.find("localeCode")
|
||||
|
||||
event['name'] = 'edit_caption_history'
|
||||
event['locale_name'] = locale.text
|
||||
event["name"] = "edit_caption_history"
|
||||
event["locale_name"] = locale.text
|
||||
if localeCode is not None:
|
||||
event['locale'] = localeCode.text
|
||||
event["locale"] = localeCode.text
|
||||
else:
|
||||
# Fallback for missing 'localeCode'
|
||||
event['locale'] = "en"
|
||||
event["locale"] = "en"
|
||||
if text.text is None:
|
||||
event['text'] = list()
|
||||
event["text"] = ""
|
||||
else:
|
||||
event['text'] = list(text.text)
|
||||
event['start_index'] = int(startIndex.text)
|
||||
event['end_index'] = int(endIndex.text)
|
||||
event["text"] = text.text
|
||||
event["start_index"] = int(startIndex.text)
|
||||
event["end_index"] = int(endIndex.text)
|
||||
|
||||
|
||||
def parse_events(directory="."):
|
||||
@ -353,22 +366,22 @@ def parse_events(directory="."):
|
||||
event = {}
|
||||
|
||||
# Convert timestamps to be in seconds from recording start
|
||||
timestamp = int(element.attrib['timestamp'])
|
||||
timestamp = int(element.attrib["timestamp"])
|
||||
if not start_time:
|
||||
start_time = timestamp
|
||||
timestamp = timestamp - start_time
|
||||
|
||||
# Only need events from these modules
|
||||
if not element.attrib['module'] in ['CAPTION','PARTICIPANT']:
|
||||
if not element.attrib["module"] in ["CAPTION", "PARTICIPANT"]:
|
||||
continue
|
||||
|
||||
event['name'] = name = element.attrib['eventname']
|
||||
event['timestamp'] = timestamp
|
||||
event["name"] = name = element.attrib["eventname"]
|
||||
event["timestamp"] = timestamp
|
||||
|
||||
if name == 'RecordStatusEvent':
|
||||
if name == "RecordStatusEvent":
|
||||
parse_record_status(event, element)
|
||||
have_record_events = True
|
||||
elif name == 'EditCaptionHistoryEvent':
|
||||
elif name == "EditCaptionHistoryEvent":
|
||||
parse_caption_edit(event, element)
|
||||
else:
|
||||
logger.debug("Unhandled event: %s", name)
|
||||
@ -381,25 +394,31 @@ def parse_events(directory="."):
|
||||
if not have_record_events:
|
||||
# Add a fake record start event to the events list
|
||||
event = {
|
||||
'name': 'record_status',
|
||||
'user_id': None,
|
||||
'timestamp': 0,
|
||||
'status': True
|
||||
}
|
||||
"name": "record_status",
|
||||
"user_id": None,
|
||||
"timestamp": 0,
|
||||
"status": True,
|
||||
}
|
||||
events.appendleft(event)
|
||||
|
||||
return events
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate WebVTT files from BigBlueButton captions",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
||||
parser.add_argument("-i", "--input", metavar="PATH",
|
||||
help="input directory with events.xml file",
|
||||
default=os.curdir)
|
||||
parser.add_argument("-o", "--output", metavar="PATH",
|
||||
help="output directory",
|
||||
default=os.curdir)
|
||||
description="Generate WebVTT files from BigBlueButton captions",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-i",
|
||||
"--input",
|
||||
metavar="PATH",
|
||||
help="input directory with events.xml file",
|
||||
default=os.curdir,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o", "--output", metavar="PATH", help="output directory", default=os.curdir
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
rawdir = args.input
|
||||
@ -419,6 +438,6 @@ if __name__ == "__main__":
|
||||
filename = os.path.join(outputdir, "captions.json")
|
||||
logger.info("Writing captions index file to %s", filename)
|
||||
|
||||
caption_descs = [ caption.caption_desc() for caption in captions.values() ]
|
||||
caption_descs = [caption.caption_desc() for caption in captions.values()]
|
||||
with open(filename, "w") as f:
|
||||
json.dump(caption_descs, f)
|
||||
|
@ -597,11 +597,15 @@ def events_parse_shape(shapes, event, current_presentation, current_slide, times
|
||||
shape[:type] == 'ellipse' or shape[:type] == 'triangle' or
|
||||
shape[:type] == 'line'
|
||||
shape[:color] = color_to_hex(event.at_xpath('color').text)
|
||||
thickness = event.at_xpath('thickness').text
|
||||
thickness = event.at_xpath('thickness')
|
||||
unless thickness
|
||||
BigBlueButton.logger.warn("Draw #{shape[:shape_id]} Shape #{shape[:shape_unique_id]} ID #{shape[:id]} is missing thickness")
|
||||
return
|
||||
end
|
||||
if $version_atleast_2_0_0
|
||||
shape[:thickness_percent] = thickness.to_f
|
||||
shape[:thickness_percent] = thickness.text.to_f
|
||||
else
|
||||
shape[:thickness] = thickness.to_i
|
||||
shape[:thickness] = thickness.text.to_i
|
||||
end
|
||||
end
|
||||
if shape[:type] == 'rectangle'
|
||||
|
Loading…
Reference in New Issue
Block a user