snapshot of https://github.com/jitsi/jicofo.git @ 9ab59aa3d348e282da89da2c9f11ecbecd3f7b79 (2026-09-15)
This commit is contained in:
9
.editorconfig
Normal file
9
.editorconfig
Normal 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
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.java text eol=lf diff=java
|
||||
27
.github/ISSUE_TEMPLATE/bug-report.md
vendored
Normal file
27
.github/ISSUE_TEMPLATE/bug-report.md
vendored
Normal 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
43
.github/workflows/maven.yml
vendored
Normal 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
15
.gitignore
vendored
Normal 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
159
CLAUDE.md
Normal 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
202
LICENSE
Normal 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
104
README.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
# 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
9
SECURITY.md
Normal 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
45
checkstyle.xml
Normal 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
5
debian/README.Debian
vendored
Normal 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
6
debian/changelog
vendored
Normal 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
1
debian/compat
vendored
Normal file
@@ -0,0 +1 @@
|
||||
12
|
||||
14
debian/control
vendored
Normal file
14
debian/control
vendored
Normal 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
13
debian/copyright
vendored
Normal 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
0
debian/docs
vendored
Normal file
101
debian/init.d
vendored
Normal file
101
debian/init.d
vendored
Normal 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
6
debian/install
vendored
Normal 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
38
debian/manpage.1
vendored
Normal 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
1
debian/po/POTFILES.in
vendored
Normal file
@@ -0,0 +1 @@
|
||||
[type: gettext/rfc822deb] templates
|
||||
54
debian/po/templates.pot
vendored
Normal file
54
debian/po/templates.pot
vendored
Normal 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
167
debian/postinst
vendored
Normal 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
57
debian/postrm
vendored
Normal 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
14
debian/preinst
vendored
Normal 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
30
debian/rules
vendored
Executable 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
2
debian/source/format
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
3.0 (quilt)
|
||||
|
||||
9
debian/templates
vendored
Normal file
9
debian/templates
vendored
Normal 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
61
doc/conference-request.md
Normal 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
BIN
doc/diagram.key
Executable file
Binary file not shown.
BIN
doc/diagram.png
Normal file
BIN
doc/diagram.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
29
doc/health-checks.md
Normal file
29
doc/health-checks.md
Normal 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
1
doc/reservation.md
Normal 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
5
doc/shibboleth.md
Normal 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
412
jicofo-common/pom.xml
Normal 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>
|
||||
69
jicofo-common/spotbugs-exclude.xml
Normal file
69
jicofo-common/spotbugs-exclude.xml
Normal 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>
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
24
jicofo-common/src/main/kotlin/org/jitsi/jicofo/MediaType.kt
Normal file
24
jicofo-common/src/main/kotlin/org/jitsi/jicofo/MediaType.kt
Normal 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
|
||||
}
|
||||
40
jicofo-common/src/main/kotlin/org/jitsi/jicofo/OctoConfig.kt
Normal file
40
jicofo-common/src/main/kotlin/org/jitsi/jicofo/OctoConfig.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
57
jicofo-common/src/main/kotlin/org/jitsi/jicofo/TaskPools.kt
Normal file
57
jicofo-common/src/main/kotlin/org/jitsi/jicofo/TaskPools.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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) } })
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
76
jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/Util.kt
Normal file
76
jicofo-common/src/main/kotlin/org/jitsi/jicofo/xmpp/Util.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
214
jicofo-common/src/main/resources/reference.conf
Normal file
214
jicofo-common/src/main/resources/reference.conf
Normal 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 = []
|
||||
}
|
||||
}
|
||||
@@ -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]\""
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user