Files
Qlyra/calls-protocol.md
T

584 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Протокол звонков (ru.oneme.app 26.24.0, versionCode 6784)
Источник: реверс APK `ru.oneme.app.apk` (jadx). SDK звонков — `ru.ok.android.externcalls.sdk`
(`calls-sdk`, sdkVersion `0.2.3`), медиаядро — `libjingle_peerconnection_so.so` (WebRTC).
## 0. Два независимых слоя
Звонок живёт в **двух** протоколах, и это ключ ко всему:
| Слой | Транспорт | Формат | За что отвечает |
|------|-----------|--------|-----------------|
| **MAX** | основной сокет приложения | MessagePack + Zstd, опкоды | создать/присоединиться к звонку, ссылки, история, пуш о входящем |
| **Signaling** | отдельный WebSocket на `endpoint` из ответа MAX | **JSON текстом** | всё внутри звонка: медиа, SDP/ICE, админка, участники |
MAX-слой выдаёт `endpoint` + `token`, дальше клиент открывает второй сокет и всё
управление звонком идёт уже там. Админка групповых звонков — целиком второй слой.
---
## 1. MAX-слой: опкоды звонков
Таблица опкодов вытащена из enum `p000/kzb.java` (`new kzb("ИМЯ", ordinal, (short) opcode, parser)`).
| Опкод | Имя | Назначение |
|-------|-----|-----------|
| 76 | `VIDEO_CHAT_START` | старт видеочата (в этой сборке ссылок на отправку нет — легаси) |
| 78 | `VIDEO_CHAT_START_ACTIVE` | **создать / войти в звонок** |
| 79 | `VIDEO_CHAT_HISTORY` | история видеочатов |
| 84 | `VIDEO_CHAT_CREATE_JOIN_LINK` | **создать ссылку-приглашение** |
| 89 | `LINK_INFO` | превью по ссылке (в т.ч. звонковой) |
| 137 | `NOTIF_CALL_START` | пуш «входящий звонок» |
| 163 | `CALL_HISTORY` | история звонков |
| 164 | `CALL_HISTORY_CLEAR` | очистка истории |
| 165 | `NOTIF_CALL_HISTORY` | пуш обновления истории |
| 166 | `VIDEO_CHAT_JOIN` | **вход по ссылке** |
| 195 | `VIDEO_CHAT_MEMBERS` | участники видеочата |
Полный дамп всех 175 опкодов — `calls-opcodes.txt` рядом с этим файлом.
### 1.0 Сверка с нашим `opcode_map.dart`
По звонкам расхождений в номерах нет, только в названиях (`164` мы зовём
`videoChatDeleteHistory`, в APK это `CALL_HISTORY_CLEAR`; `166``videoChatJoinByLink`
против `VIDEO_CHAT_JOIN`). Не хватает трёх звонковых опкодов:
`163 CALL_HISTORY`, `165 NOTIF_CALL_HISTORY``103 GET_INBOUND_CALLS` у нас есть).
Вне звонков сверка дала одно расхождение, которое стоит проверить отдельно:
у нас `contactsGet = 8`, в APK опкод `8` — это `LOGIN2`. Остальные отличия — только имена
(`91` GET_COMMENTS_UPDATES / commentsInfo, `202`, `209`, `210`, `293`, `302`).
### 1.1 Создание / вход в звонок — opcode 78 `VIDEO_CHAT_START_ACTIVE`
`p000/vjb.java``m30941c()`:
```jsonc
// request payload
{
"conversationId": "<string>", // ApiProtocol.PARAM_CONVERSATION_ID
"calleeIds": [<long>, ...], // только если массив непустой (p2p-звонок контактам)
"chatId": <long>, // только если != null (звонок в чате/группе)
"isVideo": <bool>,
"internalParams": "<json-string>" // см. 1.4
}
```
`conversationId` генерируется клиентом (`one.me.calls.api.conversationid.ConversationIdGenerator`),
а не сервером — это идемпотентный ключ звонка.
### 1.2 Ссылка на групповой звонок — opcode 84 `VIDEO_CHAT_CREATE_JOIN_LINK`
`p000/rg4.java:1664`:
```jsonc
// request
{ "conversationId": "<string>" }
```
Вызывается уже **после** того, как звонок создан (лог `"start creating p2p join link"`,
ошибки `"create p2p join link failed due to conversationId in null or empty"`,
`"join link already exist"`, метрика провала `CREATE_LINK_FAILED`).
То есть ссылка не создаёт звонок — она выдаётся к существующему `conversationId`.
Ответ — объект `VideoConference` (`p000/r6i.java`, msgpack-ключи):
```jsonc
{
"conferenceId": "<string>",
"conversationId": "<string>",
"joinLink": "<string>",
"chatId": <long>,
"callName": "<string>",
"participantsCount": <int>,
"previewParticipantIds": [<long>, ...],
"startAt": <long>,
"type": <int>,
"owner": <...>
}
```
Этот же объект приходит в превью ссылки (`LINK_INFO`, 89).
### 1.3 Вход по ссылке — opcode 166 `VIDEO_CHAT_JOIN`
`p000/vjb.java``m30940b()`:
```jsonc
// request
{
"joinLink": "<string>", // ApiProtocol.PARAM_JOIN_LINK
"isVideo": <bool>,
"internalParams": "<json-string>"
}
```
Deep-link/роут разбирается в `p000/wg1.java` по ключам
`call_link`, `is_link_call`, `call_chat_id`, `call_title`, `chat_id`.
### 1.4 `internalParams`
JSON-строка, сериализуется из `InternalParamsDto`
(`ru/ok/android/externcalls/sdk/api/delegate/InternalParamsDto`), собирается в `p000/f98.java`:
```jsonc
{
"platform": "ANDROID", // UploadHelper.SDK_TYPE_STRING
"sdkVersion": "0.2.3",
"clientAppKey": "<string>",
"deviceId": "<string>",
"protocolVersion": 6, // 6 если multiple-devices включён, иначе 5
"domainId": "<string|null>",
"onlyAdminCanRecord": false,
"isWaitForAdminEnabled": <bool>,
"hexCapability": "1877f" // см. раздел 6
}
```
### 1.5 Ответ: параметры конференции
Разбирается `CallInfoParser` / `ConversationParams` / `CallInfo`, ключи из `ApiProtocol`:
```jsonc
{
"endpoint": "<wss url>", // сюда открываем signaling-сокет
"wt_endpoint": "<wss url>", // watch-together
"id": "<string>",
"token": "<string>",
"join_link": "<string>",
"client_type": "<string>",
"device_idx": <int>,
"is_concurrent": <bool>,
"p2p_forbidden": <bool>,
"upload_url": "<string>",
"stun_server": { "urls": [ ... ] },
"turn_server": { "urls": [ ... ], "username": "...", "credential": "..." }
}
```
Плюс `wsIps` / `wtIps` — списки IP для обхода DNS.
---
## 2. Signaling: конверт сообщений
WebSocket на `endpoint`, полезная нагрузка — **JSON строкой** (не msgpack).
**Клиент → сервер** (`p000/r7l.java:m26483a`, `p000/tkf.java:117`):
```jsonc
{ "command": "<имя>", ...поля..., "sequence": <long> }
```
**Сервер → клиент**, три вида (`p000/tkf.java:m29114f`, `p000/r51.java:100`):
```jsonc
{ "type": "response", "sequence": <long>, "response": "<имя команды>", ... }
{ "type": "error", "sequence": <long>, "error": "<код>", "errorCode": <int>, "recoverable": <bool> }
{ "notification": "<имя>", ... } // + "stamp": <long> для порядка
```
Коды ошибок, которые встречаются: `service-unavailable`, `participants-limit-reached` (+`limit`),
`conversation-ended`, `conversation-not-found`, `conversation-recording`, `closed-conversation`,
`illegal-conversation-state`, `illegal-participant-state`, `invalid-request`, `invalid-token`,
`no-call`, `call-unfeasible`, `command-not-delivered`, `command-discarded`,
`command-can-not-be-postponed`, `movie-not-found`, `movie-limit-exceeded`.
### 2.1 Идентификатор участника
`participantId` в разных местах в двух формах:
- разложенный: `{"participantId": <long>, "participantType": "GROUP"|"USER", "deviceIdx": <int>}`
- строкой (`mq1.m20652b()`): `u<id>` для юзера, `g<id>` для группы, с суффиксом устройства
Идентификаторы медиа-треков (`p000/r7l.java:m26478K`):
```
<participantId>:sCAMERA // камера
<participantId>:sSCREEN // ДЕМОНСТРАЦИЯ ЭКРАНА
<participantId>:sMOVIE:m<id> // расшаренное видео
<participantId>:sSTREAM
<participantId>:sANIMOJI
audio-<participantId> // аудио-трек
video-<participantId> // видео-трек
```
---
## 3. Медиа: камера / микрофон / экран
### 3.1 Своё состояние — `change-media-settings`
`p000/zkf.java` + `p000/r7l.java:m26497o`. Шлётся **целиком, не дельтой**, при любом переключении:
```jsonc
{
"command": "change-media-settings",
"mediaSettings": {
"isVideoEnabled": <bool>, // камера
"isAudioEnabled": <bool>, // микрофон
"isScreenSharingEnabled": <bool>, // ДЕМОНСТРАЦИЯ ЭКРАНА
"isAnimojiEnabled": <bool>,
"isFastScreenSharingEnabled": <bool>, // только если включён fastScreenShare
"isAudioSharingEnabled": <bool> // только если включён audioShare
},
"sequence": <long>
}
```
Последние два поля **условные** — добавляются только когда соответствующая фича согласована
при подключении. Если слать их всегда — есть риск, что старый сервер отвергнет команду.
### 3.2 Чужое состояние — нотификации
```jsonc
{ "notification": "media-settings-changed",
"participantId": <long>, "participantType": "...", "deviceIdx": <int>,
"mediaSettings": { "isAudioEnabled": ..., "isVideoEnabled": ...,
"isScreenSharingEnabled": ..., "isAnimojiEnabled": ... } }
```
```jsonc
// админ принудительно выключил медиа — клиент ОБЯЗАН применить и погасить у себя
{ "notification": "force-media-settings-change", "mediaSettings": { ... } }
```
```jsonc
{ "notification": "audio-activity", "activeParticipants": ["u123", ...] }
{ "notification": "speaker-changed", "speaker": "u123" }
```
### 3.3 Демонстрация экрана — детали
Что нужно, чтобы экран реально поехал (`ScreenCaptureManagerImpl.setScreenCaptureEnabled`):
1. **Capability `SCREEN_TRACK_PRODUCER` (бит 0)** должен быть в маске (см. 6).
Без него сервер не примет screen-трек. `SCREEN_TRACK_CONSUMER` (бит 4) — чтобы видеть чужой.
2. Проверяется медиа-опция `screenshareState`: если админ поставил `MUTE_PERMANENT`
на `SCREEN_SHARING` — включение молча игнорируется.
3. `change-media-settings` с `isScreenSharingEnabled: true`.
4. В PeerConnection добавляется **отдельный видео-трек** с id `<pid>:sSCREEN`
(unified plan, вторая m-line) — камера при этом остаётся своим треком `<pid>:sCAMERA`.
Это самое частое место поломки: если слать экран в тот же трек, что и камеру,
удалённая сторона увидит либо камеру, либо ничего.
5. Foreground-сервис `CallScreenShareService` с типом `mediaProjection` — андроид-специфика,
но без него система убивает захват.
**Про 1:1 (topology `DIRECT`).** Топология бывает `DIRECT` (p2p) и `SERVER` (`p000/f9h.java`).
Само видео экрана в `DIRECT` работает, а вот **звук демонстрации** (`setAudioCaptureEnabled`,
`n61.m21001I`) жёстко требует `SERVER`:
```java
if (m21020p() && this.f46846n0.m24163I(f9h.f22608c)) { // f22608c == SERVER
```
Апгрейд топологии клиент запрашивает сам:
```jsonc
{ "command": "switch-topology", "topology": "SERVER", "force": false }
```
В стоковом клиенте это шлётся при развале p2p ICE (`onTopologyUpgradeProposed`), но команда
доступна в любой момент. Сервер отвечает нотификацией:
```jsonc
{ "notification": "topology-changed", "topology": "SERVER" | "DIRECT" }
```
Практический вывод для нашего клиента: если экран в 1:1 не едет — проверять в порядке
(1) бит capability, (2) отдельный `sSCREEN`-трек, (3) при необходимости `switch-topology → SERVER`.
### 3.4 Согласование фич при подключении
`p000/r7l.java:m26485c` — объект, который клиент отдаёт при инициализации сессии.
Именно он включает `fastScreenShare` и остальное:
```jsonc
{
"maxH264Decoders": <int>,
"estimatedPerformanceIndex": <int>, // опционально
"producerNotificationDataChannelVersion": 7,
"producerCommandDataChannelVersion": <int>,
"audioMix": true,
"consumerUpdate": <bool>,
"onDemandTracks": <bool>,
"singleSession": true,
"unifiedPlan": true,
"fastScreenShare": true,
"producerScreenDataChannelVersion": 1, // условно
"consumerScreenDataChannelVersion": 1, // условно
"animojiDataChannelVersion": 2, // условно
"animojiBackendRender": true, // условно
"asrDataChannelVersion": 1, // условно
"consumerFastScreenShare": true, // условно
"consumerFastScreenShareQualityOnDemand": true,
"audioShare": true, // условно
"simulcast": true, // условно
"simulcastNativeOrder": true, // условно
"red": true,
"videoTracksCount": <int>, // если > 0
"csrcAccessible": true // если videoTracksCount > 0
}
```
---
## 4. SDP / ICE
Всё заворачивается в `transmit-data` (`p000/r7l.java:m26493k`, `m26502t`, `m26503u`):
```jsonc
// offer / answer
{ "command": "transmit-data",
"participantId": <long>, "participantType": "...", "deviceIdx": <int>,
"data": {
"sdp": { "type": "offer"|"answer", "sdp": "<sdp>", "p2pRelay": "true" },
"capabilities": "1877f", // hex, если != 0
"label": "<string>" // опционально
},
"sequence": <long> }
// одиночный кандидат
{ "command": "transmit-data", ..., "data": { "candidate": { "candidate": "...", "sdpMid": "...", "sdpMLineIndex": <int> } } }
// удалённые кандидаты
{ "command": "transmit-data", ..., "data": { "candidates-removed": [ {...}, ... ] } }
```
`p2pRelay: "true"` (строкой!) добавляется только в offer при включённом P2P-relay.
Топология SERVER добавляет отдельный набор команд (`p000/ooh.java`):
`allocate-consumer`, `accept-producer`, `request-realloc` — аллокация консьюмеров/продюсеров
на медиасервере. Плюс `change-simulcast`, `update-display-layout`, `report-network-stat`,
`report-perf-stat` (`p000/ug8.java`).
---
## 5. Групповые звонки: администрирование
Роли (`p000/pq1.java`): **`CREATOR`**, **`ADMIN`**, **`SPEAKER`**.
### 5.1 Мьют участников
```jsonc
// запросить включение медиа у участника (или у всех, если participantId=null)
{ "command": "mute-participant",
"participantId": "u123" | null,
"requestedMedia": ["AUDIO"|"VIDEO"|"SCREEN_SHARING"|"MOVIE_SHARING", ...],
"roomId": "<id>" // опционально, для session room
}
```
```jsonc
// жёстко выставить состояния мьюта
{ "command": "mute-participant",
"participantId": "u123" | null,
"muteStates": {
"AUDIO": "UNMUTE"|"MUTE"|"MUTE_PERMANENT"|null,
"VIDEO": ...,
"SCREEN_SHARING": ...,
"MOVIE_SHARING": ...
},
"roomId": "<id>" // опционально
}
```
`MUTE` — выключить сейчас (можно включить обратно), `MUTE_PERMANENT` — запретить.
Внутренние состояния клиента: `UNMUTED`, `UNMUTED_BUT_MUTED_ONCE`, `MUTED_PERMANENT`,
`MUTED_PERMANENT_BUT_UNMUTED_ONCE`.
Быстрый мьют микрофона (`ConversationImpl:2331`, `:3446`):
```jsonc
{ "command": "switch-micro", "eId": "u123", "muteTarget": <bool> }
{ "command": "switch-micro", "all": true, "muteTarget": true }
```
Обратная нотификация: `{ "notification": "switch-micro", "mute": <bool> }`
(если поля `mute` нет — клиент логирует `switch-micro without 'mute'` и игнорирует).
### 5.2 Роли и промоушен
```jsonc
{ "command": "promote-participant", "participantId": "u123", "demote": <bool> }
{ "command": "grant-roles", "participantId": "u123",
"roles": ["ADMIN", "SPEAKER", ...], "revoke": <bool> }
// стерео-режим / запрос слова
{ "command": "request-promotion", ... } // + reject / unrequest
{ "command": "accept-promotion", ... }
{ "command": "get-hand-queue" }
```
### 5.3 Участники
```jsonc
// по внешним id
{ "command": "add-participant",
"externalIds": [ ... ],
"unban": true, // опционально
"payload": "{\"show_chat_history\":true}" } // опционально, СТРОКОЙ
// по ссылке/QR
{ "command": "add-participant", "participantIdAsQRCodeLink": "<link>" }
{ "command": "remove-participant", "participantId": "u123" }
{ "command": "pin-participant", "participantId": "u123",
"unpin": <bool>, "roomId": "<id>" }
{ "command": "get-participant-list-chunk",
"count": <int>, "listType": "GRID"|"SIDE", "roomId": "<id>" }
{ "command": "get-waiting-hall", "count": <int>, "fromId": "...", "backward": <bool> }
```
### 5.4 Состояния участника (рука, помощь)
```jsonc
{ "command": "change-participant-state",
"participantId": "u123", // отсутствует → своё состояние
"participantState": { "state": { "hand": "1"|"0", "drat": "1"|"0" } } }
{ "command": "put-hands-down" } // опустить руки всем
```
`hand` — поднятая рука, `drat` — запрошена помощь (`ASSISTANCE_REQUESTED`).
Значения строковые `"1"` / `"0"`.
### 5.5 Опции конференции
```jsonc
{ "command": "change-options",
"options": { "WAITING_HALL": true, "AUDIENCE_MODE": false, ... } }
```
Доступные опции (`p000/l61.java`): `REQUIRE_AUTH_TO_JOIN`, `WAITING_HALL`, `RECURRING`,
`FEEDBACK`, `AUDIENCE_MODE`, `ASR`, `WAIT_FOR_ADMIN`, `ADMIN_IS_HERE`.
```jsonc
{ "command": "enable-feature-for-roles",
"feature": "ADD_PARTICIPANT"|"ADMIN"|"ASR"|"MOVIE_SHARE"|"RECORD"|"SPEAKER",
"roles": ["ADMIN", "SPEAKER", ...] }
```
### 5.6 Запись
```jsonc
{ "command": "record-start",
"movieId": <long>, "name": "...", "description": "...",
"privacy": "...", "groupId": <long>, "albumId": "...",
"streamMovie": <bool>, "roomId": "<id>" }
{ "command": "record-stop", "roomId": "<id>", "remove": <bool> }
```
Ответ на `record-start`: `{"type":"response","sequence":N,"response":"record-start","recordMovieId":<id>}`.
### 5.7 Комнаты (session rooms)
```jsonc
{ "command": "switch-room", "participantId": "u123", "toRoomId": "<id>" }
{ "command": "update-rooms", "rooms": [ { "id": "...",
"addParticipantIds": [...], "removeParticipantIds": [...] } ] }
{ "command": "get-rooms" }
{ "command": "room-query", ... }
{ "command": "room-tx", ... }
```
### 5.8 Совместный просмотр / шаринг ссылки
```jsonc
{ "command": "add-movie", "movieId": <long>, ... }
{ "command": "update-movie", "movieId": <long>, "pause": <bool> }
{ "command": "remove-movie", "movieId": <long> }
{ "command": "start-url-sharing", "sharedUrl": "<url>" }
{ "command": "stop-url-sharing" }
```
### 5.9 Прочее
```jsonc
{ "command": "hangup", "reason": "..." }
{ "command": "accept-call", ... }
{ "command": "custom-data", "participantId": <long>, "participantType": "...",
"deviceIdx": <int>, "data": { ...произвольный JSON... } }
{ "command": "request-asr", ... }
```
---
## 6. Capabilities
`ClientCapabilities` — битовая маска, передаётся **hex-строкой**`capabilities` внутри
`transmit-data.data` и в `hexCapability` внутри `internalParams`).
| Бит | Имя | Что даёт |
|-----|-----|----------|
| 0 | `SCREEN_TRACK_PRODUCER` | **отдавать демонстрацию экрана** |
| 1 | `VIDEO_TRACKS` | видео-треки |
| 2 | `WAITING_HALL` | зал ожидания |
| 3 | `FILTER_DEFAULTS` | |
| 4 | `SCREEN_TRACK_CONSUMER` | **принимать чужую демонстрацию** |
| 5 | `ADMIN_MUTE_NOTIFY` | нотификации админского мьюта |
| 6 | `WATCH_MOVIE` | совместный просмотр |
| 8 | `SESSION_ROOMS` | комнаты |
| 9 | `VMOJI` | анимоджи |
| 10 | `CALL_TO_CONTACTS` | |
| 11 | `AUDIENCE_MODE` | режим зрителей |
| 14 | `SESSION_STATE_UPDATES` | |
| 15 | `ADD_PARTICIPANT` | добавление участников |
| 16 | `USE_P2P_RELAY` | p2p-relay |
| 17 | `WAIT_FOR_ADMIN` | ждать админа |
| 18 | `HOLD` | удержание |
Дефолт стокового клиента — биты `0,1,2,3,4,5,6,8,9,10,15,16` = **`0x1877f`**.
Обрати внимание: `AUDIENCE_MODE`(11), `SESSION_STATE_UPDATES`(14), `WAIT_FOR_ADMIN`(17),
`HOLD`(18) в дефолт **не входят** и включаются по конфигу.
---
## 7. Нотификации сервер → клиент (полный список)
Из switch в `p000/r51.java` (ключ `"notification"`):
**Сессия и участники**
`connection`, `registered-peer`, `participant-added`, `participant-joined`, `accepted-call`,
`participants-state-changed`, `participant-state-changed`, `participant-animoji-changed`,
`decorative-participant-id-changed`, `session-state`, `stalled-activity`, `hold`, `hungup`,
`closed-conversation`, `multiparty-chat-created`
**Медиа**
`media-settings-changed`, `force-media-settings-change`, `audio-activity`, `speaker-changed`,
`switch-micro`, `mute-participant`, `topology-changed`, `realloc-con`, `transmitted-data`
**Админка**
`roles-changed`, `promote-participant`, `promotion-approved`, `options-changed`,
`feature-set-changed`, `features-per-role-changed`, `pin-participant`, `settings-update`
**Комнаты**
`room-updated`, `rooms-updated`, `room-participants-updated`, `chat-room-updated`
**Контент**
`record-started`, `record-stopped`, `movie-share-started`, `movie-share-stopped`,
`url-sharing-info-updated`, `asr-started`, `asr-stopped`, `chat-message`, `custom-data`,
`join-link-changed`, `feedback`, `rate-call-data`
---
## 8. Что из этого чинит наши баги
**Демонстрация экрана в 1:1** — раздел 3.3. Порядок проверки: бит 0 в capabilities →
отдельный трек `<pid>:sSCREEN``change-media-settings` с полным объектом →
при необходимости `switch-topology → SERVER` (обязательно, если нужен звук демонстрации).
**Групповые звонки** — скорее всего не хватает серверной топологии целиком:
`allocate-consumer` / `accept-producer` / `request-realloc` (`p000/ooh.java`),
плюс `get-participant-list-chunk` для подгрузки участников пачками и
`update-display-layout` для раскладки. В `DIRECT` группа не работает by design.
**Условные поля.** `isFastScreenSharingEnabled` / `isAudioSharingEnabled` в `mediaSettings`
и половина полей в объекте согласования фич добавляются **только** при включённой фиче.
Слать их безусловно — рискованно.