From d679a53f6e0096439385794e5fbc1947e5d67dc6 Mon Sep 17 00:00:00 2001 From: RoyceDa Date: Sat, 28 Feb 2026 18:31:58 +0200 Subject: [PATCH 01/19] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20Packet26Signal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/im/rosetta/Boot.java | 2 + .../im/rosetta/packet/Packet26Signal.java | 122 ++++++++++++++++++ .../packet/runtime/NetworkSignalType.java | 46 +++++++ 3 files changed, 170 insertions(+) create mode 100644 src/main/java/im/rosetta/packet/Packet26Signal.java create mode 100644 src/main/java/im/rosetta/packet/runtime/NetworkSignalType.java diff --git a/src/main/java/im/rosetta/Boot.java b/src/main/java/im/rosetta/Boot.java index 1a45a6a..795f4e0 100644 --- a/src/main/java/im/rosetta/Boot.java +++ b/src/main/java/im/rosetta/Boot.java @@ -44,6 +44,7 @@ import im.rosetta.packet.Packet22GroupBan; import im.rosetta.packet.Packet23DeviceList; import im.rosetta.packet.Packet24DeviceResolve; import im.rosetta.packet.Packet25Sync; +import im.rosetta.packet.Packet26Signal; import im.rosetta.packet.Packet2Result; import im.rosetta.packet.Packet3Search; import im.rosetta.packet.Packet4OnlineSubscribe; @@ -194,6 +195,7 @@ public class Boot { this.packetManager.registerPacket(23, Packet23DeviceList.class); this.packetManager.registerPacket(24, Packet24DeviceResolve.class); this.packetManager.registerPacket(25, Packet25Sync.class); + this.packetManager.registerPacket(26, Packet26Signal.class); } private void registerAllExecutors() { diff --git a/src/main/java/im/rosetta/packet/Packet26Signal.java b/src/main/java/im/rosetta/packet/Packet26Signal.java new file mode 100644 index 0000000..03f2674 --- /dev/null +++ b/src/main/java/im/rosetta/packet/Packet26Signal.java @@ -0,0 +1,122 @@ +package im.rosetta.packet; + +import im.rosetta.packet.runtime.NetworkSignalType; +import io.orprotocol.Stream; +import io.orprotocol.packet.Packet; + +/** + * Пакет cигналинга для совершения звонка. Учавствует в обмене ключами, + * иницилизации звонка. + */ +public class Packet26Signal extends Packet { + + /** + * Идентификатор отправителя сигнала, обычно это PK пользователя, который отправляет пакет + */ + private String src; + /** + * Идентификатор получателя сигнала, обычно это PK пользователя, который должен принять пакет + */ + private String dst; + + /** + * Если сигнал предназначен для обмена ключами, то в это поле + * будет помещаться sharedPublic публичная часть ключа DH алгоритма + */ + private String sharedPublic; + + /** + * Тип сигнала + */ + private NetworkSignalType signalType; + + @Override + public void read(Stream stream) { + this.signalType = NetworkSignalType.fromCode(stream.readInt8()); + this.src = stream.readString(); + this.dst = stream.readString(); + if (signalType == NetworkSignalType.KEY_EXCHANGE) { + this.sharedPublic = stream.readString(); + } else { + this.sharedPublic = null; + } + } + + @Override + public Stream write() { + Stream stream = new Stream(); + stream.writeInt16(this.packetId); + stream.writeInt8(this.signalType.getCode()); + stream.writeString(this.src); + stream.writeString(this.dst); + if (signalType == NetworkSignalType.KEY_EXCHANGE) { + stream.writeString(this.sharedPublic); + } + return stream; + } + + /** + * Получить идентификатор отправителя сигнала + * @return идентификатор отправителя сигнала + */ + public String getSrc() { + return src; + } + + /** + * Установить идентификатор отправителя сигнала + * @param src идентификатор отправителя сигнала + */ + public void setSrc(String src) { + this.src = src; + } + + /** + * Получить идентификатор получателя сигнала + * @return идентификатор получателя сигнала + */ + public String getDst() { + return dst; + } + + /** + * Установить идентификатор получателя сигнала + * @param dst идентификатор получателя сигнала + */ + public void setDst(String dst) { + this.dst = dst; + } + + /** + * Получить публичную часть ключа DH алгоритма, если сигнал предназначен для обмена ключами + * @return публичная часть ключа DH алгоритма или null, если сигнал не предназначен для обмена ключами + */ + public String getSharedPublic() { + return sharedPublic; + } + + /** + * Установить публичную часть ключа DH алгоритма, если сигнал предназначен для обмена ключами + * @param sharedPublic публичная часть ключа DH алгоритма + */ + public void setSharedPublic(String sharedPublic) { + this.sharedPublic = sharedPublic; + } + + /** + * Получить тип сигнала + * @return тип сигнала + */ + + public NetworkSignalType getSignalType() { + return signalType; + } + + /** + * Установить тип сигнала + * @param signalType тип сигнала + */ + public void setSignalType(NetworkSignalType signalType) { + this.signalType = signalType; + } +} diff --git a/src/main/java/im/rosetta/packet/runtime/NetworkSignalType.java b/src/main/java/im/rosetta/packet/runtime/NetworkSignalType.java new file mode 100644 index 0000000..ccaa4f1 --- /dev/null +++ b/src/main/java/im/rosetta/packet/runtime/NetworkSignalType.java @@ -0,0 +1,46 @@ +package im.rosetta.packet.runtime; + +/** + * Типы сигналов, используемых в сетевом взаимодействии при звонках + */ +public enum NetworkSignalType { + /** + * CALL - сигнал для совершения звонка, инициирует процесс звонка + */ + CALL(0), + /** + * KEY_EXCHANGE - сигнал для обмена ключами, используется для обмена DH ключами между участниками звонка + */ + KEY_EXCHANGE(1), + /** + * ICE_CANDIDATE - сигнал для обмена ICE кандидатами, используется для обмена сетевыми кандидатами между участниками звонка + */ + ICE_CONFIG(2), + /** + * ACTIVE_CALL - сигнал для активного звонка, указывает на то, что звонок активен и участники могут обмениваться данными + */ + ACTIVE_CALL(3), + /** + * END_CALL - сигнал для завершения звонка, указывает на то, что звонок завершен и участники должны прекратить обмен данными + */ + END_CALL(4); + + private final int code; + + NetworkSignalType(int code) { + this.code = code; + } + + public int getCode() { + return code; + } + + public static NetworkSignalType fromCode(int code) { + for (NetworkSignalType type : NetworkSignalType.values()) { + if (type.code == code) { + return type; + } + } + throw new IllegalArgumentException("Unknown NetworkSignalType code: " + code); + } +} -- 2.49.1 From b84f69da33a4a2573d95afd83be175892c9acbda Mon Sep 17 00:00:00 2001 From: RoyceDa Date: Tue, 10 Mar 2026 16:06:11 +0200 Subject: [PATCH 02/19] =?UTF-8?q?=D0=91=D0=B0=D0=B7=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B9=20SFU=20SDK=20=D0=B4=D0=BB=D1=8F=20g365sfu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 3 + src/main/java/im/rosetta/Boot.java | 9 +- .../rosetta/executors/Executor26Signal.java | 39 ++++++++ .../packet/runtime/NetworkSignalType.java | 16 ++-- .../service/services/ForwardUnitService.java | 56 ++++++++++++ src/main/java/io/g365sfu/SFU.java | 77 ++++++++++++++++ .../io/g365sfu/exception/SFUException.java | 13 +++ .../exception/SFUHandshakeException.java | 13 +++ src/main/java/io/g365sfu/net/SfuSock.java | 90 +++++++++++++++++++ 9 files changed, 305 insertions(+), 11 deletions(-) create mode 100644 src/main/java/im/rosetta/executors/Executor26Signal.java create mode 100644 src/main/java/im/rosetta/service/services/ForwardUnitService.java create mode 100644 src/main/java/io/g365sfu/SFU.java create mode 100644 src/main/java/io/g365sfu/exception/SFUException.java create mode 100644 src/main/java/io/g365sfu/exception/SFUHandshakeException.java create mode 100644 src/main/java/io/g365sfu/net/SfuSock.java diff --git a/.env b/.env index bacac01..c21b809 100644 --- a/.env +++ b/.env @@ -18,3 +18,6 @@ FIREBASE_CREDENTIALS_PATH=serviceAccount.json #Каждые сколько дней будет очищаться буфер (максимальная дистанция синхронизации сообщений) BUFFER_CLEANUP_DAYS=7 +#SFU Сервера +SFU_SERVERS=127.0.0.1:1001@SFU_TEST_SECRET + diff --git a/src/main/java/im/rosetta/Boot.java b/src/main/java/im/rosetta/Boot.java index 795f4e0..eef6f93 100644 --- a/src/main/java/im/rosetta/Boot.java +++ b/src/main/java/im/rosetta/Boot.java @@ -17,6 +17,7 @@ import im.rosetta.executors.Executor21GroupLeave; import im.rosetta.executors.Executor22GroupBan; import im.rosetta.executors.Executor24DeviceResolve; import im.rosetta.executors.Executor25Sync; +import im.rosetta.executors.Executor26Signal; import im.rosetta.executors.Executor3Search; import im.rosetta.executors.Executor4OnlineState; import im.rosetta.executors.Executor6Message; @@ -54,6 +55,8 @@ import im.rosetta.packet.Packet7Read; import im.rosetta.packet.Packet8Delivery; import im.rosetta.packet.Packet9DeviceNew; import im.rosetta.service.services.BufferCleanupService; +import im.rosetta.service.services.ForwardUnitService; +import io.g365sfu.SFU; import io.orprotocol.Server; import io.orprotocol.Settings; import io.orprotocol.packet.PacketManager; @@ -75,6 +78,7 @@ public class Boot { private ClientManager clientManager; private OnlineManager onlineManager; private BufferCleanupService bufferCleanupService; + private ForwardUnitService forwardUnitService; /** * Конструктор по умолчанию, использует порт 3000 для сервера @@ -106,6 +110,7 @@ public class Boot { int cleanupEveryDays = System.getenv("BUFFER_CLEANUP_DAYS") != null ? Integer.parseInt(System.getenv("BUFFER_CLEANUP_DAYS")) : 7; this.bufferCleanupService = new BufferCleanupService(cleanupEveryDays, this.logger); + this.forwardUnitService = new ForwardUnitService(this.logger); } /** @@ -152,7 +157,8 @@ public class Boot { this.registerAllEvents(); this.printBootMessage(); this.bufferCleanupService.start(); - return this; + this.forwardUnitService.connectToAllSFUServers(); + return this; }catch(Exception e){ this.logger.error(Color.RED + "Booting error, stack trace:"); e.printStackTrace(); @@ -217,6 +223,7 @@ public class Boot { this.packetManager.registerExecutor(22, new Executor22GroupBan()); this.packetManager.registerExecutor(24, new Executor24DeviceResolve(this.clientManager, this.eventManager, this.packetManager)); this.packetManager.registerExecutor(25, new Executor25Sync(this.packetManager)); + this.packetManager.registerExecutor(26, new Executor26Signal(this.clientManager)); } private void printBootMessage() { diff --git a/src/main/java/im/rosetta/executors/Executor26Signal.java b/src/main/java/im/rosetta/executors/Executor26Signal.java new file mode 100644 index 0000000..be0108d --- /dev/null +++ b/src/main/java/im/rosetta/executors/Executor26Signal.java @@ -0,0 +1,39 @@ +package im.rosetta.executors; + +import im.rosetta.client.ClientManager; +import im.rosetta.client.tags.ECIAuthentificate; +import im.rosetta.packet.Packet26Signal; +import io.orprotocol.ProtocolException; +import io.orprotocol.client.Client; +import io.orprotocol.packet.PacketExecutor; + +public class Executor26Signal extends PacketExecutor { + + public ClientManager clientManager; + + public Executor26Signal(ClientManager clientManager) { + this.clientManager = clientManager; + } + + @Override + public void onPacketReceived(Packet26Signal packet, Client client) throws Exception, ProtocolException { + ECIAuthentificate eciAuthentificate = client.getTag(ECIAuthentificate.class); + if (eciAuthentificate == null || !eciAuthentificate.hasAuthorized()) { + /** + * Если клиент не авторизован, то мы не будем обрабатывать его сигналы на анициализацию звонка + * и просто отключим его от сервера. + */ + client.disconnect(); + return; + } + /** + * TODO: Проверка на существование получателя + */ + this.clientManager.sendPacketToAuthorizedPK(packet.getDst(), packet); + /** + * TODO: Высокоприоритетный пуш для сигналов звонков, чтобы мобильные устройства могли показать + * интерфейс входящего звонка, даже если приложение находится в фоне + */ + } + +} diff --git a/src/main/java/im/rosetta/packet/runtime/NetworkSignalType.java b/src/main/java/im/rosetta/packet/runtime/NetworkSignalType.java index ccaa4f1..9ab8677 100644 --- a/src/main/java/im/rosetta/packet/runtime/NetworkSignalType.java +++ b/src/main/java/im/rosetta/packet/runtime/NetworkSignalType.java @@ -5,25 +5,21 @@ package im.rosetta.packet.runtime; */ public enum NetworkSignalType { /** - * CALL - сигнал для совершения звонка, инициирует процесс звонка + * Сигнал для совершения звонка, инициирует процесс звонка */ CALL(0), /** - * KEY_EXCHANGE - сигнал для обмена ключами, используется для обмена DH ключами между участниками звонка + * Сигнал для обмена ключами, используется для обмена DH ключами между участниками звонка */ KEY_EXCHANGE(1), /** - * ICE_CANDIDATE - сигнал для обмена ICE кандидатами, используется для обмена сетевыми кандидатами между участниками звонка + * Сигнал для активного звонка, указывает на то, что звонок активен и участники могут обмениваться данными */ - ICE_CONFIG(2), + ACTIVE_CALL(2), /** - * ACTIVE_CALL - сигнал для активного звонка, указывает на то, что звонок активен и участники могут обмениваться данными + * Сигнал для завершения звонка, указывает на то, что звонок завершен и участники должны прекратить обмен данными */ - ACTIVE_CALL(3), - /** - * END_CALL - сигнал для завершения звонка, указывает на то, что звонок завершен и участники должны прекратить обмен данными - */ - END_CALL(4); + END_CALL(3); private final int code; diff --git a/src/main/java/im/rosetta/service/services/ForwardUnitService.java b/src/main/java/im/rosetta/service/services/ForwardUnitService.java new file mode 100644 index 0000000..2528efa --- /dev/null +++ b/src/main/java/im/rosetta/service/services/ForwardUnitService.java @@ -0,0 +1,56 @@ +package im.rosetta.service.services; + +import java.util.HashSet; +import java.util.Set; + +import im.rosetta.logger.Logger; +import im.rosetta.logger.enums.Color; +import io.g365sfu.SFU; + +/** + * Это сервис который взаимодействуют с SFU серверами для организации звонков между пользователями. + */ +public class ForwardUnitService { + + private Logger logger; + private Set sfuConnections = new HashSet<>(); + + public ForwardUnitService(Logger logger) { + this.logger = logger; + } + + /** + * Инициализирует соединения к SFU серверам для звонков. + * Ожидается, что адреса SFU серверов и секретные ключи для них будут переданы через переменную окружения SFU_SERVERS в формате "address1@secretKey1,address2@secretKey2,...". + * Для каждого сервера будет предпринята попытка установить соединение и выполнить рукопожатие. + * Если соединение не может быть установлено или рукопожатие не удается, + * будет выведено сообщение об ошибке в лог, но процесс продолжится для остальных серверов. + * Успешные подключения будут сохранены в наборе sfuConnections для дальнейшего использования + */ + public void connectToAllSFUServers() { + String sfuServersEnv = System.getenv("SFU_SERVERS"); + if(sfuServersEnv == null || sfuServersEnv.isEmpty()) { + this.logger.info(Color.YELLOW + "No SFU servers configured, skipping SFU connections boot"); + return; + } + String[] sfuServers = sfuServersEnv.split(","); + for(String sfuServer : sfuServers) { + String[] parts = sfuServer.split("@"); + if(parts.length != 2) { + this.logger.error(Color.RED + "Invalid SFU server configuration: " + sfuServer); + continue; + } + String address = parts[0]; + String secretKey = parts[1]; + try { + SFU connection = new SFU(address, secretKey); + connection.connect(); + this.sfuConnections.add(connection); + this.logger.info(Color.GREEN + "Successfully connected to SFU server: " + address); + } catch (Exception e) { + this.logger.error(Color.RED + "Failed to connect to SFU server: " + address + ", error: " + e.getMessage()); + } + } + } + +} diff --git a/src/main/java/io/g365sfu/SFU.java b/src/main/java/io/g365sfu/SFU.java new file mode 100644 index 0000000..235544d --- /dev/null +++ b/src/main/java/io/g365sfu/SFU.java @@ -0,0 +1,77 @@ +package io.g365sfu; + +import java.net.URISyntaxException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import io.g365sfu.exception.SFUException; +import io.g365sfu.exception.SFUHandshakeException; +import io.g365sfu.net.SfuSock; + + +public class SFU { + + private String serverAddress; + private String secretKey; + private SfuSock socket; + + /** + * Конструктор для создания объекта SFU, который будет использоваться для установления соединения с SFU сервером. + * @param serverAddress адрес SFU сервера в формате "host:port", например "sfu.example.com:8080" + * @param secretKey секретный ключ для аутентификации с SFU сервером, который должен быть согласован с настройками сервера. + */ + public SFU(String serverAddress, String secretKey) { + this.serverAddress = serverAddress; + this.secretKey = secretKey; + } + + /** + * Установить соединение с SFU сервером и начать обмен рукопожатиями для аутентификации и установления безопасного канала связи. + * @throws URISyntaxException если адрес сервера имеет неправильный формат + * @throws InterruptedException если соединение было прервано во время попытки подключения + * @throws SFUException если не удалось установить соединение с SFU сервером или если соединение было установлено, + * но не открыто после подключения + * @throws TimeoutException не удалось обменяться рукопожатиями с SFU сервером в течение 30 секунд + * @throws ExecutionException если во время обмена рукопожатиями произошла ошибка выполнения + * @throws SFUHandshakeException если обмен рукопожатиями с SFU завершился неудачно (например, плохой ключ) + */ + public void connect() throws URISyntaxException, InterruptedException, SFUException, ExecutionException, TimeoutException, SFUHandshakeException { + this.socket = new SfuSock(this.serverAddress); + boolean connected = this.socket.connectBlocking(30, TimeUnit.SECONDS); + if(!connected){ + throw new SFUException("Failed to connect to SFU server, timeout after 30 seconds: " + this.serverAddress); + } + if(!this.socket.isOpen()) { + throw new SFUException("Connection to SFU server at " + this.serverAddress + " is not open"); + } + boolean estabilished = this.socket.handshakeExchange(this.secretKey).get(30, TimeUnit.SECONDS); + if(!estabilished) { + throw new SFUHandshakeException("Failed to establish handshake with SFU server at " + this.serverAddress); + } + } + + /** + * Получить адрес SFU сервера, к которому установлено соединение + * @return адрес SFU сервера + */ + public String getServerAddress() { + return this.serverAddress; + } + + /** + * Получить соединение к SFU серверу, если оно было установлено + * @return объект SfuSock, представляющий соединение к SFU серверу + */ + public SfuSock getConnection() { + return this.socket; + } + + /** + * Проверить, установлено ли соединение с SFU сервером и открыто ли оно + * @return true, если соединение установлено и открыто, false в противном случае + */ + public boolean isOpen() { + return this.socket != null && this.socket.isOpen(); + } +} diff --git a/src/main/java/io/g365sfu/exception/SFUException.java b/src/main/java/io/g365sfu/exception/SFUException.java new file mode 100644 index 0000000..e3c6239 --- /dev/null +++ b/src/main/java/io/g365sfu/exception/SFUException.java @@ -0,0 +1,13 @@ +package io.g365sfu.exception; + +public class SFUException extends Exception { + + public SFUException(String message) { + super(message); + } + + public SFUException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/src/main/java/io/g365sfu/exception/SFUHandshakeException.java b/src/main/java/io/g365sfu/exception/SFUHandshakeException.java new file mode 100644 index 0000000..9103da1 --- /dev/null +++ b/src/main/java/io/g365sfu/exception/SFUHandshakeException.java @@ -0,0 +1,13 @@ +package io.g365sfu.exception; + +public class SFUHandshakeException extends Exception { + + public SFUHandshakeException(String message) { + super(message); + } + + public SFUHandshakeException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/src/main/java/io/g365sfu/net/SfuSock.java b/src/main/java/io/g365sfu/net/SfuSock.java new file mode 100644 index 0000000..6330736 --- /dev/null +++ b/src/main/java/io/g365sfu/net/SfuSock.java @@ -0,0 +1,90 @@ +package io.g365sfu.net; + +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.ByteBuffer; +import java.util.concurrent.CompletableFuture; + +import org.java_websocket.client.WebSocketClient; +import org.java_websocket.handshake.ServerHandshake; + +public class SfuSock extends WebSocketClient { + + private CompletableFuture handshakeFuture = new CompletableFuture<>(); + + public SfuSock(String serverAddress) throws URISyntaxException { + super(new URI("ws://" + serverAddress)); + } + + @Override + public void onOpen(ServerHandshake handshakedata) { + System.out.println("Connected to SFU server"); + } + + @Override + public void onMessage(ByteBuffer bytes) { + if(bytes.remaining() < 1) { + System.err.println("Received empty message from SFU server"); + return; + } + byte messageType = bytes.get(); + if(messageType == 0x01) { + /** + * Сервер ответил на рукопожатие, и мы можем считать его успешным + */ + this.handshakeFuture.complete(true); + return; + } + if(messageType == 0xFF) { + /** + * Сервер отклонил рукопожатие, и мы должны считать его неудачным + */ + this.handshakeFuture.complete(false); + return; + } + } + + @Override + public void onMessage(String message) { + System.err.println("Received unexpected text message from SFU server: " + message); + } + + @Override + public void onClose(int code, String reason, boolean remote) { + if(!this.handshakeFuture.isDone()) { + /** + * Если соединение было закрыто до завершения рукопожатия, то мы считаем его неудачным + */ + this.handshakeFuture.complete(false); + } + } + + @Override + public void onError(Exception ex){ + System.err.println("Error: " + ex.getMessage()); + } + + /** + * Запускает обмен рукопожатиями с сервером SFU + * @param secretKey секретный ключ для аутентификации с сервером SFU + * @return CompletableFuture, который будет завершен с результатом true, если + * рукопожатие прошло успешно, или false, если рукопожатие не удалось или было отклонено сервером SFU + * @internal Этот метод отправляет пакет рукопожатия, который состоит из одного байта 0x01, за которым следует секретный ключ в виде строки байтов. + * Сервер SFU должен ответить одним байтом 0x01 для успешного рукопожатия или 0xFF для отклонения рукопожатия. + */ + public CompletableFuture handshakeExchange(String secretKey) { + ByteBuffer buffer = ByteBuffer.allocate(secretKey.length() + 1); + /** + * 0x01 - код рукопожатия в соотвествии с протоколом g365sfu, за которым следует секретный ключ в виде строки байтов + */ + buffer.put((byte)0x01); + buffer.put(secretKey.getBytes()); + buffer.flip(); + /** + * Отправляем сформированный пакет, и возвращаем CompletableFuture + */ + this.send(buffer); + return this.handshakeFuture; + } + +} -- 2.49.1 From 68cdec860d22d4bfbdc25ba6be3c7b27e5b01cb5 Mon Sep 17 00:00:00 2001 From: RoyceDa Date: Sat, 14 Mar 2026 22:56:24 +0200 Subject: [PATCH 03/19] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D1=80=D1=82=D0=BE=D0=BA=D0=BE?= =?UTF-8?q?=D0=BB=D0=B0=20=D0=B4=D0=BB=D1=8F=20=D1=81=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=20g365sfu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/io/g365sfu/Room.java | 158 ++++++++++++ src/main/java/io/g365sfu/SFU.java | 225 ++++++++++++++++++ src/main/java/io/g365sfu/net/SfuSock.java | 25 +- src/main/java/io/g365sfu/util/StrUtils.java | 15 ++ .../java/io/g365sfu/webrtc/ICECandidate.java | 34 +++ .../java/io/g365sfu/webrtc/RTCIceServer.java | 44 ++++ .../java/io/g365sfu/webrtc/SDPAnswer.java | 31 +++ src/main/java/io/g365sfu/webrtc/SDPOffer.java | 30 +++ 8 files changed, 561 insertions(+), 1 deletion(-) create mode 100644 src/main/java/io/g365sfu/Room.java create mode 100644 src/main/java/io/g365sfu/util/StrUtils.java create mode 100644 src/main/java/io/g365sfu/webrtc/ICECandidate.java create mode 100644 src/main/java/io/g365sfu/webrtc/RTCIceServer.java create mode 100644 src/main/java/io/g365sfu/webrtc/SDPAnswer.java create mode 100644 src/main/java/io/g365sfu/webrtc/SDPOffer.java diff --git a/src/main/java/io/g365sfu/Room.java b/src/main/java/io/g365sfu/Room.java new file mode 100644 index 0000000..e463092 --- /dev/null +++ b/src/main/java/io/g365sfu/Room.java @@ -0,0 +1,158 @@ +package io.g365sfu; + +import java.nio.ByteBuffer; +import java.util.HashSet; + +/** + * Это комната для звонков, она может быть как для двоих участников, так и для групповых звонков. + * Комната содержит в себе информацию о том, на каком она SFU сервере, какой у нее ID, кто в ней участвует. + */ +public class Room { + private String roomId; + private SFU sfu; + private HashSet participants; + + /** + * Создать комнату с заданным ID, SFU сервером и участниками + * @param roomId уникальный идентификатор комнаты, который должен быть согласован с SFU сервером и использоваться для всех операций с этой комнатой + * @param sfu SFU сервер, на котором будет создана комната и через который будет происходить обмен данными между участниками + * @param participants массив идентификаторов участников, которые будут добавлены в комнату. Идентификаторы должны быть согласованы с SFU сервером и использоваться для маршрутизации данных между участниками. + */ + public Room(String roomId, SFU sfu, HashSet participants) { + this.roomId = roomId; + this.sfu = sfu; + this.participants = participants; + } + + public String getRoomId() { + return roomId; + } + + public SFU getSfu() { + return sfu; + } + + public HashSet getParticipants() { + return participants; + } + + public void addParticipant(String participantId) { + this.participants.add(participantId); + } + + public void removeParticipant(String participantId) { + this.participants.remove(participantId); + } + + public boolean containsParticipant(String participantId) { + return this.participants.contains(participantId); + } + + /** + * Отправляет SDP offer в SFU сервер для организации звонка между пользователями. + * Этот метод используется для отправки предложения о соединении от одного участника + * к другому через сервер SFU, который будет пересылать это предложение целевому участнику. + * Параметры roomId и peerId используются для идентификации комнаты и целевого участника, + * а sdpOffer содержит описание медиа-сессии, которую участник хочет установить. + * @param roomId идентификатор комнаты, в которой участник хочет организовать звонок + * @param peerId идентификатор целевого участника, которому отправляется предложение о соединении + * @param sdpOffer строка, содержащая SDP offer, которая описывает медиа-сессию, которую участник хочет установить с целевым участником. + * @internal Этот метод формирует пакет с кодом 0x04, за которым следует идентификатор комнаты, идентификатор целевого участника и строка SDP offer. + */ + public void sdpOffer(String participantId, String sdpOffer) { + /** + * 1 байт номер пакета, + * 4 байта длина ID комнаты, + * N байт ID комнаты, + * 4 байта длина ID участника, + * M байт ID участника, + * 4 байта длина SDP offer, + * K байт SDP offer + */ + ByteBuffer buffer = ByteBuffer.allocate( + 1 + 4 + + this.roomId.getBytes().length + 4 + + participantId.getBytes().length + 4 + + sdpOffer.getBytes().length); + /** + * 0x03 - SDP offer + */ + buffer.put((byte)0x03); + buffer.putInt(this.roomId.getBytes().length); + buffer.put(this.roomId.getBytes()); + buffer.putInt(participantId.getBytes().length); + buffer.put(participantId.getBytes()); + buffer.putInt(sdpOffer.getBytes().length); + buffer.put(sdpOffer.getBytes()); + buffer.flip(); + this.sfu.getConnection().send(buffer); + } + + /** + * Отправляет ICE-кандидата в SFU сервер для одного из участников комнаты. + * @param participantId участник комнаты + * @param iceCandidate кандидат + */ + public void iceCandidate(String participantId, String iceCandidate) { + /** + * 1 байт номер пакета, + * 4 байта длина ID комнаты, + * N байт ID комнаты, + * 4 байта длина ID участника, + * M байт ID участника, + * 4 байта длина ICE кандидата, + * K байт ICE кандидата + */ + ByteBuffer buffer = ByteBuffer.allocate( + 1 + 4 + + this.roomId.getBytes().length + 4 + + participantId.getBytes().length + 4 + + iceCandidate.getBytes().length); + /** + * 0x06 - ICE кандидат + */ + buffer.put((byte)0x06); + buffer.putInt(this.roomId.getBytes().length); + buffer.put(this.roomId.getBytes()); + buffer.putInt(participantId.getBytes().length); + buffer.put(participantId.getBytes()); + buffer.putInt(iceCandidate.getBytes().length); + buffer.put(iceCandidate.getBytes()); + buffer.flip(); + this.sfu.getConnection().send(buffer); + } + + /** + * Отправляет SDP answer в SFU сервер для одного из участников комнаты. + * @param participantId участник комнаты + * @param sdpAnswer SDP answer + */ + public void sdpAnswer(String participantId, String sdpAnswer) { + /** + * 1 байт номер пакета, + * 4 байта длина ID комнаты, + * N байт ID комнаты, + * 4 байта длина ID участника, + * M байт ID участника, + * 4 байта длина SDP answer, + * K байт SDP answer + */ + ByteBuffer buffer = ByteBuffer.allocate( + 1 + 4 + + this.roomId.getBytes().length + 4 + + participantId.getBytes().length + 4 + + sdpAnswer.getBytes().length); + /** + * 0x07 - SDP answer + */ + buffer.put((byte)0x07); + buffer.putInt(this.roomId.getBytes().length); + buffer.put(this.roomId.getBytes()); + buffer.putInt(participantId.getBytes().length); + buffer.put(participantId.getBytes()); + buffer.putInt(sdpAnswer.getBytes().length); + buffer.put(sdpAnswer.getBytes()); + buffer.flip(); + this.sfu.getConnection().send(buffer); + } +} \ No newline at end of file diff --git a/src/main/java/io/g365sfu/SFU.java b/src/main/java/io/g365sfu/SFU.java index 235544d..ce089bb 100644 --- a/src/main/java/io/g365sfu/SFU.java +++ b/src/main/java/io/g365sfu/SFU.java @@ -1,13 +1,22 @@ package io.g365sfu; import java.net.URISyntaxException; +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.HashSet; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; import io.g365sfu.exception.SFUException; import io.g365sfu.exception.SFUHandshakeException; import io.g365sfu.net.SfuSock; +import io.g365sfu.util.StrUtils; +import io.g365sfu.webrtc.ICECandidate; +import io.g365sfu.webrtc.SDPAnswer; +import io.g365sfu.webrtc.SDPOffer; public class SFU { @@ -16,6 +25,28 @@ public class SFU { private String secretKey; private SfuSock socket; + private HashMap> pendingRoomCreations = new HashMap<>(); + /** + * Комнаты которые принадлежат этому серверу SFU. Ключом является ID комнаты, + * а значением объект Room, который содержит информацию о комнате, ее участниках и связанном с ней сервере SFU. + */ + private HashMap rooms = new HashMap<>(); + + /** + * Потребитель для обработки входящих ICE-кандидатов от сервера SFU. + * Этот потребитель будет вызываться при получении сообщения от сервера SFU с кодом 0x04, + * содержащим информацию об ICE-кандидате для одного из участников комнаты. + */ + private Consumer onIceCandidate; + /** + * Потребитель для обработки входящих SDP Answer от сервера SFU. + * Этот потребитель будет вызываться при получении сообщения от сервера SFU с кодом 0x05, + * содержащим информацию об SDP Answer для одного из участников комнаты. + */ + private Consumer onSdpAnswer; + + private Consumer onSdpOffer; + /** * Конструктор для создания объекта SFU, который будет использоваться для установления соединения с SFU сервером. * @param serverAddress адрес SFU сервера в формате "host:port", например "sfu.example.com:8080" @@ -38,6 +69,7 @@ public class SFU { */ public void connect() throws URISyntaxException, InterruptedException, SFUException, ExecutionException, TimeoutException, SFUHandshakeException { this.socket = new SfuSock(this.serverAddress); + this.socket.setMessageConsumer(this::onMessage); boolean connected = this.socket.connectBlocking(30, TimeUnit.SECONDS); if(!connected){ throw new SFUException("Failed to connect to SFU server, timeout after 30 seconds: " + this.serverAddress); @@ -51,6 +83,94 @@ public class SFU { } } + private void onMessage(ByteBuffer message) { + if(message.remaining() < 1) { + System.err.println("Received empty message from SFU server"); + return; + } + byte packetId = message.get(0); + if(packetId == 0x02) { + /** + * Ответ на создание комнаты, который содержит ID созданной комнаты + */ + int roomIdLength = message.getInt(); + byte[] roomIdBytes = new byte[roomIdLength]; + message.get(roomIdBytes); + String roomId = new String(roomIdBytes).trim(); + CompletableFuture future = this.pendingRoomCreations.remove(roomId); + if(future != null) { + future.complete(roomId); + } + return; + } + if(packetId == 0x04) { + /** + * ICE-candidate от сервера SFU для одного из участников комнаты + */ + int roomidLength = message.getInt(); + byte[] roomIdBytes = new byte[roomidLength]; + message.get(roomIdBytes); + String roomId = new String(roomIdBytes).trim(); + int peerIdLength = message.getInt(); + byte[] peerIdBytes = new byte[peerIdLength]; + message.get(peerIdBytes); + String peerId = new String(peerIdBytes).trim(); + int candidateLength = message.getInt(); + byte[] candidateBytes = new byte[candidateLength]; + message.get(candidateBytes); + String candidate = new String(candidateBytes).trim(); + ICECandidate iceCandidate = new ICECandidate(roomId, peerId, candidate); + if(this.onIceCandidate != null) { + this.onIceCandidate.accept(iceCandidate); + } + return; + } + if(packetId == 0x05) { + /** + * Ответ на Offer от сервера SFU, который содержит SDP Answer + */ + int roomidLength = message.getInt(); + byte[] roomIdBytes = new byte[roomidLength]; + message.get(roomIdBytes); + String roomId = new String(roomIdBytes).trim(); + int peerIdLength = message.getInt(); + byte[] peerIdBytes = new byte[peerIdLength]; + message.get(peerIdBytes); + String peerId = new String(peerIdBytes).trim(); + int sdpAnswerLength = message.getInt(); + byte[] sdpAnswerBytes = new byte[sdpAnswerLength]; + message.get(sdpAnswerBytes); + String sdpAnswer = new String(sdpAnswerBytes).trim(); + SDPAnswer answer = new SDPAnswer(roomId, peerId, sdpAnswer); + if(this.onSdpAnswer != null) { + this.onSdpAnswer.accept(answer); + } + return; + } + if(packetId == 0x08) { + /** + * Offer от сервера SFU для одного из участников комнаты при renegotiation + */ + int roomidLength = message.getInt(); + byte[] roomIdBytes = new byte[roomidLength]; + message.get(roomIdBytes); + String roomId = new String(roomIdBytes).trim(); + int peerIdLength = message.getInt(); + byte[] peerIdBytes = new byte[peerIdLength]; + message.get(peerIdBytes); + String peerId = new String(peerIdBytes).trim(); + int sdpOfferLength = message.getInt(); + byte[] sdpAnswerBytes = new byte[sdpOfferLength]; + message.get(sdpAnswerBytes); + String sdpOffer = new String(sdpAnswerBytes).trim(); + SDPOffer offer = new SDPOffer(roomId, peerId, sdpOffer); + if(this.onSdpOffer != null) { + this.onSdpOffer.accept(offer); + } + return; + } + } + /** * Получить адрес SFU сервера, к которому установлено соединение * @return адрес SFU сервера @@ -74,4 +194,109 @@ public class SFU { public boolean isOpen() { return this.socket != null && this.socket.isOpen(); } + + /** + * Создает комнату на сервере SFU для организации звонков между пользователями. Комната автоматически удаляется + * при выходе последнего участника из нее. Внутри комнаты пользователи могут обмениваться аудио и видео потоками, а сервер SFU + * будет эффективно их пересылать между участниками, минимизируя задержки и оптимизируя использование пропускной способности. + * @throws TimeoutException + * @throws ExecutionException + * @throws InterruptedException + * @internal Этот метод формирует пакет с кодом 0x02, + * за которым следует случайно сгенерированный идентификатор комнаты, + * и отправляет его на сервер SFU. + */ + public Room createRoom() throws InterruptedException, ExecutionException, TimeoutException { + String roomId = StrUtils.randomString(64); + /** + * 1 байт номер пакета, 4 байта длина ID комнаты, N байт ID комнаты + */ + ByteBuffer buffer = ByteBuffer.allocate(1 + 4 + roomId.getBytes().length); + CompletableFuture future = new CompletableFuture<>(); + this.pendingRoomCreations.put(roomId, future); + /** + * 0x02 - создание комнаты + */ + buffer.put((byte)0x02); + buffer.putInt(roomId.getBytes().length); + buffer.put(roomId.getBytes()); + buffer.flip(); + this.socket.send(buffer); + String createdRoomId = future.get(30, TimeUnit.SECONDS); + Room room = new Room(createdRoomId, this, new HashSet<>()); + this.rooms.put(createdRoomId, room); + return room; + } + + /** + * Получить все комнаты на сервере + * @return комнаты на этом сервере + */ + public HashSet getRooms() { + return new HashSet<>(this.rooms.values()); + } + + /** + * Получить комнату по ее идентификатору + * @param roomId идентификатор комнаты + * @return объект Room, представляющий комнату с данным идентификатором, или null, если комната не найдена + */ + public Room getRoom(String roomId) { + return this.rooms.get(roomId); + } + + /** + * Получить комнату, в которой участвует пользователь с данным идентификатором + * @param participantId идентификатор пользователя, который является участником комнаты + * @return объект Room, представляющий комнату, в которой участвует пользователь с данным идентификатором, или null, если такой комнаты не найдено + */ + public Room getRoomByParticipantId(String participantId) { + for(Room room : this.rooms.values()) { + if(room.containsParticipant(participantId)) { + return room; + } + } + return null; + } + + /** + * Получает количество комнат на этом сервере + * @return возвращает количество комнат на сервере + */ + public int getRoomsCount() { + return this.rooms.size(); + } + + /** + * Устанавливает потребителя для обработки входящих ICE-кандидатов от сервера SFU. + * @param onIceCandidate потребитель, который будет вызываться при получении сообщения от сервера SFU с кодом 0x04, + * содержащим информацию об ICE-кандидате для одного из участников комнаты. + * Параметром будет объект ICECandidate, который содержит информацию о комнате, участнике и самом + * кандидате, необходимую для правильной маршрутизации данных между участниками звонка через сервер SFU. + */ + public void setIceConsumer(Consumer onIceCandidate) { + this.onIceCandidate = onIceCandidate; + } + + /** + * Устанавливает потребителя для обработки входящих SDP Answer от сервера SFU. + * @param onSdpAnswer потребитель, который будет вызываться при получении сообщения от сервера SFU с кодом 0x05, + * содержащим информацию об SDP Answer для одного из участников комнаты. + * Параметром будет объект SDPAnswer, который содержит информацию о комнате, участнике и самом SDP Answer, + * необходимую для установления медиа-сессии между участником и сервером SFU. + */ + public void setAnswerConsumer(Consumer onSdpAnswer) { + this.onSdpAnswer = onSdpAnswer; + } + + /** + * Устанавливает потребителя для обработки входящих SDP Offer от сервера SFU при renegotiation. + * @param onSdpOffer потребитель, который будет вызываться при получении сообщения от сервера SFU с кодом 0x08, + * содержащим информацию об SDP Offer для одного из участников комнаты при renegotiation. + * Параметром будет объект SDPOffer, который содержит информацию о комнате, участнике и самом SDP Offer, + * необходимую для установления медиа-сессии между участником и сервером SFU. + */ + public void setOfferConsumer(Consumer onSdpOffer) { + this.onSdpOffer = onSdpOffer; + } } diff --git a/src/main/java/io/g365sfu/net/SfuSock.java b/src/main/java/io/g365sfu/net/SfuSock.java index 6330736..ac207b1 100644 --- a/src/main/java/io/g365sfu/net/SfuSock.java +++ b/src/main/java/io/g365sfu/net/SfuSock.java @@ -4,6 +4,7 @@ import java.net.URI; import java.net.URISyntaxException; import java.nio.ByteBuffer; import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; import org.java_websocket.client.WebSocketClient; import org.java_websocket.handshake.ServerHandshake; @@ -11,6 +12,7 @@ import org.java_websocket.handshake.ServerHandshake; public class SfuSock extends WebSocketClient { private CompletableFuture handshakeFuture = new CompletableFuture<>(); + private Consumer onMessage; public SfuSock(String serverAddress) throws URISyntaxException { super(new URI("ws://" + serverAddress)); @@ -42,6 +44,13 @@ public class SfuSock extends WebSocketClient { this.handshakeFuture.complete(false); return; } + + /** + * Если это не сообщение рукопожатия, то мы передаем его в установленного потребителя + */ + if(this.onMessage != null) { + this.onMessage.accept(bytes); + } } @Override @@ -73,11 +82,17 @@ public class SfuSock extends WebSocketClient { * Сервер SFU должен ответить одним байтом 0x01 для успешного рукопожатия или 0xFF для отклонения рукопожатия. */ public CompletableFuture handshakeExchange(String secretKey) { - ByteBuffer buffer = ByteBuffer.allocate(secretKey.length() + 1); + /** + * 1 байт номер пакета, 4 байта длина секретного ключа, N байт секретный ключ + */ + ByteBuffer buffer = ByteBuffer.allocate( + 1 + 4 + secretKey.getBytes().length + ); /** * 0x01 - код рукопожатия в соотвествии с протоколом g365sfu, за которым следует секретный ключ в виде строки байтов */ buffer.put((byte)0x01); + buffer.putInt(secretKey.getBytes().length); buffer.put(secretKey.getBytes()); buffer.flip(); /** @@ -87,4 +102,12 @@ public class SfuSock extends WebSocketClient { return this.handshakeFuture; } + /** + * Устанавливает потребителя для обработки входящих текстовых сообщений от сервера SFU. + * @param onMessage потребитель, который будет вызван с текстом сообщения при получении текстового сообщения от сервера SFU. + * @internal Этот метод позволяет установить обработчик для текстовых сообщений от сервера SFU + */ + public void setMessageConsumer(Consumer onMessage) { + this.onMessage = onMessage; + } } diff --git a/src/main/java/io/g365sfu/util/StrUtils.java b/src/main/java/io/g365sfu/util/StrUtils.java new file mode 100644 index 0000000..deccec8 --- /dev/null +++ b/src/main/java/io/g365sfu/util/StrUtils.java @@ -0,0 +1,15 @@ +package io.g365sfu.util; + +public class StrUtils { + + public static String randomString(int length) { + String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < length; i++) { + int index = (int) (Math.random() * chars.length()); + sb.append(chars.charAt(index)); + } + return sb.toString(); + } + +} diff --git a/src/main/java/io/g365sfu/webrtc/ICECandidate.java b/src/main/java/io/g365sfu/webrtc/ICECandidate.java new file mode 100644 index 0000000..63ac1ee --- /dev/null +++ b/src/main/java/io/g365sfu/webrtc/ICECandidate.java @@ -0,0 +1,34 @@ +package io.g365sfu.webrtc; + +/** + * Этот класс представляет собой ICE-кандидат, + * который используется для обмена информацией о сетевых маршрутах между участниками звонка через сервер SFU. + * + * Содержит информацию о комнате, участнике и самом кандидате, которая необходима для правильной маршрутизации данных между участниками звонка через сервер SFU. + */ +public class ICECandidate { + + private String roomId; + private String participantId; + private String candidate; + + public ICECandidate(String roomId, String participantId, String candidate) { + this.roomId = roomId; + this.participantId = participantId; + this.candidate = candidate; + } + + public String getRoomId() { + return roomId; + } + + public String getParticipantId() { + return participantId; + } + + public String getCandidate() { + return candidate; + } + + +} diff --git a/src/main/java/io/g365sfu/webrtc/RTCIceServer.java b/src/main/java/io/g365sfu/webrtc/RTCIceServer.java new file mode 100644 index 0000000..40f20d3 --- /dev/null +++ b/src/main/java/io/g365sfu/webrtc/RTCIceServer.java @@ -0,0 +1,44 @@ +package io.g365sfu.webrtc; + + +/** + * Представляет собой объект RTCIceServer который содержит информацию о сервере ICE, + * например TURN + */ +public class RTCIceServer { + + private String url; + private String username; + private String credential; + + public RTCIceServer(String url, String username, String credential) { + this.url = url; + this.username = username; + this.credential = credential; + } + + /** + * URL сервера ICE, который используется для обмена кандидатами между участниками звонка через сервер SFU. + * @return строка, содержащая URL сервера ICE, который используется для обмена кандидатами между участниками звонка через сервер SFU. + */ + public String getUrl() { + return url; + } + + /** + * Имя пользователя для аутентификации на сервере ICE. + * @return строка, содержащая имя пользователя для аутентификации на сервере ICE. + */ + public String getUsername() { + return username; + } + + /** + * Учетные данные для аутентификации на сервере ICE. + * @return строка, содержащая учетные данные для аутентификации на сервере ICE. + */ + public String getCredential() { + return credential; + } + +} diff --git a/src/main/java/io/g365sfu/webrtc/SDPAnswer.java b/src/main/java/io/g365sfu/webrtc/SDPAnswer.java new file mode 100644 index 0000000..333d13a --- /dev/null +++ b/src/main/java/io/g365sfu/webrtc/SDPAnswer.java @@ -0,0 +1,31 @@ +package io.g365sfu.webrtc; + +/** + * Приходит с сервера SFU в ответ на отправленный SDP Offer от участника комнаты. + * Содержит информацию о комнате, участнике и самом SDP Answer, который необходим для установления медиа-сессии + * между участником и сервером SFU + */ +public class SDPAnswer { + + private String roomId; + private String participantId; + private String sdp; + + public SDPAnswer(String roomId, String participantId, String sdp) { + this.roomId = roomId; + this.participantId = participantId; + this.sdp = sdp; + } + + public String getRoomId() { + return roomId; + } + + public String getParticipantId() { + return participantId; + } + + public String getSdp() { + return sdp; + } +} diff --git a/src/main/java/io/g365sfu/webrtc/SDPOffer.java b/src/main/java/io/g365sfu/webrtc/SDPOffer.java new file mode 100644 index 0000000..4814007 --- /dev/null +++ b/src/main/java/io/g365sfu/webrtc/SDPOffer.java @@ -0,0 +1,30 @@ +package io.g365sfu.webrtc; + +/** + * Приходит от SFU сервера для конкретного участника комнаты. Обычно приходит при renegotiation, + * когда участник комнаты отправляет новый SDP Offer на сервер SFU, а сервер SFU отвечает ему новым SDP Answer, + * который содержит обновленную информацию о медиа-сессии для этого участника. + */ +public class SDPOffer { + private String roomId; + private String participantId; + private String sdp; + + public SDPOffer(String roomId, String participantId, String sdp) { + this.roomId = roomId; + this.participantId = participantId; + this.sdp = sdp; + } + + public String getRoomId() { + return roomId; + } + + public String getParticipantId() { + return participantId; + } + + public String getSdp() { + return sdp; + } +} -- 2.49.1 From 6c43f2d273c0c4d553c2c2627b4b1fab21a53f4d Mon Sep 17 00:00:00 2001 From: RoyceDa Date: Sat, 14 Mar 2026 22:56:43 +0200 Subject: [PATCH 04/19] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81=20warnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/im/rosetta/database/Repository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/im/rosetta/database/Repository.java b/src/main/java/im/rosetta/database/Repository.java index 2d63286..eded74f 100644 --- a/src/main/java/im/rosetta/database/Repository.java +++ b/src/main/java/im/rosetta/database/Repository.java @@ -236,7 +236,7 @@ public abstract class Repository { * @param noResultType если true, то не указывать тип результата в запросе, используется для запросов типа UPDATE и DELETE * @return список сущностей */ - @SuppressWarnings("deprecation") + @SuppressWarnings({"unchecked", "deprecation"}) public QuerySession buildQuery(String queryString, HashMap parameters, boolean noResultType) { Session session = HibernateUtil.openSession(); try { -- 2.49.1 From 566802c91473c42d2efac3e7ad4971535156de26 Mon Sep 17 00:00:00 2001 From: RoyceDa Date: Sat, 14 Mar 2026 22:57:13 +0200 Subject: [PATCH 05/19] =?UTF-8?q?=D0=94=D0=BE=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D1=81=D0=B5?= =?UTF-8?q?=D0=BA=D1=86=D0=B8=D1=8F=20=D1=81=D0=B8=D0=B3=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../packet/runtime/NetworkSignalType.java | 6 +- .../packet/runtime/NetworkWebRTCType.java | 36 +++++ .../service/services/ForwardUnitService.java | 151 +++++++++++++++++- 3 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 src/main/java/im/rosetta/packet/runtime/NetworkWebRTCType.java diff --git a/src/main/java/im/rosetta/packet/runtime/NetworkSignalType.java b/src/main/java/im/rosetta/packet/runtime/NetworkSignalType.java index 9ab8677..d062cd0 100644 --- a/src/main/java/im/rosetta/packet/runtime/NetworkSignalType.java +++ b/src/main/java/im/rosetta/packet/runtime/NetworkSignalType.java @@ -19,7 +19,11 @@ public enum NetworkSignalType { /** * Сигнал для завершения звонка, указывает на то, что звонок завершен и участники должны прекратить обмен данными */ - END_CALL(3); + END_CALL(3), + /** + * Создание комнаты + */ + CREATE_ROOM(4); private final int code; diff --git a/src/main/java/im/rosetta/packet/runtime/NetworkWebRTCType.java b/src/main/java/im/rosetta/packet/runtime/NetworkWebRTCType.java new file mode 100644 index 0000000..964477a --- /dev/null +++ b/src/main/java/im/rosetta/packet/runtime/NetworkWebRTCType.java @@ -0,0 +1,36 @@ +package im.rosetta.packet.runtime; + +public enum NetworkWebRTCType { + /** + * Оффер + */ + OFFER(0), + /** + * Ответ на оффер + */ + ANSWER(1), + /** + * ICE кандидат + */ + ICE_CANDIDATE(2); + + private final int code; + + NetworkWebRTCType(int code) { + this.code = code; + } + + public int getCode() { + return code; + } + + public static NetworkWebRTCType fromCode(int code) { + for (NetworkWebRTCType type : NetworkWebRTCType.values()) { + if (type.code == code) { + return type; + } + } + throw new IllegalArgumentException("Unknown NetworkWebRTCType code: " + code); + } + +} diff --git a/src/main/java/im/rosetta/service/services/ForwardUnitService.java b/src/main/java/im/rosetta/service/services/ForwardUnitService.java index 2528efa..fa49f3b 100644 --- a/src/main/java/im/rosetta/service/services/ForwardUnitService.java +++ b/src/main/java/im/rosetta/service/services/ForwardUnitService.java @@ -3,9 +3,18 @@ package im.rosetta.service.services; import java.util.HashSet; import java.util.Set; +import im.rosetta.client.ClientManager; import im.rosetta.logger.Logger; import im.rosetta.logger.enums.Color; +import im.rosetta.packet.Packet27WebRTC; +import im.rosetta.packet.runtime.NetworkWebRTCType; +import io.g365sfu.Room; import io.g365sfu.SFU; +import io.g365sfu.webrtc.ICECandidate; +import io.g365sfu.webrtc.RTCIceServer; +import io.g365sfu.webrtc.SDPAnswer; +import io.g365sfu.webrtc.SDPOffer; +import io.orprotocol.ProtocolException; /** * Это сервис который взаимодействуют с SFU серверами для организации звонков между пользователями. @@ -14,9 +23,44 @@ public class ForwardUnitService { private Logger logger; private Set sfuConnections = new HashSet<>(); + private ClientManager clientManager; + private Set turnServers = new HashSet<>(); - public ForwardUnitService(Logger logger) { + public ForwardUnitService(Logger logger, ClientManager clientManager) { this.logger = logger; + this.clientManager = clientManager; + this.readAllTurnServers(); + } + + /** + * Читаем все TURN сервера из переменной окружения и сохраняем их для дальнейшего + * использования при организации звонков через SFU серверы. + */ + private void readAllTurnServers() { + String turnServersEnv = System.getenv("TURN_SERVERS"); + if(turnServersEnv == null || turnServersEnv.isEmpty()) { + this.logger.info(Color.YELLOW + "No TURN servers configured, skipping TURN servers boot"); + return; + } + String[] turnServers = turnServersEnv.split(","); + for(String turnServer : turnServers) { + String[] parts = turnServer.split("@"); + if(parts.length != 2) { + this.logger.error(Color.RED + "Invalid TURN server configuration: " + turnServer); + continue; + } + String address = parts[0]; + String credentialsPart = parts[1]; + String[] credentialsParts = credentialsPart.split(":"); + if(credentialsParts.length != 2) { + this.logger.error(Color.RED + "Invalid TURN server credentials configuration: " + credentialsPart); + continue; + } + String username = credentialsParts[0]; + String credential = credentialsParts[1]; + RTCIceServer iceServer = new RTCIceServer(address, username, credential); + this.turnServers.add(iceServer); + } } /** @@ -45,6 +89,22 @@ public class ForwardUnitService { try { SFU connection = new SFU(address, secretKey); connection.connect(); + connection.setIceConsumer(arg0 -> { + try { + onIceCandidate(arg0); + } catch (ProtocolException e) { + this.logger.error(Color.RED + "Failed to retranslate ICE-candidate from SFU server: " + address + ", error: " + e.getMessage()); + e.printStackTrace(); + } + }); + connection.setAnswerConsumer(arg0 -> { + try{ + onSdpAnswer(arg0); + }catch(ProtocolException e){ + this.logger.error(Color.RED + "Failed to retranslate SDP answer from SFU server: " + address + ", error: " + e.getMessage()); + e.printStackTrace(); + } + }); this.sfuConnections.add(connection); this.logger.info(Color.GREEN + "Successfully connected to SFU server: " + address); } catch (Exception e) { @@ -52,5 +112,94 @@ public class ForwardUnitService { } } } + + public void onSdpAnswer(SDPAnswer sdpAnswer) throws ProtocolException { + String participantId = sdpAnswer.getParticipantId(); + Packet27WebRTC packet = new Packet27WebRTC(); + packet.setSdpOrCandidate(sdpAnswer.getSdp()); + packet.setType(NetworkWebRTCType.ANSWER); + this.clientManager.sendPacketToAuthorizedPK(participantId, packet); + } + + /** + * Выполняется когда сервер SFU отправляет ICE-кандидата для одного из участников комнаты. + * @param iceCandidate объект ICECandidate, + * который содержит информацию о комнате, участнике и самом кандидате, + * которая необходима для правильной маршрутизации данных между участниками звонка через сервер SFU. + * @throws ProtocolException + */ + public void onIceCandidate(ICECandidate iceCandidate) throws ProtocolException { + String publicKey = iceCandidate.getParticipantId(); + Packet27WebRTC packet = new Packet27WebRTC(); + packet.setSdpOrCandidate(iceCandidate.getCandidate()); + packet.setType(NetworkWebRTCType.ICE_CANDIDATE); + this.clientManager.sendPacketToAuthorizedPK(publicKey, packet); + } + + /** + * Выполняется когда сервер SFU отправляет SDP оффер для одного из участников комнаты. + * @param sdpOffer объект SDPOffer, + * который содержит информацию о комнате, участнике и самом оффере, + * которая необходима для правильной маршрутизации данных между участниками звонка через сервер SFU. + * @throws ProtocolException + */ + public void onSdpOffer(SDPOffer sdpOffer) throws ProtocolException { + String participantId = sdpOffer.getParticipantId(); + Packet27WebRTC packet = new Packet27WebRTC(); + packet.setSdpOrCandidate(sdpOffer.getSdp()); + packet.setType(NetworkWebRTCType.OFFER); + this.clientManager.sendPacketToAuthorizedPK(participantId, packet); + } + + /** + * Получает комнату в которой сейчас находится пользователь + * @param participantId идентификатор пользователя на сервере SFU + * @return комната Room если найдена, иначе null + */ + public Room getRoomByParticipantId(String participantId) { + for(SFU sfu : this.sfuConnections){ + Room room = sfu.getRoomByParticipantId(participantId); + if(room != null){ + return room; + } + } + return null; + } + + /** + * Автоматически выбирает сервер для создания комнаты, и создает в нем комнату + * @return комната + */ + public Room createRoom() { + if(this.sfuConnections.isEmpty()) { + return null; + } + SFU selectedSfu = null; + int minRooms = Integer.MAX_VALUE; + for(SFU sfu : this.sfuConnections) { + int roomsCount = sfu.getRoomsCount(); + if(roomsCount < minRooms) { + minRooms = roomsCount; + selectedSfu = sfu; + } + } + if(selectedSfu != null) { + try{ + return selectedSfu.createRoom(); + }catch(Exception e){ + this.logger.error(Color.RED + "Failed to create room on SFU server: " + selectedSfu.getServerAddress() + ", error: " + e.getMessage()); + } + } + return null; + } + + /** + * Получить список TURN серверов, которые могут быть использованы для обмена кандидатами между участниками звонка через сервер SFU. + * @return список серверов для RTC + */ + public Set getTurnServers() { + return turnServers; + } + } -- 2.49.1 From 8aab1b19b0ab4e8a3eca3da75616651d820bd34c Mon Sep 17 00:00:00 2001 From: RoyceDa Date: Sat, 14 Mar 2026 22:57:33 +0200 Subject: [PATCH 06/19] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D0=B0=D1=8F=20=D0=BE=D1=80=D0=B3=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rosetta/executors/Executor26Signal.java | 39 ----------- .../executors/Executor26SignalPeer.java | 64 +++++++++++++++++++ 2 files changed, 64 insertions(+), 39 deletions(-) delete mode 100644 src/main/java/im/rosetta/executors/Executor26Signal.java create mode 100644 src/main/java/im/rosetta/executors/Executor26SignalPeer.java diff --git a/src/main/java/im/rosetta/executors/Executor26Signal.java b/src/main/java/im/rosetta/executors/Executor26Signal.java deleted file mode 100644 index be0108d..0000000 --- a/src/main/java/im/rosetta/executors/Executor26Signal.java +++ /dev/null @@ -1,39 +0,0 @@ -package im.rosetta.executors; - -import im.rosetta.client.ClientManager; -import im.rosetta.client.tags.ECIAuthentificate; -import im.rosetta.packet.Packet26Signal; -import io.orprotocol.ProtocolException; -import io.orprotocol.client.Client; -import io.orprotocol.packet.PacketExecutor; - -public class Executor26Signal extends PacketExecutor { - - public ClientManager clientManager; - - public Executor26Signal(ClientManager clientManager) { - this.clientManager = clientManager; - } - - @Override - public void onPacketReceived(Packet26Signal packet, Client client) throws Exception, ProtocolException { - ECIAuthentificate eciAuthentificate = client.getTag(ECIAuthentificate.class); - if (eciAuthentificate == null || !eciAuthentificate.hasAuthorized()) { - /** - * Если клиент не авторизован, то мы не будем обрабатывать его сигналы на анициализацию звонка - * и просто отключим его от сервера. - */ - client.disconnect(); - return; - } - /** - * TODO: Проверка на существование получателя - */ - this.clientManager.sendPacketToAuthorizedPK(packet.getDst(), packet); - /** - * TODO: Высокоприоритетный пуш для сигналов звонков, чтобы мобильные устройства могли показать - * интерфейс входящего звонка, даже если приложение находится в фоне - */ - } - -} diff --git a/src/main/java/im/rosetta/executors/Executor26SignalPeer.java b/src/main/java/im/rosetta/executors/Executor26SignalPeer.java new file mode 100644 index 0000000..674b864 --- /dev/null +++ b/src/main/java/im/rosetta/executors/Executor26SignalPeer.java @@ -0,0 +1,64 @@ +package im.rosetta.executors; + +import im.rosetta.Failures; +import im.rosetta.client.ClientManager; +import im.rosetta.client.tags.ECIAuthentificate; +import im.rosetta.packet.Packet26SignalPeer; +import im.rosetta.packet.runtime.NetworkSignalType; +import im.rosetta.service.services.ForwardUnitService; +import io.g365sfu.Room; +import io.orprotocol.ProtocolException; +import io.orprotocol.client.Client; +import io.orprotocol.packet.PacketExecutor; + +/** + * Используется в Peer To Peer звонках, в групповых звонках другой сигналинг + */ +public class Executor26SignalPeer extends PacketExecutor { + + private ClientManager clientManager; + private ForwardUnitService fus; + + public Executor26SignalPeer(ClientManager clientManager, ForwardUnitService fus) { + this.clientManager = clientManager; + this.fus = fus; + } + + @Override + public void onPacketReceived(Packet26SignalPeer packet, Client client) throws Exception, ProtocolException { + ECIAuthentificate eciAuthentificate = client.getTag(ECIAuthentificate.class); + if (eciAuthentificate == null || !eciAuthentificate.hasAuthorized()) { + /** + * Если клиент не авторизован, то мы не будем обрабатывать его сигналы на анициализацию звонка + * и просто отключим его от сервера. + */ + client.disconnect(Failures.HANDSHAKE_NOT_COMPLETED); + return; + } + NetworkSignalType type = packet.getSignalType(); + if(type == NetworkSignalType.CREATE_ROOM){ + /** + * Создается комната для звонка + */ + Room room = this.fus.createRoom(); + room.addParticipant(packet.getSrc()); + room.addParticipant(packet.getDst()); + packet.setRoomId(room.getRoomId()); + /** + * Результат создания комнаты транслируем обоим участникам, чтобы они могли начать обмен WebRTC SDP, и тд + */ + this.clientManager.sendPacketToAuthorizedPK(packet.getSrc(), packet); + this.clientManager.sendPacketToAuthorizedPK(packet.getDst(), packet); + return; + } + /** + * TODO: Проверка на существование получателя + */ + this.clientManager.sendPacketToAuthorizedPK(packet.getDst(), packet); + /** + * TODO: Высокоприоритетный пуш для сигналов звонков, чтобы мобильные устройства могли показать + * интерфейс входящего звонка, даже если приложение находится в фоне + */ + } + +} -- 2.49.1 From 312cc5df0fa5360455d64029d70b875dd6ad8353 Mon Sep 17 00:00:00 2001 From: RoyceDa Date: Sat, 14 Mar 2026 22:57:59 +0200 Subject: [PATCH 07/19] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81?= =?UTF-8?q?=D0=BE=D0=B2=20ICE=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=20(TURN)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 8 ++- .../executors/Executor28IceServers.java | 39 +++++++++++++ .../im/rosetta/packet/Packet28IceServers.java | 56 +++++++++++++++++++ 3 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 src/main/java/im/rosetta/executors/Executor28IceServers.java create mode 100644 src/main/java/im/rosetta/packet/Packet28IceServers.java diff --git a/.env b/.env index c21b809..251f355 100644 --- a/.env +++ b/.env @@ -8,9 +8,9 @@ PORT=3000 # Порт, на котором будет работать серве # Список серверов CDN и SDU. Разделяются запятой если их несколько # Без пробелов -CDN_SERVERS=http://10.211.55.2:7789 +CDN_SERVERS=http://192.168.6.82:7789 #SDU - Server Delivery Updates -SDU_SERVERS=http://10.211.55.2:7777 +SDU_SERVERS=http://192.168.6.82:7777 #Firebase Credentials FIREBASE_CREDENTIALS_PATH=serviceAccount.json @@ -21,3 +21,7 @@ BUFFER_CLEANUP_DAYS=7 #SFU Сервера SFU_SERVERS=127.0.0.1:1001@SFU_TEST_SECRET +#TURN Сервера (должны поддерживать TCP и UDP протоколы) +# Формат: host:port@username:password через запятую если их несколько, без пробелов +TURN_SERVERS=192.168.6.82:3478@user:pass + diff --git a/src/main/java/im/rosetta/executors/Executor28IceServers.java b/src/main/java/im/rosetta/executors/Executor28IceServers.java new file mode 100644 index 0000000..e9007b7 --- /dev/null +++ b/src/main/java/im/rosetta/executors/Executor28IceServers.java @@ -0,0 +1,39 @@ +package im.rosetta.executors; + +import java.util.ArrayList; + +import im.rosetta.Failures; +import im.rosetta.client.tags.ECIAuthentificate; +import im.rosetta.packet.Packet28IceServers; +import im.rosetta.service.services.ForwardUnitService; +import io.orprotocol.ProtocolException; +import io.orprotocol.client.Client; +import io.orprotocol.packet.PacketExecutor; + +public class Executor28IceServers extends PacketExecutor { + + private ForwardUnitService fus; + + public Executor28IceServers(ForwardUnitService fus) { + this.fus = fus; + } + + @Override + public void onPacketReceived(Packet28IceServers packet, Client client) throws Exception, ProtocolException { + ECIAuthentificate eciAuthentificate = client.getTag(ECIAuthentificate.class); + if(eciAuthentificate == null || !eciAuthentificate.hasAuthorized()) { + /** + * Если клиент не авторизован, то мы не будем обрабатывать его запрос на получение ICE серверов + * и просто отключим его от сервера. + */ + client.disconnect(Failures.HANDSHAKE_NOT_COMPLETED); + return; + } + /** + * Берем TURN сервера и отправляем их клиенту + */ + packet.setIceServers(new ArrayList<>(this.fus.getTurnServers())); + client.send(packet); + } + +} diff --git a/src/main/java/im/rosetta/packet/Packet28IceServers.java b/src/main/java/im/rosetta/packet/Packet28IceServers.java new file mode 100644 index 0000000..7562e6a --- /dev/null +++ b/src/main/java/im/rosetta/packet/Packet28IceServers.java @@ -0,0 +1,56 @@ +package im.rosetta.packet; + +import java.util.ArrayList; +import java.util.List; + +import io.g365sfu.webrtc.RTCIceServer; +import io.orprotocol.Stream; +import io.orprotocol.packet.Packet; + +public class Packet28IceServers extends Packet { + + private List iceServers; + + @Override + public void read(Stream stream) { + int count = stream.readInt16(); + this.iceServers = new ArrayList<>(); + for (int i = 0; i < count; i++) { + String url = stream.readString(); + String username = stream.readString(); + String credential = stream.readString(); + RTCIceServer iceServer = new RTCIceServer(url, username, credential); + iceServers.add(iceServer); + } + } + + @Override + public Stream write() { + Stream stream = new Stream(); + stream.writeInt16(this.packetId); + stream.writeInt16(iceServers.size()); + for (RTCIceServer iceServer : iceServers) { + stream.writeString(iceServer.getUrl()); + stream.writeString(iceServer.getUsername()); + stream.writeString(iceServer.getCredential()); + } + return stream; + } + + /** + * Получить список серверов ICE, которые могут быть использованы для обмена кандидатами между участниками звонка через сервер SFU. + * @return список серверов ICE, которые могут быть использованы для обмена кандидатами между участниками звонка через сервер SFU. + */ + public List getIceServers() { + return iceServers; + } + + /** + * Установить список серверов ICE, которые могут быть использованы для обмена кандидатами между участниками звонка через сервер SFU. + * @param iceServers список серверов ICE, которые могут быть использованы для обмена кандидатами между участниками звонка через сервер SFU. + */ + public void setIceServers(List iceServers) { + this.iceServers = iceServers; + } + +} -- 2.49.1 From 145adf3ec7135498e1194090936dda6e26a328d5 Mon Sep 17 00:00:00 2001 From: RoyceDa Date: Sat, 14 Mar 2026 22:58:27 +0200 Subject: [PATCH 08/19] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D0=B0=D1=8F=20=D0=BE=D1=80=D0=B3=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...t26Signal.java => Packet26SignalPeer.java} | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) rename src/main/java/im/rosetta/packet/{Packet26Signal.java => Packet26SignalPeer.java} (77%) diff --git a/src/main/java/im/rosetta/packet/Packet26Signal.java b/src/main/java/im/rosetta/packet/Packet26SignalPeer.java similarity index 77% rename from src/main/java/im/rosetta/packet/Packet26Signal.java rename to src/main/java/im/rosetta/packet/Packet26SignalPeer.java index 03f2674..b72f672 100644 --- a/src/main/java/im/rosetta/packet/Packet26Signal.java +++ b/src/main/java/im/rosetta/packet/Packet26SignalPeer.java @@ -8,7 +8,7 @@ import io.orprotocol.packet.Packet; * Пакет cигналинга для совершения звонка. Учавствует в обмене ключами, * иницилизации звонка. */ -public class Packet26Signal extends Packet { +public class Packet26SignalPeer extends Packet { /** * Идентификатор отправителя сигнала, обычно это PK пользователя, который отправляет пакет @@ -29,6 +29,11 @@ public class Packet26Signal extends Packet { * Тип сигнала */ private NetworkSignalType signalType; + + /** + * Идентификатор комнаты, в которой происходит звонок, заполняется если тип сигнала CREATE_ROOM, иначе null + */ + private String roomId; @Override public void read(Stream stream) { @@ -37,8 +42,9 @@ public class Packet26Signal extends Packet { this.dst = stream.readString(); if (signalType == NetworkSignalType.KEY_EXCHANGE) { this.sharedPublic = stream.readString(); - } else { - this.sharedPublic = null; + } + if(signalType == NetworkSignalType.CREATE_ROOM) { + this.roomId = stream.readString(); } } @@ -52,6 +58,9 @@ public class Packet26Signal extends Packet { if (signalType == NetworkSignalType.KEY_EXCHANGE) { stream.writeString(this.sharedPublic); } + if(signalType == NetworkSignalType.CREATE_ROOM) { + stream.writeString(this.roomId); + } return stream; } @@ -119,4 +128,20 @@ public class Packet26Signal extends Packet { public void setSignalType(NetworkSignalType signalType) { this.signalType = signalType; } + + /** + * Получить идентификатор созданной комнаты, если тип сигнала CREATE_ROOM + * @return идентификатор комнаты, если тип сигнала CREATE_ROOM, иначе null + */ + public String getRoomId() { + return roomId; + } + + /** + * Установить идентификатор комнаты, в которой происходит звонок, если тип сигнала CREATE_ROOM + * @param roomId идентификатор комнаты, если тип сигнала CREATE_ROOM + */ + public void setRoomId(String roomId) { + this.roomId = roomId; + } } -- 2.49.1 From 567fd9fb4c1dc0a6f083b1663c1c74aaef681939 Mon Sep 17 00:00:00 2001 From: RoyceDa Date: Sat, 14 Mar 2026 22:58:40 +0200 Subject: [PATCH 09/19] =?UTF-8?q?=D0=A1=D0=B8=D0=B3=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=20WebRTC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/im/rosetta/Boot.java | 15 ++-- .../rosetta/executors/Executor27WebRTC.java | 73 +++++++++++++++++++ .../im/rosetta/packet/Packet27WebRTC.java | 65 +++++++++++++++++ 3 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 src/main/java/im/rosetta/executors/Executor27WebRTC.java create mode 100644 src/main/java/im/rosetta/packet/Packet27WebRTC.java diff --git a/src/main/java/im/rosetta/Boot.java b/src/main/java/im/rosetta/Boot.java index eef6f93..a5f411e 100644 --- a/src/main/java/im/rosetta/Boot.java +++ b/src/main/java/im/rosetta/Boot.java @@ -17,7 +17,8 @@ import im.rosetta.executors.Executor21GroupLeave; import im.rosetta.executors.Executor22GroupBan; import im.rosetta.executors.Executor24DeviceResolve; import im.rosetta.executors.Executor25Sync; -import im.rosetta.executors.Executor26Signal; +import im.rosetta.executors.Executor26SignalPeer; +import im.rosetta.executors.Executor27WebRTC; import im.rosetta.executors.Executor3Search; import im.rosetta.executors.Executor4OnlineState; import im.rosetta.executors.Executor6Message; @@ -45,7 +46,8 @@ import im.rosetta.packet.Packet22GroupBan; import im.rosetta.packet.Packet23DeviceList; import im.rosetta.packet.Packet24DeviceResolve; import im.rosetta.packet.Packet25Sync; -import im.rosetta.packet.Packet26Signal; +import im.rosetta.packet.Packet26SignalPeer; +import im.rosetta.packet.Packet27WebRTC; import im.rosetta.packet.Packet2Result; import im.rosetta.packet.Packet3Search; import im.rosetta.packet.Packet4OnlineSubscribe; @@ -56,7 +58,6 @@ import im.rosetta.packet.Packet8Delivery; import im.rosetta.packet.Packet9DeviceNew; import im.rosetta.service.services.BufferCleanupService; import im.rosetta.service.services.ForwardUnitService; -import io.g365sfu.SFU; import io.orprotocol.Server; import io.orprotocol.Settings; import io.orprotocol.packet.PacketManager; @@ -110,7 +111,7 @@ public class Boot { int cleanupEveryDays = System.getenv("BUFFER_CLEANUP_DAYS") != null ? Integer.parseInt(System.getenv("BUFFER_CLEANUP_DAYS")) : 7; this.bufferCleanupService = new BufferCleanupService(cleanupEveryDays, this.logger); - this.forwardUnitService = new ForwardUnitService(this.logger); + this.forwardUnitService = new ForwardUnitService(this.logger, this.clientManager); } /** @@ -201,7 +202,8 @@ public class Boot { this.packetManager.registerPacket(23, Packet23DeviceList.class); this.packetManager.registerPacket(24, Packet24DeviceResolve.class); this.packetManager.registerPacket(25, Packet25Sync.class); - this.packetManager.registerPacket(26, Packet26Signal.class); + this.packetManager.registerPacket(26, Packet26SignalPeer.class); + this.packetManager.registerPacket(27, Packet27WebRTC.class); } private void registerAllExecutors() { @@ -223,7 +225,8 @@ public class Boot { this.packetManager.registerExecutor(22, new Executor22GroupBan()); this.packetManager.registerExecutor(24, new Executor24DeviceResolve(this.clientManager, this.eventManager, this.packetManager)); this.packetManager.registerExecutor(25, new Executor25Sync(this.packetManager)); - this.packetManager.registerExecutor(26, new Executor26Signal(this.clientManager)); + this.packetManager.registerExecutor(26, new Executor26SignalPeer(this.clientManager, this.forwardUnitService)); + this.packetManager.registerExecutor(27, new Executor27WebRTC(this.forwardUnitService)); } private void printBootMessage() { diff --git a/src/main/java/im/rosetta/executors/Executor27WebRTC.java b/src/main/java/im/rosetta/executors/Executor27WebRTC.java new file mode 100644 index 0000000..4d9aa19 --- /dev/null +++ b/src/main/java/im/rosetta/executors/Executor27WebRTC.java @@ -0,0 +1,73 @@ +package im.rosetta.executors; + +import im.rosetta.Failures; +import im.rosetta.client.tags.ECIAuthentificate; +import im.rosetta.packet.Packet27WebRTC; +import im.rosetta.packet.runtime.NetworkWebRTCType; +import im.rosetta.service.services.ForwardUnitService; +import io.g365sfu.Room; +import io.orprotocol.ProtocolException; +import io.orprotocol.client.Client; +import io.orprotocol.packet.PacketExecutor; + +public class Executor27WebRTC extends PacketExecutor { + + private ForwardUnitService fus; + + public Executor27WebRTC(ForwardUnitService fus) { + this.fus = fus; + } + + @Override + public void onPacketReceived(Packet27WebRTC packet, Client client) throws Exception, ProtocolException { + ECIAuthentificate eciAuthentificate = client.getTag(ECIAuthentificate.class); + if(eciAuthentificate == null || !eciAuthentificate.hasAuthorized()) { + /** + * Если клиент не авторизован, то мы не будем обрабатывать его сигналы на инициализацию звонка + * и просто отключим его от сервера. + */ + client.disconnect(Failures.HANDSHAKE_NOT_COMPLETED); + return; + } + + String publicKey = eciAuthentificate.getPublicKey(); + + /** + * Так как в комнатах Participants это публичные ключи пользователей, то мы можем + * найти комнату, в которой находится пользователь, по его публичному ключу + */ + Room room = this.fus.getRoomByParticipantId(publicKey); + if(room == null) { + /** + * Если комната не найдена, то мы не будем обрабатывать сигналы для звонка + * и просто отключим клиента от сервера. + */ + client.disconnect(Failures.DATA_MISSMATCH); + return; + } + + NetworkWebRTCType type = packet.getType(); + if(type == NetworkWebRTCType.OFFER) { + /** + * Если это OFFER, то отправляем OFFER на сервер SFU, + * который отвечает за эту комнату, чтобы он транслировал его всем участникам комнаты, кроме отправителя + */ + room.sdpOffer(publicKey, packet.getSdpOrCandidate()); + } + if(type == NetworkWebRTCType.ICE_CANDIDATE) { + /** + * Если это ICE кандидат, то отправляем его на сервер SFU, + * который отвечает за эту комнату, чтобы он транслировал его всем участникам комнаты, кроме отправителя + */ + room.iceCandidate(publicKey, packet.getSdpOrCandidate()); + } + if(type == NetworkWebRTCType.ANSWER) { + /** + * Если это ANSWER, то отправляем его на сервер SFU, + * который отвечает за эту комнату, чтобы он транслировал его всем участникам комнаты, кроме отправителя + */ + room.sdpAnswer(publicKey, packet.getSdpOrCandidate()); + } + } + +} diff --git a/src/main/java/im/rosetta/packet/Packet27WebRTC.java b/src/main/java/im/rosetta/packet/Packet27WebRTC.java new file mode 100644 index 0000000..611c9a2 --- /dev/null +++ b/src/main/java/im/rosetta/packet/Packet27WebRTC.java @@ -0,0 +1,65 @@ +package im.rosetta.packet; + +import im.rosetta.packet.runtime.NetworkWebRTCType; +import io.orprotocol.Stream; +import io.orprotocol.packet.Packet; + +public class Packet27WebRTC extends Packet { + /** + * SDP оффер/answer или ICE кандидат, в зависимости от типа сообщения + */ + private String sdpOrCandidate; + /** + * Тип сообщения WebRTC + */ + private NetworkWebRTCType type; + + @Override + public void read(Stream stream) { + this.type = NetworkWebRTCType.fromCode(stream.readInt8()); + this.sdpOrCandidate = stream.readString(); + } + + @Override + public Stream write() { + Stream steram = new Stream(); + steram.writeInt16(this.packetId); + steram.writeInt8(this.type.getCode()); + steram.writeString(this.sdpOrCandidate); + return steram; + } + + /** + * Получить SDP оффер/answer или ICE кандидат, в зависимости от типа сообщения + * @return SDP оффер/answer или ICE кандидат + */ + public String getSdpOrCandidate() { + return sdpOrCandidate; + } + + /** + * Получить тип сообщения WebRTC, который указывает на то, является ли это оффером, ответом на оффер или ICE кандидатом + * @return тип сообщения WebRTC + */ + public NetworkWebRTCType getType() { + return type; + } + + /** + * Установить SDP оффер/answer или ICE кандидат, в зависимости от типа сообщения + * @param sdpOrCandidate SDP оффер/answer или ICE кандидат + */ + public void setSdpOrCandidate(String sdpOrCandidate) { + this.sdpOrCandidate = sdpOrCandidate; + } + + /** + * Установить тип сообщения WebRTC, который указывает на то, является ли это оффером, ответом на оффер или ICE кандидатом + * @param type тип сообщения WebRTC + */ + public void setType(NetworkWebRTCType type) { + this.type = type; + } + + +} -- 2.49.1 From 08e9ce98371583390d23df5d96d7b7ec6f7b17da Mon Sep 17 00:00:00 2001 From: RoyceDa Date: Sat, 14 Mar 2026 22:59:32 +0200 Subject: [PATCH 10/19] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=2028=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82?= =?UTF-8?q?=D0=B0=20=D0=B2=20=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D0=BA=D0=BE?= =?UTF-8?q?=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/im/rosetta/Boot.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/im/rosetta/Boot.java b/src/main/java/im/rosetta/Boot.java index a5f411e..7be744e 100644 --- a/src/main/java/im/rosetta/Boot.java +++ b/src/main/java/im/rosetta/Boot.java @@ -19,6 +19,7 @@ import im.rosetta.executors.Executor24DeviceResolve; import im.rosetta.executors.Executor25Sync; import im.rosetta.executors.Executor26SignalPeer; import im.rosetta.executors.Executor27WebRTC; +import im.rosetta.executors.Executor28IceServers; import im.rosetta.executors.Executor3Search; import im.rosetta.executors.Executor4OnlineState; import im.rosetta.executors.Executor6Message; @@ -48,6 +49,7 @@ import im.rosetta.packet.Packet24DeviceResolve; import im.rosetta.packet.Packet25Sync; import im.rosetta.packet.Packet26SignalPeer; import im.rosetta.packet.Packet27WebRTC; +import im.rosetta.packet.Packet28IceServers; import im.rosetta.packet.Packet2Result; import im.rosetta.packet.Packet3Search; import im.rosetta.packet.Packet4OnlineSubscribe; @@ -204,6 +206,7 @@ public class Boot { this.packetManager.registerPacket(25, Packet25Sync.class); this.packetManager.registerPacket(26, Packet26SignalPeer.class); this.packetManager.registerPacket(27, Packet27WebRTC.class); + this.packetManager.registerPacket(28, Packet28IceServers.class); } private void registerAllExecutors() { @@ -227,6 +230,7 @@ public class Boot { this.packetManager.registerExecutor(25, new Executor25Sync(this.packetManager)); this.packetManager.registerExecutor(26, new Executor26SignalPeer(this.clientManager, this.forwardUnitService)); this.packetManager.registerExecutor(27, new Executor27WebRTC(this.forwardUnitService)); + this.packetManager.registerExecutor(28, new Executor28IceServers(this.forwardUnitService)); } private void printBootMessage() { -- 2.49.1 From 4709665dd15bfea45bcfe716a8da166564623872 Mon Sep 17 00:00:00 2001 From: RoyceDa Date: Sun, 15 Mar 2026 18:11:16 +0200 Subject: [PATCH 11/19] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=83=D1=82=D0=B5=D1=87=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=B0=D0=BC=D1=8F=D1=82=D0=B8=20=D0=B2=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82=D0=B5=20=D1=81?= =?UTF-8?q?=D0=BB=D0=B8=D1=88=D0=BA=D0=BE=D0=BC=20=D1=87=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D1=8B=D1=85=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=20=D1=81=D0=BE=D0=B5=D0=B4=D0=B8=D0=BD=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/io/orprotocol/Server.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/io/orprotocol/Server.java b/src/main/java/io/orprotocol/Server.java index fbf29e4..dfa705a 100644 --- a/src/main/java/io/orprotocol/Server.java +++ b/src/main/java/io/orprotocol/Server.java @@ -237,7 +237,7 @@ public class Server extends WebSocketServer { client.disconnect(ServerFailures.INACTIVITY_TIMEOUT); } } - }, this.settings.heartbeatInterval, this.settings.heartbeatInterval, TimeUnit.MILLISECONDS); + }, this.settings.heartbeatInterval, this.settings.heartbeatInterval, TimeUnit.SECONDS); } /** -- 2.49.1 From ce404188d4e3f153b77a07f17a5a9548ec0f1abc Mon Sep 17 00:00:00 2001 From: RoyceDa Date: Sun, 15 Mar 2026 18:57:03 +0200 Subject: [PATCH 12/19] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B6=D0=B8=D0=B7=D0=BD=D0=B5=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D0=BE=D0=B1=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20SFU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 6 +-- .../service/services/ForwardUnitService.java | 37 ++++++++++++- src/main/java/io/g365sfu/SFU.java | 8 +-- src/main/java/io/g365sfu/net/SfuSock.java | 52 ++++++++++++++++--- 4 files changed, 88 insertions(+), 15 deletions(-) diff --git a/.env b/.env index 251f355..db4a9c6 100644 --- a/.env +++ b/.env @@ -8,9 +8,9 @@ PORT=3000 # Порт, на котором будет работать серве # Список серверов CDN и SDU. Разделяются запятой если их несколько # Без пробелов -CDN_SERVERS=http://192.168.6.82:7789 +CDN_SERVERS=http://10.211.55.2:7789 #SDU - Server Delivery Updates -SDU_SERVERS=http://192.168.6.82:7777 +SDU_SERVERS=http://10.211.55.2:7777 #Firebase Credentials FIREBASE_CREDENTIALS_PATH=serviceAccount.json @@ -23,5 +23,5 @@ SFU_SERVERS=127.0.0.1:1001@SFU_TEST_SECRET #TURN Сервера (должны поддерживать TCP и UDP протоколы) # Формат: host:port@username:password через запятую если их несколько, без пробелов -TURN_SERVERS=192.168.6.82:3478@user:pass +TURN_SERVERS=10.211.55.2:3478@user:pass diff --git a/src/main/java/im/rosetta/service/services/ForwardUnitService.java b/src/main/java/im/rosetta/service/services/ForwardUnitService.java index fa49f3b..d3b349a 100644 --- a/src/main/java/im/rosetta/service/services/ForwardUnitService.java +++ b/src/main/java/im/rosetta/service/services/ForwardUnitService.java @@ -2,6 +2,10 @@ package im.rosetta.service.services; import java.util.HashSet; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import im.rosetta.client.ClientManager; import im.rosetta.logger.Logger; @@ -22,14 +26,43 @@ import io.orprotocol.ProtocolException; public class ForwardUnitService { private Logger logger; - private Set sfuConnections = new HashSet<>(); + private Set sfuConnections = ConcurrentHashMap.newKeySet(); private ClientManager clientManager; private Set turnServers = new HashSet<>(); + private ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); public ForwardUnitService(Logger logger, ClientManager clientManager) { this.logger = logger; this.clientManager = clientManager; this.readAllTurnServers(); + this.sfuConnectionsSheduler(); + } + + /** + * Каждые 10 секунд проверяет доступность всех SFU серверов, и если сервер недоступен, то удаляет его из + * пула доступных серверов для организации звонков. + * Проверка доступности сервера осуществляется через отправку специального сообщения проверки соединения, + * и ожидание ответа от сервера. Если ответ не приходит в течение 10 секунд, то сервер считается недоступным. + * + * Так же, если sfuConnections пустой, то мы стараемся установить соединение со всеми серверами еще раз. + */ + private void sfuConnectionsSheduler() { + this.scheduler.scheduleAtFixedRate(() -> { + for(SFU sfu : this.sfuConnections) { + try{ + if(!sfu.getConnection().checkConnection()){ + this.logger.error("Server " + sfu.getServerAddress() + " not responding"); + this.sfuConnections.remove(sfu); + } + }catch(Exception e) { + this.logger.error("Failed to check connection to SFU server: " + sfu.getServerAddress() + ", error: " + e.getMessage()); + this.sfuConnections.remove(sfu); + } + } + if(this.sfuConnections.isEmpty()) { + this.connectToAllSFUServers(); + } + }, 10, 10, TimeUnit.SECONDS); } /** @@ -108,7 +141,7 @@ public class ForwardUnitService { this.sfuConnections.add(connection); this.logger.info(Color.GREEN + "Successfully connected to SFU server: " + address); } catch (Exception e) { - this.logger.error(Color.RED + "Failed to connect to SFU server: " + address + ", error: " + e.getMessage()); + //this.logger.error(Color.RED + "Failed to connect to SFU server: " + address + ", error: " + e.getMessage()); } } } diff --git a/src/main/java/io/g365sfu/SFU.java b/src/main/java/io/g365sfu/SFU.java index ce089bb..7d26d5b 100644 --- a/src/main/java/io/g365sfu/SFU.java +++ b/src/main/java/io/g365sfu/SFU.java @@ -72,7 +72,7 @@ public class SFU { this.socket.setMessageConsumer(this::onMessage); boolean connected = this.socket.connectBlocking(30, TimeUnit.SECONDS); if(!connected){ - throw new SFUException("Failed to connect to SFU server, timeout after 30 seconds: " + this.serverAddress); + throw new SFUException("Failed to connect to SFU server, read time out: " + this.serverAddress); } if(!this.socket.isOpen()) { throw new SFUException("Connection to SFU server at " + this.serverAddress + " is not open"); @@ -160,9 +160,9 @@ public class SFU { message.get(peerIdBytes); String peerId = new String(peerIdBytes).trim(); int sdpOfferLength = message.getInt(); - byte[] sdpAnswerBytes = new byte[sdpOfferLength]; - message.get(sdpAnswerBytes); - String sdpOffer = new String(sdpAnswerBytes).trim(); + byte[] sdpOfferBytes = new byte[sdpOfferLength]; + message.get(sdpOfferBytes); + String sdpOffer = new String(sdpOfferBytes).trim(); SDPOffer offer = new SDPOffer(roomId, peerId, sdpOffer); if(this.onSdpOffer != null) { this.onSdpOffer.accept(offer); diff --git a/src/main/java/io/g365sfu/net/SfuSock.java b/src/main/java/io/g365sfu/net/SfuSock.java index ac207b1..a993c4c 100644 --- a/src/main/java/io/g365sfu/net/SfuSock.java +++ b/src/main/java/io/g365sfu/net/SfuSock.java @@ -3,7 +3,9 @@ package io.g365sfu.net; import java.net.URI; import java.net.URISyntaxException; import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import org.java_websocket.client.WebSocketClient; @@ -12,6 +14,7 @@ import org.java_websocket.handshake.ServerHandshake; public class SfuSock extends WebSocketClient { private CompletableFuture handshakeFuture = new CompletableFuture<>(); + private CompletableFuture connectionFuture = new CompletableFuture<>(); private Consumer onMessage; public SfuSock(String serverAddress) throws URISyntaxException { @@ -20,24 +23,25 @@ public class SfuSock extends WebSocketClient { @Override public void onOpen(ServerHandshake handshakedata) { - System.out.println("Connected to SFU server"); + //System.out.println("Connected to SFU server"); } @Override public void onMessage(ByteBuffer bytes) { + bytes.order(ByteOrder.BIG_ENDIAN); if(bytes.remaining() < 1) { - System.err.println("Received empty message from SFU server"); return; } byte messageType = bytes.get(); - if(messageType == 0x01) { + if(messageType == (byte)0x01) { /** * Сервер ответил на рукопожатие, и мы можем считать его успешным */ this.handshakeFuture.complete(true); return; } - if(messageType == 0xFF) { + + if(messageType == (byte)0xFF) { /** * Сервер отклонил рукопожатие, и мы должны считать его неудачным */ @@ -45,6 +49,14 @@ public class SfuSock extends WebSocketClient { return; } + if(messageType == (byte)0xAE) { + /** + * Сервер отправил сообщение о том, что соединение живое (ответ на проверку соединения), и мы можем считать его успешным + */ + this.connectionFuture.complete(true); + return; + } + /** * Если это не сообщение рукопожатия, то мы передаем его в установленного потребителя */ @@ -55,7 +67,7 @@ public class SfuSock extends WebSocketClient { @Override public void onMessage(String message) { - System.err.println("Received unexpected text message from SFU server: " + message); + //System.err.println("Received unexpected text message from SFU server: " + message); } @Override @@ -70,7 +82,7 @@ public class SfuSock extends WebSocketClient { @Override public void onError(Exception ex){ - System.err.println("Error: " + ex.getMessage()); + //System.err.println("Error: " + ex.getMessage()); } /** @@ -110,4 +122,32 @@ public class SfuSock extends WebSocketClient { public void setMessageConsumer(Consumer onMessage) { this.onMessage = onMessage; } + + /** + * Проверяет состояние соединения, если соединение активно и готово к обмену данными, то возвращает false + * @return + * @throws InterruptedException + */ + public boolean checkConnection() { + ByteBuffer buffer = ByteBuffer.allocate(1); + /** + * 0x08 - код проверки соединения в соотвествии с протоколом g365sfu + */ + buffer.put((byte)0xAE); + buffer.flip(); + this.send(buffer); + try { + boolean result = this.connectionFuture.get(10, TimeUnit.SECONDS); + this.connectionFuture = new CompletableFuture<>(); + return result; + }catch(Exception e){ + /** + * Сбрасываем handshakeFuture, так как соединение не активно, + * и нам нужно будет пройти рукопожатие заново при следующей проверке соединения + */ + this.handshakeFuture = new CompletableFuture<>(); + this.connectionFuture = new CompletableFuture<>(); + return false; + } + } } -- 2.49.1 From a4346679e7d9e11bd8583f9119d7559d2d27e358 Mon Sep 17 00:00:00 2001 From: RoyceDa Date: Mon, 16 Mar 2026 17:06:25 +0200 Subject: [PATCH 13/19] =?UTF-8?q?=D0=9B=D0=BE=D0=B3=D0=B8=D1=87=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F=20=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=BE=D0=B2=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=BC=D0=B0=D0=B3=D0=B8=D1=87=D0=B5=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=D1=85=20=D1=87=D0=B8=D1=81=D0=B5=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/io/g365sfu/Room.java | 8 +++++--- src/main/java/io/g365sfu/SFU.java | 12 +++++++----- src/main/java/io/g365sfu/net/Incoming.java | 19 +++++++++++++++++++ src/main/java/io/g365sfu/net/Outgoing.java | 17 +++++++++++++++++ src/main/java/io/g365sfu/net/SfuSock.java | 10 +++++----- 5 files changed, 53 insertions(+), 13 deletions(-) create mode 100644 src/main/java/io/g365sfu/net/Incoming.java create mode 100644 src/main/java/io/g365sfu/net/Outgoing.java diff --git a/src/main/java/io/g365sfu/Room.java b/src/main/java/io/g365sfu/Room.java index e463092..9784c33 100644 --- a/src/main/java/io/g365sfu/Room.java +++ b/src/main/java/io/g365sfu/Room.java @@ -3,6 +3,8 @@ package io.g365sfu; import java.nio.ByteBuffer; import java.util.HashSet; +import io.g365sfu.net.Outgoing; + /** * Это комната для звонков, она может быть как для двоих участников, так и для групповых звонков. * Комната содержит в себе информацию о том, на каком она SFU сервере, какой у нее ID, кто в ней участвует. @@ -77,7 +79,7 @@ public class Room { /** * 0x03 - SDP offer */ - buffer.put((byte)0x03); + buffer.put(Outgoing.SDP_OFFER_RETRANSLATE); buffer.putInt(this.roomId.getBytes().length); buffer.put(this.roomId.getBytes()); buffer.putInt(participantId.getBytes().length); @@ -111,7 +113,7 @@ public class Room { /** * 0x06 - ICE кандидат */ - buffer.put((byte)0x06); + buffer.put(Outgoing.ICE_CANDIDATE_RETRANSLATE); buffer.putInt(this.roomId.getBytes().length); buffer.put(this.roomId.getBytes()); buffer.putInt(participantId.getBytes().length); @@ -145,7 +147,7 @@ public class Room { /** * 0x07 - SDP answer */ - buffer.put((byte)0x07); + buffer.put(Outgoing.SDP_ANSWER_RETRANSLATE); buffer.putInt(this.roomId.getBytes().length); buffer.put(this.roomId.getBytes()); buffer.putInt(participantId.getBytes().length); diff --git a/src/main/java/io/g365sfu/SFU.java b/src/main/java/io/g365sfu/SFU.java index 7d26d5b..6296872 100644 --- a/src/main/java/io/g365sfu/SFU.java +++ b/src/main/java/io/g365sfu/SFU.java @@ -12,6 +12,8 @@ import java.util.function.Consumer; import io.g365sfu.exception.SFUException; import io.g365sfu.exception.SFUHandshakeException; +import io.g365sfu.net.Incoming; +import io.g365sfu.net.Outgoing; import io.g365sfu.net.SfuSock; import io.g365sfu.util.StrUtils; import io.g365sfu.webrtc.ICECandidate; @@ -89,7 +91,7 @@ public class SFU { return; } byte packetId = message.get(0); - if(packetId == 0x02) { + if(packetId == Incoming.ROOM_CREATE) { /** * Ответ на создание комнаты, который содержит ID созданной комнаты */ @@ -103,7 +105,7 @@ public class SFU { } return; } - if(packetId == 0x04) { + if(packetId == Incoming.ICE_CANDIDATE) { /** * ICE-candidate от сервера SFU для одного из участников комнаты */ @@ -125,7 +127,7 @@ public class SFU { } return; } - if(packetId == 0x05) { + if(packetId == Incoming.SDP_ANSWER) { /** * Ответ на Offer от сервера SFU, который содержит SDP Answer */ @@ -147,7 +149,7 @@ public class SFU { } return; } - if(packetId == 0x08) { + if(packetId == Incoming.SDP_OFFER) { /** * Offer от сервера SFU для одного из участников комнаты при renegotiation */ @@ -217,7 +219,7 @@ public class SFU { /** * 0x02 - создание комнаты */ - buffer.put((byte)0x02); + buffer.put(Outgoing.ROOM_CREATE); buffer.putInt(roomId.getBytes().length); buffer.put(roomId.getBytes()); buffer.flip(); diff --git a/src/main/java/io/g365sfu/net/Incoming.java b/src/main/java/io/g365sfu/net/Incoming.java new file mode 100644 index 0000000..c8630ff --- /dev/null +++ b/src/main/java/io/g365sfu/net/Incoming.java @@ -0,0 +1,19 @@ +package io.g365sfu.net; + +public class Incoming { + + public static final byte HANDSHAKE_OK = (byte) 0x01; + + public static final byte HANDSHAKE_FAILURE = (byte) 0xFF; + + public static final byte CONNECTION_ALIVE = (byte) 0xAE; + + public static final byte ICE_CANDIDATE = (byte) 0x04; + + public static final byte SDP_OFFER = (byte) 0x08; + + public static final byte SDP_ANSWER = (byte) 0x05; + + public static final byte ROOM_CREATE= (byte) 0x02; + +} diff --git a/src/main/java/io/g365sfu/net/Outgoing.java b/src/main/java/io/g365sfu/net/Outgoing.java new file mode 100644 index 0000000..d23d5df --- /dev/null +++ b/src/main/java/io/g365sfu/net/Outgoing.java @@ -0,0 +1,17 @@ +package io.g365sfu.net; + +public class Outgoing { + + public static final byte HANDSHAKE_EXCHANGE = (byte) 0x01; + + public static final byte CONNECTION_ALIVE = (byte) 0xAE; + + public static final byte ICE_CANDIDATE_RETRANSLATE = (byte) 0x06; + + public static final byte SDP_ANSWER_RETRANSLATE = (byte) 0x07; + + public static final byte SDP_OFFER_RETRANSLATE = (byte) 0x03; + + public static final byte ROOM_CREATE= (byte) 0x02; + +} diff --git a/src/main/java/io/g365sfu/net/SfuSock.java b/src/main/java/io/g365sfu/net/SfuSock.java index a993c4c..e396057 100644 --- a/src/main/java/io/g365sfu/net/SfuSock.java +++ b/src/main/java/io/g365sfu/net/SfuSock.java @@ -33,7 +33,7 @@ public class SfuSock extends WebSocketClient { return; } byte messageType = bytes.get(); - if(messageType == (byte)0x01) { + if(messageType == Incoming.HANDSHAKE_OK) { /** * Сервер ответил на рукопожатие, и мы можем считать его успешным */ @@ -41,7 +41,7 @@ public class SfuSock extends WebSocketClient { return; } - if(messageType == (byte)0xFF) { + if(messageType == Incoming.HANDSHAKE_FAILURE) { /** * Сервер отклонил рукопожатие, и мы должны считать его неудачным */ @@ -49,7 +49,7 @@ public class SfuSock extends WebSocketClient { return; } - if(messageType == (byte)0xAE) { + if(messageType == Incoming.CONNECTION_ALIVE) { /** * Сервер отправил сообщение о том, что соединение живое (ответ на проверку соединения), и мы можем считать его успешным */ @@ -103,7 +103,7 @@ public class SfuSock extends WebSocketClient { /** * 0x01 - код рукопожатия в соотвествии с протоколом g365sfu, за которым следует секретный ключ в виде строки байтов */ - buffer.put((byte)0x01); + buffer.put(Outgoing.HANDSHAKE_EXCHANGE); buffer.putInt(secretKey.getBytes().length); buffer.put(secretKey.getBytes()); buffer.flip(); @@ -133,7 +133,7 @@ public class SfuSock extends WebSocketClient { /** * 0x08 - код проверки соединения в соотвествии с протоколом g365sfu */ - buffer.put((byte)0xAE); + buffer.put(Outgoing.CONNECTION_ALIVE); buffer.flip(); this.send(buffer); try { -- 2.49.1 From 6071e4ddbda71838a0e8d8d66ac01661bc1aeee8 Mon Sep 17 00:00:00 2001 From: RoyceDa Date: Mon, 16 Mar 2026 18:29:01 +0200 Subject: [PATCH 14/19] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8?= =?UTF-8?q?=D0=B9=20ROOM=5FDELETE=20=D0=B8=20PEER=5FDISCONNECTED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/io/g365sfu/SFU.java | 54 +++++++++++++++++++ .../java/io/g365sfu/net/DisconnectedPeer.java | 28 ++++++++++ src/main/java/io/g365sfu/net/Incoming.java | 4 ++ 3 files changed, 86 insertions(+) create mode 100644 src/main/java/io/g365sfu/net/DisconnectedPeer.java diff --git a/src/main/java/io/g365sfu/SFU.java b/src/main/java/io/g365sfu/SFU.java index 6296872..a058deb 100644 --- a/src/main/java/io/g365sfu/SFU.java +++ b/src/main/java/io/g365sfu/SFU.java @@ -12,6 +12,7 @@ import java.util.function.Consumer; import io.g365sfu.exception.SFUException; import io.g365sfu.exception.SFUHandshakeException; +import io.g365sfu.net.DisconnectedPeer; import io.g365sfu.net.Incoming; import io.g365sfu.net.Outgoing; import io.g365sfu.net.SfuSock; @@ -49,6 +50,18 @@ public class SFU { private Consumer onSdpOffer; + /** + * Потребитель для обработки сообщений от сервера SFU о том, что комната была удалена. + * Передает ID удаленной комнаты, чтобы клиент мог удалить ее из своего списка комнат и прекратить попытки взаимодействия с ней. + */ + private Consumer onDeleteRoom; + + /** + * Потребитель для обработки сообщений от сервера SFU о том, что участник отключился от комнаты. + * Передает объект DisconnectedPeer, который содержит информацию об отключившемся участнике и комнате, от которой он отключился + */ + private Consumer onPeerDisconnected; + /** * Конструктор для создания объекта SFU, который будет использоваться для установления соединения с SFU сервером. * @param serverAddress адрес SFU сервера в формате "host:port", например "sfu.example.com:8080" @@ -171,6 +184,37 @@ public class SFU { } return; } + if(packetId == Incoming.ROOM_DELETE) { + int roomIdLength = message.getInt(); + byte[] roomIdBytes = new byte[roomIdLength]; + message.get(roomIdBytes); + String roomId = new String(roomIdBytes).trim(); + this.rooms.remove(roomId); + if(this.onDeleteRoom != null) { + this.onDeleteRoom.accept(roomId); + } + } + if(packetId == Incoming.PEER_DISCONNECTED) { + int roomIdLength = message.getInt(); + byte[] roomIdBytes = new byte[roomIdLength]; + message.get(roomIdBytes); + String roomId = new String(roomIdBytes).trim(); + int peerIdLength = message.getInt(); + byte[] peerIdBytes = new byte[peerIdLength]; + message.get(peerIdBytes); + String peerId = new String(peerIdBytes).trim(); + Room room = this.rooms.get(roomId); + DisconnectedPeer disconnectedPeer = new DisconnectedPeer(peerId, roomId, room); + if(room != null) { + /** + * Если такая комната существует то удаляем оттуда участника + */ + room.removeParticipant(peerId); + } + if(this.onPeerDisconnected != null) { + this.onPeerDisconnected.accept(disconnectedPeer); + } + } } /** @@ -301,4 +345,14 @@ public class SFU { public void setOfferConsumer(Consumer onSdpOffer) { this.onSdpOffer = onSdpOffer; } + + /** + * Устанавливает потребителя для обработки сообщений от сервера SFU о том, что комната была удалена. + * @param onDeleteRoom потребитель, который будет вызываться при получении сообщения от сервера SFU с кодом 0x10, + * содержащим информацию о том, что комната была удалена. Параметром будет строка, содержащая ID удаленной комнаты, + * чтобы клиент мог удалить ее из своего списка комнат и прекратить попытки взаимодействия с ней. + */ + public void setDeleteRoomConsumer(Consumer onDeleteRoom) { + this.onDeleteRoom = onDeleteRoom; + } } diff --git a/src/main/java/io/g365sfu/net/DisconnectedPeer.java b/src/main/java/io/g365sfu/net/DisconnectedPeer.java new file mode 100644 index 0000000..d74e2fd --- /dev/null +++ b/src/main/java/io/g365sfu/net/DisconnectedPeer.java @@ -0,0 +1,28 @@ +package io.g365sfu.net; + +import io.g365sfu.Room; + +public class DisconnectedPeer { + + private String peerId; + private Room room; + private String roomId; + + public DisconnectedPeer(String peerId, String roomId, Room room) { + this.peerId = peerId; + this.room = room; + this.roomId = roomId; + } + + public String getPeerId() { + return peerId; + } + + public Room getRoom() { + return room; + } + + public String getRoomId() { + return roomId; + } +} diff --git a/src/main/java/io/g365sfu/net/Incoming.java b/src/main/java/io/g365sfu/net/Incoming.java index c8630ff..3ab8367 100644 --- a/src/main/java/io/g365sfu/net/Incoming.java +++ b/src/main/java/io/g365sfu/net/Incoming.java @@ -16,4 +16,8 @@ public class Incoming { public static final byte ROOM_CREATE= (byte) 0x02; + public static final byte ROOM_DELETE = (byte) 0x10; + + public static final byte PEER_DISCONNECTED = (byte) 0x11; + } -- 2.49.1 From 7a8692f3c602bcbce7c6e710499d83267be1687e Mon Sep 17 00:00:00 2001 From: RoyceDa Date: Mon, 16 Mar 2026 19:26:21 +0200 Subject: [PATCH 15/19] =?UTF-8?q?=D0=9D=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D0=BA=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=8B=D0=BB?= =?UTF-8?q?=D0=BA=D0=B8=20SDP=20=D0=BE=D1=84=D1=84=D0=B5=D1=80=D0=B0=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20Renegotiation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../im/rosetta/service/services/ForwardUnitService.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/im/rosetta/service/services/ForwardUnitService.java b/src/main/java/im/rosetta/service/services/ForwardUnitService.java index d3b349a..afa52f1 100644 --- a/src/main/java/im/rosetta/service/services/ForwardUnitService.java +++ b/src/main/java/im/rosetta/service/services/ForwardUnitService.java @@ -138,6 +138,14 @@ public class ForwardUnitService { e.printStackTrace(); } }); + connection.setOfferConsumer(arg0 -> { + try{ + onSdpOffer(arg0); + }catch(ProtocolException e){ + this.logger.error(Color.RED + "Failed to retranslate SDP offer from SFU server: " + address + ", error: " + e.getMessage()); + e.printStackTrace(); + } + }); this.sfuConnections.add(connection); this.logger.info(Color.GREEN + "Successfully connected to SFU server: " + address); } catch (Exception e) { -- 2.49.1 From ddcb54821f2e1625e8f99b46ec3b400d0310709a Mon Sep 17 00:00:00 2001 From: RoyceDa Date: Mon, 16 Mar 2026 19:36:53 +0200 Subject: [PATCH 16/19] =?UTF-8?q?=D0=94=D0=BE=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/io/g365sfu/net/Incoming.java | 34 +++++++++++++++++++++- src/main/java/io/g365sfu/net/Outgoing.java | 32 +++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/g365sfu/net/Incoming.java b/src/main/java/io/g365sfu/net/Incoming.java index 3ab8367..ffeac84 100644 --- a/src/main/java/io/g365sfu/net/Incoming.java +++ b/src/main/java/io/g365sfu/net/Incoming.java @@ -1,23 +1,55 @@ package io.g365sfu.net; +/** + * Входящие пакеты от SFU сервера, могут быть инициированы запросом клиента, или отправляться SFU при + * некоторых событиях + */ public class Incoming { + /** + * Означает, что сервер ответил на рукопожатие, и мы можем считать его успешным + */ public static final byte HANDSHAKE_OK = (byte) 0x01; + /** + * Сервер ответил на рукопожатие, но по какой-то из причин, отклонил его, например, неправильный + * секретный ключ + */ public static final byte HANDSHAKE_FAILURE = (byte) 0xFF; + /** + * Ответ от сервера о том, что соединение живое (ответ на проверку соединения), и мы можем считать его успешным + */ public static final byte CONNECTION_ALIVE = (byte) 0xAE; + /** + * ICE-Кандидат с сервера SFU, который нужно переслать целевому участнику + */ public static final byte ICE_CANDIDATE = (byte) 0x04; + /** + * SDP offer от сервера SFU, который нужно переслать целевому участнику + */ public static final byte SDP_OFFER = (byte) 0x08; + /** + * SDP Answer от SFU в ответ на отправленный целевым участником SDP Offer, который нужно переслать целевому участнику + */ public static final byte SDP_ANSWER = (byte) 0x05; - public static final byte ROOM_CREATE= (byte) 0x02; + /** + * Сообщение от сервера SFU о том, тто комната успешно создана + */ + public static final byte ROOM_CREATE = (byte) 0x02; + /** + * Сообщение от сервера SFU о том, что комната была удалена + */ public static final byte ROOM_DELETE = (byte) 0x10; + /** + * Сообщение об отсоединении участника от сервера SFU, например при обрыве связи + */ public static final byte PEER_DISCONNECTED = (byte) 0x11; } diff --git a/src/main/java/io/g365sfu/net/Outgoing.java b/src/main/java/io/g365sfu/net/Outgoing.java index d23d5df..9fba6fa 100644 --- a/src/main/java/io/g365sfu/net/Outgoing.java +++ b/src/main/java/io/g365sfu/net/Outgoing.java @@ -1,17 +1,47 @@ package io.g365sfu.net; +/** + * Исходящие пакеты к SFU серверу, могут быть отправлены бекендом как в ответ на входящие сообщения от сервера, + * так и по инициативе бекенда + */ public class Outgoing { + /** + * Рукопожатие с сервером SFU, которое необходимо выполнить перед любыми другими операциями. + * Этот пакет используется для установления начального соединения с сервером SFU, и + * должен быть отправлен первым при подключении к серверу. Он может содержать информацию, + * необходимую для аутентификации (секретный ключ) + */ public static final byte HANDSHAKE_EXCHANGE = (byte) 0x01; + /** + * Проверка соединения с сервером SFU, которая может быть отправлена бекендом по инициативе бекенда для проверки, + * что соединение с сервером все еще активно. + */ public static final byte CONNECTION_ALIVE = (byte) 0xAE; + /** + * Ретрансляция ICE-кандидата от одного участника на сервер SFU, чтобы участник мог установить + * соединение с SFU + */ public static final byte ICE_CANDIDATE_RETRANSLATE = (byte) 0x06; + /** + * Ретрансляция SDP answer от одного участника на сервер SFU, чтобы участник мог установить + * соединение с SFU + */ public static final byte SDP_ANSWER_RETRANSLATE = (byte) 0x07; + /** + * Ретрансляция SDP offer от одного участника на сервер SFU, чтобы участник мог установить + * соединение с SFU + */ public static final byte SDP_OFFER_RETRANSLATE = (byte) 0x03; - public static final byte ROOM_CREATE= (byte) 0x02; + /** + * Вызывается когда бекенд хочет создать комнату на сервере SFU, и сообщает об этом серверу, + * чтобы сервер создал комнату и был готов к приему участников + */ + public static final byte ROOM_CREATE = (byte) 0x02; } -- 2.49.1 From 12a3e6266bbc7a5c13bd9775247ec8fb21430aca Mon Sep 17 00:00:00 2001 From: RoyceDa Date: Tue, 17 Mar 2026 14:55:53 +0200 Subject: [PATCH 17/19] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B4=D0=B8=D0=BD=D0=B0=D0=BC=D0=B8?= =?UTF-8?q?=D1=87=D0=B5=D1=81=D0=BA=D0=BE=D0=B3=D0=BE=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=81=D0=B0=20TURN=20=D0=BF=D1=80=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../im/rosetta/packet/Packet28IceServers.java | 4 +- .../service/services/ForwardUnitService.java | 40 +++------------- src/main/java/io/g365sfu/SFU.java | 48 +++++++++++++++++++ src/main/java/io/g365sfu/net/Incoming.java | 6 +++ src/main/java/io/g365sfu/net/Outgoing.java | 6 +++ .../java/io/g365sfu/webrtc/RTCIceServer.java | 12 ++++- 6 files changed, 81 insertions(+), 35 deletions(-) diff --git a/src/main/java/im/rosetta/packet/Packet28IceServers.java b/src/main/java/im/rosetta/packet/Packet28IceServers.java index 7562e6a..ef4f26a 100644 --- a/src/main/java/im/rosetta/packet/Packet28IceServers.java +++ b/src/main/java/im/rosetta/packet/Packet28IceServers.java @@ -19,7 +19,8 @@ public class Packet28IceServers extends Packet { String url = stream.readString(); String username = stream.readString(); String credential = stream.readString(); - RTCIceServer iceServer = new RTCIceServer(url, username, credential); + String transport = stream.readString(); + RTCIceServer iceServer = new RTCIceServer(url, username, credential, transport); iceServers.add(iceServer); } } @@ -33,6 +34,7 @@ public class Packet28IceServers extends Packet { stream.writeString(iceServer.getUrl()); stream.writeString(iceServer.getUsername()); stream.writeString(iceServer.getCredential()); + stream.writeString(iceServer.getTransport()); } return stream; } diff --git a/src/main/java/im/rosetta/service/services/ForwardUnitService.java b/src/main/java/im/rosetta/service/services/ForwardUnitService.java index afa52f1..5d276a1 100644 --- a/src/main/java/im/rosetta/service/services/ForwardUnitService.java +++ b/src/main/java/im/rosetta/service/services/ForwardUnitService.java @@ -28,13 +28,11 @@ public class ForwardUnitService { private Logger logger; private Set sfuConnections = ConcurrentHashMap.newKeySet(); private ClientManager clientManager; - private Set turnServers = new HashSet<>(); private ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); public ForwardUnitService(Logger logger, ClientManager clientManager) { this.logger = logger; this.clientManager = clientManager; - this.readAllTurnServers(); this.sfuConnectionsSheduler(); } @@ -65,37 +63,6 @@ public class ForwardUnitService { }, 10, 10, TimeUnit.SECONDS); } - /** - * Читаем все TURN сервера из переменной окружения и сохраняем их для дальнейшего - * использования при организации звонков через SFU серверы. - */ - private void readAllTurnServers() { - String turnServersEnv = System.getenv("TURN_SERVERS"); - if(turnServersEnv == null || turnServersEnv.isEmpty()) { - this.logger.info(Color.YELLOW + "No TURN servers configured, skipping TURN servers boot"); - return; - } - String[] turnServers = turnServersEnv.split(","); - for(String turnServer : turnServers) { - String[] parts = turnServer.split("@"); - if(parts.length != 2) { - this.logger.error(Color.RED + "Invalid TURN server configuration: " + turnServer); - continue; - } - String address = parts[0]; - String credentialsPart = parts[1]; - String[] credentialsParts = credentialsPart.split(":"); - if(credentialsParts.length != 2) { - this.logger.error(Color.RED + "Invalid TURN server credentials configuration: " + credentialsPart); - continue; - } - String username = credentialsParts[0]; - String credential = credentialsParts[1]; - RTCIceServer iceServer = new RTCIceServer(address, username, credential); - this.turnServers.add(iceServer); - } - } - /** * Инициализирует соединения к SFU серверам для звонков. * Ожидается, что адреса SFU серверов и секретные ключи для них будут переданы через переменную окружения SFU_SERVERS в формате "address1@secretKey1,address2@secretKey2,...". @@ -239,6 +206,13 @@ public class ForwardUnitService { * @return список серверов для RTC */ public Set getTurnServers() { + Set turnServers = new HashSet<>(); + for(SFU sfu : this.sfuConnections) { + RTCIceServer turnServer = sfu.getTurnServer(); + if(turnServer != null) { + turnServers.add(turnServer); + } + } return turnServers; } diff --git a/src/main/java/io/g365sfu/SFU.java b/src/main/java/io/g365sfu/SFU.java index a058deb..5abc6eb 100644 --- a/src/main/java/io/g365sfu/SFU.java +++ b/src/main/java/io/g365sfu/SFU.java @@ -18,6 +18,7 @@ import io.g365sfu.net.Outgoing; import io.g365sfu.net.SfuSock; import io.g365sfu.util.StrUtils; import io.g365sfu.webrtc.ICECandidate; +import io.g365sfu.webrtc.RTCIceServer; import io.g365sfu.webrtc.SDPAnswer; import io.g365sfu.webrtc.SDPOffer; @@ -62,6 +63,13 @@ public class SFU { */ private Consumer onPeerDisconnected; + /** + * TURN сервер предоставляемый SFU (если включен), который может быть использован + * для обмена кандидатами между участниками звонка через NAT и брандмауэры. + * Если SFU сервер не предоставляет TURN сервер, то это поле будет равно null. + */ + private RTCIceServer turnServer; + /** * Конструктор для создания объекта SFU, который будет использоваться для установления соединения с SFU сервером. * @param serverAddress адрес SFU сервера в формате "host:port", например "sfu.example.com:8080" @@ -96,6 +104,10 @@ public class SFU { if(!estabilished) { throw new SFUHandshakeException("Failed to establish handshake with SFU server at " + this.serverAddress); } + /** + * Спрашиваем про TURN + */ + this.askTurnServer(); } private void onMessage(ByteBuffer message) { @@ -215,6 +227,25 @@ public class SFU { this.onPeerDisconnected.accept(disconnectedPeer); } } + if(packetId == Incoming.TURN_SERVER) { + int urlLength = message.getInt(); + byte[] urlBytes = new byte[urlLength]; + message.get(urlBytes); + String url = new String(urlBytes).trim(); + int usernameLength = message.getInt(); + byte[] usernameBytes = new byte[usernameLength]; + message.get(usernameBytes); + String username = new String(usernameBytes).trim(); + int credentialLength = message.getInt(); + byte[] credentialBytes = new byte[credentialLength]; + message.get(credentialBytes); + String credential = new String(credentialBytes).trim(); + int transportLength = message.getInt(); + byte[] transportBytes = new byte[transportLength]; + message.get(transportBytes); + String transport = new String(transportBytes).trim(); + this.turnServer = new RTCIceServer(url, username, credential, transport); + } } /** @@ -274,6 +305,16 @@ public class SFU { return room; } + /** + * После успешного установления соединения и обменом handshake нужно узнать, поддерживает ли наш SFU встроенный TURN + */ + public void askTurnServer() { + ByteBuffer buffer = ByteBuffer.allocate(1); + buffer.put(Outgoing.ASK_TURN); + buffer.flip(); + this.socket.send(buffer); + } + /** * Получить все комнаты на сервере * @return комнаты на этом сервере @@ -355,4 +396,11 @@ public class SFU { public void setDeleteRoomConsumer(Consumer onDeleteRoom) { this.onDeleteRoom = onDeleteRoom; } + + /** + * Возвращает TURN сервер на этом SFU + */ + public RTCIceServer getTurnServer() { + return turnServer; + } } diff --git a/src/main/java/io/g365sfu/net/Incoming.java b/src/main/java/io/g365sfu/net/Incoming.java index ffeac84..f018e4b 100644 --- a/src/main/java/io/g365sfu/net/Incoming.java +++ b/src/main/java/io/g365sfu/net/Incoming.java @@ -52,4 +52,10 @@ public class Incoming { */ public static final byte PEER_DISCONNECTED = (byte) 0x11; + /** + * Вызывается когда сервер SFU отправляет TURN сервер (если он поддерживается), который может быть использован + * для обмена кандидатами между участниками звонка через NAT. + */ + public static final byte TURN_SERVER = (byte) 0x19; + } diff --git a/src/main/java/io/g365sfu/net/Outgoing.java b/src/main/java/io/g365sfu/net/Outgoing.java index 9fba6fa..752b8fe 100644 --- a/src/main/java/io/g365sfu/net/Outgoing.java +++ b/src/main/java/io/g365sfu/net/Outgoing.java @@ -44,4 +44,10 @@ public class Outgoing { */ public static final byte ROOM_CREATE = (byte) 0x02; + /** + * Вызывается когда бекенд хочет спросить есть ли TURN сервер предоставляемый SFU, сервер ничего не ответит если + * TURN сервер не поддерживается. По умолчанию в G365SFU .env TURN сервер включен. + */ + public static final byte ASK_TURN = (byte) 0x19; + } diff --git a/src/main/java/io/g365sfu/webrtc/RTCIceServer.java b/src/main/java/io/g365sfu/webrtc/RTCIceServer.java index 40f20d3..15cc7cd 100644 --- a/src/main/java/io/g365sfu/webrtc/RTCIceServer.java +++ b/src/main/java/io/g365sfu/webrtc/RTCIceServer.java @@ -10,11 +10,13 @@ public class RTCIceServer { private String url; private String username; private String credential; + private String transport; - public RTCIceServer(String url, String username, String credential) { + public RTCIceServer(String url, String username, String credential, String transport) { this.url = url; this.username = username; this.credential = credential; + this.transport = transport; } /** @@ -41,4 +43,12 @@ public class RTCIceServer { return credential; } + /** + * Транспортный протокол, используемый для связи с сервером ICE (например, "udp" или "tcp"). + * @return строка, содержащая транспортный протокол, используемый для связи с сервером ICE (например, "udp" или "tcp"). + */ + public String getTransport() { + return transport; + } + } -- 2.49.1 From 6166211c60c0115838ca9018f3b78307aadead67 Mon Sep 17 00:00:00 2001 From: RoyceDa Date: Tue, 17 Mar 2026 15:26:20 +0200 Subject: [PATCH 18/19] =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D0=B4=D0=B5=D1=80?= =?UTF-8?q?=D0=B6=D0=BA=D0=B0=20=D0=BD=D0=B5=D1=81=D0=BA=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=BA=D0=B8=D1=85=20=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D0=BA=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=20=D1=83=20ICE=20=D1=81=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/services/ForwardUnitService.java | 10 ++++------ src/main/java/io/g365sfu/SFU.java | 13 ++++++------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/main/java/im/rosetta/service/services/ForwardUnitService.java b/src/main/java/im/rosetta/service/services/ForwardUnitService.java index 5d276a1..02069d4 100644 --- a/src/main/java/im/rosetta/service/services/ForwardUnitService.java +++ b/src/main/java/im/rosetta/service/services/ForwardUnitService.java @@ -206,14 +206,12 @@ public class ForwardUnitService { * @return список серверов для RTC */ public Set getTurnServers() { - Set turnServers = new HashSet<>(); + Set iceServers = new HashSet<>(); for(SFU sfu : this.sfuConnections) { - RTCIceServer turnServer = sfu.getTurnServer(); - if(turnServer != null) { - turnServers.add(turnServer); - } + Set iceServersSupporetd = sfu.getIceServers(); + iceServers.addAll(iceServersSupporetd); } - return turnServers; + return iceServers; } diff --git a/src/main/java/io/g365sfu/SFU.java b/src/main/java/io/g365sfu/SFU.java index 5abc6eb..a2aa358 100644 --- a/src/main/java/io/g365sfu/SFU.java +++ b/src/main/java/io/g365sfu/SFU.java @@ -4,6 +4,7 @@ import java.net.URISyntaxException; import java.nio.ByteBuffer; import java.util.HashMap; import java.util.HashSet; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -64,11 +65,9 @@ public class SFU { private Consumer onPeerDisconnected; /** - * TURN сервер предоставляемый SFU (если включен), который может быть использован - * для обмена кандидатами между участниками звонка через NAT и брандмауэры. - * Если SFU сервер не предоставляет TURN сервер, то это поле будет равно null. + * TURN сервер предоставляемый SFU (если включен), может поддерживать udp,tcp протоколы (несколько ice) */ - private RTCIceServer turnServer; + private Set iceServers = new HashSet<>(); /** * Конструктор для создания объекта SFU, который будет использоваться для установления соединения с SFU сервером. @@ -244,7 +243,7 @@ public class SFU { byte[] transportBytes = new byte[transportLength]; message.get(transportBytes); String transport = new String(transportBytes).trim(); - this.turnServer = new RTCIceServer(url, username, credential, transport); + this.iceServers.add(new RTCIceServer(url, username, credential, transport)); } } @@ -400,7 +399,7 @@ public class SFU { /** * Возвращает TURN сервер на этом SFU */ - public RTCIceServer getTurnServer() { - return turnServer; + public Set getIceServers() { + return this.iceServers; } } -- 2.49.1 From 163d66d0b046f30ac2ca23c007b052591da121f5 Mon Sep 17 00:00:00 2001 From: RoyceDa Date: Wed, 18 Mar 2026 19:34:48 +0200 Subject: [PATCH 19/19] =?UTF-8?q?=D0=9F=D1=80=D0=B0=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20DisconnectReason=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D1=81=D0=B1=D0=BE=D1=80=D1=81=D0=B0=D1=85=20=D0=B8=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D1=8B=D0=B2=D0=B0=D1=85=20=D1=81=D0=BE=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 4 -- .../executors/Executor26SignalPeer.java | 15 +++++++ .../im/rosetta/packet/Packet26SignalPeer.java | 6 +++ .../packet/runtime/NetworkSignalType.java | 10 ++++- .../service/services/ForwardUnitService.java | 41 +++++++++++++++++++ src/main/java/io/g365sfu/SFU.java | 12 +++++- .../java/io/g365sfu/net/DisconnectReason.java | 25 +++++++++++ .../java/io/g365sfu/net/DisconnectedPeer.java | 8 +++- 8 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 src/main/java/io/g365sfu/net/DisconnectReason.java diff --git a/.env b/.env index db4a9c6..c21b809 100644 --- a/.env +++ b/.env @@ -21,7 +21,3 @@ BUFFER_CLEANUP_DAYS=7 #SFU Сервера SFU_SERVERS=127.0.0.1:1001@SFU_TEST_SECRET -#TURN Сервера (должны поддерживать TCP и UDP протоколы) -# Формат: host:port@username:password через запятую если их несколько, без пробелов -TURN_SERVERS=10.211.55.2:3478@user:pass - diff --git a/src/main/java/im/rosetta/executors/Executor26SignalPeer.java b/src/main/java/im/rosetta/executors/Executor26SignalPeer.java index 674b864..9ea8147 100644 --- a/src/main/java/im/rosetta/executors/Executor26SignalPeer.java +++ b/src/main/java/im/rosetta/executors/Executor26SignalPeer.java @@ -36,6 +36,21 @@ public class Executor26SignalPeer extends PacketExecutor { return; } NetworkSignalType type = packet.getSignalType(); + if(type == NetworkSignalType.CALL) { + /** + * Инициируется звонок от src к dst, проверяем, что dst не занят другим звонком, если занят, то отправляем сигнал END_CALL_BECAUSE_BUSY обратно src + */ + Room room = this.fus.getRoomByParticipantId(packet.getDst()); + if(room != null) { + /** + * Получатель сигнала уже находится в другой комнате, значит он занят другим звонком, отправляем сигнал END_CALL_BECAUSE_BUSY обратно src + */ + Packet26SignalPeer responsePacket = new Packet26SignalPeer(); + responsePacket.setSignalType(NetworkSignalType.END_CALL_BECAUSE_BUSY); + this.clientManager.sendPacketToAuthorizedPK(packet.getSrc(), responsePacket); + return; + } + } if(type == NetworkSignalType.CREATE_ROOM){ /** * Создается комната для звонка diff --git a/src/main/java/im/rosetta/packet/Packet26SignalPeer.java b/src/main/java/im/rosetta/packet/Packet26SignalPeer.java index b72f672..ffb37a1 100644 --- a/src/main/java/im/rosetta/packet/Packet26SignalPeer.java +++ b/src/main/java/im/rosetta/packet/Packet26SignalPeer.java @@ -38,6 +38,9 @@ public class Packet26SignalPeer extends Packet { @Override public void read(Stream stream) { this.signalType = NetworkSignalType.fromCode(stream.readInt8()); + if(this.signalType == NetworkSignalType.END_CALL_BECAUSE_BUSY || this.signalType == NetworkSignalType.END_CALL_BECAUSE_PEER_DISCONNECTED) { + return; + } this.src = stream.readString(); this.dst = stream.readString(); if (signalType == NetworkSignalType.KEY_EXCHANGE) { @@ -53,6 +56,9 @@ public class Packet26SignalPeer extends Packet { Stream stream = new Stream(); stream.writeInt16(this.packetId); stream.writeInt8(this.signalType.getCode()); + if(this.signalType == NetworkSignalType.END_CALL_BECAUSE_BUSY || this.signalType == NetworkSignalType.END_CALL_BECAUSE_PEER_DISCONNECTED) { + return stream; + } stream.writeString(this.src); stream.writeString(this.dst); if (signalType == NetworkSignalType.KEY_EXCHANGE) { diff --git a/src/main/java/im/rosetta/packet/runtime/NetworkSignalType.java b/src/main/java/im/rosetta/packet/runtime/NetworkSignalType.java index d062cd0..7ee79bd 100644 --- a/src/main/java/im/rosetta/packet/runtime/NetworkSignalType.java +++ b/src/main/java/im/rosetta/packet/runtime/NetworkSignalType.java @@ -23,7 +23,15 @@ public enum NetworkSignalType { /** * Создание комнаты */ - CREATE_ROOM(4); + CREATE_ROOM(4), + /** + * Обрыв связи с пиром + */ + END_CALL_BECAUSE_PEER_DISCONNECTED(5), + /** + * Не удалось дозвониться - пользователь занят другим звонком + */ + END_CALL_BECAUSE_BUSY(6); private final int code; diff --git a/src/main/java/im/rosetta/service/services/ForwardUnitService.java b/src/main/java/im/rosetta/service/services/ForwardUnitService.java index 02069d4..b07a9ab 100644 --- a/src/main/java/im/rosetta/service/services/ForwardUnitService.java +++ b/src/main/java/im/rosetta/service/services/ForwardUnitService.java @@ -10,10 +10,14 @@ import java.util.concurrent.TimeUnit; import im.rosetta.client.ClientManager; import im.rosetta.logger.Logger; import im.rosetta.logger.enums.Color; +import im.rosetta.packet.Packet26SignalPeer; import im.rosetta.packet.Packet27WebRTC; +import im.rosetta.packet.runtime.NetworkSignalType; import im.rosetta.packet.runtime.NetworkWebRTCType; import io.g365sfu.Room; import io.g365sfu.SFU; +import io.g365sfu.net.DisconnectReason; +import io.g365sfu.net.DisconnectedPeer; import io.g365sfu.webrtc.ICECandidate; import io.g365sfu.webrtc.RTCIceServer; import io.g365sfu.webrtc.SDPAnswer; @@ -113,6 +117,14 @@ public class ForwardUnitService { e.printStackTrace(); } }); + connection.setPeerDisconnectedConsumer(arg0 -> { + try{ + onPeerDisconnected(arg0); + }catch(ProtocolException e){ + this.logger.error(Color.RED + "Failed to handle peer disconnection from SFU server: " + address + ", error: " + e.getMessage()); + e.printStackTrace(); + } + }); this.sfuConnections.add(connection); this.logger.info(Color.GREEN + "Successfully connected to SFU server: " + address); } catch (Exception e) { @@ -121,6 +133,35 @@ public class ForwardUnitService { } } + public void onPeerDisconnected(DisconnectedPeer disconnectedPeer) throws ProtocolException { + Room room = disconnectedPeer.getRoom(); + if(disconnectedPeer.getReason() != DisconnectReason.FAILED){ + /** + * Если у нас произошло штатное отключение, а не в результате обрыва связи - то не нужно отправлять + * оппонентам пакеты о том, что участник отключился в результате обрыва связи. + */ + return; + } + for(String peerId : room.getParticipants()) { + /** + * Уведомляем все пиры, что соединение с пиром было потеряно + */ + if(room.getParticipants().size() == 1) { + /** + * Звонок был завершен, так как в комнате остался только один участник, который не может продолжать звонок в одиночку. + */ + Packet26SignalPeer packet = new Packet26SignalPeer(); + packet.setSignalType(NetworkSignalType.END_CALL_BECAUSE_PEER_DISCONNECTED); + this.clientManager.sendPacketToAuthorizedPK(peerId, packet); + } + } + } + + /** + * Выполняется когда сервер SFU отправляет SDP answer для одного из участников комнаты. + * @param sdpAnswer объект SDPAnswer, который содержит информацию о комнате, участнике и самом answer, + * @throws ProtocolException + */ public void onSdpAnswer(SDPAnswer sdpAnswer) throws ProtocolException { String participantId = sdpAnswer.getParticipantId(); Packet27WebRTC packet = new Packet27WebRTC(); diff --git a/src/main/java/io/g365sfu/SFU.java b/src/main/java/io/g365sfu/SFU.java index a2aa358..370922c 100644 --- a/src/main/java/io/g365sfu/SFU.java +++ b/src/main/java/io/g365sfu/SFU.java @@ -13,6 +13,7 @@ import java.util.function.Consumer; import io.g365sfu.exception.SFUException; import io.g365sfu.exception.SFUHandshakeException; +import io.g365sfu.net.DisconnectReason; import io.g365sfu.net.DisconnectedPeer; import io.g365sfu.net.Incoming; import io.g365sfu.net.Outgoing; @@ -215,13 +216,14 @@ public class SFU { message.get(peerIdBytes); String peerId = new String(peerIdBytes).trim(); Room room = this.rooms.get(roomId); - DisconnectedPeer disconnectedPeer = new DisconnectedPeer(peerId, roomId, room); + DisconnectReason reason = io.g365sfu.net.DisconnectReason.fromCode(message.getInt()); if(room != null) { /** * Если такая комната существует то удаляем оттуда участника */ room.removeParticipant(peerId); } + DisconnectedPeer disconnectedPeer = new DisconnectedPeer(peerId, roomId, room, reason); if(this.onPeerDisconnected != null) { this.onPeerDisconnected.accept(disconnectedPeer); } @@ -396,6 +398,14 @@ public class SFU { this.onDeleteRoom = onDeleteRoom; } + /** + * Устанавливает потребителя для обработки сообщений от сервера SFU о том, что участник отключился от комнаты. + * @param onPeerDisconnected потребитель, который будет вызываться при получении сообщения от сервера SFU с кодом 0x11, + */ + public void setPeerDisconnectedConsumer(Consumer onPeerDisconnected) { + this.onPeerDisconnected = onPeerDisconnected; + } + /** * Возвращает TURN сервер на этом SFU */ diff --git a/src/main/java/io/g365sfu/net/DisconnectReason.java b/src/main/java/io/g365sfu/net/DisconnectReason.java new file mode 100644 index 0000000..21f8ccf --- /dev/null +++ b/src/main/java/io/g365sfu/net/DisconnectReason.java @@ -0,0 +1,25 @@ +package io.g365sfu.net; + +public enum DisconnectReason { + FAILED(0), + CLOSED(1); + + private final int code; + + DisconnectReason(int code) { + this.code = code; + } + + public int getCode() { + return code; + } + + public static DisconnectReason fromCode(int code) { + for (DisconnectReason reason : DisconnectReason.values()) { + if (reason.code == code) { + return reason; + } + } + throw new IllegalArgumentException("Unknown DisconnectReason code: " + code); + } +} diff --git a/src/main/java/io/g365sfu/net/DisconnectedPeer.java b/src/main/java/io/g365sfu/net/DisconnectedPeer.java index d74e2fd..36d83fe 100644 --- a/src/main/java/io/g365sfu/net/DisconnectedPeer.java +++ b/src/main/java/io/g365sfu/net/DisconnectedPeer.java @@ -7,11 +7,13 @@ public class DisconnectedPeer { private String peerId; private Room room; private String roomId; + private DisconnectReason reason; - public DisconnectedPeer(String peerId, String roomId, Room room) { + public DisconnectedPeer(String peerId, String roomId, Room room, DisconnectReason reason) { this.peerId = peerId; this.room = room; this.roomId = roomId; + this.reason = reason; } public String getPeerId() { @@ -25,4 +27,8 @@ public class DisconnectedPeer { public String getRoomId() { return roomId; } + + public DisconnectReason getReason() { + return reason; + } } -- 2.49.1