feat: js бриджы для вебапов
This commit is contained in:
@@ -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`.
|
|
||||||
@@ -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
|
|
||||||
@@ -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`
|
|
||||||
и половина полей в объекте согласования фич добавляются **только** при включённой фиче.
|
|
||||||
Слать их безусловно — рискованно.
|
|
||||||
@@ -21,8 +21,21 @@ bool hasMiniAppOption(Set<String>? options) =>
|
|||||||
class WebAppLaunch {
|
class WebAppLaunch {
|
||||||
final String url;
|
final String url;
|
||||||
final String? queryId;
|
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 {
|
class ExternalCallbackResult {
|
||||||
@@ -52,12 +65,12 @@ class ExternalCallbackResult {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static int? _asInt(dynamic value) {
|
int? _asInt(dynamic value) {
|
||||||
if (value is int) return value;
|
if (value is int) return value;
|
||||||
if (value is num) return value.toInt();
|
if (value is num) return value.toInt();
|
||||||
return int.tryParse(value?.toString() ?? '');
|
return int.tryParse(value?.toString() ?? '');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class WebAppModule {
|
class WebAppModule {
|
||||||
@@ -93,7 +106,32 @@ class WebAppModule {
|
|||||||
throw const WebAppUnavailable('Сервер не вернул адрес приложения');
|
throw const WebAppUnavailable('Сервер не вернул адрес приложения');
|
||||||
}
|
}
|
||||||
final queryId = (data is Map) ? data['query_id']?.toString() : null;
|
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 {
|
Future<WebAppLaunch> fetchSferum() async {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ abstract class Opcode {
|
|||||||
static const int authLoginRestorePassword = 101; // Восстановление пароля
|
static const int authLoginRestorePassword = 101; // Восстановление пароля
|
||||||
static const int auth2faDetails = 104; // Детали 2FA
|
static const int auth2faDetails = 104; // Детали 2FA
|
||||||
static const int externalCallback = 105; // Внешний коллбэк
|
static const int externalCallback = 105; // Внешний коллбэк
|
||||||
|
static const int phoneWebappShare = 106;
|
||||||
static const int authValidatePassword = 107; // Валидация пароля
|
static const int authValidatePassword = 107; // Валидация пароля
|
||||||
static const int authValidateHint = 108; // Валидация подсказки пароля
|
static const int authValidateHint = 108; // Валидация подсказки пароля
|
||||||
static const int authVerifyEmail = 109; // Верификация email
|
static const int authVerifyEmail = 109; // Верификация email
|
||||||
@@ -246,6 +247,7 @@ abstract class Opcode {
|
|||||||
authLoginRestorePassword: 'AUTH_LOGIN_RESTORE_PASSWORD',
|
authLoginRestorePassword: 'AUTH_LOGIN_RESTORE_PASSWORD',
|
||||||
auth2faDetails: 'AUTH_2FA_DETAILS',
|
auth2faDetails: 'AUTH_2FA_DETAILS',
|
||||||
externalCallback: 'EXTERNAL_CALLBACK',
|
externalCallback: 'EXTERNAL_CALLBACK',
|
||||||
|
phoneWebappShare: 'PHONE_WEBAPP_SHARE',
|
||||||
authValidatePassword: 'AUTH_VALIDATE_PASSWORD',
|
authValidatePassword: 'AUTH_VALIDATE_PASSWORD',
|
||||||
authValidateHint: 'AUTH_VALIDATE_HINT',
|
authValidateHint: 'AUTH_VALIDATE_HINT',
|
||||||
authVerifyEmail: 'AUTH_VERIFY_EMAIL',
|
authVerifyEmail: 'AUTH_VERIFY_EMAIL',
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ class AppDatabase {
|
|||||||
await _migrateLegacyDb(target);
|
await _migrateLegacyDb(target);
|
||||||
return openDatabase(
|
return openDatabase(
|
||||||
target,
|
target,
|
||||||
version: 21,
|
version: 22,
|
||||||
onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
||||||
onCreate: (db, _) => _createTables(db),
|
onCreate: (db, _) => _createTables(db),
|
||||||
onUpgrade: (db, oldVersion, newVersion) async {
|
onUpgrade: (db, oldVersion, newVersion) async {
|
||||||
@@ -349,6 +349,10 @@ class AppDatabase {
|
|||||||
'TEXT',
|
'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(_contactsSchema);
|
||||||
await db.execute(_messagesSchema);
|
await db.execute(_messagesSchema);
|
||||||
await db.execute(_chatParticipantsSchema);
|
await db.execute(_chatParticipantsSchema);
|
||||||
|
await db.execute(_webAppStorageSchema);
|
||||||
|
await db.execute(_webAppBiometrySchema);
|
||||||
await _createIndexes(db);
|
await _createIndexes(db);
|
||||||
await _createChatParticipantsIndex(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(
|
static Future<void> saveProfile(
|
||||||
ProfileData profile, {
|
ProfileData profile, {
|
||||||
bool isActive = true,
|
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(
|
static Future<void> savePrivacyConfig(
|
||||||
int accountId,
|
int accountId,
|
||||||
String jsonConfig,
|
String jsonConfig,
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ class TokenStorage {
|
|||||||
await _secure.delete(key: key);
|
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 {
|
static Future<void> saveToken(String token, int accountId) async {
|
||||||
await _secure.write(key: '$_tokenPrefix$accountId', value: token);
|
await _secure.write(key: '$_tokenPrefix$accountId', value: token);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/foundation.dart' show kDebugMode;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
|
|
||||||
import '../../../backend/modules/webapp.dart' show WebAppLaunch;
|
import '../../../backend/modules/webapp.dart' show WebAppLaunch;
|
||||||
import '../../../core/utils/logger.dart';
|
import '../../../core/utils/logger.dart';
|
||||||
import '../../../main.dart' show api, webAppModule, digitalIdModule;
|
import '../../../main.dart' show digitalIdModule, webAppModule;
|
||||||
import '../webapp/web_app_screen.dart';
|
import '../webapp/web_app_screen.dart';
|
||||||
|
|
||||||
Future<void> resetDigitalIdWebData() async {
|
Future<void> resetDigitalIdWebData() async {
|
||||||
@@ -24,269 +21,6 @@ Future<void> resetDigitalIdSession() async {
|
|||||||
} catch (_) {}
|
} 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 {
|
class DigitalIdWebScreen extends StatelessWidget {
|
||||||
final WebAppLaunch? initialLaunch;
|
final WebAppLaunch? initialLaunch;
|
||||||
|
|
||||||
@@ -294,54 +28,12 @@ class DigitalIdWebScreen extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Тот же device_id, что уходит в handshake (опкод 6, sessionInit),
|
|
||||||
// с учётом спуфинга — сервер привязывает Цифровой ID к device'у сессии.
|
|
||||||
final deviceId = api.deviceId ?? '';
|
|
||||||
return WebAppScreen(
|
return WebAppScreen(
|
||||||
title: 'Цифровой ID',
|
title: 'Цифровой ID',
|
||||||
preferSystemUserAgent: true,
|
preferSystemUserAgent: true,
|
||||||
|
privateChannel: true,
|
||||||
|
mobileIdVerifier: digitalIdModule.fetchMobileIdVerification,
|
||||||
loader: () async => initialLaunch ?? await webAppModule.fetchDigitalId(),
|
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,
|
onExternalCallback: webAppModule.handleExternalCallback,
|
||||||
onConsoleMessage: (controller, consoleMessage) {
|
onConsoleMessage: (controller, consoleMessage) {
|
||||||
final msg = '[DID] ${consoleMessage.message}';
|
final msg = '[DID] ${consoleMessage.message}';
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import '../../../core/config/app_digital_id_mode.dart';
|
|||||||
import '../../../core/utils/webview_support.dart';
|
import '../../../core/utils/webview_support.dart';
|
||||||
import '../digital_id/digital_id_screen.dart';
|
import '../digital_id/digital_id_screen.dart';
|
||||||
import '../digital_id/digital_id_web_screen.dart';
|
import '../digital_id/digital_id_web_screen.dart';
|
||||||
|
import '../webapp/web_app_bridge.dart';
|
||||||
import '../webapp/web_app_screen.dart';
|
import '../webapp/web_app_screen.dart';
|
||||||
import 'cloud_storage_screen.dart';
|
import 'cloud_storage_screen.dart';
|
||||||
import 'customization_section.dart';
|
import 'customization_section.dart';
|
||||||
@@ -428,6 +429,7 @@ class _SettingsTabState extends State<SettingsTab> with SpectrumSurface {
|
|||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => WebAppScreen(
|
builder: (context) => WebAppScreen(
|
||||||
title: 'Сферум',
|
title: 'Сферум',
|
||||||
|
entryPoint: WebAppEntryPoint.settings,
|
||||||
loader: () => webAppModule.fetchSferum(),
|
loader: () => webAppModule.fetchSferum(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../../../core/utils/webview_support.dart';
|
|||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
import '../../../main.dart' show webAppModule;
|
import '../../../main.dart' show webAppModule;
|
||||||
import '../../widgets/custom_notification.dart';
|
import '../../widgets/custom_notification.dart';
|
||||||
|
import 'web_app_bridge.dart';
|
||||||
import 'web_app_screen.dart';
|
import 'web_app_screen.dart';
|
||||||
|
|
||||||
Future<void> openMiniApp(
|
Future<void> openMiniApp(
|
||||||
@@ -14,6 +15,7 @@ Future<void> openMiniApp(
|
|||||||
required int botId,
|
required int botId,
|
||||||
required String title,
|
required String title,
|
||||||
int? chatId,
|
int? chatId,
|
||||||
|
String entryPoint = WebAppEntryPoint.webApp,
|
||||||
}) async {
|
}) async {
|
||||||
if (botId <= 0) return;
|
if (botId <= 0) return;
|
||||||
Haptics.tap();
|
Haptics.tap();
|
||||||
@@ -23,6 +25,7 @@ Future<void> openMiniApp(
|
|||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => WebAppScreen(
|
builder: (_) => WebAppScreen(
|
||||||
title: title,
|
title: title,
|
||||||
|
entryPoint: entryPoint,
|
||||||
loader: () => webAppModule.fetchLaunch(botId, chatId: chatId),
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,15 +6,21 @@ import 'package:material_symbols_icons/symbols.dart';
|
|||||||
|
|
||||||
import '../../../backend/modules/webapp.dart';
|
import '../../../backend/modules/webapp.dart';
|
||||||
import '../../../core/storage/spoofing_service.dart';
|
import '../../../core/storage/spoofing_service.dart';
|
||||||
|
import '../../../core/utils/link_opener.dart';
|
||||||
import '../../../main.dart' show api;
|
import '../../../main.dart' show api;
|
||||||
|
import '../../widgets/confirm_dialog.dart';
|
||||||
import '../../widgets/connection_status.dart';
|
import '../../widgets/connection_status.dart';
|
||||||
import '../../widgets/error_view.dart';
|
import '../../widgets/error_view.dart';
|
||||||
import '../../widgets/small_spinner.dart';
|
import '../../widgets/small_spinner.dart';
|
||||||
import '../../widgets/webview_permission_prompt.dart';
|
import '../../widgets/webview_permission_prompt.dart';
|
||||||
|
import 'web_app_bridge.dart';
|
||||||
|
|
||||||
class WebAppScreen extends StatefulWidget {
|
class WebAppScreen extends StatefulWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final Future<WebAppLaunch> Function() loader;
|
final Future<WebAppLaunch> Function() loader;
|
||||||
|
final String entryPoint;
|
||||||
|
final bool privateChannel;
|
||||||
|
final WebAppMobileIdVerifier? mobileIdVerifier;
|
||||||
final List<UserScript>? extraUserScripts;
|
final List<UserScript>? extraUserScripts;
|
||||||
final void Function(InAppWebViewController controller)? onWebViewCreated;
|
final void Function(InAppWebViewController controller)? onWebViewCreated;
|
||||||
final void Function(
|
final void Function(
|
||||||
@@ -38,6 +44,9 @@ class WebAppScreen extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.loader,
|
required this.loader,
|
||||||
|
this.entryPoint = WebAppEntryPoint.webApp,
|
||||||
|
this.privateChannel = false,
|
||||||
|
this.mobileIdVerifier,
|
||||||
this.extraUserScripts,
|
this.extraUserScripts,
|
||||||
this.onWebViewCreated,
|
this.onWebViewCreated,
|
||||||
this.onConsoleMessage,
|
this.onConsoleMessage,
|
||||||
@@ -54,10 +63,12 @@ class WebAppScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _WebAppScreenState extends State<WebAppScreen> {
|
class _WebAppScreenState extends State<WebAppScreen> {
|
||||||
InAppWebViewController? _controller;
|
InAppWebViewController? _controller;
|
||||||
|
WebAppBridge? _bridge;
|
||||||
WebAppLaunch? _launch;
|
WebAppLaunch? _launch;
|
||||||
String? _loadError;
|
String? _loadError;
|
||||||
String _userAgent = '';
|
String _userAgent = '';
|
||||||
double _progress = 0;
|
double _progress = 0;
|
||||||
|
Size _viewport = Size.zero;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -65,10 +76,18 @@ class _WebAppScreenState extends State<WebAppScreen> {
|
|||||||
_load();
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_bridge?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_loadError = null;
|
_loadError = null;
|
||||||
_launch = null;
|
_launch = null;
|
||||||
|
_bridge?.dispose();
|
||||||
|
_bridge = null;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
// Тот же UA, что уходит в sessionInit (из handshake-устройства ядра),
|
// Тот же UA, что уходит в sessionInit (из handshake-устройства ядра),
|
||||||
@@ -89,19 +108,50 @@ class _WebAppScreenState extends State<WebAppScreen> {
|
|||||||
}
|
}
|
||||||
final launch = await widget.loader();
|
final launch = await widget.loader();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _launch = launch);
|
setState(() {
|
||||||
|
_launch = launch;
|
||||||
|
_bridge = _createBridge(launch.botId);
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _loadError = e.toString());
|
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 {
|
Future<bool> _handleBack() async {
|
||||||
|
final bridge = _bridge;
|
||||||
|
if (bridge != null && bridge.handlesBackButton) {
|
||||||
|
bridge.notifyBackPressed();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
final controller = _controller;
|
final controller = _controller;
|
||||||
if (controller != null && await controller.canGoBack()) {
|
if (controller != null && await controller.canGoBack()) {
|
||||||
await controller.goBack();
|
await controller.goBack();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (bridge != null && bridge.needsCloseConfirmation && mounted) {
|
||||||
|
return showConfirmDialog(
|
||||||
|
context,
|
||||||
|
title: widget.title,
|
||||||
|
message: 'Закрыть мини-приложение?',
|
||||||
|
confirmLabel: 'Закрыть',
|
||||||
|
);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,14 +235,28 @@ class _WebAppScreenState extends State<WebAppScreen> {
|
|||||||
return ErrorView(message: _loadError!, onRetry: _load);
|
return ErrorView(message: _loadError!, onRetry: _load);
|
||||||
}
|
}
|
||||||
final launch = _launch;
|
final launch = _launch;
|
||||||
if (launch == null) {
|
final bridge = _bridge;
|
||||||
|
if (launch == null || bridge == null) {
|
||||||
return const Center(child: SmallSpinner(size: 36));
|
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(
|
return InAppWebView(
|
||||||
initialUrlRequest: URLRequest(url: WebUri(launch.url)),
|
initialUrlRequest: URLRequest(url: WebUri(launch.url)),
|
||||||
initialUserScripts: widget.extraUserScripts == null
|
initialUserScripts: UnmodifiableListView<UserScript>([
|
||||||
? null
|
bridge.userScript,
|
||||||
: UnmodifiableListView<UserScript>(widget.extraUserScripts!),
|
...?widget.extraUserScripts,
|
||||||
|
]),
|
||||||
initialSettings: InAppWebViewSettings(
|
initialSettings: InAppWebViewSettings(
|
||||||
javaScriptEnabled: true,
|
javaScriptEnabled: true,
|
||||||
domStorageEnabled: true,
|
domStorageEnabled: true,
|
||||||
@@ -201,24 +265,28 @@ class _WebAppScreenState extends State<WebAppScreen> {
|
|||||||
transparentBackground: true,
|
transparentBackground: true,
|
||||||
mediaPlaybackRequiresUserGesture: false,
|
mediaPlaybackRequiresUserGesture: false,
|
||||||
useHybridComposition: true,
|
useHybridComposition: true,
|
||||||
useShouldOverrideUrlLoading:
|
supportMultipleWindows: true,
|
||||||
widget.shouldOverrideUrlLoading != null ||
|
allowFileAccess: false,
|
||||||
widget.onExternalCallback != null,
|
useShouldOverrideUrlLoading: true,
|
||||||
userAgent: _userAgent,
|
userAgent: _userAgent,
|
||||||
),
|
),
|
||||||
onWebViewCreated: (controller) {
|
onWebViewCreated: (controller) {
|
||||||
_controller = controller;
|
_controller = controller;
|
||||||
|
bridge.attach(controller);
|
||||||
widget.onWebViewCreated?.call(controller);
|
widget.onWebViewCreated?.call(controller);
|
||||||
},
|
},
|
||||||
onPermissionRequest: (controller, request) =>
|
onPermissionRequest: (controller, request) =>
|
||||||
askWebViewPermission(context, 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,
|
onConsoleMessage: widget.onConsoleMessage,
|
||||||
onLoadStart: widget.onLoadStart,
|
onLoadStart: widget.onLoadStart,
|
||||||
shouldOverrideUrlLoading:
|
shouldOverrideUrlLoading: _handleNavigation,
|
||||||
widget.shouldOverrideUrlLoading == null &&
|
|
||||||
widget.onExternalCallback == null
|
|
||||||
? null
|
|
||||||
: _handleNavigation,
|
|
||||||
onProgressChanged: (controller, progress) {
|
onProgressChanged: (controller, progress) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _progress = progress / 100);
|
setState(() => _progress = progress / 100);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../../main.dart';
|
|||||||
import '../screens/chats/chat_list_screen.dart';
|
import '../screens/chats/chat_list_screen.dart';
|
||||||
import '../screens/chats/chat_screen.dart';
|
import '../screens/chats/chat_screen.dart';
|
||||||
import '../screens/contacts/open_contact_profile.dart';
|
import '../screens/contacts/open_contact_profile.dart';
|
||||||
|
import '../screens/webapp/web_app_bridge.dart';
|
||||||
import '../screens/webapp/web_app_screen.dart';
|
import '../screens/webapp/web_app_screen.dart';
|
||||||
import 'custom_notification.dart';
|
import 'custom_notification.dart';
|
||||||
import 'sticker_pack_sheet.dart';
|
import 'sticker_pack_sheet.dart';
|
||||||
@@ -137,6 +138,7 @@ Future<bool> openWebAppForBot(
|
|||||||
context,
|
context,
|
||||||
(_) => WebAppScreen(
|
(_) => WebAppScreen(
|
||||||
title: title,
|
title: title,
|
||||||
|
entryPoint: WebAppEntryPoint.url,
|
||||||
loader: () => webAppModule.fetchLaunch(
|
loader: () => webAppModule.fetchLaunch(
|
||||||
botId,
|
botId,
|
||||||
startParam: startParam,
|
startParam: startParam,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:flutter/rendering.dart';
|
|||||||
import 'package:komet/main.dart';
|
import 'package:komet/main.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import '../../backend/modules/messages.dart';
|
import '../../backend/modules/messages.dart';
|
||||||
|
import '../screens/webapp/web_app_bridge.dart';
|
||||||
import '../screens/webapp/web_app_screen.dart';
|
import '../screens/webapp/web_app_screen.dart';
|
||||||
import '../../core/config/app_bubble_behavior.dart';
|
import '../../core/config/app_bubble_behavior.dart';
|
||||||
import '../../core/config/app_bubble_shape.dart';
|
import '../../core/config/app_bubble_shape.dart';
|
||||||
@@ -1444,6 +1445,7 @@ class MessageBubble extends StatelessWidget {
|
|||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => WebAppScreen(
|
builder: (_) => WebAppScreen(
|
||||||
title: button.text,
|
title: button.text,
|
||||||
|
entryPoint: WebAppEntryPoint.inlineButton,
|
||||||
loader: () => webAppModule.fetchLaunch(
|
loader: () => webAppModule.fetchLaunch(
|
||||||
botId,
|
botId,
|
||||||
startParam: startParam,
|
startParam: startParam,
|
||||||
|
|||||||
@@ -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` не обрабатывается.
|
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user