[ADD] discuss: add sfu server support for calls

The SFU is a server used to relay streaming between the participants of
a call to provide better scalability and performance than the current
peer-to-peer connections.

The required fields have been added in https://github.com/odoo/odoo/pull/138581

* `/mail/static/lib/discuss_sfu` contains the client code that is used
by the odoo client to interact with the SFU.
From: https://github.com/odoo/sfu/releases/tag/v1.0.0
* The Json Web Token code has been extracted from `web_push` while
adding support for the HS256 algorithm, to be used as the authentication
method between the SFU and Odoo.
* The python code has been updated so that the odoo server requests a
channel from the SFU when going above a participant threshold, and swap
the participants to a SFU connection afterward.
* `rtc_service.js` has been updated to support both peer-to-peer and
SFU modes.
* The `call_context_menu` has been updated to display information
relative to the SFU connection when in SFU mode.

task-2765922

closes odoo/odoo#132153

Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
Thanh Dodeur
2023-12-08 17:19:10 +00:00
committed by TSO
parent a21243dc39
commit 5cdd63c457
21 changed files with 17619 additions and 381 deletions
+4
View File
@@ -132,6 +132,7 @@ For more specific needs, you may also assign custom-defined actions
'mail/static/src/scss/variables/derived_variables.scss',
'mail/static/src/scss/*.scss',
'mail/static/lib/**/*',
('remove', 'mail/static/lib/odoo_sfu/odoo_sfu.js'),
('remove', 'mail/static/lib/lame/lame.js'),
'mail/static/src/js/**/*',
'mail/static/src/core/common/**/*',
@@ -178,6 +179,9 @@ For more specific needs, you may also assign custom-defined actions
('remove', 'mail/static/tests/tours/**/*'),
('remove', 'mail/static/tests/helpers/**/*'),
],
'mail.assets_odoo_sfu': [
'mail/static/lib/odoo_sfu/odoo_sfu.js',
],
'mail.assets_lamejs': [
'mail/static/lib/lame/lame.js',
],
+149 -230
View File
@@ -74,7 +74,6 @@ msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.js:0
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.js:0
#, python-format
msgid "%(candidateType)s (%(protocol)s)"
msgstr ""
@@ -211,7 +210,7 @@ msgstr ""
#. odoo-javascript
#: code:addons/mail/static/src/discuss/call/common/rtc_service.js:0
#, python-format
msgid "%s raised a hand"
msgid "%s raised their hand"
msgstr ""
#. module: mail
@@ -881,53 +880,9 @@ msgid "Advanced Options"
msgstr ""
#. module: mail
#: model:ir.model.fields.selection,name:mail.selection__account_journal__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__account_move__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__account_payment__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__crm_lead__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__event_booth__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__event_event__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__event_registration__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__event_sponsor__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__event_track__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__fleet_vehicle__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__fleet_vehicle_log_contract__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__fleet_vehicle_log_services__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__hr_applicant__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__hr_contract__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__hr_employee__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__hr_expense__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__hr_expense_sheet__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__hr_leave__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__hr_leave_allocation__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__lunch_supplier__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__mail_activity_mixin__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__mail_activity_type__decoration_type__warning
#: model:ir.model.fields.selection,name:mail.selection__mailing_mailing__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__maintenance_equipment__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__maintenance_request__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__mrp_production__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__mrp_unbuild__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__pos_session__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__product_pricelist__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__product_product__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__product_template__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__project_project__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__project_task__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__project_update__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__purchase_order__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__purchase_requisition__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__repair_order__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__res_partner__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__res_partner_bank__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__sale_order__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__slide_channel__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__stock_landed_cost__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__stock_lot__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__stock_picking__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__stock_picking_batch__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__survey_survey__activity_exception_decoration__warning
#: model:ir.model.fields.selection,name:mail.selection__survey_user_input__activity_exception_decoration__warning
msgid "Alert"
msgstr ""
@@ -1818,7 +1773,6 @@ msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/core/web/messaging_menu.js:0
#: code:addons/mail/static/src/core/web/messaging_menu.js:0
#: model:ir.model.fields,field_description:mail.field_discuss_channel_member__channel_id
#: model:ir.model.fields,field_description:mail.field_discuss_channel_rtc_session__channel_id
#: model:ir.model.fields.selection,name:mail.selection__discuss_channel__channel_type__channel
@@ -1840,6 +1794,13 @@ msgstr ""
msgid "Channel Type"
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/call/common/rtc_service.js:0
#, python-format
msgid "Channel full"
msgstr ""
#. module: mail
#. odoo-python
#: code:addons/mail/models/discuss/discuss_channel_member.py:0
@@ -1876,7 +1837,6 @@ msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/core/web/messaging_menu.js:0
#: code:addons/mail/static/src/core/web/messaging_menu.js:0
#: model:ir.model.fields.selection,name:mail.selection__discuss_channel__channel_type__chat
#, python-format
msgid "Chat"
@@ -2105,8 +2065,8 @@ msgid "Confirm"
msgstr ""
#. module: mail
#. odoo-python
#. odoo-javascript
#. odoo-python
#: code:addons/mail/models/mail_template.py:0
#: code:addons/mail/static/src/core/common/message_confirm_dialog.js:0
#: model_terms:ir.ui.view,arch_db:mail.mail_template_view_form_confirm_delete
@@ -2153,6 +2113,21 @@ msgstr ""
msgid "Connection test failed: %s"
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/call/common/rtc_service.js:0
#, python-format
msgid "Connection to SFU server closed by the server"
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
#, python-format
msgid "Connection type:"
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
@@ -2952,6 +2927,13 @@ msgstr ""
msgid "Download logs"
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
#, python-format
msgid "Download:"
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/core/common/dropzone.xml:0
@@ -3327,53 +3309,9 @@ msgstr ""
#. odoo-javascript
#: code:addons/mail/static/src/core/common/notification_model.js:0
#: model:ir.model.fields,field_description:mail.field_mail_activity_schedule__error
#: model:ir.model.fields.selection,name:mail.selection__account_journal__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__account_move__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__account_payment__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__crm_lead__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__event_booth__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__event_event__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__event_registration__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__event_sponsor__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__event_track__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__fleet_vehicle__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__fleet_vehicle_log_contract__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__fleet_vehicle_log_services__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__hr_applicant__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__hr_contract__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__hr_employee__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__hr_expense__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__hr_expense_sheet__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__hr_leave__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__hr_leave_allocation__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__lunch_supplier__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__mail_activity_mixin__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__mail_activity_type__decoration_type__danger
#: model:ir.model.fields.selection,name:mail.selection__mailing_mailing__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__maintenance_equipment__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__maintenance_request__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__mrp_production__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__mrp_unbuild__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__pos_session__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__product_pricelist__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__product_product__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__product_template__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__project_project__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__project_task__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__project_update__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__purchase_order__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__purchase_requisition__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__repair_order__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__res_partner__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__res_partner_bank__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__sale_order__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__slide_channel__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__stock_landed_cost__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__stock_lot__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__stock_picking__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__stock_picking_batch__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__survey_survey__activity_exception_decoration__danger
#: model:ir.model.fields.selection,name:mail.selection__survey_user_input__activity_exception_decoration__danger
#, python-format
msgid "Error"
msgstr ""
@@ -3470,6 +3408,13 @@ msgstr ""
msgid "Failed to load gifs..."
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/call/common/rtc_service.js:0
#, python-format
msgid "Failed to load the SFU server, falling back to peer-to-peer"
msgstr ""
#. module: mail
#. odoo-python
#: code:addons/mail/models/mail_render_mixin.py:0
@@ -4893,6 +4838,11 @@ msgstr ""
msgid "Keep activities marked as done in the activity view"
msgstr ""
#. module: mail
#: model_terms:ir.ui.view,arch_db:mail.res_config_settings_view_form
msgid "Key"
msgstr ""
#. module: mail
#. odoo-python
#: code:addons/mail/models/mail_alias.py:0
@@ -6433,7 +6383,6 @@ msgstr ""
msgid "Old values"
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/core/common/im_status.xml:0
@@ -6602,6 +6551,14 @@ msgstr ""
msgid "Original message was deleted"
msgstr ""
#. module: mail
#. odoo-python
#: code:addons/mail/models/res_users.py:0
#: model:ir.actions.act_window,name:mail.mail_activity_without_access_action
#, python-format
msgid "Other activities"
msgstr ""
#. module: mail
#: model:ir.model.fields.selection,name:mail.selection__mail_mail__state__outgoing
#: model_terms:ir.ui.view,arch_db:mail.view_mail_search
@@ -6639,53 +6596,9 @@ msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/core/web/activity_list_popover.xml:0
#: model:ir.model.fields.selection,name:mail.selection__account_journal__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__account_move__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__account_payment__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__crm_lead__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__event_booth__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__event_event__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__event_registration__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__event_sponsor__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__event_track__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__fleet_vehicle__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__fleet_vehicle_log_contract__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__fleet_vehicle_log_services__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__hr_applicant__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__hr_contract__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__hr_employee__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__hr_expense__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__hr_expense_sheet__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__hr_leave__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__hr_leave_allocation__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__lunch_supplier__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__mail_activity__state__overdue
#: model:ir.model.fields.selection,name:mail.selection__mail_activity_mixin__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__mailing_mailing__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__maintenance_equipment__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__maintenance_request__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__mrp_production__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__mrp_unbuild__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__pos_session__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__product_pricelist__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__product_product__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__product_template__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__project_project__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__project_task__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__project_update__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__purchase_order__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__purchase_requisition__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__repair_order__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__res_partner__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__res_partner_bank__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__sale_order__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__slide_channel__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__stock_landed_cost__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__stock_lot__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__stock_picking__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__stock_picking_batch__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__survey_survey__activity_state__overdue
#: model:ir.model.fields.selection,name:mail.selection__survey_user_input__activity_state__overdue
#, python-format
msgid "Overdue"
msgstr ""
@@ -6713,6 +6626,7 @@ msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
#, python-format
msgid "Packets received:"
msgstr ""
@@ -6720,6 +6634,7 @@ msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
#, python-format
msgid "Packets sent:"
msgstr ""
@@ -6911,53 +6826,9 @@ msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/core/web/activity_list_popover.xml:0
#: model:ir.model.fields.selection,name:mail.selection__account_journal__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__account_move__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__account_payment__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__crm_lead__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__event_booth__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__event_event__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__event_registration__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__event_sponsor__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__event_track__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__fleet_vehicle__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__fleet_vehicle_log_contract__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__fleet_vehicle_log_services__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__hr_applicant__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__hr_contract__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__hr_employee__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__hr_expense__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__hr_expense_sheet__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__hr_leave__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__hr_leave_allocation__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__lunch_supplier__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__mail_activity__state__planned
#: model:ir.model.fields.selection,name:mail.selection__mail_activity_mixin__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__mailing_mailing__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__maintenance_equipment__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__maintenance_request__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__mrp_production__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__mrp_unbuild__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__pos_session__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__product_pricelist__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__product_product__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__product_template__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__project_project__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__project_task__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__project_update__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__purchase_order__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__purchase_requisition__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__repair_order__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__res_partner__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__res_partner_bank__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__sale_order__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__slide_channel__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__stock_landed_cost__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__stock_lot__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__stock_picking__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__stock_picking_batch__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__survey_survey__activity_state__planned
#: model:ir.model.fields.selection,name:mail.selection__survey_user_input__activity_state__planned
#, python-format
msgid "Planned"
msgstr ""
@@ -7211,6 +7082,13 @@ msgstr ""
msgid "RTC Session"
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
#, python-format
msgid "RTC Session ID:"
msgstr ""
#. module: mail
#: model:ir.model.fields,field_description:mail.field_discuss_channel_member__rtc_session_ids
msgid "RTC Sessions"
@@ -7699,6 +7577,11 @@ msgstr ""
msgid "SFU Server key"
msgstr ""
#. module: mail
#: model_terms:ir.ui.view,arch_db:mail.res_config_settings_view_form
msgid "SFU server"
msgstr ""
#. module: mail
#: model_terms:ir.ui.view,arch_db:mail.view_email_template_search
msgid "SMTP Server"
@@ -7754,8 +7637,8 @@ msgid "Schedule & Mark as Done"
msgstr ""
#. module: mail
#. odoo-python
#. odoo-javascript
#. odoo-python
#: code:addons/mail/static/src/core/web/activity_service.js:0
#: code:addons/mail/static/src/core/web/activity_service.js:0
#: code:addons/mail/wizard/mail_activity_schedule.py:0
@@ -7764,8 +7647,8 @@ msgid "Schedule Activity"
msgstr ""
#. module: mail
#. odoo-python
#. odoo-javascript
#. odoo-python
#: code:addons/mail/static/src/core/web/activity_service.js:0
#: code:addons/mail/wizard/mail_activity_schedule.py:0
#, python-format
@@ -8365,7 +8248,6 @@ msgstr ""
msgid "Specific User"
msgstr ""
#. module: mail
#: model:ir.model.fields,help:mail.field_mail_activity_plan__res_model
#: model:ir.model.fields,help:mail.field_mail_activity_plan_template__res_model
@@ -9188,53 +9070,9 @@ msgstr ""
#: code:addons/mail/static/src/core/web/activity_list_popover.xml:0
#: code:addons/mail/static/src/core/web/activity_list_popover_item.js:0
#: code:addons/mail/static/src/core/web/activity_menu.xml:0
#: model:ir.model.fields.selection,name:mail.selection__account_journal__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__account_move__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__account_payment__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__crm_lead__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__event_booth__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__event_event__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__event_registration__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__event_sponsor__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__event_track__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__fleet_vehicle__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__fleet_vehicle_log_contract__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__fleet_vehicle_log_services__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__hr_applicant__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__hr_contract__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__hr_employee__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__hr_expense__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__hr_expense_sheet__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__hr_leave__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__hr_leave_allocation__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__lunch_supplier__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__mail_activity__state__today
#: model:ir.model.fields.selection,name:mail.selection__mail_activity_mixin__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__mailing_mailing__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__maintenance_equipment__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__maintenance_request__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__mrp_production__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__mrp_unbuild__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__pos_session__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__product_pricelist__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__product_product__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__product_template__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__project_project__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__project_task__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__project_update__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__purchase_order__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__purchase_requisition__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__repair_order__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__res_partner__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__res_partner_bank__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__sale_order__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__slide_channel__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__stock_landed_cost__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__stock_lot__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__stock_picking__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__stock_picking_batch__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__survey_survey__activity_state__today
#: model:ir.model.fields.selection,name:mail.selection__survey_user_input__activity_state__today
#, python-format
msgid "Today"
msgstr ""
@@ -9411,6 +9249,7 @@ msgstr ""
#. module: mail
#: model:ir.model.fields,field_description:mail.field_mail_link_preview__source_url
#: model_terms:ir.ui.view,arch_db:mail.res_config_settings_view_form
msgid "URL"
msgstr ""
@@ -9609,6 +9448,13 @@ msgstr ""
msgid "Upload file"
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
#, python-format
msgid "Upload:"
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/core/common/attachment_list.xml:0
@@ -10410,6 +10256,13 @@ msgstr ""
msgid "attachment(s) of this email."
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
#, python-format
msgid "available bitrate:"
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/gif_picker/common/gif_picker.xml:0
@@ -10434,6 +10287,13 @@ msgstr ""
msgid "by"
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
#, python-format
msgid "camera"
msgstr ""
#. module: mail
#: model_terms:ir.ui.view,arch_db:mail.mail_bounce_catchall
msgid ""
@@ -10448,6 +10308,15 @@ msgstr ""
msgid "channels"
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
#, python-format
msgid "clock rate:"
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/core/common/search_messages_panel.xml:0
@@ -10455,6 +10324,15 @@ msgstr ""
msgid "close"
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
#, python-format
msgid "codec:"
msgstr ""
#. module: mail
#. odoo-python
#: code:addons/mail/models/discuss/discuss_channel.py:0
@@ -10509,6 +10387,20 @@ msgstr ""
msgid "done"
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
#, python-format
msgid "down DTLS:"
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
#, python-format
msgid "down ICE:"
msgstr ""
#. module: mail
#: model_terms:ir.ui.view,arch_db:mail.mail_activity_type_view_form
msgid "e.g. \"Discuss proposal\""
@@ -10690,6 +10582,13 @@ msgstr ""
msgid "media player Error"
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
#, python-format
msgid "microphone"
msgstr ""
#. module: mail
#. odoo-python
#: code:addons/mail/models/mail_thread.py:0
@@ -10720,7 +10619,6 @@ msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.js:0
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.js:0
#, python-format
msgid "no connection"
msgstr ""
@@ -10808,6 +10706,13 @@ msgstr ""
msgid "results out of"
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
#, python-format
msgid "screen"
msgstr ""
#. module: mail
#. odoo-python
#: code:addons/mail/models/mail_alias.py:0
@@ -10867,6 +10772,20 @@ msgstr ""
msgid "unknown target model %s"
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
#, python-format
msgid "up DTLS:"
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/call/common/call_context_menu.xml:0
#, python-format
msgid "up ICE:"
msgstr ""
#. module: mail
#. odoo-javascript
#: code:addons/mail/static/src/discuss/core/web/command_palette.js:0
@@ -1,8 +1,15 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import requests
from odoo import api, fields, models, _
from odoo.exceptions import AccessError, UserError, ValidationError
from odoo.osv import expression
from ...tools import jwt, discuss
_logger = logging.getLogger(__name__)
SFU_MODE_THRESHOLD = 3
class ChannelMember(models.Model):
@@ -212,13 +219,16 @@ class ChannelMember(models.Model):
self.rtc_session_ids.unlink()
rtc_session = self.env['discuss.channel.rtc.session'].create({'channel_member_id': self.id})
current_rtc_sessions, outdated_rtc_sessions = self._rtc_sync_sessions(check_rtc_session_ids=check_rtc_session_ids)
ice_servers = self.env["mail.ice.server"]._get_ice_servers()
self._join_sfu(ice_servers)
res = {
'iceServers': self.env['mail.ice.server']._get_ice_servers() or False,
'iceServers': ice_servers or False,
'rtcSessions': [
('ADD', [rtc_session_sudo._mail_rtc_session_format() for rtc_session_sudo in current_rtc_sessions]),
('DELETE', [{'id': missing_rtc_session_sudo.id} for missing_rtc_session_sudo in outdated_rtc_sessions]),
],
'sessionId': rtc_session.id,
'serverInfo': self._get_rtc_server_info(rtc_session, ice_servers),
}
if len(self.channel_id.rtc_session_ids) == 1 and self.channel_id.channel_type in {'chat', 'group'}:
self.channel_id.message_post(body=_("%s started a live conference", self.partner_id.name or self.guest_id.name), message_type='notification')
@@ -227,6 +237,62 @@ class ChannelMember(models.Model):
res['invitedMembers'] = [('ADD', list(invited_members._discuss_channel_member_format(fields={'id': True, 'channel': {}, 'persona': {'partner': {'id', 'name', 'im_status'}, 'guest': {'id', 'name', 'im_status'}}}).values()))]
return res
def _join_sfu(self, ice_servers=None):
if len(self.channel_id.rtc_session_ids) < SFU_MODE_THRESHOLD:
if self.channel_id.sfu_channel_uuid:
self.channel_id.sfu_channel_uuid = None
self.channel_id.sfu_server_url = None
return
elif self.channel_id.sfu_channel_uuid and self.channel_id.sfu_server_url:
return
sfu_server_url = discuss.get_sfu_url(self.env)
if not sfu_server_url:
return
sfu_server_key = discuss.get_sfu_key(self.env)
json_web_token = jwt.sign(
{"iss": f"{self.get_base_url()}:channel:{self.channel_id.id}"},
key=sfu_server_key,
ttl=30,
algorithm=jwt.Algorithm.HS256,
)
try:
response = requests.get(
sfu_server_url + "/v1/channel",
headers={"Authorization": "jwt " + json_web_token},
timeout=3,
)
response.raise_for_status()
except requests.exceptions.RequestException as error:
_logger.warning("Failed to obtain a channel from the SFU server, user will stay in p2p: %s", error)
return
response_dict = response.json()
self.channel_id.sfu_channel_uuid = response_dict["uuid"]
self.channel_id.sfu_server_url = response_dict["url"]
notifications = [
[
session.guest_id or session.partner_id,
"discuss.channel.rtc.session/sfu_hot_swap",
{"serverInfo": self._get_rtc_server_info(session, ice_servers, key=sfu_server_key)},
]
for session in self.channel_id.rtc_session_ids
]
self.env["bus.bus"]._sendmany(notifications)
def _get_rtc_server_info(self, rtc_session, ice_servers=None, key=None):
sfu_channel_uuid = self.channel_id.sfu_channel_uuid
sfu_server_url = self.channel_id.sfu_server_url
if not sfu_channel_uuid or not sfu_server_url:
return None
if not key:
key = discuss.get_sfu_key(self.env)
claims = {
"sfu_channel_uuid": sfu_channel_uuid,
"session_id": rtc_session.id,
"ice_servers": ice_servers,
}
json_web_token = jwt.sign(claims, key=key, ttl=60 * 60 * 8, algorithm=jwt.Algorithm.HS256) # 8 hours
return {"url": sfu_server_url, "jsonWebToken": json_web_token}
def _rtc_leave_call(self):
self.ensure_one()
if self.rtc_session_ids:
@@ -1,9 +1,15 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import requests
from collections import defaultdict
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models
from odoo.addons.mail.tools import discuss, jwt
_logger = logging.getLogger(__name__)
class MailRtcSession(models.Model):
@@ -45,6 +51,11 @@ class MailRtcSession(models.Model):
# Note: invitation depends on field `rtc_inviting_session_id` so the cancel must be
# done before the delete to be able to know who was invited.
channel._rtc_cancel_invitations()
# If there is no member left in the RTC call, we remove the SFU channel uuid as the SFU
# server will timeout the channel. It is better to obtain a new channel from the SFU server
# than to attempt recycling a possibly stale channel uuid.
channel.sfu_channel_uuid = False
channel.sfu_server_url = False
notifications = [(channel, 'discuss.channel/rtc_sessions_update', {
'id': channel.id,
'rtcSessions': [('DELETE', [{'id': session_data['id']} for session_data in sessions_data])],
@@ -77,6 +88,24 @@ class MailRtcSession(models.Model):
self.search(self._inactive_rtc_session_domain()).unlink()
def action_disconnect(self):
session_ids_by_channel_by_url = defaultdict(lambda: defaultdict(list))
for rtc_session in self:
sfu_channel_uuid = rtc_session.channel_id.sfu_channel_uuid
url = rtc_session.channel_id.sfu_server_url
if sfu_channel_uuid and url:
session_ids_by_channel_by_url[url][sfu_channel_uuid].append(rtc_session.id)
key = discuss.get_sfu_key(self.env)
if key:
with requests.Session() as requests_session:
for url, session_ids_by_channel in session_ids_by_channel_by_url.items():
try:
requests_session.post(
url + '/v1/disconnect',
data=jwt.sign({'sessionIdsByChannel': session_ids_by_channel}, key=key, ttl=20, algorithm=jwt.Algorithm.HS256),
timeout=3
).raise_for_status()
except requests.exceptions.RequestException as error:
_logger.warning("Could not disconnect sessions at sfu server %s: %s", url, error)
self.unlink()
def _delete_inactive_rtc_sessions(self):
+2 -2
View File
@@ -4,7 +4,7 @@ import json
import logging as logger
from odoo import api, fields, models
from ..web_push import generate_web_push_vapid_key
from ..tools.jwt import generate_vapid_keys
_logger = logger.getLogger(__name__)
@@ -37,7 +37,7 @@ class PartnerDevice(models.Model):
# Regenerate new Keys if public key not present
if not public_key_value:
self.sudo().search([]).unlink() # Reset all devices (ServiceWorker)
private_key_value, public_key_value = generate_web_push_vapid_key()
private_key_value, public_key_value = generate_vapid_keys()
ir_params_sudo.set_param('mail.web_push_vapid_private_key', private_key_value)
ir_params_sudo.set_param(public_key, public_key_value)
_logger.info("WebPush: missing public key, new VAPID keys generated")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,424 @@
Name: mediasoup-client
Version: 3.7.0
License: ISC
Private: false
Description: mediasoup client side JavaScript library
Repository: https://github.com/versatica/mediasoup-client.git
Homepage: https://mediasoup.org
Contributors:
Iñaki Baz Castillo <ibc@aliax.net> (https://inakibaz.me)
José Luis Millán <jmillan@aliax.net> (https://github.com/jmillan)
License Copyright:
===
ISC License
Copyright © 2015, Iñaki Baz Castillo <ibc@aliax.net>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
Name: debug
Version: 4.3.4
License: MIT
Private: false
Description: Lightweight debugging utility for Node.js and the browser
Repository: git://github.com/debug-js/debug.git
Author: Josh Junon <josh.junon@protonmail.com>
Contributors:
TJ Holowaychuk <tj@vision-media.ca>
Nathan Rajlich <nathan@tootallnate.net> (http://n8.io)
Andrew Rhyne <rhyneandrew@gmail.com>
License Copyright:
===
(The MIT License)
Copyright (c) 2014-2017 TJ Holowaychuk <tj@vision-media.ca>
Copyright (c) 2018-2021 Josh Junon
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
and associated documentation files (the 'Software'), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
---
Name: ms
Version: 2.1.2
License: MIT
Private: false
Description: Tiny millisecond conversion utility
Repository: undefined
License Copyright:
===
The MIT License (MIT)
Copyright (c) 2016 Zeit, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Name: ua-parser-js
Version: 1.0.37
License: MIT
Private: false
Description: Detect Browser, Engine, OS, CPU, and Device type/model from User-Agent data. Supports browser & node.js environment
Repository: https://github.com/faisalman/ua-parser-js.git
Homepage: https://github.com/faisalman/ua-parser-js
Author: Faisal Salman <f@faisalman.com> (http://faisalman.com)
Contributors:
Aamir Poonawalla <aamir@urx.com>
Admas <mollases@users.noreply.github.com>
algenon <m@antonz.ru>
Alvin Portillo <alvin@8thwall.com>
Amumu <yoyo837@hotmail.com>
Andrea Vaghi <andrea.vaghi@jobrapido.com>
Anton Zhiyanov <m@antonz.ru>
Arturo Mejia <amejia@blim.com>
Arun Rama Reddy <arunramareddy@gmail.com>
Austin Pray <austin@austinpray.com>
Bendeguz <symunona@gmail.com>
Benjamin Bertrand <bertrand.design@gmail.com>
Benjamin Urban <benjamin.urban@sueddeutsche.de>
boneyao <admin@boneyao.com>
Carl C Von Lewin <carlchristianlewin@gmail.com>
CESAR RAMOS <c@imagenproactiva.com>
Chad Killingsworth <ckillingsworth@jackhenry.com>
Christopher De Cairos <chris.decairos@gmail.com>
Cyrille David <cyrille@qonto.eu>
Dario Vladovic <d.vladimyr@gmail.com>
David Annez <david.annez@gmail.com>
Davit Barbakadze <jayarjo@gmail.com>
ddivernois <david-emmanuel.divernois@amadeus.com>
Deliaz <D3liaz@gmail.com>
Demis Palma <demis.palma@gmail.com>
dhoko <aurelien@protonmail.com>
dianhe <dianhe@webank.com>
dineshks1 <dineshks1@d.kumar44@outlook.com>
Dmitry Tyschenko <dtyschenko@gmail.com>
Douglas Li <doug@knotch.it>
Dumitru Uzun <contact@duzun.me>
Dustin <duburns@fanatics.com>
Elchin Valiyev <elchin.valiyev@autodesk.com>
Emil Hesslow <hesslow@gmail.com>
Eric Redon <eric.redon@coox.org>
Eric Schrenker <eric.schrenker@gmail.com>
Erik Hesselink <hesselink@gmail.com>
Fabian Becker <halfdan@xnorfz.de>
Faisal Salman <f@faisalman.com>
Frédéric Camblor <fcamblor@gmail.com>
Frederik Ring <frederik.ring@gmail.com>
Gerald Host <me@jacobford.co.uk>
Germán M. Bravo <german.mb@gmail.com>
Grigory Dmitrenko <grigory@snsk.ru>
gulpin <gulping.gulpin@gmail.com>
Hendrik Helwich <h.helwich@iplabs.de>
Hermann Ebert <ebbmo@HE.local>
hr6r <hedian@gmail.com>
Igor Topal <i.topal@smartweb.com.ua>
Ildar Kamalov <i.kamalov@adguard.com>
insanehong <insane.hong@navercorp.com>
jackpoll <jackpoll123456@gmail.com>
Jake Mc <startswithaj@users.noreply.github.com>
JBYoshi <12983479+JBYoshi@users.noreply.github.com>
Joey Parrish <joeyparrish@google.com>
John Tantalo <john.tantalo@gmail.com>
John Yanarella <jmy@codecatalyst.com>
Jon Buckley <jon@jbuckley.ca>
Josh Goldberg <joshuakgoldberg@outlook.com>
Junki-Ishida <junki_ishida@dwango.co.jp>
Kendall Buchanan <kendall@kendagriff.com>
Lee Treveil <leetreveil@gmail.com>
leonardo <leofiore@libero.it>
Levente Balogh <balogh.levente.hu@gmail.com>
Liam Quinn <lquinn@blackberry.com>
Lithin <lithin@webklipper.com>
ll-syber <670159357@qq.com>
Loris Guignard <loris.guignard@gmail.com>
Lukas Drgon <lukas.drgon@gmail.com>
Lukas Eipert <leipert@users.noreply.github.com>
Malash <i@malash.me>
Martynas <martynas@firmfirm.co>
Masahiko Sato <masasato@yahoo-corp.jp>
Matt Brophy <matt@brophy.org>
Matthew Origer <karatecowboy@gmail.com>
Maximilian Haupt <mail@maximilianhaupt.com>
Max Maurer <maxemanuel.maurer@gmail.com>
Max Nordlund <max.nordlund@gmail.com>
Michael Hess <mhess@connectify.me>
MimyyK <michele.marais@hakisa.com>
naoh <naoh.cs03g@nctu.edu.tw>
Nicholas Ionata <nionata@ufl.edu>
Nikhil Motiani <nikhil.motiani@outlook.com>
Nik Rolls <nik@rolls.cc>
nionata <n.ionata129@gmail.com>
niris <nirisix@gmail.com>
Nobuo Okada <nookada@yahoo-corp.jp>
o.drapeza <o.drapeza@tinkoff.ru>
otakuSiD <otakusid@gmail.com>
patrick-nurt <github@pereira.dk>
Pavel Studeny <studeny@avast.com>
Peter Dave Hello <PeterDaveHello@users.noreply.github.com>
philippsimon <github@philippsimon.de>
Pieter Hendrickx <pieter.hendrickx@belfius.be>
Piper Chester <piperchester@gmail.com>
Queen Vinyl Darkscratch <vinyldarkscratch@gmail.com>
Raine Makelainen <raine.makelainen@jolla.com>
Raman Savaryn <homeneartheocean@gmail.com>
Robert Tod <robert@qubit.com>
roman.savarin <roman.savarin@skywindgroup.com>
Ron Korland <ron@testim.io>
Ross Noble <rosshnoble@gmail.com>
ruicong <466403866@qq.com>
Sandro Sonntag <sandro.sonntag@adorsys.de>
sgautrea <shanegautreau@gmail.com>
Shane Gautreau <sgautrea@opentext.com>
Shane Thacker <shane@steadymade.com>
Shreedhar <shreedhar@uber.com>
Simon Eisenmann <simon@longsleep.org>
Simon Lang <me@simonlang.org>
Stiekel <histkc@gmail.com>
sUP <dani3l@gmail.com>
Sylvain Gizard <sylvain.gizard@gmail.com>
szchenghuang <szchenghuang@gmail.com>
Tanguy Krotoff <tkrotoff@gmail.com>
Tony Tomarchio <tony@tomarchio.cc>
Ulrich Schmidt <u.schmidt@velian.de>
Vadim Kurachevsky <vadim@hmvs.org>
Yılmaz <yilmazdemir36@gmail.com>
yuanyang <work_yuanyang@163.com>
Yun Young-jin <yupmin@yupmin-office-macmini.local>
Zach Bjornson <zbbjornson@gmail.com>
License Copyright:
===
MIT License
Copyright (c) 2012-2023 Faisal Salman <<f@faisalman.com>>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Name: events
Version: 3.3.0
License: MIT
Private: false
Description: Node's event emitter for all engines.
Repository: git://github.com/Gozala/events.git
Author: Irakli Gozalishvili <rfobic@gmail.com> (http://jeditoolkit.com)
License Copyright:
===
MIT
Copyright Joyent, Inc. and other Node contributors.
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to permit
persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
---
Name: h264-profile-level-id
Version: 1.0.2
License: ISC
Private: false
Description: Utility to process H264 profile-level-id values
Repository: https://github.com/ibc/h264-profile-level-id.git
Author: Iñaki Baz Castillo <ibc@aliax.net> (https://inakibaz.me)
License Copyright:
===
ISC License
Copyright © 2019, Iñaki Baz Castillo <ibc@aliax.net>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
Name: awaitqueue
Version: 3.0.2
License: ISC
Private: false
Description: JavaScript utility to enqueue asynchronous tasks and run them sequentially one after another
Repository: https://github.com/versatica/awaitqueue.git
Author: Iñaki Baz Castillo <ibc@aliax.net> (https://inakibaz.me)
License Copyright:
===
ISC License
Copyright © 2019, Iñaki Baz Castillo <ibc@aliax.net>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
Name: queue-microtask
Version: 1.2.3
License: MIT
Private: false
Description: fast, tiny `queueMicrotask` shim for modern engines
Repository: git://github.com/feross/queue-microtask.git
Homepage: https://github.com/feross/queue-microtask
Author: Feross Aboukhadijeh <feross@feross.org> (https://feross.org)
License Copyright:
===
The MIT License (MIT)
Copyright (c) Feross Aboukhadijeh
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
---
Name: sdp-transform
Version: 2.14.1
License: MIT
Private: false
Description: A simple parser/writer for the Session Description Protocol
Repository: clux/sdp-transform
Author: Eirik Albrigtsen <sszynrae@gmail.com>
License Copyright:
===
(The MIT License)
Copyright (c) 2013 Eirik Albrigtsen
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -6,6 +6,8 @@ import { browser } from "@web/core/browser/browser";
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { CONNECTION_TYPES } from "@mail/discuss/call/common/rtc_service";
const PROTOCOLS_TEXT = { host: "HOST", srflx: "STUN", prflx: "STUN", relay: "TURN" };
export class CallContextMenu extends Component {
@@ -13,46 +15,125 @@ export class CallContextMenu extends Component {
static template = "discuss.CallContextMenu";
updateStatsTimeout;
rtcConnectionTypes = CONNECTION_TYPES;
setup() {
this.userSettings = useState(useService("mail.user_settings"));
this.rtc = useState(useService("discuss.rtc"));
this.state = useState({
downloadStats: {},
uploadStats: {},
producerStats: {},
});
onMounted(() => {
if (!this.env.debug) {
return;
}
this.props.rtcSession.updateStats();
this.updateStatsTimeout = browser.setInterval(
() => this.props.rtcSession.updateStats(),
3000
);
this.updateStats();
this.updateStatsTimeout = browser.setInterval(() => this.updateStats(), 3000);
});
onWillUnmount(() => browser.clearInterval(this.updateStatsTimeout));
}
get isSelf() {
return this.rtc.state.selfSession?.eq(this.props.rtcSession);
}
get inboundConnectionTypeText() {
if (!this.props.rtcSession.remoteCandidateType) {
return _t("no connection");
}
return _t("%(candidateType)s (%(protocol)s)", {
candidateType: this.props.rtcSession.remoteCandidateType,
protocol: PROTOCOLS_TEXT[this.props.rtcSession.remoteCandidateType],
});
const candidateType =
this.rtc.state.connectionType === CONNECTION_TYPES.SERVER
? this.state.downloadStats.remoteCandidateType
: this.props.rtcSession.remoteCandidateType;
return this.formatProtocol(candidateType);
}
get outboundConnectionTypeText() {
if (!this.props.rtcSession.localCandidateType) {
return _t("no connection");
}
return _t("%(candidateType)s (%(protocol)s)", {
candidateType: this.props.rtcSession.localCandidateType,
protocol: PROTOCOLS_TEXT[this.props.rtcSession.localCandidateType],
});
const candidateType =
this.rtc.state.connectionType === CONNECTION_TYPES.SERVER
? this.state.uploadStats.localCandidateType
: this.props.rtcSession.localCandidateType;
return this.formatProtocol(candidateType);
}
get volume() {
return this.userSettings.getVolume(this.props.rtcSession);
}
/**
* @param {string} candidateType
* @returns {string} a formatted string that describes the connection type e.g: "prflx (STUN)"
*/
formatProtocol(candidateType) {
if (!candidateType) {
return _t("no connection");
}
return _t("%(candidateType)s (%(protocol)s)", {
candidateType,
protocol: PROTOCOLS_TEXT[candidateType],
});
}
async updateStats() {
if (this.rtc.state.selfSession?.eq(this.props.rtcSession)) {
if (this.rtc.sfuClient) {
const { uploadStats, downloadStats, ...producerStats } =
await this.rtc.sfuClient.getStats();
const formattedUploadStats = {};
for (const value of uploadStats.values?.() || []) {
switch (value.type) {
case "candidate-pair":
if (value.state === "succeeded" && value.localCandidateId) {
formattedUploadStats.localCandidateType =
uploadStats.get(value.localCandidateId)?.candidateType || "";
formattedUploadStats.availableOutgoingBitrate =
value.availableOutgoingBitrate;
}
break;
case "transport":
formattedUploadStats.dtlsState = value.dtlsState;
formattedUploadStats.iceState = value.iceState;
formattedUploadStats.packetsSent = value.packetsSent;
break;
}
}
const formattedDownloadStats = {};
for (const value of downloadStats.values?.() || []) {
switch (value.type) {
case "candidate-pair":
if (value.state === "succeeded" && value.localCandidateId) {
formattedDownloadStats.remoteCandidateType =
downloadStats.get(value.remoteCandidateId)?.candidateType || "";
}
break;
case "transport":
formattedDownloadStats.dtlsState = value.dtlsState;
formattedDownloadStats.iceState = value.iceState;
formattedDownloadStats.packetsReceived = value.packetsReceived;
break;
}
}
const formattedProducerStats = {};
for (const [type, stat] of Object.entries(producerStats)) {
const currentTypeStats = {};
for (const value of stat.values()) {
switch (value.type) {
case "codec":
currentTypeStats.codec = value.mimeType;
currentTypeStats.clockRate = value.clockRate;
break;
}
}
formattedProducerStats[type] = currentTypeStats;
}
this.state.uploadStats = formattedUploadStats;
this.state.downloadStats = formattedDownloadStats;
this.state.producerStats = formattedProducerStats;
}
return;
}
await this.props.rtcSession.updateStats();
}
onChangeVolume(ev) {
const volume = Number(ev.target.value);
this.userSettings.saveVolumeSetting({
@@ -2,22 +2,56 @@
<templates xml:space="preserve">
<t t-name="discuss.CallContextMenu">
<div class="d-flex flex-column p-3">
<input type="range" min="0.0" max="1" step="0.01" t-att-value="volume" t-on-change="onChangeVolume" class="form-range"/>
<t t-if="env.debug">
<input t-if="!isSelf" type="range" min="0.0" max="1" step="0.01" t-att-value="volume" t-on-change="onChangeVolume" class="form-range"/>
<t t-if="env.debug and !isSelf and rtc.state.connectionType === rtcConnectionTypes.P2P">
<hr class="w-100 border-top"/>
<div><span class="fw-bolder">Connection: </span><t t-esc="props.rtcSession.connectionState"/></div>
<div><span class="fw-bolder">ICE: </span><t t-esc="props.rtcSession.iceState"/></div>
<div><span class="fw-bolder">DTLS: </span><t t-esc="props.rtcSession.dtlsState"/></div>
<div><span class="fw-bolder">Data channel: </span><t t-esc="props.rtcSession.dataChannelState"/></div>
<div t-if="props.rtcSession.audioError"><span class="fw-bolder">Audio player: </span><t t-esc="props.rtcSession.audioError"/></div>
<div t-if="props.rtcSession.videoError"><span class="fw-bolder">Video player: </span><t t-esc="props.rtcSession.videoError"/></div>
<div><span class="fw-bolder">Connection type: </span><t t-out="rtc.state.connectionType"/></div>
<div><span class="fw-bolder">To peer: </span><t t-out="outboundConnectionTypeText"/></div>
<div><span class="fw-bolder">From peer: </span><t t-out="inboundConnectionTypeText"/></div>
<div><span class="fw-bolder">Connection: </span><t t-out="props.rtcSession.connectionState"/></div>
<div><span class="fw-bolder">ICE: </span><t t-out="props.rtcSession.iceState"/></div>
<div><span class="fw-bolder">DTLS: </span><t t-out="props.rtcSession.dtlsState"/></div>
<div><span class="fw-bolder">Data channel: </span><t t-out="props.rtcSession.dataChannelState"/></div>
<div t-if="props.rtcSession.audioError"><span class="fw-bolder">Audio player: </span><t t-out="props.rtcSession.audioError"/></div>
<div t-if="props.rtcSession.videoError"><span class="fw-bolder">Video player: </span><t t-out="props.rtcSession.videoError"/></div>
<hr class="w-100 border-top"/>
<div><span class="fw-bolder">ICE gathering: </span><t t-esc="props.rtcSession.iceGatheringState"/></div>
<div><span class="fw-bolder">From peer: </span><t t-esc="inboundConnectionTypeText"/></div>
<div><span class="fw-bolder">To peer: </span><t t-esc="outboundConnectionTypeText"/></div>
<div><span class="fw-bolder">Packets sent: </span><t t-esc="props.rtcSession.packetsSent"/></div>
<div><span class="fw-bolder">Packets received: </span><t t-esc="props.rtcSession.packetsReceived"/></div>
<div><span class="fw-bolder">Log step: </span><t t-esc="props.rtcSession.logStep"/></div>
<div><span class="fw-bolder">ICE gathering: </span><t t-out="props.rtcSession.iceGatheringState"/></div>
<div><span class="fw-bolder">Packets sent: </span><t t-out="props.rtcSession.packetsSent"/></div>
<div><span class="fw-bolder">Packets received: </span><t t-out="props.rtcSession.packetsReceived"/></div>
<div><span class="fw-bolder">Log step: </span><t t-out="props.rtcSession.logStep"/></div>
</t>
<t t-elif="env.debug and isSelf and rtc.state.connectionType === rtcConnectionTypes.SERVER">
<div><span class="fw-bolder">Connection type: </span><t t-out="rtc.state.connectionType"/></div>
<div><span class="fw-bolder">RTC Session ID: </span><t t-out="props.rtcSession.id"/></div>
<hr class="w-100 border-top"/>
<div><span class="fw-bolder">Upload: </span><t t-out="outboundConnectionTypeText"/></div>
<div><span class="fw-bolder">up ICE: </span><t t-out="state.uploadStats.iceState"/></div>
<div><span class="fw-bolder">up DTLS: </span><t t-out="state.uploadStats.dtlsState"/></div>
<div><span class="fw-bolder">Packets sent: </span><t t-out="state.uploadStats.packetsSent"/></div>
<div><span class="fw-bolder">available bitrate: </span><t t-out="state.uploadStats.availableOutgoingBitrate"/></div>
<hr class="w-100 border-top"/>
<div><span class="fw-bolder">Download: </span><t t-out="inboundConnectionTypeText"/></div>
<div><span class="fw-bolder">down ICE: </span><t t-out="state.downloadStats.iceState"/></div>
<div><span class="fw-bolder">down DTLS: </span><t t-out="state.downloadStats.dtlsState"/></div>
<div><span class="fw-bolder">Packets received: </span><t t-out="state.downloadStats.packetsReceived"/></div>
<t t-if="state.producerStats.audio">
<hr class="w-100 border-top"/>
<div><span class="fw-bolder">microphone</span></div>
<div><span class="fw-bolder">codec: </span><t t-out="state.producerStats.audio.codec"/></div>
<div><span class="fw-bolder">clock rate: </span><t t-out="state.producerStats.audio.clockRate"/></div>
</t>
<t t-if="state.producerStats.camera and props.rtcSession.isCameraOn">
<hr class="w-100 border-top"/>
<div><span class="fw-bolder">camera</span></div>
<div><span class="fw-bolder">codec: </span><t t-out="state.producerStats.camera.codec"/></div>
<div><span class="fw-bolder">clock rate: </span><t t-out="state.producerStats.camera.clockRate"/></div>
</t>
<t t-if="state.producerStats.screen and props.rtcSession.isScreenSharingOn">
<hr class="w-100 border-top"/>
<div><span class="fw-bolder">screen</span></div>
<div><span class="fw-bolder">codec: </span><t t-out="state.producerStats.screen.codec"/></div>
<div><span class="fw-bolder">clock rate: </span><t t-out="state.producerStats.screen.clockRate"/></div>
</t>
</t>
</div>
</t>
@@ -59,6 +59,9 @@ export class CallParticipantCard extends Component {
if (!this.rtcSession) {
return false;
}
if (this.env.debug) {
return true;
}
return !this.rtcSession?.eq(this.rtc.state.selfSession);
}
@@ -77,11 +80,19 @@ export class CallParticipantCard extends Component {
get showConnectionState() {
return Boolean(
this.isOfActiveCall &&
!this.rtcSession.channelMember?.persona.eq(this.store.self) &&
this.rtcSession?.peerConnection &&
!HIDDEN_CONNECTION_STATES.has(this.rtcSession.connectionState)
);
}
get showServerState() {
return Boolean(
this.rtcSession.channelMember?.persona.eq(this.store.self) &&
this.rtc.state.serverState &&
this.rtc.state.serverState !== "connected"
);
}
get name() {
return this.channelMember?.persona.name;
}
@@ -56,6 +56,9 @@
<span t-if="showConnectionState" class="d-flex flex-column justify-content-center me-1 p-2 rounded-circle o-discuss-CallParticipantCard-iconBlackBg" t-att-title="rtcSession.connectionState">
<i class="fa fa-exclamation-triangle text-warning"/>
</span>
<span t-if="showServerState" class="d-flex flex-column justify-content-center me-1 p-2 rounded-circle o-discuss-CallParticipantCard-iconBlackBg" t-att-title="rtc.state.serverState">
<i class="fa fa-exclamation-triangle text-warning"/>
</span>
<span t-if="rtcSession.isScreenSharingOn and !props.minimized and !isOfActiveCall" class="user-select-none rounded-pill text-bg-danger d-flex align-items-center me-1 fw-bolder" title="live" aria-label="live">
LIVE
</span>
@@ -73,7 +73,7 @@ function _loadScriptProcessor(
audioContext,
{
frequencyRange = HUMAN_VOICE_FREQUENCY_RANGE,
minimumActiveCycles = 10,
minimumActiveCycles = 30,
onThreshold,
onTic,
volumeThreshold = 0.3,
@@ -10,12 +10,30 @@ import { browser } from "@web/core/browser/browser";
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { debounce } from "@web/core/utils/timing";
import { loadBundle } from "@web/core/assets";
import { memoize } from "@web/core/utils/functions";
/**
* @return {Promise<{ SfuClient: import("@mail/static/libs/odoo_sfu/odoo_sfu").SfuClient, SFU_CLIENT_STATE: import("@mail/static/libs/odoo_sfu/odoo_sfu").SFU_CLIENT_STATE }>}
*/
const loadSfuAssets = memoize(async () => await loadBundle("mail.assets_odoo_sfu"));
const ORDERED_TRANSCEIVER_NAMES = ["audio", "screen", "camera"];
export const CONNECTION_TYPES = { P2P: "p2p", SERVER: "server" };
const PEER_NOTIFICATION_WAIT_DELAY = 50;
const RECOVERY_TIMEOUT = 15_000;
const RECOVERY_DELAY = 3_000;
const VIDEO_CONFIG = {
const SCREEN_CONFIG = {
width: { max: 1920 },
height: { max: 1080 },
aspectRatio: 16 / 9,
frameRate: {
max: 24,
},
};
const CAMERA_CONFIG = {
width: { max: 1280 },
height: { max: 720 },
aspectRatio: 16 / 9,
frameRate: {
max: 30,
@@ -86,7 +104,12 @@ function hasTurn(iceServers) {
export class Rtc {
notifications = reactive(new Map());
/** @type {Map<string, number>} timeoutId by notificationId for call notifications */
timeouts = new Map();
/** @type {Map<number, number>} timeoutId by sessionId for download pausing delay */
downloadTimeouts = new Map();
/** @type {import("@mail/static/libs/odoo_sfu/odoo_sfu").SfuClient} */
sfuClient = undefined;
/**
* @param {import("@web/env").OdooEnv} env
@@ -100,7 +123,10 @@ export class Rtc {
this.soundEffectsService = services["mail.sound_effects"];
this.userSettingsService = services["mail.user_settings"];
this.pttExtService = services["discuss.ptt_extension"];
this._handleSfuClientUpdates = this._handleSfuClientUpdates.bind(this);
this._handleSfuClientStateChange = this._handleSfuClientStateChange.bind(this);
this.state = reactive({
connectionType: undefined,
hasPendingRequest: false,
selfSession: undefined,
channel: undefined,
@@ -108,6 +134,8 @@ export class Rtc {
logs: new Map(),
sendCamera: false,
sendScreen: false,
serverInfo: undefined,
serverState: undefined,
updateAndBroadcastDebounce: undefined,
isPendingNotify: false,
notificationsToSend: new Map(),
@@ -196,6 +224,7 @@ export class Rtc {
// browser prevents async requests from firing when the browser
// is closed. Alternatives like synchronous XHR are not reliable.
browser.navigator.sendBeacon("/mail/rtc/channel/leave_call", blob);
this.sfuClient?.disconnect();
}
});
/**
@@ -203,8 +232,8 @@ export class Rtc {
* a regular interval to try to recover any connection that failed
* to start.
*
* This is distinct from this.recover which tries to restores
* connection that were established but failed or timed out.
* This is distinct from this.recover which tries to restore
* connections that were established but failed or timed out.
*/
browser.setInterval(async () => {
if (!this.state.selfSession || !this.state.channel) {
@@ -241,6 +270,32 @@ export class Rtc {
}
this.setTalking(true);
}
async loadSfuClient() {
this.state.connectionType = CONNECTION_TYPES.P2P;
if (!this.state.serverInfo) {
return;
}
try {
await loadSfuAssets();
const sfuModule = odoo.loader.modules.get("@mail/../lib/odoo_sfu/odoo_sfu");
this.disconnectFromSfu();
this.SFU_CLIENT_STATE = sfuModule.SFU_CLIENT_STATE;
this.sfuClient = new sfuModule.SfuClient();
this.sfuClient.addEventListener("update", this._handleSfuClientUpdates);
this.sfuClient.addEventListener("stateChange", this._handleSfuClientStateChange);
this.state.connectionType = CONNECTION_TYPES.SERVER;
} catch (e) {
this.notification.add(
_t("Failed to load the SFU server, falling back to peer-to-peer"),
{
type: "warning",
}
);
this.log(this.state.selfSession, "failed to load sfu server", { error: e });
}
}
/**
* @param {Object} param0
* @param {any} param0.id
@@ -274,8 +329,19 @@ export class Rtc {
* Notifies the server and does the cleanup of the current call.
*/
async leaveCall(channel = this.state.channel) {
this.state.hasPendingRequest = true;
await this.rpcLeaveCall(channel);
this.endCall(channel);
this.state.hasPendingRequest = false;
}
disconnectFromSfu() {
if (!this.sfuClient) {
return;
}
this.sfuClient.removeEventListener("update", this._handleSfuClientUpdates);
this.sfuClient.removeEventListener("stateChange", this._handleSfuClientStateChange);
this.sfuClient.disconnect();
}
/**
@@ -286,6 +352,7 @@ export class Rtc {
channel.activeRtcSession = undefined;
if (channel.eq(this.state.channel)) {
this.pttExtService.unsubscribe();
this.disconnectFromSfu();
this.clear();
this.soundEffectsService.play("channel-leave");
}
@@ -296,9 +363,28 @@ export class Rtc {
this.soundEffectsService.play("deafen");
}
/**
* @param {import("@mail/discuss/call/common/rtc_session_model").RtcSession} session
* @param {boolean} active
*/
setRemoteRaiseHand(session, active) {
Object.assign(session, {
raisingHand: active ? new Date() : undefined,
});
const notificationId = "raise_hand_" + session.id;
if (session.raisingHand) {
this.addCallNotification({
id: notificationId,
text: _t("%s raised their hand", session.name),
});
} else {
this.removeCallNotification(notificationId);
}
}
async handleNotification(sessionId, content) {
const { event, channelId, payload } = JSON.parse(content);
const session = this.state.channel?.rtcSessions.find((session) => session.id === sessionId);
const session = this.store.RtcSession.get(sessionId);
if (
!session ||
!IS_CLIENT_RTC_COMPATIBLE ||
@@ -418,19 +504,7 @@ export class Rtc {
this.disconnect(session);
break;
case "raise_hand":
Object.assign(session, {
raisingHand: payload.active ? new Date() : undefined,
});
// eslint-disable-next-line no-case-declarations
const notificationId = "raise_hand_" + session.id;
if (session.raisingHand) {
this.addCallNotification({
id: notificationId,
text: _t("%s raised a hand", session.name),
});
} else {
this.removeCallNotification(notificationId);
}
this.setRemoteRaiseHand(session, payload.active);
break;
case "trackChange": {
if (payload.type === "audio") {
@@ -474,7 +548,6 @@ export class Rtc {
if (this.state.hasPendingRequest) {
return;
}
this.state.hasPendingRequest = true;
const isActiveCall = channel.eq(this.state.channel);
if (this.state.channel) {
await this.leaveCall(this.state.channel);
@@ -482,7 +555,6 @@ export class Rtc {
if (!isActiveCall) {
await this.joinCall(channel, { video });
}
this.state.hasPendingRequest = false;
}
async toggleMicrophone() {
@@ -554,7 +626,109 @@ export class Rtc {
this.state.outgoingSessions.add(session.id);
}
call() {
/**
* @param {CustomEvent} param0
* @param {Object} param0.detail
* @param {String} param0.detail.name
* @param {any} param0.detail.payload
*/
async _handleSfuClientUpdates({ detail: { name, payload } }) {
if (!this.state.channel) {
return;
}
switch (name) {
case "disconnect":
{
const { sessionId } = payload;
const session = this.store.RtcSession.get(sessionId);
if (!session) {
return;
}
this.disconnect(session);
}
return;
case "info_change":
if (!payload) {
return;
}
for (const [id, info] of Object.entries(payload)) {
const session = this.store.RtcSession.get(Number(id));
if (!session) {
return;
}
// `isRaisingHand` is turned into the Date `raisingHand`
this.setRemoteRaiseHand(session, info.isRaisingHand);
delete info.isRaisingHand;
Object.assign(session, info);
}
return;
case "track":
{
const { sessionId, type, track, active } = payload;
const session = this.store.RtcSession.get(sessionId);
if (!session) {
return;
}
try {
await this.handleRemoteTrack({ session, track, type, active });
} catch {
// ignored, the session may be closing.
// this can happen when you join a call from another tab in which you have another session.
}
// makes sure we are not downloading a video that is not displayed
setTimeout(() => {
this.updateVideoDownload(session);
}, 2000);
}
return;
}
}
async _handleSfuClientStateChange({ detail: { state, cause } }) {
this.state.serverState = state;
switch (state) {
case this.SFU_CLIENT_STATE.AUTHENTICATED:
// if we are hot-swapping connection type, we clear the p2p as late as possible
this.clearPeerToPeer();
break;
case this.SFU_CLIENT_STATE.CONNECTED:
this.sfuClient.updateInfo(this.formatInfo(), {
needRefresh: true, // asks the server to send the info from all the channel
});
this.sfuClient.updateUpload("audio", this.state.audioTrack);
this.sfuClient.updateUpload("camera", this.state.cameraTrack);
this.sfuClient.updateUpload("screen", this.state.screenTrack);
return;
case this.SFU_CLIENT_STATE.CLOSED:
{
let text;
if (cause === "full") {
text = _t("Channel full");
} else {
text = _t("Connection to SFU server closed by the server");
}
this.notification.add(text, {
type: "warning",
});
await this.leaveCall();
}
return;
}
}
async call() {
if (this.state.connectionType === CONNECTION_TYPES.SERVER) {
if (this.sfuClient.state === this.SFU_CLIENT_STATE.DISCONNECTED) {
await this.sfuClient.connect(
this.state.serverInfo.url,
this.state.serverInfo.jsonWebToken,
{
iceServers: this.state.iceServers,
}
);
}
return;
}
if (this.state.channel.rtcSessions.length === 0) {
return;
}
@@ -730,7 +904,8 @@ export class Rtc {
return;
}
this.pttExtService.subscribe();
const { rtcSessions, iceServers, sessionId } = await this.rpc(
this.state.hasPendingRequest = true;
const { rtcSessions, iceServers, sessionId, serverInfo } = await this.rpc(
"/mail/rtc/channel/join_call",
{
channel_id: channel.id,
@@ -742,6 +917,7 @@ export class Rtc {
this.clear();
this.state.logs.clear();
this.state.channel = channel;
this.state.serverInfo = serverInfo;
this.state.channel.rtcSessions = rtcSessions;
this.state.selfSession = this.store.RtcSession.get(sessionId);
this.state.iceServers = iceServers || DEFAULT_ICE_SERVERS;
@@ -790,12 +966,14 @@ export class Rtc {
true
);
this.state.channel.rtcInvitingSession = undefined;
this.call();
await this.loadSfuClient();
await this.call();
this.soundEffectsService.play("channel-join");
await this.resetAudioTrack({ force: true });
if (video) {
await this.toggleVideo("camera");
}
this.state.hasPendingRequest = false;
}
/**
@@ -908,6 +1086,11 @@ export class Rtc {
}
disconnect(session) {
const downloadTimeout = this.downloadTimeouts.get(session.id);
if (downloadTimeout) {
clearTimeout(downloadTimeout);
this.downloadTimeouts.delete(session.id);
}
this.removeCallNotification("raise_hand_" + session.id);
closeStream(session.audioStream);
if (session.audioElement) {
@@ -962,20 +1145,29 @@ export class Rtc {
this.log(session, "peer removed", { step: "peer removed" });
}
clear() {
clearPeerToPeer() {
for (const session of Object.values(this.store.RtcSession.records)) {
if (session === this.state.selfSession) {
continue;
}
this.disconnect(session);
}
for (const timeoutId of this.state.recoverTimeouts.values()) {
clearTimeout(timeoutId);
}
this.state.recoverTimeouts.clear();
this.state.notificationsToSend.clear();
}
clear() {
this.clearPeerToPeer();
this.sfuClient = undefined;
this.state.serverState = undefined;
this.state.updateAndBroadcastDebounce?.cancel();
this.state.disconnectAudioMonitor?.();
this.state.audioTrack?.stop();
this.state.cameraTrack?.stop();
this.state.screenTrack?.stop();
this.state.notificationsToSend.clear();
closeStream(this.state.sourceCameraStream);
this.state.sourceCameraStream = null;
if (this.blurManager) {
@@ -984,6 +1176,7 @@ export class Rtc {
}
Object.assign(this.state, {
updateAndBroadcastDebounce: undefined,
connectionType: undefined,
disconnectAudioMonitor: undefined,
outgoingSessions: new Set(),
cameraTrack: undefined,
@@ -992,6 +1185,7 @@ export class Rtc {
selfSession: undefined,
sendCamera: false,
sendScreen: false,
serverInfo: undefined,
channel: undefined,
});
}
@@ -1127,11 +1321,16 @@ export class Rtc {
}
}
}
for (const session of this.state.channel.rtcSessions) {
if (session.eq(this.state.selfSession)) {
continue;
const updatedTrack = type === "camera" ? this.state.cameraTrack : this.state.screenTrack;
if (this.sfuClient) {
await this.sfuClient.updateUpload(type, updatedTrack);
} else {
for (const session of this.state.channel.rtcSessions) {
if (session.eq(this.state.selfSession)) {
continue;
}
await this.updateRemote(session, type);
}
await this.updateRemote(session, type);
}
if (!this.state.selfSession) {
return;
@@ -1168,6 +1367,9 @@ export class Rtc {
}
this.state.audioTrack.enabled =
!this.state.selfSession.isMute && this.state.selfSession.isTalking;
if (this.sfuClient && this.sfuClient.state === this.SFU_CLIENT_STATE.CONNECTED) {
this.sfuClient.updateInfo(this.formatInfo());
}
await this.notify(this.state.channel.rtcSessions, "trackChange", {
type: "audio",
state: {
@@ -1219,7 +1421,7 @@ export class Rtc {
sourceStream = this.state.sourceCameraStream;
} else {
sourceStream = await browser.navigator.mediaDevices.getUserMedia({
video: VIDEO_CONFIG,
video: CAMERA_CONFIG,
});
}
}
@@ -1228,7 +1430,7 @@ export class Rtc {
sourceStream = this.state.sourceScreenStream;
} else {
sourceStream = await browser.navigator.mediaDevices.getDisplayMedia({
video: VIDEO_CONFIG,
video: SCREEN_CONFIG,
});
}
this.soundEffectsService.play("screen-sharing");
@@ -1369,6 +1571,10 @@ export class Rtc {
audioTrack.enabled = !this.state.selfSession.isMute && this.state.selfSession.isTalking;
this.state.audioTrack = audioTrack;
await this.linkVoiceActivation();
if (this.sfuClient) {
await this.sfuClient.updateUpload("audio", this.state.audioTrack);
return;
}
for (const session of this.state.channel.rtcSessions) {
if (session.eq(this.state.selfSession)) {
continue;
@@ -1431,6 +1637,12 @@ export class Rtc {
}
}
formatInfo() {
this.state.selfSession.isCameraOn = Boolean(this.state.cameraTrack);
this.state.selfSession.isScreenSharingOn = Boolean(this.state.screenTrack);
return this.state.selfSession.info;
}
/**
* @param {RtcSession} session
* @param {MediaStreamTrack} track
@@ -1517,8 +1729,41 @@ export class Rtc {
}
}
updateVideoDownload(rtcSession, { viewCountIncrement }) {
/**
* @param {import("@mail/discuss/call/common/rtc_session_model").RtcSession} rtcSession
* @param {Object} [param1]
* @param {number} [param1.viewCountIncrement=0] negative value to decrement
*/
updateVideoDownload(rtcSession, { viewCountIncrement = 0 } = {}) {
rtcSession.videoComponentCount += viewCountIncrement;
const downloadTimeout = this.downloadTimeouts.get(rtcSession.id);
if (downloadTimeout) {
this.downloadTimeouts.delete(rtcSession.id);
browser.clearTimeout(downloadTimeout);
}
if (this.state.connectionType === CONNECTION_TYPES.SERVER) {
if (rtcSession.videoComponentCount > 0) {
this.sfuClient?.updateDownload(rtcSession.id, {
camera: true,
screen: true,
});
} else {
/**
* We wait a bit before pausing a download to avoid flickering, if the user stops downloading and starts again
* soon after, it is not worth pausing the download.
*/
this.downloadTimeouts.set(
rtcSession.id,
browser.setTimeout(() => {
this.downloadTimeouts.delete(rtcSession.id);
this.sfuClient?.updateDownload(rtcSession.id, {
camera: false,
screen: false,
});
}, 1000)
);
}
}
if (!rtcSession.peerConnection) {
return;
}
@@ -1586,6 +1831,22 @@ export const rtcService = {
*/
start(env, services) {
const rtc = new Rtc(env, services);
services["bus_service"].subscribe(
"discuss.channel.rtc.session/sfu_hot_swap",
async ({ serverInfo }) => {
if (!rtc.state.selfSession) {
return;
}
if (rtc.state.serverInfo?.url === serverInfo?.url) {
// no reason to swap if the server is the same, if at some point we want to force a swap
// there should be an explicit flag in the event payload.
return;
}
rtc.state.serverInfo = serverInfo;
await rtc.loadSfuClient();
await rtc.call();
}
);
services["bus_service"].subscribe(
"discuss.channel.rtc.session/peer_notification",
({ sender, notifications }) => {
@@ -94,6 +94,20 @@ export class RtcSession extends Record {
return isActive && this.videoStreams.get(type);
}
/**
* @returns {{isSelfMuted: boolean, isDeaf: boolean, isTalking: boolean, isRaisingHand: boolean}}
*/
get info() {
return {
isSelfMuted: this.isSelfMuted,
isRaisingHand: Boolean(this.raisingHand),
isDeaf: this.isDeaf,
isTalking: this.isTalking,
isCameraOn: this.isCameraOn,
isScreenSharingOn: this.isScreenSharingOn,
};
}
get partnerId() {
const persona = this.channelMember?.persona;
return persona.type === "partner" ? persona.id : undefined;
@@ -9,7 +9,7 @@ class ThresholdProcessor extends globalThis.AudioWorkletProcessor {
frequencies that we want to monitor in hz.
* @param {number} [param0.processorOptions.minimumActiveCycles] - how many cycles have to pass since the last time the
threshold was exceeded to go back to inactive state. It prevents the microphone to shut down
when the user's voice drops in volume mid-sentence.
when the user's voice drops in volume mid-sentence. Time in ms = minimumActiveCycles * processInterval.
* @param {boolean} [param0.processorOptions.postAllTics] true if we need to postMessage at each tics, this prevents
sending events to the main thread on all tics when not necessary.
* @param {number} [param0.processorOptions.volumeThreshold] the minimum value for audio detection
@@ -17,7 +17,7 @@ class ThresholdProcessor extends globalThis.AudioWorkletProcessor {
constructor({
processorOptions: {
frequencyRange,
minimumActiveCycles = 10,
minimumActiveCycles = 30,
postAllTics,
volumeThreshold = 0.3,
},
+1
View File
@@ -80,6 +80,7 @@ class TestChannelRTC(MailCommon):
('DELETE', [{'id': channel_member.rtc_session_ids.id - 1}]),
],
'sessionId': channel_member.rtc_session_ids.id,
'serverInfo': None,
})
@users('employee')
+11 -1
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
def get_twilio_credentials(env):
def get_twilio_credentials(env) -> (str, str):
"""
To be overridable if we need to obtain credentials from another source.
:return: tuple(account_sid: str, auth_token: str)
@@ -10,3 +10,13 @@ def get_twilio_credentials(env):
account_sid = params.get_param("mail.twilio_account_sid")
auth_token = params.get_param("mail.twilio_account_token")
return account_sid, auth_token
def get_sfu_url(env) -> str | None:
sfu_url = env['ir.config_parameter'].sudo().get_param("mail.sfu_server_url")
if sfu_url:
return sfu_url.rstrip("/")
def get_sfu_key(env) -> str | None:
return env['ir.config_parameter'].sudo().get_param('mail.sfu_server_key')
+91
View File
@@ -0,0 +1,91 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import hashlib
import json
import binascii
import time
import enum
import hmac
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, utils
class Algorithm(enum.Enum):
ES256 = "ES256" # ECDSA SHA-256
HS256 = "HS256" # HMAC SHA-256
def _generate_keys(key_encoding, key_format) -> (bytes, bytes):
private_object = ec.generate_private_key(ec.SECP256R1(), default_backend())
private_int = private_object.private_numbers().private_value
private_bytes = private_int.to_bytes(32, "big")
public_object = private_object.public_key()
public_bytes = public_object.public_bytes(
encoding=key_encoding,
format=key_format,
)
return private_bytes, public_bytes
def generate_vapid_keys() -> (str, str):
"""
Generate the VAPID (Voluntary Application Server Identification) used for the Web Push
This function generates a signing key pair usable with the Elliptic Curve Digital
Signature Algorithm (ECDSA) over the P-256 curve.
https://www.rfc-editor.org/rfc/rfc8292
:return: tuple (private_key, public_key)
"""
private, public = _generate_keys(serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)
private_string = base64.urlsafe_b64encode(private).decode("ascii").strip("=")
public_string = base64.urlsafe_b64encode(public).decode("ascii").strip("=")
return private_string, public_string
def base64_decode_with_padding(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "==")
def _generate_jwt(claims: dict, key: str, algorithm: Algorithm) -> str:
JOSE_header = base64.urlsafe_b64encode(json.dumps({"typ": "JWT", "alg": algorithm.value}).encode())
payload = base64.urlsafe_b64encode(json.dumps(claims).encode())
unsigned_token = "{}.{}".format(JOSE_header.decode().strip("="), payload.decode().strip("="))
key_decoded = base64_decode_with_padding(key)
match algorithm:
case Algorithm.HS256:
signature = hmac.new(key_decoded, unsigned_token.encode(), hashlib.sha256).digest()
sig = base64.urlsafe_b64encode(signature)
case Algorithm.ES256:
# Retrieve the private key using a P256 elliptic curve
private_key = ec.derive_private_key(
int(binascii.hexlify(key_decoded), 16), ec.SECP256R1(), default_backend()
)
signature = private_key.sign(unsigned_token.encode(), ec.ECDSA(hashes.SHA256()))
(r, s) = utils.decode_dss_signature(signature)
sig = base64.urlsafe_b64encode(r.to_bytes(32, "big") + s.to_bytes(32, "big"))
case _:
raise ValueError(f"Unsupported algorithm: {algorithm}")
return "{}.{}".format(unsigned_token, sig.decode().strip("="))
def sign(claims: dict, key: str, ttl: int, algorithm: Algorithm) -> str:
"""
A JSON Web Token is a signed pair of JSON objects, turned into base64 strings.
RFC: https://www.rfc-editor.org/rfc/rfc7519
:param claims: the payload of the jwt: https://www.rfc-editor.org/rfc/rfc7519#section-4.1
:param key: base64 string
:param ttl: the time to live of the token in seconds ('exp' claim)
:param algorithm: to use to sign the token
:return: JSON Web Token
"""
non_padded_key = key.strip("=")
assert ttl
claims["exp"] = int(time.time()) + ttl
return _generate_jwt(claims, non_padded_key, algorithm=algorithm)
@@ -66,6 +66,10 @@
<field name="channel_type" groups="base.group_no_one"/>
<field name="description" placeholder="Topics discussed in this group..."/>
</group>
<group class="o_label_nowrap" groups="base.group_no_one">
<field name="sfu_channel_uuid"/>
<field name="sfu_server_url"/>
</group>
<notebook>
<page string="Privacy" name="privacy">
<group class="o_label_nowrap">
@@ -75,6 +75,18 @@
</div>
</div>
</setting>
<setting string="SFU server">
<div class="content-group">
<div class="row mt16">
<label for="sfu_server_url" string="URL" class="col-lg-3"/>
<field name="sfu_server_url"/>
</div>
<div class="row mt16">
<label for="sfu_server_key" string="Key" class="col-lg-3"/>
<field name="sfu_server_key"/>
</div>
</div>
</setting>
<setting string="Custom ICE server list" help="Configure your ICE server list for webRTC">
<div class="content-group">
<div class="row col-lg-4">
+22 -79
View File
@@ -1,95 +1,25 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import binascii
import json
import logging as logger
import os
import struct
import textwrap
import time
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, utils
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
from urllib.parse import urlparse
from urllib.parse import urlsplit
from .tools import jwt
MAX_PAYLOAD_SIZE = 4096
_logger = logger.getLogger(__name__)
def _base64_decode_with_padding(value):
return base64.urlsafe_b64decode(value + '==')
def generate_web_push_vapid_key():
"""
Generate the VAPID (Voluntary Application Server Identification) used for the Web Push
This function generates a signing key pair usable with the Elliptic Curve Digital
Signature Algorithm (ECDSA) over the P-256 curve.
These keys will be used during communication with the endpoint/browser
https://www.rfc-editor.org/rfc/rfc8292
"""
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
private_int = private_key.private_numbers().private_value
private = private_int.to_bytes(32, 'big')
private_string = base64.urlsafe_b64encode(private).decode('ascii').strip('=')
public_key = private_key.public_key()
public = public_key.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint
)
public_string = base64.urlsafe_b64encode(public).decode('ascii').strip('=')
return private_string, public_string
def _generate_jwt(endpoint, base_url, vapid_private_key):
"""
JWT are a pair of JSON objects, turned into base64 strings, and signed with the private ECDH key
https://www.rfc-editor.org/rfc/rfc7519
https://www.rfc-editor.org/rfc/rfc8291
:param endpoint: the browser endpoint
:param base_url: the base url
:param vapid_private_key: the private ECDH key generate at mail_entreprise install
:return:
"""
url = urlparse(endpoint)
jwt_info = base64.urlsafe_b64encode(json.dumps({
'typ': 'JWT',
'alg': 'ES256'
}).encode())
# The expiration is a timestamp in seconds and must be no longer 12 hours.
token_validity = 12 * 60 * 60
jwt_data = base64.urlsafe_b64encode(json.dumps({
# aud: The “Audience” is a JWT construct that indicates the recipient scheme and host
# e.g. for an endpoint like https://updates.push.services.mozilla.com/wpush/v2/gAAAAABY...,
# the “aud” would be https://updates.push.services.mozilla.com
'aud': '{}://{}'.format(url.scheme, url.netloc),
# sub: the sub value needs to be either a URL address. This is so that if a push service needed to reach out
# to sender, it can find contact information from the JWT.
'sub': base_url,
# exp: It's the expiration of the JWT, this prevents snoopers from being able to re-use a JWT if they intercept it.
'exp': int(time.time()) + token_validity
}).encode())
unsigned_token = '{}.{}'.format(jwt_info.decode().strip('='), jwt_data.decode().strip('='))
# Retrieve the private key using a P256 elliptic curve
vapid_private_key_decoded = _base64_decode_with_padding(vapid_private_key)
private_key = ec.derive_private_key(int(binascii.hexlify(vapid_private_key_decoded), 16), ec.SECP256R1(), default_backend())
# sign with ECDSA SHA-256
signature = private_key.sign(unsigned_token.encode(), ec.ECDSA(hashes.SHA256()))
(r, s) = utils.decode_dss_signature(signature)
sig = base64.urlsafe_b64encode(r.to_bytes(32, 'big') + s.to_bytes(32, 'big'))
return '{}.{}'.format(unsigned_token, sig.decode().strip('='))
def _iv(base, counter):
mask = int.from_bytes(base[4:], 'big')
return base[:4] + (counter ^ mask).to_bytes(8, 'big')
@@ -97,8 +27,8 @@ def _iv(base, counter):
def _derive_key(salt, private_key, device):
# browser keys
device_keys = json.loads(device["keys"])
p256dh = _base64_decode_with_padding(device_keys.get('p256dh'))
auth = _base64_decode_with_padding(device_keys.get('auth'))
p256dh = jwt.base64_decode_with_padding(device_keys.get('p256dh'))
auth = jwt.base64_decode_with_padding(device_keys.get('auth'))
# generate a public key derived from the browser public key
pub_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), p256dh)
@@ -176,15 +106,28 @@ def _encrypt_payload(content, device, record_size=MAX_PAYLOAD_SIZE):
return header + body
def push_to_end_point(base_url, device, payload, vapid_private_key, vapid_public_key, session):
"""
https://www.rfc-editor.org/rfc/rfc8291
"""
endpoint = device["endpoint"]
jwt = _generate_jwt(endpoint, base_url, vapid_private_key)
url = urlsplit(endpoint)
jwt_claims = {
# aud: The “Audience” is a JWT construct that indicates the recipient scheme and host
# e.g. for an endpoint like https://updates.push.services.mozilla.com/wpush/v2/gAAAAABY...,
# the “aud” would be https://updates.push.services.mozilla.com
'aud': '{}://{}'.format(url.scheme, url.netloc),
# sub: the sub value needs to be either a URL address. This is so that if a push service needed to reach out
# to sender, it can find contact information from the JWT.
'sub': base_url,
}
token = jwt.sign(jwt_claims, vapid_private_key, ttl=12 * 60 * 60, algorithm=jwt.Algorithm.ES256)
body_payload = payload.encode()
payload = _encrypt_payload(body_payload, device)
headers = {
# Authorization header field contains these parameters:
# - "t" is the JWT;
# - "k" the base64url-encoded key that signed that token.
'Authorization': 'vapid t={}, k={}'.format(jwt, vapid_public_key),
'Authorization': 'vapid t={}, k={}'.format(token, vapid_public_key),
'Content-Encoding': 'aes128gcm',
'TTL': '0',
}