feat: js бриджы для вебапов

This commit is contained in:
Jganenokk
2026-08-15 21:31:38 +07:00
parent d91759a1cf
commit f7a986fec7
17 changed files with 1347 additions and 1532 deletions
-154
View File
@@ -1,154 +0,0 @@
# Баннеры (informer) в web-версии MAX
Баннеры вроде «Публичные каналы — для всех» внутри кода называются **informer**.
Они **полностью приходят с сервера** по WebSocket запросом с опкодом `302`.
В JS-бандлах текста баннеров нет — локально хранятся только счётчики показов.
Исходники: `dump/web.max.ru/_app/immutable/chunks/BWjA24QQ.js` (логика),
`_app/immutable/nodes/9.Dqm5zlvE.js` (рендер тайла + аналитика).
---
## Как это работает
1. В общем sync-ответе сессии сервер выставляет бит `1` в bitmask-поле `updates`:
```js
e.updates && new Pu(e.updates).get(1) && await J.banners.resync()
```
2. Клиент шлёт запрос с опкодом **302**:
```js
Ah = Y(async ({bannersSync:e}, {send:t}) => await t(302, {bannersSync:e}))
// resync(): let e = await Ah({bannersSync:0}); await this.upsert(e);
```
3. Сервер отвечает `{showTime, updateTime, banners:[…]}`.
4. Какой баннер показать — считается на клиенте (`activeBanner`): фильтр `canShow`,
затем выбор с максимальным `priority`.
5. Весь механизм гейтится серверным конфигом `informer-enabled`.
6. Состояние показов хранится в `localStorage['__oneme_informer']`:
```js
informerState = { lastShowedBannerId: null, banners: { /* id -> {showCounter, closedTime} */ } }
```
---
## Пример ответа сервера
```json
{
"ver": 10,
"cmd": 1,
"seq": 14,
"opcode": 302,
"payload": {
"showTime": 86400000,
"updateTime": 1784123975588,
"banners": [
{
"id": "public_channels_cid_test",
"title": "Публичные каналы — для всех",
"settings": 1,
"description": "Привлекайте новую аудиторию",
"priority": 16,
"repeat": 1,
"rerun": 5400000000000000,
"animojiId": 1446,
"url": "https://max.ru/max_news/AZ9kk1KhJAo",
"type": 1
}
]
}
}
```
---
## Конверт (обёртка WS-сообщения)
| Поле | Значение | На что влияет |
|---|---|---|
| `ver` | 10 | Версия протокола WebSocket. |
| `cmd` | 1 | Тип пакета. `1` = ответ на запрос (response), не server-push. |
| `seq` | 14 | Номер запроса. Клиент послал `302 {bannersSync:0}` под этим seq — сервер отвечает тем же seq для матчинга. |
| `opcode` | 302 | Код операции = синхронизация баннеров/informer. |
| `payload` | {…} | Полезная нагрузка, попадает в `banners.upsert(e)`. |
## payload — верхний уровень
**`showTime`: 86400000** (24 ч в мс)
Сколько времени баннер считается «свежим» после первого показа.
```js
isShowTimeOver = ka.now - this.showAt > this.showTime
```
Прошло больше `showTime` с момента первого показа → `canShow` = false, баннер пропадает.
**`updateTime`: 1784123975588** (unix-мс)
Версия набора баннеров. Новые данные применяются только если пришло свежее:
```js
e.banners && e.updateTime > this.informer.updateTime && (…apply…)
```
## Объект баннера
**`id`: `"public_channels_cid_test"`**
Уникальный ключ. По нему хранятся `showCounter` и `closedTime` в `localStorage['__oneme_informer']`
и запоминается `lastShowedBannerId` (чтобы не показать один баннер подряд дважды).
Суффикс `_test` = тестовый баннер.
**`title`: «Публичные каналы — для всех»** — верхняя (цветная) строка тайла.
**`description`: «Привлекайте новую аудиторию»** — вторая строка. Оба текста только с сервера.
**`settings`: 1** — битовая маска флагов отображения (`new Pu(settings)`). Биты:
- `1` = TEXT_ANIMATION — анимация текста
- `2` = HIDE_CLOSE_BUTTON — скрыть крестик
- `4` = HIDE_ON_CLICK — закрывать по клику
- `8` = ICON_THEME_COLOR — красить иконку в цвет темы
Здесь `1` → только анимация текста; крестик «×» есть, клик ведёт по ссылке.
**`priority`: 16**
Приоритет при нескольких баннерах — показывается один с максимальным:
```js
e.reduce((a,b) => b.priority > a.priority ? b : a)
```
При одном баннере роли не играет.
**`repeat`: 1**
Максимум показов:
```js
hasRemainingShows = showCounter===0 ? true : showCounter < repeat
```
`1` → показать ровно один раз.
**`rerun`: 5400000000000000**
Кулдаун (мс) перед повторным показом после закрытия крестиком:
```js
isCloseCooldownOver = closedAt ? (rerun>0 ? Date.now()-closedAt > rerun : false) : true
```
Значение огромное (~171 000 лет) → «закрыл — больше не увидишь».
**`animojiId`: 1446**
ID аниможи-иконки слева в тайле:
```js
J.assets.animojisCache.getLazy(bu.resolveId(this.$.animojiId))
```
**`url`: `https://max.ru/max_news/AZ9kk1KhJAo`**
Куда ведёт клик (вместе с `type:1`).
**`type`: 1** — тип баннера:
- `0` TYPE_TEXT — просто текст
- `1` TYPE_LINK — кликабельный, открывает `url` ← этот случай
- `2` TYPE_UPDATE — баннер обновления приложения
---
## Итог по этому баннеру
Тестовый кликабельный баннер про публичные каналы: показывается один раз (`repeat:1`),
с анимацией текста (`settings:1`), «живёт» сутки после показа (`showTime`), по клику
открывает `max.ru/max_news/...`; если закрыть крестиком — не возвращается практически
никогда (`rerun`). Приоритет 16 нужен только при конкуренции нескольких баннеров.
Убрать/подменить баннер локально можно, перехватив ответ на опкод `302`
либо отключив серверный флаг `informer-enabled`.
-175
View File
@@ -1,175 +0,0 @@
1 PING
2 DEBUG
3 RECONNECT
5 LOG
6 SESSION_INIT
8 LOGIN2
16 PROFILE
17 AUTH_REQUEST
18 AUTH
19 LOGIN
20 LOGOUT
21 SYNC
22 CONFIG
23 AUTH_CONFIRM
25 PRESET_AVATARS
26 ASSETS_GET
27 ASSETS_UPDATE
28 ASSETS_GET_BY_IDS
29 ASSETS_ADD
32 CONTACT_INFO
33 CONTACT_ADD
34 CONTACT_UPDATE
35 CONTACT_PRESENCE
36 CONTACT_LIST
37 CONTACT_SEARCH
39 CONTACT_PHOTOS
40 CONTACT_SORT
42 CONTACT_VERIFY
43 REMOVE_CONTACT_PHOTO
46 CONTACT_INFO_BY_PHONE
48 CHAT_INFO
49 CHAT_HISTORY
50 CHAT_MARK
51 CHAT_MEDIA
52 CHAT_DELETE
53 CHATS_LIST
54 CHAT_CLEAR
55 CHAT_UPDATE
56 CHAT_CHECK_LINK
57 CHAT_JOIN
58 CHAT_LEAVE
59 CHAT_MEMBERS
60 PUBLIC_SEARCH
61 CHAT_PERSONAL_CONFIG
62 CHAT_LIVESTREAM_INFO
64 MSG_SEND
65 MSG_TYPING
66 MSG_DELETE
67 MSG_EDIT
68 CHAT_SEARCH
70 MSG_SHARE_PREVIEW
71 MSG_GET
72 MSG_SEARCH_TOUCH
73 MSG_SEARCH
74 MSG_GET_STAT
75 CHAT_SUBSCRIBE
76 VIDEO_CHAT_START
77 CHAT_MEMBERS_UPDATE
78 VIDEO_CHAT_START_ACTIVE
79 VIDEO_CHAT_HISTORY
80 PHOTO_UPLOAD
81 STICKER_UPLOAD
82 VIDEO_UPLOAD
83 VIDEO_PLAY
84 VIDEO_CHAT_CREATE_JOIN_LINK
86 CHAT_PIN_SET_VISIBILITY
87 FILE_UPLOAD
88 FILE_DOWNLOAD
89 LINK_INFO
91 GET_COMMENTS_UPDATES
92 MSG_DELETE_RANGE
94 MSG_DELETE_USER
96 SESSIONS_INFO
97 SESSIONS_CLOSE
98 PHONE_BIND_REQUEST
99 PHONE_BIND_CONFIRM
101 AUTH_LOGIN_RESTORE_PASSWORD
103 GET_INBOUND_CALLS
104 AUTH_2FA_DETAILS
105 EXTERNAL_CALLBACK
106 PHONE_WEBAPP_SHARE
107 AUTH_VALIDATE_PASSWORD
108 AUTH_VALIDATE_HINT
109 AUTH_VERIFY_EMAIL
110 AUTH_CHECK_EMAIL
111 AUTH_SET_2FA
112 AUTH_CREATE_TRACK
113 AUTH_CHECK_PASSWORD
115 AUTH_LOGIN_CHECK_PASSWORD
116 AUTH_LOGIN_PROFILE_DELETE
117 CHAT_COMPLAIN
118 MSG_SEND_CALLBACK
119 SUSPEND_BOT
124 LOCATION_STOP
125 LOCATION_SEND
126 LOCATION_REQUEST
127 GET_LAST_MENTIONS
128 NOTIF_MESSAGE
129 NOTIF_TYPING
130 NOTIF_MARK
131 NOTIF_CONTACT
132 NOTIF_PRESENCE
134 NOTIF_CONFIG
135 NOTIF_CHAT
136 NOTIF_ATTACH
137 NOTIF_CALL_START
139 NOTIF_CONTACT_SORT
140 NOTIF_MSG_DELETE_RANGE
142 NOTIF_MSG_DELETE
143 NOTIF_CALLBACK_ANSWER
144 CHAT_BOT_COMMANDS
145 BOT_INFO
147 NOTIF_LOCATION
148 NOTIF_LOCATION_REQUEST
150 NOTIF_ASSETS_UPDATE
154 NOTIF_MSG_DELAYED
155 NOTIF_MSG_REACTIONS_CHANGED
156 NOTIF_MSG_YOU_REACTED
158 OK_TOKEN
159 NOTIF_PROFILE
160 WEB_APP_INIT_DATA
161 COMPLAIN
162 COMPLAIN_REASONS_GET
163 CALL_HISTORY
164 CALL_HISTORY_CLEAR
165 NOTIF_CALL_HISTORY
166 VIDEO_CHAT_JOIN
178 MSG_REACTION
179 MSG_CANCEL_REACTION
180 MSG_GET_REACTIONS
181 MSG_GET_DETAILED_REACTIONS
193 STICKER_CREATE
194 STICKER_SUGGEST
195 VIDEO_CHAT_MEMBERS
196 CHAT_HIDE
198 CHAT_SEARCH_COMMON_PARTICIPANTS
199 PROFILE_DELETE
200 PROFILE_DELETE_TIME
202 TRANSCRIBE_MEDIA
203 PHOTO_URL_REFRESH
208 STORIES_LIST
209 STORIES_LIST_BY_OWNER_ID
210 STORIES_GET_BY_OWNER_ID
211 STORIES_GET_STATS
212 STORIES_GET_DETAILED_STATS
213 STORIES_REACT
214 STORIES_MARK
215 STORIES_SEND
216 NOTIF_STORIES_UPDATE
217 STORIES_EDIT
218 STORIES_DELETE
220 STORIES_GET_BY_STORY_ID
256 ORG_INFO
257 CHAT_REACTIONS_SETTINGS_SET
258 REACTIONS_SETTINGS_GET_BY_CHAT_ID
259 ASSETS_REMOVE
260 ASSETS_MOVE
261 ASSETS_LIST_MODIFY
272 FOLDERS_GET
273 FOLDERS_GET_BY_ID
274 FOLDERS_UPDATE
275 FOLDERS_REORDER
276 FOLDERS_DELETE
277 NOTIF_FOLDERS
290 AUTH_QR_APPROVE
292 NOTIF_BANNERS
293 NOTIF_TRANSCRIPTION
300 CHAT_SUGGEST
301 AUDIO_PLAY
302 BANNERS_GET
303 MSG_DELIVERY
304 SEND_VOTE
305 VOTERS_LIST_BY_ANSWER
306 GET_POLL_UPDATES
307 CHAT_CHECK_ESIA
-583
View File
@@ -1,583 +0,0 @@
# Протокол звонков (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`
и половина полей в объекте согласования фич добавляются **только** при включённой фиче.
Слать их безусловно — рискованно.
+45 -7
View File
@@ -21,8 +21,21 @@ bool hasMiniAppOption(Set<String>? options) =>
class WebAppLaunch {
final String url;
final String? queryId;
final int botId;
const WebAppLaunch({required this.url, this.queryId});
const WebAppLaunch({required this.url, required this.botId, this.queryId});
}
class WebAppPhone {
final String phone;
final String hash;
final int authDate;
const WebAppPhone({
required this.phone,
required this.hash,
required this.authDate,
});
}
class ExternalCallbackResult {
@@ -52,12 +65,12 @@ class ExternalCallbackResult {
}
return null;
}
}
static int? _asInt(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
return int.tryParse(value?.toString() ?? '');
}
int? _asInt(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
return int.tryParse(value?.toString() ?? '');
}
class WebAppModule {
@@ -93,7 +106,32 @@ class WebAppModule {
throw const WebAppUnavailable('Сервер не вернул адрес приложения');
}
final queryId = (data is Map) ? data['query_id']?.toString() : null;
return WebAppLaunch(url: url, queryId: queryId);
return WebAppLaunch(url: url, botId: botId, queryId: queryId);
}
Future<WebAppPhone> requestPhone(int botId) async {
if (_api.state != SessionState.online) {
throw const WebAppUnavailable('Нет соединения с сервером');
}
final packet = await _api.sendRequest(Opcode.phoneWebappShare, {
'botId': botId,
});
final data = packet.payload;
if (!packet.isOk || data is! Map) {
throw const WebAppUnavailable('Не удалось передать номер телефона');
}
final phone = data['phone']?.toString();
final hash = data['hash']?.toString();
final authDate = _asInt(data['authDate'] ?? data['auth_date']);
if (phone == null ||
phone.isEmpty ||
hash == null ||
hash.isEmpty ||
authDate == null ||
authDate == 0) {
throw const WebAppUnavailable('Сервер не вернул номер телефона');
}
return WebAppPhone(phone: phone, hash: hash, authDate: authDate);
}
Future<WebAppLaunch> fetchSferum() async {
+2
View File
@@ -29,6 +29,7 @@ abstract class Opcode {
static const int authLoginRestorePassword = 101; // Восстановление пароля
static const int auth2faDetails = 104; // Детали 2FA
static const int externalCallback = 105; // Внешний коллбэк
static const int phoneWebappShare = 106;
static const int authValidatePassword = 107; // Валидация пароля
static const int authValidateHint = 108; // Валидация подсказки пароля
static const int authVerifyEmail = 109; // Верификация email
@@ -246,6 +247,7 @@ abstract class Opcode {
authLoginRestorePassword: 'AUTH_LOGIN_RESTORE_PASSWORD',
auth2faDetails: 'AUTH_2FA_DETAILS',
externalCallback: 'EXTERNAL_CALLBACK',
phoneWebappShare: 'PHONE_WEBAPP_SHARE',
authValidatePassword: 'AUTH_VALIDATE_PASSWORD',
authValidateHint: 'AUTH_VALIDATE_HINT',
authVerifyEmail: 'AUTH_VERIFY_EMAIL',
+125 -1
View File
@@ -221,7 +221,7 @@ class AppDatabase {
await _migrateLegacyDb(target);
return openDatabase(
target,
version: 21,
version: 22,
onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'),
onCreate: (db, _) => _createTables(db),
onUpgrade: (db, oldVersion, newVersion) async {
@@ -349,6 +349,10 @@ class AppDatabase {
'TEXT',
);
}
if (oldVersion < 22) {
await db.execute(_webAppStorageSchema);
await db.execute(_webAppBiometrySchema);
}
},
);
}
@@ -375,6 +379,8 @@ class AppDatabase {
await db.execute(_contactsSchema);
await db.execute(_messagesSchema);
await db.execute(_chatParticipantsSchema);
await db.execute(_webAppStorageSchema);
await db.execute(_webAppBiometrySchema);
await _createIndexes(db);
await _createChatParticipantsIndex(db);
}
@@ -535,6 +541,26 @@ class AppDatabase {
)
''';
static const _webAppStorageSchema = '''
CREATE TABLE webapp_storage (
account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
bot_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (account_id, bot_id, key)
)
''';
static const _webAppBiometrySchema = '''
CREATE TABLE webapp_biometry (
account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
bot_id INTEGER NOT NULL,
access_requested INTEGER NOT NULL DEFAULT 0,
access_granted INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (account_id, bot_id)
)
''';
static Future<void> saveProfile(
ProfileData profile, {
bool isActive = true,
@@ -634,6 +660,104 @@ class AppDatabase {
};
}
static Future<void> saveWebAppValue(
int accountId,
int botId,
String key,
String value,
) async {
final db = await _instance;
await db.insert('webapp_storage', {
'account_id': accountId,
'bot_id': botId,
'key': key,
'value': value,
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
static Future<String?> getWebAppValue(
int accountId,
int botId,
String key,
) async {
final db = await _instance;
final rows = await db.query(
'webapp_storage',
where: 'account_id = ? AND bot_id = ? AND key = ?',
whereArgs: [accountId, botId, key],
limit: 1,
);
if (rows.isEmpty) return null;
return rows.first['value'] as String;
}
static Future<void> removeWebAppValue(
int accountId,
int botId,
String key,
) async {
final db = await _instance;
await db.delete(
'webapp_storage',
where: 'account_id = ? AND bot_id = ? AND key = ?',
whereArgs: [accountId, botId, key],
);
}
static Future<void> clearWebAppValues(int accountId, int botId) async {
final db = await _instance;
await db.delete(
'webapp_storage',
where: 'account_id = ? AND bot_id = ?',
whereArgs: [accountId, botId],
);
}
static Future<int> countWebAppValues(int accountId, int botId) async {
final db = await _instance;
final rows = await db.rawQuery(
'SELECT COUNT(*) AS total FROM webapp_storage '
'WHERE account_id = ? AND bot_id = ?',
[accountId, botId],
);
if (rows.isEmpty) return 0;
return (rows.first['total'] as num?)?.toInt() ?? 0;
}
static Future<(bool, bool)> getWebAppBiometryAccess(
int accountId,
int botId,
) async {
final db = await _instance;
final rows = await db.query(
'webapp_biometry',
where: 'account_id = ? AND bot_id = ?',
whereArgs: [accountId, botId],
limit: 1,
);
if (rows.isEmpty) return (false, false);
final row = rows.first;
return (
(row['access_requested'] as int? ?? 0) != 0,
(row['access_granted'] as int? ?? 0) != 0,
);
}
static Future<void> setWebAppBiometryAccess(
int accountId,
int botId, {
required bool requested,
required bool granted,
}) async {
final db = await _instance;
await db.insert('webapp_biometry', {
'account_id': accountId,
'bot_id': botId,
'access_requested': requested ? 1 : 0,
'access_granted': granted ? 1 : 0,
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
static Future<void> savePrivacyConfig(
int accountId,
String jsonConfig,
+5
View File
@@ -22,6 +22,11 @@ class TokenStorage {
await _secure.delete(key: key);
}
static Future<List<String>> secureKeysWithPrefix(String prefix) async {
final all = await _secure.readAll();
return all.keys.where((key) => key.startsWith(prefix)).toList();
}
static Future<void> saveToken(String token, int accountId) async {
await _secure.write(key: '$_tokenPrefix$accountId', value: token);
}
+123
View File
@@ -0,0 +1,123 @@
import 'app_database.dart';
import 'token_storage.dart';
enum WebAppStorageBackend { device, secure }
class WebAppStorage {
static const int deviceKeyLimit = 512;
static const int secureKeyLimit = 128;
static String _securePrefix(int accountId, int botId) =>
'webapp_ss_${accountId}_${botId}_';
static String _secureKey(int accountId, int botId, String key) =>
'${_securePrefix(accountId, botId)}$key';
static String _biometryTokenKey(int accountId, int botId) =>
'webapp_bio_${accountId}_$botId';
static Future<String?> read(
int accountId,
int botId,
WebAppStorageBackend backend,
String key,
) {
if (backend == WebAppStorageBackend.secure) {
return TokenStorage.readSecure(_secureKey(accountId, botId, key));
}
return AppDatabase.getWebAppValue(accountId, botId, key);
}
static Future<bool> save(
int accountId,
int botId,
WebAppStorageBackend backend,
String key,
String value,
) async {
if (await read(accountId, botId, backend, key) == null &&
await _count(accountId, botId, backend) >= _limit(backend)) {
return false;
}
if (backend == WebAppStorageBackend.secure) {
await TokenStorage.writeSecure(_secureKey(accountId, botId, key), value);
} else {
await AppDatabase.saveWebAppValue(accountId, botId, key, value);
}
return true;
}
static Future<void> remove(
int accountId,
int botId,
WebAppStorageBackend backend,
String key,
) async {
if (backend == WebAppStorageBackend.secure) {
await TokenStorage.deleteSecure(_secureKey(accountId, botId, key));
return;
}
await AppDatabase.removeWebAppValue(accountId, botId, key);
}
static Future<void> clear(
int accountId,
int botId,
WebAppStorageBackend backend,
) async {
if (backend == WebAppStorageBackend.secure) {
final keys = await TokenStorage.secureKeysWithPrefix(
_securePrefix(accountId, botId),
);
for (final key in keys) {
await TokenStorage.deleteSecure(key);
}
return;
}
await AppDatabase.clearWebAppValues(accountId, botId);
}
static Future<String?> biometryToken(int accountId, int botId) =>
TokenStorage.readSecure(_biometryTokenKey(accountId, botId));
static Future<void> saveBiometryToken(
int accountId,
int botId,
String token,
) => TokenStorage.writeSecure(_biometryTokenKey(accountId, botId), token);
static Future<void> removeBiometryToken(int accountId, int botId) =>
TokenStorage.deleteSecure(_biometryTokenKey(accountId, botId));
static Future<(bool, bool)> biometryAccess(int accountId, int botId) =>
AppDatabase.getWebAppBiometryAccess(accountId, botId);
static Future<void> setBiometryAccess(
int accountId,
int botId, {
required bool requested,
required bool granted,
}) => AppDatabase.setWebAppBiometryAccess(
accountId,
botId,
requested: requested,
granted: granted,
);
static int _limit(WebAppStorageBackend backend) =>
backend == WebAppStorageBackend.secure ? secureKeyLimit : deviceKeyLimit;
static Future<int> _count(
int accountId,
int botId,
WebAppStorageBackend backend,
) async {
if (backend == WebAppStorageBackend.secure) {
final keys = await TokenStorage.secureKeysWithPrefix(
_securePrefix(accountId, botId),
);
return keys.length;
}
return AppDatabase.countWebAppValues(accountId, botId);
}
}
@@ -1,13 +1,10 @@
import 'dart:convert';
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../backend/modules/webapp.dart' show WebAppLaunch;
import '../../../core/utils/logger.dart';
import '../../../main.dart' show api, webAppModule, digitalIdModule;
import '../../../main.dart' show digitalIdModule, webAppModule;
import '../webapp/web_app_screen.dart';
Future<void> resetDigitalIdWebData() async {
@@ -24,269 +21,6 @@ Future<void> resetDigitalIdSession() async {
} catch (_) {}
}
const String _kBridge = r'''
(function(){
if (!/(^|\.)max\.ru$/i.test(location.hostname)) { return; }
var sawOpenLink = false;
var DBG = !!window.__KOMET_DID_DEBUG;
function log(m){ try { console.log('[BRIDGE] ' + m); } catch(e){} }
try { log('UA ' + navigator.userAgent); } catch(e){}
var lastTouch = 0;
var lastExternalOpen = 0;
try {
['touchstart','pointerdown','mousedown','click'].forEach(function(ev){
document.addEventListener(ev, function(){ lastTouch = Date.now(); }, true);
});
} catch(e){}
if (DBG) {
try {
var origFetch = window.fetch;
window.fetch = function(){
var u, method = 'GET';
try {
if (typeof arguments[0] === 'string') { u = arguments[0]; }
else if (arguments[0]) { u = arguments[0].url; method = arguments[0].method || method; }
if (arguments[1] && arguments[1].method) method = arguments[1].method;
} catch(e){}
var watched = ('' + u).indexOf('ext-api') >= 0 || ('' + u).indexOf('digital-id') >= 0 || ('' + u).indexOf('oneme.ru') >= 0;
var path = ('' + u).split('?')[0];
if (watched) {
var body = '';
try {
if (arguments[1] && typeof arguments[1].body === 'string') body = arguments[1].body.slice(0, 300);
else if (arguments[0] && arguments[0]._bodyInit && typeof arguments[0]._bodyInit === 'string') body = arguments[0]._bodyInit.slice(0, 300);
} catch(e){}
var auth = '';
try {
var h = (arguments[1] && arguments[1].headers) || (arguments[0] && arguments[0].headers);
if (h) {
if (typeof h.get === 'function') auth = h.get('Authorization') || h.get('authorization') || '';
else if (typeof h.forEach === 'function') { h.forEach(function(v,k){ if (('' + k).toLowerCase() === 'authorization') auth = v; }); }
else auth = h['Authorization'] || h['authorization'] || '';
}
} catch(e){}
log('FETCH> ' + method + ' ' + path + (auth ? ' AUTH=[' + ('' + auth).slice(0, 70) + ']' : ' AUTH=none') + (body ? ' body=' + body : ''));
}
return origFetch.apply(this, arguments).then(function(r){
if (watched) {
try { r.clone().text().then(function(t){ log('FETCH< ' + r.status + ' ' + path + ' :: ' + t.slice(0, 500)); }); } catch(e){}
}
return r;
}).catch(function(err){ if (watched) log('FETCH ERR ' + path + ' ' + err); throw err; });
};
} catch(e){}
}
function ssKey(k){ return 'komet_did_ss_' + k; }
function userId(){
try {
var h = decodeURIComponent(decodeURIComponent(location.hash || ''));
var m = h.match(/"id"\s*:\s*(\d+)/);
if (m) return m[1];
} catch(e){}
try {
var m2 = (location.hash || '').match(/id\W{1,8}?(\d{4,})/);
if (m2) return m2[1];
} catch(e){}
return 'anon';
}
try {
var uid = userId();
var prev = localStorage.getItem('komet_did_owner');
log('owner check uid=' + uid + ' prev=' + prev);
// Wipe ТОЛЬКО при реальной смене аккаунта (реальный id -> другой реальный id).
// Никогда не чистим, когда id не удалось определить ('anon') или его не было —
// иначе флапающий userId() стирает device-binding на каждом перезапуске (петля).
if (uid !== 'anon' && prev && prev !== 'anon' && prev !== uid) {
log('owner switch ' + prev + ' -> ' + uid + ' :: wiping did storage');
try { localStorage.clear(); } catch(e){}
try { sessionStorage.clear(); } catch(e){}
try {
if (window.indexedDB && indexedDB.databases) {
indexedDB.databases().then(function(dbs){
(dbs || []).forEach(function(db){ try { indexedDB.deleteDatabase(db.name); } catch(e){} });
});
}
} catch(e){}
}
if (uid !== 'anon') { localStorage.setItem('komet_did_owner', uid); }
} catch(e){}
var pending = [];
function chan(priv){
var pub = (window.WebApp && typeof window.WebApp.sendEvent === 'function') ? window.WebApp : null;
var prv = (window.PrivateWebApp && typeof window.PrivateWebApp.sendEvent === 'function') ? window.PrivateWebApp : null;
if (priv) return prv;
return pub || prv;
}
function flush(){
if (!pending.length) return;
var keep = [];
for (var i = 0; i < pending.length; i++) {
var it = pending[i];
var t = chan(it[2]);
if (!t) { keep.push(it); continue; }
try { t.sendEvent(it[0], it[1]); } catch(e){ log('sendEvent err ' + e); keep.push(it); }
}
pending = keep;
}
setInterval(flush, 50);
function reply(type, data, priv){
var payload;
try { payload = JSON.stringify(data == null ? {} : data); } catch(e){ payload = '{}'; }
log('reply ' + (priv ? '[priv] ' : '') + type + ' ' + payload);
pending.push([type, payload, !!priv]);
setTimeout(flush, 0);
}
function bioToken(){
try {
var k = 'komet_did_bio_token';
var v = localStorage.getItem(k);
if (!v) {
v = '';
for (var i = 0; i < 32; i++) v += Math.floor(Math.random() * 16).toString(16);
localStorage.setItem(k, v);
}
return v;
} catch(e){ return 'komet-did-fallback-token'; }
}
function tokenSaved(){
try { return !!localStorage.getItem('komet_did_bio_token'); } catch(e){ return false; }
}
function didDeviceId(){
// Реальный device_id (как MAX: ANDROID_ID/persisted). Приоритет — прокинутый
// из Dart стабильный per-install id (переопределяемый под реальный id из MAX);
// фолбэк — стабильный 16-hex в localStorage (формат ANDROID_ID).
try { if (window.__KOMET_DID_DEVICE_ID) return '' + window.__KOMET_DID_DEVICE_ID; } catch(e){}
try {
var k = 'komet_did_device_id';
var v = localStorage.getItem(k);
if (!v) {
v = '';
for (var i = 0; i < 16; i++) v += Math.floor(Math.random() * 16).toString(16);
localStorage.setItem(k, v);
}
return v;
} catch(e){ return 'komet-device'; }
}
var NO_REPLY = {
WebAppReady: 1, WebAppSetupBackButton: 1, WebAppBackButtonPressed: 1,
WebAppUrlInterceptor: 1, WebAppSetupClosingBehavior: 1, WebAppStat: 1,
WebAppHapticFeedbackImpact: 1, WebAppHapticFeedbackNotification: 1,
WebAppHapticFeedbackSelectionChange: 1
};
function handle(type, dataStr, priv){
var data = {};
try { data = JSON.parse(dataStr || '{}'); } catch(e){}
log('recv ' + (priv ? '[priv] ' : '') + type + ' ' + dataStr);
var requestId = data.requestId;
switch (type) {
case 'WebAppBiometryGetInfo':
reply(type, {
requestId: requestId, available: true,
accessRequested: tokenSaved(), accessGranted: tokenSaved(),
access_requested: tokenSaved(), access_granted: tokenSaved(),
tokenSaved: tokenSaved(), token_saved: tokenSaved(),
deviceId: didDeviceId(), device_id: didDeviceId(),
type: ['face']
});
return;
case 'WebAppBiometryRequestAccess':
reply(type, { requestId: requestId, granted: true, access_granted: true, accessGranted: true, status: 'authorized' });
return;
case 'WebAppBiometryAuthenticate':
case 'WebAppBiometryRequestAuth':
reply(type, { requestId: requestId, token: bioToken(), success: true, status: 'authorized' });
return;
case 'WebAppBiometryUpdateToken':
case 'WebAppBiometryUpdateBiometricToken':
reply(type, { requestId: requestId, success: true, status: 'updated' });
return;
case 'WebAppGetLaunchContext':
reply(type, { requestId: requestId, entryPoint: 'default' });
return;
case 'WebAppGetViewportSize':
reply(type, { requestId: requestId, height: window.innerHeight, width: window.innerWidth, isStateStable: true });
return;
case 'WebAppSetupScreenCaptureBehavior':
reply(type, { requestId: requestId, isScreenCaptureEnabled: !!data.isScreenCaptureEnabled });
return;
case 'WebAppRequestPhone':
reply(type, { requestId: requestId, error: { code: 'client.request_phone.user_refused_provide_phone_number' } });
return;
case 'WebAppVerifyMobileId':
(function(){
try {
window.flutter_inappwebview.callHandler('verifyMobileId', data.url).then(function(res){
if (res && typeof res.statusCode !== 'undefined') {
reply(type, { requestId: requestId, statusCode: res.statusCode, headers: res.headers || {}, data: res.data || '' }, true);
} else {
reply(type, { requestId: requestId, error: { code: 'client.verify_mobile_id.request_failed' } }, true);
}
}).catch(function(e){
reply(type, { requestId: requestId, error: { code: 'client.verify_mobile_id.request_failed' } }, true);
});
} catch(e){
reply(type, { requestId: requestId, error: { code: 'client.verify_mobile_id.json_decode_error' } }, true);
}
})();
return;
case 'WebAppOpenLink':
sawOpenLink = true;
var now = Date.now();
if (data && data.url && (now - lastTouch < 8000) && (now - lastExternalOpen > 15000)) {
lastExternalOpen = now;
try { window.flutter_inappwebview.callHandler('openExternal', data.url); } catch(e){}
} else {
log('OpenLink dropped (gesture ' + (now - lastTouch) + 'ms, sinceOpen ' + (now - lastExternalOpen) + 'ms)');
}
return;
case 'WebAppClose':
return;
default:
if (type.indexOf('SecureStorage') >= 0 || type.indexOf('DeviceStorage') >= 0) {
var backend = (type.indexOf('SecureStorage') >= 0) ? 's' : 'd';
var pfx = 'komet_did_' + backend + '_';
var key = data.key;
if (/Set|Save|Put/i.test(type)) {
try { localStorage.setItem(pfx + key, JSON.stringify(data.value !== undefined ? data.value : null)); } catch(e){}
reply(type, { requestId: requestId, status: 'saved' });
} else if (/Clear/i.test(type)) {
try {
var rm = [];
for (var i = 0; i < localStorage.length; i++) { var k = localStorage.key(i); if (k && k.indexOf(pfx) === 0) rm.push(k); }
for (var j = 0; j < rm.length; j++) localStorage.removeItem(rm[j]);
} catch(e){}
reply(type, { requestId: requestId, status: 'cleared' });
} else if (/Remove|Delete/i.test(type)) {
try { localStorage.removeItem(pfx + key); } catch(e){}
reply(type, { requestId: requestId, status: 'removed' });
} else {
var val = null;
try {
var raw = localStorage.getItem(pfx + key);
val = (raw == null) ? null : JSON.parse(raw);
} catch(e){}
reply(type, { requestId: requestId, key: key, value: val });
}
return;
}
if (NO_REPLY[type]) return;
reply(type, { requestId: requestId });
}
}
try {
window.WebViewHandler = {
postEvent: function(type, dataStr){ try { handle(type, dataStr, false); } catch(e){} },
resolveShare: function(){}
};
window.PrivateWebViewHandler = {
postEvent: function(type, dataStr){ try { handle(type, dataStr, true); } catch(e){} },
resolveShare: function(){}
};
if (!window.AndroidPerf) window.AndroidPerf = { trackFcp: function(){} };
} catch(e){}
})();
''';
class DigitalIdWebScreen extends StatelessWidget {
final WebAppLaunch? initialLaunch;
@@ -294,54 +28,12 @@ class DigitalIdWebScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Тот же device_id, что уходит в handshake (опкод 6, sessionInit),
// с учётом спуфинга — сервер привязывает Цифровой ID к device'у сессии.
final deviceId = api.deviceId ?? '';
return WebAppScreen(
title: 'Цифровой ID',
preferSystemUserAgent: true,
privateChannel: true,
mobileIdVerifier: digitalIdModule.fetchMobileIdVerification,
loader: () async => initialLaunch ?? await webAppModule.fetchDigitalId(),
extraUserScripts: [
UserScript(
source:
'window.__KOMET_DID_DEBUG=$kDebugMode; window.__KOMET_DID_DEVICE_ID=${jsonEncode(deviceId)};',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
),
UserScript(
source: _kBridge,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
),
],
onWebViewCreated: (controller) {
controller.addJavaScriptHandler(
handlerName: 'closeWebApp',
callback: (args) {
if (context.mounted) Navigator.of(context).maybePop();
return null;
},
);
controller.addJavaScriptHandler(
handlerName: 'openExternal',
callback: (args) async {
final url = args.isNotEmpty ? args.first?.toString() : null;
final uri = (url != null && url.isNotEmpty) ? Uri.tryParse(url) : null;
if (uri != null) {
try {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {}
}
return null;
},
);
controller.addJavaScriptHandler(
handlerName: 'verifyMobileId',
callback: (args) async {
final url = args.isNotEmpty ? args.first?.toString() : null;
if (url == null || url.isEmpty) return null;
return digitalIdModule.fetchMobileIdVerification(url);
},
);
},
onExternalCallback: webAppModule.handleExternalCallback,
onConsoleMessage: (controller, consoleMessage) {
final msg = '[DID] ${consoleMessage.message}';
@@ -33,6 +33,7 @@ import '../../../core/config/app_digital_id_mode.dart';
import '../../../core/utils/webview_support.dart';
import '../digital_id/digital_id_screen.dart';
import '../digital_id/digital_id_web_screen.dart';
import '../webapp/web_app_bridge.dart';
import '../webapp/web_app_screen.dart';
import 'cloud_storage_screen.dart';
import 'customization_section.dart';
@@ -428,6 +429,7 @@ class _SettingsTabState extends State<SettingsTab> with SpectrumSurface {
MaterialPageRoute(
builder: (context) => WebAppScreen(
title: 'Сферум',
entryPoint: WebAppEntryPoint.settings,
loader: () => webAppModule.fetchSferum(),
),
),
@@ -7,6 +7,7 @@ import '../../../core/utils/webview_support.dart';
import '../../../l10n/app_localizations.dart';
import '../../../main.dart' show webAppModule;
import '../../widgets/custom_notification.dart';
import 'web_app_bridge.dart';
import 'web_app_screen.dart';
Future<void> openMiniApp(
@@ -14,6 +15,7 @@ Future<void> openMiniApp(
required int botId,
required String title,
int? chatId,
String entryPoint = WebAppEntryPoint.webApp,
}) async {
if (botId <= 0) return;
Haptics.tap();
@@ -23,6 +25,7 @@ Future<void> openMiniApp(
MaterialPageRoute(
builder: (_) => WebAppScreen(
title: title,
entryPoint: entryPoint,
loader: () => webAppModule.fetchLaunch(botId, chatId: chatId),
),
),
@@ -0,0 +1,763 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/widgets.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:share_plus/share_plus.dart';
import '../../../core/storage/token_storage.dart';
import '../../../core/storage/webapp_storage.dart';
import '../../../core/utils/haptics.dart';
import '../../../core/utils/link_opener.dart';
import '../../../core/utils/media_saver.dart';
import '../../../main.dart' show api, messagesModule, webAppModule;
import '../../widgets/confirm_dialog.dart';
import '../chats/chat_list_screen.dart' show openForwardScreen;
import '../profile/web_qr_scan_screen.dart';
abstract class WebAppEntryPoint {
static const String webApp = 'web_app';
static const String url = 'url';
static const String startButton = 'start_button';
static const String inlineButton = 'inline_button';
static const String chatProfile = 'chat_profile';
static const String externalCallback = 'external_callback';
static const String settings = 'settings';
static const String fromSearch = 'from_search';
}
typedef WebAppMobileIdVerifier = Future<Map<String, dynamic>?> Function(
String url,
);
typedef WebAppEmitter =
void Function(String method, String payload, bool private);
const Duration _gestureWindow = Duration(milliseconds: 3000);
const Set<String> _gestureGated = {
'WebAppMaxShare',
'WebAppShare',
'WebAppDownloadFile',
'WebAppOpenLink',
'WebAppOpenMaxLink',
};
const Map<String, String> _methodSlugs = {
'WebAppReady': 'ready',
'WebAppClose': 'close',
'WebAppSetupBackButton': 'setup_back_button',
'WebAppSetupClosingBehavior': 'setup_closing_behaviour',
'WebAppBackButtonPressed': 'back_button_pressed',
'WebAppSetupScreenCaptureBehavior': 'setup_screen_capture_behavior',
'WebAppGetLaunchContext': 'launch_context',
'WebAppGetViewportSize': 'get_viewport_size',
'WebAppRequestPhone': 'request_phone',
'WebAppOpenLink': 'open_link',
'WebAppOpenMaxLink': 'open_max_link',
'WebAppShare': 'web_app_share',
'WebAppMaxShare': 'web_app_max_share',
'WebAppDeviceStorageSaveKey': 'device_storage_save_key',
'WebAppDeviceStorageGetKey': 'device_storage_get_key',
'WebAppDeviceStorageClear': 'device_storage_clear',
'WebAppSecureStorageSaveKey': 'secure_storage_save_key',
'WebAppSecureStorageGetKey': 'secure_storage_get_key',
'WebAppSecureStorageClear': 'secure_storage_clear',
'WebAppBiometryGetInfo': 'biometry_get_info',
'WebAppBiometryRequestAccess': 'biometry_request_access',
'WebAppBiometryRequestAuth': 'biometry_request_auth',
'WebAppBiometryUpdateToken': 'biometry_update_token',
'WebAppBiometryOpenSettings': 'biometry_open_settings',
'WebAppHapticFeedbackImpact': 'haptic_feedback_impact',
'WebAppHapticFeedbackNotification': 'haptic_feedback_notification',
'WebAppHapticFeedbackSelectionChange': 'haptic_feedback_selection_change',
'WebAppDownloadFile': 'download_file',
'WebAppOpenCodeReader': 'open_code_reader',
'WebAppChangeScreenBrightness': 'change_screen_brightness',
'WebAppNfcGetInfo': 'nfc_get_info',
'WebAppNfcEmulateNfcTag': 'nfc_emulate_nfc_tag',
'WebAppNfcOpenSystemSettings': 'nfc_open_system_settings',
'WebAppVerifyMobileId': 'verify_mobile_id',
};
const Set<String> _silentMethods = {
'WebAppReady',
'WebAppStat',
'WebAppUrlInterceptor',
'WebAppBackButtonPressed',
};
const String _shim = r'''
(function(){
if (window.__kometWebAppBridge) { return; }
window.__kometWebAppBridge = true;
var pending = [];
function target(priv){
var box = priv ? window.PrivateWebApp : window.WebApp;
return (box && typeof box.sendEvent === 'function') ? box : null;
}
function flush(){
if (!pending.length) { return; }
var keep = [];
for (var i = 0; i < pending.length; i++) {
var item = pending[i];
var box = target(item[2]);
if (!box) { keep.push(item); continue; }
try { box.sendEvent(item[0], item[1]); } catch (e) { keep.push(item); }
}
pending = keep;
}
setInterval(flush, 50);
window.__kometWebAppDeliver = function(name, data, priv){
pending.push([name, data, !!priv]);
flush();
};
function post(name, data, priv){
try {
window.flutter_inappwebview.callHandler('webAppEvent', name, data, priv);
} catch (e) {}
}
var lastGesture = 0;
function gesture(){
var now = Date.now();
if (now - lastGesture < 400) { return; }
lastGesture = now;
try { window.flutter_inappwebview.callHandler('webAppGesture'); } catch (e) {}
}
['touchstart', 'pointerdown', 'mousedown', 'click'].forEach(function(name){
try { document.addEventListener(name, gesture, true); } catch (e) {}
});
window.WebViewHandler = {
postEvent: function(name, data){ post(name, data, false); },
resolveShare: function(requestId, bytes, mimeType, fileName){
try {
window.flutter_inappwebview.callHandler(
'webAppResolveShare', requestId, mimeType, fileName);
} catch (e) {}
}
};
window.PrivateWebViewHandler = {
postEvent: function(name, data){ post(name, data, true); },
resolveShare: function(){}
};
if (!window.AndroidPerf) {
window.AndroidPerf = { trackFcp: function(){} };
}
})();
''';
class WebAppBridge {
WebAppBridge({
required this.botId,
required this.entryPoint,
required this.contextResolver,
required this.viewportResolver,
required this.onClose,
this.privateChannel = false,
this.mobileIdVerifier,
this.emitter,
});
final int botId;
final String entryPoint;
final BuildContext? Function() contextResolver;
final Size Function() viewportResolver;
final VoidCallback onClose;
final bool privateChannel;
final WebAppMobileIdVerifier? mobileIdVerifier;
final WebAppEmitter? emitter;
InAppWebViewController? _controller;
DateTime? _lastGesture;
bool _customBackButton = false;
bool _closeConfirmation = false;
bool get handlesBackButton => _customBackButton;
bool get needsCloseConfirmation => _closeConfirmation;
UserScript get userScript => UserScript(
source: _shim,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
);
void attach(InAppWebViewController controller) {
_controller = controller;
controller.addJavaScriptHandler(
handlerName: 'webAppEvent',
callback: (args) {
final name = args.isNotEmpty ? args[0]?.toString() : null;
if (name == null || name.isEmpty) return null;
final raw = args.length > 1 ? args[1]?.toString() : null;
final private = args.length > 2 && args[2] == true;
handleEvent(name, raw, private);
return null;
},
);
controller.addJavaScriptHandler(
handlerName: 'webAppGesture',
callback: (args) {
registerGesture();
return null;
},
);
controller.addJavaScriptHandler(
handlerName: 'webAppResolveShare',
callback: (args) => null,
);
}
void registerGesture() => _lastGesture = DateTime.now();
void notifyBackPressed() => _send('WebAppBackButtonPressed', const {});
void dispose() => _controller = null;
void _send(String method, Map<String, dynamic> data, {bool private = false}) {
final payload = jsonEncode(data);
final custom = emitter;
if (custom != null) {
custom(method, payload, private);
return;
}
final controller = _controller;
if (controller == null) return;
controller.evaluateJavascript(
source:
'window.__kometWebAppDeliver('
'${jsonEncode(method)}, ${jsonEncode(payload)}, $private);',
);
}
void _ok(
String method,
String? requestId,
String status, {
bool private = false,
}) {
_send(method, {
'status': status,
'requestId': ?requestId,
}, private: private);
}
void _fail(
String method,
String? requestId,
String reason, {
bool private = false,
}) {
if (requestId == null) return;
final slug = _methodSlugs[method] ?? 'unsupported_method';
_send(method, {
'requestId': requestId,
'error': {'code': 'client.$slug.$reason'},
}, private: private);
}
Future<void> handleEvent(String method, String? raw, bool private) async {
if (private && !privateChannel) return;
if (_gestureGated.contains(method) && !_hasRecentGesture) return;
Map<String, dynamic> data = const {};
if (raw != null && raw.isNotEmpty) {
try {
final decoded = jsonDecode(raw);
if (decoded is Map) data = Map<String, dynamic>.from(decoded);
} catch (_) {
_fail(method, null, 'json_decode_error', private: private);
return;
}
}
final requestId = data['requestId']?.toString();
switch (method) {
case 'WebAppClose':
onClose();
return;
case 'WebAppSetupBackButton':
_customBackButton = data['isVisible'] == true;
return;
case 'WebAppSetupClosingBehavior':
_closeConfirmation = data['needConfirmation'] == true;
return;
case 'WebAppSetupScreenCaptureBehavior':
_send(method, {
'requestId': ?requestId,
'isScreenCaptureEnabled': data['isScreenCaptureEnabled'] == true,
});
return;
case 'WebAppGetLaunchContext':
_send(method, {
'requestId': ?requestId,
'entryPoint': entryPoint,
});
return;
case 'WebAppGetViewportSize':
final size = viewportResolver();
_send(method, {
'requestId': ?requestId,
'height': size.height.round(),
'width': size.width.round(),
'isStateStable': true,
});
return;
case 'WebAppRequestPhone':
await _requestPhone(method, requestId);
return;
case 'WebAppOpenLink':
case 'WebAppOpenMaxLink':
await _openLink(data['url']?.toString());
return;
case 'WebAppShare':
await _share(method, requestId, data);
return;
case 'WebAppMaxShare':
await _maxShare(method, requestId, data);
return;
case 'WebAppDeviceStorageSaveKey':
case 'WebAppSecureStorageSaveKey':
await _storageSave(method, requestId, data);
return;
case 'WebAppDeviceStorageGetKey':
case 'WebAppSecureStorageGetKey':
await _storageGet(method, requestId, data);
return;
case 'WebAppDeviceStorageClear':
case 'WebAppSecureStorageClear':
await _storageClear(method, requestId);
return;
case 'WebAppBiometryGetInfo':
await _biometryInfo(method, requestId);
return;
case 'WebAppBiometryRequestAccess':
case 'WebAppBiometryRequestAuth':
await _biometryAuth(method, requestId);
return;
case 'WebAppBiometryUpdateToken':
await _biometryUpdateToken(method, requestId, data);
return;
case 'WebAppBiometryOpenSettings':
_ok(method, requestId, 'opened');
return;
case 'WebAppHapticFeedbackImpact':
await _impact(data['impactStyle']?.toString());
_ok(method, requestId, 'impactOccured');
return;
case 'WebAppHapticFeedbackNotification':
await _notification(data['notificationType']?.toString());
_ok(method, requestId, 'notificationOccured');
return;
case 'WebAppHapticFeedbackSelectionChange':
await Haptics.selection();
_ok(method, requestId, 'selectionChanged');
return;
case 'WebAppDownloadFile':
await _downloadFile(method, requestId, data);
return;
case 'WebAppOpenCodeReader':
await _openCodeReader(method, requestId);
return;
case 'WebAppNfcGetInfo':
_send(method, {
'requestId': ?requestId,
'available': false,
'enabled': false,
});
return;
case 'WebAppVerifyMobileId':
await _verifyMobileId(method, requestId, data, private);
return;
case 'WebAppChangeScreenBrightness':
case 'WebAppNfcEmulateNfcTag':
case 'WebAppNfcOpenSystemSettings':
_fail(method, requestId, 'not_supported', private: private);
return;
default:
if (_silentMethods.contains(method)) return;
_fail(method, requestId, 'unsupported_method', private: private);
}
}
bool get _hasRecentGesture {
final last = _lastGesture;
if (last == null) return false;
return DateTime.now().difference(last) < _gestureWindow;
}
Future<void> _requestPhone(String method, String? requestId) async {
final context = contextResolver();
if (context == null) {
_fail(method, requestId, 'request_error');
return;
}
final confirmed = await showConfirmDialog(
context,
title: 'Передать номер телефона?',
message: 'Мини-приложение получит ваш номер телефона.',
confirmLabel: 'Поделиться',
cancelLabel: 'Отклонить',
);
if (!confirmed) {
_fail(method, requestId, 'user_refused_provide_phone_number');
return;
}
try {
final phone = await webAppModule.requestPhone(botId);
_send(method, {
'requestId': ?requestId,
'phone': phone.phone,
'hash': phone.hash,
'authDate': phone.authDate,
});
} catch (_) {
_fail(method, requestId, 'request_error');
}
}
Future<void> _openLink(String? url) async {
if (url == null || url.isEmpty) return;
final context = contextResolver();
if (context == null) return;
await openExternalUrl(context, url);
}
Future<void> _share(
String method,
String? requestId,
Map<String, dynamic> data,
) async {
final text = _shareText(data);
if (text == null) {
_fail(method, requestId, 'invalid_request');
return;
}
try {
final result = await Share.share(text);
_send(method, {
'requestId': ?requestId,
'status': result.status == ShareResultStatus.dismissed
? 'cancelled'
: 'shared',
});
} catch (_) {
_fail(method, requestId, 'invalid_request');
}
}
Future<void> _maxShare(
String method,
String? requestId,
Map<String, dynamic> data,
) async {
final text = _shareText(data);
if (text == null) {
_fail(method, requestId, 'invalid_request');
return;
}
final context = contextResolver();
if (context == null) {
_fail(method, requestId, 'invalid_request');
return;
}
final target = await openForwardScreen(context: context);
if (target == null) {
_send(method, {
'requestId': ?requestId,
'status': 'cancelled',
});
return;
}
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) {
_fail(method, requestId, 'invalid_request');
return;
}
try {
await messagesModule.sendMessage(accountId, target.chatId, text);
_send(method, {
'requestId': ?requestId,
'status': 'shared',
});
} catch (_) {
_fail(method, requestId, 'invalid_request');
}
}
String? _shareText(Map<String, dynamic> data) {
final text = data['text']?.toString();
final link = data['link']?.toString();
final parts = [
if (text != null && text.isNotEmpty) text,
if (link != null && link.isNotEmpty) link,
];
if (parts.isEmpty) return null;
return parts.join('\n');
}
WebAppStorageBackend _backendOf(String method) =>
method.startsWith('WebAppSecure')
? WebAppStorageBackend.secure
: WebAppStorageBackend.device;
Future<void> _storageSave(
String method,
String? requestId,
Map<String, dynamic> data,
) async {
final key = data['key']?.toString();
if (key == null || key.isEmpty) {
_fail(method, requestId, 'invalid_request');
return;
}
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) {
_fail(method, requestId, 'invalid_request');
return;
}
final backend = _backendOf(method);
final value = data['value'];
if (value == null) {
await WebAppStorage.remove(accountId, botId, backend, key);
_ok(method, requestId, 'removed');
return;
}
final saved = await WebAppStorage.save(
accountId,
botId,
backend,
key,
value.toString(),
);
if (!saved) {
_fail(method, requestId, 'too_many_keys');
return;
}
_ok(method, requestId, 'updated');
}
Future<void> _storageGet(
String method,
String? requestId,
Map<String, dynamic> data,
) async {
final key = data['key']?.toString();
if (key == null || key.isEmpty) {
_fail(method, requestId, 'invalid_request');
return;
}
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) {
_fail(method, requestId, 'invalid_request');
return;
}
final value = await WebAppStorage.read(
accountId,
botId,
_backendOf(method),
key,
);
if (value == null) {
_fail(method, requestId, 'not_found');
return;
}
_send(method, {
'requestId': ?requestId,
'key': key,
'value': value,
});
}
Future<void> _storageClear(String method, String? requestId) async {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) {
_fail(method, requestId, 'invalid_request');
return;
}
await WebAppStorage.clear(accountId, botId, _backendOf(method));
_ok(method, requestId, 'cleared');
}
Future<void> _biometryInfo(String method, String? requestId) async {
final accountId = await TokenStorage.getActiveAccountId();
final deviceId = api.deviceId ?? '';
if (accountId == null) {
_send(method, {
'requestId': ?requestId,
'available': false,
'deviceId': deviceId,
});
return;
}
final (requested, granted) = await WebAppStorage.biometryAccess(
accountId,
botId,
);
final token = await WebAppStorage.biometryToken(accountId, botId);
_send(method, {
'requestId': ?requestId,
'available': true,
'type': const ['unknown'],
'accessRequested': requested,
'accessGranted': granted,
'tokenSaved': token != null,
'deviceId': deviceId,
});
}
Future<void> _biometryAuth(String method, String? requestId) async {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) {
_fail(method, requestId, 'access_denied');
return;
}
var token = await WebAppStorage.biometryToken(accountId, botId);
if (token == null) {
token = _randomToken();
await WebAppStorage.saveBiometryToken(accountId, botId, token);
}
await WebAppStorage.setBiometryAccess(
accountId,
botId,
requested: true,
granted: true,
);
_send(method, {
'requestId': ?requestId,
'token': token,
'status': 'authorized',
'granted': true,
'accessGranted': true,
});
}
Future<void> _biometryUpdateToken(
String method,
String? requestId,
Map<String, dynamic> data,
) async {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) {
_fail(method, requestId, 'access_denied');
return;
}
final token = data['token']?.toString();
if (token == null || token.isEmpty) {
await WebAppStorage.removeBiometryToken(accountId, botId);
_ok(method, requestId, 'removed');
return;
}
if (token.length > 1024) {
_fail(method, requestId, 'too_large');
return;
}
await WebAppStorage.saveBiometryToken(accountId, botId, token);
_ok(method, requestId, 'updated');
}
Future<void> _impact(String? style) async {
switch (style) {
case 'heavy':
case 'rigid':
await Haptics.heavy();
return;
case 'medium':
await Haptics.medium();
return;
default:
await Haptics.tap();
}
}
Future<void> _notification(String? type) async {
if (type == 'error') {
await Haptics.error();
return;
}
await Haptics.success();
}
Future<void> _downloadFile(
String method,
String? requestId,
Map<String, dynamic> data,
) async {
final url = data['url']?.toString();
if (url == null || url.isEmpty) {
_fail(method, requestId, 'invalid_request');
return;
}
final rawName = data['file_name']?.toString();
final name = (rawName == null || rawName.isEmpty)
? 'webapp_${DateTime.now().millisecondsSinceEpoch}'
: rawName;
final result = await saveMediaFile(
cacheName: 'webapp_${botId}_${url.hashCode & 0x7fffffff}_$name',
resolveUrl: () async => url,
saveName: name,
kind: SaveMediaKind.file,
);
_send(method, {
'requestId': ?requestId,
'status': result.ok ? 'success' : 'cancelled',
});
}
Future<void> _openCodeReader(String method, String? requestId) async {
final context = contextResolver();
if (context == null) {
_fail(method, requestId, 'not_supported');
return;
}
final value = await Navigator.of(context).push<String>(
PageRouteBuilder(
pageBuilder: (_, _, _) => const WebQrScanScreen(),
transitionsBuilder: (_, animation, _, child) =>
FadeTransition(opacity: animation, child: child),
),
);
if (value == null || value.isEmpty) {
_fail(method, requestId, 'cancelled');
return;
}
_send(method, {
'requestId': ?requestId,
'value': value,
});
}
Future<void> _verifyMobileId(
String method,
String? requestId,
Map<String, dynamic> data,
bool private,
) async {
final verifier = mobileIdVerifier;
final url = data['url']?.toString();
if (verifier == null || url == null || url.isEmpty) {
_fail(method, requestId, 'not_supported', private: private);
return;
}
try {
final result = await verifier(url);
if (result == null) {
_fail(method, requestId, 'request_error', private: private);
return;
}
_send(method, {
'requestId': ?requestId,
'statusCode': result['statusCode'],
'headers': result['headers'] ?? const <String, String>{},
'data': result['data'] ?? '',
}, private: private);
} catch (_) {
_fail(method, requestId, 'request_error', private: private);
}
}
static String _randomToken() {
final random = Random.secure();
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
}
+81 -13
View File
@@ -6,15 +6,21 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/webapp.dart';
import '../../../core/storage/spoofing_service.dart';
import '../../../core/utils/link_opener.dart';
import '../../../main.dart' show api;
import '../../widgets/confirm_dialog.dart';
import '../../widgets/connection_status.dart';
import '../../widgets/error_view.dart';
import '../../widgets/small_spinner.dart';
import '../../widgets/webview_permission_prompt.dart';
import 'web_app_bridge.dart';
class WebAppScreen extends StatefulWidget {
final String title;
final Future<WebAppLaunch> Function() loader;
final String entryPoint;
final bool privateChannel;
final WebAppMobileIdVerifier? mobileIdVerifier;
final List<UserScript>? extraUserScripts;
final void Function(InAppWebViewController controller)? onWebViewCreated;
final void Function(
@@ -38,6 +44,9 @@ class WebAppScreen extends StatefulWidget {
super.key,
required this.title,
required this.loader,
this.entryPoint = WebAppEntryPoint.webApp,
this.privateChannel = false,
this.mobileIdVerifier,
this.extraUserScripts,
this.onWebViewCreated,
this.onConsoleMessage,
@@ -54,10 +63,12 @@ class WebAppScreen extends StatefulWidget {
class _WebAppScreenState extends State<WebAppScreen> {
InAppWebViewController? _controller;
WebAppBridge? _bridge;
WebAppLaunch? _launch;
String? _loadError;
String _userAgent = '';
double _progress = 0;
Size _viewport = Size.zero;
@override
void initState() {
@@ -65,10 +76,18 @@ class _WebAppScreenState extends State<WebAppScreen> {
_load();
}
@override
void dispose() {
_bridge?.dispose();
super.dispose();
}
Future<void> _load() async {
setState(() {
_loadError = null;
_launch = null;
_bridge?.dispose();
_bridge = null;
});
try {
// Тот же UA, что уходит в sessionInit (из handshake-устройства ядра),
@@ -89,19 +108,50 @@ class _WebAppScreenState extends State<WebAppScreen> {
}
final launch = await widget.loader();
if (!mounted) return;
setState(() => _launch = launch);
setState(() {
_launch = launch;
_bridge = _createBridge(launch.botId);
});
} catch (e) {
if (!mounted) return;
setState(() => _loadError = e.toString());
}
}
WebAppBridge _createBridge(int botId) => WebAppBridge(
botId: botId,
entryPoint: widget.entryPoint,
privateChannel: widget.privateChannel,
mobileIdVerifier: widget.mobileIdVerifier,
contextResolver: () => mounted ? context : null,
viewportResolver: () => _viewport,
onClose: _closeFromWebApp,
);
void _closeFromWebApp() {
if (!mounted) return;
Navigator.of(context).maybePop();
}
Future<bool> _handleBack() async {
final bridge = _bridge;
if (bridge != null && bridge.handlesBackButton) {
bridge.notifyBackPressed();
return false;
}
final controller = _controller;
if (controller != null && await controller.canGoBack()) {
await controller.goBack();
return false;
}
if (bridge != null && bridge.needsCloseConfirmation && mounted) {
return showConfirmDialog(
context,
title: widget.title,
message: 'Закрыть мини-приложение?',
confirmLabel: 'Закрыть',
);
}
return true;
}
@@ -185,14 +235,28 @@ class _WebAppScreenState extends State<WebAppScreen> {
return ErrorView(message: _loadError!, onRetry: _load);
}
final launch = _launch;
if (launch == null) {
final bridge = _bridge;
if (launch == null || bridge == null) {
return const Center(child: SmallSpinner(size: 36));
}
return LayoutBuilder(
builder: (context, constraints) {
_viewport = Size(constraints.maxWidth, constraints.maxHeight);
return Listener(
onPointerDown: (_) => bridge.registerGesture(),
child: _buildWebView(launch, bridge),
);
},
);
}
Widget _buildWebView(WebAppLaunch launch, WebAppBridge bridge) {
return InAppWebView(
initialUrlRequest: URLRequest(url: WebUri(launch.url)),
initialUserScripts: widget.extraUserScripts == null
? null
: UnmodifiableListView<UserScript>(widget.extraUserScripts!),
initialUserScripts: UnmodifiableListView<UserScript>([
bridge.userScript,
...?widget.extraUserScripts,
]),
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
domStorageEnabled: true,
@@ -201,24 +265,28 @@ class _WebAppScreenState extends State<WebAppScreen> {
transparentBackground: true,
mediaPlaybackRequiresUserGesture: false,
useHybridComposition: true,
useShouldOverrideUrlLoading:
widget.shouldOverrideUrlLoading != null ||
widget.onExternalCallback != null,
supportMultipleWindows: true,
allowFileAccess: false,
useShouldOverrideUrlLoading: true,
userAgent: _userAgent,
),
onWebViewCreated: (controller) {
_controller = controller;
bridge.attach(controller);
widget.onWebViewCreated?.call(controller);
},
onPermissionRequest: (controller, request) =>
askWebViewPermission(context, request),
onCreateWindow: (controller, action) async {
final url = action.request.url?.toString();
if (url != null && url.isNotEmpty && mounted) {
await openExternalUrl(context, url);
}
return false;
},
onConsoleMessage: widget.onConsoleMessage,
onLoadStart: widget.onLoadStart,
shouldOverrideUrlLoading:
widget.shouldOverrideUrlLoading == null &&
widget.onExternalCallback == null
? null
: _handleNavigation,
shouldOverrideUrlLoading: _handleNavigation,
onProgressChanged: (controller, progress) {
if (!mounted) return;
setState(() => _progress = progress / 100);
+2
View File
@@ -11,6 +11,7 @@ import '../../main.dart';
import '../screens/chats/chat_list_screen.dart';
import '../screens/chats/chat_screen.dart';
import '../screens/contacts/open_contact_profile.dart';
import '../screens/webapp/web_app_bridge.dart';
import '../screens/webapp/web_app_screen.dart';
import 'custom_notification.dart';
import 'sticker_pack_sheet.dart';
@@ -137,6 +138,7 @@ Future<bool> openWebAppForBot(
context,
(_) => WebAppScreen(
title: title,
entryPoint: WebAppEntryPoint.url,
loader: () => webAppModule.fetchLaunch(
botId,
startParam: startParam,
+2
View File
@@ -8,6 +8,7 @@ import 'package:flutter/rendering.dart';
import 'package:komet/main.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../backend/modules/messages.dart';
import '../screens/webapp/web_app_bridge.dart';
import '../screens/webapp/web_app_screen.dart';
import '../../core/config/app_bubble_behavior.dart';
import '../../core/config/app_bubble_shape.dart';
@@ -1444,6 +1445,7 @@ class MessageBubble extends StatelessWidget {
MaterialPageRoute(
builder: (_) => WebAppScreen(
title: button.text,
entryPoint: WebAppEntryPoint.inlineButton,
loader: () => webAppModule.fetchLaunch(
botId,
startParam: startParam,
-288
View File
@@ -1,288 +0,0 @@
# Диплинки MAX
Разобрано из `ru.oneme.app.apk` — MAX 26.24.0 (versionCode 6784, minSdk 26).
Точки входа: `AndroidManifest.xml``one.me.android.deeplink.LinkInterceptorActivity`,
парсер `one.me.link.interceptor.b0.a()`, хелпер `ru.ok.messages.utils.a`,
реестр внутренних маршрутов `iz4` / `nz4` (`DeepLinkRoute`), навигатор `pz4`.
## Что вообще перехватывается
Один активити (`LinkInterceptorActivity`) с двумя intent-filter, `android:autoVerify="true"`:
| Схема | Хост | Путь |
|---|---|---|
| `https` | `max.ru` | `/..*` (минимум один символ после `/`) |
| `http` | `max.ru` | `/..*` |
| `max` | `max.ru` | любой |
`max://…` внутри сразу переписывается в `https://…` (`b0.k()`), так что дальше всё едино.
Важная деталь: `/..*` требует непустой путь, поэтому ссылки вида `https://max.ru/?uid=123`
из браузера в приложение не попадают — только через схему `max://`, где путь не ограничен.
Нормализация ссылки перед разбором (`ru.ok.messages.utils.a.e()`):
- завершающий `/` отбрасывается;
- строка без схемы → дописывается `https://` (**то есть `max.ru/xxx` — валидный диплинк**);
- начинается с `:` или `max://:` → внутренний маршрут (см. ниже);
- начинается с `@` → ник, ссылка не переписывается.
Общий порядок разбора в `b0.a()`: `:auth` → (не готов к работе → `OpenApp` с отложенной ссылкой)
`:current` → внутренний маршрут → корневая ссылка → `:share-self-out` → чужой хост → контентные ссылки.
## Контентные ссылки max.ru
| Ссылка | Что открывает | Результат в коде |
|---|---|---|
| `max.ru``http://max.ru`, `https://max.ru`, `max://max.ru`, `max://max.ru/`) | просто открывает приложение | `DeepLinkData$OpenApp``OpenApp` |
| `max.ru/<username>` | чат/канал/бота по публичной ссылке; если такой чат уже локально есть — открывает его напрямую, иначе резолвит на сервере | `JoinLink``ShowChat` / `ShowContact` / `ConfirmJoin` |
| `max.ru/@<nickname>` | то же, ник ищется среди ссылок известных чатов | → чат либо `UnknownContact` |
| `max.ru/<username>?start=<payload>` | диалог с ботом + автозапуск: отправляется `botStarted` со `startPayload` | `ShowContactDialog(chatId, startPayload, externalCallback)` |
| `max.ru/<username>?startapp=<payload>` | мини-приложение бота (payload обрезается по первому `&`, query из ссылки вычищается) | `StartWebAppLink``OpenWebApp(botId, startParam)` / `ErrorWebAppNotExist` |
| `max.ru/join/<code>` | вступление по приватной инвайт-ссылке | `JoinLink``ConfirmJoin` / `ShowChat` |
| `max.ru/joincall/<code>` | экран входа в звонок по ссылке | `CallJoinLink``ShowJoinCall` |
| `max.ru/stickerset/<id>` | стикерпак; `id` берётся до первого `-` | `StickerSet``ShowStickerSet` |
| `max.ru/<username>/<messageId>` | чат на конкретном сообщении (второй сегмент должен быть числом) | `MessagestLink``ShowChat(chatId, messageId)` |
| `max.ru/c/<chatId>/<messageId>` | сообщение/пост в чате по числовым id | `MessagestLink``ShowChat` |
| `max.ru/:folder?id=<folderId>` | список чатов в папке | `FolderChatList``OpenChatListInFolder`, иначе `UnknownFolderError` |
| `max.ru/?uid=<userId>` | контакт/диалог по id пользователя (ищется локально) | `DeepLinkData(contactId)` |
| `max.ru/?cid=<chatId>` | чат по серверному id (ищется локально) | `DeepLinkData(chatId)` |
| `max.ru/:auth/<...>` | подтверждение веб-логина по QR; путь принудительно урезается до `https://max.ru/:auth` | маршрут `:auth` |
| `max.ru/:current` | остаётся на текущем экране (для внешних колбэков) | `OpenCurrent` |
| `https://max.ru/:share-self-out` | системный шэринг своей инвайт-ссылки | `OpenExternalSharingToInvite` |
| `max.ru/:share?text=<text>` | шэринг текста внутрь приложения (выбор чата) | маршрут `:share` |
| любой другой хост | открывается во внешнем браузере | `OpenBrowser` |
Ошибочные ветки: `ErrorBrokenLink`, `ErrorPrivateChat`, `ErrorPrivateChannel`,
`ErrorMessageNotFounded`, `ErrorPostNotFounded`, `ErrorWebAppNotExist`,
`ContentLevelError`, `ItsYou` (ссылка на себя), `ShowContactRemoved`.
Отдельный флаг: `?externalCallback=1` в любой ссылке — результат прокидывается обратно
как внешний колбэк (`b0.d()`), плюс есть маршрут `:external_callback`.
Параметры `mt_*` (myTracker) вычищаются из ссылки до разбора.
## Внутренние маршруты (`:route`)
Полноценная часть диплинк-системы: 137 маршрутов, объявленных как
`DeepLinkRoute(uri, constraints, requiredParams, supportRoot)`. Матчинг — по пути без
ведущего `/`, регистронезависимо. Обязательные параметры передаются как query:
`max.ru/:profile?id=123&type=CHAT`. Если хотя бы одного обязательного параметра нет —
`Error`, экран не откроется.
Помимо http(s)/`max://` эти же маршруты дергаются изнутри приложения (`pz4.b/d`) и из пушей.
Пометки в таблице:
- **только внутри приложения** — маршрут исключён из обработки внешних ссылок (`constraints` содержит `k2b.g`);
- **без авторизации** — доступен без активной сессии, иначе редирект на `:login`;
- **не может быть корневым экраном**`supportRoot = false`.
| Маршрут | Обязательные параметры | Примечания |
|---|---|---|
| `:app-update/force` | — | без авторизации |
| `:attach/viewer` | `chat_id`, `attach_id`, `msg_id` | |
| `:auth` | — | |
| `:call-active` | — | |
| `:call-admin-settings` | — | |
| `:call-admin-waiting-room` | — | |
| `:call-chat` | `chat_id` | только внутри приложения |
| `:call-contact` | — | |
| `:call-debug-menu` | — | |
| `:call-history-info` | — | |
| `:call-incoming` | `chat_id`, `call_name` | |
| `:call-join-link` | `link` | только внутри приложения |
| `:call-join-preview` | `link` | |
| `:call-list` | — | |
| `:call-opponents-list` | — | |
| `:call-pip` | — | |
| `:call-presettings` | `chat_id` | |
| `:call-rate` | `call_id`, `is_group`, `is_video` | |
| `:call-user` | `opponent_id` | только внутри приложения |
| `:calls-history` | — | |
| `:chat-list` | — | |
| `:chat/add-icon` | — | |
| `:chats` | `id`, `type` | |
| `:chats-search` | — | |
| `:chats/callshare` | — | |
| `:chats/forward` | `messages_ids` | |
| `:chats/share` | — | |
| `:comments` | `parent_chat_server_id`, `parent_message_server_id` | |
| `:complaint` | — | |
| `:contact-list` | — | |
| `:contact-list/create-contact` | — | |
| `:contact-list/share-invite` | — | |
| `:contact/add/dialog` | `contact_id` | |
| `:contacts-picker` | `request_code` | |
| `:dialogs/file-download-warning` | `chat_id`, `message_id`, `file_id`, `file_name`, `file_size` | |
| `:dialogs/share-media` | `msg_id`, `attach_id`, `local_attach_id`, `cause_ordinal` | |
| `:external_callback` | — | |
| `:inAppReview/fake` | — | |
| `:invite/friends_to_max_bottom_sheet` | — | |
| `:invite/phone` | — | |
| `:invite/qr` | — | |
| `:join` | `id`, `link` | |
| `:link-intercept` | — | |
| `:location/pick` | `chat_id`, `request_code` | |
| `:location/show` | `chat_id`, `lat`, `lon`, `z` | |
| `:login` | — | без авторизации |
| `:logout` | — | без авторизации |
| `:media-editor` | — | |
| `:media-editor/crop` | `image_uri`, `file_path`, `mode` | |
| `:media-picker/select/photo` | — | без авторизации, не корневой экран |
| `:neuro-avatars` | `id` | |
| `:photo-editor` | — | |
| `:polls/create` | `chat_id`, `request_code` | |
| `:polls/result` | `chat_id`, `message_id`, `poll_id` | |
| `:polls/result/voters` | `chat_id`, `message_id`, `poll_id`, `answer_id` | |
| `:profile` | `id`, `type` | |
| `:profile/add-admins` | `chat_id` | |
| `:profile/add-members` | `chat_id`, `is_chat` | |
| `:profile/attaches` | `id` | |
| `:profile/avatars` | `id`, `type` | |
| `:profile/change-owner` | `chat_id` | |
| `:profile/comments-black-list` | `id` | |
| `:profile/edit` | `id`, `type` | |
| `:profile/edit/admin_permission` | `chat_id`, `contact_id`, `permissions_type` | |
| `:profile/edit/link` | `id`, `type`, `flow` | |
| `:profile/edit/reactions` | `id` | |
| `:profile/invite` | `id` | |
| `:profile/join-requests` | `id` | |
| `:profile/member_permissions` | `id` | |
| `:profile/members` | `id`, `type` | |
| `:qr-scanner` | — | |
| `:saved-messages` | — | |
| `:scheduled-messages` | `id` | |
| `:settings` | — | |
| `:settings/aboutapp` | — | |
| `:settings/appearance` | — | |
| `:settings/battery` | — | |
| `:settings/blacklist` | — | |
| `:settings/caching` | — | |
| `:settings/dev` | — | без авторизации, не корневой экран |
| `:settings/dev/integritylogsviewer` | — | без авторизации, не корневой экран |
| `:settings/dev/logsviewer` | — | без авторизации, не корневой экран |
| `:settings/dev/memorydebugger` | — | без авторизации |
| `:settings/dev/showroom` | — | без авторизации |
| `:settings/dev/threadsviewer` | — | без авторизации |
| `:settings/devices` | — | |
| `:settings/folder` | `id` | |
| `:settings/folder-list` | — | |
| `:settings/folder/by-chat` | `ids` | |
| `:settings/folder/create` | — | |
| `:settings/folder/edit` | — | |
| `:settings/folder/members-picker` | — | |
| `:settings/folder/settings` | — | |
| `:settings/locale` | — | |
| `:settings/magic-room` | — | без авторизации |
| `:settings/media` | — | |
| `:settings/media/autoload/video` | — | |
| `:settings/media/autosave` | `type` | |
| `:settings/messages` | — | |
| `:settings/notifications` | — | |
| `:settings/notifications/chat` | — | |
| `:settings/notifications/dialog` | — | |
| `:settings/notifications/other` | — | |
| `:settings/privacy` | — | |
| `:settings/privacy/creation-twofa` | `track_id`, `src` | |
| `:settings/privacy/onboarding` | — | |
| `:settings/privacy/onboarding-twofa` | `state` | |
| `:settings/privacy/pincode` | `mode` | |
| `:settings/privacy/profile-deletion` | — | |
| `:settings/ringtone` | — | |
| `:settings/server-host` | — | без авторизации |
| `:settings/server-port` | — | без авторизации |
| `:settings/webapp` | `bot_id` | |
| `:settings/webapps` | — | |
| `:share` | `text` | |
| `:start-conversation` | — | |
| `:start-conversation/add-subscribers` | `id` | |
| `:start-conversation/channel` | — | |
| `:start-conversation/chat` | — | |
| `:stickers/favorite` | — | |
| `:stickers/preview` | `sticker_id` | |
| `:stickers/recent` | — | |
| `:stickers/search` | — | |
| `:stickers/set` | `set_id` | |
| `:stickers/settings` | — | |
| `:stickers/showcase` | — | |
| `:stories/edit-privacy` | `story_id`, `settings` | |
| `:stories/publish` | `path` | |
| `:stories/publish/picker` | `title` | |
| `:stories/viewer` | `owner_id`, `owner_type`, `type` | |
| `:story/editor` | — | |
| `:twofa/auth/password/check` | `track_id`, `phone` | без авторизации, не корневой экран |
| `:twofa/password/check` | — | |
| `:unknown-call` | `call_id`, `caller_id` | |
| `:videoweb/full` | `chat_id`, `msg_id` | |
| `:webapp:root` | `bot_id`, `entry_point` | |
| `:webview/faq` | — | без авторизации, не корневой экран |
## Как это работает в Komet
Точка входа одна для всех случаев: `MaxLink.parse()` в `lib/core/links/max_link.dart`
разбирает ссылку в sealed-тип, `tryHandleMaxLink()` в
`lib/frontend/widgets/max_link_handler.dart` его исполняет. Через неё идут и внешние
диплинки (`DeepLinkService``app_links`), и тапы внутри приложения
(`openExternalUrl` → текст сообщений, био, описания каналов, inline-кнопки, упоминания).
Ссылки без схемы (`max.ru/…`) распознаются и парсером, и автолинковкой в тексте.
Ссылки, которым не нужен сервер (маршруты, `:share`, папки, вкладки), больше не ждут
подключения — `DeepLinkService` проверяет `MaxLink.needsConnection`.
Манифест Komet уже ловит `https/http max.ru` (+ `www.`) и схемы `komet://`, `max://`.
### Контентные ссылки
| Ссылка | Поведение в Komet |
|---|---|
| `max.ru` | возврат на корневой экран |
| `max.ru/<username>`, `@<nickname>`, `u/<id>` | резолв через `LINK_INFO` → чат, канал или профиль |
| `?start=<payload>` | диалог с ботом + автоотправка `botStarted` |
| `?startapp=<payload>` | мини-приложение бота (`WebAppScreen`), botId берётся из резолва ссылки |
| `join/<code>` | подтверждение вступления и переход в чат |
| `joincall/<code>` | экран входа в звонок |
| `stickerset/<id>` | шит стикерпака |
| `<username>/<messageId>`, `c/<chatId>/<messageId>` | чат с переходом к сообщению (время берётся из `LINK_INFO`, иначе догружаем историю назад) |
| `?uid=<userId>` | профиль контакта |
| `?cid=<chatId>` | чат по серверному id |
| `:folder?id=` | корневой список с выбранной папкой |
| `:auth/<token>` | подтверждение веб-логина (в отличие от MAX токен не отбрасывается — он нужен нашему флоу) |
| `:current` | ничего не делает, как в MAX |
| `:share?text=` | панель пересылки → выбранный чат с текстом в поле ввода |
| `:share-self-out` | системный шэринг своей публичной ссылки |
| чужой хост | внешний браузер |
### Внутренние маршруты
Реализованы в `lib/frontend/widgets/max_route_handler.dart`:
| Маршрут | Экран Komet |
|---|---|
| `:chat-list`, `:settings/folder-list` | список чатов |
| `:calls-history`, `:call-list` | вкладка звонков |
| `:contact-list` | вкладка контактов |
| `:settings` | вкладка настроек |
| `:chats-search` | поиск |
| `:saved-messages` | Избранное |
| `:chats?id=` | чат |
| `:profile?id=&type=` | профиль контакта или чата (по `type`) |
| `:profile/members`, `:profile/avatars` | тот же профиль |
| `:profile/attaches?id=` | профиль на вкладке медиа |
| `:profile/edit` | редактирование своего профиля |
| `:scheduled-messages?id=` | отложенные сообщения чата |
| `:stickers/set?set_id=` | шит стикерпака |
| `:webapp:root?bot_id=&entry_point=`, `:settings/webapp?bot_id=` | мини-приложение |
| `:location/show?lat=&lon=&z=` | карта |
| `:qr-scanner` | сканер QR |
| `:settings/appearance` | внешний вид |
| `:settings/notifications` (+ `/chat`, `/dialog`, `/other`) | уведомления |
| `:settings/devices` | устройства |
| `:settings/aboutapp` | о приложении |
| `:settings/privacy`, `:settings/privacy/pincode`, `:settings/blacklist` | безопасность |
| `:settings/messages` | действия с сообщениями |
| `:settings/dev` (+ подэкраны) | отладочное меню |
| `:link-intercept`, `:external_callback` | no-op |
Остальные маршруты из таблицы выше показывают уведомление
«Ссылка не поддерживается: `<маршрут>`» — в Komet для них нет экрана. Сознательно не
подключены: `:login`/`:logout` (деструктивно), `:polls/*`, `:comments`, `:chats/forward`,
`:stories/*`, `:call-*` (кроме списка), `:twofa/*`, `:complaint`, `:media-*`,
`:attach/viewer`, `:videoweb/full`, `:invite/*` — им нужны либо чужие экраны, либо
контекст, которого в ссылке нет. Флаг `?externalCallback=1` не обрабатывается.
+191
View File
@@ -0,0 +1,191 @@
import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/frontend/screens/webapp/web_app_bridge.dart';
void main() {
late List<(String, Map<String, dynamic>, bool)> sent;
late int closeCalls;
WebAppBridge buildBridge({
bool privateChannel = false,
String entryPoint = WebAppEntryPoint.webApp,
}) {
return WebAppBridge(
botId: 777,
entryPoint: entryPoint,
privateChannel: privateChannel,
contextResolver: () => null,
viewportResolver: () => const Size(420, 800),
onClose: () => closeCalls++,
emitter: (method, payload, private) => sent.add((
method,
jsonDecode(payload) as Map<String, dynamic>,
private,
)),
);
}
setUp(() {
sent = [];
closeCalls = 0;
});
test('reports the launch context it was created with', () async {
final bridge = buildBridge(entryPoint: WebAppEntryPoint.inlineButton);
await bridge.handleEvent(
'WebAppGetLaunchContext',
'{"requestId":"r1"}',
false,
);
expect(sent, hasLength(1));
expect(sent.first.$1, 'WebAppGetLaunchContext');
expect(sent.first.$2, {
'requestId': 'r1',
'entryPoint': 'inline_button',
});
});
test('answers viewport requests with the current webview size', () async {
final bridge = buildBridge();
await bridge.handleEvent(
'WebAppGetViewportSize',
'{"requestId":"r2"}',
false,
);
expect(sent.first.$2['width'], 420);
expect(sent.first.$2['height'], 800);
expect(sent.first.$2['isStateStable'], isTrue);
});
test('rejects an unknown method with the client error code', () async {
final bridge = buildBridge();
await bridge.handleEvent('WebAppSomethingElse', '{"requestId":"r3"}', false);
expect(sent.first.$2['error'], {
'code': 'client.unsupported_method.unsupported_method',
});
});
test('stays silent for methods that never get an answer', () async {
final bridge = buildBridge();
await bridge.handleEvent('WebAppReady', '{}', false);
await bridge.handleEvent('WebAppStat', '{}', false);
expect(sent, isEmpty);
});
test('drops gesture-gated methods until the user touches the page', () async {
final bridge = buildBridge();
await bridge.handleEvent(
'WebAppShare',
'{"requestId":"r4","text":"hi"}',
false,
);
expect(sent, isEmpty);
bridge.registerGesture();
await bridge.handleEvent(
'WebAppShare',
'{"requestId":"r5"}',
false,
);
expect(sent.single.$2['error'], {'code': 'client.web_app_share.invalid_request'});
});
test('ignores private-channel events when the channel is off', () async {
final bridge = buildBridge();
await bridge.handleEvent(
'WebAppVerifyMobileId',
'{"requestId":"r6","url":"https://example.test/verify"}',
true,
);
expect(sent, isEmpty);
});
test('reports malformed payloads as a decode error', () async {
final bridge = buildBridge();
await bridge.handleEvent('WebAppGetViewportSize', 'not-json', false);
expect(sent, isEmpty);
});
test('tracks the back button and closing behaviour the app asked for', () async {
final bridge = buildBridge();
expect(bridge.handlesBackButton, isFalse);
expect(bridge.needsCloseConfirmation, isFalse);
await bridge.handleEvent(
'WebAppSetupBackButton',
'{"isVisible":true}',
false,
);
await bridge.handleEvent(
'WebAppSetupClosingBehavior',
'{"needConfirmation":true}',
false,
);
expect(bridge.handlesBackButton, isTrue);
expect(bridge.needsCloseConfirmation, isTrue);
bridge.notifyBackPressed();
expect(sent.single.$1, 'WebAppBackButtonPressed');
});
test('closes the screen when the app asks to', () async {
final bridge = buildBridge();
await bridge.handleEvent('WebAppClose', '{}', false);
expect(closeCalls, 1);
});
test('echoes the screen capture behaviour back', () async {
final bridge = buildBridge();
await bridge.handleEvent(
'WebAppSetupScreenCaptureBehavior',
'{"requestId":"r7","isScreenCaptureEnabled":true}',
false,
);
expect(sent.first.$2, {
'requestId': 'r7',
'isScreenCaptureEnabled': true,
});
});
test('answers NFC availability without pretending to support it', () async {
final bridge = buildBridge();
await bridge.handleEvent('WebAppNfcGetInfo', '{"requestId":"r8"}', false);
await bridge.handleEvent(
'WebAppNfcEmulateNfcTag',
'{"requestId":"r9"}',
false,
);
expect(sent[0].$2, {
'requestId': 'r8',
'available': false,
'enabled': false,
});
expect(sent[1].$2['error'], {
'code': 'client.nfc_emulate_nfc_tag.not_supported',
});
});
}