snapshot of https://github.com/jitsi/jicofo.git @ 9ab59aa3d348e282da89da2c9f11ecbecd3f7b79 (2026-09-15)
Some checks failed
Java CI with Maven / Java 21 (push) Failing after 4m41s
Java CI with Maven / Java 17 (push) Failing after 4m42s

This commit is contained in:
upstream-mirror
2026-09-15 16:13:53 +05:00
commit fcbc274d01
275 changed files with 41230 additions and 0 deletions

9
.editorconfig Normal file
View File

@@ -0,0 +1,9 @@
[*.{kt,kts}]
# Keep constructor parameters wrapped one per line, and kotest specs unindented.
ktlint_standard_class-signature = disabled
max_line_length=120
ktlint_code_style = intellij_idea
# I find trailing commas annoying
ktlint_standard_trailing-comma-on-call-site = disabled
ktlint_standard_trailing-comma-on-declaration-site = disabled

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.java text eol=lf diff=java

27
.github/ISSUE_TEMPLATE/bug-report.md vendored Normal file
View File

@@ -0,0 +1,27 @@
---
name: Bug Report
about: Before posting, please make sure you check https://community.jitsi.org
---
*This Issue tracker is only for reporting bugs and tracking code related issues.*
Before posting, please make sure you check community.jitsi.org to see if the same or similar bugs have already been discussed. General questions, installation help, and feature requests can also be posted to community.jitsi.org.
## Description
---
## Current behavior
---
## Expected Behavior
---
## Possible Solution
---
## Steps to reproduce
---
# Environment details
---

43
.github/workflows/maven.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
# This workflow will build a Java project with Maven
# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven
name: Java CI with Maven
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
env:
# Java version to use for the release
RELEASE_JAVA_VERSION: 17
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
java: [ 17, 21 ]
name: Java ${{ matrix.java }}
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Set up JDK ${{ matrix.java }}
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
with:
distribution: temurin
java-version: ${{ matrix.java }}
cache: maven
- name: Build and test with Maven
run: mvn verify -B -Pcoverage
- name: Upload coverage report
uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4
with:
token: ${{ secrets.CODECOV_TOKEN }}

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
*.class
/classes/
/dist/
*.jar
/junit-reports/
*.swp
/target/
jicofo-common/target/
jicofo-selector/target/
jicofo/target/
.classpath
.project
.settings/
**/.idea/
*.iml

159
CLAUDE.md Normal file
View File

@@ -0,0 +1,159 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Overview
Jicofo (JItsi COnference FOcus) is a signaling server for Jitsi Meet conferences. It manages XMPP Multi-User Chat (MUC) rooms, initiates Jingle sessions with participants, and coordinates media routing through Jitsi Videobridge instances using the Colibri2 protocol.
## Build and Test Commands
### Building
```bash
mvn install # Build all modules and create distribution package
mvn clean install # Clean build
mvn install -DskipTests # Build without running tests
```
The distribution package is created in `jicofo/target/jicofo-1.1-SNAPSHOT-archive.zip`.
### Testing
```bash
mvn test # Run all tests
mvn test -pl jicofo-selector # Run tests for specific module
mvn test -Dtest=BridgeSelectorTest # Run specific test class
```
### Code Quality
```bash
mvn exec:exec@ktlint-check # Run ktlint (must be run for individual modules e.g. in ./jicofo-selector/; sibling modules must
# be resolvable, so run "mvn install -DskipTests" from the root first if they are not)
mvn exec:exec@ktlint-format # Auto-format Kotlin code with ktlint (same restriction)
mvn checkstyle:check # Run checkstyle (config: checkstyle.xml)
```
## Architecture
### Module Structure
This is a multi-module Maven project with three modules:
1. **jicofo-common** - Shared utilities and data structures
- Conference source management (`ConferenceSourceMap`, `EndpointSourceSet`, `Source`)
- XMPP/MUC abstractions (`ChatRoom`, `ChatRoomMember`, `BaseBrewery`)
- Codec management
- Metrics infrastructure
- No dependencies on other jicofo modules
2. **jicofo-selector** - Bridge selection and Colibri session management
- Self-contained, reusable component for bridge selection logic
- Bridge management (`Bridge`, `BridgeSelector`, `BridgeMucDetector`)
- Pluggable selection strategies:
- `SingleBridgeSelectionStrategy` - Single bridge, no Octo
- `RegionBasedBridgeSelectionStrategy` - Geographic distribution with Octo
- `IntraRegionBridgeSelectionStrategy` - Load-balanced Octo within regions
- `VisitorSelectionStrategy` - Separate strategies for visitors vs participants
- Colibri2 session management (`ColibriV2SessionManager`, `Colibri2Session`)
- Depends only on jicofo-common
3. **jicofo** - Main application module
- Entry point: `Main.java`
- Service orchestration: `JicofoServices.kt` (creates and manages all services)
- Conference management: `JitsiMeetConferenceImpl` (Java, ~2000+ lines, core conference logic)
- Focus management: `FocusManager` (conference lifecycle, room JID mapping)
- XMPP services: `XmppServices`, `XmppProvider`, modular IQ handlers
- Authentication: `AbstractAuthAuthority`, `XMPPDomainAuthAuthority`, `ExternalJWTAuthority`
- External service integration: Jibri (recording/streaming), Jigasi (SIP gateway)
- REST API: Ktor-based HTTP interface for health checks, stats, debug endpoints
- Depends on both jicofo-common and jicofo-selector
### Key Components & Responsibilities
**Conference Management**
- `JitsiMeetConferenceImpl` - Main conference implementation (manages Jingle sessions, coordinates with bridges, handles MUC membership, lifecycle management)
- `Participant` - Represents a conference participant with Jingle session, sources, and bridge allocation
- `ParticipantInviteRunnable` - Handles participant invitation flow (Jingle session establishment)
- `FocusManager` - Conference lifecycle management, maps room JIDs to conference instances
**Bridge Selection**
- `BridgeSelector` - Core component for selecting optimal bridges based on health, stress, operational status, and configured strategy
- `Bridge` - Represents a single jitsi-videobridge instance with health and stress tracking
- `BridgeMucDetector` - Discovers bridges via XMPP MUC (brewery pattern)
- `JvbDoctor` - Health check management for bridges
**Colibri Session Management**
- `ColibriV2SessionManager` - Manages Colibri2 sessions with bridges
- `Colibri2Session` - Represents a session with a single bridge
- Sends `ConferenceModifyIQ` to bridges for allocations and updates
**XMPP Handling**
- `XmppProvider` - Manages multiple XMPP connections (client, service, visitor)
- Modular IQ handlers: `ConferenceIqHandler`, `JingleIqRequestHandler`, `JibriIqHandler`, etc.
- `VisitorsManager` - Manages visitor functionality across multiple XMPP nodes
### Application Flow
1. **Startup**: `Main.java` loads config, creates `JicofoServices`, sets up signal handlers
2. **Conference Creation**: Client requests conference → `FocusManager.conferenceRequest()` → `JitsiMeetConferenceImpl.start()`
3. **Participant Join**: Participant joins MUC → conference detects new member → `ParticipantInviteRunnable` selects bridge, allocates Colibri endpoint, sends Jingle session-initiate
4. **Bridge Selection**: Considers operational status, stress level, graceful shutdown status, version compatibility, and selection strategy
5. **Media Flow**: Participant accepts Jingle session, media flows through videobridge
### Configuration
Configuration uses HOCON format (lightbend/config). Main config file is typically `/etc/jitsi/jicofo/jicofo.conf`.
Reference configuration with all available options: `jicofo-selector/src/main/resources/reference.conf`
Configuration objects in code are typically Kotlin companion objects (e.g., `BridgeConfig.config`, `ConferenceConfig.config`).
### Language & Conventions
- **Kotlin**: New code, utilities, data structures, all test code
- **Java**: Legacy code, notably `JitsiMeetConferenceImpl` (complex conference management)
- **Interop**: Seamless with `@JvmOverloads`, `@JvmField`, companion objects
### Testing Framework
- **Primary**: Kotest (BDD-style specs with `ShouldSpec`)
- **Style**: `context("...") { should("...") { ... } }` for behavior-driven tests
- **Mocking**: MockK for Kotlin (`mockk { every { ... } returns ... }`)
- **Test Utilities**: Mock implementations in `jicofo/src/test/kotlin/org/jitsi/jicofo/mock/`
- `MockXmppConnection`, `MockXmppProvider` - XMPP testing
- `MockChatRoom` - MUC simulation
- `TestColibri2Server` - Colibri2 protocol testing
- **Configuration Testing**: Use `withNewConfig { ... }` helper for custom configs
- **Time Control**: `FakeClock` for time-dependent tests
- **Isolation**: Tests use `IsolationMode.InstancePerLeaf` for test isolation
- **Location**: Tests mirror source structure (`src/test/kotlin` matches `src/main/kotlin`)
### Important Architectural Patterns
**Separation of Concerns**: Clear module boundaries (common utilities, bridge selection logic, application orchestration)
**Dependency Injection via Constructor**: Services are composed via constructor injection in `JicofoServices.kt`
**Strategy Pattern**: Bridge selection strategies and topology strategies are pluggable, configured via HOCON
**Observer Pattern**: Event-driven architecture with `AsyncEventEmitter` (e.g., `BridgeSelector.EventHandler`, `ColibriSessionManager.Listener`)
**Thread Safety**: Extensive use of `synchronized` blocks, `ConferenceSourceMap` uses `ConcurrentHashMap`, careful lock ordering to prevent deadlocks
**Metrics & Observability**: Centralized metrics via `JicofoMetricsContainer`, periodic metric updates, debug state endpoints (`debugState` properties)
### Key Protocols
**XMPP**: Communication backbone with multiple connections (Client for participants, Service for bridges, Visitor for visitor nodes)
**Jingle**: Standard XMPP protocol for peer-to-peer session negotiation (jicofo terminates Jingle signaling, not media)
**Colibri2**: Modern protocol for bridge communication (endpoint allocation, management, relay support for Octo)
**Brewery Pattern**: Service discovery - bridges/Jibris/Jigasis join specific MUC rooms, jicofo monitors presence
### Notable Design Decisions
- **Graceful Degradation**: Bridges report stress levels, can enter graceful shutdown, health checks with timeout-based recovery
- **Scalability**: Multi-bridge support (Octo mesh), region-based selection, visitor architecture for large conferences, load redistribution
- **Singleton with Synchronization**: `JicofoServices` uses synchronized singleton pattern (required because services start threads that depend on the singleton)
- **Legacy Code Preservation**: `JitsiMeetConferenceImpl` remains in Java with extensive synchronization (noted as needing refactoring, but stable)

202
LICENSE Normal file
View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

104
README.md Normal file
View File

@@ -0,0 +1,104 @@
# Jicofo
JItsi COnference FOcus is a signaling server, one of the backend components in the [Jitsi Meet] stack.
[Jitsi Meet]: https://github.com/jitsi/jitsi-meet
## Overview
Jitsi Meet conferences are associated with an XMPP
[Multi-User Chat (MUC) room](https://xmpp.org/extensions/xep-0045.html). The MUC functionality is provided by the
XMPP server (prosody).
Jicofo joins the conference MUC and is then responsible for initiating a
[Jingle](https://xmpp.org/extensions/xep-0166.html) session with each participant (in this sense it is the "focus" of the
conference, which is where its name comes from). While Jicofo manages and terminates Jingle sessions, it does not
process any of the media (audio/video). Instead, it uses one or more
[Jitsi Videobridge](github.com/jitsi/jitsi-videobridge/) instances.
Jicofo is responsible for selecting a Jitsi Videobridge for each participant, and manages the set of videobridges for
the conference with the COLIBRI protocol (colibri version 2 is now used, the format in XEP-0340 is now deprecated).
In general the conference participants and videobridge instances are accessed through different XMPP connections --
the configured Client and Service connections, respectively, though they may coincide.
![Connection between Jicofo and the other components in the Jitsi Meet stack.](https://github.com/jitsi/jicofo/blob/master/doc/diagram.png?raw=true)
# Configuration
Jicofo takes its configuration from a [hocon](https://github.com/lightbend/config/blob/main/HOCON.md) config file,
usually installed in `/etc/jitsi/jicofo/jicofo.conf`. See the
[reference.conf](https://github.com/jitsi/jicofo/blob/master/jicofo-selector/src/main/resources/reference.conf) file
for the available options.
# Installation
## Debian
The recommended way to install Jicofo and Jitsi Meet is to follow the
[Quick Install Guide](https://github.com/jitsi/jitsi-meet/blob/master/doc/quick-install.md) for debian-based systems.
### Binaries
You can download Debian/Ubuntu binaries here:
* [stable](https://download.jitsi.org/stable/) ([instructions](https://jitsi.org/downloads/ubuntu-debian-installations-instructions/))
* [testing](https://download.jitsi.org/testing/) ([instructions](https://jitsi.org/downloads/ubuntu-debian-installations-instructions-for-testing/))
* [nightly](https://download.jitsi.org/unstable/) ([instructions](https://jitsi.org/downloads/ubuntu-debian-installations-instructions-nightly/))
## Docker
Jicofo is available as a docker image as part of [docker-jitsi-meet](https://github.com/jitsi/docker-jitsi-meet).
## Manual
This section is only required for a manual setup, not necessary when using Quick Install or other methods.
### Prosody configuration
Jicofo needs privileges to create Multi-User Chat rooms. To grant these privileges we create an account for it and add
it to the global `admins` list. We create a new virtual host, because the one used by clients only supports anonymous
authentication. We add this to Prosody's config file (`/etc/prosody/prosody.cfg.lua` by default):
```
admins = { focus@auth.jitsi.example.com }
VirtualHost "auth.jitsi.example.com"
authentication = "internal_hashed"
```
Then restart Prosody and create the user account:
```
sudo prosodyctl restart
sudo prosodyctl register focus auth.jitsi.example.com focuspassword
```
### Building Jicofo
Build using maven with:
```commandline
mvn install
```
This will create a package in `jicofo/target/jicofo-1.1-SNAPSHOT-archive.zip`
### Running Jicofo
Extract the distribution package and run with `jicofo.sh`.
### Certificates
Jicofo uses an XMPP user connection (on port 5222 by default), and since the
upgrade to smack4 it verifies the server's certificate. In a default
installation the debian installation scripts take care of generating a
self-signed certificate and adding it to the keystore.
For situations in which the certificate is not trusted you can add it to the
store by:
#### On Linux
```
sudo cp cert.pem /usr/local/share/ca-certificates/
sudo update-ca-certificates
```
#### On MacOS X
On Mac java uses its own keystore, so adding the certificate to the system one
does not work. Add it to the java keystore with:
```
sudo keytool -importcert -file cert.pem -keystore /Library/Java//JavaVirtualMachines/jdk1.8.0_25.jdk/Contents/Home/jre/lib/security/cacerts
```
Note that if the XMPP server you are connecting to is a prosody instance
configured with the jitsi-meet scripts, then you can find the certificate in:
```
/var/lib/prosody/$JICOFO_AUTH_DOMAIN.crt
```

9
SECURITY.md Normal file
View File

@@ -0,0 +1,9 @@
# Security
## Reporting security issues
We take security very seriously and develop all Jitsi projects to be secure and safe.
If you find (or simply suspect) a security issue in any of the Jitsi projects, please report it to us via [HackerOne](https://hackerone.com/8x8-bounty) or send us an email to security@jitsi.org.
**We encourage responsible disclosure for the sake of our users, so please reach out before posting in a public space.**

45
checkstyle.xml Normal file
View File

@@ -0,0 +1,45 @@
<?xml version="1.0"?>
<!DOCTYPE module PUBLIC
"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
"https://checkstyle.org/dtds/configuration_1_3.dtd">
<module name="Checker">
<module name="LineLength">
<property name="fileExtensions" value="java"/>
<property name="max" value="120"/>
<property name="ignorePattern" value="http://|https://"/>
</module>
<module name="TreeWalker">
<module name="LeftCurly">
<property name="option" value="nl"/>
<property name="tokens" value="LITERAL_CATCH"/>
<property name="tokens" value="LITERAL_ELSE"/>
<property name="tokens" value="LITERAL_FINALLY"/>
<property name="tokens" value="LITERAL_IF"/>
<property name="tokens" value="LITERAL_TRY"/>
<property name="tokens" value="LITERAL_SYNCHRONIZED"/>
<property name="tokens" value="ANNOTATION_DEF"/>
<property name="tokens" value="CLASS_DEF"/>
<property name="tokens" value="CTOR_DEF"/>
<property name="tokens" value="ENUM_DEF"/>
<property name="tokens" value="INTERFACE_DEF"/>
<property name="tokens" value="METHOD_DEF"/>
<property name="tokens" value="RECORD_DEF"/>
</module>
<!-- Skips empty blocks {}, to not skip them: value="alone" -->
<module name="RightCurly">
<property name="option" value="alone_or_singleline"/>
</module>
<module name="WhitespaceAfter">
<!-- Drops TYPECAST from default tokens -->
<property name="tokens" value="COMMA , SEMI , LITERAL_IF , LITERAL_ELSE , LITERAL_WHILE , LITERAL_DO , LITERAL_FOR , DO_WHILE"/>
</module>
</module>
<module name="FileTabCharacter">
<property name="eachLine" value="true"/>
</module>
</module>

5
debian/README.Debian vendored Normal file
View File

@@ -0,0 +1,5 @@
jicofo for Debian
---------------------
If you want a WebRTC videoconferencing solution you have to install jitsi-meet
and jitsi-videobridge packages which uses Jitsi Videobridge as a SFU (Selective Forwarding Unit)

6
debian/changelog vendored Normal file
View File

@@ -0,0 +1,6 @@
jicofo (1.0-0-g9fc93ed-1) unstable; urgency=low
* Initial release.
-- Damian Minkov <damencho@jitsi.org> Fri, 10 Oct 2017 16:18:00 +0000

1
debian/compat vendored Normal file
View File

@@ -0,0 +1 @@
12

14
debian/control vendored Normal file
View File

@@ -0,0 +1,14 @@
Source: jicofo
Section: net
Priority: extra
Maintainer: Jitsi Team <dev@jitsi.org>
Uploaders: Emil Ivov <emcho@jitsi.org>, Damian Minkov <damencho@jitsi.org>, Pawel Domas <pawel.domas@jitsi.org>
Build-Depends: debhelper, openjdk-17-jdk | openjdk-21-jdk, maven
Standards-Version: 3.9.3
Homepage: https://jitsi.org/meet
Package: jicofo
Architecture: all
Depends: ${misc:Depends}, openjdk-17-jre-headless | openjdk-17-jre | openjdk-21-jre-headless | openjdk-21-jre, ruby-hocon, jq
Description: JItsi Meet COnference FOcus
Jicofo is a conference focus for Jitsi Meet application.

13
debian/copyright vendored Normal file
View File

@@ -0,0 +1,13 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: Jitsi Conference Focus
Upstream-Contact: Emil Ivov <emcho@jitsi.org>
Source: https://github.com/jitsi/jicofo
Files: *
Copyright: 2013-2018 Jitsi
License: Apache 2.0
License: Apache 2.0
On Debian systems, the full text of the Apache licence
version 2.0 can be found in the file
'/usr/share/common-licenses/Apache-2.0'.

0
debian/docs vendored Normal file
View File

101
debian/init.d vendored Normal file
View File

@@ -0,0 +1,101 @@
#! /bin/sh
#
# INIT script for Jitsi Conference Focus
# Version: 1.0 4-Dec-2014 pawel.domas@jitsi.org
#
### BEGIN INIT INFO
# Provides: jicofo
# Required-Start: $local_fs $remote_fs
# Required-Stop: $local_fs $remote_fs
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: Jitsi conference Focus
# Description: Conference focus for Jitsi Meet application.
### END INIT INFO
. /lib/lsb/init-functions
# Include jicofo defaults if available
if [ -f /etc/jitsi/jicofo/config ]; then
. /etc/jitsi/jicofo/config
fi
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
DAEMON=/usr/share/jicofo/jicofo.sh
DAEMON_DIR=/usr/share/jicofo/
NAME=jicofo
USER=jicofo
PIDFILE=/var/run/jicofo.pid
LOGFILE=/var/log/jitsi/jicofo.log
DESC=jicofo
if [ ! -x $DAEMON ] ;then
echo "Daemon not executable: $DAEMON"
exit 1
fi
set -e
stop() {
if [ -f $PIDFILE ]; then
PID=$(cat $PIDFILE)
fi
echo -n "Stopping $NAME: "
if [ $PID ]; then
kill $PID || true
rm $PIDFILE || true
echo "$NAME stopped."
else
echo "$NAME doesn't seem to be running."
fi
}
start() {
if [ -f $PIDFILE ]; then
echo "$NAME seems to be already running, we found pidfile $PIDFILE."
exit 1
fi
echo -n "Starting $NAME: "
export JICOFO_AUTH_PASSWORD JICOFO_MAX_MEMORY
start-stop-daemon --start --quiet --background --chuid $USER --make-pidfile --pidfile $PIDFILE \
--exec /bin/bash -- -c "cd $DAEMON_DIR; JAVA_SYS_PROPS=\"$JAVA_SYS_PROPS\" exec $DAEMON $JICOFO_OPTS < /dev/null >> $LOGFILE 2>&1"
echo "$NAME started."
}
reload() {
echo 'Not yet implemented.'
}
status() {
status_of_proc -p $PIDFILE java "$NAME" && exit 0 || exit $?
}
case "$1" in
start)
start
;;
stop)
stop
;;
restart)
stop
start
;;
reload)
reload
;;
force-reload)
reload
;;
status)
status
;;
*)
N=/etc/init.d/$NAME
echo "Usage: $N {start|stop|restart|reload|status}" >&2
exit 1
;;
esac
exit 0

6
debian/install vendored Normal file
View File

@@ -0,0 +1,6 @@
jicofo/target/dependency/* usr/share/jicofo/lib
lib/logging.properties etc/jitsi/jicofo
jicofo.jar usr/share/jicofo
resources/jicofo.sh usr/share/jicofo
resources/collect-dump-logs.sh usr/share/jicofo
jicofo/jicofo etc/logrotate.d/

38
debian/manpage.1 vendored Normal file
View File

@@ -0,0 +1,38 @@
.\" Hey, EMACS: -*- nroff -*-
.\" First parameter, NAME, should be all caps
.\" Second parameter, SECTION, should be 1-8, maybe w/ subsection
.\" other parameters are allowed: see man(7), man(1)
.TH JICOFO SECTION "November 28, 2014"
.\" Please adjust this date whenever revising the manpage.
.\"
.\" Some roff macros, for reference:
.\" .nh disable hyphenation
.\" .hy enable hyphenation
.\" .ad l left justify
.\" .ad b justify to both left and right margins
.\" .nf disable filling
.\" .fi enable filling
.\" .br insert line break
.\" .sp <n> insert n+1 empty lines
.\" for manpage-specific macros, see man(7)
.SH NAME
jicofo \- conference focus for Jitsi Meet application.
.SH SYNOPSIS
.B /usr/share/jicofo/jicofo.sh
.RI [ options ] ...
.br
.B /etc/init.d/jicofo (start|stop|restart)
.SH DESCRIPTION
This manual page documents briefly the
.B jicofo
service.
.PP
.\" TeX users may be more comfortable with the \fB<whatever>\fP and
.\" \fI<whatever>\fP escape sequences to invode bold face and italics,
.\" respectively.
\fBjicofo\fP is a conference focus for Jitsi Meet application.
.SH AUTHOR
jicofo was written by Jitsi.org and Bluejimp.com.
.PP
This manual page was written by Pawel Domas <pawel.domas@jitsi.org>,
for the Debian project (and may be used by others).

1
debian/po/POTFILES.in vendored Normal file
View File

@@ -0,0 +1 @@
[type: gettext/rfc822deb] templates

54
debian/po/templates.pot vendored Normal file
View File

@@ -0,0 +1,54 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the jicofo package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: jicofo\n"
"Report-Msgid-Bugs-To: jicofo@packages.debian.org\n"
"POT-Creation-Date: 2016-11-21 19:43+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=CHARSET\n"
"Content-Transfer-Encoding: 8bit\n"
#. Type: string
#. Description
#: ../templates:1001
msgid "Hostname:"
msgstr ""
#. Type: string
#. Description
#: ../templates:1001
msgid "The jicofo package needs the DNS hostname of your instance."
msgstr ""
#. Type: string
#. Description
#: ../templates:2001
msgid "Jicofo username:"
msgstr ""
#. Type: string
#. Description
#: ../templates:2001
msgid "The jicofo needs an authenticated admin user to connect to xmpp server."
msgstr ""
#. Type: password
#. Description
#: ../templates:3001
msgid "Jicofo user password:"
msgstr ""
#. Type: password
#. Description
#: ../templates:3001
msgid "The secret used to connect to xmpp server as jicofo user."
msgstr ""

167
debian/postinst vendored Normal file
View File

@@ -0,0 +1,167 @@
#!/bin/bash
# postinst script for jicofo
set -e
case "$1" in
configure)
CONFIG="/etc/jitsi/jicofo/config"
# we don't want to regenerate config on upgrade
OLDCONFIG="false"
if [ -f $CONFIG ]; then
. $CONFIG
if [ -n "$JICOFO_HOSTNAME" ] \
&& [ -n "$JICOFO_AUTH_DOMAIN" ] && [ -n "$JICOFO_AUTH_USER" ] && [ -n "$JICOFO_AUTH_PASSWORD" ]; then
OLDCONFIG="true"
fi
fi
# debconf hostname question
. /usr/share/debconf/confmodule
# try to get host from jitsi-videobridge
db_get jitsi-videobridge/jvb-hostname
if [ -z "$RET" ] ; then
# server hostname
db_set jitsi-videobridge/jvb-hostname "localhost"
db_input critical jitsi-videobridge/jvb-hostname || true
db_go
fi
JVB_HOSTNAME=$(echo "$RET" | xargs echo -n)
# generate config on new install
if [ "$OLDCONFIG" = "false" ] || [ "$JVB_HOSTNAME" != "$JICOFO_HOSTNAME" ]; then
JICOFO_HOSTNAME="$JVB_HOSTNAME"
JICOFO_AUTH_DOMAIN="auth.$JICOFO_HOSTNAME"
JICOFO_AUTH_USER="focus"
db_get jicofo/jicofo-authpassword
if [ -z "$RET" ] ; then
RET=`cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 16`
fi
JICOFO_AUTH_PASSWORD="$RET"
# This re-creates $CONFIG. If previous values existed, they will be set in jicofo.conf below.
echo '# adds java system props that are passed to jicofo (default are for home and logging config file)' > $CONFIG
echo "JAVA_SYS_PROPS=\"-Dnet.java.sip.communicator.SC_HOME_DIR_LOCATION=/etc/jitsi\
-Dnet.java.sip.communicator.SC_HOME_DIR_NAME=jicofo\
-Dnet.java.sip.communicator.SC_LOG_DIR_LOCATION=/var/log/jitsi\
-Djava.util.logging.config.file=/etc/jitsi/jicofo/logging.properties\"" >> $CONFIG
fi
# Store values, so we can use it if we are configuring other packages on the same machine
# we are currently doing this on every upgrade in order to be able to successfully upgrade
# from old packages, otherwise we can do it only on new configuration or reconfiguration
db_set jicofo/jicofo-authpassword "$JICOFO_AUTH_PASSWORD"
# and we're done with debconf
db_stop
OLD_JITSI_CONFIG="/etc/jitsi/jicofo/sip-communicator.properties"
HOCON_CONFIG="/etc/jitsi/jicofo/jicofo.conf"
if [ ! -f $HOCON_CONFIG ]; then
echo "Generating an empty jicofo.conf file"
echo "# Jicofo HOCON configuration. See reference.conf in /usr/share/jicofo/jicofo.jar for
#available options, syntax, and default values.
jicofo {
}" >> $HOCON_CONFIG
fi
# Make sure the client-proxy is set correctly.
hocon -f $HOCON_CONFIG set jicofo.xmpp.client.client-proxy "\"focus.$JVB_HOSTNAME\""
# Set the client XMPP config in jicofo.conf. The values were either read from an existing
# /etc/jitsi/jicofo/config or generated above.
if ! hocon -f $HOCON_CONFIG get "jicofo.xmpp.client.xmpp-domain" > /dev/null 2>&1 ;then
hocon -f $HOCON_CONFIG set "jicofo.xmpp.client.xmpp-domain" "\"$JICOFO_HOSTNAME\""
fi
if ! hocon -f $HOCON_CONFIG get "jicofo.xmpp.client.domain" > /dev/null 2>&1 ;then
hocon -f $HOCON_CONFIG set "jicofo.xmpp.client.domain" "\"$JICOFO_AUTH_DOMAIN\""
fi
if ! hocon -f $HOCON_CONFIG get "jicofo.xmpp.client.username" > /dev/null 2>&1 ;then
hocon -f $HOCON_CONFIG set "jicofo.xmpp.client.username" "\"$JICOFO_AUTH_USER\""
fi
if ! hocon -f $HOCON_CONFIG get "jicofo.xmpp.client.password" > /dev/null 2>&1 ;then
hocon -f $HOCON_CONFIG set "jicofo.xmpp.client.password" "\"$JICOFO_AUTH_PASSWORD\""
fi
# Add the JvbBrewery config if missing
if ! grep -sq "org.jitsi.jicofo.BRIDGE_MUC" "$OLD_JITSI_CONFIG" ;then
if ! hocon -f $HOCON_CONFIG get "jicofo.bridge.brewery-jid" > /dev/null 2>&1 ; then
hocon -f $HOCON_CONFIG set "jicofo.bridge.brewery-jid" \"JvbBrewery@internal.auth.${JVB_HOSTNAME}\"
fi
fi
# Mark the jibri domain as trusted unless the trusted-domains have been set.
if [[ $(hocon -j -f $HOCON_CONFIG get "jicofo.xmpp.trusted-domains" 2> /dev/null | jq -r "type") != "array" ]] ;then
hocon -f $HOCON_CONFIG set "jicofo.xmpp.trusted-domains" "[ \"recorder.$JVB_HOSTNAME\" ]"
fi
# we don't want to start the daemon as root
if ! getent group jitsi > /dev/null ; then
groupadd jitsi
fi
if ! getent passwd jicofo > /dev/null ; then
useradd -r -g jitsi --shell /bin/bash --create-home -d /usr/share/jicofo jicofo
fi
if ! groups jicofo | grep '\bjitsi\b' > /dev/null ; then
usermod -g jitsi jicofo
fi
mkdir -p /usr/share/jicofo
# we claim the home folder of jicofo in case it is owned by someone else
OWNER=$(stat -c '%U' /usr/share/jicofo)
GROUP=$(stat -c '%G' /usr/share/jicofo)
if ! dpkg-statoverride --list /usr/share/jicofo/* >/dev/null && [ "$OWNER:$GROUP" != "jicofo:jitsi" ]; then
chown -R jicofo:jitsi /usr/share/jicofo
OWNER=jicofo
GROUP=jitsi
fi
CONFIG_DIR=$(dirname $CONFIG)
if ! dpkg-statoverride --list "$CONFIG_DIR" >/dev/null; then
chown -R jicofo:jitsi "$CONFIG_DIR"
chmod 750 "$CONFIG_DIR"
fi
# die logz
if [ ! -d /var/log/jitsi ]; then
mkdir -p /var/log/jitsi
chown $OWNER:$GROUP /var/log/jitsi
chmod 770 /var/log/jitsi
fi
ls /var/log/jitsi/jicofo* 1>/dev/null 2>&1 && chown -f -R $OWNER:$GROUP /var/log/jitsi/jicofo*
ls /var/log/jitsi/jicofo* 1>/dev/null 2>&1 && chmod -f -R 640 /var/log/jitsi/jicofo*
# ensure focus is not running - it will be started at the end
if invoke-rc.d jicofo status >/dev/null 2>&1 ; then
invoke-rc.d jicofo stop || true
fi
# clean up old jicofo group
if getent group jicofo > /dev/null; then
groupdel jicofo
fi
;;
abort-upgrade|abort-remove|abort-deconfigure)
;;
*)
echo "postinst called with unknown argument \`$1'" >&2
exit 1
;;
esac
# dh_installdeb will replace this with shell code automatically
# generated by other debhelper scripts.
#DEBHELPER#
exit 0

57
debian/postrm vendored Normal file
View File

@@ -0,0 +1,57 @@
#!/bin/bash
# postrm script for jicofo
set -e
# Load debconf
. /usr/share/debconf/confmodule
case "$1" in
purge)
# Clear the debconf variable
db_purge
# clear user and group
# ensure nothing is running as jicofo user
if ps -u jicofo h; then
`ps -u jicofo -o pid h | xargs kill`
fi
# and then remove the user
if getent passwd jicofo > /dev/null ; then
deluser jicofo
fi
# clear logs
if [ -f "/var/log/jitsi/jicofo.log" ]; then
rm -f "/var/log/jitsi/jicofo.log"
fi
rm -rf /usr/share/jicofo/
rm -f /etc/jitsi/jicofo/config
rm -f /etc/jitsi/jicofo/sip-communicator.properties
rm -f /etc/jitsi/jicofo/logging.properties
rm -f /etc/jitsi/jicofo/jicofo.conf
;;
remove)
;;
upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
;;
*)
echo "postrm called with unknown argument \`$1'" >&2
exit 1
;;
esac
# dh_installdeb will replace this with shell code automatically
# generated by other debhelper scripts.
#DEBHELPER#
db_stop
exit 0

14
debian/preinst vendored Normal file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
# preinst script for jicofo
set -e
echo "Running preinst $@"
if [ "$1" == "upgrade" ] ;then
if [ -d /etc/logrotate.d/jicofo ] ;then
# An earlier version of the package incorrectly installed a directory in logrotate.d. If that's the case
# remove it, so the file can be properly installed.
echo "/etc/logrotate.d/jicofo is a directory, removing."
rm -rf /etc/logrotate.d/jicofo
fi
fi

30
debian/rules vendored Executable file
View File

@@ -0,0 +1,30 @@
#!/usr/bin/make -f
# Uncomment this to turn on verbose mode.
#export DH_VERBOSE=1
#export DEB_VERBOSE_ALL=true
%:
dh $@
override_dh_auto_clean:
mvn -Dmaven.repo.local=${HOME}/.m2/repository clean
override_dh_auto_configure:
# do nothing
override_dh_auto_test:
# do nothing
override_dh_auto_build:
mvn -Dmaven.repo.local=${HOME}/.m2/repository -DskipTests -Dassembly.skipAssembly=true package
mvn -Dmaven.repo.local=${HOME}/.m2/repository dependency:copy-dependencies -DincludeScope=runtime
override_dh_install:
mkdir -p debian/tmp/jicofo
cp resources/config/jicofo-logrotate.d debian/tmp/jicofo/jicofo
cp jicofo/target/jicofo*.jar debian/tmp/jicofo.jar
dh_install
override_dh_auto_install:
# do nothing

2
debian/source/format vendored Normal file
View File

@@ -0,0 +1,2 @@
3.0 (quilt)

9
debian/templates vendored Normal file
View File

@@ -0,0 +1,9 @@
Template: jitsi-videobridge/jvb-hostname
Type: string
_Description: Hostname:
The jicofo package needs the DNS hostname of your instance.
Template: jicofo/jicofo-authpassword
Type: password
_Description: Jicofo user password:
The secret used to connect to xmpp server as jicofo user.

61
doc/conference-request.md Normal file
View File

@@ -0,0 +1,61 @@
#
Conference-request refers to an initial exchange between a client and jicofo, which happens before the client joins
the conference MUC. Its purposes are to:
1. Notify jicofo that it should join a certain MUC.
2. Have the client block until jicofo has created/joined the MUC (in case the client is not allowed to create a MUC).
3. Allow the first client in a conference to specify certain conference-wide options to jicofo.
4. Allow jicofo to redirect the client to a visitor node.
5. Allow external authentication with an XMPP domain or [Shibboleth](./shibboleth.md)
# Format
The request and response share a similar format.
## HTTP format
For HTTP, the conference request is encoded as JSON and POSTed to /conference-request/v1. Note that authentication is
not supported over HTTP due to the lack of an associated JID.
The reason HTTP support was added is to prevent unnecessary connections to prosody when visitor redirection is used
(i.e. a visitor sends an HTTP request, then logs into the XMPP server specified in the response)
### Example request
```js
{
"room": "conferenceName@example.com", // The JID of the conference
"properties": {
"rtcstatsEnabled": true
}
}
```
### Example response
```js
{
"ready": true, // Jicofo has joined the room and the client can proceed.
"focusJid": "jicofo@v1.example.com", // Indicates jicofo's JID, which can be trusted by the client
"vnode": "v1", // Redirect to a visitor node with id "v1"
"properties": {
"sipGatewayEnabled": true // Signal jigasi support
}
}
```
## XMPP format
For XMPP, the conference request is encoded in a
[ConferenceIq](https://github.com/jitsi/jitsi-xmpp-extensions/blob/master/src/main/java/org/jitsi/xmpp/extensions/jitsimeet/ConferenceIq.java).
### Example request
```xml
<iq to='client@example.com' from='jicofo@example.com' type='set'>
<conference xmlns='http://jitsi.org/protocol/focus' room='conferenceName@example.com'>
<property name='rtcstatsEnabled' value='true'/>
</conference>
</iq>
```
### Example response
```xml
<iq to='jicofo@example.com' from='client@example.com' type='set'>
<conference xmlns='http://jitsi.org/protocol/focus' ready='true' focusJid='jicofo@v1.example.com' vnode='v1'>
<property name='sipGatewayEnabled' value='true'/>"
</conference>
</iq>
```

BIN
doc/diagram.key Executable file

Binary file not shown.

BIN
doc/diagram.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

29
doc/health-checks.md Normal file
View File

@@ -0,0 +1,29 @@
The REST API allows querying Jicofo whether it deems itself in a healthy state (i.e. the application is operational and the functionality it provides should perform as expected) at the time of the query or not. Videobridge will run an internal test in response to the request to determine its current health status.
The default port for the REST API is `8888` to have Videobridge and Jicofo running on the same machine with their defaults without them clashing.
<table>
<tr>
<th>HTTP Method</th>
<th>Resource</th>
<th>Response</th>
</tr>
<tr>
<td><code>GET</code></td>
<td><code>/about/health</code></td>
<td>
<ul>
<li><code>200 OK</code> if Jicofo ran its internal test to determine its current health status and the test completed successfully. The response is with <code>Content-Type: application/json</code> but no JSON value is provided i.e. <code>Content-Length: 0</code>.</li>
<li><code>503 Service Unavailable</code> if Jicofo is currently unable to run its health check. The reasons for the failure include (but are not limited to):
<ul>
<li>The REST API server has been brought up but the core conference-related functionality of Jicofo is still initializing. Videbridge could be considered healthy if the client chooses to interpret the HTTP status in such a fashion since the application may pass though the described transitional state under normal operation.</li>
<li>The core conference-related functionality of Jicofo has commenced a shutdown procedure but the REST API server has not shut down yet. Videbridge could be considered healthy if the client chooses to interpret the HTTP status in such a fashion since the application may pass though the described transitional state under normal operation.</li>
<li>The core conference-related functionality of Jicofo is in an unhealthy state and the REST API server is operating as expected.</li>
</ul>
</li>
<li><code>500 Internal Server Error</code> or any other <code>5xx</code> status code if Jicofo ran its health check and determined that it is in an unhealthy state.</li>
<li>No response (within a client-defined time frame) if Jicofo has entered a deadlock, an infinite loop, or a similar condition which prevents the completion of the health check. The server is to be considered in an unhealthy state by the client.</li>
</ul>
</td>
</tr>
</table>

1
doc/reservation.md Normal file
View File

@@ -0,0 +1 @@
This document has been moved [here](https://jitsi.github.io/handbook/docs/devops-guide/reservation).

5
doc/shibboleth.md Normal file
View File

@@ -0,0 +1,5 @@
# Shibboleth authentication
Shibboleth authentication is no longer supported.
If you'd still like to use it you can use this project: https://github.com/Renater/Jitsi-SAML2JWT

412
jicofo-common/pom.xml Normal file
View File

@@ -0,0 +1,412 @@
<?xml version="1.0"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.jitsi</groupId>
<artifactId>jicofo-parent</artifactId>
<version>1.1-SNAPSHOT</version>
</parent>
<artifactId>jicofo-common</artifactId>
<version>1.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>jicofo-common</name>
<description>JItsi COnference FOcus is a server side focus component used in Jitsi Meet conferences.</description>
<dependencies>
<dependency>
<groupId>org.igniterealtime.smack</groupId>
<artifactId>smack-tcp</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/org.jetbrains/annotations -->
<dependency>
<groupId>org.jetbrains</groupId>
<artifactId>annotations</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<scope>compile</scope>
</dependency>
<!-- org.jitsi -->
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>jicoco-config</artifactId>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>jicoco-metrics</artifactId>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>jicoco-tracing</artifactId>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>jitsi-metaconfig</artifactId>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>jitsi-utils</artifactId>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>jitsi-xmpp-extensions</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-jdk14</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-kotlin</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/com.github.spotbugs/spotbugs-annotations -->
<dependency>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-annotations</artifactId>
</dependency>
<!-- runtime -->
<dependency>
<groupId>io.sentry</groupId>
<artifactId>sentry</artifactId>
<scope>runtime</scope>
</dependency>
<!-- test -->
<dependency>
<groupId>org.xmlunit</groupId>
<artifactId>xmlunit-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.kotest</groupId>
<artifactId>kotest-runner-junit5-jvm</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.kotest</groupId>
<artifactId>kotest-assertions-core-jvm</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.kotest</groupId>
<artifactId>kotest-property-jvm</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-launcher</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>jicoco-test-kotlin</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.mockk</groupId>
<artifactId>mockk-jvm</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<executions>
<execution>
<id>compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/main/kotlin</sourceDir>
<sourceDir>${project.basedir}/src/main/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
<execution>
<id>test-compile</id>
<phase>test-compile</phase>
<goals>
<goal>test-compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/test/kotlin</sourceDir>
<sourceDir>${project.basedir}/src/test/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
</executions>
<configuration>
<jvmTarget>17</jvmTarget>
<args>
<arg>-Xallow-result-return-type</arg>
</args>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<executions>
<!-- Replacing default-compile as it is treated specially by maven -->
<execution>
<id>default-compile</id>
<phase>none</phase>
</execution>
<!-- Replacing default-testCompile as it is treated specially by maven -->
<execution>
<id>default-testCompile</id>
<phase>none</phase>
</execution>
<execution>
<id>java-compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
</execution>
<execution>
<id>java-test-compile</id>
<phase>test-compile</phase>
<goals>
<goal>testCompile</goal>
</goals>
</execution>
</executions>
<configuration>
<source>17</source>
<target>17</target>
<compilerArgs>
<arg>-Xlint:all,-serial</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
<configuration>
<systemPropertyVariables>
<java.util.logging.config.file>${project.parent.basedir}/lib/logging.properties</java.util.logging.config.file>
<kotest.framework.classpath.scanning.autoscan.disable>true</kotest.framework.classpath.scanning.autoscan.disable>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<!-- Instructs the plugin to add the current package version in the jar manifest
So we can access it runtime with: .class.getPackage().getImplementationVersion()
-->
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<archive>
<manifest>
<addDefaultImplementationEntries>true</addDefaultImplementationEntries>
</manifest>
</archive>
</configuration>
<executions>
<execution>
<!-- Publish test utilities (mocks, helpers) for use in the other jicofo modules. -->
<id>test-jar</id>
<goals>
<goal>test-jar</goal>
</goals>
<configuration>
<excludes>
<exclude>**/*Test.class</exclude>
<exclude>**/*Test$*.class</exclude>
<exclude>**/JicofoCommonKotestProjectConfig.class</exclude>
</excludes>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>3.1.2</version>
<configuration>
<configLocation>checkstyle.xml</configLocation>
</configuration>
<dependencies>
<dependency>
<groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId>
<version>9.1</version>
</dependency>
</dependencies>
<executions>
<execution>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
<version>${spotbugs-maven-plugin.version}</version>
<configuration>
<excludeFilterFile>spotbugs-exclude.xml</excludeFilterFile>
<includeTests>true</includeTests>
</configuration>
<executions>
<execution>
<phase>verify</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs</artifactId>
<version>${spotbugs.version}</version>
</dependency>
</dependencies>
</plugin>
<!-- ktlint: check runs in the verify phase; auto-format with "mvn exec:exec@ktlint-format" -->
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.6.3</version>
<dependencies>
<dependency>
<groupId>com.pinterest.ktlint</groupId>
<artifactId>ktlint-cli</artifactId>
<version>${ktlint.version}</version>
<classifier>all</classifier>
</dependency>
</dependencies>
<executions>
<execution>
<id>ktlint-check</id>
<phase>verify</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<executable>java</executable>
<includePluginDependencies>true</includePluginDependencies>
<arguments>
<argument>-classpath</argument>
<classpath>
<dependency>com.pinterest.ktlint:ktlint-cli</dependency>
</classpath>
<argument>com.pinterest.ktlint.Main</argument>
<argument>--relative</argument>
<argument>src/main/kotlin/**/*.kt</argument>
<argument>src/test/kotlin/**/*.kt</argument>
</arguments>
</configuration>
</execution>
<execution>
<id>ktlint-format</id>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<executable>java</executable>
<includePluginDependencies>true</includePluginDependencies>
<arguments>
<argument>-classpath</argument>
<classpath>
<dependency>com.pinterest.ktlint:ktlint-cli</dependency>
</classpath>
<argument>com.pinterest.ktlint.Main</argument>
<argument>--format</argument>
<argument>--relative</argument>
<argument>src/main/kotlin/**/*.kt</argument>
<argument>src/test/kotlin/**/*.kt</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>jitsi-maven-repository-releases</id>
<layout>default</layout>
<name>Jitsi Maven Repository (Releases)</name>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>false</enabled>
</snapshots>
<url>https://github.com/jitsi/jitsi-maven-repository/raw/master/releases/</url>
</repository>
<repository>
<id>jitsi-maven-repository-snapshots</id>
<layout>default</layout>
<name>Jitsi Maven Repository (Snapshots)</name>
<releases>
<enabled>false</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
<url>https://github.com/jitsi/jitsi-maven-repository/raw/master/snapshots/</url>
</repository>
</repositories>
<profiles>
<profile>
<id>coverage</id>
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<!-- attached to Maven test phase -->
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>

View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<FindBugsFilter>
<Match>
<And>
<Source name="~.*\.*.kt" />
<Or>
<Bug pattern="REC_CATCH_EXCEPTION"/>
<!-- This seems to get triggered by certain kotlin constructs though they look safe -->
<Bug pattern="BC_BAD_CAST_TO_ABSTRACT_COLLECTION"/>
<!-- False positive -->
<Bug pattern="BC_VACUOUS_INSTANCEOF"/>
<!-- Serialization -->
<Bug pattern="SE_BAD_FIELD"/>
<!-- Serialization -->
<Bug pattern="SE_BAD_FIELD_STORE"/>
<!-- False positives with lazy vals -->
<Bug pattern="NP_NULL_ON_SOME_PATH"/>
<!-- False positives with kotlin 1.5 -->
<Bug pattern="NP_PARAMETER_MUST_BE_NONNULL_BUT_MARKED_AS_NULLABLE"/>
<!-- False positive -->
<Bug pattern="NP_NONNULL_RETURN_VIOLATION"/>
<!-- False positive with kotlin's "when" -->
<Bug pattern="SF_SWITCH_NO_DEFAULT"/>
<!-- False positives with ?. chains. -->
<Bug pattern="RCN_REDUNDANT_NULLCHECK_OF_NONNULL_VALUE"/>
<And>
<!-- Spotbugs doesn't recognize that a value can't be null, and using @SuppressFBWarnings doesn't work. -->
<Bug pattern="NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE"/>
<Source name="MuteIqHandler.kt"/>
</And>
<!-- False positives with kotlin 1.6. -->
<Bug pattern="NP_NONNULL_PARAM_VIOLATION"/>
<Bug pattern="SA_LOCAL_SELF_ASSIGNMENT"/>
<!-- False positive with kotlin lazy initialization. -->
<Bug pattern="BC_IMPOSSIBLE_INSTANCEOF"/>
</Or>
</And>
</Match>
<Match>
<Or>
<!-- Exposure of internal representation is not a concern. -->
<Bug pattern="EI_EXPOSE_REP"/>
<Bug pattern="EI_EXPOSE_REP2"/>
<!-- False positives in java code for an unknown reason. -->
<Bug pattern="NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE"/>
<!-- We throw from constructors on illegal arguments, i.e. programming errors. -->
<Bug pattern="CT_CONSTRUCTOR_THROW"/>
</Or>
</Match>
<Match>
<!-- Tests have a valid reason to ignore return values (expecting exceptions, etc) and to force GC (testing
weak-reference eviction). -->
<And>
<Or>
<Bug pattern="RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT"/>
<Bug pattern="DM_GC"/>
</Or>
<Or>
<Source name="~.*\.*Test.kt" />
<Source name="~.*\.*Test.java" />
</Or>
</And>
</Match>
</FindBugsFilter>

View File

@@ -0,0 +1,33 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.impl.protocol.xmpp.log;
import java.util.logging.*;
/**
* Excludes logs coming from {@link PacketDebugger}.
*/
public class ExcludeXmppPackets implements Filter
{
/**
* {@inheritDoc}
*/
@Override
public boolean isLoggable(LogRecord record)
{
return !record.getLoggerName().equals(PacketDebugger.class.getName());
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.impl.protocol.xmpp.log;
import java.util.logging.*;
/**
* Allows only logs coming from {@link PacketDebugger}.
*/
public class IncludeXmppPackets implements Filter
{
/**
* {@inheritDoc}
*/
@Override
public boolean isLoggable(LogRecord record)
{
return record.getLoggerName().equals(PacketDebugger.class.getName());
}
}

View File

@@ -0,0 +1,104 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.impl.protocol.xmpp.log;
import edu.umd.cs.findbugs.annotations.*;
import org.jitsi.utils.logging2.*;
import org.jitsi.xmpp.util.RedactColibri;
import org.jivesoftware.smack.*;
import org.jivesoftware.smack.debugger.*;
import org.jivesoftware.smack.packet.*;
import java.lang.*;
/**
* Implements {@link SmackDebugger} in order to get info about XMPP traffic.
*/
public class PacketDebugger
extends AbstractDebugger
{
/**
* The logger used by this class.
*/
private final static Logger logger = new LoggerImpl(PacketDebugger.class.getName());
/**
* Whether XMPP logging is enabled. We don't want to insert a debugger into Smack when it's not going to actually
* log anything.
*/
public static boolean isEnabled()
{
return logger.isDebugEnabled();
}
/**
* An ID to log to identify the connection.
*/
@NonNull
private final String id;
/**
* Creates new {@link PacketDebugger}
* {@inheritDoc}
*/
@SuppressFBWarnings("ST_WRITE_TO_STATIC_FROM_INSTANCE_METHOD")
public PacketDebugger(XMPPConnection connection, @NonNull String id)
{
super(connection);
this.id = id;
// Change the static value only if an instance is created.
AbstractDebugger.printInterpreted = true;
}
@Override
public void onIncomingStreamElement(TopLevelStreamElement streamElement)
{
logger.debug(() -> "RCV PKT (" + id + "): " + redact(streamElement));
}
@Override
public void onOutgoingStreamElement(TopLevelStreamElement streamElement)
{
logger.debug(() -> "SENT PKT (" + id + "): " + redact(streamElement));
}
private static String redact(TopLevelStreamElement streamElement)
{
return RedactColibri.Companion.redactToken(streamElement.toXML().toString());
}
/**
* {@inheritDoc}
*/
@Override
protected void log(String logMessage)
{
if (logger.isDebugEnabled() && !logMessage.startsWith("RECV (") && !logMessage.startsWith("SENT ("))
{
logger.debug(logMessage);
}
}
/**
* {@inheritDoc}
*/
@Override
protected void log(String logMessage, Throwable throwable)
{
logger.warn("Smack: " + logMessage, throwable);
}
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.impl.protocol.xmpp.log;
import java.io.*;
import java.text.*;
import java.time.*;
import java.util.*;
import java.util.logging.*;
/**
* Logs XMPP traffic to a file if configured in the logging.properties file.
*/
public class XmppPacketsFileHandler extends FileHandler
{
/**
* A constructor.
*/
public XmppPacketsFileHandler()
throws IOException, SecurityException
{
setFilter(new IncludeXmppPackets());
setFormatter(new Formatter());
setLevel(Level.ALL);
}
/**
* The formatter used by {@link XmppPacketsFileHandler}.
*/
static class Formatter extends java.util.logging.Formatter
{
final SimpleDateFormat stampFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
@Override
public String format(LogRecord record)
{
Date when = Date.from(Instant.ofEpochMilli(record.getMillis()));
String msg = record.getMessage();
return String.format("%s: %s%n", stampFormat.format(when), msg);
}
}
}

View File

@@ -0,0 +1,425 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp;
import org.jetbrains.annotations.*;
import org.jitsi.jicofo.*;
import org.jitsi.jicofo.xmpp.muc.*;
import org.jitsi.utils.logging2.*;
import org.jivesoftware.smack.*;
import org.jivesoftware.smack.packet.*;
import org.jivesoftware.smack.tcp.*;
import org.jxmpp.jid.*;
import java.lang.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.*;
/**
* <tt>BaseBrewery</tt> manages the pool of service instances which
* exist in the current session. Does that by joining "brewery" room where
* instances connect to and publish their status in MUC presence.
* <tt>PacketExtension</tt> is the packet extension that will be used
* from that service to publish its presence.
*
* @author Pawel Domas
* @author Damian Minkov
*/
public abstract class BaseBrewery<T extends ExtensionElement>
implements XmppProvider.Listener
{
/**
* The logger
*/
private final Logger logger;
private final ChatRoomListener chatRoomListener = new ChatRoomListenerImpl();
/**
* The MUC JID of the room which this detector will join.
*/
private final EntityBareJid breweryJid;
private final XmppProvider xmppProvider;
/**
* The <tt>ChatRoom</tt> instance for the brewery.
*/
private ChatRoom chatRoom;
/**
* The list of all currently known instances.
*/
protected final List<BrewInstance> instances = new CopyOnWriteArrayList<>();
/**
* The presence extension element name.
*/
private final String extensionElementName;
/**
* The presence extension namespace.
*/
private final String extensionNamespace;
/**
* Reconnect timer. Used to stop the conference if XMPP connection is not restored in a given time.
*/
private Future<?> reconnectTimeout;
/**
* Creates new instance of <tt>BaseBrewery</tt>
* @param breweryJid the MUC JID of the room which this detector will join,
* e.g. {@code roomName@muc-servicename.jabserver.com}.
* @param presenceExtensionElementName the element name of the extension
* which this brewery will look for.
* @param presenceExtensionNamespace the namespace of the extension
* which this brewery will look for.
*/
public BaseBrewery(
@NotNull XmppProvider xmppProvider,
@NotNull EntityBareJid breweryJid,
String presenceExtensionElementName,
String presenceExtensionNamespace,
Logger parentLogger)
{
this.logger = parentLogger.createChildLogger(getClass().getName());
this.xmppProvider = xmppProvider;
this.breweryJid = breweryJid;
logger.addContext("brewery", breweryJid.getLocalpartOrThrow().toString());
this.extensionElementName = presenceExtensionElementName;
this.extensionNamespace = presenceExtensionNamespace;
logger.info("Initialized with JID=" + breweryJid);
}
/**
* Checks whether or not there are any service instances connected.
* @return <tt>true</tt> if there are any instances currently
* connected to the brewery room or <tt>false</tt> otherwise.
*/
public boolean isAnyInstanceConnected()
{
return instances.size() > 0;
}
/**
* Initializes this instance.
*/
public void init()
{
xmppProvider.addListener(this);
maybeStart();
}
/**
* Starts the whole thing if we have XMPP connection up and running.
*/
private void maybeStart()
{
if (chatRoom == null && xmppProvider.getRegistered())
{
start();
}
}
/**
* Stops and releases allocated resources.
*/
public void shutdown()
{
xmppProvider.removeListener(this);
stop();
}
/**
* {@inheritDoc}
*/
@Override
synchronized public void registrationChanged(boolean registered)
{
if (registered)
{
if (this.reconnectTimeout != null)
{
this.reconnectTimeout.cancel(true);
this.reconnectTimeout = null;
}
AbstractXMPPConnection connection = xmppProvider.getXmppConnection();
if (connection instanceof XMPPTCPConnection && !((XMPPTCPConnection) connection).streamWasResumed())
{
// We are not resuming the stream, so we need to stop and start clean
stop();
}
maybeStart();
}
else
{
reconnectTimeout = TaskPools.getScheduledPool().schedule(
this::stop,
XmppConfig.service.getReplyTimeout().toMillis(),
TimeUnit.MILLISECONDS);
}
}
@Override
public void componentsChanged(@NotNull Set<XmppProvider.Component> components)
{
}
/**
* Starts the detector by joining it to the brewery room.
*/
private void start()
{
try
{
chatRoom = xmppProvider.createRoom(breweryJid);
chatRoom.addListener(chatRoomListener);
chatRoom.join();
logger.info("Joined the room.");
}
catch (InterruptedException | SmackException | XMPPException | XmppProvider.RoomExistsException e)
{
logger.error("Failed to create room.", e);
// cleanup on failure so we can retry
if (chatRoom != null)
{
chatRoom.removeListener(chatRoomListener);
chatRoom.leave();
chatRoom = null;
}
// The XMPP connection is already authenticated at this point, so registrationChanged() won't
// fire again. Schedule an explicit retry so a transient failure (e.g. the server not responding
// to a stale MUC leave during reconnect) doesn't permanently break the brewery.
TaskPools.getScheduledPool().schedule(
this::maybeStart,
XmppConfig.service.getReplyTimeout().toMillis(),
TimeUnit.MILLISECONDS);
}
}
/**
* Stops detector by removing listeners and leaving brewery room.
*/
private void stop()
{
try
{
if (chatRoom != null)
{
chatRoom.removeListener(chatRoomListener);
chatRoom.leave();
logger.info("Left the room.");
}
}
finally
{
// even if leave fails we want to cleanup, so we can retry
chatRoom = null;
// Clean up the list of service instances
instances.forEach(this::removeInstance);
}
}
private class ChatRoomListenerImpl extends DefaultChatRoomListener
{
@Override
public void memberJoined(@NotNull ChatRoomMember member)
{
// Process idle or busy
processMemberPresence(member);
}
@Override
public void memberLeft(@NotNull ChatRoomMember member)
{
removeInstanceForMember(member);
}
@Override
public void memberKicked(@NotNull ChatRoomMember member)
{
removeInstanceForMember(member);
}
@Override
public void memberPresenceChanged(@NotNull ChatRoomMember member)
{
// Process idle or busy
processMemberPresence(member);
}
private void removeInstanceForMember(@NotNull ChatRoomMember member)
{
// Process offline status
BrewInstance instance = find(member.getOccupantJid());
if (instance != null)
{
removeInstance(instance);
}
}
}
/**
* Process chat room member status changed. Extract the appropriate
* presence extension and use it to process it further.
* @param member the chat member to process
*
* Temporarily exposed for testing.
*/
public void processMemberPresence(@NotNull ChatRoomMember member)
{
Presence presence = member.getPresence();
if (presence == null)
{
return;
}
@SuppressWarnings("unchecked")
T ext = (T) presence.getExtensionElement(extensionElementName, extensionNamespace);
// if the extension is missing skip processing
if (ext == null)
{
return;
}
processInstanceStatusChanged(member.getOccupantJid(), ext);
}
/**
* Process a MUC member status presence changed. Use the presence extension
* to notify implementors for the change. Stores instance if we do not
* have it cached locally, otherwise just update the new status.
* @param jid the occupant (MUC) JID of the member.
* @param extension the presence extension representing this brewing
* instance status.
*/
protected void processInstanceStatusChanged(@NotNull EntityFullJid jid, @NotNull T extension)
{
BrewInstance instance = find(jid);
if (instance == null)
{
instance = new BrewInstance(jid, extension);
addInstance(instance);
}
else
{
instance.status = extension;
}
logger.debug(() -> "New presence from " + jid + ": " + extension.toXML());
onInstanceStatusChanged(jid, extension);
}
public int getInstanceCount()
{
return instances.size();
}
public int getInstanceCount(@NotNull Predicate<? super BrewInstance> filter)
{
return (int) instances.stream().filter(filter) .count();
}
/**
* Notified for MUC service status update by providing its presence
* extension.
*
* @param jid the brewing instance muc address
* @param status the updated status for that instance
*/
abstract protected void onInstanceStatusChanged(@NotNull EntityFullJid jid, @NotNull T status);
/**
* Finds instance by muc address.
*
* @param jid the address to use while searching for muc instance.
* @return the brewing instance or null if not found.
*/
private BrewInstance find(Jid jid)
{
return instances.stream()
.filter(i -> i.jid.equals(jid))
.findFirst()
.orElse(null);
}
private void addInstance(BrewInstance i)
{
instances.add(i);
logger.info("Added brewery instance: " + i.jid);
}
/**
* Removes an instance from the cache and notifies that instance is going
* offline.
*
* @param i the brewing instance
*/
private void removeInstance(@NotNull BrewInstance i)
{
instances.remove(i);
logger.info("Removed brewery instance: " + i.jid);
notifyInstanceOffline(i.jid);
}
/**
* Notifies that a brewing instance is going offline.
* @param jid the instance muc address
*/
abstract protected void notifyInstanceOffline(@NotNull Jid jid);
/**
* Internal structure for storing information about brewing instances.
*/
@edu.umd.cs.findbugs.annotations.SuppressFBWarnings("URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD")
public class BrewInstance
{
/**
* Eg. "room@muc.server.net/nick"
*/
@NotNull
public final EntityFullJid jid;
/**
* One of {@link ExtensionElement}
*/
@NotNull
public T status;
BrewInstance(@NotNull EntityFullJid jid, @NotNull T status)
{
this.jid = jid;
this.status = status;
}
}
}

View File

@@ -0,0 +1,52 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2020 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo
import org.jitsi.config.JitsiConfig.Companion.legacyConfig
import org.jitsi.config.JitsiConfig.Companion.newConfig
import org.jitsi.metaconfig.config
import org.jitsi.metaconfig.optionalconfig
import java.time.Duration
class JicofoConfig private constructor() {
val localRegion: String? by optionalconfig {
"org.jitsi.jicofo.BridgeSelector.LOCAL_REGION".from(legacyConfig)
"$BASE.local-region".from(newConfig)
}
val enableSctp: Boolean by config {
"$BASE.sctp.enabled".from(newConfig)
}
fun enableSctp() = enableSctp
fun localRegion() = localRegion
// TODO this logically should be in VisitorsConfig, but that's in jicofo and this is needed by ChatRoom
// in jicofo-common
val vnodeJoinLatencyInterval: Duration by config {
"jicofo.visitors.vnode-join-latency-interval".from(newConfig)
}
companion object {
const val BASE = "jicofo"
@JvmField
val config = JicofoConfig()
}
}

View File

@@ -0,0 +1,24 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2025-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo
enum class MediaType {
AUDIO,
VIDEO,
DESKTOP
}

View File

@@ -0,0 +1,40 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2020 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo
import org.jitsi.config.JitsiConfig.Companion.newConfig
import org.jitsi.metaconfig.config
class OctoConfig {
val enabled: Boolean by config {
"jicofo.octo.enabled".from(newConfig)
}
val allowMixedVersions: Boolean by config {
"jicofo.octo.allow-mixed-versions".from(newConfig)
}
val sctpDatachannels: Boolean by config {
"jicofo.octo.sctp-datachannels".from(newConfig)
}
companion object {
@JvmField
val config = OctoConfig()
}
}

View File

@@ -0,0 +1,57 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2020 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings
import org.jitsi.utils.concurrent.CustomizableThreadFactory
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
@SuppressFBWarnings("NP_NONNULL_FIELD_NOT_INITIALIZED_IN_CONSTRUCTOR")
class TaskPools {
companion object {
private val defaultIoPool: ExecutorService =
Executors.newCachedThreadPool(CustomizableThreadFactory("Jicofo Global IO Pool", false))
@JvmStatic
var ioPool: ExecutorService = defaultIoPool
fun resetIoPool() {
ioPool = defaultIoPool
}
private val defaultScheduledPool: ScheduledExecutorService = Executors.newScheduledThreadPool(
3,
CustomizableThreadFactory("Jicofo Global Scheduled Pool", true)
)
@JvmStatic
var scheduledPool: ScheduledExecutorService = defaultScheduledPool
fun resetScheduledPool() {
scheduledPool = defaultScheduledPool
}
@JvmStatic
fun shutdown() {
ioPool.shutdown()
scheduledPool.shutdown()
}
}
}

View File

@@ -0,0 +1,183 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2020 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.codec
import org.jitsi.config.JitsiConfig
import org.jitsi.metaconfig.config
open class CodecConfig(protected val base: String, protected val name: String) {
protected open val enabled: Boolean by config {
"$base.enabled".from(JitsiConfig.newConfig)
}
protected open val pt: Int by config {
"$base.pt".from(JitsiConfig.newConfig)
}
fun enabled() = enabled && pt > 0
@Throws(IllegalStateException::class)
fun pt(): Int = if (enabled()) pt else throw IllegalStateException("$name is not enabled.")
open val enableRemb = false
}
open class RtxCodecConfig(base: String, name: String) : CodecConfig(base, name) {
protected open val rtxPt: Int by config {
"$base.rtx-pt".from(JitsiConfig.newConfig)
}
fun rtxEnabled(): Boolean = enabled() && rtxPt > 0
@Throws(IllegalStateException::class)
fun rtxPt(): Int = if (rtxEnabled()) rtxPt else throw IllegalStateException("RTX is not enabled for $name")
}
private class RtxCodecConfigWithLegacy(
legacyBase: String,
newBase: String,
name: String
) : RtxCodecConfig(newBase, name) {
override val enabled: Boolean by config {
"$legacyBase.ENABLE_$name".from(JitsiConfig.legacyConfig)
"$newBase.enabled".from(JitsiConfig.newConfig)
}
override val pt: Int by config {
"$legacyBase.${name}_PT".from(JitsiConfig.legacyConfig)
"$newBase.pt".from(JitsiConfig.newConfig)
}
override val rtxPt: Int by config {
"$legacyBase.${name}_RTX_PT".from(JitsiConfig.legacyConfig)
"$newBase.rtx-pt".from(JitsiConfig.newConfig)
}
override val enableRemb: Boolean by config {
"$newBase.enable-remb".from(JitsiConfig.newConfig)
}
}
class OpusConfig : CodecConfig("jicofo.codec.audio.opus", "opus") {
private val minptime: Int by config {
"$base.minptime".from(JitsiConfig.newConfig)
}
fun minptime() = minptime
private val useInbandFec: Boolean by config {
"$base.use-inband-fec".from(JitsiConfig.newConfig)
}
fun useInbandFec() = useInbandFec
@JvmField
val red = CodecConfig("$base.red", "red")
}
open class RtpExtensionConfig(base: String) {
open val enabled: Boolean by config {
"$base.enabled".from(JitsiConfig.newConfig)
}
fun enabled() = enabled
val id: Int by config {
"$base.id".from(JitsiConfig.newConfig)
}
fun id() = id
}
private class RtpExtensionConfigWithLegacy(
legacyEnabledName: String,
base: String
) : RtpExtensionConfig(base) {
override val enabled: Boolean by config {
legacyEnabledName.from(JitsiConfig.legacyConfig)
"$base.enabled".from(JitsiConfig.newConfig)
}
}
class Config {
@JvmField
val av1: RtxCodecConfig = RtxCodecConfigWithLegacy(LEGACY_BASE, "jicofo.codec.video.av1", "AV1")
@JvmField
val vp8: RtxCodecConfig = RtxCodecConfigWithLegacy(LEGACY_BASE, "jicofo.codec.video.vp8", "VP8")
@JvmField
val vp9: RtxCodecConfig = RtxCodecConfigWithLegacy(LEGACY_BASE, "jicofo.codec.video.vp9", "VP9")
@JvmField
val h264: RtxCodecConfig = RtxCodecConfigWithLegacy(LEGACY_BASE, "jicofo.codec.video.h264", "H264")
@JvmField
val opus = OpusConfig()
@JvmField
val telephoneEvent = CodecConfig("jicofo.codec.audio.telephone-event", "telephone-event")
@JvmField
val audioLevel = RtpExtensionConfig("jicofo.codec.rtp-extensions.audio-level")
@JvmField
val av1DependencyDescriptor: RtpExtensionConfig =
RtpExtensionConfig("jicofo.codec.rtp-extensions.av1-dependency-descriptor")
val videoLayersAllocation: RtpExtensionConfig =
RtpExtensionConfig("jicofo.codec.rtp-extensions.video-layers-allocation")
@JvmField
val tof: RtpExtensionConfig =
RtpExtensionConfigWithLegacy("$LEGACY_BASE.ENABLE_TOF", "jicofo.codec.rtp-extensions.tof")
@JvmField
val absSendTime: RtpExtensionConfig =
RtpExtensionConfigWithLegacy("$LEGACY_BASE.ENABLE_AST", "jicofo.codec.rtp-extensions.abs-send-time")
@JvmField
val rid: RtpExtensionConfig =
RtpExtensionConfigWithLegacy("$LEGACY_BASE.ENABLE_RID", "jicofo.codec.rtp-extensions.rid")
@JvmField
val tcc = RtpExtensionConfig("jicofo.codec.rtp-extensions.tcc")
@JvmField
val mid = RtpExtensionConfig("jicofo.codec.rtp-extensions.mid")
@JvmField
val videoContentType: RtpExtensionConfig =
RtpExtensionConfigWithLegacy(
"$LEGACY_BASE.ENABLE_VIDEO_CONTENT_TYPE",
"jicofo.codec.rtp-extensions.video-content-type"
)
@JvmField
val framemarking: RtpExtensionConfig =
RtpExtensionConfigWithLegacy("$LEGACY_BASE.ENABLE_FRAMEMARKING", "jicofo.codec.rtp-extensions.framemarking")
val extmapAllowMixed: Boolean by config {
"jicofo.codec.rtp-extensions.extmap-allow-mixed".from(JitsiConfig.newConfig)
}
companion object {
const val LEGACY_BASE = "org.jitsi.jicofo"
@JvmField
val config = Config()
}
}

View File

@@ -0,0 +1,326 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2022-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.codec
import org.jitsi.jicofo.codec.Config.Companion.config
import org.jitsi.xmpp.extensions.jingle.ParameterPacketExtension
import org.jitsi.xmpp.extensions.jingle.PayloadTypePacketExtension
import org.jitsi.xmpp.extensions.jingle.RTPHdrExtPacketExtension
import org.jitsi.xmpp.extensions.jingle.RtcpFbPacketExtension
import java.net.URI
class CodecUtil {
companion object {
fun createVideoPayloadTypeExtensions(
options: OfferOptions = OfferOptions()
): Collection<PayloadTypePacketExtension> = buildList {
if (config.av1.enabled()) {
// a:rtpmap:XXX AV1/90000
val av1 = createPayloadTypeExtension(config.av1.pt(), "AV1", 90000)
av1.addVideoExtensions(options, config.av1)
add(av1)
}
if (config.vp8.enabled()) {
// a=rtpmap:XXX VP8/90000
val vp8 = createPayloadTypeExtension(config.vp8.pt(), "VP8", 90000)
vp8.addVideoExtensions(options, config.vp8)
add(vp8)
}
if (config.h264.enabled()) {
// a=rtpmap:XXX H264/90000
// XXX(gp): older Chrome versions (users have reported 53/55/61)
// fail to enable h264, if the encoding name is in lower case.
val h264 = createPayloadTypeExtension(config.h264.pt(), "H264", 90000)
h264.addVideoExtensions(options, config.h264)
h264.addParameterExtension("profile-level-id", "42e01f;level-asymmetry-allowed=1;packetization-mode=1;")
add(h264)
}
if (config.vp9.enabled()) {
// a=rtpmap:XXX VP9/90000
val vp9 = createPayloadTypeExtension(config.vp9.pt(), "VP9", 90000)
vp9.addVideoExtensions(options, config.vp9)
add(vp9)
}
if (options.rtx) {
if (config.av1.rtxEnabled()) {
// a=rtpmap:XXX rtx/90000
val rtx = createPayloadTypeExtension(config.av1.rtxPt(), "rtx", 90000)
// a=fmtp:XXX apt=YYY (XXX = av1.rtxPt(), YYY = config.av1.pt())
rtx.addParameterExtension("apt", config.av1.pt().toString())
// a=rtcp-fb:XXX ccm fir
rtx.addRtcpFeedbackType(createRtcpFbPacketExtension("ccm", "fir"))
// a=rtcp-fb:XXX nack
rtx.addRtcpFeedbackType(createRtcpFbPacketExtension("nack", null))
// a=rtcp-fb:XXX nack pli
rtx.addRtcpFeedbackType(createRtcpFbPacketExtension("nack", "pli"))
add(rtx)
}
if (config.vp8.rtxEnabled()) {
// a=rtpmap:96 rtx/90000
val rtx = createPayloadTypeExtension(config.vp8.rtxPt(), "rtx", 90000)
// a=fmtp:96 apt=100
rtx.addParameterExtension("apt", config.vp8.pt().toString())
// Chrome doesn't have these when it creates an offer, but they were observed in a hangouts
// conference. Not sure whether they have any effect.
// a=rtcp-fb:96 ccm fir
rtx.addRtcpFeedbackType(createRtcpFbPacketExtension("ccm", "fir"))
// a=rtcp-fb:96 nack
rtx.addRtcpFeedbackType(createRtcpFbPacketExtension("nack", null))
// a=rtcp-fb:96 nack pli
rtx.addRtcpFeedbackType(createRtcpFbPacketExtension("nack", "pli"))
add(rtx)
}
if (config.vp9.rtxEnabled()) {
// a=rtpmap:97 rtx/90000
val rtxVP9 = createPayloadTypeExtension(config.vp9.rtxPt(), "rtx", 90000)
// a=fmtp:97 apt=101
rtxVP9.addParameterExtension("apt", config.vp9.pt().toString())
// Chrome doesn't have these when it creates an offer, but they were observed in a hangouts
// conference. Not sure whether they have any effect.
// a=rtcp-fb:96 ccm fir
rtxVP9.addRtcpFeedbackType(createRtcpFbPacketExtension("ccm", "fir"))
// a=rtcp-fb:96 nack
rtxVP9.addRtcpFeedbackType(createRtcpFbPacketExtension("nack", null))
// a=rtcp-fb:96 nack pli
rtxVP9.addRtcpFeedbackType(createRtcpFbPacketExtension("nack", "pli"))
add(rtxVP9)
}
if (config.h264.rtxEnabled()) {
// a=rtpmap:99 rtx/90000
val rtxH264 = createPayloadTypeExtension(config.h264.rtxPt(), "rtx", 90000)
// a=fmtp:99 apt=107
rtxH264.addParameterExtension("apt", config.h264.pt().toString())
add(rtxH264)
}
}
}
fun createAudioPayloadTypeExtensions(
options: OfferOptions = OfferOptions()
): Collection<PayloadTypePacketExtension> = buildList {
if (config.opus.enabled()) {
// Though RED has a payload type of its own and can be used to encode multiple other payload types, we need
// it to be advertised with the same clock rate as opus, so it's defined here.
// Add the RED payload type before Opus, so that it is the selected codec.
if (config.opus.red.enabled() && options.opusRed) {
val red = createPayloadTypeExtension(config.opus.red.pt(), "red", 48000)
red.channels = 2
// RFC 2198 fmtp line
// Indicates Opus payload type (111) as primary/secondary encoding for RED (112)
// fmtp:112 111/111
red.addParameterExtension(null, config.opus.pt().toString() + "/" + config.opus.pt())
add(red)
}
// a=rtpmap:111 opus/48000/2
val opus = createPayloadTypeExtension(config.opus.pt(), "opus", 48000)
add(opus)
// Opus is always signaled with 2 channels, regardless of 'stereo'
opus.channels = 2
// fmtp:111 minptime=10
opus.addParameterExtension("minptime", config.opus.minptime().toString())
// Avoid double FEC if RED is offered already.
if (config.opus.useInbandFec() && !config.opus.red.enabled()) {
// fmtp:111 useinbandfec=1
opus.addParameterExtension("useinbandfec", "1")
}
if (config.tcc.enabled() && options.tcc) {
// a=rtcp-fb:111 transport-cc
opus.addRtcpFeedbackType(createRtcpFbPacketExtension("transport-cc", null))
}
}
if (config.telephoneEvent.enabled()) {
// rtpmap:126 telephone-event/8000
add(createPayloadTypeExtension(config.telephoneEvent.pt(), "telephone-event", 8000))
}
}
/**
* Create [RTPHdrExtPacketExtension]s for audio based on jicofo's configuration and the given `options`.
*/
fun createAudioRtpHdrExtExtensions(
options: OfferOptions = OfferOptions()
): Collection<RTPHdrExtPacketExtension> = buildList {
if (config.audioLevel.enabled()) {
val ssrcAudioLevel = RTPHdrExtPacketExtension()
ssrcAudioLevel.id = config.audioLevel.id().toString()
ssrcAudioLevel.uri = URI.create("urn:ietf:params:rtp-hdrext:ssrc-audio-level")
add(ssrcAudioLevel)
}
if (config.mid.enabled()) {
// a=extmap:10 rn:ietf:params:rtp-hdrext:sdes:mid
val mid = RTPHdrExtPacketExtension()
mid.id = config.mid.id().toString()
mid.uri = URI.create("urn:ietf:params:rtp-hdrext:sdes:mid")
add(mid)
}
if (config.tcc.enabled() && options.tcc) {
// a=extmap:5 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
val tcc = RTPHdrExtPacketExtension()
tcc.id = config.tcc.id().toString()
tcc.uri = URI.create("http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01")
add(tcc)
}
}
fun createVideoRtpHdrExtExtensions(
options: OfferOptions = OfferOptions()
): Collection<RTPHdrExtPacketExtension> = buildList {
if (config.videoLayersAllocation.enabled) {
// a=extmap:12 http://www.webrtc.org/experiments/rtp-hdrext/video-layers-allocation00
add(
RTPHdrExtPacketExtension().apply {
id = config.videoLayersAllocation.id.toString()
uri = URI.create("http://www.webrtc.org/experiments/rtp-hdrext/video-layers-allocation00")
}
)
}
if (config.av1DependencyDescriptor.enabled()) {
// https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension
val dependencyDescriptorExt = RTPHdrExtPacketExtension()
dependencyDescriptorExt.id = config.av1DependencyDescriptor.id().toString()
dependencyDescriptorExt.uri = URI.create(
"https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension"
)
add(dependencyDescriptorExt)
}
if (config.tof.enabled()) {
// a=extmap:2 urn:ietf:params:rtp-hdrext:toffset
val toOffset = RTPHdrExtPacketExtension()
toOffset.id = config.tof.id().toString()
toOffset.uri = URI.create("urn:ietf:params:rtp-hdrext:toffset")
add(toOffset)
}
if (config.mid.enabled()) {
// a=extmap:10 rn:ietf:params:rtp-hdrext:sdes:mid
val mid = RTPHdrExtPacketExtension()
mid.id = config.mid.id().toString()
mid.uri = URI.create("urn:ietf:params:rtp-hdrext:sdes:mid")
add(mid)
}
if (config.absSendTime.enabled()) {
// a=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
val absSendTime = RTPHdrExtPacketExtension()
absSendTime.id = config.absSendTime.id().toString()
absSendTime.uri = URI.create("http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time")
add(absSendTime)
}
if (config.framemarking.enabled()) {
// a=extmap:XXX urn:ietf:params:rtp-hdrext:framemarking
val framemarking = RTPHdrExtPacketExtension()
framemarking.id = config.framemarking.id().toString()
framemarking.uri = URI.create("http://tools.ietf.org/html/draft-ietf-avtext-framemarking-07")
add(framemarking)
}
if (config.videoContentType.enabled()) {
// http://www.webrtc.org/experiments/rtp-hdrext/video-content-type
val videoContentType = RTPHdrExtPacketExtension()
videoContentType.id = config.videoContentType.id().toString()
videoContentType.uri = URI.create("http://www.webrtc.org/experiments/rtp-hdrext/video-content-type")
add(videoContentType)
}
if (config.rid.enabled()) {
val rtpStreamId = RTPHdrExtPacketExtension()
rtpStreamId.id = config.rid.id.toString()
rtpStreamId.uri = URI.create("urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id")
add(rtpStreamId)
}
if (config.tcc.enabled() && options.tcc) {
// a=extmap:5 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
val tcc = RTPHdrExtPacketExtension()
tcc.id = config.tcc.id().toString()
tcc.uri = URI.create("http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01")
add(tcc)
}
}
private fun createPayloadTypeExtension(id: Int, name: String, clockRate: Int) =
PayloadTypePacketExtension().apply {
setId(id)
this.name = name
clockrate = clockRate
}
fun PayloadTypePacketExtension.addVideoExtensions(options: OfferOptions, codecConfig: CodecConfig) {
// a=rtcp-fb:XXX ccm fir
addRtcpFeedbackType(createRtcpFbPacketExtension("ccm", "fir"))
// a=rtcp-fb:XXX nack
addRtcpFeedbackType(createRtcpFbPacketExtension("nack", null))
// a=rtcp-fb:XXX nack pli
addRtcpFeedbackType(createRtcpFbPacketExtension("nack", "pli"))
if (codecConfig.enableRemb && options.remb) {
// a=rtcp-fb:XXX goog-remb
addRtcpFeedbackType(createRtcpFbPacketExtension("goog-remb", null))
}
if (config.tcc.enabled() && options.tcc) {
// a=rtcp-fb:XXX transport-cc
addRtcpFeedbackType(createRtcpFbPacketExtension("transport-cc", null))
}
}
}
}
/**
* Adds a [ParameterPacketExtension] with a given name and value to this [PayloadTypePacketExtension].
* @return the added extension.
*/
private fun PayloadTypePacketExtension.addParameterExtension(name: String?, value: String) =
ParameterPacketExtension().apply {
this.name = name
this.value = value
addParameter(this)
}
private fun createRtcpFbPacketExtension(type: String, subtype: String?) = RtcpFbPacketExtension().apply {
feedbackType = type
subtype?.let { this.feedbackSubtype = subtype }
}

View File

@@ -0,0 +1,31 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.codec
/**
* Options for an offer that jicofo generates for a specific participant (or for an Octo link).
*/
data class OfferOptions(
var audio: Boolean = true,
var video: Boolean = true,
var sctp: Boolean = true,
var tcc: Boolean = true,
var remb: Boolean = false,
var rtx: Boolean = true,
var opusRed: Boolean = true
)

View File

@@ -0,0 +1,222 @@
/*
* Copyright @ 2021 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.databind.node.ObjectNode
import org.jitsi.utils.MediaType
import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension
import org.jitsi.xmpp.extensions.jingle.ContentPacketExtension
import org.jitsi.xmpp.extensions.jingle.SourceGroupPacketExtension
import java.lang.UnsupportedOperationException
import java.util.concurrent.ConcurrentHashMap
/**
* A container for sources from multiple endpoints, mapped by the ID of the endpoint. This could contain sources for
* an entire conference, or a subset.
*
* This map is thread safe. Reading and iteration are safe because the underlying map is a [ConcurrentHashMap]. The
* only mutating operations that are exposed require a lock on [syncRoot].
*
* Note that the [java.util.Map] mutating operations (e.g. [java.util.Map.put]) are still visible in Java, but they
* are not meant to be used and will result in an exception.
*/
open class ConferenceSourceMap(
/**
* The sources mapped by endpoint ID.
* Note that this primary constructor uses the provided [ConcurrentHashMap], and not a copy, as the underlying map.
*/
private val endpointSourceSets: ConcurrentHashMap<String, EndpointSourceSet> = ConcurrentHashMap()
) : Map<String, EndpointSourceSet> by endpointSourceSets {
/** Constructs a new [ConferenceSourceMap] from the entries in [map] ([map] itself is not reused). */
constructor(map: Map<String, EndpointSourceSet>) : this(ConcurrentHashMap(map))
constructor(vararg entries: Pair<String, EndpointSourceSet>) : this(entries.toMap())
constructor(owner: String, endpointSourceSet: EndpointSourceSet) : this(owner to endpointSourceSet)
constructor(owner: String, source: Source) : this(owner, EndpointSourceSet(source))
constructor(owner: String, contents: List<ContentPacketExtension>) :
this(owner, EndpointSourceSet.fromJingle(contents))
constructor(owner: String, sources: Set<Source>, groups: Set<SsrcGroup>) :
this(owner, EndpointSourceSet(sources, groups))
/** The lock used for write operations to the map. Can and should be used by extending classes. */
protected val syncRoot = Any()
/** Remove the entry associated with [owner]. */
open fun remove(owner: String) = synchronized(syncRoot) {
endpointSourceSets.remove(owner)
}
/** An unmodifiable view of this [ConferenceSourceMap]. */
val unmodifiable by lazy { UnmodifiableConferenceSourceMap(endpointSourceSets) }
fun unmodifiable() = unmodifiable
/** Create a new [ConferenceSourceMap] instance with the same entries as this one. */
fun copy(): ConferenceSourceMap = ConferenceSourceMap(ConcurrentHashMap(endpointSourceSets))
/** Adds the sources of another [ConferenceSourceMap] to this one. */
open fun add(other: ConferenceSourceMap) = synchronized(syncRoot) {
other.endpointSourceSets.forEach { (owner, endpointSourceSet) ->
endpointSourceSets[owner] += endpointSourceSet
}
}
/** Adds [endpointSourceSet] as sources owned by [owner]. */
open fun add(owner: String, endpointSourceSet: EndpointSourceSet) = synchronized(syncRoot) {
endpointSourceSets[owner] += endpointSourceSet
}
/**
* Create a compact JSON representation of this [ConferenceSourceMap]. The JSON is a map of an ID of the owner
* to the compact JSON of its [EndpointSourceSet] (see [EndpointSourceSet.compactJson]).
*/
fun compactJson(): String = synchronized(syncRoot) {
buildString {
append("{")
endpointSourceSets.entries.forEachIndexed { i, entry ->
if (i > 0) append(",")
append(""""${entry.key}":${entry.value.compactJson}""")
}
append("}")
}
}
/** Removes the sources of another [ConferenceSourceMap] from this one. */
open fun remove(other: ConferenceSourceMap) = synchronized(syncRoot) {
other.endpointSourceSets.forEach { (owner, endpointSourceSet) ->
val existing = endpointSourceSets[owner]
if (existing != null) {
val result = existing - endpointSourceSet
// TODO: do we want to allow lingering SsrcGroups? Should we actually remove SsrcGroups when their
// sources are removed?
if (result.isEmpty()) {
endpointSourceSets.remove(owner)
} else {
endpointSourceSets[owner] = result
}
}
}
}
operator fun plus(other: ConferenceSourceMap) = copy().apply { add(other) }
operator fun minus(other: ConferenceSourceMap) = copy().apply { remove(other) }
/**
* Creates a list of [ContentPacketExtension]s that describe the sources in this [ConferenceSourceMap].
*/
fun toJingle(): List<ContentPacketExtension> {
val contents = mutableMapOf<MediaType, ContentPacketExtension>()
forEach { (owner, sourceSet) -> sourceSet.toJingle(contents, owner) }
return contents.values.toList()
}
fun createSourcePacketExtensions(mediaType: MediaType): List<SourcePacketExtension> {
val extensions = mutableListOf<SourcePacketExtension>()
forEach { (owner, endpointSourceSet) ->
endpointSourceSet.sources.filter { it.mediaType == mediaType }.forEach { source ->
extensions.add(source.toPacketExtension(owner))
}
}
return extensions
}
fun createSourceGroupPacketExtensions(mediaType: MediaType): List<SourceGroupPacketExtension> {
val extensions = mutableListOf<SourceGroupPacketExtension>()
forEach { (_, endpointSourceSet) ->
endpointSourceSet.ssrcGroups.filter { it.mediaType == mediaType }.forEach { ssrcGroup ->
extensions.add(ssrcGroup.toPacketExtension())
}
}
return extensions
}
/** Use a kotlin map for easy pretty printing. Inefficient. */
override fun toString(): String = endpointSourceSets.toMap().toString()
/**
* Strip simulcast SSRCs from each entry in the map. Modifies the map in place.
*/
fun stripSimulcast() = map { it.stripSimulcast }
open fun map(transform: (EndpointSourceSet) -> EndpointSourceSet) = synchronized(syncRoot) {
endpointSourceSets.forEach { (owner, sources) ->
val transformed = transform(sources)
if (transformed.isEmpty()) {
endpointSourceSets.remove(owner)
} else {
endpointSourceSets[owner] = transformed
}
}
this
}
/**
* Remove all sources from this [ConferenceSourceMap] unless their media type is in [retain].
*/
fun stripByMediaType(
/** The set of media types to retain, all other media types will be removed */
retain: Set<MediaType>
) = synchronized(syncRoot) {
if (retain.contains(MediaType.AUDIO) && retain.contains(MediaType.VIDEO)) {
// Nothing to strip.
return this
}
map { sources ->
val strippedSources = sources.sources.filter { retain.contains(it.mediaType) }.toSet()
if (strippedSources.isEmpty()) {
EndpointSourceSet.EMPTY
} else {
val strippedSsrcGroups = sources.ssrcGroups.filter { retain.contains(it.mediaType) }.toSet()
EndpointSourceSet(strippedSources, strippedSsrcGroups)
}
}
}
/** Expanded JSON format used for debugging */
fun toJson(): ObjectNode = JsonNodeFactory.instance.objectNode().apply {
synchronized(syncRoot) {
endpointSourceSets.forEach { (owner, sourceSet) -> set<ObjectNode>(owner, sourceSet.toJson()) }
}
}
}
/**
* A read-only version of [ConferenceSourceMap]. Attempts to modify the map will via [add], [remove] or any of the
* standard [java.util.Map] mutating methods will result in an exception.
*/
class UnmodifiableConferenceSourceMap(
endpointSourceSets: ConcurrentHashMap<String, EndpointSourceSet>
) : ConferenceSourceMap(endpointSourceSets) {
constructor(map: Map<String, EndpointSourceSet>) : this(ConcurrentHashMap(map))
override fun add(other: ConferenceSourceMap) =
throw UnsupportedOperationException("add() not supported in unmodifiable view")
override fun add(owner: String, endpointSourceSet: EndpointSourceSet) =
throw UnsupportedOperationException("add() not supported in unmodifiable view")
override fun remove(other: ConferenceSourceMap) =
throw UnsupportedOperationException("remove() not supported in unmodifiable view")
override fun remove(owner: String) =
throw UnsupportedOperationException("remove() not supported in unmodifiable view")
override fun map(transform: (EndpointSourceSet) -> EndpointSourceSet) =
throw UnsupportedOperationException("map() not supported in unmodifiable view")
}

View File

@@ -0,0 +1,283 @@
/*
* Copyright @ 2021 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.databind.node.ObjectNode
import org.jitsi.utils.MediaType
import org.jitsi.utils.MediaType.AUDIO
import org.jitsi.utils.MediaType.VIDEO
import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension
import org.jitsi.xmpp.extensions.jingle.ContentPacketExtension
import org.jitsi.xmpp.extensions.jingle.RtpDescriptionPacketExtension
import org.jitsi.xmpp.extensions.jingle.SourceGroupPacketExtension
import java.lang.IllegalArgumentException
import kotlin.jvm.Throws
/** A set of [Source]s and [SsrcGroup]s, usually associated with an endpoint. */
data class EndpointSourceSet(
val sources: Set<Source> = emptySet(),
val ssrcGroups: Set<SsrcGroup> = emptySet(),
) {
constructor(source: Source) : this(setOf(source))
constructor(ssrcGroup: SsrcGroup) : this(ssrcGroups = setOf(ssrcGroup))
/**
* Whether this set of sources is empty.
* Note: it is not clear whether it makes sense to describe any [SsrcGroup]s without corresponding sources, but
* for this representation we allow it.
*/
fun isEmpty() = sources.isEmpty() && ssrcGroups.isEmpty()
/**
* Whether there are any audio sources in this set.
*/
val hasAudio: Boolean by lazy { sources.any { it.mediaType == AUDIO } }
/**
* Whether there are any video sources in this set.
*/
val hasVideo: Boolean by lazy { sources.any { it.mediaType == VIDEO } }
/**
* Creates a list of Jingle [ContentPacketExtension]s that describe the sources in this [EndpointSourceSet].
*/
fun toJingle(owner: String? = null): List<ContentPacketExtension> = toJingle(mutableMapOf(), owner)
val audioSsrcs: Set<Long> by lazy { getSsrcs(AUDIO) }
val videoSsrcs: Set<Long> by lazy { getSsrcs(VIDEO) }
private fun getSsrcs(mediaType: MediaType) = sources.filter { it.mediaType == mediaType }.map { it.ssrc }.toSet()
/**
* A concise string more suitable for logging. This may be slow for large sets.
*/
override fun toString() = "[audio=$audioSsrcs, video=$videoSsrcs, groups=$ssrcGroups]"
/**
* Get a new [EndpointSourceSet] by stripping all simulcast-related SSRCs and groups except for the first SSRC in
* a simulcast group. This assumes that the first SSRC in the simulcast group and its associated RTX SSRC are the only
* SSRCs that receivers of simulcast streams need to know about, i.e. that jitsi-videobridge uses that SSRC as the
* target SSRC when rewriting streams.
*/
val stripSimulcast: EndpointSourceSet by lazy {
doStripSimulcast()
}
/**
* A compact JSON representation of this [EndpointSourceSet] (optimized for size). This is done ad-hoc instead of
* using e.g. jackson because the format is substantially different than the natural representation of
* [EndpointSourceSet], we only need serialization support (no parsing) and to keep it separated from the main code.
*
* The JSON is an array of up to four elements, with index 0 encoding the video sources, index 1 encoding the video
* source groups, index 2 encoding the audio sources, and index 3 encoding the audio source groups. Trailing empty
* elements may be skipped, e.g. if there are no audio source groups the list will only have 3 elements.
*
* Each element is itself a JSON array of the `compactJson` representation of the sources or SSRC groups.
*
* For example, an [EndpointSourceSet] with video sources 1, 2 grouped in a Fid group and an audio source 3 may be
* encoded as:
* [
* // Array of video sources
* [
* {"s": 1},
* {"s": 2}
* ],
* // Array of video SSRC groups
* [
* ["f", 1, 2]
* ],
* // Array of audio sources
* [
* {"s": 3}
* ]
* // Empty array of audio SSRC groups not present.
* ]
*/
val compactJson: String by lazy {
buildString {
append("[[")
sources.filter { it.mediaType == VIDEO }.forEachIndexed { i, source ->
if (i > 0) append(",")
append(source.compactJson)
}
append("],[")
ssrcGroups.filter { it.mediaType == VIDEO }.forEachIndexed { i, ssrcGroup ->
if (i > 0) append(",")
append(ssrcGroup.compactJson)
}
append("],[")
sources.filter { it.mediaType == AUDIO }.forEachIndexed { i, source ->
if (i > 0) append(",")
append(source.compactJson)
}
append("]")
if (ssrcGroups.any { it.mediaType == AUDIO }) {
append(",[")
ssrcGroups.filter { it.mediaType == AUDIO }.forEachIndexed { i, ssrcGroup ->
if (i > 0) append(",")
append(ssrcGroup.compactJson)
}
append("]")
}
append("]")
}
}
/** Expanded JSON format used for debugging. */
fun toJson(): ObjectNode = JsonNodeFactory.instance.objectNode().apply {
set<ObjectNode>(
"sources",
JsonNodeFactory.instance.arrayNode().apply {
sources.forEach { add(it.toJson()) }
}
)
set<ObjectNode>(
"groups",
JsonNodeFactory.instance.arrayNode().apply {
ssrcGroups.forEach { add(it.toJson()) }
}
)
}
companion object {
/** An [EndpointSourceSet] instance with no sources or source groups */
@JvmField
val EMPTY = EndpointSourceSet()
/**
* Parses a list of Jingle [ContentPacketExtension]s into an [EndpointSourceSet].
*
* @throws IllegalArgumentException if the media type of any of the contents is invalid, or the semantics of
* any of the SSRC groups is invalid.
*/
@JvmStatic
@Throws(IllegalArgumentException::class)
fun fromJingle(contents: List<ContentPacketExtension>): EndpointSourceSet {
val sources = mutableSetOf<Source>()
val ssrcGroups = mutableSetOf<SsrcGroup>()
contents.forEach { content ->
val rtpDesc: RtpDescriptionPacketExtension? =
content.getFirstChildOfType(RtpDescriptionPacketExtension::class.java)
val mediaTypeString = if (rtpDesc != null) rtpDesc.media else content.name
val mediaType = MediaType.parseString(mediaTypeString)
// The previous code looked for [SourcePacketExtension] as children of both the "content" and
// "description" elements, so this is reproduced here. I don't know which one is correct and/or used.
rtpDesc?.let {
rtpDesc.getChildExtensionsOfType(SourcePacketExtension::class.java).forEach { spe ->
sources.add(Source(mediaType, spe))
}
rtpDesc.getChildExtensionsOfType(SourceGroupPacketExtension::class.java).forEach { sgpe ->
ssrcGroups.add(SsrcGroup.fromPacketExtension(sgpe, mediaType))
}
}
content.getChildExtensionsOfType(SourcePacketExtension::class.java).forEach { spe ->
sources.add(Source(mediaType, spe))
}
}
return EndpointSourceSet(sources, ssrcGroups)
}
}
}
/**
* Populates a list of Jingle [ContentPacketExtension]s with extensions that describe the sources in this
* [EndpointSourceSet]. Creates new [ContentPacketExtension] if necessary, returns the [ContentPacketExtension]s
* as a list.
*/
fun EndpointSourceSet.toJingle(
contentMap: MutableMap<MediaType, ContentPacketExtension>,
owner: String?
): List<ContentPacketExtension> {
sources.forEach { source ->
val content = contentMap.computeIfAbsent(source.mediaType) {
ContentPacketExtension().apply { name = source.mediaType.toString() }
}
val rtpDesc = content.getOrCreateRtpDescription()
rtpDesc.addChildExtension(source.toPacketExtension(owner))
}
ssrcGroups.forEach { ssrcGroup ->
val content = contentMap.computeIfAbsent(ssrcGroup.mediaType) {
ContentPacketExtension().apply { name = ssrcGroup.mediaType.toString() }
}
val rtpDesc = content.getOrCreateRtpDescription()
rtpDesc.addChildExtension(ssrcGroup.toPacketExtension())
}
return contentMap.values.toList()
}
/**
* Returns the [RtpDescriptionPacketExtension] child of this [ContentPacketExtension], creating it if it doesn't
* exist.
*/
private fun ContentPacketExtension.getOrCreateRtpDescription() =
getChildExtension(RtpDescriptionPacketExtension::class.java) ?: RtpDescriptionPacketExtension().apply {
media = name
this@getOrCreateRtpDescription.addChildExtension(this)
}
operator fun EndpointSourceSet?.plus(other: EndpointSourceSet?): EndpointSourceSet = when {
this == null && other == null -> EndpointSourceSet.EMPTY
this == null -> other!!
other == null -> this
else -> EndpointSourceSet(
sources + other.sources,
ssrcGroups + other.ssrcGroups
)
}
operator fun EndpointSourceSet.minus(other: EndpointSourceSet): EndpointSourceSet =
EndpointSourceSet(sources - other.sources, ssrcGroups - other.ssrcGroups)
operator fun EndpointSourceSet.plus(sources: Set<Source>) = EndpointSourceSet(this.sources + sources, this.ssrcGroups)
operator fun EndpointSourceSet.plus(source: Source) = EndpointSourceSet(this.sources + source, this.ssrcGroups)
operator fun EndpointSourceSet.plus(ssrcGroup: SsrcGroup) = EndpointSourceSet(this.sources, this.ssrcGroups + ssrcGroup)
private fun EndpointSourceSet.doStripSimulcast(): EndpointSourceSet {
val groupsToRemove = mutableSetOf<SsrcGroup>()
val ssrcsToRemove = mutableSetOf<Long>()
ssrcGroups.filter { it.semantics == SsrcGroupSemantics.Sim }.forEach { simGroup ->
groupsToRemove.add(simGroup)
simGroup.ssrcs.forEachIndexed { index: Int, ssrc: Long ->
if (index > 0) ssrcsToRemove.add(ssrc)
}
}
ssrcGroups.filter { it.semantics == SsrcGroupSemantics.Fid }.forEach { fidGroup ->
if (fidGroup.ssrcs.size != 2) {
throw IllegalArgumentException("Invalid FID group, has ${fidGroup.ssrcs.size} ssrcs.")
}
if (ssrcsToRemove.contains(fidGroup.ssrcs[0])) {
ssrcsToRemove.add(fidGroup.ssrcs[1])
groupsToRemove.add(fidGroup)
}
}
return EndpointSourceSet(
sources.filter { !ssrcsToRemove.contains(it.ssrc) }.toSet(),
ssrcGroups - groupsToRemove
)
}

View File

@@ -0,0 +1,120 @@
/*
* Copyright @ 2021 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import org.jitsi.utils.MediaType
import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension
import org.jitsi.xmpp.extensions.jingle.ParameterPacketExtension
import org.jitsi.xmpp.extensions.jitsimeet.SSRCInfoPacketExtension
import org.jxmpp.jid.impl.JidCreate
/**
* The description of a single source (i.e. an SSRC and its properties from "a=ssrc" lines in SDP).
*/
data class Source(
val ssrc: Long,
/** Audio or Video **/
val mediaType: MediaType,
/** Optional name */
val name: String? = null,
/** Optional msid */
val msid: String? = null,
/** Optional video type (camera or desktop) */
val videoType: VideoType? = null,
/**
* Whether this source is synthetic, i.e. injected by the server (e.g. a translated audio stream). Synthetic
* sources are created by jicofo (not parsed from client Jingle); the flag is carried to the bridge on the
* Colibri2 media-source element (see [org.jitsi.jicofo.bridge.colibri.toColibriMediaSources]).
*/
val synthetic: Boolean = false
) {
/** Create a [Source] from an XML extension. */
constructor(mediaType: MediaType, sourcePacketExtension: SourcePacketExtension) : this(
sourcePacketExtension.ssrc,
mediaType,
sourcePacketExtension.name,
sourcePacketExtension.getChildExtensionsOfType(ParameterPacketExtension::class.java)
.filter { it.name == "msid" }.map { it.value }.firstOrNull(),
if (sourcePacketExtension.videoType == null) null else VideoType.parseString(sourcePacketExtension.videoType)
)
/** Serializes this [Source] to XML */
@JvmOverloads
fun toPacketExtension(
/** An optional JID for the owner of this source to encode in the XML extension. */
owner: String? = null,
encodeMsid: Boolean = true
) = SourcePacketExtension().apply {
ssrc = this@Source.ssrc
name = this@Source.name
if (owner != null) {
addChildExtension(SSRCInfoPacketExtension().apply { this.owner = JidCreate.from(owner) })
}
if (encodeMsid && msid != null) {
addChildExtension(ParameterPacketExtension("msid", msid))
}
this@Source.videoType?.let {
videoType = it.toString()
}
}
/**
* A compact JSON representation of this [Source] (optimized for size). This is done ad-hoc instead of using e.g.
* jackson because we only need serialization support (no parsing) and to keep it separated from the main code.
* Note that we don't encode the media type.
*/
val compactJson: String by lazy {
buildString {
append("""{"s":$ssrc""")
name?.let {
append(""","n":"$it"""")
}
msid?.let {
append(""","m":"$it"""")
}
if (videoType == VideoType.Desktop) {
append(""","v":"d"""")
}
append("}")
}
}
/** Expanded JSON format used for debugging. */
fun toJson() = JsonNodeFactory.instance.objectNode().apply {
put("ssrc", ssrc)
put("media_type", mediaType.toString())
put("name", name ?: "null")
put("msid", msid ?: "null")
put("videoType", videoType.toString())
if (synthetic) {
put("synthetic", true)
}
}
companion object {
/**
* Generates a source name in deterministic format used in the jitsi-meet client. First part is the endpoint id,
* followed by "-", then the first letter of the media type and the index of the source (zero based).
* For example "endpointA-a0" is the first audio source of "endpointA" or "endpointA-v0" for the first video
* source.
*/
fun nameForIdAndMediaType(endpointId: String, mediaType: MediaType, idx: Int): String =
"$endpointId-${(mediaType.toString()[0])}$idx"
}
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright @ 2021 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.databind.node.ObjectNode
import org.jitsi.utils.MediaType
import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension
import org.jitsi.xmpp.extensions.jingle.SourceGroupPacketExtension
import java.lang.IllegalArgumentException
import kotlin.jvm.Throws
/** The description of an SSRC grouping (i.e. an ssrc-group line in SDP) */
data class SsrcGroup(
val semantics: SsrcGroupSemantics,
val ssrcs: List<Long>,
val mediaType: MediaType = MediaType.VIDEO
) {
/** Serializes this [SsrcGroup] to XML */
fun toPacketExtension(): SourceGroupPacketExtension = SourceGroupPacketExtension().apply {
semantics = this@SsrcGroup.semantics.toString()
addSources(
ssrcs.map { SourcePacketExtension().apply { ssrc = it } }.toList()
)
}
/** A concise string more suitable for logging. */
override fun toString(): String = "${semantics.toString().uppercase()}$ssrcs"
companion object {
/**
* Creates an [SsrcGroup] from an XML extension. The semantics is encoded as a string, so needs to be parsed
* into a [SsrcGroupSemantics] (which may fail).
*
* @throws IllegalArgumentException if the XML extension does not have a valid "semantics" field.
*/
@Throws(IllegalArgumentException::class)
fun fromPacketExtension(sgpe: SourceGroupPacketExtension, mediaType: MediaType = MediaType.VIDEO): SsrcGroup {
val semantics = try {
SsrcGroupSemantics.fromString(sgpe.semantics)
} catch (e: NoSuchElementException) {
throw IllegalArgumentException("Invalid ssrc-group semantics: ${sgpe.semantics}")
}
return SsrcGroup(semantics, sgpe.sources.map { it.ssrc }.toList(), mediaType)
}
}
/**
* A compact JSON representation of this [SsrcGroup] (optimized for size). This is done ad-hoc instead of using e.g.
* jackson because we only need serialization support (no parsing) and to keep it separated from the main code.
*
* The JSON contains a list with the first element encoding the semantics ("s" for Sim, "f" for Fid), and the rest
* of the elements encoding the SSRCs. For example a simulcast group for SSRCs 1, 2, 3 is encoded as:
* ["s", 1, 2, 3]
*/
val compactJson: String by lazy {
buildString {
append("[")
when (semantics) {
SsrcGroupSemantics.Sim -> append("\"s\"")
SsrcGroupSemantics.Fid -> append("\"f\"")
}
ssrcs.forEach { append(",$it") }
append("]")
}
}
/** Expanded JSON format used for debugging. */
fun toJson(): ObjectNode = JsonNodeFactory.instance.objectNode().apply {
put("semantics", semantics.toString())
put("media_type", mediaType.toString())
set<ObjectNode>("ssrcs", JsonNodeFactory.instance.arrayNode().apply { ssrcs.forEach { add(it) } })
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright @ 2021 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import kotlin.jvm.Throws
/** The supported semantics for [SsrcGroup] */
enum class SsrcGroupSemantics {
/** Simulcast. */
Sim,
/** Flow ID, used for RTX. */
Fid;
override fun toString() = super.toString().uppercase()
companion object {
/** Parse a string case-insensitively. */
@Throws(NoSuchElementException::class)
fun fromString(str: String): SsrcGroupSemantics =
values().first { it.toString().lowercase() == str.lowercase() }
}
}

View File

@@ -0,0 +1,358 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import org.jitsi.utils.MediaType
import org.jitsi.utils.logging2.createLogger
import java.lang.IllegalStateException
/**
* An extension of [ConferenceSourceMap] with an API for addition ([tryToAdd]) and removal ([tryToRemove]) of sources
* with validation. Note that the standard [add] and [remove] APIs are still available and do NOT perform validation.
*
* Validation means that the addition of sources does not lead to a conflict of SSRC or MSID with another endpoint
* in the conference, and that the addition/removal operations leave the endpoint's resulting set of sources valid.
*
* A set of sources is valid if there are no conflicts in SSRC or MSID, all sources within a group have the same MSID,
* all SSRCs within a group have a corresponding source, and other similar checks pass. See [validateEndpointSourceSet].
*
* This implementation inherits the thread safety characteristics of [ConferenceSourceMap].
*/
class ValidatingConferenceSourceMap(
private val maxSsrcsPerUser: Int,
private val maxSsrcGroupsPerUser: Int
) : ConferenceSourceMap() {
val logger = createLogger()
/**
* Maps an SSRC to the JID of the endpoint that owns it. Used to detect cross-endpoint conflicts efficiently.
*
* Note that since the value is nullable [Map.containsKey] must be used when checking for the existence of an entry.
*/
private val ssrcToOwnerMap = mutableMapOf<Long, String>()
/**
* Maps an MSID to the JID of the endpoint that owns it. Used to detect cross-endpoint conflicts efficiently.
*
* Note that since the value is nullable [Map.containsKey] must be used when checking for the existence of an entry.
*/
private val msidToOwnerMap = mutableMapOf<String, String>()
/**
* Attempts to add [sourcesToAdd] as sources owned by [owner]. The attempt is successful if the addition does not
* introduce any conflicts in SSRC/MSID and the resulting set of sources for [owner] is valid (see
* [validateEndpointSourceSet]).
*
* If the attempt is successful, the sources are added to [owner]'s sources and the accepted sources are returned.
* The accepted sources might differ from [sourcesToAdd] in their SSRC groups, because empty and duplicate groups
* are silently ignored.
*
* If the attempt is unsuccessful, a [ValidationFailedException] is thrown and the state of the map remains
* unchanged.
*
* @throws ValidationFailedException if the attempt to add sources is unsuccessful. The state of the map remains
* unchanged.
* @return The sources that have been accepted and added.
*/
@Throws(ValidationFailedException::class)
fun tryToAdd(owner: String, sourcesToAdd: EndpointSourceSet): EndpointSourceSet = synchronized(syncRoot) {
val existingSourceSet = this[owner] ?: EndpointSourceSet.EMPTY
// Check for validity of the new SSRCs, and conflicts with other endpoints.
sourcesToAdd.sources.forEach { source ->
if (source.ssrc <= 0 || source.ssrc >= 0x1_0000_0000) {
throw InvalidSsrcException(source.ssrc)
}
if (ssrcToOwnerMap.containsKey(source.ssrc)) {
// Adding the same source for the same endpoint is also invalid.
throw SsrcAlreadyUsedException(source.ssrc)
}
source.msid?.let { msid ->
if (msidToOwnerMap.containsKey(msid) && msidToOwnerMap[msid] != owner) {
throw MsidConflictException(msid)
}
}
if (existingSourceSet.sources.size + sourcesToAdd.sources.size > maxSsrcsPerUser) {
throw SsrcLimitExceededException(maxSsrcsPerUser)
}
}
// If we rejected any of the SSRCs we'd throw above. We accepted them all.
val acceptedSources = sourcesToAdd.sources
val resultingSources = existingSourceSet.sources + acceptedSources
val resultingSsrcs = resultingSources.map { it.ssrc }.toSet()
val acceptedGroups = mutableSetOf<SsrcGroup>()
sourcesToAdd.ssrcGroups.forEach {
// We just ignore empty groups and groups which have been signaled multiple times.
when {
it.ssrcs.isEmpty() -> logger.info("Empty group signaled, ignoring.")
existingSourceSet.ssrcGroups.contains(it) -> logger.info("Duplicate group signaled, ignoring.")
!resultingSsrcs.containsAll(it.ssrcs) -> throw GroupContainsUnknownSourceException(it.ssrcs)
else -> acceptedGroups.add(it)
}
}
if (existingSourceSet.ssrcGroups.size + acceptedGroups.size > maxSsrcGroupsPerUser) {
throw SsrcGroupLimitExceededException(maxSsrcGroupsPerUser)
}
val resultingSourceSet = EndpointSourceSet(resultingSources, existingSourceSet.ssrcGroups + acceptedGroups)
validateEndpointSourceSet(resultingSourceSet)
val acceptedSourceSet = EndpointSourceSet(acceptedSources, acceptedGroups)
add(ConferenceSourceMap(owner to acceptedSourceSet))
return acceptedSourceSet
}
/**
* Attempts to remove [sourcesToRemove] as sources owned by [owner]. The attempt is successful if all referenced
* sources and groups are indeed owned by [owner], and the resulting set of sources for [owner] is valid (see
* [validateEndpointSourceSet]).
*
* If the attempt is successful, the sources are removed from [owner]'s sources and the set of removed sources is
* returned. The set of removed sources might differ from [sourcesToRemove] because:
* 1. We only match a source's SSRC, so a source added with parameters (MSID, etc) can be removed by simply
* referencing the SSRC.
* 2. We automatically remove any groups that contain any of the removed sources.
*
* If the attempt is unsuccessful, a [ValidationFailedException] is thrown and the state of the map remains
* unchanged.
*
* @throws ValidationFailedException if the attempt to remove sources is unsuccessful. The state of the map remains
* unchanged.
* @return The sources that have been removed.
*/
@Throws(ValidationFailedException::class)
fun tryToRemove(owner: String, sourcesToRemove: EndpointSourceSet): EndpointSourceSet = synchronized(syncRoot) {
if (sourcesToRemove.isEmpty()) return EndpointSourceSet.EMPTY
val existingSources = this[owner]
if (existingSources == null || existingSources.isEmpty()) {
throw SourceDoesNotExistException()
}
val sourcesAcceptedToBeRemoved = mutableSetOf<Source>()
sourcesToRemove.sources.forEach { source ->
// Be lenient and allow sources to be removed without matching the original parameters (media type, msid).
val existingSource = existingSources.sources.find { it.ssrc == source.ssrc }
?: throw SourceDoesNotExistException(source.ssrc)
sourcesAcceptedToBeRemoved.add(existingSource)
}
val ssrcsAcceptedToRemove = sourcesAcceptedToBeRemoved.map { it.ssrc }
if (!existingSources.ssrcGroups.containsAll(sourcesToRemove.ssrcGroups)) {
throw SourceGroupDoesNotExistException()
}
val groupsAcceptedToBeRemoved = mutableSetOf(*sourcesToRemove.ssrcGroups.toTypedArray())
// Also automatically remove groups some of whose sources are removed.
existingSources.ssrcGroups.forEach { existingGroup ->
if (existingGroup.ssrcs.any { ssrcsAcceptedToRemove.contains(it) }) {
groupsAcceptedToBeRemoved.add(existingGroup)
}
}
val resultingEndpointSourceSet = EndpointSourceSet(
existingSources.sources - sourcesAcceptedToBeRemoved,
existingSources.ssrcGroups - groupsAcceptedToBeRemoved
)
validateEndpointSourceSet(resultingEndpointSourceSet)
val acceptedSourceSet = EndpointSourceSet(sourcesAcceptedToBeRemoved, groupsAcceptedToBeRemoved)
remove(ConferenceSourceMap(owner to acceptedSourceSet))
return acceptedSourceSet
}
/** Override [add] to keep the additional [ssrcToOwnerMap] and [msidToOwnerMap] maps updated. */
override fun add(other: ConferenceSourceMap) = synchronized(syncRoot) {
super.add(other).also {
other.forEach { (owner, endpointSourceSet) -> sourceSetAdded(owner, endpointSourceSet) }
}
}
/** Override [add] to keep the additional [ssrcToOwnerMap] and [msidToOwnerMap] maps updated. */
override fun add(owner: String, endpointSourceSet: EndpointSourceSet) = synchronized(syncRoot) {
super.add(owner, endpointSourceSet).also {
sourceSetAdded(owner, endpointSourceSet)
}
}
private fun sourceSetAdded(owner: String, endpointSourceSet: EndpointSourceSet) = synchronized(syncRoot) {
endpointSourceSet.sources.forEach { source ->
ssrcToOwnerMap[source.ssrc] = owner
source.msid?.let {
msidToOwnerMap[it] = owner
}
}
}
/** Override [remove] to keep the additional [ssrcToOwnerMap] and [msidToOwnerMap] maps updated. */
override fun remove(other: ConferenceSourceMap) = synchronized(syncRoot) {
super.remove(other).also {
other.forEach { (owner, ownerRemovedSourceSet) -> sourceSetRemoved(owner, ownerRemovedSourceSet) }
}
}
/** Override [remove] to keep the additional [ssrcToOwnerMap] and [msidToOwnerMap] maps updated. */
override fun remove(owner: String): EndpointSourceSet? = synchronized(syncRoot) {
val ownerRemovedSourceSet = super.remove(owner)
ownerRemovedSourceSet?.let {
sourceSetRemoved(owner, it)
}
return ownerRemovedSourceSet
}
/**
* Update the local maps ([ssrcToOwnerMap] and [msidToOwnerMap] after the removal of a source set.
* @param owner the owner of the removed source set.
* @param endpointSourceSet the source set which has already been removed.
*/
private fun sourceSetRemoved(owner: String, endpointSourceSet: EndpointSourceSet) = synchronized(syncRoot) {
val ownerRemainingSourceSet = this[owner]
endpointSourceSet.sources.forEach { source ->
ssrcToOwnerMap.remove(source.ssrc)
source.msid?.let { sourceMsid ->
if (ownerRemainingSourceSet == null ||
ownerRemainingSourceSet.sources.none { it.msid == sourceMsid }
) {
msidToOwnerMap.remove(sourceMsid)
}
}
}
}
companion object {
/**
* Checks if a given [EndpointSourceSet] is internally valid. The checks include:
* -- FID groups have exactly 2 SSRCs
* -- Grouped sources have an MSID
* -- Sources within a group have the same MSID
* -- There are no MSID conflicts
*/
@Throws(ValidationFailedException::class)
private fun validateEndpointSourceSet(endpointSourceSet: EndpointSourceSet) {
endpointSourceSet.ssrcGroups.forEach { group ->
if (group.ssrcs.isEmpty()) {
throw IllegalStateException("Empty group should have been filtered out.")
}
var groupMsid: String? = null
group.ssrcs.forEach { ssrc ->
// We are doing a linear search in a list, but the list only has sources for one endpoint, so
// its size is limited to [maxSsrcsPerUser].
val source = endpointSourceSet.sources.find { it.ssrc == ssrc }
?: throw IllegalStateException(
"Groups with SSRCs that have no corresponding source should have been filtered out."
)
if (group.semantics == SsrcGroupSemantics.Fid && group.ssrcs.size != 2) {
throw InvalidFidGroupException(group.ssrcs)
}
if (source.msid == null) {
throw RequiredParameterMissingException("msid")
}
if (groupMsid == null) {
groupMsid = source.msid
}
if (source.msid != groupMsid) {
throw GroupMsidMismatchException(group.ssrcs)
}
}
}
// Audio and video sources can have the same MSID.
listOf(MediaType.AUDIO, MediaType.VIDEO).forEach { mediaType ->
// This groups all sources according to the extended group that they belong to, i.e. a SIM group gets
// associated with all of its sources plus the sources in FID groups associated with the SIM group's
// sources. For example given these groups:
// SIM(1, 2, 3)
// FID(1, 4)
// FID(2, 5)
// FID(3, 6)
// FID(111, 222)
// And non-grouped source 333 this will associate the SIM group with sources 1..6, the lonely FID group
// with sources 111 and 222, and a dummy single-source group with source 333.
//
// This is technically O(s*g^2) where s is the number of sources and g is the number of groups. But in
// practice both s and g are very small (at most 20 and 4 respectively).
val grouped: Map<SsrcGroup, List<Source>> =
endpointSourceSet.sources
.filter { it.mediaType == mediaType && it.msid != null }
.groupBy { groupBySimulcastGroup(it.ssrc, endpointSourceSet.ssrcGroups) }
// Here we check for MSID conflicts, i.e. we make sure that each group has a unique MSID.
val msidsSeen = mutableSetOf<String?>()
grouped.values.map { it[0] }.forEach { source ->
if (msidsSeen.contains(source.msid)) throw MsidConflictException(source.msid ?: "NONE")
msidsSeen.add(source.msid)
}
}
}
/** Returns the extended group to which an SSRC belongs. See [validateEndpointSourceSet]. */
private fun groupBySimulcastGroup(ssrc: Long, ssrcGroups: Set<SsrcGroup>): SsrcGroup {
val simGroup = ssrcGroups.find {
it.semantics == SsrcGroupSemantics.Sim && it.ssrcs.contains(ssrc)
}
if (simGroup != null) {
return simGroup
}
val fidGroup = ssrcGroups.find {
it.semantics == SsrcGroupSemantics.Fid && it.ssrcs.contains(ssrc)
}
if (fidGroup != null) {
if (ssrc == fidGroup.ssrcs[1]) {
val simGroup2 = ssrcGroups.find {
it.semantics == SsrcGroupSemantics.Sim && it.ssrcs.contains(fidGroup.ssrcs[0])
}
if (simGroup2 != null) {
return simGroup2
}
}
return fidGroup
}
// Dummy group just for this source.
return SsrcGroup(SsrcGroupSemantics.Sim, listOf(ssrc))
}
}
}
sealed class ValidationFailedException(message: String?) : Exception(message)
class InvalidSsrcException(ssrc: Long) : ValidationFailedException("Invalid SSRC: $ssrc.")
class SsrcLimitExceededException(limit: Int) : ValidationFailedException("SSRC limit ($limit) exceeded.")
class SsrcGroupLimitExceededException(limit: Int) : ValidationFailedException("ssrc-group limit ($limit) exceeded.")
class SsrcAlreadyUsedException(ssrc: Long) : ValidationFailedException("SSRC is already used: $ssrc.")
class RequiredParameterMissingException(name: String) : ValidationFailedException(
"Required source parameter '$name' is not present."
)
class GroupMsidMismatchException(ssrcs: List<Long>) : ValidationFailedException(
"SsrcGroup contains sources with different MSIDs: $ssrcs."
)
class MsidConflictException(msid: String) : ValidationFailedException("MSID is already used: $msid.")
class GroupContainsUnknownSourceException(groupSsrcs: List<Long>) : ValidationFailedException(
"An SSRC group contains an SSRC, which hasn't been signaled as a source: $groupSsrcs."
)
class InvalidFidGroupException(groupSsrcs: List<Long>) : ValidationFailedException("Invalid FID group: $groupSsrcs.")
class SourceDoesNotExistException(ssrc: Long? = null) : ValidationFailedException(
"Source does not exist or is not owned by endpoint ${ssrc?.let { "(ssrc=$it)" }}."
)
class SourceGroupDoesNotExistException : ValidationFailedException("Source group does not exist")

View File

@@ -0,0 +1,27 @@
/*
* Copyright @ 2022 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
enum class VideoType {
Camera,
Desktop;
override fun toString() = super.toString().lowercase()
companion object {
fun parseString(s: String?): VideoType = VideoType.values().find { it.name.equals(s, ignoreCase = true) }
?: throw IllegalArgumentException("Invalid VideoType: $s")
}
}

View File

@@ -0,0 +1,40 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2023 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.metrics
import java.lang.management.ManagementFactory
import org.jitsi.jicofo.metrics.JicofoMetricsContainer.Companion.instance as metricsContainer
class GlobalMetrics {
companion object {
@JvmField
val threadCount = metricsContainer.registerLongGauge(
"threads",
"The current number of JVM threads"
)
val xmppDisconnects = metricsContainer.registerCounter(
"xmpp_disconnects",
"The number of times one of the XMPP connections has disconnected."
)
fun update() {
threadCount.set(ManagementFactory.getThreadMXBean().threadCount.toLong())
}
}
}

View File

@@ -0,0 +1,67 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2026 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.metrics
import org.jitsi.jicofo.metrics.JicofoMetricsContainer.Companion.instance as metricsContainer
/**
* Metrics for the in-place ICE restart flow. They live here rather than next to either end of the flow because it
* spans modules: the request comes in in the `jicofo` module (Participant/JitsiMeetConferenceImpl) while the colibri2
* exchange with the bridge happens in `jicofo-selector`.
*
* The naming matches jitsi-videobridge's `ice_restarts_*` metrics, which cover the other half of the same flow.
*/
class IceRestartMetrics {
companion object {
/** A participant asked for an in-place ICE restart via session-info. */
@JvmField
val requested = metricsContainer.registerCounter(
"ice_restarts_requested",
"Number of in-place ICE restarts requested by a participant."
)
/** The bridge's rotated transport was signaled back to the participant in a Jingle transport-info. */
@JvmField
val relayed = metricsContainer.registerCounter(
"ice_restarts_relayed",
"Number of in-place ICE restarts for which the bridge's rotated transport was signaled to the participant."
)
/**
* The bridge answered with the transport it already had, because it did not need to restart (its own
* transport has not connected yet). Nothing was relayed and the participant keeps the session it has, so
* this is neither a completed restart nor a failed one.
*/
@JvmField
val notNeeded = metricsContainer.registerCounter(
"ice_restarts_not_needed",
"Number of in-place ICE restarts for which the bridge kept its existing transport."
)
/**
* An ICE restart did not complete: the request was rejected (disabled, rate-limited, stale bridge-session ID)
* or the bridge's response could not be relayed (no transport in the response, stale generation, participant
* or Jingle session gone).
*/
@JvmField
val failed = metricsContainer.registerCounter(
"ice_restarts_failed",
"Number of in-place ICE restarts that were rejected or could not be completed."
)
}
}

View File

@@ -0,0 +1,38 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2022 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.metrics
import org.jitsi.config.JitsiConfig
import org.jitsi.jicofo.TaskPools
import org.jitsi.metaconfig.config
import org.jitsi.metrics.MetricsContainer
import org.jitsi.metrics.MetricsUpdater
import java.time.Duration
class JicofoMetricsContainer private constructor() : MetricsContainer(namespace = "jitsi_jicofo") {
val metricsUpdater = MetricsUpdater(TaskPools.scheduledPool, updateInterval)
companion object {
private val updateInterval: Duration by config {
"jicofo.metrics.update-interval".from(JitsiConfig.newConfig)
}
@JvmStatic
val instance = JicofoMetricsContainer()
}
}

View File

@@ -0,0 +1,53 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.util
import org.jitsi.utils.ms
import org.jitsi.utils.stats.RateTracker
import java.time.Clock
import java.time.Duration
/** Keep a count of some events which have been triggered to happen within some interval, but which
* may or may not happen, and thus should expire from the count after some time if they have not.
* The intended use case is visitors who have been invited to a visitor ChatRoom.
*/
class PendingCount(
timeout: Duration,
clock: Clock = Clock.systemUTC()
) {
private var occurredEvents = 0L
private var tracker = object : RateTracker(timeout, 100.ms, clock) {
override fun bucketExpired(count: Long) {
occurredEvents = (occurredEvents - count).coerceAtLeast(0)
}
}
@Synchronized
fun eventPending() {
tracker.update(1)
}
@Synchronized
fun eventOccurred() {
occurredEvents = (occurredEvents + 1).coerceAtMost(tracker.getAccumulatedCount())
}
@Synchronized
fun getCount(): Long = tracker.getAccumulatedCount() - occurredEvents
}

View File

@@ -0,0 +1,33 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2020 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.util
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
class SynchronizedDelegate<T>(defaultValue: T, private val syncRoot: Any) : ReadWriteProperty<Any, T> {
private var backingField = defaultValue
override fun getValue(thisRef: Any, property: KProperty<*>): T = synchronized(syncRoot) {
backingField
}
override fun setValue(thisRef: Any, property: KProperty<*>, value: T) = synchronized(syncRoot) {
backingField = value
}
}

View File

@@ -0,0 +1,25 @@
package org.jitsi.jicofo.util
import io.opentelemetry.api.common.Attributes
import org.jitsi.jicofo.xmpp.muc.ChatRoom
import org.jitsi.jicofo.xmpp.muc.ChatRoomMember
import java.util.Objects
class TracingUtil {
companion object {
@JvmStatic
fun memberAttributes(member: ChatRoomMember): Attributes = Attributes.builder()
.put("member.name", member.name)
.put("member.id", Objects.toString(member.jid))
.put("member.role", member.role.toString())
.put("member.region", Objects.toString(member.region))
.build()
@JvmStatic
fun roomAttributes(room: ChatRoom): Attributes = Attributes.builder()
.put("room.id", Objects.toString(room.roomJid))
.put("room.members", room.memberCount.toString())
.put("room.visitors", room.visitorCount.toString())
.build()
}
}

View File

@@ -0,0 +1,51 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2022 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.util
import java.lang.ref.WeakReference
import java.util.concurrent.ConcurrentHashMap
/**
* Self-cleaning map with weak values.
* TODO: maybe move to jitsi-utils
*/
class WeakValueMap<K, V>(
private val cleanInterval: Int = 100
) {
private val map: MutableMap<K, WeakReference<V>> = ConcurrentHashMap<K, WeakReference<V>>()
private var i = 0
fun get(key: K) = map[key]?.get().also {
maybeClean()
}
fun put(key: K, value: V) {
map[key] = WeakReference(value)
maybeClean()
}
fun containsKey(key: K): Boolean = (map[key]?.get() != null).also {
maybeClean()
}
fun remove(key: K) = map.remove(key)?.get()
fun values(): List<V> {
clean()
return map.values.mapNotNull { it.get() }.toList()
}
private fun maybeClean() = ((i++ % cleanInterval == 0)) && clean()
private fun clean() = map.values.removeIf { it.get() == null }
}

View File

@@ -0,0 +1,137 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp
import org.jitsi.jicofo.xmpp.IqProcessingResult.AcceptedWithResponse
import org.jivesoftware.smack.AbstractXMPPConnection
import org.jivesoftware.smack.XMPPConnection
import org.jivesoftware.smack.iqrequest.AbstractIqRequestHandler
import org.jivesoftware.smack.iqrequest.IQRequestHandler
import org.jivesoftware.smack.packet.ErrorIQ
import org.jivesoftware.smack.packet.IQ
import org.jivesoftware.smack.packet.StanzaError
import java.lang.IllegalArgumentException
/**
* Implements an IQ handler registered with a specific set of [XMPPConnection]s. Hides the Smack-level API and exposes
* just [handleRequest], which uses the specific [IQ] subtype ([T]) and provides the connection on which it was received
* as a parameter.
*/
abstract class AbstractIqHandler<T : IQ>(
/**
* The set of connections to which to register.
*/
connections: Set<AbstractXMPPConnection>,
/**
* The name of the IQ child element for this handler. It must match the element name returned by [T] instances.
*/
elementName: String,
/**
* The namespace of the IQ child element for this handler. It must match the namespace returned by [T] instances.
*/
elementNamespace: String,
iqTypes: Set<IQ.Type> = setOf(IQ.Type.set, IQ.Type.get),
mode: IQRequestHandler.Mode = IQRequestHandler.Mode.sync
) {
/**
* One handler for each (connection, iqType) pair. These are the actual instances registered with the
* [XMPPConnection]s.
*/
private val handlers: List<IQRequestHandlerImpl> = connections.flatMap { connection ->
iqTypes.map { iqType ->
IQRequestHandlerImpl(connection, elementName, elementNamespace, iqType, mode).also {
connection.registerIQRequestHandler(it)
}
}
}
fun shutdown() = handlers.forEach { it.connection.unregisterIQRequestHandler(it) }
/**
* Handle a request that was received on one of the [XMPPConnection]s.
*
* @return A non-null value to be sent as a response (should be either an IQ of type `result`, or an Error), or
* `null` if no response is to be sent immediately. If this returns `null`, the code handling the IQ takes the
* responsibility of (eventually) sending a response. The response, whether returned by the method or sent in
* another way, should have the correct properties for an XMPP response (`from` and `to` swapped, matching `id`,
* and type `result` if the response is an IQ).
*/
abstract fun handleRequest(request: IqRequest<T>): IqProcessingResult
private inner class IQRequestHandlerImpl(
val connection: AbstractXMPPConnection,
elementName: String,
elementNamespace: String,
iqType: IQ.Type,
mode: IQRequestHandler.Mode
) : AbstractIqRequestHandler(elementName, elementNamespace, iqType, mode) {
/**
* Handle a request. The cast can not be checked because the type is erased, but we perform the cast here, so
* any exceptions will occur early and not cause further problems.
*/
@Suppress("UNCHECKED_CAST")
override fun handleIQRequest(iq: IQ): IQ? {
val result = handleRequest(
IqRequest(
iq as? T ?: throw IllegalArgumentException("Unexpected IQ type: ${iq::class}"),
connection
)
)
return when (result) {
is AcceptedWithResponse -> result.response
is IqProcessingResult.AcceptedWithNoResponse -> null
is IqProcessingResult.RejectedWithError -> result.response
is IqProcessingResult.NotProcessed ->
IQ.createErrorResponse(iq, StanzaError.Condition.feature_not_implemented)
}
}
}
}
/** An IQ received on a specific [XMPPConnection] */
data class IqRequest<T : IQ>(val iq: T, val connection: AbstractXMPPConnection)
sealed class IqProcessingResult {
/** The IQ was accepted/handled. The given `response` should be sent as a response. */
class AcceptedWithResponse(val response: IQ) : IqProcessingResult()
/**
* The IQ was accepted/handled, but no response is available (yet). The handler is responsible for eventually
* sending a response by other means.
* */
class AcceptedWithNoResponse : IqProcessingResult()
/** The IQ was handled, but it resulted in an error. The given error `response` should be sent as a response. */
class RejectedWithError(val response: ErrorIQ) : IqProcessingResult() {
constructor(
request: IqRequest<*>,
condition: StanzaError.Condition
) : this(request.iq, condition)
constructor(
iq: IQ,
condition: StanzaError.Condition
) : this(IQ.createErrorResponse(iq, StanzaError.getBuilder(condition).build()))
}
/** The IQ was not handled. */
class NotProcessed : IqProcessingResult()
}

View File

@@ -0,0 +1,94 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2020 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp
enum class Features(val value: String) {
AUDIO("urn:xmpp:jingle:apps:rtp:audio"),
VIDEO("urn:xmpp:jingle:apps:rtp:video"),
/** DTLS/SCTP feature name. */
SCTP("urn:xmpp:jingle:transports:dtls-sctp:1"),
/** RTX (RFC4588) support. */
RTX("urn:ietf:rfc:4588"),
/** RTCP REMB. */
REMB("http://jitsi.org/remb"),
/** Transport-wide congestion control. */
TCC("http://jitsi.org/tcc"),
/** Source name signalling. */
SOURCE_NAMES("http://jitsi.org/source-name"),
SSRC_REWRITING_V1("http://jitsi.org/ssrc-rewriting-1"),
/** The endpoint can demux forwarded media by the RTP sdes:mid header extension (requires SSRC rewriting). */
RTP_MID_DEMUX("http://jitsi.org/rtp-mid-demux"),
RECEIVE_MULTIPLE_STREAMS("http://jitsi.org/receive-multiple-video-streams"),
/** Jingle sources encoded as JSON. */
JSON_SOURCES("http://jitsi.org/json-encoded-sources"),
OPUS_RED("http://jitsi.org/opus-red"),
AUDIO_MUTE("http://jitsi.org/protocol/audio-mute"),
AUDIO_TRANSLATION("http://jitsi.org/protocol/audio-translation"),
// The ones below are not used in jicofo, but are defined here to improve the xmpp-caps statistics and avoid logs
// about unknown features.
JIBRI("http://jitsi.org/protocol/jibri"),
JIGASI("http://jitsi.org/protocol/jigasi"),
LIPSYNC("http://jitsi.org/meet/lipsync"),
E2EE("https://jitsi.org/meet/e2ee"),
XMPP_CAPS("http://jabber.org/protocol/caps"),
JINGLE("urn:xmpp:jingle:1"),
JINGLE_RTP("urn:xmpp:jingle:apps:rtp:1"),
JINGLE_ICE("urn:xmpp:jingle:transports:ice-udp:1"),
DTLS("urn:xmpp:jingle:apps:dtls:0"),
RTCPMUX("urn:ietf:rfc:5761"),
BUNDLE("urn:ietf:rfc:5888"),
RAYO("urn:xmpp:rayo:client:1"),
REMOTECONTROL("http://jitsi.org/meet/remotecontrol"),
// Supports handling "start muted" via room metadata (instead of via jingle).
START_MUTED_RMD("http://jitsi.org/start-muted-room-metadata"),
VISITORS_V1("http://jitsi.org/visitors-1"),
/** The endpoint understands the "jitsi:client-requirements" IQ, used to signal missing capabilities. */
CLIENT_REQUIREMENTS_1("http://jitsi.org/client-requirements-1");
companion object {
val defaultFeatures = setOf(AUDIO, VIDEO, SCTP)
/** Parse a string and silently return null if not recognized. */
fun parseString(s: String): Features? = values().find { it.value == s }
}
}
/**
* The result of a feature discovery (disco#info) for an endpoint.
*/
data class FeatureDiscoveryResult(
val features: Set<Features>,
/**
* Whether the features were actually discovered. When this is false [features] is just an assumed default set,
* and the absence of a feature from it does not mean that the endpoint does not support the feature.
*/
val discovered: Boolean
)
/** Used when the features of an endpoint could not be discovered. */
val featuresNotDiscovered = FeatureDiscoveryResult(Features.defaultFeatures, false)

View File

@@ -0,0 +1,43 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp
import org.jitsi.jicofo.xmpp.jingle.JingleSession
import org.jitsi.xmpp.extensions.jingle.ContentPacketExtension
import org.jitsi.xmpp.extensions.jingle.JingleAction
import org.jitsi.xmpp.extensions.jingle.JingleIQ
import org.jivesoftware.smack.packet.IQ
import org.jxmpp.jid.Jid
fun createSessionInitiate(from: Jid, to: Jid, sid: String, contents: List<ContentPacketExtension>) =
JingleIQ(JingleAction.SESSION_INITIATE, sid).apply {
this.from = from
this.to = to
initiator = from
type = IQ.Type.set
contents.forEach { addContent(it) }
}
fun createTransportReplace(from: Jid, session: JingleSession, contents: List<ContentPacketExtension>) =
JingleIQ(JingleAction.TRANSPORT_REPLACE, session.sid).apply {
this.from = from
this.to = session.remoteJid
initiator = from
type = IQ.Type.set
contents.forEach { addContent(it) }
}

View File

@@ -0,0 +1,132 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2025-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.JsonMappingException
import com.fasterxml.jackson.databind.MapperFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import org.jitsi.jicofo.MediaType
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes(
JsonSubTypes.Type(value = AvModerationMessage::class, name = AvModerationMessage.TYPE),
JsonSubTypes.Type(value = RoomMetadata::class, name = RoomMetadata.TYPE)
)
@JsonIgnoreProperties(ignoreUnknown = true)
sealed class JsonMessage(val type: String) {
companion object {
private val mapper = jacksonObjectMapper().apply {
enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS)
enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION)
}
@JvmStatic
@Throws(JsonProcessingException::class, JsonMappingException::class)
fun parse(string: String): JsonMessage = mapper.readValue(string)
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class AvModerationMessage(
val room: String?,
val enabled: Boolean? = null,
val mediaType: MediaType? = null,
val actor: String? = null,
val whitelists: Map<MediaType, List<String>>? = null
) : JsonMessage(TYPE) {
companion object {
const val TYPE = "av_moderation"
}
}
/**
* The JSON structure included in the MUC config form from the room_metadata prosody module in jitsi-meet. Includes
* only the fields that we need here in jicofo.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
data class RoomMetadata(val metadata: Metadata?) : JsonMessage(TYPE) {
@JsonIgnoreProperties(ignoreUnknown = true)
data class Metadata(
val visitors: Visitors?,
val startMuted: StartMuted?,
val moderators: List<String>?,
val participants: List<String>?,
val recording: Recording?,
val asyncTranscription: Boolean? = null,
val participantsSoftLimit: Int? = null,
val visitorsEnabled: Boolean? = null,
val lobbyEnabled: Boolean? = null,
val transcription: Transcription? = null,
/**
* Per-room live-translation connect config (e.g. a per-customer usage token as an HTTP header,
* delivered to jicofo on the admin-only metadata path). Mirrors [transcription].
*/
val translation: Translation? = null,
/** Aggregated live-translation requests: sender endpoint id -> set of requested language codes. */
val audioTranslationRequests: Map<String, List<String>>? = null
) {
@JsonIgnoreProperties(ignoreUnknown = true)
data class Visitors(
val live: Boolean?,
/**
* The languages the visitors want transcriptions translated into, as a comma-separated list, or an empty
* string when they want none.
*
* Visitors are in a MUC on a visitor node, so their presence is not visible here. Instead each visitor
* node's mod_fmuc collects their requested languages and sends them to the main prosody, which aggregates
* across nodes into this field (mod_visitors_component). This is the same field jigasi reads.
*/
val transcribingLanguages: String? = null,
/** How many visitors are requesting transcription, across all visitor nodes. */
val transcribingCount: Long? = null
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class StartMuted(val audio: Boolean?, val video: Boolean?)
@JsonIgnoreProperties(ignoreUnknown = true)
data class Recording(val isTranscribingEnabled: Boolean?)
@JsonIgnoreProperties(ignoreUnknown = true)
data class Transcription(
val urlParams: Map<String, String>? = null,
val httpHeaders: Map<String, String>? = null
) {
override fun toString(): String = "Transcription(urlParams=${urlParams?.mapValues { "***" }}, " +
"httpHeaders=${httpHeaders?.mapValues { "***" }})"
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class Translation(
val httpHeaders: Map<String, String>? = null
) {
override fun toString(): String = "Translation(httpHeaders=${httpHeaders?.mapValues { "***" }})"
}
}
companion object {
const val TYPE = "room_metadata"
}
}

View File

@@ -0,0 +1,76 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp
import org.jitsi.utils.logging2.LoggerImpl
import org.jivesoftware.smack.AbstractXMPPConnection
import org.jivesoftware.smack.SmackException
import org.jivesoftware.smack.XMPPConnection
import org.jivesoftware.smack.packet.IQ
import org.jivesoftware.smack.packet.Stanza
import org.jxmpp.jid.DomainBareJid
import org.jxmpp.jid.Jid
import org.jxmpp.jid.impl.JidCreate
import org.jxmpp.stringprep.XmppStringprepException
private val logger = LoggerImpl("org.jitsi.jicofo.xmpp.Util")
/**
* Reads the original jid as encoded in the resource part by mod_client_proxy, returns the original jid if it format
* is not as expected.
*/
fun parseJidFromClientProxyJid(
/**
* The JID of the client_proxy component.
*/
clientProxy: DomainBareJid?,
/**
* The JID to parse.
*/
jid: Jid
): Jid {
clientProxy ?: return jid
if (clientProxy == jid.asDomainBareJid()) {
jid.resourceOrNull?.let { resource ->
return try {
JidCreate.from(resource.toString())
} catch (e: XmppStringprepException) {
jid
}
}
}
return jid
}
fun XMPPConnection.tryToSendStanza(stanza: Stanza) = try {
sendStanza(stanza)
} catch (e: SmackException.NotConnectedException) {
logger.error("No connection - unable to send packet: ${stanza.toXML()}", e)
} catch (e: InterruptedException) {
logger.error("Failed to send packet: ${stanza.toXML()}", e)
}
@Throws(SmackException.NotConnectedException::class)
fun AbstractXMPPConnection.sendIqAndGetResponse(iq: IQ): IQ? = createStanzaCollectorAndSend(iq).let {
try {
it.nextResult()
} finally {
it.cancel()
}
}

View File

@@ -0,0 +1,69 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2022-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.jitsi.utils.logging2.createLogger
private val jsonMapper = jacksonObjectMapper()
class XmppCapsStats {
companion object {
/**
* Maps a nodeVer string (the "node" and "ver" attributes from a caps extension (XEP-0115) joined by "#") to
* the associated set of features and a counter for the number of participants with that nodeVer.
*/
private val map: MutableMap<String, FeaturesAndCount> = mutableMapOf()
private const val MAX_ENTRIES = 1000
private val logger = createLogger()
@JvmStatic
val stats: ObjectNode
get() = JsonNodeFactory.instance.objectNode().apply {
synchronized(map) {
map.forEach { (nodeVer, e) ->
set<ObjectNode>(nodeVer, e.json())
}
}
}
fun update(nodeVer: String, features: Set<Features>) {
synchronized(map) {
if (map.size < MAX_ENTRIES) {
map.computeIfAbsent(nodeVer) { FeaturesAndCount(features) }.count++
} else {
map[nodeVer]?.let {
it.count++
} ?: logger.warn("Too many entries. Ignoring nodeVer=$nodeVer, features=$features")
}
}
}
}
private class FeaturesAndCount(val features: Set<Features>) {
/** The number of participants seen with this set of features. */
var count = 0
fun json(): ObjectNode = JsonNodeFactory.instance.objectNode().apply {
put("count", count)
set<ArrayNode>("features", jsonMapper.valueToTree(features.map { it.name }))
}
}
}

View File

@@ -0,0 +1,298 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2020 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp
import com.typesafe.config.ConfigObject
import com.typesafe.config.ConfigValue
import org.jitsi.config.JitsiConfig.Companion.legacyConfig
import org.jitsi.config.JitsiConfig.Companion.newConfig
import org.jitsi.metaconfig.ConfigException
import org.jitsi.metaconfig.ConfigException.UnableToRetrieve.NotFound
import org.jitsi.metaconfig.config
import org.jitsi.metaconfig.optionalconfig
import org.jitsi.utils.secs
import org.jxmpp.jid.DomainBareJid
import org.jxmpp.jid.impl.JidCreate
import org.jxmpp.jid.parts.Resourcepart
import java.time.Duration
class XmppConfig private constructor() {
val trustedDomains: List<DomainBareJid> by config {
"jicofo.xmpp.trusted-domains".from(newConfig)
.convertFrom<List<String>> { l -> l.map { JidCreate.domainBareFrom(it) } }
}
val useJitsiJidValidation: Boolean by config {
"jicofo.xmpp.use-jitsi-jid-validation".from(newConfig)
}
companion object {
@JvmField
val service = XmppServiceConnectionConfig()
@JvmField
val client = XmppClientConnectionConfig()
@JvmStatic
val visitors: Map<String, XmppVisitorConnectionConfig> by config {
"jicofo.xmpp.visitors".from(newConfig)
.convertFrom<ConfigObject> { cfg ->
cfg.entries.associate {
val xmppConfig = it.toXmppVisitorConnectionConfig()
Pair(xmppConfig.name, xmppConfig)
}
}
}
@JvmField
val config = XmppConfig()
}
}
interface XmppConnectionConfig {
val enabled: Boolean
val hostname: String
val port: Int
val domain: DomainBareJid
val username: Resourcepart
val resource: Resourcepart
val password: String?
val replyTimeout: Duration
val disableCertificateVerification: Boolean
val useTls: Boolean
val name: String
val xmppDomain: DomainBareJid?
val jid
get() = "$username@$domain"
}
class XmppVisitorConnectionConfig(
override val enabled: Boolean,
override val hostname: String,
override val port: Int,
override val domain: DomainBareJid,
override val username: Resourcepart,
override val resource: Resourcepart,
override val password: String?,
override val replyTimeout: Duration,
override val disableCertificateVerification: Boolean,
override val useTls: Boolean,
override val name: String,
override val xmppDomain: DomainBareJid?,
val conferenceService: DomainBareJid
) : XmppConnectionConfig
private fun MutableMap.MutableEntry<String, ConfigValue>.toXmppVisitorConnectionConfig(): XmppVisitorConnectionConfig {
val name = this.key
val c = value as? ConfigObject ?: throw ConfigException.UnsupportedType("visitors config must be an object")
val hostname = c["hostname"]?.unwrapped()?.toString()
?: throw NotFound("hostname required for visitors config $name")
val domain = c["domain"]?.unwrapped()?.toString() ?: hostname
val xmppDomain = c["xmpp-domain"]?.unwrapped()?.toString()
val username = c["username"]?.unwrapped()?.toString() ?: "focus"
val conferenceService = c["conference-service"]?.unwrapped()?.toString()
?: throw NotFound("conference-service required for visitors config $name")
return XmppVisitorConnectionConfig(
enabled = c["enabled"]?.let { it.unwrapped().toString().toBoolean() } ?: true,
hostname = c["hostname"]?.unwrapped()?.toString()
?: throw NotFound("hostname required for visitors config $name"),
port = c["port"]?.unwrapped()?.toString()?.toInt() ?: 5222,
domain = JidCreate.domainBareFrom(domain),
username = Resourcepart.from(username),
resource = Resourcepart.from(c["resource"]?.unwrapped()?.toString() ?: username),
password = c["password"]?.unwrapped()?.toString(),
replyTimeout = c["reply-timeout"]?.unwrapped() as? Duration ?: 15.secs,
disableCertificateVerification = c["disable-certificate-verification"]?.unwrapped()?.toString()?.toBoolean()
?: false,
useTls = c["use-tls"]?.unwrapped()?.toString()?.toBoolean() ?: true,
name = name,
conferenceService = JidCreate.domainBareFrom(conferenceService),
xmppDomain = if (xmppDomain != null) JidCreate.domainBareFrom(xmppDomain) else null
)
}
class XmppServiceConnectionConfig : XmppConnectionConfig {
override val enabled: Boolean by config {
// If the legacy host is set to anything, the connection is enabled.
"org.jitsi.jicofo.BRIDGE_MUC_XMPP_HOST".from(legacyConfig).convertFrom<String> { true }
"jicofo.xmpp.service.enabled".from(newConfig)
}
override val hostname: String by config {
"org.jitsi.jicofo.BRIDGE_MUC_XMPP_HOST".from(legacyConfig)
"jicofo.xmpp.service.hostname".from(newConfig)
}
override val port: Int by config {
"org.jitsi.jicofo.BRIDGE_MUC_XMPP_PORT".from(legacyConfig)
"jicofo.xmpp.service.port".from(newConfig)
}
override val domain: DomainBareJid by config {
"org.jitsi.jicofo.BRIDGE_MUC_XMPP_USER_DOMAIN".from(legacyConfig).convertFrom<String> {
JidCreate.domainBareFrom(it)
}
"jicofo.xmpp.service.domain".from(newConfig).convertFrom<String> {
JidCreate.domainBareFrom(it)
}
}
override val xmppDomain: DomainBareJid? by optionalconfig {
"jicofo.xmpp.service.xmpp-domain".from(newConfig)
}
override val username: Resourcepart by config {
"org.jitsi.jicofo.BRIDGE_MUC_XMPP_USER".from(legacyConfig).convertFrom<String> {
Resourcepart.from(it)
}
"jicofo.xmpp.service.username".from(newConfig).convertFrom<String> {
Resourcepart.from(it)
}
}
override val resource: Resourcepart by config {
"jicofo.xmpp.service.resource".from(newConfig).convertFrom<String> {
Resourcepart.from(it)
}
}
override val password: String? by optionalconfig {
"org.jitsi.jicofo.BRIDGE_MUC_XMPP_USER_PASS".from(legacyConfig)
"jicofo.xmpp.service.password".from(newConfig)
}
override val replyTimeout: Duration by config {
"jicofo.xmpp.service.reply-timeout".from(newConfig)
}
override val disableCertificateVerification: Boolean by config {
"org.jitsi.jicofo.ALWAYS_TRUST_MODE_ENABLED".from(legacyConfig)
"jicofo.xmpp.service.disable-certificate-verification".from(newConfig)
}
override val useTls: Boolean by config {
"jicofo.xmpp.service.use-tls".from(newConfig)
}
override fun toString(): String = "XmppServiceConnectionConfig[hostname=$hostname, port=$port, username=$username]"
override val name = "service"
}
class XmppClientConnectionConfig : XmppConnectionConfig {
override val enabled: Boolean by config {
LEGACY_HOSTNAME_PROPERTY_NAME.from(legacyConfig).convertFrom<String> { true }
"jicofo.xmpp.client.enabled".from(newConfig)
}
override val hostname: String by config {
LEGACY_HOSTNAME_PROPERTY_NAME.from(legacyConfig)
"jicofo.xmpp.client.hostname".from(newConfig)
}
override val port: Int by config {
"org.jitsi.jicofo.XMPP_PORT".from(legacyConfig)
"jicofo.xmpp.client.port".from(newConfig)
}
/**
* This is the domain used for login. Not necessarily the root XMPP domain.
*/
override val domain: DomainBareJid by config {
LEGACY_DOMAIN_PROPERTY_NAME.from(legacyConfig).convertFrom<String> {
JidCreate.domainBareFrom(it)
}
"jicofo.xmpp.client.domain".from(newConfig).convertFrom<String> {
JidCreate.domainBareFrom(it)
}
}
override val username: Resourcepart by config {
LEGACY_USERNAME_PROPERTY_NAME.from(legacyConfig).convertFrom<String> {
Resourcepart.from(it)
}
"jicofo.xmpp.client.username".from(newConfig).convertFrom<String> {
Resourcepart.from(it)
}
}
override val resource: Resourcepart by config {
"jicofo.xmpp.client.resource".from(newConfig).convertFrom<String> {
Resourcepart.from(it)
}
}
override val password: String? by optionalconfig {
LEGACY_PASSWORD_PROPERTY_NAME.from(legacyConfig)
"jicofo.xmpp.client.password".from(newConfig)
}
/**
* This is the top-level domain hosted by the XMPP server (not necessarily the one used for login).
*/
override val xmppDomain: DomainBareJid by config {
LEGACY_XMPP_DOMAIN_PROPERTY_NAME.from(legacyConfig).convertFrom<String> {
JidCreate.domainBareFrom(it)
}
"jicofo.xmpp.client.xmpp-domain".from(newConfig).convertFrom<String> {
JidCreate.domainBareFrom(it)
}
}
val conferenceMucJid: DomainBareJid by config {
"org.jitsi.jicofo.XMPP_MUC_COMPONENT_PREFIX".from(legacyConfig).convertFrom<String> {
JidCreate.domainBareFrom("$it.$xmppDomain")
}
"jicofo.xmpp.client.conference-muc-jid".from(newConfig).convertFrom<String> {
JidCreate.domainBareFrom(it)
}
"default" { JidCreate.domainBareFrom("conference.$xmppDomain") }
}
override val replyTimeout: Duration by config {
"jicofo.xmpp.client.reply-timeout".from(newConfig)
}
override val disableCertificateVerification: Boolean by config {
"org.jitsi.jicofo.ALWAYS_TRUST_MODE_ENABLED".from(legacyConfig)
"jicofo.xmpp.client.disable-certificate-verification".from(newConfig)
}
override val useTls: Boolean by config {
"jicofo.xmpp.client.use-tls".from(newConfig)
}
val clientProxy: DomainBareJid? by optionalconfig {
"jicofo.xmpp.client.client-proxy".from(newConfig).convertFrom<String> {
JidCreate.domainBareFrom(it)
}
}
override fun toString(): String = "XmppClientConnectionConfig[hostname=$hostname, port=$port, username=$username]"
override val name = "client"
companion object {
const val LEGACY_HOSTNAME_PROPERTY_NAME = "org.jitsi.jicofo.HOSTNAME"
const val LEGACY_DOMAIN_PROPERTY_NAME = "org.jitsi.jicofo.FOCUS_USER_DOMAIN"
const val LEGACY_USERNAME_PROPERTY_NAME = "org.jitsi.jicofo.FOCUS_USER_NAME"
const val LEGACY_PASSWORD_PROPERTY_NAME = "org.jitsi.jicofo.FOCUS_USER_PASSWORD"
const val LEGACY_XMPP_DOMAIN_PROPERTY_NAME = "org.jitsi.jicofo.XMPP_DOMAIN"
}
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp
enum class XmppConnectionEnum { Client, Service }

View File

@@ -0,0 +1,390 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2020 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp
import org.jitsi.impl.protocol.xmpp.log.PacketDebugger
import org.jitsi.jicofo.TaskPools
import org.jitsi.jicofo.metrics.GlobalMetrics
import org.jitsi.jicofo.xmpp.XmppProvider.RoomExistsException
import org.jitsi.jicofo.xmpp.muc.ChatRoom
import org.jitsi.jicofo.xmpp.muc.ChatRoomImpl
import org.jitsi.utils.logging2.Logger
import org.jitsi.utils.logging2.createChildLogger
import org.jivesoftware.smack.AbstractXMPPConnection
import org.jivesoftware.smack.ConnectionConfiguration
import org.jivesoftware.smack.ConnectionListener
import org.jivesoftware.smack.ReconnectionListener
import org.jivesoftware.smack.ReconnectionManager
import org.jivesoftware.smack.SmackException
import org.jivesoftware.smack.XMPPConnection
import org.jivesoftware.smack.tcp.XMPPTCPConnection
import org.jivesoftware.smack.tcp.XMPPTCPConnectionConfiguration
import org.jivesoftware.smackx.caps.EntityCapsManager
import org.jivesoftware.smackx.disco.ServiceDiscoveryManager
import org.jxmpp.jid.DomainBareJid
import org.jxmpp.jid.EntityBareJid
import org.jxmpp.jid.EntityFullJid
import java.security.cert.X509Certificate
import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.logging.Level
import javax.net.ssl.X509TrustManager
/** Wraps a Smack [XMPPConnection]. */
class XmppProvider(val config: XmppConnectionConfig, parentLogger: Logger) {
private val logger: Logger = createChildLogger(parentLogger).apply {
addContext("xmpp_connection", config.name)
}
/** A task which will keep trying to connect to the XMPP server at a fixed 1-second delay. Smack's
* ReconnectionManager only kicks in once a connection suceeds. */
private var connectTask: ScheduledFuture<*>? = null
private val connectSyncRoot = Any()
/** A list of all listeners registered with this instance. */
private val listeners = CopyOnWriteArraySet<Listener>()
/** The Smack [XMPPConnection] used by this instance. */
val xmppConnection = createXmppConnection(config, logger)
private val started = AtomicBoolean(false)
var registered = false
private set(value) {
if (value != field) {
field = value
fireRegistrationStateChanged(value)
}
}
var components: Set<Component> = emptySet()
private set(value) {
if (value != field) {
field = value
logger.warn("Discovered components: $field")
fireComponentsChanged(value)
}
}
private val muc = Muc(this)
/** Listens to re-connection status updates. */
private val reconnectionListener = object : ReconnectionListener {
override fun reconnectingIn(seconds: Int) {
logger.info("XMPP reconnecting in: $seconds seconds.")
}
override fun reconnectionFailed(e: Exception) {
logger.error("XMPP reconnection failed: ${e.message}", e)
if (xmppConnection.isConnected) {
xmppConnection.disconnect()
// In some after cases with Stream Management Smack's ReconnectionManager gives up. Make sure we keep
// trying.
scheduleConnectTask()
}
}
}
/** Start/restart the connect task. */
private fun scheduleConnectTask() = synchronized(connectSyncRoot) {
val delay = 1L
connectTask?.cancel(true)
connectTask = TaskPools.scheduledPool.scheduleAtFixedRate({
try {
if (doConnect()) {
logger.warn("Failed to connect, will re-try after $delay second")
} else {
logger.info("Connected.")
synchronized(connectSyncRoot) {
connectTask?.cancel(false)
connectTask = null
}
}
} catch (e: Exception) {
logger.error("Failed to connect: ${e.message}, will re-try after $delay second", e)
}
}, 0, delay, TimeUnit.SECONDS)
}
private val connectionListener = object : ConnectionListener {
override fun authenticated(connection: XMPPConnection?, resumed: Boolean) {
registered = true
logger.info(
"Registered (resumed=$resumed)." + if (connection is XMPPTCPConnection) {
" isSmEnabled:" + connection.isSmEnabled +
" isSmAvailable:" + connection.isSmAvailable +
" isSmResumptionPossible:" + connection.isSmResumptionPossible
} else {
""
}
)
config.xmppDomain?.let {
logger.info("Will discover components for $it")
TaskPools.ioPool.submit { discoverComponents(it) }
} ?: run {
logger.info("No xmpp-domain configured, will not discover components.")
}
}
override fun connectionClosed() {
logger.info("XMPP connection closed")
GlobalMetrics.xmppDisconnects.inc()
registered = false
}
override fun connectionClosedOnError(e: Exception) {
logger.error("XMPP connection closed on error: ${e.message}", e)
GlobalMetrics.xmppDisconnects.inc()
registered = false
}
}
override fun toString(): String = "XmppProvider[$config]"
fun start() {
if (!started.compareAndSet(false, true)) {
logger.info("Already started.")
} else {
scheduleConnectTask()
}
}
fun shutdown() {
if (!started.compareAndSet(true, false)) {
logger.info("Already stopped or not started.")
} else {
synchronized(this) {
xmppConnection.disconnect()
logger.info("Disconnected.")
xmppConnection.removeConnectionListener(connectionListener)
}
registered = false
}
}
/**
* Registers the specified listener with this provider so that it would receive notifications on changes of its
* state.
*/
fun addListener(listener: Listener) = listeners.add(listener)
/** Removes the specified listener. */
fun removeListener(listener: Listener) = listeners.remove(listener)
/**
* Tries to establish the XMPP connection.
*
* @return `true` if another attempt should be performed, and `false` otherwise. Returns `false` (i.e. success) if
* either the connection was successful or the provider hasn't been started.
*/
private fun doConnect(): Boolean {
if (!started.get()) {
return false
}
synchronized(this) {
return try {
xmppConnection.connect()
logger.info("Connected, JID=${xmppConnection.user}")
// XXX Is there a reason we add listeners *after* we call connect()?
xmppConnection.addConnectionListener(connectionListener)
ReconnectionManager.getInstanceFor(xmppConnection)?.addReconnectionListener(reconnectionListener)
if (config.password != null) {
val login = config.username.toString()
val pass = config.password
val resource = config.resource
xmppConnection.login(login, pass, resource)
}
false
} catch (e: java.lang.Exception) {
logger.error("Failed to connect/login: ${e.message}", e)
// If the connect part succeeded, but login failed we don't want to
// rely on Smack's built-in retries, as it will be handled by
// the RetryStrategy
xmppConnection.removeConnectionListener(connectionListener)
ReconnectionManager.getInstanceFor(xmppConnection)?.removeReconnectionListener(reconnectionListener)
if (xmppConnection.isConnected) {
xmppConnection.disconnect()
}
true
}
}
}
private fun fireComponentsChanged(components: Set<Component>) = listeners.forEach {
TaskPools.ioPool.submit {
try {
it.componentsChanged(components)
} catch (throwable: Throwable) {
logger.error("An error occurred while executing componentsChanged() on $it", throwable)
}
}
}
private fun fireRegistrationStateChanged(registered: Boolean) {
listeners.forEach {
TaskPools.ioPool.submit {
try {
it.registrationChanged(registered)
} catch (throwable: Throwable) {
logger.error("An error occurred while executing registrationStateChanged() on $it", throwable)
}
}
}
if (registered) {
xmppConnection.replyTimeout = config.replyTimeout.toMillis()
logger.info("Set replyTimeout=${config.replyTimeout}")
}
}
@Throws(RoomExistsException::class)
fun createRoom(name: EntityBareJid): ChatRoom = muc.createChatRoom(name, null)
fun findOrCreateRoom(name: EntityBareJid, logLevel: Level): ChatRoom = muc.findOrCreateRoom(name, logLevel)
fun discoverFeatures(jid: EntityFullJid): FeatureDiscoveryResult {
if (!xmppConnection.isConnected) {
logger.error("Can not discover features, not connected.")
return featuresNotDiscovered
}
val discoveryManager = ServiceDiscoveryManager.getInstanceFor(xmppConnection)
if (discoveryManager == null) {
logger.error("Can not discover features, no ServiceDiscoveryManager")
return featuresNotDiscovered
}
val start = System.currentTimeMillis()
val featureStrings: List<String> = try {
discoveryManager.discoverInfo(jid)?.features?.map { it.`var` }?.toList() ?: emptyList()
} catch (e: SmackException.NoResponseException) {
logger.info("No response for disco#info, assuming default features.")
return featuresNotDiscovered
} catch (e: Exception) {
logger.warn("Failed to discover features for $jid: ${e.message}, assuming default feature set.", e)
return featuresNotDiscovered
}
logger.info("Discovered features for $jid in ${System.currentTimeMillis() - start} ms.")
val features = featureStrings.mapNotNull { Features.parseString(it) }.toSet()
if (features.size != featureStrings.size) {
val unrecognizedFeatures = featureStrings - features.map { it.value }.toSet()
logger.info("Unrecognized features for $jid: $unrecognizedFeatures")
}
return FeatureDiscoveryResult(features, true)
}
private fun discoverComponents(domain: DomainBareJid) {
val discoveryManager = ServiceDiscoveryManager.getInstanceFor(xmppConnection)
val components: Set<Component> = if (discoveryManager == null) {
logger.info("Can not discover components, no ServiceDiscoveryManager")
emptySet()
} else {
try {
discoveryManager.discoverInfo(domain)?.identities
?.filter { it.category == "component" }
?.map { Component(it.type, it.name) }?.toSet() ?: emptySet()
} catch (e: Exception) {
logger.warn("Failed to discover info", e)
emptySet()
}
}
this.components = components
}
class RoomExistsException(message: String) : Exception(message)
interface Listener {
fun registrationChanged(registered: Boolean) { }
fun componentsChanged(components: Set<Component>) { }
}
data class Component(val type: String, val address: String)
}
/** Create the Smack [AbstractXMPPConnection] based on the specified config. */
private fun createXmppConnection(config: XmppConnectionConfig, logger: Logger): AbstractXMPPConnection {
val connConfig = XMPPTCPConnectionConfiguration.builder().apply {
setHost(config.hostname)
setPort(config.port)
setXmppDomain(config.domain)
if (PacketDebugger.isEnabled()) {
// If XMPP debug logging is enabled, insert our debugger.
setDebuggerFactory { PacketDebugger(it, config.name) }
}
if (!config.useTls) {
setSecurityMode(ConnectionConfiguration.SecurityMode.disabled)
} else {
/* TODO - make this required except on localhost. */
setSecurityMode(ConnectionConfiguration.SecurityMode.ifpossible)
}
if (config.password == null) {
performSaslAnonymousAuthentication()
}
if (config.disableCertificateVerification) {
logger.warn("Disabling TLS certificate verification!")
setCustomX509TrustManager(object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
})
setHostnameVerifier { _, _ -> true }
}
}
val connection = XMPPTCPConnection(connConfig.build())
// This can be removed once all clients are updated reading this from the presence conference property
ServiceDiscoveryManager.getInstanceFor(connection).addFeature("https://jitsi.org/meet/jicofo/terminate-restart")
EntityCapsManager.getInstanceFor(connection).enableEntityCaps()
return connection
}
private class Muc(val xmppProvider: XmppProvider) {
/** The active chat rooms mapped by their name. */
private val rooms: MutableMap<EntityBareJid, ChatRoomImpl> = HashMap()
@Throws(RoomExistsException::class)
fun createChatRoom(roomJid: EntityBareJid, logLevel: Level? = Level.ALL): ChatRoom {
synchronized(rooms) {
if (rooms.containsKey(roomJid)) {
throw RoomExistsException("Room '$roomJid' exists")
}
return ChatRoomImpl(xmppProvider, roomJid, logLevel) { removeRoom(it) }.also {
rooms[roomJid] = it
}
}
}
fun findOrCreateRoom(roomJid: EntityBareJid, logLevel: Level = Level.ALL): ChatRoom {
synchronized(rooms) {
try {
return rooms[roomJid] ?: createChatRoom(roomJid, logLevel)
} catch (e: RoomExistsException) {
throw RuntimeException("Unexpected RoomExistsException.")
}
}
}
fun removeRoom(chatRoom: ChatRoomImpl) = synchronized(rooms) {
rooms.remove(chatRoom.roomJid)
}
}

View File

@@ -0,0 +1,69 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.jingle
import org.jitsi.jicofo.util.WeakValueMap
import org.jitsi.jicofo.xmpp.AbstractIqHandler
import org.jitsi.jicofo.xmpp.IqProcessingResult
import org.jitsi.jicofo.xmpp.IqProcessingResult.RejectedWithError
import org.jitsi.jicofo.xmpp.IqRequest
import org.jitsi.utils.logging2.createLogger
import org.jitsi.xmpp.extensions.jingle.JingleIQ
import org.jivesoftware.smack.AbstractXMPPConnection
import org.jivesoftware.smack.packet.IQ
import org.jivesoftware.smack.packet.StanzaError
/**
* Maintain a weak map of [JingleSession]s and route incoming Jingle IQs to the associated session.
* @author Pawel Domas
*/
class JingleIqRequestHandler(
connections: Set<AbstractXMPPConnection>
) : AbstractIqHandler<JingleIQ>(connections, JingleIQ.ELEMENT, JingleIQ.NAMESPACE, setOf(IQ.Type.set)) {
private val logger = createLogger()
/** The list of active Jingle sessions. */
private val sessions = WeakValueMap<String, JingleSession>()
override fun handleRequest(request: IqRequest<JingleIQ>): IqProcessingResult {
val session = sessions.get(request.iq.sid)
if (session == null) {
logger.warn("No session found for SID ${request.iq.sid}")
return RejectedWithError(
IQ.createErrorResponse(
request.iq,
StanzaError.getBuilder(StanzaError.Condition.bad_request).build()
)
)
}
return session.processIq(request.iq)
}
fun registerSession(session: JingleSession) {
val existingSession = sessions.get(session.sid)
if (existingSession != null) {
logger.warn("Replacing existing session with SID ${session.sid}")
}
sessions.put(session.sid, session)
}
fun removeSession(session: JingleSession) {
sessions.remove(session.sid)
}
}

View File

@@ -0,0 +1,90 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.jingle
import org.jitsi.xmpp.extensions.jingle.ContentPacketExtension
import org.jitsi.xmpp.extensions.jingle.JingleIQ
import org.jivesoftware.smack.packet.StanzaError
/**
* Listener class notified about Jingle requests received during a session.
*
* @author Pawel Domas
*/
interface JingleRequestHandler {
/**
* A 'source-add' IQ was received.
*
* @return a [StanzaError] if an error should be returned as response to the original request or null if
* processing was successful.
*/
fun onAddSource(jingleSession: JingleSession, contents: List<ContentPacketExtension>): StanzaError? = null
/**
* A 'source-remove' IQ was received.
*
* @return a [StanzaError] if an error should be returned as response to the original request or null if
* processing was successful.
*/
fun onRemoveSource(jingleSession: JingleSession, contents: List<ContentPacketExtension>): StanzaError? = null
/**
* A 'session-accept' IQ was received.
*
* @return a [StanzaError] if an error should be returned as response to the original request or null if
* processing was successful.
*/
fun onSessionAccept(jingleSession: JingleSession, contents: List<ContentPacketExtension>): StanzaError? = null
/**
* A 'session-info' IQ was received.
*
* @return a [StanzaError] if an error should be returned as response to the original request or null if
* processing was successful.
*/
fun onSessionInfo(jingleSession: JingleSession, iq: JingleIQ): StanzaError? = null
/**
* A 'session-terminate' IQ was received.
*
* @return a [StanzaError] if an error should be returned as response to the original request or null if
* processing was successful.
*/
fun onSessionTerminate(jingleSession: JingleSession, iq: JingleIQ): StanzaError? = null
/**
* A 'transport-info' IQ was received.
*/
fun onTransportInfo(jingleSession: JingleSession, contents: List<ContentPacketExtension>): StanzaError? = null
/**
* A 'transport-accept' IQ was received.
*
* @return a [StanzaError] if an error should be returned as response to the original request or null if
* processing was successful.
*/
fun onTransportAccept(jingleSession: JingleSession, contents: List<ContentPacketExtension>): StanzaError? = null
/**
* A 'transport-reject' IQ was received.
*/
fun onTransportReject(jingleSession: JingleSession, iq: JingleIQ) { }
}
/** Export a default impl so it can be used in java without -Xjvm-default */
class NoOpJingleRequestHandler : JingleRequestHandler

View File

@@ -0,0 +1,472 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.jingle
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.databind.node.ObjectNode
import io.opentelemetry.api.trace.Span
import io.opentelemetry.api.trace.SpanContext
import io.opentelemetry.api.trace.StatusCode
import io.opentelemetry.api.trace.TraceFlags
import io.opentelemetry.api.trace.TraceState
import io.opentelemetry.api.trace.Tracer
import io.opentelemetry.context.Context
import org.jitsi.jicofo.TaskPools
import org.jitsi.jicofo.conference.source.ConferenceSourceMap
import org.jitsi.jicofo.xmpp.IqProcessingResult
import org.jitsi.jicofo.xmpp.createSessionInitiate
import org.jitsi.jicofo.xmpp.createTransportReplace
import org.jitsi.jicofo.xmpp.sendIqAndGetResponse
import org.jitsi.jicofo.xmpp.tryToSendStanza
import org.jitsi.tracing.TracingGlobal
import org.jitsi.utils.MediaType
import org.jitsi.utils.logging2.createLogger
import org.jitsi.utils.queue.PacketQueue
import org.jitsi.xmpp.extensions.TraceParent
import org.jitsi.xmpp.extensions.jingle.ContentPacketExtension
import org.jitsi.xmpp.extensions.jingle.GroupPacketExtension
import org.jitsi.xmpp.extensions.jingle.IceUdpTransportPacketExtension
import org.jitsi.xmpp.extensions.jingle.JingleAction
import org.jitsi.xmpp.extensions.jingle.JingleIQ
import org.jitsi.xmpp.extensions.jingle.JinglePacketFactory
import org.jitsi.xmpp.extensions.jingle.Reason
import org.jitsi.xmpp.extensions.jingle.RtpDescriptionPacketExtension
import org.jitsi.xmpp.extensions.jitsimeet.ClientVersionPacketExtension
import org.jitsi.xmpp.extensions.jitsimeet.JsonMessageExtension
import org.jivesoftware.smack.AbstractXMPPConnection
import org.jivesoftware.smack.SmackException
import org.jivesoftware.smack.packet.ExtensionElement
import org.jivesoftware.smack.packet.IQ
import org.jivesoftware.smack.packet.StanzaError
import org.jxmpp.jid.Jid
import java.util.Objects
/**
* Extracts a remote [Span] from the `traceparent` extension of an IQ, if present.
*
* Inlined from jicoco-tracing's TracingUtil, which was removed because it pulled in a Smack
* dependency that isn't available on Maven Central: https://github.com/jitsi/jicoco/pull/241
*/
private fun remoteSpanFromIq(iq: IQ): Span? {
val extension = iq.getExtension(TraceParent::class.java) ?: return null
return Span.wrap(
SpanContext.createFromRemoteParent(
extension.traceId,
extension.parentId,
TraceFlags.fromHex(extension.traceFlags, 0),
TraceState.getDefault()
)
)
}
/**
* Extracts a remote [Context] from the `traceparent` extension of an IQ, if present, or the root
* context otherwise.
*/
private fun remoteContextFromIq(iq: IQ): Context {
val root = Context.root()
val span = remoteSpanFromIq(iq) ?: return root
return root.with(span)
}
/**
* Class describes Jingle session.
*
* @author Pawel Domas
* @author Lyubomir Marinov
*/
class JingleSession(
/** Jingle session identifier. */
val sid: String,
/** Remote peer XMPP address. */
val remoteJid: Jid,
private val jingleIqRequestHandler: JingleIqRequestHandler,
private val connection: AbstractXMPPConnection,
private val requestHandler: JingleRequestHandler,
private val encodeSourcesAsJson: Boolean
) {
private var state = State.PENDING
fun isActive() = state == State.ACTIVE
val logger = createLogger().apply {
addContext("remoteJid", remoteJid.toString())
addContext("sid", sid)
}
val tracer: Tracer = TracingGlobal.sdk.getTracer("org.jitsi.jicofo.jingle")
private val incomingIqQueue = PacketQueue<JingleIQ>(
Integer.MAX_VALUE,
true,
"jingle-iq-queue",
{ iq ->
val span = tracer.spanBuilder("jingle.${iq.action}")
.setParent(remoteContextFromIq(iq))
.setAttribute("initiator.id", Objects.toString(iq.initiator))
.setAttribute("responder.id", Objects.toString(iq.responder))
.setAttribute("session.id", Objects.toString(iq.sid))
.startSpan()
try {
span.makeCurrent().use {
doProcessIq(iq)
}
} catch (e: Throwable) {
span.setStatus(StatusCode.ERROR, e.message ?: "")
throw e
} finally {
span.end()
}
return@PacketQueue true
},
TaskPools.ioPool
)
private val localJid: Jid = connection.user
fun processIq(iq: JingleIQ): IqProcessingResult {
val action = iq.action
?: return IqProcessingResult.RejectedWithError(
IQ.createErrorResponse(
iq,
StanzaError.getBuilder(StanzaError.Condition.bad_request)
.setConditionText("Missing 'action'").build()
)
)
JingleStats.stanzaReceived(action)
if (state == State.ENDED) {
return IqProcessingResult.RejectedWithError(
IQ.createErrorResponse(
iq,
StanzaError.getBuilder(StanzaError.Condition.gone).setConditionText("session ended").build()
)
)
}
incomingIqQueue.add(iq)
return IqProcessingResult.AcceptedWithNoResponse()
}
/** The version of the client, if it included one in its session-accept. */
var clientVersion: String? = null
private set
private fun doProcessIq(iq: JingleIQ) {
val error = when (iq.action) {
JingleAction.SESSION_ACCEPT -> {
// The client includes its version in the session-accept that it sends to us.
iq.getExtension(ClientVersionPacketExtension::class.java)?.version?.let {
clientVersion = it
logger.info("Client version: $it")
}
// The session needs to be marked as active early to allow code executing as part of onSessionAccept
// to proceed (e.g. to signal source updates).
state = State.ACTIVE
val error = requestHandler.onSessionAccept(this, iq.contentList)
if (error != null) state = State.ENDED
error
}
JingleAction.SESSION_INFO -> requestHandler.onSessionInfo(this, iq)
JingleAction.SESSION_TERMINATE -> requestHandler.onSessionTerminate(this, iq).also {
state = State.ENDED
}
JingleAction.TRANSPORT_ACCEPT -> requestHandler.onTransportAccept(this, iq.contentList)
JingleAction.TRANSPORT_INFO -> requestHandler.onTransportInfo(this, iq.contentList)
JingleAction.TRANSPORT_REJECT -> {
requestHandler.onTransportReject(this, iq)
null
}
JingleAction.ADDSOURCE, JingleAction.SOURCEADD -> requestHandler.onAddSource(this, iq.contentList)
JingleAction.REMOVESOURCE, JingleAction.SOURCEREMOVE -> requestHandler.onRemoveSource(
this,
iq.contentList
)
else -> {
logger.warn("unsupported action ${iq.action}")
StanzaError.getBuilder(StanzaError.Condition.feature_not_implemented)
.setConditionText("Unsupported 'action'").build()
}
}
val response = if (error == null) {
IQ.createResultIQ(iq)
} else {
logger.info("Returning error: request=${iq.toXML()}, error=${error.toXML()} ")
IQ.createErrorResponse(iq, error)
}
connection.tryToSendStanza(response)
}
fun terminate(
reason: Reason,
message: String?,
/** Whether to send a session-terminate IQ, or only terminate the session locally. */
sendIq: Boolean
) {
logger.debug("Terminating session with $remoteJid, reason=$reason, sendIq=$sendIq")
val oldState = state
state = State.ENDED
if (oldState == State.ENDED) logger.warn("Terminating session which is already in state ENDED")
if (sendIq) {
if (oldState == State.ENDED) {
logger.warn("Not sending session-terminate for session in state $state")
} else {
val terminate = JinglePacketFactory.createSessionTerminate(
localJid,
remoteJid,
sid,
reason,
message
)
connection.tryToSendStanza(terminate)
JingleStats.stanzaSent(JingleAction.SESSION_TERMINATE)
}
}
jingleIqRequestHandler.removeSession(this)
}
/**
* Send a transport-replace IQ and wait for a response. Return true if the response is successful, and false
* otherwise.
*/
@Throws(SmackException.NotConnectedException::class)
fun replaceTransport(
contents: List<ContentPacketExtension>,
additionalExtensions: List<ExtensionElement>,
sources: ConferenceSourceMap
): Boolean {
logger.info("Sending transport-replace, sources=$sources.")
if (state != State.ACTIVE) logger.error("Sending transport-replace for session in state $state")
val contentsWithSources = if (encodeSourcesAsJson) contents else sources.toContents(contents)
val jingleIq = createTransportReplace(localJid, this, contentsWithSources)
jingleIq.addExtension(GroupPacketExtension.createBundleGroup(jingleIq.contentList))
additionalExtensions.forEach { jingleIq.addExtension(it) }
if (encodeSourcesAsJson) {
jingleIq.addExtension(sources.toJsonMessageExtension())
}
JingleStats.stanzaSent(jingleIq.action)
val response = connection.sendIqAndGetResponse(jingleIq)
return if (response?.type == IQ.Type.result) {
true
} else {
logger.error("Unexpected response to transport-replace: ${response?.toXML()}")
false
}
}
/**
* Send a source-remove IQ with the specified sources. Returns immediately without waiting for a response.
*/
fun removeSource(sourcesToRemove: ConferenceSourceMap) {
val removeSourceIq = JingleIQ(JingleAction.SOURCEREMOVE, sid).apply {
from = localJid
type = IQ.Type.set
to = remoteJid
}
if (encodeSourcesAsJson) {
removeSourceIq.addExtension(sourcesToRemove.toJsonMessageExtension())
} else {
sourcesToRemove.toJingle().forEach { removeSourceIq.addContent(it) }
}
logger.debug { "Sending source-remove, sources=$sourcesToRemove" }
if (state != State.ACTIVE) logger.error("Sending source-remove for session in state $state")
connection.tryToSendStanza(removeSourceIq)
JingleStats.stanzaSent(JingleAction.SOURCEREMOVE)
}
/**
* Send a transport-info IQ carrying the bridge's transport. Used after the bridge performed an ICE restart
* and rotated its ICE credentials: the client's connectivity checks are addressed to those credentials, so
* it has to be told the new ones. Returns immediately without waiting for a response.
*
* The transport is sent on a single content; the client applies the credentials to its whole (bundled)
* transport, so which content carries them does not matter.
*/
fun sendTransportInfo(transport: IceUdpTransportPacketExtension) {
val transportInfoIq = JingleIQ(JingleAction.TRANSPORT_INFO, sid).apply {
from = localJid
type = IQ.Type.set
to = remoteJid
addContent(
ContentPacketExtension().apply {
creator = ContentPacketExtension.CreatorEnum.initiator
name = "audio"
addChildExtension(IceUdpTransportPacketExtension.cloneTransportAndCandidates(transport, true))
}
)
}
logger.info(
"ICE restart: sending transport-info with the bridge's transport, generation=${transport.iceGeneration}, " +
"ufrag=${transport.ufrag}"
)
if (state != State.ACTIVE) logger.error("Sending transport-info for session in state $state")
connection.tryToSendStanza(transportInfoIq)
JingleStats.stanzaSent(JingleAction.TRANSPORT_INFO)
}
/**
* Send a source-add IQ with the specified sources. Returns immediately without waiting for a response.
*/
fun addSource(sources: ConferenceSourceMap) {
logger.debug { "Sending source-add, sources=$sources" }
JingleStats.stanzaSent(JingleAction.SOURCEADD)
if (state != State.ACTIVE) logger.error("Sending source-add for session in state $state")
connection.tryToSendStanza(createAddSourceIq(sources))
}
@Throws(SmackException.NotConnectedException::class)
fun initiateSession(
contents: List<ContentPacketExtension>,
additionalExtensions: List<ExtensionElement>,
sources: ConferenceSourceMap,
): Boolean {
if (state != State.PENDING) logger.error("Sending session-initiate for session in state $state")
val contentsWithSources = if (encodeSourcesAsJson) contents else sources.toContents(contents)
val sessionInitiate = createSessionInitiate(localJid, remoteJid, sid, contentsWithSources).apply {
addExtension(GroupPacketExtension.createBundleGroup(contentList))
additionalExtensions.forEach { addExtension(it) }
if (encodeSourcesAsJson) {
addExtension(sources.toJsonMessageExtension())
}
Span.fromContextOrNull(Context.current())?.let {
addExtension(
TraceParent(
it.spanContext.traceId,
it.spanContext.spanId,
it.spanContext.traceFlags.asHex()
)
)
}
}
jingleIqRequestHandler.registerSession(this)
JingleStats.stanzaSent(sessionInitiate.action)
val response = connection.sendIqAndGetResponse(sessionInitiate)
// We treat a timeout (null) as success. This prevents failures in case the client delays processing, observed
// when joining a large conference. The session will be pending until we receive session-accept.
return if (response == null || response.type == IQ.Type.result) {
true
} else {
logger.error("Unexpected response to session-initiate: $response")
false
}
}
private fun createAddSourceIq(sources: ConferenceSourceMap) = JingleIQ(JingleAction.SOURCEADD, sid).apply {
from = localJid
type = IQ.Type.set
to = remoteJid
if (encodeSourcesAsJson) {
addExtension(sources.toJsonMessageExtension())
} else {
sources.toJingle().forEach { addContent(it) }
}
}
fun debugState(): ObjectNode = JsonNodeFactory.instance.objectNode().apply {
put("sid", sid)
put("remoteJid", remoteJid.toString())
put("state", state.toString())
put("client_version", clientVersion)
}
enum class State { PENDING, ACTIVE, ENDED }
}
/**
* Encodes the sources described in `sources` as a [JsonMessageExtension] in the compact JSON format
* (see [ConferenceSourceMap.compactJson]).
* @return the [JsonMessageExtension] encoding `sources`.
*/
fun ConferenceSourceMap.toJsonMessageExtension() = JsonMessageExtension("{\"sources\":${compactJson()}}")
/**
* Encodes the sources described in `sources` in the list of Jingle contents. If necessary, new
* [ContentPacketExtension]s are created. Returns the resulting list of [ContentPacketExtension] which
* contains the encoded sources.
*
* @return the resulting list of [ContentPacketExtension], which consists of `contents` plus any new
* [ContentPacketExtension]s that were created.
*/
private fun ConferenceSourceMap.toContents(
/** The list of existing [ContentPacketExtension] to which to add sources if possible. */
existingContents: List<ContentPacketExtension>
): List<ContentPacketExtension> {
val ret = mutableListOf<ContentPacketExtension>()
var audioContent = existingContents.find { it.name == "audio" }
var videoContent = existingContents.find { it.name == "video" }
audioContent?.let { ret.add(it) }
videoContent?.let { ret.add(it) }
val audioSourceExtensions = createSourcePacketExtensions(MediaType.AUDIO)
val audioSsrcGroupExtensions = createSourceGroupPacketExtensions(MediaType.AUDIO)
if (audioSourceExtensions.isNotEmpty() || audioSsrcGroupExtensions.isNotEmpty()) {
if (audioContent == null) {
audioContent = ContentPacketExtension().apply { name = "audio" }
ret.add(audioContent)
}
var audioDescription = audioContent.getFirstChildOfType(RtpDescriptionPacketExtension::class.java)
if (audioDescription == null) {
audioDescription = RtpDescriptionPacketExtension().apply { media = "audio" }
audioContent.addChildExtension(audioDescription)
}
for (extension in audioSourceExtensions) {
audioDescription.addChildExtension(extension)
}
for (extension in audioSsrcGroupExtensions) {
audioDescription.addChildExtension(extension)
}
}
val videoSourceExtensions = createSourcePacketExtensions(MediaType.VIDEO)
val videoSsrcGroupExtensions = createSourceGroupPacketExtensions(MediaType.VIDEO)
if (videoSourceExtensions.isNotEmpty() || videoSsrcGroupExtensions.isNotEmpty()) {
if (videoContent == null) {
videoContent = ContentPacketExtension().apply { name = "video" }
ret.add(videoContent)
}
var videoDescription = videoContent.getFirstChildOfType(RtpDescriptionPacketExtension::class.java)
if (videoDescription == null) {
videoDescription = RtpDescriptionPacketExtension().apply { media = "video" }
videoContent.addChildExtension(videoDescription)
}
for (extension in videoSourceExtensions) {
videoDescription.addChildExtension(extension)
}
for (extension in videoSsrcGroupExtensions) {
videoDescription.addChildExtension(extension)
}
}
return ret
}

View File

@@ -0,0 +1,68 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.jingle
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.jitsi.jicofo.metrics.JicofoMetricsContainer
import org.jitsi.metrics.CounterMetric
import org.jitsi.xmpp.extensions.jingle.JingleAction
import java.util.concurrent.ConcurrentHashMap
private val jsonMapper = jacksonObjectMapper()
class JingleStats {
companion object {
private val stanzasReceivedByAction: MutableMap<JingleAction, CounterMetric> = ConcurrentHashMap()
private val stanzasSentByAction: MutableMap<JingleAction, CounterMetric> = ConcurrentHashMap()
@JvmStatic
fun stanzaReceived(action: JingleAction) {
stanzasReceivedByAction.computeIfAbsent(action) {
JicofoMetricsContainer.instance.registerCounter(
"jingle_${action.name.lowercase()}_received",
"Number of ${action.name} stanzas received"
)
}.inc()
}
@JvmStatic
fun stanzaSent(action: JingleAction) {
stanzasSentByAction.computeIfAbsent(action) {
JicofoMetricsContainer.instance.registerCounter(
"jingle_${action.name.lowercase()}_sent",
"Number of ${action.name} stanzas sent."
)
}.inc()
}
@JvmStatic
fun toJson(): ObjectNode = JsonNodeFactory.instance.objectNode().apply {
set<ObjectNode>(
"sent",
jsonMapper.valueToTree(stanzasSentByAction.map { it.key to it.value.get() }.toMap())
)
set<ObjectNode>(
"received",
jsonMapper.valueToTree(stanzasReceivedByAction.map { it.key to it.value.get() }.toMap())
)
}
}
}

View File

@@ -0,0 +1,213 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.muc
import com.fasterxml.jackson.databind.node.ObjectNode
import org.jitsi.jicofo.MediaType
import org.jitsi.jicofo.xmpp.RoomMetadata
import org.jitsi.jicofo.xmpp.XmppProvider
import org.jivesoftware.smack.SmackException
import org.jivesoftware.smack.XMPPException
import org.jivesoftware.smack.packet.ExtensionElement
import org.jxmpp.jid.EntityBareJid
import org.jxmpp.jid.EntityFullJid
import org.jxmpp.jid.Jid
/** Wraps a Smack [MultiUserChat] for the purposes of jicofo. */
interface ChatRoom {
val xmppProvider: XmppProvider
/** The JID of the chat room. */
val roomJid: EntityBareJid
/** Whether the local user is currently in the multi user chat (after calling one of the [.join] methods). */
val isJoined: Boolean
/**
* Returns the list of members of this [ChatRoom]. Note that this does not include a representation of the local
* user as an occupant of the MUC.
*/
val members: List<ChatRoomMember>
/** The size of [members], exposed separately for performance (avoid creating a new list just to get the count) */
val memberCount: Int
/**
* The number of [members] with role VISITOR. Exposed separately for performance (avoid creating a new list
* just to get the count). Also includes visitors who have joined within the last vnode-join-latency-interval,
* as reported by [visitorInvited]. */
val visitorCount: Int
/** Whether a lobby is enabled for the room. Read from the MUC config form. */
val lobbyEnabled: Boolean
/** Whether the visitors feature is enabled for the room. Read from the MUC config form. */
val visitorsEnabled: Boolean?
/** Whether the visitorsLive flag is enabled for the room. Read from the MUC config form. */
val visitorsLive: Boolean
/** The number of participants in the room after which new endpoints should be redirected to visitors.
* Read from the MUC config form. */
val participantsSoftLimit: Int?
/** Transcription configuration from room metadata. */
val transcription: RoomMetadata.Metadata.Transcription?
/**
* Whether the room is configured for async (backend/proxy) transcription rather than a live Jigasi
* transcriber. Read from room metadata. When true, a client-initiated transcriber dial-out request
* should be rejected -- the client is expected to enable transcription via room metadata instead.
*/
val asyncTranscription: Boolean
/** Translation configuration from room metadata (e.g. per-customer connect headers). */
val translation: RoomMetadata.Metadata.Translation?
/**
* The languages the visitors want transcriptions translated into, from room metadata.
*
* Visitor presence is not visible in this room, so their requests arrive this way instead. Use
* [getTranslationLanguages] to get the whole room's set.
*/
val visitorTranslationLanguages: Set<String>
/**
* All the languages this room wants transcriptions translated into: the members' requested languages together
* with [visitorTranslationLanguages].
*/
fun getTranslationLanguages(): Set<String> = translationLanguages(members, visitorTranslationLanguages)
companion object {
/**
* The union of the languages [members] request and the [visitorLanguages] the visitors request. Split out
* from [getTranslationLanguages] so it can be tested without an implementation of this interface.
*/
fun translationLanguages(members: List<ChatRoomMember>, visitorLanguages: Set<String>): Set<String> =
members.mapNotNullTo(mutableSetOf()) { it.translationLanguage } + visitorLanguages
}
val debugState: ObjectNode
/** Returns the number of members that currently have their audio sources unmuted. */
var audioSendersCount: Int
/** Returns the number of members that currently have their video sources unmuted. */
var videoSendersCount: Int
/**
* Whether a user with a certain ID and a certain group ID would be allowed to join the main room. Note that this
* does not actually perform authentication (i.e. if the user has a valid claim for the user ID and group ID),
* just checks the MUC configuration.
*/
fun isAllowedInMainRoom(userId: String?, groupId: String?): Boolean
/**
* Whether a user with a certain ID and a certain group ID is preferred in the main room, i.e. is explicitly listed
* in the list of participants or moderators for the main room.
*/
fun isPreferredInMainRoom(userId: String?, groupId: String?): Boolean
/**
* Joins this chat room with the preconfigured nickname. Returns the fields read from the MUC config form after
* joining.
*/
@Throws(SmackException::class, XMPPException::class, InterruptedException::class)
fun join(): ChatRoomInfo
/** Leave the chat room. */
fun leave()
/**
* Grants ownership privileges to another user. Room owners may grant
* ownership privileges. Some room implementations will not allow to grant
* ownership privileges to other users. An owner is allowed to change
* defining room features as well as perform all administrative functions.
*
* @param member the member to grant ownership to.
*/
fun grantOwnership(member: ChatRoomMember)
/**
* Get the [ChatRoomMember] for a given MUC occupant jid.
*
* @param occupantJid full MUC jid of the user for whom we want to find chat
* member instance. Ex. chatroom1@muc.server.com/nick1234
*/
fun getChatMember(occupantJid: EntityFullJid): ChatRoomMember?
/** Add all of [extensions] to our presence. */
fun addPresenceExtensions(extensions: Collection<ExtensionElement>)
/** Add [extension] to our presence, no-op if we already have an extension with the same QName. */
fun addPresenceExtensionIfMissing(extension: ExtensionElement)
/** Remove presence extensions matching the predicate [pred]. */
fun removePresenceExtensions(pred: (ExtensionElement) -> Boolean)
/**
* Add [extension] to presence and remove any previous extensions with the same QName.
*
* Note that this always sends a presence stanza. If the intention is to only send a packet if the extension was
* modified use [addPresenceExtensionIfMissing].
*/
fun setPresenceExtension(extension: ExtensionElement)
/** Add a [ChatRoomListener] to the list of listeners to be notified of events from this [ChatRoom]. */
fun addListener(listener: ChatRoomListener)
/** Removes a [ChatRoomListener] from the list of listeners to be notified of events from this [ChatRoom]. */
fun removeListener(listener: ChatRoomListener)
/** Removes all [ChatRoomListener]s. */
fun removeAllListeners()
/** Whether A/V moderation is enabled for [mediaType] (audio or video). */
fun isAvModerationEnabled(mediaType: MediaType): Boolean
/** Enable or disable a/v moderation for [mediaType]. */
fun setAvModerationEnabled(mediaType: MediaType, value: Boolean)
/** Updates the list of members that are allowed to unmute audio or video. */
fun setAvModerationWhitelist(mediaType: MediaType, whitelist: List<String>)
/** Update the value in the room_metadata structure */
fun setRoomMetadata(roomMetadata: RoomMetadata)
/** whether the current A/V moderation setting allow the member [jid] to unmute (for a specific [mediaType]). */
fun isMemberAllowedToUnmute(jid: Jid, mediaType: MediaType): Boolean
/** Notify the chatroom that a visitor has been redirected to this room.
*/
fun visitorInvited()
/** Queue a task to be executed sequentially with respect to other tasks queued through this method. */
fun queueXmppTask(runnable: () -> Unit)
}
/** Holds fields read from the MUC config form at join time, which never change. */
data class ChatRoomInfo(
/** The meeting ID, or null if none is set. */
val meetingId: String?,
/** The JID of the main room if this is a breakout room, otherwise null. */
val mainRoomJid: EntityBareJid?
)
/** Whether a user with a certain JID is allowed to unmute with any of [mediaTypes]. */
fun ChatRoom.isMemberAllowedToUnmute(jid: Jid, mediaTypes: Set<MediaType>): Boolean =
mediaTypes.any { isMemberAllowedToUnmute(jid, it) }

View File

@@ -0,0 +1,835 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.muc
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings
import org.jitsi.jicofo.JicofoConfig
import org.jitsi.jicofo.MediaType
import org.jitsi.jicofo.TaskPools.Companion.ioPool
import org.jitsi.jicofo.util.PendingCount
import org.jitsi.jicofo.xmpp.RoomMetadata
import org.jitsi.jicofo.xmpp.XmppProvider
import org.jitsi.jicofo.xmpp.muc.MemberRole.Companion.fromSmack
import org.jitsi.jicofo.xmpp.sendIqAndGetResponse
import org.jitsi.jicofo.xmpp.tryToSendStanza
import org.jitsi.utils.event.EventEmitter
import org.jitsi.utils.event.SyncEventEmitter
import org.jitsi.utils.logging2.createLogger
import org.jitsi.utils.observableWhenChanged
import org.jitsi.utils.queue.PacketQueue
import org.jivesoftware.smack.PresenceListener
import org.jivesoftware.smack.SmackException
import org.jivesoftware.smack.XMPPConnection
import org.jivesoftware.smack.XMPPException
import org.jivesoftware.smack.packet.ExtensionElement
import org.jivesoftware.smack.packet.IQ
import org.jivesoftware.smack.packet.Presence
import org.jivesoftware.smack.packet.PresenceBuilder
import org.jivesoftware.smack.util.Consumer
import org.jivesoftware.smackx.muc.MUCAffiliation
import org.jivesoftware.smackx.muc.MUCRole
import org.jivesoftware.smackx.muc.MultiUserChat
import org.jivesoftware.smackx.muc.MultiUserChatManager
import org.jivesoftware.smackx.muc.Occupant
import org.jivesoftware.smackx.muc.UserStatusListener
import org.jivesoftware.smackx.muc.packet.MUCAdmin
import org.jivesoftware.smackx.muc.packet.MUCInitialPresence
import org.jivesoftware.smackx.muc.packet.MUCItem
import org.jivesoftware.smackx.muc.packet.MUCUser
import org.jxmpp.jid.EntityBareJid
import org.jxmpp.jid.EntityFullJid
import org.jxmpp.jid.Jid
import org.jxmpp.jid.impl.JidCreate
import org.jxmpp.jid.parts.Resourcepart
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.logging.Level
private val jsonMapper = jacksonObjectMapper()
@SuppressFBWarnings(
value = ["JLM_JSR166_UTILCONCURRENT_MONITORENTER"],
justification = "We intentionally synchronize on [members] (a ConcurrentHashMap)."
)
class ChatRoomImpl(
override val xmppProvider: XmppProvider,
override val roomJid: EntityBareJid,
logLevel: Level?,
/** Callback to call when the room is left. */
private val leaveCallback: (ChatRoomImpl) -> Unit
) : ChatRoom, PresenceListener {
private val logger = createLogger(minLogLevel = logLevel ?: Level.ALL).apply {
addContext("room", roomJid.toString())
}
/**
* Keep track of the recently added visitors.
*/
private val pendingVisitorsCounter = PendingCount(
JicofoConfig.config.vnodeJoinLatencyInterval
)
/**
* Latch that is counted down when the room metadata is set.
* This is used to block the join until the room metadata is available.
*/
private val roomMetadataLatch = CountDownLatch(1)
/**
* Latch that is counted down when the room is joined. Processing presence blocks until this latch is counted down.
*/
private val roomJoinedLatch = CountDownLatch(1)
override fun visitorInvited() {
pendingVisitorsCounter.eventPending()
}
/**
* A queue for tasks originating from XMPP for this room which need to be processed in order.
*/
private val xmppTaskQueue = PacketQueue<Runnable>(
Integer.MAX_VALUE,
false,
"ChatRoomImpl presence queue",
{
try {
it.run()
} catch (e: Exception) {
logger.warn("Error processing XMPP task", e)
}
true
},
ioPool
)
private val membersMap: MutableMap<EntityFullJid, ChatRoomMemberImpl> = ConcurrentHashMap()
override val members: List<ChatRoomMember>
get() = synchronized(membersMap) { return membersMap.values.toList() }
override val memberCount
get() = membersMap.size
private var visitorMemberCount: Int = 0
override val visitorCount: Int
get() = synchronized(membersMap) { visitorMemberCount } +
pendingVisitorsCounter.getCount().toInt()
/** Stores our last MUC presence packet for future update. */
private var lastPresenceSent: PresenceBuilder? = null
private val memberListener: MemberListener = MemberListener()
private val userListener = LocalUserStatusListener()
/** Listener for presence that smack sends on our behalf. */
private var presenceInterceptor = Consumer<PresenceBuilder> { presenceBuilder ->
// The initial presence sent by smack contains an empty "x"
// extension. If this extension is included in a subsequent stanza,
// it indicates that the client lost its synchronization and causes
// the MUC service to re-send the presence of each occupant in the
// room.
synchronized(this@ChatRoomImpl) {
val p = presenceBuilder.build()
p.removeExtension(
MUCInitialPresence.ELEMENT,
MUCInitialPresence.NAMESPACE
)
lastPresenceSent = p.asBuilder()
}
}
/** Smack multi user chat backend instance. */
private val muc: MultiUserChat =
MultiUserChatManager.getInstanceFor(xmppProvider.xmppConnection).getMultiUserChat(this.roomJid).apply {
addUserStatusListener(userListener)
addParticipantListener(this@ChatRoomImpl)
}
override val isJoined
get() = muc.isJoined && roomJoinedLatch.count <= 0
/** Our full Multi User Chat XMPP address. */
private var myOccupantJid: EntityFullJid? = null
override var lobbyEnabled: Boolean = false
private set(value) {
if (value != field) {
logger.info("Lobby is now ${if (value) "enabled" else "disabled"}.")
field = value
}
}
override var visitorsEnabled: Boolean? = null
private set(value) {
if (value != field) {
logger.info("Visitors is now: $value")
field = value
}
}
override var visitorsLive: Boolean = false
private set(value) {
if (value != field) {
logger.info("VisitorsLive is now: $value")
field = value
}
}
override var participantsSoftLimit: Int? = null
private set(value) {
if (value != field) {
logger.info("ParticipantsSoftLimit is now $value.")
field = value
}
}
/** Transcription configuration from room metadata. */
override var transcription: RoomMetadata.Metadata.Transcription? = null
private set
/** Translation configuration from room metadata. */
override var translation: RoomMetadata.Metadata.Translation? = null
private set
/** Whether the room is configured for async (backend/proxy) transcription. Read from room metadata. */
override var asyncTranscription: Boolean = false
private set
/**
* The languages the visitors want transcriptions translated into, from room metadata.
*
* Visitor presence is not visible in this room, so the visitors' requests reach us this way instead: each visitor
* node collects them and the main prosody aggregates them into the room metadata. Fires
* [ChatRoomListener.translationLanguagesChanged] on change, like the per-member languages do.
*/
override var visitorTranslationLanguages: Set<String> = emptySet()
private set(value) {
if (value != field) {
logger.info("Visitor translation languages are now: $value")
field = value
eventEmitter.fireEvent { translationLanguagesChanged() }
}
}
/**
* List of user IDs which the room is configured to allow to be moderators.
*/
private var moderators: List<String> = emptyList()
/**
* List of user IDs which the room is configured to allow to be moderators.
* When [null], the feature is not used, meaning that the room is open to participants.
* When empty, the feature is used and no participants are explicitly allowed (so the room requires a token,
* and users without a token should be redirected as visitors.
*/
private var participants: List<String>? = null
override fun isAllowedInMainRoom(userId: String?, groupId: String?): Boolean {
if (participants == null) {
// The room is open to participants.
return true
}
return isPreferredInMainRoom(userId, groupId)
}
override fun isPreferredInMainRoom(userId: String?, groupId: String?): Boolean {
if (userId != null && (moderators.contains(userId) || participants?.contains(userId) == true)) {
// The user is explicitly allowed to join the main room.
return true
}
if (groupId != null && (moderators.contains(groupId) || participants?.contains(groupId) == true)) {
// The user is explicitly allowed to join the main room based on their group ID.
return true
}
return false
}
private val avModerationByMediaType = ConcurrentHashMap<MediaType, AvModerationForMediaType>()
/** The emitter used to fire events. */
private val eventEmitter: EventEmitter<ChatRoomListener> = SyncEventEmitter()
/** The number of members that currently have their audio sources unmuted. */
override var audioSendersCount by observableWhenChanged(0) { _, _, newValue ->
logger.debug { "The number of audio senders has changed to $newValue." }
eventEmitter.fireEvent { numAudioSendersChanged(newValue) }
}
/** The number of members that currently have their video sources unmuted. */
override var videoSendersCount by observableWhenChanged(0) { _, _, newValue ->
logger.debug { "The number of video senders has changed to $newValue." }
eventEmitter.fireEvent { numVideoSendersChanged(newValue) }
}
override val debugState: ObjectNode
get() = JsonNodeFactory.instance.objectNode().apply {
put("room_jid", roomJid.toString())
put("my_occupant_jid", myOccupantJid.toString())
val membersJson = JsonNodeFactory.instance.objectNode()
membersMap.values.forEach {
membersJson.set<ObjectNode>(it.name, it.debugState)
}
set<ObjectNode>("members", membersJson)
put("audio_senders_count", audioSendersCount)
put("video_senders_count", videoSendersCount)
put("lobby_enabled", lobbyEnabled)
put("participants_soft_limit", participantsSoftLimit ?: -1)
participants?.let {
set<ArrayNode>("participants", jsonMapper.valueToTree(it))
}
set<ArrayNode>("moderators", jsonMapper.valueToTree(moderators))
put("visitors_enabled", visitorsEnabled?.toString() ?: "null")
put("visitors_live", visitorsLive)
put("async_transcription", asyncTranscription)
set<ObjectNode>(
"av_moderation",
JsonNodeFactory.instance.objectNode().apply {
avModerationByMediaType.forEach { (k, v) -> set<ObjectNode>(k.toString(), v.debugState) }
}
)
}
override fun addListener(listener: ChatRoomListener) = eventEmitter.addHandler(listener)
override fun removeListener(listener: ChatRoomListener) = eventEmitter.removeHandler(listener)
// Use toList to avoid concurrent modification. TODO: add a removeAll to EventEmitter.
override fun removeAllListeners() = eventEmitter.eventHandlers.toList().forEach { eventEmitter.removeHandler(it) }
private fun avModeration(mediaType: MediaType): AvModerationForMediaType =
avModerationByMediaType.computeIfAbsent(mediaType) { AvModerationForMediaType(mediaType) }
override fun isAvModerationEnabled(mediaType: MediaType) = avModeration(mediaType).enabled
override fun setAvModerationEnabled(mediaType: MediaType, value: Boolean) {
avModeration(mediaType).enabled = value
}
override fun setAvModerationWhitelist(mediaType: MediaType, whitelist: List<String>) {
avModeration(mediaType).whitelist = whitelist
}
override fun getChatMember(occupantJid: EntityFullJid) = membersMap[occupantJid]
override fun isMemberAllowedToUnmute(jid: Jid, mediaType: MediaType): Boolean =
avModeration(mediaType).isAllowedToUnmute(jid)
@Throws(SmackException::class, XMPPException::class, InterruptedException::class)
override fun join(): ChatRoomInfo {
// TODO: clean-up the way we figure out what nickname to use.
resetState()
return joinAs(xmppProvider.config.username)
}
/**
* Prepare this [ChatRoomImpl] for a call to [.joinAs], which sends initial presence to the MUC. Resets any state
* that might have been set the previous time the MUC was joined.
*/
private fun resetState() {
synchronized(membersMap) {
if (membersMap.isNotEmpty()) {
logger.warn("Removing ${membersMap.size} stale members ($visitorMemberCount stale visitors).")
membersMap.clear()
visitorMemberCount = 0
audioSendersCount = 0
videoSendersCount = 0
}
}
synchronized(this) {
lastPresenceSent = null
logger.removeContext("meeting_id")
avModerationByMediaType.values.forEach { it.reset() }
}
}
@Throws(SmackException::class, XMPPException::class, InterruptedException::class)
private fun joinAs(nickname: Resourcepart): ChatRoomInfo {
// The queue should block until the room is fully joined.
xmppTaskQueue.add { roomJoinedLatch.await() }
myOccupantJid = JidCreate.entityFullFrom(roomJid, nickname)
synchronized(muc) {
if (muc.isJoined) {
muc.leave()
}
}
muc.addPresenceInterceptor(presenceInterceptor)
muc.createOrJoin(nickname)
val config = muc.configurationForm
// Make the room non-anonymous, so that others can recognize focus JID
val answer = config.fillableForm
answer.setAnswer(MucConfigFields.WHOIS, "anyone")
muc.sendConfigurationForm(answer)
// Read the breakout room and meetingId.
val mainRoomStr = if (config.getField(MucConfigFields.IS_BREAKOUT_ROOM)?.firstValue?.toBoolean() == true) {
config.getField(MucConfigFields.MAIN_ROOM)?.firstValue
} else {
null
}
if (config.getField(MucConfigFields.CONFERENCE_PRESET_ENABLED)?.firstValue?.toBoolean() == true) {
logger.info("Conference presets service is enabled. Will block until RoomMetadata is set.")
if (roomMetadataLatch.await(10, TimeUnit.SECONDS)) {
logger.info("RoomMetadata is set, room is fully joined.")
} else {
logger.warn("Timed out waiting for RoomMetadata to be set. Will continue without it.")
}
}
roomJoinedLatch.countDown()
return ChatRoomInfo(
meetingId = config.getField(MucConfigFields.MEETING_ID)?.firstValue,
mainRoomJid = if (mainRoomStr == null) null else JidCreate.entityBareFrom(mainRoomStr)
)
}
override fun setRoomMetadata(roomMetadata: RoomMetadata) {
// The initial RoomMetadata is required to consider the room fully joined. The queue will block until that
// happens, so handle it outside the queue
if (roomJoinedLatch.count > 0L) {
ioPool.submit { doSetRoomMetadata(roomMetadata) }
} else {
queueXmppTask { doSetRoomMetadata(roomMetadata) }
}
}
private fun doSetRoomMetadata(roomMetadata: RoomMetadata) {
logger.info("Setting room metadata: $roomMetadata")
lobbyEnabled = roomMetadata.metadata?.lobbyEnabled == true
visitorsLive = roomMetadata.metadata?.visitors?.live == true
moderators = roomMetadata.metadata?.moderators ?: emptyList()
participants = roomMetadata.metadata?.participants
// We read these fields from both the config form (for backwards compatibility) and the room metadata. Only
// override if they are set.
roomMetadata.metadata?.visitorsEnabled?.let {
visitorsEnabled = it
}
roomMetadata.metadata?.participantsSoftLimit?.let {
participantsSoftLimit = it
}
roomMetadata.metadata?.startMuted?.let {
eventEmitter.fireEvent { startMutedChanged(it.audio == true, it.video == true) }
}
transcription = roomMetadata.metadata?.transcription
translation = roomMetadata.metadata?.translation
asyncTranscription = roomMetadata.metadata?.asyncTranscription == true
eventEmitter.fireEvent {
transcribingEnabledChanged(
roomMetadata.metadata?.recording?.isTranscribingEnabled == true &&
roomMetadata.metadata.asyncTranscription == true
)
}
eventEmitter.fireEvent {
audioTranslationRequestsChanged(roomMetadata.metadata?.audioTranslationRequests ?: emptyMap())
}
visitorTranslationLanguages = parseVisitorTranslationLanguages(roomMetadata)
roomMetadataLatch.countDown()
}
override fun leave() {
muc.removePresenceInterceptor(presenceInterceptor)
muc.removeUserStatusListener(userListener)
muc.removeParticipantListener(this)
leaveCallback(this)
// Unblock any threads waiting on the latches
xmppTaskQueue.close()
roomMetadataLatch.countDown()
roomJoinedLatch.countDown()
// Call MultiUserChat.leave() in an IO thread, because it now (with Smack 4.4.3) blocks waiting for a response
// from the XMPP server (and we want ChatRoom#leave to return immediately).
ioPool.execute {
val connection: XMPPConnection = xmppProvider.xmppConnection
try {
// FIXME smack4: there used to be a custom dispose() method if leave() fails, there might still be some
// listeners lingering around
if (muc.isJoined) {
muc.leave()
} else {
// If the join attempt timed out the XMPP server might have processed it and created the MUC, and
// since the XMPP connection is long-lived the MUC will leak.
val leavePresence = connection.stanzaFactory.buildPresenceStanza()
.ofType(Presence.Type.unavailable)
.to(myOccupantJid)
.build()
connection.sendStanza(leavePresence)
}
} catch (e: Exception) {
// when the connection is not connected or we get NotConnectedException, this is expected (skip log)
if (connection.isConnected || e !is SmackException.NotConnectedException) {
logger.error("Failed to properly leave $muc", e)
}
}
}
}
override fun grantOwnership(member: ChatRoomMember) {
logger.debug("Grant owner to $member")
// Have to construct the IQ manually as Smack version used here seems
// to be using wrong namespace(muc#owner instead of muc#admin)
// which does not work with the Prosody.
val jid = member.jid?.asBareJid() ?: run {
logger.warn("Can not grant ownership to ${member.name}, real JID unknown")
return
}
val item = MUCItem(MUCAffiliation.owner, jid)
val admin = MUCAdmin().apply {
type = IQ.Type.set
to = roomJid
addItem(item)
}
try {
val reply = xmppProvider.xmppConnection.sendIqAndGetResponse(admin)
if (reply == null || reply.type != IQ.Type.result) {
logger.warn("Failed to grant ownership: ${reply?.toString() ?: "timeout"}")
}
} catch (e: SmackException.NotConnectedException) {
logger.warn("Failed to grant ownership: XMPP disconnected")
}
}
fun getOccupant(chatMember: ChatRoomMemberImpl): Occupant? = muc.getOccupant(chatMember.occupantJid)
override fun setPresenceExtension(extension: ExtensionElement) {
val presenceToSend = synchronized(this) {
val presence = lastPresenceSent ?: run {
logger.error("No presence packet obtained yet")
return
}
presence.getExtensions(extension.qName).toList().forEach { existingExtension ->
presence.removeExtension(existingExtension)
}
presence.addExtension(extension)
presence.build()
}
presenceToSend?.let { xmppProvider.xmppConnection.tryToSendStanza(it) }
}
override fun addPresenceExtensionIfMissing(extension: ExtensionElement) {
val presenceToSend = synchronized(this) {
val presence = lastPresenceSent ?: run {
logger.error("No presence packet obtained yet")
return
}
if (presence.extensions?.any { it.qName == extension.qName } == true) {
null
} else {
presence.addExtension(extension).build()
}
}
presenceToSend?.let { xmppProvider.xmppConnection.tryToSendStanza(it) }
}
override fun removePresenceExtensions(pred: (ExtensionElement) -> Boolean) {
val presenceToSend = synchronized(this) {
lastPresenceSent?.extensions?.filter { pred(it) }?.let {
modifyPresenceExtensions(toRemove = it)
}
}
presenceToSend?.let { xmppProvider.xmppConnection.tryToSendStanza(it) }
}
override fun addPresenceExtensions(extensions: Collection<ExtensionElement>) {
val presenceToSend = synchronized(this) {
modifyPresenceExtensions(toAdd = extensions)
}
presenceToSend?.let { xmppProvider.xmppConnection.tryToSendStanza(it) }
}
/** Add/remove extensions to/from our presence and return the updated presence or null if it wasn't modified. */
private fun modifyPresenceExtensions(
toRemove: Collection<ExtensionElement> = emptyList(),
toAdd: Collection<ExtensionElement> = emptyList()
): Presence? = synchronized(this) {
val presence = lastPresenceSent ?: run {
logger.error("No presence packet obtained yet")
return null
}
var changed = false
toRemove.forEach {
presence.removeExtension(it)
// We don't have a good way to check if it was actually removed.
changed = true
}
toAdd.forEach {
presence.addExtension(it)
changed = true
}
return if (changed) presence.build() else null
}
/**
* Adds a new [ChatRoomMemberImpl] with the given JID to [.members].
* If a member with the given JID already exists, it returns the existing
* instance.
* @param jid the JID of the member.
* @return the [ChatRoomMemberImpl] for the member with the given JID.
*/
private fun addMember(jid: EntityFullJid): ChatRoomMemberImpl {
synchronized(membersMap) {
membersMap[jid]?.let { return it }
val newMember = ChatRoomMemberImpl(jid, this@ChatRoomImpl, logger)
membersMap[jid] = newMember
if (!newMember.isAudioMuted) audioSendersCount++
if (!newMember.isVideoMuted) videoSendersCount++
if (newMember.role == MemberRole.VISITOR) {
pendingVisitorsCounter.eventOccurred()
visitorMemberCount++
}
return newMember
}
}
/**
* Gets the "real" JID of an occupant of this room specified by its
* occupant JID.
* @param occupantJid the occupant JID.
* @return the "real" JID of the occupant, or `null`.
*/
fun getJid(occupantJid: EntityFullJid): Jid? = muc.getOccupant(occupantJid)?.jid ?: run {
logger.error("Unable to get occupant for $occupantJid")
null
}
/**
* Processes a <tt>Presence</tt> packet addressed to our own occupant
* JID.
* @param presence the packet to process.
*/
private fun processOwnPresence(presence: Presence) {
val mucUser = presence.getExtension(MUCUser::class.java)
if (mucUser != null) {
val affiliation = mucUser.item.affiliation
val role = mucUser.item.role
// This is our initial role and affiliation, as smack does not fire any initial events.
if (!presence.isAvailable && MUCAffiliation.none == affiliation && MUCRole.none == role) {
val destroy = mucUser.destroy
if (destroy == null) {
// the room is unavailable to us, there is no
// message we will just leave
leave()
} else {
logger.info("Leave, reason: ${destroy.reason} alt-jid: ${destroy.jid}")
leave()
}
} else {
eventEmitter.fireEvent { localRoleChanged(fromSmack(role, affiliation)) }
}
}
}
/**
* Process a <tt>Presence</tt> packet sent by one of the other room
* occupants.
* @param presence the presence.
*/
private fun processOtherPresence(presence: Presence) {
val jid = presence.from.asEntityFullJidIfPossible() ?: run {
logger.warn("Presence without a valid jid: ${presence.from}")
return
}
var memberJoined = false
var memberLeft = false
val member = synchronized(membersMap) {
val m = getChatMember(jid)
if (m == null) {
if (presence.type == Presence.Type.available) {
// This is how we detect that a new member has joined. We do not use the
// ParticipantStatusListener#joined callback.
logger.debug { "Joined $jid room: $roomJid" }
memberJoined = true
addMember(jid)
} else {
// We received presence from an unknown member which doesn't look like a new member's presence.
// Ignore it. The member might have been just removed via left(), which is fine.
null
}
} else {
memberLeft = presence.type == Presence.Type.unavailable
m
}
}
if (member != null) {
val previousTranslationLanguage = member.translationLanguage
member.processPresence(presence)
if (memberJoined) {
// Trigger member "joined"
eventEmitter.fireEvent { memberJoined(member) }
} else if (memberLeft) {
if (MUCUser.from(presence).status.contains(MUCUser.Status.KICKED_307)) {
memberListener.kicked(jid)
} else {
memberListener.left(jid)
}
}
if (!memberLeft) {
eventEmitter.fireEvent { memberPresenceChanged(member) }
}
// This member picked a translation language, changed it, or gave it up, so the room's set may have
// changed. A member joining with one set is covered too, because its language is null before its first
// presence is processed. A member leaving is handled by MemberListener instead, because it can also be
// removed without an unavailable presence.
if (member.translationLanguage != previousTranslationLanguage) {
eventEmitter.fireEvent { translationLanguagesChanged() }
}
}
}
/**
* Offload processing presence for the room from Smack's thread to a queue running in the jicofo IO pool.
*/
override fun processPresence(presence: Presence?) {
if (presence == null) {
logger.warn("Received null presence packet")
return
}
xmppTaskQueue.add { doProcessPresence(presence) }
}
private fun doProcessPresence(presence: Presence) {
if (presence.error != null) {
logger.warn("Received presence with error: ${presence.toXML()}")
return
}
logger.trace { "Presence received ${presence.toXML()}" }
// Should never happen, but log if something is broken
val myOccupantJid = this.myOccupantJid
if (myOccupantJid == null) {
logger.error("Processing presence when myOccupantJid is not set: ${presence.toXML()}")
}
if (myOccupantJid != null && myOccupantJid.equals(presence.from)) {
processOwnPresence(presence)
} else {
processOtherPresence(presence)
}
}
override fun queueXmppTask(runnable: () -> Unit) = xmppTaskQueue.add(runnable)
private object MucConfigFields {
const val IS_BREAKOUT_ROOM = "muc#roominfo_isbreakout"
const val MAIN_ROOM = "muc#roominfo_breakout_main_room"
const val MEETING_ID = "muc#roominfo_meetingId"
const val WHOIS = "muc#roomconfig_whois"
const val CONFERENCE_PRESET_ENABLED = "muc#roominfo_conference_presets_service_enabled"
}
private inner class MemberListener {
private fun removeMember(occupantJid: EntityFullJid?): ChatRoomMemberImpl? {
synchronized(membersMap) {
val removed = membersMap.remove(occupantJid)
if (removed == null) {
logger.error("$occupantJid not in room")
} else {
if (!removed.isAudioMuted) audioSendersCount--
if (!removed.isVideoMuted) videoSendersCount--
if (removed.role == MemberRole.VISITOR) {
visitorMemberCount--
}
}
return removed
}
}
/** This needs to be prepared to run twice for the same member. */
fun left(occupantJid: EntityFullJid) {
logger.debug { "Left $occupantJid room: $roomJid" }
val member = synchronized(membersMap) { removeMember(occupantJid) }
member?.let {
eventEmitter.fireEvent { memberLeft(it) }
translationLanguageReleased(it)
} ?: logger.info("Member left event for non-existing member: $occupantJid")
}
fun kicked(occupantJid: EntityFullJid) {
val member = synchronized(membersMap) { removeMember(occupantJid) }
member?.let {
eventEmitter.fireEvent { memberKicked(it) }
translationLanguageReleased(it)
} ?: logger.error("Kicked member does not exist: $occupantJid")
}
/**
* A member that was removed from the room may have been the only one wanting its translation language, so the
* room's set may have shrunk. Fired here rather than from presence handling, because a member can also be
* removed without an unavailable presence (e.g. the stale-member cleanup).
*/
private fun translationLanguageReleased(member: ChatRoomMemberImpl) {
if (member.translationLanguage != null) {
eventEmitter.fireEvent { translationLanguagesChanged() }
}
}
}
/**
* Listens for room destroyed and pass it to the conference.
*/
private inner class LocalUserStatusListener : UserStatusListener {
override fun roomDestroyed(alternateMUC: MultiUserChat?, reason: String?) {
eventEmitter.fireEvent { roomDestroyed(reason) }
}
}
@SuppressFBWarnings("RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT")
private inner class AvModerationForMediaType(mediaType: MediaType) {
var enabled: Boolean by observableWhenChanged(false) { _, _, newValue ->
logger.info("Setting enabled=$newValue for $mediaType")
}
var whitelist: List<String> by observableWhenChanged(emptyList()) { _, _, newValue ->
logger.info("Setting whitelist for $mediaType: $newValue")
}
val debugState: ObjectNode
get() = JsonNodeFactory.instance.objectNode().apply {
put("enabled", enabled)
set<ArrayNode>("whitelist", jsonMapper.valueToTree(whitelist))
}
fun isAllowedToUnmute(jid: Jid) = !enabled || whitelist.contains(jid.toString())
fun reset() {
enabled = false
whitelist = emptyList()
}
}
companion object {
/**
* The visitors' requested translation languages from [roomMetadata], as a set.
*
* Prosody sends them as one comma-separated string, and an empty string when there are none. Note that this
* must clear the set when the string is empty, so that the languages are released when the last visitor that
* wanted them goes away.
*/
private fun parseVisitorTranslationLanguages(roomMetadata: RoomMetadata): Set<String> =
roomMetadata.metadata?.visitors?.transcribingLanguages
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?.toSet()
?: emptySet()
}
}

View File

@@ -0,0 +1,50 @@
/*
* Jitsi, the OpenSource Java VoIP and Instant Messaging client.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.muc
/** Listener for events fired from a [org.jitsi.impl.protocol.xmpp.ChatRoom] **/
interface ChatRoomListener {
fun memberJoined(member: ChatRoomMember) {}
fun memberKicked(member: ChatRoomMember) {}
fun memberLeft(member: ChatRoomMember) {}
fun memberPresenceChanged(member: ChatRoomMember) {}
fun roomDestroyed(reason: String? = null) {}
fun startMutedChanged(startAudioMuted: Boolean, startVideoMuted: Boolean) {}
fun localRoleChanged(newRole: MemberRole) {}
fun numAudioSendersChanged(numAudioSenders: Int) {}
fun numVideoSendersChanged(numVideoSenders: Int) {}
fun transcribingEnabledChanged(enabled: Boolean) {}
/**
* The aggregated live-translation request map changed: sender endpoint id -> set of requested language codes.
*/
fun audioTranslationRequestsChanged(requests: Map<String, List<String>>) {}
/**
* The set of languages the room wants transcriptions translated into may have changed, because a member changed
* or gave up its language, a member holding one left, or the visitors' languages changed.
*
* The new set is not passed: read it from [ChatRoom.getTranslationLanguages], which unions the members' languages
* with the visitors'. The event only says "recompute" -- it can fire when the union is in fact unchanged.
*/
fun translationLanguagesChanged() {}
}
/** A class with the default kotlin method implementations (to avoid using @JvmDefault) **/
open class DefaultChatRoomListener : ChatRoomListener

View File

@@ -0,0 +1,96 @@
/*
* Jitsi, the OpenSource Java VoIP and Instant Messaging client.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.muc
import com.fasterxml.jackson.databind.node.ObjectNode
import org.jitsi.jicofo.xmpp.Features
import org.jivesoftware.smack.packet.Presence
import org.jxmpp.jid.EntityFullJid
import org.jxmpp.jid.Jid
/**
* This interface represents chat room participants. Instances are retrieved
* through implementations of the <tt>ChatRoom</tt> interface and offer methods
* that allow querying member properties, such as, moderation permissions,
* associated chat room and other.
*
* @author Emil Ivov
* @author Boris Grozev
*/
interface ChatRoomMember {
val chatRoom: ChatRoom
/** The ID of this member. Set to the resource part of the occupant JID. */
val name: String
/** The role of this chat room member in its containing room. */
val role: MemberRole
/** Returns the JID of the user (outside the MUC), i.e. the "real" JID. It may not always be known. */
val jid: Jid?
/** Get the latest [SourceInfo]s advertised by this chat member in presence. */
val sourceInfos: Set<SourceInfo>
/** The occupant JID of the member in the chat room */
val occupantJid: EntityFullJid
/** The last [Presence] packet received for this member (or null it if no presence has been received yet) */
val presence: Presence?
val isJigasi: Boolean
val isTranscriber: Boolean
val isJibri: Boolean
val isAudioMuted: Boolean
val isVideoMuted: Boolean
/** Whether this member requested per-endpoint diarization (signaled via the "diarize" participant property). */
val diarize: Boolean
/**
* The language this member wants transcriptions translated into, or null when it wants none or signaled one that
* does not look like a language code.
*
* Signaled via the "translation_language" participant property, the same presence element jigasi has always used.
* Whether transcription runs at all is a separate question, decided by the room metadata.
*/
val translationLanguage: String?
/** Gets the region (e.g. "us-east") of this [ChatRoomMember]. */
val region: String?
/** The statistics id if any. */
val statsId: String?
/** The supported video codecs if any */
val videoCodecs: List<String>?
/**
* The list of features advertised as XMPP capabilities. Note that although the features are cached (XEP-0115),
* the first time [features] is accessed it may block waiting for a disco#info response!
*/
val features: Set<Features>
/**
* Whether [features] were actually discovered. When this is false [features] is just an assumed default set, and
* the absence of a feature from it does not mean that the member does not support the feature.
*/
val featuresDiscovered: Boolean
val debugState: ObjectNode
}

View File

@@ -0,0 +1,325 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.muc
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.jitsi.jicofo.xmpp.FeatureDiscoveryResult
import org.jitsi.jicofo.xmpp.Features
import org.jitsi.jicofo.xmpp.XmppCapsStats
import org.jitsi.jicofo.xmpp.XmppConfig
import org.jitsi.jicofo.xmpp.muc.MemberRole.Companion.fromSmack
import org.jitsi.utils.MediaType
import org.jitsi.utils.logging2.Logger
import org.jitsi.utils.logging2.createChildLogger
import org.jitsi.xmpp.extensions.jitsimeet.AudioMutedExtension
import org.jitsi.xmpp.extensions.jitsimeet.FeaturesExtension
import org.jitsi.xmpp.extensions.jitsimeet.JitsiParticipantCodecList
import org.jitsi.xmpp.extensions.jitsimeet.JitsiParticipantRegionPacketExtension
import org.jitsi.xmpp.extensions.jitsimeet.StatsId
import org.jitsi.xmpp.extensions.jitsimeet.VideoMutedExtension
import org.jivesoftware.smack.packet.Presence
import org.jivesoftware.smack.packet.StandardExtensionElement
import org.jivesoftware.smackx.caps.packet.CapsExtension
import org.jxmpp.jid.EntityFullJid
import org.jxmpp.jid.Jid
private val jsonMapper = jacksonObjectMapper()
/**
* Stripped Smack implementation of [ChatRoomMember].
*
* @author Pawel Domas
*/
class ChatRoomMemberImpl(
/** The full MUC JID (room_name@muc.server.net/nickname) */
override val occupantJid: EntityFullJid,
override val chatRoom: ChatRoomImpl,
parentLogger: Logger
) : ChatRoomMember {
override val name: String = occupantJid.resourceOrThrow.toString()
private val logger: Logger = createChildLogger(parentLogger).apply {
addContext("id", name)
}
override var region: String? = null
private set
override var presence: Presence? = null
private set
override var isJigasi = false
private set
override var isTranscriber = false
private set
override var isJibri = false
private set
override var statsId: String? = null
private set
override var diarize: Boolean = false
override var translationLanguage: String? = null
private set
override var videoCodecs: List<String>? = null
private set
override var isAudioMuted = true
private set
override var isVideoMuted = true
private set
override var sourceInfos = emptySet<SourceInfo>()
private set
/** The node#ver advertised in a Caps extension. */
private var capsNodeVer: String? = null
override var role: MemberRole =
chatRoom.getOccupant(this)?.let { fromSmack(it.role, it.affiliation) } ?: MemberRole.VISITOR
private set
/**
* Caches user JID of the participant if we're able to see it.
*/
override var jid: Jid? = null
get() {
if (field == null) {
field = chatRoom.getJid(occupantJid)
}
return field
}
private set
private fun updateSourceInfo(presence: Presence) {
val sourceInfo = presence.getExtension<StandardExtensionElement>("SourceInfo", "jabber:client")
if (sourceInfo == null) {
sourceInfos = emptySet()
} else {
val sourceInfos: Set<SourceInfo> = try {
parseSourceInfoJson(sourceInfo.text)
} catch (e: Exception) {
logger.warn("Ignoring invalid SourceInfo JSON", e)
return
}
this.sourceInfos = sourceInfos
}
val wasAudioMuted = isAudioMuted
isAudioMuted = if (sourceInfo == null) {
// Support the old format, still used by jigasi
presence.getExtension(AudioMutedExtension::class.java)?.isAudioMuted ?: true
} else {
sourceInfos.filter { it.mediaType == MediaType.AUDIO }.none { !it.muted }
}
if (isAudioMuted != wasAudioMuted) {
logger.debug { "isAudioMuted = $isAudioMuted" }
if (isAudioMuted) chatRoom.audioSendersCount-- else chatRoom.audioSendersCount++
}
val wasVideoMuted = isVideoMuted
isVideoMuted = if (sourceInfo == null) {
// Support the old format, still used by jigasi
presence.getExtension(VideoMutedExtension::class.java)?.isVideoMuted ?: true
} else {
sourceInfos.filter { it.mediaType == MediaType.VIDEO }.none { !it.muted }
}
if (isVideoMuted != wasVideoMuted) {
logger.debug { "isVideoMuted = $isVideoMuted" }
if (isVideoMuted) chatRoom.videoSendersCount-- else chatRoom.videoSendersCount++
}
}
/**
* Process a new presence published by the member and update the mutable fields.
*
* @param presence the <tt>Presence</tt> which was sent by this chat member.
* @throws IllegalArgumentException if [presence] does not
* belong to this <tt>ChatMemberImpl</tt>.
*/
fun processPresence(presence: Presence) {
require(presence.from == occupantJid) { "Ignoring presence for a different member: ${presence.from}" }
val firstPresence = (this.presence == null)
this.presence = presence
presence.getExtension(CapsExtension::class.java)?.let {
capsNodeVer = "${it.node}#${it.ver}"
}
updateSourceInfo(presence)
// We recognize jigasi by the existence of a "feature" extension in its presence.
val features = presence.getExtension(FeaturesExtension::class.java)
if (features != null) {
val domain = jid?.asDomainBareJid()
val jidTrusted = domain != null && XmppConfig.config.trustedDomains.contains(domain)
val jibriSignaled = features.featureExtensions.any { it.`var` == "http://jitsi.org/protocol/jibri" }
val jigasiSignaled = features.featureExtensions.any { it.`var` == "http://jitsi.org/protocol/jigasi" }
val transcriberSignaled = features.featureExtensions.any {
it.`var` == "http://jitsi.org/protocol/transcriber"
}
isJibri = jibriSignaled && jidTrusted
isJigasi = jigasiSignaled && jidTrusted
isTranscriber = transcriberSignaled && jidTrusted
if (jibriSignaled && !jidTrusted) {
logger.warn(
"Jibri signaled from a non-trusted domain ($domain). The domain can be " +
"configured as trusted with the jicofo.xmpp.trusted-domains property."
)
}
if (jigasiSignaled && !jidTrusted) {
logger.warn(
"Jigasi signaled from a non-trusted domain ($domain). The domain can be " +
"configured as trusted with the jicofo.xmpp.trusted-domains property."
)
}
} else {
isJigasi = false
isJibri = false
isTranscriber = false
}
var newRole: MemberRole = MemberRole.VISITOR
chatRoom.getOccupant(this)?.let { newRole = fromSmack(it.role, it.affiliation) }
if (!firstPresence && (role == MemberRole.VISITOR) != (newRole == MemberRole.VISITOR)) {
// Allowing this to change would mess up various member counts, ignore the change
// TODO: Should we try to update them, instead?
if (presence.type != Presence.Type.unavailable) {
logger.warn("Member role changed from $role to $newRole - not supported!")
}
} else {
role = newRole
}
presence.getExtension(JitsiParticipantRegionPacketExtension::class.java)?.let {
region = it.regionId
}
presence.getExtension(StatsId::class.java)?.let {
statsId = it.statsId
}
val diarizeElement = presence.getExtensionElement("jitsi_participant_diarize", "jabber:client")
diarize = (diarizeElement as? StandardExtensionElement)?.text?.toBoolean() ?: false
translationLanguage = parseTranslationLanguage(presence)
val newVideoCodecs =
presence.getExtension(JitsiParticipantCodecList::class.java)?.let {
if (!it.codecs.contains("vp8")) {
if (firstPresence) {
logger.warn("Video codec list {${it.codecs}} does not contain vp8! Adding manually.")
}
it.codecs + "vp8"
} else {
it.codecs
}
} ?: // Older clients sent a single codec in codecType rather than all supported ones in codecList
presence.getExtensionElement("jitsi_participant_codecType", "jabber:client")?.let {
if (it is StandardExtensionElement) {
val codec = it.text.lowercase()
if (codec == "vp8") {
listOf(codec)
} else {
listOf(codec, "vp8")
}
} else {
null
}
}
if (!firstPresence && newVideoCodecs != videoCodecs) {
// Allowing this to change would mess up visitor codec preference counts, ignore the change
if (role == MemberRole.VISITOR && presence.type != Presence.Type.unavailable && newVideoCodecs != null) {
logger.warn("Visitor video codec list changed from $videoCodecs to $newVideoCodecs - not supported!")
}
} else {
videoCodecs = newVideoCodecs
}
}
override fun toString() = "ChatMember[id=$name role=$role]"
companion object {
/**
* The shape of a language code, as jitsi-meet sends them (the keys of its
* lang/translation-languages.json): a 2- or 3-letter primary subtag with optional region or script subtags,
* for example "fr", "ceb" or "zh-CN".
*/
private val LANGUAGE_CODE_REGEX = Regex("[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*")
/**
* The language a member wants transcriptions translated into, or null when it wants none. A blank value means
* the same as an absent one.
*
* The value is set by the participant, so it is validated rather than trusted. It ends up in a colibri2
* attribute, in the bridge's log lines, and (later) in a request to a translation provider. Smack escapes the
* attribute and Jackson escapes the JSON, so this is not about breaking out of the XML or the JSON. It keeps
* junk and control characters out of the signalling, and it bounds the length.
*/
private fun parseTranslationLanguage(presence: Presence): String? {
val language = presence
.getExtensionElement("jitsi_participant_translation_language", "jabber:client")
.let { (it as? StandardExtensionElement)?.text }
?.trim()
?: return null
if (language.isEmpty()) {
return null
}
return language.takeIf { LANGUAGE_CODE_REGEX.matches(it) }
}
}
private val featureDiscoveryResult: FeatureDiscoveryResult by lazy {
val result = chatRoom.xmppProvider.discoverFeatures(occupantJid)
// Update the stats once when the features are discovered.
capsNodeVer?.let {
XmppCapsStats.update(it, result.features)
} ?: logger.error("No caps nodeVer found")
result
}
override val features: Set<Features>
get() = featureDiscoveryResult.features
override val featuresDiscovered: Boolean
get() = featureDiscoveryResult.discovered
override val debugState: ObjectNode
get() = JsonNodeFactory.instance.objectNode().apply {
put("region", region.toString())
put("occupant_jid", occupantJid.toString())
put("jid", jid.toString())
put("is_jibri", isJibri)
put("is_jigasi", isJigasi)
put("is_transcriber", isTranscriber)
put("role", role.toString())
set<ObjectNode>(
"video_codecs",
JsonNodeFactory.instance.arrayNode().apply { videoCodecs?.forEach { add(it) } }
)
put("stats_id", statsId.toString())
put("is_audio_muted", isAudioMuted)
put("is_video_muted", isVideoMuted)
put("diarize", diarize)
put("translation_language", translationLanguage)
set<ObjectNode>("features", jsonMapper.valueToTree(features.map { it.name }))
put("features_discovered", featuresDiscovered)
put("capsNodeVer", capsNodeVer.toString())
}
}

View File

@@ -0,0 +1,55 @@
/*
* Jitsi, the OpenSource Java VoIP and Instant Messaging client.
*
* Copyright @ 2015-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.muc
import org.jivesoftware.smackx.muc.MUCAffiliation
import org.jivesoftware.smackx.muc.MUCRole
/** Indicates roles that a chat room member detains in its containing chat room. */
enum class MemberRole {
/** A role implying the full set of chat room permissions */
OWNER,
/** A role implying moderator permissions. */
MODERATOR,
/** A role implying the ability to send to a chat room */
PARTICIPANT,
/** A role implying only the ability to watch a chat room. */
VISITOR;
companion object {
@JvmStatic
fun fromSmack(mucRole: MUCRole?, mucAffiliation: MUCAffiliation?) = when (mucAffiliation) {
MUCAffiliation.admin -> MODERATOR
MUCAffiliation.owner -> OWNER
else -> when (mucRole) {
MUCRole.moderator -> MODERATOR
MUCRole.participant -> PARTICIPANT
else -> VISITOR
}
}
}
}
/** Has sufficient rights to moderate (i.e. is MODERATOR or OWNER). */
fun MemberRole?.hasModeratorRights() = this != null && this <= MemberRole.MODERATOR
fun MemberRole?.hasOwnerRights() = this != null && this <= MemberRole.OWNER

View File

@@ -0,0 +1,80 @@
/*
* Copyright @ 2021 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.xmpp.muc
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.jitsi.jicofo.conference.source.VideoType
import org.jitsi.utils.MediaType
/** The information about a source contained in a jitsi-meet SourceInfo extension. */
data class SourceInfo(
val name: String,
val muted: Boolean,
val videoType: VideoType?,
val mediaType: MediaType?
)
/**
* Parse the JSON string encoded in a SourceInfo XML extension as a set of [SourceInfo]s.
* @throws IllegalArgumentException if the string is not valid JSON or not in the expected format.
*/
@kotlin.jvm.Throws(IllegalArgumentException::class)
fun parseSourceInfoJson(s: String): Set<SourceInfo> {
val json = try {
jacksonObjectMapper().readTree(s)
} catch (e: JsonProcessingException) {
throw IllegalArgumentException("Illegal SourceInfo JSON: $s", e)
}
if (json !is ObjectNode) throw IllegalArgumentException("Illegal SourceInfo JSON: $s")
return json.fields().asSequence().map { (name, sourceNode) ->
val sourceJson = sourceNode as? ObjectNode
?: throw IllegalArgumentException("Invalid source value: $sourceNode")
val muted = sourceJson.get("muted")?.asBoolean() ?: true
val videoTypeNode = sourceJson.get("videoType")
val videoType = when {
videoTypeNode == null || videoTypeNode.isNull -> null
!videoTypeNode.isTextual -> throw IllegalArgumentException("Invalid videoType: $videoTypeNode")
else -> VideoType.parseString(videoTypeNode.asText())
}
val mediaTypeField = sourceJson.get("mediaType")?.takeIf { it.isTextual }?.asText()
val mediaType = when {
"audio".equals(mediaTypeField, ignoreCase = true) -> MediaType.AUDIO
"video".equals(mediaTypeField, ignoreCase = true) -> MediaType.VIDEO
else -> parseMediaTypeFromName(name)
}
SourceInfo(name, muted, videoType, mediaType)
}.toSet()
}
/**
* If not explicitly signaled the media type is encoded in the name as in `abcd-a0` (an audio source) and `abcd-v5`
* (a video source).
*/
private fun parseMediaTypeFromName(name: String): MediaType? = name.indexOf('-').let { indexOfDash ->
if (indexOfDash < 0 || indexOfDash == name.length - 1) {
null
} else {
when (name[indexOfDash + 1]) {
'a' -> MediaType.AUDIO
'v' -> MediaType.VIDEO
else -> null
}
}
}

View File

@@ -0,0 +1,214 @@
// Default values for configuration read by classes in jicofo-common. Defaults for other jicofo.* sections live in
// the reference.conf of the module that owns them (jicofo-selector, jicofo).
jicofo {
// Configure the codecs and RTP extensions to be used in the offer sent to clients.
codec {
video {
av1 {
enabled = true
pt = 41
// Payload type for the associated RTX stream. Set to -1 to disable RTX.
rtx-pt = 42
enable-remb = true
}
vp8 {
enabled = true
pt = 100
// Payload type for the associated RTX stream. Set to -1 to disable RTX.
rtx-pt = 96
enable-remb = true
}
vp9 {
enabled = true
pt = 101
// Payload type for the associated RTX stream. Set to -1 to disable RTX.
rtx-pt = 97
enable-remb = true
}
h264 {
enabled = true
pt = 107
// Payload type for the associated RTX stream. Set to -1 to disable RTX.
rtx-pt = 99
enable-remb = true
}
}
audio {
opus {
enabled = true
pt = 111
minptime = 10
use-inband-fec = true
red {
enabled = false
pt = 112
}
}
telephone-event {
enabled = true
pt = 126
}
}
// RTP header extensions
rtp-extensions {
audio-level {
enabled = true
id = 1
}
av1-dependency-descriptor {
enabled = true
id = 11
}
video-layers-allocation {
enabled = false
id = 12
}
tof {
// TOF is currently disabled, because we don't support it in the bridge
// (and currently clients seem to not use it when abs-send-time is
// available).
enabled = false
id = 2
}
abs-send-time {
enabled = true
id = 3
}
rid {
enabled = false
id = 4
}
tcc {
enabled = true
id = 5
}
video-content-type {
enabled = false
id = 7
}
framemarking {
enabled = false
id = 9
}
mid {
enabled = true
id = 10
}
extmap-allow-mixed = true
}
}
// The region in which the machine is running.
#local-region="us-east-1"
// Controls the metrics accessible at /metrics (but not /stats).
metrics {
// A subset of the metrics are re-calculated and updated periodically. This controls how often.
update-interval = 30 seconds
}
octo {
// Whether or not to use Octo. Note that when enabled, its use will be determined by
// $jicofo.bridge.selection-strategy. There's a corresponding flag in the JVB and these
// two MUST be in sync (otherwise bridges will crash because they won't know how to
// deal with octo channels).
enabled = false
// Whether to allow bridges with different versions to be used in the same conference. Intended only for testing.
allow-mixed-versions = false
// Whether bridge-to-bridge communication should use SCTP datachannels (as opposed to websockets)
sctp-datachannels = ${jicofo.sctp.enabled}
}
sctp {
// Whether to allocate SCTP channels on the bridge (only when the client advertises support, and SCTP is
// enabled in the per-conference configuration).
enabled = true
}
visitors {
# After a visitor is redirected to a vnode, the amount of time to count it toward the vnode's visitor count
# even before its presence shows up. This stops vnodes from getting overloaded when many visitors join simultaneously.
# The rest of the visitors configuration is defined in the jicofo module.
vnode-join-latency-interval = 60 seconds
}
xmpp {
// Whether to use JitsiXmppStringprep to validate JIDs. If set to false uses the default validation in Smack.
use-jitsi-jid-validation = true
// The separate XMPP connection used for communication with clients (endpoints).
client {
enabled = true
hostname = "localhost"
port = 5222
# The domain to use for login.
#domain =
# The domain where XMPP pings will be sent and where components will be discovered.
#xmpp-domain =
username = "focus"
resource = ${jicofo.xmpp.client.username}
#password =
// How long to wait for a response to a stanza before giving up.
reply-timeout = 15 seconds
// The JID/domain of the MUC service used for conferencing.
# conference-muc-jid = conference.example.com
// A flag to suppress the TLS certificate verification.
disable-certificate-verification = false
// The JID of the mod_client_proxy component if used. It will be trusted to encode the JID of the original
// sender in the resource part of the JID.
#client-proxy = focus.example.com
// Use TLS between Jicofo and the XMPP server
// Only disable this if your xmpp connection is on loopback!
use-tls = true
}
// The separate XMPP connection used for internal services (currently only jitsi-videobridge).
service {
enabled = false
hostname = "localhost"
port = 6222
#domain =
# The domain where XMPP pings will be sent and where components will be discovered.
#xmpp-domain =
username = "focus"
resource = ${jicofo.xmpp.service.username}
#password =
// How long to wait for a response to a stanza before giving up.
reply-timeout = 15 seconds
// A flag to suppress the TLS certificate verification.
disable-certificate-verification = false
// Use TLS between Jicofo and the XMPP server
// Only disable this if your xmpp connection is on loopback!
use-tls = true
}
// Additional XMPP environments to use for visitor nodes, mapped by an id/name.
visitors {
#v1 {
#enabled=true
#conference-service = conference.v1.example.com
#hostname = 127.0.0.1
#domain = "auth.v1.example.com"
#port = 7222
#password = "changeme"
#disable-certificate-verification = true
# The domain where XMPP pings will be sent and where components will be discovered.
#xmpp-domain =
#}
}
// The list of domains with trusted services. Only members logged in to these domains can declare themselves to be
// Jibri instances.
trusted-domains = []
}
}

View File

@@ -0,0 +1,42 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2026 - present 8x8, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.impl.protocol.xmpp.log
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import org.jitsi.xmpp.extensions.jitsimeet.ConferenceIq
import org.jitsi.xmpp.util.RedactColibri.Companion.redactToken
import org.jxmpp.jid.impl.JidCreate
class PacketDebuggerTest : ShouldSpec({
should("redact the token attribute of a logged ConferenceIq, as done by PacketDebugger") {
val token = "eyJhbGciOiJIUzI1NiJ9.super-secret-jwt"
val iq = ConferenceIq().apply {
room = JidCreate.entityBareFrom("room@conference.example.com")
this.token = token
stanzaId = "id1"
}
val redacted = redactToken(iq.toXML().toString())
redacted shouldNotContain token
redacted shouldContain "room=\"room@conference.example.com\""
redacted shouldContain "token=\"[redacted]\""
}
})

View File

@@ -0,0 +1,33 @@
import io.kotest.core.config.AbstractProjectConfig
import org.jitsi.jicofo.metrics.JicofoMetricsContainer
import org.jitsi.metaconfig.MetaconfigSettings
/*
* Copyright @ 2026 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Note: the jicofo modules share packages, so the class name is unique to avoid conflicts.
*/
class JicofoCommonKotestProjectConfig : AbstractProjectConfig() {
override suspend fun beforeProject() = super.beforeProject().also {
// The only purpose of config caching is performance. We always want caching disabled in tests (so we can
// freely modify the config without affecting other tests executing afterwards).
MetaconfigSettings.cacheEnabled = false
JicofoMetricsContainer.instance.checkForNameConflicts = false
JicofoMetricsContainer.instance.metricsUpdater.disablePeriodicUpdates = true
}
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright @ 2020 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import org.jitsi.config.withLegacyConfig
import org.jitsi.config.withNewConfig
import org.jitsi.jicofo.JicofoConfig.Companion.config
class JicofoConfigTest : ShouldSpec() {
init {
context("Local region config") {
context("With no config") {
config.localRegion shouldBe null
}
context("With legacy config") {
withLegacyConfig("org.jitsi.jicofo.BridgeSelector.LOCAL_REGION=legacy") {
config.localRegion shouldBe "legacy"
}
}
context("With new config") {
withNewConfig("jicofo { local-region=new }") {
config.localRegion shouldBe "new"
}
}
context("With both new and legacy") {
withNewConfig("jicofo { local-region=new }") {
withLegacyConfig("org.jitsi.jicofo.BridgeSelector.LOCAL_REGION=legacy") {
config.localRegion shouldBe "legacy"
}
}
}
}
context("SCTP") {
context("Should be enabled by default") {
config.enableSctp shouldBe true
}
context("Should be disabled with new config") {
withNewConfig("jicofo { sctp { enabled=false } }") {
config.enableSctp shouldBe false
}
}
}
}
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright @ 2020 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import org.jitsi.config.withNewConfig
class OctoConfigTest : ShouldSpec() {
init {
context("Enabled flag") {
// Note that there's a corresponding flag in the JVB and these two
// MUST be in sync (otherwise bridges will crash because they won't
// know how to deal with octo channels).
context("default") {
OctoConfig.config.enabled shouldBe false
}
context("disabled") {
withNewConfig("jicofo.octo.enabled=false") {
OctoConfig.config.enabled shouldBe false
}
}
context("enabled") {
withNewConfig("jicofo.octo.enabled=true") {
OctoConfig.config.enabled shouldBe true
}
}
}
}
}

View File

@@ -0,0 +1,288 @@
/*
* Copyright @ 2020 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.codec
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import org.jitsi.config.withLegacyConfig
import org.jitsi.config.withNewConfig
import org.jitsi.jicofo.codec.Config.Companion.config
class CodecConfigTest : ShouldSpec() {
init {
context("Default configuration") {
config.av1.enabled() shouldBe true
config.av1.pt() shouldBe 41
config.av1.rtxEnabled() shouldBe true
config.av1.rtxPt() shouldBe 42
config.vp8.enabled() shouldBe true
config.vp8.pt() shouldBe 100
config.vp8.rtxEnabled() shouldBe true
config.vp8.rtxPt() shouldBe 96
config.vp9.enabled() shouldBe true
config.vp9.pt() shouldBe 101
config.vp9.rtxEnabled() shouldBe true
config.vp9.rtxPt() shouldBe 97
config.h264.enabled() shouldBe true
config.h264.pt() shouldBe 107
config.h264.rtxEnabled() shouldBe true
config.h264.rtxPt() shouldBe 99
config.opus.enabled() shouldBe true
config.opus.pt() shouldBe 111
config.opus.minptime() shouldBe 10
config.opus.useInbandFec() shouldBe true
config.opus.red.enabled() shouldBe false
shouldThrow<Throwable> { config.opus.red.pt() }
config.framemarking.enabled shouldBe false
config.framemarking.id shouldBe 9
config.absSendTime.enabled shouldBe true
config.absSendTime.id shouldBe 3
config.tof.enabled shouldBe false
config.tof.id shouldBe 2
config.videoContentType.enabled shouldBe false
config.videoContentType.id shouldBe 7
config.rid.enabled shouldBe false
config.rid.id shouldBe 4
config.tcc.enabled shouldBe true
config.tcc.id shouldBe 5
config.audioLevel.enabled shouldBe true
config.audioLevel.id shouldBe 1
config.mid.enabled shouldBe true
config.mid.id shouldBe 10
config.av1DependencyDescriptor.enabled shouldBe true
config.av1DependencyDescriptor.id shouldBe 11
}
context("Legacy config") {
context("Disabling a codec") {
withLegacyConfig("org.jitsi.jicofo.ENABLE_VP8=false") {
config.vp8.enabled() shouldBe false
shouldThrow<Throwable> { config.vp8.pt() }
config.vp8.rtxEnabled() shouldBe false
shouldThrow<Throwable> { config.vp8.rtxPt() }
}
withLegacyConfig("org.jitsi.jicofo.VP8_PT=-1") {
config.vp8.enabled() shouldBe false
shouldThrow<Throwable> { config.vp8.pt() }
config.vp8.rtxEnabled() shouldBe false
shouldThrow<Throwable> { config.vp8.rtxPt() }
}
withLegacyConfig("org.jitsi.jicofo.VP8_RTX_PT=-1") {
config.vp8.enabled() shouldBe true
config.vp8.pt() shouldBe 100
config.vp8.rtxEnabled() shouldBe false
shouldThrow<Throwable> { config.vp8.rtxPt() }
}
withLegacyConfig(
"""
org.jitsi.jicofo.VP8_PT=-1
org.jitsi.jicofo.VP8_RTX_PT=111
""".trimIndent()
) {
config.vp8.enabled() shouldBe false
shouldThrow<Throwable> { config.vp8.pt() }
config.vp8.rtxEnabled() shouldBe false
shouldThrow<Throwable> { config.vp8.rtxPt() }
}
}
context("Changing the PT and RTX PT") {
withLegacyConfig("org.jitsi.jicofo.VP8_PT=111") {
config.vp8.enabled() shouldBe true
config.vp8.pt() shouldBe 111
}
withLegacyConfig("org.jitsi.jicofo.VP8_RTX_PT=112") {
config.vp8.enabled() shouldBe true
config.vp8.pt() shouldBe 100
config.vp8.rtxEnabled() shouldBe true
config.vp8.rtxPt() shouldBe 112
}
}
context("Disabling/enabling extensions") {
withLegacyConfig("org.jitsi.jicofo.ENABLE_FRAMEMARKING=false") {
config.framemarking.enabled shouldBe false
}
withLegacyConfig("org.jitsi.jicofo.ENABLE_FRAMEMARKING=true") {
config.framemarking.enabled shouldBe true
}
withLegacyConfig("org.jitsi.jicofo.ENABLE_AST=false") {
config.absSendTime.enabled shouldBe false
}
withLegacyConfig("org.jitsi.jicofo.ENABLE_AST=true") {
config.absSendTime.enabled shouldBe true
}
withLegacyConfig("org.jitsi.jicofo.ENABLE_RID=false") {
config.rid.enabled shouldBe false
}
withLegacyConfig("org.jitsi.jicofo.ENABLE_RID=true") {
config.rid.enabled shouldBe true
}
withLegacyConfig("org.jitsi.jicofo.ENABLE_TOF=false") {
config.tof.enabled shouldBe false
}
withLegacyConfig("org.jitsi.jicofo.ENABLE_TOF=true") {
config.tof.enabled shouldBe true
}
}
}
context("New config") {
context("Disabling a codec") {
withNewConfig("jicofo.codec.video.vp8.enabled=false") {
config.vp8.enabled() shouldBe false
shouldThrow<Throwable> { config.vp8.pt() }
config.vp8.rtxEnabled() shouldBe false
shouldThrow<Throwable> { config.vp8.rtxPt() }
}
withNewConfig("jicofo.codec.video.vp8.pt=-1") {
config.vp8.enabled() shouldBe false
shouldThrow<Throwable> { config.vp8.pt() }
config.vp8.rtxEnabled() shouldBe false
shouldThrow<Throwable> { config.vp8.rtxPt() }
}
withNewConfig("jicofo.codec.video.vp8.rtx-pt=-1") {
config.vp8.enabled() shouldBe true
config.vp8.pt() shouldBe 100
config.vp8.rtxEnabled() shouldBe false
shouldThrow<Throwable> { config.vp8.rtxPt() }
}
withNewConfig(
"""
jicofo {
codec {
video {
vp8 {
enabled=true
pt=-1
rtp-pt=111
}
}
}
}
""".trimIndent()
) {
config.vp8.enabled() shouldBe false
shouldThrow<Throwable> { config.vp8.pt() }
config.vp8.rtxEnabled() shouldBe false
shouldThrow<Throwable> { config.vp8.rtxPt() }
}
}
context("Changing the PT and RTX PT") {
withNewConfig("jicofo.codec.video.vp8.pt=111") {
config.vp8.enabled() shouldBe true
config.vp8.pt() shouldBe 111
}
withNewConfig("jicofo.codec.video.vp8.rtx-pt=112") {
config.vp8.enabled() shouldBe true
config.vp8.pt() shouldBe 100
config.vp8.rtxEnabled() shouldBe true
config.vp8.rtxPt() shouldBe 112
}
}
context("Disabling/enabling extensions") {
withNewConfig("jicofo.codec.rtp-extensions.framemarking.enabled=false") {
config.framemarking.enabled shouldBe false
}
withNewConfig("jicofo.codec.rtp-extensions.framemarking.enabled=true") {
config.framemarking.enabled shouldBe true
}
withNewConfig("jicofo.codec.rtp-extensions.abs-send-time.enabled=false") {
config.absSendTime.enabled shouldBe false
}
withNewConfig("jicofo.codec.rtp-extensions.abs-send-time.enabled=true") {
config.absSendTime.enabled shouldBe true
}
withNewConfig("jicofo.codec.rtp-extensions.rid.enabled=false") {
config.rid.enabled shouldBe false
}
withNewConfig("jicofo.codec.rtp-extensions.rid.enabled=true") {
config.rid.enabled shouldBe true
}
withNewConfig("jicofo.codec.rtp-extensions.tof.enabled=false") {
config.tof.enabled shouldBe false
}
withNewConfig("jicofo.codec.rtp-extensions.tof.enabled=true") {
config.tof.enabled shouldBe true
}
withNewConfig("jicofo.codec.rtp-extensions.tcc.enabled=false") {
config.tcc.enabled shouldBe false
}
withNewConfig("jicofo.codec.rtp-extensions.tcc.enabled=true") {
config.tcc.enabled shouldBe true
}
withNewConfig("jicofo.codec.rtp-extensions.mid.enabled=false") {
config.mid.enabled shouldBe false
}
withNewConfig("jicofo.codec.rtp-extensions.mid.enabled=true") {
config.mid.enabled shouldBe true
}
withNewConfig("jicofo.codec.rtp-extensions.av1-dependency-descriptor.enabled=true") {
config.av1DependencyDescriptor.enabled shouldBe true
}
withNewConfig("jicofo.codec.rtp-extensions.av1-dependency-descriptor.enabled=false") {
config.av1DependencyDescriptor.enabled shouldBe false
}
}
context("Changing extension IDs") {
withNewConfig("jicofo.codec.rtp-extensions.tcc.id=1") {
config.tcc.id shouldBe 1
}
withNewConfig("jicofo.codec.rtp-extensions.abs-send-time.id=1") {
config.absSendTime.id shouldBe 1
}
}
context("Opus config") {
withNewConfig("jicofo.codec.audio.opus.red.enabled=true") {
config.opus.red.enabled() shouldBe true
config.opus.red.pt() shouldBe 112
}
}
}
context("With both legacy and new config, legacy should take precedence") {
withLegacyConfig("org.jitsi.jicofo.ENABLE_VP8=false") {
withNewConfig("jicofo.codec.video.vp8.enabled=true") {
config.vp8.enabled() shouldBe false
}
}
withLegacyConfig("org.jitsi.jicofo.ENABLE_VP8=true") {
withNewConfig("jicofo.codec.video.vp8.enabled=false") {
config.vp8.enabled() shouldBe true
}
}
withLegacyConfig("org.jitsi.jicofo.ENABLE_FRAMEMARKING=true") {
withNewConfig("jicofo.codec.rtp-extensions.framemarking.enabled=false") {
config.framemarking.enabled() shouldBe true
}
}
withLegacyConfig("org.jitsi.jicofo.ENABLE_FRAMEMARKING=false") {
withNewConfig("jicofo.codec.rtp-extensions.framemarking.enabled=true") {
config.framemarking.enabled() shouldBe false
}
}
}
}
}

View File

@@ -0,0 +1,175 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2022-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.codec
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import org.jitsi.config.withNewConfig
import java.net.URI
class CodecUtilTest : ShouldSpec() {
override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf
init {
context("Video") {
context("VP8") {
context("Default config") {
val vp8 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "VP8" }
vp8 shouldNotBe null
vp8!!.clockrate shouldBe 90000
vp8.id shouldBe Config.config.vp8.pt()
vp8.rtcpFeedbackTypeList.any { it.feedbackType == "nack" } shouldBe true
vp8.rtcpFeedbackTypeList.any { it.feedbackType == "transport-cc" } shouldBe true
vp8.rtcpFeedbackTypeList.any { it.feedbackType == "goog-remb" } shouldBe false
val vp8Rtx = CodecUtil.createVideoPayloadTypeExtensions().find {
it.id == Config.config.vp8.rtxPt()
}
vp8Rtx shouldNotBe null
vp8Rtx!!.id shouldBe Config.config.vp8.rtxPt()
vp8Rtx.name shouldBe "rtx"
vp8Rtx.parameters.any {
it.name == "apt" && it.value == Config.config.vp8.pt().toString()
} shouldBe true
}
withNewConfig("jicofo.codec.video.vp8.enabled=false") {
val vp8 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "VP8" }
vp8 shouldBe null
}
withNewConfig("jicofo.codec.video.vp8.pt=123") {
val vp8 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "VP8" }
vp8!!.id shouldBe 123
}
}
context("VP9") {
context("Default config") {
val vp9 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "VP9" }
vp9 shouldNotBe null
vp9!!.clockrate shouldBe 90000
vp9.id shouldBe Config.config.vp9.pt()
vp9.rtcpFeedbackTypeList.any { it.feedbackType == "nack" } shouldBe true
vp9.rtcpFeedbackTypeList.any { it.feedbackType == "transport-cc" } shouldBe true
vp9.rtcpFeedbackTypeList.any { it.feedbackType == "goog-remb" } shouldBe false
val vp9Rtx = CodecUtil.createVideoPayloadTypeExtensions().find {
it.id == Config.config.vp9.rtxPt()
}
vp9Rtx shouldNotBe null
vp9Rtx!!.id shouldBe Config.config.vp9.rtxPt()
vp9Rtx.name shouldBe "rtx"
vp9Rtx.parameters.any {
it.name == "apt" && it.value == Config.config.vp9.pt().toString()
} shouldBe true
}
withNewConfig("jicofo.codec.video.vp9.enabled=false") {
val vp9 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "VP9" }
vp9 shouldBe null
}
withNewConfig("jicofo.codec.video.vp9.pt=123") {
val vp9 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "VP9" }
vp9!!.id shouldBe 123
}
}
context("H264") {
context("Default config") {
val h264 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "H264" }
h264 shouldNotBe null
h264!!.clockrate shouldBe 90000
h264.id shouldBe Config.config.h264.pt()
h264.rtcpFeedbackTypeList.any { it.feedbackType == "nack" } shouldBe true
h264.rtcpFeedbackTypeList.any { it.feedbackType == "transport-cc" } shouldBe true
h264.rtcpFeedbackTypeList.any { it.feedbackType == "goog-remb" } shouldBe false
val h264Rtx = CodecUtil.createVideoPayloadTypeExtensions().find {
it.id == Config.config.h264.rtxPt()
}
h264Rtx shouldNotBe null
h264Rtx!!.id shouldBe Config.config.h264.rtxPt()
h264Rtx.name shouldBe "rtx"
h264Rtx.parameters.any {
it.name == "apt" && it.value == Config.config.h264.pt().toString()
} shouldBe true
}
withNewConfig("jicofo.codec.video.h264.enabled=false") {
val h264 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "H264" }
h264 shouldBe null
}
withNewConfig("jicofo.codec.video.h264.pt=123") {
val h264 = CodecUtil.createVideoPayloadTypeExtensions().find { it.name == "H264" }
h264!!.id shouldBe 123
}
}
}
context("Audio") {
context("Opus") {
val opus = CodecUtil.createAudioPayloadTypeExtensions().find { it.name == "opus" }
opus shouldNotBe null
opus!!.id shouldBe Config.config.opus.pt()
opus.clockrate shouldBe 48000
opus.channels shouldBe 2
}
context("RED") {
CodecUtil.createAudioPayloadTypeExtensions().any { it.name == "red" } shouldBe false
withNewConfig("jicofo.codec.audio.opus.red.enabled=true") {
val red = CodecUtil.createAudioPayloadTypeExtensions().find { it.name == "red" }
red shouldNotBe null
val opusPt = Config.config.opus.pt()
red!!.parameters.any { it.value == "$opusPt/$opusPt" } shouldBe true
}
}
}
context("RTP Header Extensions") {
context("SSRC Audio Level") {
val alUri = URI.create("urn:ietf:params:rtp-hdrext:ssrc-audio-level")
CodecUtil.createAudioRtpHdrExtExtensions().any { it.uri == alUri } shouldBe true
CodecUtil.createVideoRtpHdrExtExtensions().any { it.uri == alUri } shouldBe false
}
context("MID") {
val midUri = URI.create("urn:ietf:params:rtp-hdrext:sdes:mid")
CodecUtil.createAudioRtpHdrExtExtensions().any { it.uri == midUri } shouldBe true
CodecUtil.createVideoRtpHdrExtExtensions().any { it.uri == midUri } shouldBe true
withNewConfig("jicofo.codec.rtp-extensions.mid.enabled=false") {
CodecUtil.createAudioRtpHdrExtExtensions().any { it.uri == midUri } shouldBe false
CodecUtil.createVideoRtpHdrExtExtensions().any { it.uri == midUri } shouldBe false
}
}
context("TCC") {
val tccUri = URI.create("http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01")
CodecUtil.createAudioRtpHdrExtExtensions().any { it.uri == tccUri } shouldBe true
CodecUtil.createVideoRtpHdrExtExtensions().any { it.uri == tccUri } shouldBe true
CodecUtil.createAudioRtpHdrExtExtensions(OfferOptions(tcc = false)).any {
it.uri == tccUri
} shouldBe false
CodecUtil.createVideoRtpHdrExtExtensions(OfferOptions(tcc = false)).any {
it.uri == tccUri
} shouldBe false
withNewConfig("jicofo.codec.rtp-extensions.tcc.enabled=false") {
CodecUtil.createAudioRtpHdrExtExtensions().any { it.uri == tccUri } shouldBe false
CodecUtil.createVideoRtpHdrExtExtensions().any { it.uri == tccUri } shouldBe false
}
}
}
}
}

View File

@@ -0,0 +1,261 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import org.jitsi.jicofo.util.shouldBeValidJson
import org.jitsi.utils.MediaType
import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension
import org.jitsi.xmpp.extensions.jingle.RtpDescriptionPacketExtension
import org.jitsi.xmpp.extensions.jingle.SourceGroupPacketExtension
import org.jitsi.xmpp.extensions.jitsimeet.SSRCInfoPacketExtension
import java.lang.UnsupportedOperationException
@Suppress("NAME_SHADOWING")
@SuppressFBWarnings(
value = ["RCN_REDUNDANT_NULLCHECK_OF_NONNULL_VALUE"],
justification = "False positives."
)
class ConferenceSourceMapTest : ShouldSpec() {
override fun isolationMode() = IsolationMode.InstancePerLeaf
init {
val s7 = Source(7, MediaType.AUDIO)
val endpoint1SourceSet = EndpointSourceSet(
setOf(s1, s2, s7),
setOf(
SsrcGroup(SsrcGroupSemantics.Fid, listOf(1, 2))
)
)
val endpoint1AdditionalSourceSet = EndpointSourceSet(
setOf(
// The duplicates should be removed
s1,
s2,
s3,
s4,
s7
),
setOf(SsrcGroup(SsrcGroupSemantics.Fid, listOf(3, 4)))
)
val endpoint1CombinedSourceSet = endpoint1SourceSet + endpoint1AdditionalSourceSet
val endpoint2SourceSet = EndpointSourceSet(
setOf(
Source(101, MediaType.VIDEO),
Source(102, MediaType.VIDEO),
Source(103, MediaType.AUDIO)
),
setOf(
SsrcGroup(SsrcGroupSemantics.Fid, listOf(101, 102))
)
)
context("Constructors") {
context("Default") {
val conferenceSourceMap = ConferenceSourceMap(mutableMapOf(JID_1 to endpoint1SourceSet))
conferenceSourceMap.size shouldBe 1
conferenceSourceMap[JID_1] shouldBe endpoint1SourceSet
}
context("With a single EndpointSourceSet") {
val conferenceSourceMap = ConferenceSourceMap(JID_1 to endpoint1SourceSet)
conferenceSourceMap.size shouldBe 1
conferenceSourceMap[JID_1] shouldBe endpoint1SourceSet
}
context("With multiple EndpointSourceSets") {
val conferenceSourceMap = ConferenceSourceMap(
JID_1 to endpoint1SourceSet,
JID_2 to endpoint2SourceSet
)
conferenceSourceMap.size shouldBe 2
conferenceSourceMap[JID_1] shouldBe endpoint1SourceSet
conferenceSourceMap[JID_2] shouldBe endpoint2SourceSet
}
}
context("Add") {
val conferenceSourceMap = ConferenceSourceMap(JID_1 to endpoint1SourceSet)
context("Without overlap in endpoints") {
conferenceSourceMap.add(JID_2, endpoint2SourceSet)
conferenceSourceMap.size shouldBe 2
conferenceSourceMap[JID_1] shouldBe endpoint1SourceSet
conferenceSourceMap[JID_2] shouldBe endpoint2SourceSet
}
context("With overlap in endpoints") {
conferenceSourceMap.add(
ConferenceSourceMap(
JID_1 to endpoint1AdditionalSourceSet,
JID_2 to endpoint2SourceSet
)
)
conferenceSourceMap.size shouldBe 2
conferenceSourceMap[JID_1] shouldBe endpoint1CombinedSourceSet
conferenceSourceMap[JID_2] shouldBe endpoint2SourceSet
}
}
context("Remove") {
val conferenceSourceMap = ConferenceSourceMap(
JID_1 to endpoint1CombinedSourceSet,
JID_2 to endpoint2SourceSet
)
context("All of an endpoint's sources") {
conferenceSourceMap.remove(ConferenceSourceMap(JID_2 to endpoint2SourceSet))
conferenceSourceMap.size shouldBe 1
conferenceSourceMap[JID_1] shouldBe endpoint1CombinedSourceSet
conferenceSourceMap[JID_2] shouldBe null
}
context("Some of an endpoint's sources") {
conferenceSourceMap.remove(ConferenceSourceMap(JID_1 to endpoint1SourceSet))
conferenceSourceMap.size shouldBe 2
conferenceSourceMap[JID_1] shouldBe EndpointSourceSet(
setOf(s3, s4),
setOf(SsrcGroup(SsrcGroupSemantics.Fid, listOf(3, 4)))
)
conferenceSourceMap[JID_2] shouldBe endpoint2SourceSet
}
context("Nonexistent endpoint sources") {
conferenceSourceMap.remove(
ConferenceSourceMap(
JID_1 to EndpointSourceSet(
setOf(Source(12345, MediaType.VIDEO)),
setOf(SsrcGroup(SsrcGroupSemantics.Fid, listOf(12345)))
)
)
)
conferenceSourceMap.size shouldBe 2
conferenceSourceMap[JID_1] shouldBe endpoint1CombinedSourceSet
conferenceSourceMap[JID_2] shouldBe endpoint2SourceSet
}
context("Nonexistent endpoint") {
conferenceSourceMap.remove(ConferenceSourceMap("differentJid" to endpoint1CombinedSourceSet))
conferenceSourceMap.size shouldBe 2
conferenceSourceMap[JID_1] shouldBe endpoint1CombinedSourceSet
conferenceSourceMap[JID_2] shouldBe endpoint2SourceSet
}
}
context("Operator plus") {
val a = ConferenceSourceMap(JID_1 to endpoint1SourceSet)
val b = ConferenceSourceMap(JID_2 to endpoint2SourceSet)
(a + b) shouldBe ConferenceSourceMap(JID_1 to endpoint1SourceSet, JID_2 to endpoint2SourceSet)
}
context("Operator minus") {
val a = ConferenceSourceMap(JID_1 to endpoint1SourceSet, JID_2 to endpoint2SourceSet)
val b = ConferenceSourceMap(JID_1 to endpoint1SourceSet)
(a - b) shouldBe ConferenceSourceMap(JID_2 to endpoint2SourceSet)
}
context("To Jingle") {
val conferenceSourceMap = ConferenceSourceMap(JID_1 to endpoint1SourceSet)
val contents = conferenceSourceMap.toJingle()
contents.size shouldBe 2
val videoContent = contents.find { it.name == "video" }!!
val videoRtpDesc = videoContent.getFirstChildOfType(RtpDescriptionPacketExtension::class.java)!!
val videoSources =
videoRtpDesc.getChildExtensionsOfType(SourcePacketExtension::class.java)
videoSources.map { Source(MediaType.VIDEO, it) }.toSet() shouldBe setOf(
Source(1, MediaType.VIDEO),
Source(2, MediaType.VIDEO)
)
videoSources.forEach {
it.getFirstChildOfType(SSRCInfoPacketExtension::class.java).owner shouldBe JID_1
}
val sourceGroupPacketExtension =
videoRtpDesc.getFirstChildOfType(SourceGroupPacketExtension::class.java)!!
SsrcGroup.fromPacketExtension(sourceGroupPacketExtension) shouldBe
SsrcGroup(SsrcGroupSemantics.Fid, listOf(1, 2))
val audioContent = contents.find { it.name == "audio" }!!
val audioRtpDesc = audioContent.getFirstChildOfType(RtpDescriptionPacketExtension::class.java)!!
val audioSources = audioRtpDesc.getChildExtensionsOfType(SourcePacketExtension::class.java)
audioSources.map { Source(MediaType.AUDIO, it) } shouldBe listOf(s7)
audioSources.forEach {
it.getFirstChildOfType(SSRCInfoPacketExtension::class.java).owner shouldBe JID_1
}
}
context("unmodifiable") {
val conferenceSourceMap = ConferenceSourceMap(
JID_1 to endpoint1SourceSet,
JID_2 to endpoint2SourceSet
)
val unmodifiable = conferenceSourceMap.unmodifiable
unmodifiable[JID_1] shouldBe endpoint1SourceSet
conferenceSourceMap.add(JID_1, endpoint1AdditionalSourceSet)
conferenceSourceMap[JID_1] shouldBe endpoint1CombinedSourceSet
unmodifiable[JID_1] shouldBe endpoint1CombinedSourceSet
shouldThrow<UnsupportedOperationException> {
unmodifiable.add(ConferenceSourceMap())
}
shouldThrow<UnsupportedOperationException> {
unmodifiable.remove(ConferenceSourceMap())
}
}
context("Strip") {
val s7 = Source(7, MediaType.AUDIO)
val sourceSet = EndpointSourceSet(
setOf(s1, s2, s3, s4, s5, s6, s7),
setOf(sim, fid1, fid2, fid3)
)
val conferenceSourceMap = ConferenceSourceMap(JID_1 to sourceSet, JID_2 to e2sourceSet)
// Assume EndpointSourceSet.stripSimulcast works correctly, tested above.
context("Simulcast") {
conferenceSourceMap.stripSimulcast()
conferenceSourceMap[JID_1] shouldBe sourceSet.stripSimulcast
conferenceSourceMap[JID_2] shouldBe e2sourceSet.stripSimulcast
}
}
context("Compact JSON") {
val endpointId1 = "bebebebe"
val endpointId2 = "dadadada"
val jvb = "jvb"
val jvbEndpointSourceSet = EndpointSourceSet(Source(12345, MediaType.VIDEO))
val conferenceSourceMap = ConferenceSourceMap(
endpointId1 to sourceSet,
endpointId2 to e2sourceSet,
jvb to jvbEndpointSourceSet
)
val jsonString = conferenceSourceMap.compactJson()
val json = jacksonObjectMapper().readTree(jsonString)
json.shouldBeInstanceOf<ObjectNode>()
json.size() shouldBe 3
json.fieldNames().asSequence().toSet() shouldBe setOf(endpointId1, endpointId2, "jvb")
}
context("Debug json") {
val conferenceSourceMap = ConferenceSourceMap(
"jid1" to endpoint1CombinedSourceSet,
"jid2" to endpoint2SourceSet
)
conferenceSourceMap.toJson().shouldBeValidJson()
}
}
}

View File

@@ -0,0 +1,164 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import org.jitsi.utils.MediaType.AUDIO
import org.jitsi.utils.MediaType.VIDEO
import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension
import org.jitsi.xmpp.extensions.jingle.RtpDescriptionPacketExtension
import org.jitsi.xmpp.extensions.jingle.SourceGroupPacketExtension
@Suppress("NAME_SHADOWING")
class EndpointSourceSetTest : ShouldSpec() {
override fun isolationMode() = IsolationMode.InstancePerLeaf
init {
context("From XML") {
// Assume serializing works correctly -- it's tested below.
val contents = sourceSet.toJingle(JID_1)
val parsed = EndpointSourceSet.fromJingle(contents)
// Use the provided ownerJid, not the one encoded in XML.
parsed shouldBe sourceSet
}
context("To XML") {
val contents = sourceSet.toJingle()
contents.size shouldBe 2
val videoContent = contents.find { it.name == "video" }!!
val videoRtpDesc = videoContent.getFirstChildOfType(RtpDescriptionPacketExtension::class.java)!!
val videoSources = videoRtpDesc.getChildExtensionsOfType(SourcePacketExtension::class.java)
.map { Source(VIDEO, it) }.toSet()
videoSources shouldBe sourceSet.sources.filter { it.mediaType == VIDEO }.toSet()
val sourceGroupPacketExtensions =
videoRtpDesc.getChildExtensionsOfType(SourceGroupPacketExtension::class.java)
sourceGroupPacketExtensions.map { SsrcGroup.fromPacketExtension(it) }.toSet() shouldBe
sourceSet.ssrcGroups
val audioContent = contents.find { it.name == "audio" }!!
val audioRtpDesc = audioContent.getFirstChildOfType(RtpDescriptionPacketExtension::class.java)!!
val audioSources = audioRtpDesc.getChildExtensionsOfType(SourcePacketExtension::class.java)
.map { Source(AUDIO, it) }.toSet()
audioSources shouldBe sourceSet.sources.filter { it.mediaType == AUDIO }.toSet()
}
context("Strip simulcast") {
val s8 = Source(8, VIDEO)
context("Without RTX") {
EndpointSourceSet(
setOf(s1, s2, s3, s7, s8),
setOf(sim)
).stripSimulcast shouldBe EndpointSourceSet(setOf(s1, s7, s8))
}
context("With multiple SIM groups") {
EndpointSourceSet(
sourceSet.sources + s8,
setOf(
sim,
SsrcGroup(SsrcGroupSemantics.Sim, listOf(4, 5, 6))
)
).stripSimulcast shouldBe EndpointSourceSet(setOf(s1, s4, s7, s8))
}
context("With RTX") {
EndpointSourceSet(
sourceSet.sources + s8,
sourceSet.ssrcGroups
).stripSimulcast shouldBe EndpointSourceSet(
setOf(s1, s4, s7, s8),
setOf(fid1)
)
}
context("Compact JSON") {
// See the documentation of [EndpointSourceSet.compactJson] for the expected JSON format.
val json = jacksonObjectMapper().readTree(sourceSet.compactJson)
json.shouldBeInstanceOf<ArrayNode>()
val videoSourceList = json[0]
videoSourceList.shouldBeInstanceOf<ArrayNode>()
videoSourceList.map { node -> mapOf("s" to node["s"].asLong()) }.toSet() shouldBe setOf(
mapOf("s" to 1L),
mapOf("s" to 2L),
mapOf("s" to 3L),
mapOf("s" to 4L),
mapOf("s" to 5L),
mapOf("s" to 6L)
)
val videoSsrcGroups = json[1]
videoSsrcGroups.shouldBeInstanceOf<ArrayNode>()
videoSsrcGroups.map { groupNode ->
groupNode.map { if (it.isTextual) it.asText() else it.asInt() }
}.toSet() shouldBe setOf(
listOf("s", 1, 2, 3),
listOf("f", 1, 4),
listOf("f", 2, 5),
listOf("f", 3, 6)
)
val audioSourceList = json[2]
audioSourceList.shouldBeInstanceOf<ArrayNode>()
audioSourceList.map { node -> mapOf("s" to node["s"].asLong()) }.toSet() shouldBe setOf(
mapOf("s" to 7L)
)
// No audio source groups encoded.
json.size() shouldBe 3
}
}
}
}
const val JID_1 = "jid1"
const val JID_2 = "jid2"
val s1 = Source(1, VIDEO)
val s2 = Source(2, VIDEO)
val s3 = Source(3, VIDEO)
val s4 = Source(4, VIDEO)
val s5 = Source(5, VIDEO)
val s6 = Source(6, VIDEO)
val s7 = Source(7, AUDIO)
val sim = SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2, 3))
val fid1 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(1, 4))
val fid2 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(2, 5))
val fid3 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(3, 6))
val sourceSet = EndpointSourceSet(
setOf(s1, s2, s3, s4, s5, s6, s7),
setOf(sim, fid1, fid2, fid3)
)
val e2s1 = Source(11, VIDEO)
val e2s2 = Source(12, VIDEO)
val e2s3 = Source(13, VIDEO)
val e2s4 = Source(14, VIDEO)
val e2s5 = Source(15, VIDEO)
val e2s6 = Source(16, VIDEO)
val e2s7 = Source(17, AUDIO)
val e2sim = SsrcGroup(SsrcGroupSemantics.Sim, listOf(11, 12, 13))
val e2fid1 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(11, 14))
val e2fid2 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(12, 15))
val e2fid3 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(13, 16))
val e2sourceSet = EndpointSourceSet(
setOf(e2s1, e2s2, e2s3, e2s4, e2s5, e2s6, e2s7),
setOf(e2sim, e2fid1, e2fid2, e2fid3)
)

View File

@@ -0,0 +1,79 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import org.jitsi.utils.MediaType
import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension
import org.jitsi.xmpp.extensions.jingle.ParameterPacketExtension
import org.jitsi.xmpp.extensions.jitsimeet.SSRCInfoPacketExtension
class SourceTest : ShouldSpec() {
init {
context("From XML") {
val packetExtension = SourcePacketExtension().apply {
ssrc = 1
name = "name-1"
videoType = "camera"
addChildExtension(ParameterPacketExtension("msid", "msid"))
}
Source(MediaType.VIDEO, packetExtension) shouldBe
Source(1, MediaType.VIDEO, name = "name-1", msid = "msid", videoType = VideoType.Camera)
}
context("To XML") {
val msidValue = "msid-value"
val nameValue = "source-name-value"
val videoType = VideoType.Desktop
val source = Source(1, MediaType.VIDEO, name = nameValue, msid = msidValue, videoType = videoType)
val owner = "abcdabcd"
val extension = source.toPacketExtension(owner = owner)
extension.ssrc shouldBe 1
extension.name shouldBe nameValue
extension.videoType shouldBe videoType.toString()
val parameters = extension.getChildExtensionsOfType(ParameterPacketExtension::class.java)
parameters.filter { it.name == "msid" && it.value == msidValue }.size shouldBe 1
val ssrcInfo = extension.getFirstChildOfType(SSRCInfoPacketExtension::class.java)
ssrcInfo shouldNotBe null
ssrcInfo.owner.toString() shouldBe owner
}
context("Compact JSON") {
Source(1, MediaType.VIDEO, name = "test-name", msid = "msid").compactJson shouldBe
"""
{"s":1,"n":"test-name","m":"msid"}
""".trimIndent()
Source(
1,
MediaType.VIDEO,
videoType = VideoType.Desktop,
name = "test-name",
msid = "msid"
).compactJson shouldBe
"""
{"s":1,"n":"test-name","m":"msid","v":"d"}
""".trimIndent()
Source(1, MediaType.AUDIO).compactJson shouldBe """
{"s":1}
""".trimIndent()
}
}
}

View File

@@ -0,0 +1,77 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import org.jitsi.utils.MediaType
import org.jitsi.xmpp.extensions.colibri.SourcePacketExtension
import org.jitsi.xmpp.extensions.jingle.SourceGroupPacketExtension
class SsrcGroupTest : ShouldSpec() {
init {
context("SsrcGroupSemantics") {
context("Parsing") {
SsrcGroupSemantics.fromString("sim") shouldBe SsrcGroupSemantics.Sim
SsrcGroupSemantics.fromString("SIM") shouldBe SsrcGroupSemantics.Sim
SsrcGroupSemantics.fromString("sIM") shouldBe SsrcGroupSemantics.Sim
SsrcGroupSemantics.fromString("fiD") shouldBe SsrcGroupSemantics.Fid
shouldThrow<NoSuchElementException> {
SsrcGroupSemantics.fromString("invalid")
}
}
context("To string") {
SsrcGroupSemantics.Sim.toString() shouldBe "SIM"
SsrcGroupSemantics.Fid.toString() shouldBe "FID"
}
}
context("From XML") {
val packetExtension = SourceGroupPacketExtension().apply {
semantics = "sim"
addSources(
listOf(
SourcePacketExtension().apply { ssrc = 1 },
SourcePacketExtension().apply { ssrc = 2 },
SourcePacketExtension().apply { ssrc = 3 }
)
)
}
val ssrcGroup = SsrcGroup.fromPacketExtension(packetExtension)
ssrcGroup shouldBe SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2, 3))
}
context("To XML") {
val ssrcGroup = SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2, 3))
val packetExtension = ssrcGroup.toPacketExtension()
packetExtension.semantics shouldBe "SIM"
packetExtension.sources.size shouldBe 3
packetExtension.sources.map { Source(MediaType.VIDEO, it) }.toList() shouldBe listOf(
Source(1, MediaType.VIDEO),
Source(2, MediaType.VIDEO),
Source(3, MediaType.VIDEO)
)
}
context("Compact JSON") {
SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2, 3)).compactJson shouldBe """
["s",1,2,3]
""".trimIndent()
}
}
}

View File

@@ -0,0 +1,129 @@
/*
* Jicofo, the Jitsi Conference Focus.
*
* Copyright @ 2021-Present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import org.jitsi.utils.MediaType
import org.jitsi.utils.logging2.createLogger
import java.time.Clock
import java.time.Duration
class SsrcValidationPerfTest : ShouldSpec() {
init {
val numEndpoints = 500
xcontext("ValidatingConferenceSourceMap") {
context("Add/Remove a source to a large conference") {
val conferenceSources = createConferenceSourceMap(numEndpoints)
conferenceSources.size shouldBe numEndpoints
val newEndpointSourceSet = createEndpointSourceSet("new-endpoint", ssrcCount)
val newEndpointId = "new-endpoint"
ssrcCount += newEndpointSourceSet.sources.size
var added: EndpointSourceSet? = null
measureAndLog("Single add") {
added = conferenceSources.tryToAdd(newEndpointId, newEndpointSourceSet)
}
added shouldBe newEndpointSourceSet
conferenceSources.size shouldBe numEndpoints + 1
var removed: EndpointSourceSet? = null
measureAndLog("Single remove") {
removed = conferenceSources.tryToRemove(newEndpointId, newEndpointSourceSet)
}
conferenceSources.size shouldBe numEndpoints
removed shouldBe newEndpointSourceSet
}
context("Sequential add/remove") {
val conferenceSources = ValidatingConferenceSourceMap(
MAX_SSRCS_PER_USER,
MAX_SSRC_GROUPS_PER_USER
)
measureAndLog("Adding all endpoints") {
for (i in 0 until numEndpoints) {
val endpointId = "endpoint-$i"
val sourceSet = createEndpointSourceSet(endpointId, ssrcCount)
ssrcCount += sourceSet.sources.size
measureAndLog("Adding endpoint $i") {
conferenceSources.tryToAdd(endpointId, sourceSet)
}
}
}
conferenceSources.size shouldBe numEndpoints
measureAndLog("Removing all endpoints") {
var i = 0
while (conferenceSources.isNotEmpty()) {
val sourceToRemove = conferenceSources.entries.first()
measureAndLog("Removing endpoint $i") {
conferenceSources.tryToRemove(sourceToRemove.key, sourceToRemove.value)
}
i++
}
}
conferenceSources.size shouldBe 0
}
}
}
private var ssrcCount = 1L
private fun createEndpointSourceSet(endpointId: String, ssrcBase: Long) = EndpointSourceSet(
setOf(
Source(ssrcBase, MediaType.VIDEO, msid = "msid-$endpointId"),
Source(ssrcBase + 1, MediaType.VIDEO, msid = "msid-$endpointId"),
Source(ssrcBase + 2, MediaType.VIDEO, msid = "msid-$endpointId"),
Source(ssrcBase + 3, MediaType.VIDEO, msid = "msid-$endpointId"),
Source(ssrcBase + 4, MediaType.VIDEO, msid = "msid-$endpointId"),
Source(ssrcBase + 5, MediaType.VIDEO, msid = "msid-$endpointId"),
Source(ssrcBase + 6, MediaType.AUDIO, msid = "msid-$endpointId")
),
setOf(
SsrcGroup(SsrcGroupSemantics.Sim, listOf(ssrcBase, ssrcBase + 1, ssrcBase + 2)),
SsrcGroup(SsrcGroupSemantics.Fid, listOf(ssrcBase, ssrcBase + 3)),
SsrcGroup(SsrcGroupSemantics.Fid, listOf(ssrcBase + 1, ssrcBase + 4)),
SsrcGroup(SsrcGroupSemantics.Fid, listOf(ssrcBase + 2, ssrcBase + 5)),
)
)
private fun createConferenceSourceMap(numEndpoints: Int) = ValidatingConferenceSourceMap(
MAX_SSRCS_PER_USER,
MAX_SSRC_GROUPS_PER_USER
).apply {
for (i in 0 until numEndpoints) {
val endpointSourceSet = createEndpointSourceSet(i.toString(), ssrcCount)
ssrcCount += endpointSourceSet.sources.size
add("endpoint$i", endpointSourceSet)
}
}
private val logger = createLogger()
private fun measureAndLog(name: String, clock: Clock = Clock.systemUTC(), block: () -> Unit) {
logger.info("$name took ${measure(clock, block).toMillis()}")
}
}
fun measure(clock: Clock = Clock.systemUTC(), block: () -> Unit): Duration {
val start = clock.instant()
block()
return Duration.between(start, clock.instant())
}
// Default values from jicofo.conference.max-ssrcs-per-user / max-ssrc-groups-per-user.
private const val MAX_SSRCS_PER_USER = 20
private const val MAX_SSRC_GROUPS_PER_USER = 20

View File

@@ -0,0 +1,417 @@
/*
* Copyright @ 2018 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.conference.source
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.ShouldSpec
import io.kotest.matchers.shouldBe
import org.jitsi.utils.MediaType.AUDIO
import org.jitsi.utils.MediaType.VIDEO
@Suppress("NAME_SHADOWING")
class ValidatingConferenceSourceMapTest : ShouldSpec() {
override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf
init {
val jid1 = "jid1"
val jid2 = "jid2"
val msid = "msid"
val s1 = Source(1, VIDEO, msid = msid)
val s2 = Source(2, VIDEO, msid = msid)
val s3 = Source(3, VIDEO, msid = msid)
val s4 = Source(4, VIDEO, msid = msid)
val s5 = Source(5, VIDEO, msid = msid)
val s6 = Source(6, VIDEO, msid = msid)
val s7 = Source(7, AUDIO)
val videoSources = setOf(s1, s2, s3, s4, s5, s6)
val sources = setOf(s1, s2, s3, s4, s5, s6, s7)
val sim = SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2, 3))
val fid1 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(1, 4))
val fid2 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(2, 5))
val fid3 = SsrcGroup(SsrcGroupSemantics.Fid, listOf(3, 6))
val groups = setOf(sim, fid1, fid2, fid3)
val sourceSet = EndpointSourceSet(sources, groups)
val conferenceSources = ValidatingConferenceSourceMap(
MAX_SSRCS_PER_USER,
MAX_SSRC_GROUPS_PER_USER
)
context("Adding sources.") {
context("Empty sources") {
conferenceSources.tryToAdd(jid1, EndpointSourceSet()) shouldBe EndpointSourceSet.EMPTY
}
context("Standard VP8 signaling with simulcast and RTX") {
context("Signaled all together") {
conferenceSources.tryToAdd(jid1, sourceSet) shouldBe sourceSet
}
context("Signaled with video first") {
conferenceSources.tryToAdd(jid1, EndpointSourceSet(videoSources, groups)) shouldBe
EndpointSourceSet(videoSources, groups)
conferenceSources.tryToAdd(jid1, EndpointSourceSet(s7)) shouldBe EndpointSourceSet(s7)
}
context("Signaled with audio first") {
conferenceSources.tryToAdd(jid1, EndpointSourceSet(s7)) shouldBe EndpointSourceSet(s7)
conferenceSources.tryToAdd(jid1, EndpointSourceSet(videoSources, groups)) shouldBe
EndpointSourceSet(videoSources, groups)
}
context("Adding a second endpoint") {
val sourceSet2 = EndpointSourceSet(
setOf(
Source(101, VIDEO, msid = "msid2"),
Source(102, VIDEO, msid = "msid2"),
Source(103, AUDIO)
),
setOf(SsrcGroup(SsrcGroupSemantics.Fid, listOf(101, 102)))
)
conferenceSources.tryToAdd(jid1, sourceSet) shouldBe sourceSet
conferenceSources.tryToAdd(jid2, sourceSet2) shouldBe sourceSet2
}
}
context("Invalid SSRCs") {
val invalidSsrcs = listOf(-1, 0, 0x1_0000_0000)
invalidSsrcs.forEach {
val sourcesWithInvalidSsrc = EndpointSourceSet(setOf(Source(it, AUDIO)))
shouldThrow<InvalidSsrcException> {
conferenceSources.tryToAdd(jid1, sourcesWithInvalidSsrc)
}
}
}
context("Max SSRC count limit") {
val conferenceSources = ValidatingConferenceSourceMap(
maxSsrcsPerUser = 4,
MAX_SSRC_GROUPS_PER_USER
)
context("At once") {
shouldThrow<SsrcLimitExceededException> {
conferenceSources.tryToAdd(jid1, sourceSet)
}
}
context("With multiple adds") {
val sourceSet1 = EndpointSourceSet(
setOf(s1, s2, s3),
setOf(sim)
)
val sourceSet2 = EndpointSourceSet(
setOf(s4, s5, s6),
setOf(fid1, fid2, fid3)
)
conferenceSources.tryToAdd(jid1, sourceSet1) shouldBe sourceSet1
shouldThrow<SsrcLimitExceededException> {
conferenceSources.tryToAdd(jid1, sourceSet2)
}
}
}
context("Max ssrc-group count limit") {
val conferenceSources = ValidatingConferenceSourceMap(
MAX_SSRCS_PER_USER,
maxSsrcGroupsPerUser = 2
)
context("At once") {
shouldThrow<SsrcGroupLimitExceededException> {
conferenceSources.tryToAdd(jid1, sourceSet)
}
}
context("With multiple adds") {
val sourceSet1 = EndpointSourceSet(
setOf(s1, s2, s3),
setOf(sim)
)
val sourceSet2 = EndpointSourceSet(
setOf(s4, s5, s6),
setOf(fid1, fid2, fid3)
)
conferenceSources.tryToAdd(jid1, sourceSet1) shouldBe sourceSet1
shouldThrow<SsrcGroupLimitExceededException> {
conferenceSources.tryToAdd(jid1, sourceSet2)
}
}
}
context("Duplicate SSRCs") {
val source = EndpointSourceSet(setOf(Source(1, AUDIO)))
val duplicateSourceAudio = EndpointSourceSet(
setOf(Source(1, AUDIO, msid = "differentMsid"))
)
val duplicateSourceVideo = EndpointSourceSet(
setOf(Source(1, VIDEO, msid = "differentMsid"))
)
conferenceSources.tryToAdd(jid1, source) shouldBe source
context("Advertised by the same endpoint.") {
shouldThrow<SsrcAlreadyUsedException> {
conferenceSources.tryToAdd(jid1, duplicateSourceAudio)
}
shouldThrow<SsrcAlreadyUsedException> {
conferenceSources.tryToAdd(jid1, duplicateSourceVideo)
}
}
context("Advertised by another endpoint.") {
shouldThrow<SsrcAlreadyUsedException> {
conferenceSources.tryToAdd(jid2, duplicateSourceAudio)
}
shouldThrow<SsrcAlreadyUsedException> {
conferenceSources.tryToAdd(jid2, duplicateSourceVideo)
}
}
}
context("SSRC group with missing MSID") {
val sources = setOf(
Source(1, VIDEO, msid = null),
Source(2, VIDEO, msid = null),
Source(3, VIDEO, msid = null)
)
val groups = setOf(SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2, 3)))
shouldThrow<RequiredParameterMissingException> {
conferenceSources.tryToAdd(jid1, EndpointSourceSet(sources, groups))
}
}
context("SSRC group with different MSID values") {
val sources = setOf(
Source(1, VIDEO, msid = msid),
Source(2, VIDEO, msid = msid),
Source(3, VIDEO, msid = "differentMsid")
)
val groups = setOf(SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2, 3)))
shouldThrow<GroupMsidMismatchException> {
conferenceSources.tryToAdd(jid1, EndpointSourceSet(sources, groups))
}
}
context("Group with an SSRC that has no source signaled.") {
shouldThrow<ValidationFailedException> {
conferenceSources.tryToAdd(
jid1,
EndpointSourceSet(
// No source for ssrc 2
setOf(Source(1, VIDEO, msid = "msid")),
setOf(SsrcGroup(SsrcGroupSemantics.Fid, listOf(1, 2)))
)
)
}
}
context("FID groups") {
// Fid groups must have exactly 2 ssrcs
shouldThrow<InvalidFidGroupException> {
conferenceSources.tryToAdd(
jid1,
EndpointSourceSet(
setOf(s1, s2, s3),
setOf(SsrcGroup(SsrcGroupSemantics.Fid, listOf(1, 2, 3)))
)
)
}
shouldThrow<InvalidFidGroupException> {
conferenceSources.tryToAdd(
jid1,
EndpointSourceSet(
setOf(s1),
setOf(SsrcGroup(SsrcGroupSemantics.Fid, listOf(1)))
)
)
}
}
context("MSID Conflicts") {
val sourceSet1 = EndpointSourceSet(setOf(s1))
val sourceSet2 = EndpointSourceSet(setOf(s2))
val combinedSourceSet = EndpointSourceSet(setOf(s1, s2))
context("With another endpoint") {
conferenceSources.tryToAdd(jid1, sourceSet1)
shouldThrow<MsidConflictException> {
// jid2 tries to use an MSID already used by jid1
conferenceSources.tryToAdd(jid2, sourceSet2)
}
}
context("Multiple non-grouped SSRCs can have MSID=null") {
// I don't know if this is actually desired and/or used by clients. It is existing behavior that
// AdvertiseSSRCsTest depends upon.
val sourceSet = EndpointSourceSet(
setOf(
Source(1, VIDEO),
Source(2, VIDEO),
Source(3, VIDEO),
Source(4, AUDIO),
Source(5, AUDIO)
)
)
conferenceSources.tryToAdd(jid1, sourceSet) shouldBe sourceSet
}
context("Within the sources of the same endpoint.") {
context("Ungrouped") {
context("Added separately") {
conferenceSources.tryToAdd(jid1, sourceSet1)
// s1 and s2 have the same MSID
shouldThrow<MsidConflictException> {
conferenceSources.tryToAdd(jid1, sourceSet2)
}
}
context("Added together") {
shouldThrow<MsidConflictException> {
conferenceSources.tryToAdd(jid1, combinedSourceSet)
}
}
}
context("In independent FID groups") {
val endpointSourceSet = EndpointSourceSet(
setOf(s1, s2, s4, s5),
setOf(fid1, fid2)
)
shouldThrow<MsidConflictException> {
conferenceSources.tryToAdd(jid1, endpointSourceSet)
}
}
context("In independent SIM groups") {
val endpointSourceSet = EndpointSourceSet(
setOf(s1, s2, s3, s4),
setOf(
SsrcGroup(SsrcGroupSemantics.Sim, listOf(1, 2)),
SsrcGroup(SsrcGroupSemantics.Sim, listOf(3, 4))
)
)
shouldThrow<MsidConflictException> {
conferenceSources.tryToAdd(jid1, endpointSourceSet)
}
}
}
context("Audio and video can have the same MSID") {
context("With no MSID") {
val sourceSet = EndpointSourceSet(
setOf(
Source(1, AUDIO),
Source(2, VIDEO)
)
)
conferenceSources.tryToAdd(jid1, sourceSet) shouldBe sourceSet
}
context("With MSID and no groups") {
val sourceSet = EndpointSourceSet(setOf(s1, Source(111, AUDIO, msid = msid)))
conferenceSources.tryToAdd(jid1, sourceSet) shouldBe sourceSet
}
context("With MSID and groups") {
val sourceSet = EndpointSourceSet(
sources + Source(111, AUDIO, msid = msid),
groups
)
conferenceSources.tryToAdd(jid1, sourceSet) shouldBe sourceSet
}
}
}
context("Adding an empty group") {
val accepted = conferenceSources.tryToAdd(
jid1,
EndpointSourceSet(
setOf(s1, s2, s3),
setOf(
sim,
SsrcGroup(SsrcGroupSemantics.Sim, emptyList()),
SsrcGroup(SsrcGroupSemantics.Fid, emptyList()),
)
)
)
// The empty groups should be silently ignored.
accepted shouldBe EndpointSourceSet(setOf(s1, s2, s3), setOf(sim))
}
}
context("Removing sources") {
conferenceSources.tryToAdd(jid1, sourceSet)
context("Empty sources") {
conferenceSources.tryToRemove(jid1, EndpointSourceSet()) shouldBe EndpointSourceSet.EMPTY
}
context("Successful removal") {
context("Of all sources and groups") {
conferenceSources.tryToRemove(jid1, sourceSet) shouldBe sourceSet
}
context("Of all sources, groups assumed") {
conferenceSources.tryToRemove(jid1, EndpointSourceSet(sources)) shouldBe sourceSet
}
context("Of a subset of sources") {
// s1 remains, with no associated groups.
val toRemove = EndpointSourceSet(sources - s1, groups)
conferenceSources.tryToRemove(jid1, toRemove) shouldBe toRemove
}
}
context("Removing non-signaled sources") {
context("Another endpoint's sources") {
shouldThrow<SourceDoesNotExistException> {
conferenceSources.tryToRemove(jid2, sourceSet)
}
}
context("A non-signaled source") {
shouldThrow<SourceDoesNotExistException> {
conferenceSources.tryToRemove(jid1, EndpointSourceSet(Source(1111, AUDIO)))
}
}
context("A non-signaled ssrc-group") {
shouldThrow<SourceGroupDoesNotExistException> {
conferenceSources.tryToRemove(
jid1,
EndpointSourceSet(
sources,
groups + SsrcGroup(SsrcGroupSemantics.Fid, listOf(1, 2))
)
)
}
}
}
context("Removing a subset of sources") {
context("Leaving two FID groups") {
shouldThrow<MsidConflictException> {
conferenceSources.tryToRemove(
jid1,
EndpointSourceSet(
setOf(s1, s4),
setOf(sim, fid1)
)
)
}
}
context("Removing only part of a simulcast group's sources") {
// This matches multiple failure conditions (MSIF conflict, missing source from a group)
shouldThrow<ValidationFailedException> {
conferenceSources.tryToRemove(jid1, EndpointSourceSet(s1))
}
}
context("Removing a group, but not its sources") {
shouldThrow<MsidConflictException> {
conferenceSources.tryToRemove(jid1, EndpointSourceSet(sim))
}
shouldThrow<MsidConflictException> {
conferenceSources.tryToRemove(jid1, EndpointSourceSet(fid1))
}
shouldThrow<MsidConflictException> {
conferenceSources.tryToRemove(jid1, EndpointSourceSet(ssrcGroups = groups))
}
}
}
}
}
}
// Default values from jicofo.conference.max-ssrcs-per-user / max-ssrc-groups-per-user.
private const val MAX_SSRCS_PER_USER = 20
private const val MAX_SSRC_GROUPS_PER_USER = 20

View File

@@ -0,0 +1,44 @@
/*
* Copyright @ 2026 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.mock
import io.mockk.every
import io.mockk.mockk
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ExecutorService
import java.util.concurrent.ScheduledExecutorService
// Execute tasks in place (in the current thread, blocking)
val inPlaceExecutor: ExecutorService = mockk {
every { submit(any<Runnable>()) } answers {
firstArg<Runnable>().run()
CompletableFuture<Unit>().apply {
complete(Unit)
}
}
every { execute(any()) } answers {
firstArg<Runnable>().run()
}
}
val inPlaceScheduledExecutor: ScheduledExecutorService = mockk {
every { schedule(any(), any(), any()) } answers {
firstArg<Runnable>().run()
mockk(relaxed = true) {
every { isDone } returns true
}
}
}

View File

@@ -0,0 +1,103 @@
/*
* Copyright @ 2022 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.mock
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import io.mockk.every
import io.mockk.mockk
import org.jitsi.jicofo.xmpp.Features
import org.jitsi.jicofo.xmpp.XmppProvider
import org.jitsi.jicofo.xmpp.muc.ChatRoom
import org.jitsi.jicofo.xmpp.muc.ChatRoomInfo
import org.jitsi.jicofo.xmpp.muc.ChatRoomListener
import org.jitsi.jicofo.xmpp.muc.ChatRoomMember
import org.jitsi.jicofo.xmpp.muc.MemberRole
import org.jivesoftware.smack.packet.ExtensionElement
import org.jxmpp.jid.EntityBareJid
import org.jxmpp.jid.impl.JidCreate
import java.lang.IllegalArgumentException
import javax.xml.namespace.QName
class MockChatRoom(
val xmppProvider: XmppProvider,
val roomJid: EntityBareJid = JidCreate.entityBareFrom("room@conference.example.com")
) {
val chatRoomListeners = mutableListOf<ChatRoomListener>()
val memberList = mutableListOf<ChatRoomMember>()
/** Settable audio/video sender counts (the real ChatRoom derives them from member presence). */
var audioSenders = 0
var videoSenders = 0
/** Settable visitor count (the real ChatRoom derives it from member presence). */
var visitors = 0
val chatRoom = mockk<ChatRoom>(relaxed = true) {
every { addListener(capture(chatRoomListeners)) } returns Unit
every { roomJid } returns this@MockChatRoom.roomJid
every { members } returns memberList
every { memberCount } answers { memberList.size }
every { audioSendersCount } answers { audioSenders }
every { videoSendersCount } answers { videoSenders }
every { visitorCount } answers { visitors }
// Without this a relaxed mock returns a ChatRoomInfo with a non-null mainRoomJid, i.e. the room looks like a
// breakout room. Let jicofo generate the meeting ID, as it does when the MUC does not advertise one.
every { join() } returns ChatRoomInfo(meetingId = null, mainRoomJid = null)
every { xmppProvider } returns this@MockChatRoom.xmppProvider
every { debugState } returns JsonNodeFactory.instance.objectNode()
every { getChatMember(any()) } answers { memberList.find { it.occupantJid == arg(0) } }
}
fun addMember(
id: String,
role: MemberRole = MemberRole.PARTICIPANT,
jibri: Boolean = false,
jigasi: Boolean = false
): ChatRoomMember {
val occupant = JidCreate.entityFullFrom("$roomJid/$id")
val member = mockk<ChatRoomMember>(relaxed = true) {
every { name } returns id
every { occupantJid } returns occupant
every { chatRoom } returns this@MockChatRoom.chatRoom
every { features } returns Features.defaultFeatures
every { isJibri } returns jibri
every { isJigasi } returns jigasi
every { debugState } returns JsonNodeFactory.instance.objectNode()
every { presence } returns mockk {
every { status } returns null
every { getExtension(any<String>()) } returns null
every { getExtension(any<QName>()) } returns null
every { getExtension(any<Class<out ExtensionElement>>()) } returns null
}
}
every { member.role } returns role
memberList.add(member)
chatRoomListeners.forEach { it.memberJoined(member) }
return member
}
/** Change the role of [member] and notify listeners, as if a presence update was received. */
fun setRole(member: ChatRoomMember, role: MemberRole) {
every { member.role } returns role
chatRoomListeners.forEach { it.memberPresenceChanged(member) }
}
fun removeMember(member: ChatRoomMember) {
if (!memberList.contains(member)) throw IllegalArgumentException("not a member")
memberList.remove(member)
chatRoomListeners.forEach { it.memberLeft(member) }
}
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright @ 2022 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.mock
import io.mockk.every
import io.mockk.mockk
import org.jivesoftware.smack.AbstractXMPPConnection
import org.jivesoftware.smack.packet.IQ
import org.jivesoftware.smack.packet.Stanza
import org.jivesoftware.smack.packet.StanzaFactory
import org.jivesoftware.smack.packet.id.StanzaIdSource
open class MockXmppConnection {
val xmppConnection: AbstractXMPPConnection = mockk(relaxed = true) {
every { createStanzaCollectorAndSend(any()) } answers {
val request = arg<IQ>(0)
mockk(relaxed = true) {
every { nextResult<IQ>() } answers { handleIq(request) }
}
}
every { trySendStanza(any()) } answers {
val request = arg<Stanza>(0)
if (request is IQ) handleIq(request)
true
}
every { sendStanza(any()) } answers {
val request = arg<Stanza>(0)
if (request is IQ) handleIq(request)
}
val stanzaIdSource = object : StanzaIdSource {
private var stanzaId = 0
override fun getNewStanzaId() = "${stanzaId++}"
}
every { stanzaFactory } returns StanzaFactory(stanzaIdSource)
}
open fun handleIq(iq: IQ): IQ? = null
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright @ 2022 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.mock
import io.mockk.every
import io.mockk.mockk
import org.jitsi.jicofo.xmpp.XmppProvider
import org.jivesoftware.smack.AbstractXMPPConnection
import org.jivesoftware.smack.ConnectionListener
import org.jxmpp.jid.EntityBareJid
import org.jxmpp.jid.impl.JidCreate
class MockXmppProvider(
val xmppConnection: AbstractXMPPConnection = MockXmppConnection().xmppConnection,
/** The name of the connection, as it appears in [XmppProvider.getConfig]. */
val name: String = "mock",
/** The XMPP domain of the connection, needed to map a main room JID to a visitor room JID. */
xmppDomain: String? = null
) {
val chatRooms = mutableMapOf<EntityBareJid, MockChatRoom>()
/** Settable registration state, to simulate the XMPP connection going down and coming back up. */
var registered = true
val xmppProvider = mockk<XmppProvider>(relaxed = true) {
every { registered } answers { this@MockXmppProvider.registered }
every { findOrCreateRoom(any(), any()) } answers { getRoom(arg(0)).chatRoom }
every { xmppConnection } returns this@MockXmppProvider.xmppConnection
every { config } returns mockk(relaxed = true) {
every { this@mockk.name } returns this@MockXmppProvider.name
every { this@mockk.xmppDomain } returns xmppDomain?.let { JidCreate.domainBareFrom(it) }
}
}
/**
* The Smack connection listeners that were registered on [xmppConnection]. Note that when instances share an
* [xmppConnection] only the instance that was created last captures the listeners.
*/
val connectionListeners = mutableListOf<ConnectionListener>()
init {
every { xmppConnection.addConnectionListener(capture(connectionListeners)) } returns Unit
every { xmppConnection.removeConnectionListener(any()) } answers {
connectionListeners.remove(arg(0))
Unit
}
}
/** Simulate the connection authenticating, i.e. coming up or coming back up after a disconnect. */
fun authenticated(resumed: Boolean) {
registered = true
connectionListeners.toList().forEach { it.authenticated(xmppConnection, resumed) }
}
fun getRoom(jid: EntityBareJid): MockChatRoom =
chatRooms.computeIfAbsent(jid) { MockChatRoom(this.xmppProvider, jid) }
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright @ 2026 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.mock
import io.mockk.every
import io.mockk.mockk
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ExecutorService
/**
* An executor that queues tasks until [runAll] is called, so tests can control when "async" work executes. Unlike
* [inPlaceExecutor] it does not run tasks re-entrantly, which matters for code that assumes responses are processed
* on a separate thread (e.g. after a lock has been released).
*/
class PendingExecutor {
private val pending = ArrayDeque<Runnable>()
val executor: ExecutorService = mockk {
every { submit(any<Runnable>()) } answers {
pending.add(firstArg<Runnable>())
CompletableFuture<Unit>().apply { complete(Unit) }
}
every { execute(any()) } answers {
pending.add(firstArg<Runnable>())
}
}
/** Run all queued tasks, including any that get queued while draining. */
fun runAll() {
while (pending.isNotEmpty()) {
pending.removeFirst().run()
}
}
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright @ 2020 - present 8x8, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.jicofo.util
import io.kotest.core.spec.style.ShouldSpec
import org.jitsi.config.withNewConfig
fun ShouldSpec.context(config: String, name: String, block: () -> Unit) = context(name) {
withNewConfig(config) {
block()
}
}

Some files were not shown because too many files have changed in this diff Show More