From f7a986fec797f52ca66b6133e3e8c12751ef3965 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sat, 15 Aug 2026 21:31:38 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20js=20=D0=B1=D1=80=D0=B8=D0=B4=D0=B6?= =?UTF-8?q?=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D0=B5=D0=B1=D0=B0=D0=BF?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- banners-informer.md | 154 ---- calls-opcodes.txt | 175 ---- calls-protocol.md | 583 ------------- lib/backend/modules/webapp.dart | 52 +- lib/core/protocol/opcode_map.dart | 2 + lib/core/storage/app_database.dart | 126 ++- lib/core/storage/token_storage.dart | 5 + lib/core/storage/webapp_storage.dart | 123 +++ .../digital_id/digital_id_web_screen.dart | 314 +------ .../screens/profile/settings_tab.dart | 2 + .../screens/webapp/open_mini_app.dart | 3 + .../screens/webapp/web_app_bridge.dart | 763 ++++++++++++++++++ .../screens/webapp/web_app_screen.dart | 94 ++- lib/frontend/widgets/max_link_nav.dart | 2 + lib/frontend/widgets/message_bubble.dart | 2 + max-deeplinks.md | 288 ------- test/webapp_bridge_test.dart | 191 +++++ 17 files changed, 1347 insertions(+), 1532 deletions(-) delete mode 100644 banners-informer.md delete mode 100644 calls-opcodes.txt delete mode 100644 calls-protocol.md create mode 100644 lib/core/storage/webapp_storage.dart create mode 100644 lib/frontend/screens/webapp/web_app_bridge.dart delete mode 100644 max-deeplinks.md create mode 100644 test/webapp_bridge_test.dart diff --git a/banners-informer.md b/banners-informer.md deleted file mode 100644 index 2e785c8..0000000 --- a/banners-informer.md +++ /dev/null @@ -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`. diff --git a/calls-opcodes.txt b/calls-opcodes.txt deleted file mode 100644 index 9a836bc..0000000 --- a/calls-opcodes.txt +++ /dev/null @@ -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 diff --git a/calls-protocol.md b/calls-protocol.md deleted file mode 100644 index d69a237..0000000 --- a/calls-protocol.md +++ /dev/null @@ -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": "", // ApiProtocol.PARAM_CONVERSATION_ID - "calleeIds": [, ...], // только если массив непустой (p2p-звонок контактам) - "chatId": , // только если != null (звонок в чате/группе) - "isVideo": , - "internalParams": "" // см. 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": "" } -``` - -Вызывается уже **после** того, как звонок создан (лог `"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": "", - "conversationId": "", - "joinLink": "", - "chatId": , - "callName": "", - "participantsCount": , - "previewParticipantIds": [, ...], - "startAt": , - "type": , - "owner": <...> -} -``` - -Этот же объект приходит в превью ссылки (`LINK_INFO`, 89). - -### 1.3 Вход по ссылке — opcode 166 `VIDEO_CHAT_JOIN` - -`p000/vjb.java` → `m30940b()`: - -```jsonc -// request -{ - "joinLink": "", // ApiProtocol.PARAM_JOIN_LINK - "isVideo": , - "internalParams": "" -} -``` - -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": "", - "deviceId": "", - "protocolVersion": 6, // 6 если multiple-devices включён, иначе 5 - "domainId": "", - "onlyAdminCanRecord": false, - "isWaitForAdminEnabled": , - "hexCapability": "1877f" // см. раздел 6 -} -``` - -### 1.5 Ответ: параметры конференции - -Разбирается `CallInfoParser` / `ConversationParams` / `CallInfo`, ключи из `ApiProtocol`: - -```jsonc -{ - "endpoint": "", // сюда открываем signaling-сокет - "wt_endpoint": "", // watch-together - "id": "", - "token": "", - "join_link": "", - "client_type": "", - "device_idx": , - "is_concurrent": , - "p2p_forbidden": , - "upload_url": "", - "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": } -``` - -**Сервер → клиент**, три вида (`p000/tkf.java:m29114f`, `p000/r51.java:100`): - -```jsonc -{ "type": "response", "sequence": , "response": "<имя команды>", ... } -{ "type": "error", "sequence": , "error": "<код>", "errorCode": , "recoverable": } -{ "notification": "<имя>", ... } // + "stamp": для порядка -``` - -Коды ошибок, которые встречаются: `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": , "participantType": "GROUP"|"USER", "deviceIdx": }` -- строкой (`mq1.m20652b()`): `u` для юзера, `g` для группы, с суффиксом устройства - -Идентификаторы медиа-треков (`p000/r7l.java:m26478K`): - -``` -:sCAMERA // камера -:sSCREEN // ДЕМОНСТРАЦИЯ ЭКРАНА -:sMOVIE:m // расшаренное видео -:sSTREAM -:sANIMOJI -audio- // аудио-трек -video- // видео-трек -``` - ---- - -## 3. Медиа: камера / микрофон / экран - -### 3.1 Своё состояние — `change-media-settings` - -`p000/zkf.java` + `p000/r7l.java:m26497o`. Шлётся **целиком, не дельтой**, при любом переключении: - -```jsonc -{ - "command": "change-media-settings", - "mediaSettings": { - "isVideoEnabled": , // камера - "isAudioEnabled": , // микрофон - "isScreenSharingEnabled": , // ДЕМОНСТРАЦИЯ ЭКРАНА - "isAnimojiEnabled": , - "isFastScreenSharingEnabled": , // только если включён fastScreenShare - "isAudioSharingEnabled": // только если включён audioShare - }, - "sequence": -} -``` - -Последние два поля **условные** — добавляются только когда соответствующая фича согласована -при подключении. Если слать их всегда — есть риск, что старый сервер отвергнет команду. - -### 3.2 Чужое состояние — нотификации - -```jsonc -{ "notification": "media-settings-changed", - "participantId": , "participantType": "...", "deviceIdx": , - "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 `:sSCREEN` - (unified plan, вторая m-line) — камера при этом остаётся своим треком `: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": , - "estimatedPerformanceIndex": , // опционально - "producerNotificationDataChannelVersion": 7, - "producerCommandDataChannelVersion": , - "audioMix": true, - "consumerUpdate": , - "onDemandTracks": , - "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": , // если > 0 - "csrcAccessible": true // если videoTracksCount > 0 -} -``` - ---- - -## 4. SDP / ICE - -Всё заворачивается в `transmit-data` (`p000/r7l.java:m26493k`, `m26502t`, `m26503u`): - -```jsonc -// offer / answer -{ "command": "transmit-data", - "participantId": , "participantType": "...", "deviceIdx": , - "data": { - "sdp": { "type": "offer"|"answer", "sdp": "", "p2pRelay": "true" }, - "capabilities": "1877f", // hex, если != 0 - "label": "" // опционально - }, - "sequence": } - -// одиночный кандидат -{ "command": "transmit-data", ..., "data": { "candidate": { "candidate": "...", "sdpMid": "...", "sdpMLineIndex": } } } - -// удалённые кандидаты -{ "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": "" // опционально, для session room -} -``` - -```jsonc -// жёстко выставить состояния мьюта -{ "command": "mute-participant", - "participantId": "u123" | null, - "muteStates": { - "AUDIO": "UNMUTE"|"MUTE"|"MUTE_PERMANENT"|null, - "VIDEO": ..., - "SCREEN_SHARING": ..., - "MOVIE_SHARING": ... - }, - "roomId": "" // опционально -} -``` - -`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": } -{ "command": "switch-micro", "all": true, "muteTarget": true } -``` - -Обратная нотификация: `{ "notification": "switch-micro", "mute": }` -(если поля `mute` нет — клиент логирует `switch-micro without 'mute'` и игнорирует). - -### 5.2 Роли и промоушен - -```jsonc -{ "command": "promote-participant", "participantId": "u123", "demote": } - -{ "command": "grant-roles", "participantId": "u123", - "roles": ["ADMIN", "SPEAKER", ...], "revoke": } - -// стерео-режим / запрос слова -{ "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": "" } - -{ "command": "remove-participant", "participantId": "u123" } - -{ "command": "pin-participant", "participantId": "u123", - "unpin": , "roomId": "" } - -{ "command": "get-participant-list-chunk", - "count": , "listType": "GRID"|"SIDE", "roomId": "" } - -{ "command": "get-waiting-hall", "count": , "fromId": "...", "backward": } -``` - -### 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": , "name": "...", "description": "...", - "privacy": "...", "groupId": , "albumId": "...", - "streamMovie": , "roomId": "" } - -{ "command": "record-stop", "roomId": "", "remove": } -``` - -Ответ на `record-start`: `{"type":"response","sequence":N,"response":"record-start","recordMovieId":}`. - -### 5.7 Комнаты (session rooms) - -```jsonc -{ "command": "switch-room", "participantId": "u123", "toRoomId": "" } -{ "command": "update-rooms", "rooms": [ { "id": "...", - "addParticipantIds": [...], "removeParticipantIds": [...] } ] } -{ "command": "get-rooms" } -{ "command": "room-query", ... } -{ "command": "room-tx", ... } -``` - -### 5.8 Совместный просмотр / шаринг ссылки - -```jsonc -{ "command": "add-movie", "movieId": , ... } -{ "command": "update-movie", "movieId": , "pause": } -{ "command": "remove-movie", "movieId": } - -{ "command": "start-url-sharing", "sharedUrl": "" } -{ "command": "stop-url-sharing" } -``` - -### 5.9 Прочее - -```jsonc -{ "command": "hangup", "reason": "..." } -{ "command": "accept-call", ... } -{ "command": "custom-data", "participantId": , "participantType": "...", - "deviceIdx": , "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 → -отдельный трек `: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` -и половина полей в объекте согласования фич добавляются **только** при включённой фиче. -Слать их безусловно — рискованно. diff --git a/lib/backend/modules/webapp.dart b/lib/backend/modules/webapp.dart index c66bfcd..b1a36a7 100644 --- a/lib/backend/modules/webapp.dart +++ b/lib/backend/modules/webapp.dart @@ -21,8 +21,21 @@ bool hasMiniAppOption(Set? options) => class WebAppLaunch { final String url; final String? queryId; + final int botId; - const WebAppLaunch({required this.url, this.queryId}); + const WebAppLaunch({required this.url, required this.botId, this.queryId}); +} + +class WebAppPhone { + final String phone; + final String hash; + final int authDate; + + const WebAppPhone({ + required this.phone, + required this.hash, + required this.authDate, + }); } class ExternalCallbackResult { @@ -52,12 +65,12 @@ class ExternalCallbackResult { } return null; } +} - static int? _asInt(dynamic value) { - if (value is int) return value; - if (value is num) return value.toInt(); - return int.tryParse(value?.toString() ?? ''); - } +int? _asInt(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? ''); } class WebAppModule { @@ -93,7 +106,32 @@ class WebAppModule { throw const WebAppUnavailable('Сервер не вернул адрес приложения'); } final queryId = (data is Map) ? data['query_id']?.toString() : null; - return WebAppLaunch(url: url, queryId: queryId); + return WebAppLaunch(url: url, botId: botId, queryId: queryId); + } + + Future 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 fetchSferum() async { diff --git a/lib/core/protocol/opcode_map.dart b/lib/core/protocol/opcode_map.dart index 1f5cab1..0d65021 100644 --- a/lib/core/protocol/opcode_map.dart +++ b/lib/core/protocol/opcode_map.dart @@ -29,6 +29,7 @@ abstract class Opcode { static const int authLoginRestorePassword = 101; // Восстановление пароля static const int auth2faDetails = 104; // Детали 2FA static const int externalCallback = 105; // Внешний коллбэк + static const int phoneWebappShare = 106; static const int authValidatePassword = 107; // Валидация пароля static const int authValidateHint = 108; // Валидация подсказки пароля static const int authVerifyEmail = 109; // Верификация email @@ -246,6 +247,7 @@ abstract class Opcode { authLoginRestorePassword: 'AUTH_LOGIN_RESTORE_PASSWORD', auth2faDetails: 'AUTH_2FA_DETAILS', externalCallback: 'EXTERNAL_CALLBACK', + phoneWebappShare: 'PHONE_WEBAPP_SHARE', authValidatePassword: 'AUTH_VALIDATE_PASSWORD', authValidateHint: 'AUTH_VALIDATE_HINT', authVerifyEmail: 'AUTH_VERIFY_EMAIL', diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 7101456..42ef5e5 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -221,7 +221,7 @@ class AppDatabase { await _migrateLegacyDb(target); return openDatabase( target, - version: 21, + version: 22, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -349,6 +349,10 @@ class AppDatabase { 'TEXT', ); } + if (oldVersion < 22) { + await db.execute(_webAppStorageSchema); + await db.execute(_webAppBiometrySchema); + } }, ); } @@ -375,6 +379,8 @@ class AppDatabase { await db.execute(_contactsSchema); await db.execute(_messagesSchema); await db.execute(_chatParticipantsSchema); + await db.execute(_webAppStorageSchema); + await db.execute(_webAppBiometrySchema); await _createIndexes(db); await _createChatParticipantsIndex(db); } @@ -535,6 +541,26 @@ class AppDatabase { ) '''; + static const _webAppStorageSchema = ''' + CREATE TABLE webapp_storage ( + account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + bot_id INTEGER NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (account_id, bot_id, key) + ) + '''; + + static const _webAppBiometrySchema = ''' + CREATE TABLE webapp_biometry ( + account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + bot_id INTEGER NOT NULL, + access_requested INTEGER NOT NULL DEFAULT 0, + access_granted INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (account_id, bot_id) + ) + '''; + static Future saveProfile( ProfileData profile, { bool isActive = true, @@ -634,6 +660,104 @@ class AppDatabase { }; } + static Future 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 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 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 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 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 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 savePrivacyConfig( int accountId, String jsonConfig, diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart index 2740a0b..2c92299 100644 --- a/lib/core/storage/token_storage.dart +++ b/lib/core/storage/token_storage.dart @@ -22,6 +22,11 @@ class TokenStorage { await _secure.delete(key: key); } + static Future> secureKeysWithPrefix(String prefix) async { + final all = await _secure.readAll(); + return all.keys.where((key) => key.startsWith(prefix)).toList(); + } + static Future saveToken(String token, int accountId) async { await _secure.write(key: '$_tokenPrefix$accountId', value: token); } diff --git a/lib/core/storage/webapp_storage.dart b/lib/core/storage/webapp_storage.dart new file mode 100644 index 0000000..ec40832 --- /dev/null +++ b/lib/core/storage/webapp_storage.dart @@ -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 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 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 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 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 biometryToken(int accountId, int botId) => + TokenStorage.readSecure(_biometryTokenKey(accountId, botId)); + + static Future saveBiometryToken( + int accountId, + int botId, + String token, + ) => TokenStorage.writeSecure(_biometryTokenKey(accountId, botId), token); + + static Future 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 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 _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); + } +} diff --git a/lib/frontend/screens/digital_id/digital_id_web_screen.dart b/lib/frontend/screens/digital_id/digital_id_web_screen.dart index 7d87c96..a217062 100644 --- a/lib/frontend/screens/digital_id/digital_id_web_screen.dart +++ b/lib/frontend/screens/digital_id/digital_id_web_screen.dart @@ -1,13 +1,10 @@ -import 'dart:convert'; - import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:url_launcher/url_launcher.dart'; import '../../../backend/modules/webapp.dart' show WebAppLaunch; import '../../../core/utils/logger.dart'; -import '../../../main.dart' show api, webAppModule, digitalIdModule; +import '../../../main.dart' show digitalIdModule, webAppModule; import '../webapp/web_app_screen.dart'; Future resetDigitalIdWebData() async { @@ -24,269 +21,6 @@ Future resetDigitalIdSession() async { } catch (_) {} } -const String _kBridge = r''' -(function(){ - if (!/(^|\.)max\.ru$/i.test(location.hostname)) { return; } - var sawOpenLink = false; - var DBG = !!window.__KOMET_DID_DEBUG; - function log(m){ try { console.log('[BRIDGE] ' + m); } catch(e){} } - try { log('UA ' + navigator.userAgent); } catch(e){} - var lastTouch = 0; - var lastExternalOpen = 0; - try { - ['touchstart','pointerdown','mousedown','click'].forEach(function(ev){ - document.addEventListener(ev, function(){ lastTouch = Date.now(); }, true); - }); - } catch(e){} - if (DBG) { - try { - var origFetch = window.fetch; - window.fetch = function(){ - var u, method = 'GET'; - try { - if (typeof arguments[0] === 'string') { u = arguments[0]; } - else if (arguments[0]) { u = arguments[0].url; method = arguments[0].method || method; } - if (arguments[1] && arguments[1].method) method = arguments[1].method; - } catch(e){} - var watched = ('' + u).indexOf('ext-api') >= 0 || ('' + u).indexOf('digital-id') >= 0 || ('' + u).indexOf('oneme.ru') >= 0; - var path = ('' + u).split('?')[0]; - if (watched) { - var body = ''; - try { - if (arguments[1] && typeof arguments[1].body === 'string') body = arguments[1].body.slice(0, 300); - else if (arguments[0] && arguments[0]._bodyInit && typeof arguments[0]._bodyInit === 'string') body = arguments[0]._bodyInit.slice(0, 300); - } catch(e){} - var auth = ''; - try { - var h = (arguments[1] && arguments[1].headers) || (arguments[0] && arguments[0].headers); - if (h) { - if (typeof h.get === 'function') auth = h.get('Authorization') || h.get('authorization') || ''; - else if (typeof h.forEach === 'function') { h.forEach(function(v,k){ if (('' + k).toLowerCase() === 'authorization') auth = v; }); } - else auth = h['Authorization'] || h['authorization'] || ''; - } - } catch(e){} - log('FETCH> ' + method + ' ' + path + (auth ? ' AUTH=[' + ('' + auth).slice(0, 70) + ']' : ' AUTH=none') + (body ? ' body=' + body : '')); - } - return origFetch.apply(this, arguments).then(function(r){ - if (watched) { - try { r.clone().text().then(function(t){ log('FETCH< ' + r.status + ' ' + path + ' :: ' + t.slice(0, 500)); }); } catch(e){} - } - return r; - }).catch(function(err){ if (watched) log('FETCH ERR ' + path + ' ' + err); throw err; }); - }; - } catch(e){} - } - function ssKey(k){ return 'komet_did_ss_' + k; } - function userId(){ - try { - var h = decodeURIComponent(decodeURIComponent(location.hash || '')); - var m = h.match(/"id"\s*:\s*(\d+)/); - if (m) return m[1]; - } catch(e){} - try { - var m2 = (location.hash || '').match(/id\W{1,8}?(\d{4,})/); - if (m2) return m2[1]; - } catch(e){} - return 'anon'; - } - try { - var uid = userId(); - var prev = localStorage.getItem('komet_did_owner'); - log('owner check uid=' + uid + ' prev=' + prev); - // Wipe ТОЛЬКО при реальной смене аккаунта (реальный id -> другой реальный id). - // Никогда не чистим, когда id не удалось определить ('anon') или его не было — - // иначе флапающий userId() стирает device-binding на каждом перезапуске (петля). - if (uid !== 'anon' && prev && prev !== 'anon' && prev !== uid) { - log('owner switch ' + prev + ' -> ' + uid + ' :: wiping did storage'); - try { localStorage.clear(); } catch(e){} - try { sessionStorage.clear(); } catch(e){} - try { - if (window.indexedDB && indexedDB.databases) { - indexedDB.databases().then(function(dbs){ - (dbs || []).forEach(function(db){ try { indexedDB.deleteDatabase(db.name); } catch(e){} }); - }); - } - } catch(e){} - } - if (uid !== 'anon') { localStorage.setItem('komet_did_owner', uid); } - } catch(e){} - var pending = []; - function chan(priv){ - var pub = (window.WebApp && typeof window.WebApp.sendEvent === 'function') ? window.WebApp : null; - var prv = (window.PrivateWebApp && typeof window.PrivateWebApp.sendEvent === 'function') ? window.PrivateWebApp : null; - if (priv) return prv; - return pub || prv; - } - function flush(){ - if (!pending.length) return; - var keep = []; - for (var i = 0; i < pending.length; i++) { - var it = pending[i]; - var t = chan(it[2]); - if (!t) { keep.push(it); continue; } - try { t.sendEvent(it[0], it[1]); } catch(e){ log('sendEvent err ' + e); keep.push(it); } - } - pending = keep; - } - setInterval(flush, 50); - function reply(type, data, priv){ - var payload; - try { payload = JSON.stringify(data == null ? {} : data); } catch(e){ payload = '{}'; } - log('reply ' + (priv ? '[priv] ' : '') + type + ' ' + payload); - pending.push([type, payload, !!priv]); - setTimeout(flush, 0); - } - function bioToken(){ - try { - var k = 'komet_did_bio_token'; - var v = localStorage.getItem(k); - if (!v) { - v = ''; - for (var i = 0; i < 32; i++) v += Math.floor(Math.random() * 16).toString(16); - localStorage.setItem(k, v); - } - return v; - } catch(e){ return 'komet-did-fallback-token'; } - } - function tokenSaved(){ - try { return !!localStorage.getItem('komet_did_bio_token'); } catch(e){ return false; } - } - function didDeviceId(){ - // Реальный device_id (как MAX: ANDROID_ID/persisted). Приоритет — прокинутый - // из Dart стабильный per-install id (переопределяемый под реальный id из MAX); - // фолбэк — стабильный 16-hex в localStorage (формат ANDROID_ID). - try { if (window.__KOMET_DID_DEVICE_ID) return '' + window.__KOMET_DID_DEVICE_ID; } catch(e){} - try { - var k = 'komet_did_device_id'; - var v = localStorage.getItem(k); - if (!v) { - v = ''; - for (var i = 0; i < 16; i++) v += Math.floor(Math.random() * 16).toString(16); - localStorage.setItem(k, v); - } - return v; - } catch(e){ return 'komet-device'; } - } - var NO_REPLY = { - WebAppReady: 1, WebAppSetupBackButton: 1, WebAppBackButtonPressed: 1, - WebAppUrlInterceptor: 1, WebAppSetupClosingBehavior: 1, WebAppStat: 1, - WebAppHapticFeedbackImpact: 1, WebAppHapticFeedbackNotification: 1, - WebAppHapticFeedbackSelectionChange: 1 - }; - function handle(type, dataStr, priv){ - var data = {}; - try { data = JSON.parse(dataStr || '{}'); } catch(e){} - log('recv ' + (priv ? '[priv] ' : '') + type + ' ' + dataStr); - var requestId = data.requestId; - switch (type) { - case 'WebAppBiometryGetInfo': - reply(type, { - requestId: requestId, available: true, - accessRequested: tokenSaved(), accessGranted: tokenSaved(), - access_requested: tokenSaved(), access_granted: tokenSaved(), - tokenSaved: tokenSaved(), token_saved: tokenSaved(), - deviceId: didDeviceId(), device_id: didDeviceId(), - type: ['face'] - }); - return; - case 'WebAppBiometryRequestAccess': - reply(type, { requestId: requestId, granted: true, access_granted: true, accessGranted: true, status: 'authorized' }); - return; - case 'WebAppBiometryAuthenticate': - case 'WebAppBiometryRequestAuth': - reply(type, { requestId: requestId, token: bioToken(), success: true, status: 'authorized' }); - return; - case 'WebAppBiometryUpdateToken': - case 'WebAppBiometryUpdateBiometricToken': - reply(type, { requestId: requestId, success: true, status: 'updated' }); - return; - case 'WebAppGetLaunchContext': - reply(type, { requestId: requestId, entryPoint: 'default' }); - return; - case 'WebAppGetViewportSize': - reply(type, { requestId: requestId, height: window.innerHeight, width: window.innerWidth, isStateStable: true }); - return; - case 'WebAppSetupScreenCaptureBehavior': - reply(type, { requestId: requestId, isScreenCaptureEnabled: !!data.isScreenCaptureEnabled }); - return; - case 'WebAppRequestPhone': - reply(type, { requestId: requestId, error: { code: 'client.request_phone.user_refused_provide_phone_number' } }); - return; - case 'WebAppVerifyMobileId': - (function(){ - try { - window.flutter_inappwebview.callHandler('verifyMobileId', data.url).then(function(res){ - if (res && typeof res.statusCode !== 'undefined') { - reply(type, { requestId: requestId, statusCode: res.statusCode, headers: res.headers || {}, data: res.data || '' }, true); - } else { - reply(type, { requestId: requestId, error: { code: 'client.verify_mobile_id.request_failed' } }, true); - } - }).catch(function(e){ - reply(type, { requestId: requestId, error: { code: 'client.verify_mobile_id.request_failed' } }, true); - }); - } catch(e){ - reply(type, { requestId: requestId, error: { code: 'client.verify_mobile_id.json_decode_error' } }, true); - } - })(); - return; - case 'WebAppOpenLink': - sawOpenLink = true; - var now = Date.now(); - if (data && data.url && (now - lastTouch < 8000) && (now - lastExternalOpen > 15000)) { - lastExternalOpen = now; - try { window.flutter_inappwebview.callHandler('openExternal', data.url); } catch(e){} - } else { - log('OpenLink dropped (gesture ' + (now - lastTouch) + 'ms, sinceOpen ' + (now - lastExternalOpen) + 'ms)'); - } - return; - case 'WebAppClose': - return; - default: - if (type.indexOf('SecureStorage') >= 0 || type.indexOf('DeviceStorage') >= 0) { - var backend = (type.indexOf('SecureStorage') >= 0) ? 's' : 'd'; - var pfx = 'komet_did_' + backend + '_'; - var key = data.key; - if (/Set|Save|Put/i.test(type)) { - try { localStorage.setItem(pfx + key, JSON.stringify(data.value !== undefined ? data.value : null)); } catch(e){} - reply(type, { requestId: requestId, status: 'saved' }); - } else if (/Clear/i.test(type)) { - try { - var rm = []; - for (var i = 0; i < localStorage.length; i++) { var k = localStorage.key(i); if (k && k.indexOf(pfx) === 0) rm.push(k); } - for (var j = 0; j < rm.length; j++) localStorage.removeItem(rm[j]); - } catch(e){} - reply(type, { requestId: requestId, status: 'cleared' }); - } else if (/Remove|Delete/i.test(type)) { - try { localStorage.removeItem(pfx + key); } catch(e){} - reply(type, { requestId: requestId, status: 'removed' }); - } else { - var val = null; - try { - var raw = localStorage.getItem(pfx + key); - val = (raw == null) ? null : JSON.parse(raw); - } catch(e){} - reply(type, { requestId: requestId, key: key, value: val }); - } - return; - } - if (NO_REPLY[type]) return; - reply(type, { requestId: requestId }); - } - } - try { - window.WebViewHandler = { - postEvent: function(type, dataStr){ try { handle(type, dataStr, false); } catch(e){} }, - resolveShare: function(){} - }; - window.PrivateWebViewHandler = { - postEvent: function(type, dataStr){ try { handle(type, dataStr, true); } catch(e){} }, - resolveShare: function(){} - }; - if (!window.AndroidPerf) window.AndroidPerf = { trackFcp: function(){} }; - } catch(e){} -})(); -'''; - class DigitalIdWebScreen extends StatelessWidget { final WebAppLaunch? initialLaunch; @@ -294,54 +28,12 @@ class DigitalIdWebScreen extends StatelessWidget { @override Widget build(BuildContext context) { - // Тот же device_id, что уходит в handshake (опкод 6, sessionInit), - // с учётом спуфинга — сервер привязывает Цифровой ID к device'у сессии. - final deviceId = api.deviceId ?? ''; return WebAppScreen( title: 'Цифровой ID', preferSystemUserAgent: true, + privateChannel: true, + mobileIdVerifier: digitalIdModule.fetchMobileIdVerification, loader: () async => initialLaunch ?? await webAppModule.fetchDigitalId(), - extraUserScripts: [ - UserScript( - source: - 'window.__KOMET_DID_DEBUG=$kDebugMode; window.__KOMET_DID_DEVICE_ID=${jsonEncode(deviceId)};', - injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, - ), - UserScript( - source: _kBridge, - injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, - ), - ], - onWebViewCreated: (controller) { - controller.addJavaScriptHandler( - handlerName: 'closeWebApp', - callback: (args) { - if (context.mounted) Navigator.of(context).maybePop(); - return null; - }, - ); - controller.addJavaScriptHandler( - handlerName: 'openExternal', - callback: (args) async { - final url = args.isNotEmpty ? args.first?.toString() : null; - final uri = (url != null && url.isNotEmpty) ? Uri.tryParse(url) : null; - if (uri != null) { - try { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } catch (_) {} - } - return null; - }, - ); - controller.addJavaScriptHandler( - handlerName: 'verifyMobileId', - callback: (args) async { - final url = args.isNotEmpty ? args.first?.toString() : null; - if (url == null || url.isEmpty) return null; - return digitalIdModule.fetchMobileIdVerification(url); - }, - ); - }, onExternalCallback: webAppModule.handleExternalCallback, onConsoleMessage: (controller, consoleMessage) { final msg = '[DID] ${consoleMessage.message}'; diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 15be359..07fbf27 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -33,6 +33,7 @@ import '../../../core/config/app_digital_id_mode.dart'; import '../../../core/utils/webview_support.dart'; import '../digital_id/digital_id_screen.dart'; import '../digital_id/digital_id_web_screen.dart'; +import '../webapp/web_app_bridge.dart'; import '../webapp/web_app_screen.dart'; import 'cloud_storage_screen.dart'; import 'customization_section.dart'; @@ -428,6 +429,7 @@ class _SettingsTabState extends State with SpectrumSurface { MaterialPageRoute( builder: (context) => WebAppScreen( title: 'Сферум', + entryPoint: WebAppEntryPoint.settings, loader: () => webAppModule.fetchSferum(), ), ), diff --git a/lib/frontend/screens/webapp/open_mini_app.dart b/lib/frontend/screens/webapp/open_mini_app.dart index 19c50e4..bba7180 100644 --- a/lib/frontend/screens/webapp/open_mini_app.dart +++ b/lib/frontend/screens/webapp/open_mini_app.dart @@ -7,6 +7,7 @@ import '../../../core/utils/webview_support.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart' show webAppModule; import '../../widgets/custom_notification.dart'; +import 'web_app_bridge.dart'; import 'web_app_screen.dart'; Future openMiniApp( @@ -14,6 +15,7 @@ Future openMiniApp( required int botId, required String title, int? chatId, + String entryPoint = WebAppEntryPoint.webApp, }) async { if (botId <= 0) return; Haptics.tap(); @@ -23,6 +25,7 @@ Future openMiniApp( MaterialPageRoute( builder: (_) => WebAppScreen( title: title, + entryPoint: entryPoint, loader: () => webAppModule.fetchLaunch(botId, chatId: chatId), ), ), diff --git a/lib/frontend/screens/webapp/web_app_bridge.dart b/lib/frontend/screens/webapp/web_app_bridge.dart new file mode 100644 index 0000000..d7078aa --- /dev/null +++ b/lib/frontend/screens/webapp/web_app_bridge.dart @@ -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?> Function( + String url, +); + +typedef WebAppEmitter = + void Function(String method, String payload, bool private); + +const Duration _gestureWindow = Duration(milliseconds: 3000); + +const Set _gestureGated = { + 'WebAppMaxShare', + 'WebAppShare', + 'WebAppDownloadFile', + 'WebAppOpenLink', + 'WebAppOpenMaxLink', +}; + +const Map _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 _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 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 handleEvent(String method, String? raw, bool private) async { + if (private && !privateChannel) return; + if (_gestureGated.contains(method) && !_hasRecentGesture) return; + + Map data = const {}; + if (raw != null && raw.isNotEmpty) { + try { + final decoded = jsonDecode(raw); + if (decoded is Map) data = Map.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 _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 _openLink(String? url) async { + if (url == null || url.isEmpty) return; + final context = contextResolver(); + if (context == null) return; + await openExternalUrl(context, url); + } + + Future _share( + String method, + String? requestId, + Map 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 _maxShare( + String method, + String? requestId, + Map 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 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 _storageSave( + String method, + String? requestId, + Map 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 _storageGet( + String method, + String? requestId, + Map 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 _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 _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 _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 _biometryUpdateToken( + String method, + String? requestId, + Map 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 _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 _notification(String? type) async { + if (type == 'error') { + await Haptics.error(); + return; + } + await Haptics.success(); + } + + Future _downloadFile( + String method, + String? requestId, + Map 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 _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( + 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 _verifyMobileId( + String method, + String? requestId, + Map 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 {}, + 'data': result['data'] ?? '', + }, private: private); + } catch (_) { + _fail(method, requestId, 'request_error', private: private); + } + } + + static String _randomToken() { + final random = Random.secure(); + final bytes = List.generate(16, (_) => random.nextInt(256)); + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } +} diff --git a/lib/frontend/screens/webapp/web_app_screen.dart b/lib/frontend/screens/webapp/web_app_screen.dart index 3d9821a..37ec2e7 100644 --- a/lib/frontend/screens/webapp/web_app_screen.dart +++ b/lib/frontend/screens/webapp/web_app_screen.dart @@ -6,15 +6,21 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/webapp.dart'; import '../../../core/storage/spoofing_service.dart'; +import '../../../core/utils/link_opener.dart'; import '../../../main.dart' show api; +import '../../widgets/confirm_dialog.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/error_view.dart'; import '../../widgets/small_spinner.dart'; import '../../widgets/webview_permission_prompt.dart'; +import 'web_app_bridge.dart'; class WebAppScreen extends StatefulWidget { final String title; final Future Function() loader; + final String entryPoint; + final bool privateChannel; + final WebAppMobileIdVerifier? mobileIdVerifier; final List? extraUserScripts; final void Function(InAppWebViewController controller)? onWebViewCreated; final void Function( @@ -38,6 +44,9 @@ class WebAppScreen extends StatefulWidget { super.key, required this.title, required this.loader, + this.entryPoint = WebAppEntryPoint.webApp, + this.privateChannel = false, + this.mobileIdVerifier, this.extraUserScripts, this.onWebViewCreated, this.onConsoleMessage, @@ -54,10 +63,12 @@ class WebAppScreen extends StatefulWidget { class _WebAppScreenState extends State { InAppWebViewController? _controller; + WebAppBridge? _bridge; WebAppLaunch? _launch; String? _loadError; String _userAgent = ''; double _progress = 0; + Size _viewport = Size.zero; @override void initState() { @@ -65,10 +76,18 @@ class _WebAppScreenState extends State { _load(); } + @override + void dispose() { + _bridge?.dispose(); + super.dispose(); + } + Future _load() async { setState(() { _loadError = null; _launch = null; + _bridge?.dispose(); + _bridge = null; }); try { // Тот же UA, что уходит в sessionInit (из handshake-устройства ядра), @@ -89,19 +108,50 @@ class _WebAppScreenState extends State { } final launch = await widget.loader(); if (!mounted) return; - setState(() => _launch = launch); + setState(() { + _launch = launch; + _bridge = _createBridge(launch.botId); + }); } catch (e) { if (!mounted) return; setState(() => _loadError = e.toString()); } } + WebAppBridge _createBridge(int botId) => WebAppBridge( + botId: botId, + entryPoint: widget.entryPoint, + privateChannel: widget.privateChannel, + mobileIdVerifier: widget.mobileIdVerifier, + contextResolver: () => mounted ? context : null, + viewportResolver: () => _viewport, + onClose: _closeFromWebApp, + ); + + void _closeFromWebApp() { + if (!mounted) return; + Navigator.of(context).maybePop(); + } + Future _handleBack() async { + final bridge = _bridge; + if (bridge != null && bridge.handlesBackButton) { + bridge.notifyBackPressed(); + return false; + } final controller = _controller; if (controller != null && await controller.canGoBack()) { await controller.goBack(); return false; } + if (bridge != null && bridge.needsCloseConfirmation && mounted) { + return showConfirmDialog( + context, + title: widget.title, + message: 'Закрыть мини-приложение?', + confirmLabel: 'Закрыть', + ); + } return true; } @@ -185,14 +235,28 @@ class _WebAppScreenState extends State { return ErrorView(message: _loadError!, onRetry: _load); } final launch = _launch; - if (launch == null) { + final bridge = _bridge; + if (launch == null || bridge == null) { return const Center(child: SmallSpinner(size: 36)); } + return LayoutBuilder( + builder: (context, constraints) { + _viewport = Size(constraints.maxWidth, constraints.maxHeight); + return Listener( + onPointerDown: (_) => bridge.registerGesture(), + child: _buildWebView(launch, bridge), + ); + }, + ); + } + + Widget _buildWebView(WebAppLaunch launch, WebAppBridge bridge) { return InAppWebView( initialUrlRequest: URLRequest(url: WebUri(launch.url)), - initialUserScripts: widget.extraUserScripts == null - ? null - : UnmodifiableListView(widget.extraUserScripts!), + initialUserScripts: UnmodifiableListView([ + bridge.userScript, + ...?widget.extraUserScripts, + ]), initialSettings: InAppWebViewSettings( javaScriptEnabled: true, domStorageEnabled: true, @@ -201,24 +265,28 @@ class _WebAppScreenState extends State { transparentBackground: true, mediaPlaybackRequiresUserGesture: false, useHybridComposition: true, - useShouldOverrideUrlLoading: - widget.shouldOverrideUrlLoading != null || - widget.onExternalCallback != null, + supportMultipleWindows: true, + allowFileAccess: false, + useShouldOverrideUrlLoading: true, userAgent: _userAgent, ), onWebViewCreated: (controller) { _controller = controller; + bridge.attach(controller); widget.onWebViewCreated?.call(controller); }, onPermissionRequest: (controller, request) => askWebViewPermission(context, request), + onCreateWindow: (controller, action) async { + final url = action.request.url?.toString(); + if (url != null && url.isNotEmpty && mounted) { + await openExternalUrl(context, url); + } + return false; + }, onConsoleMessage: widget.onConsoleMessage, onLoadStart: widget.onLoadStart, - shouldOverrideUrlLoading: - widget.shouldOverrideUrlLoading == null && - widget.onExternalCallback == null - ? null - : _handleNavigation, + shouldOverrideUrlLoading: _handleNavigation, onProgressChanged: (controller, progress) { if (!mounted) return; setState(() => _progress = progress / 100); diff --git a/lib/frontend/widgets/max_link_nav.dart b/lib/frontend/widgets/max_link_nav.dart index 142cc8e..58b76dc 100644 --- a/lib/frontend/widgets/max_link_nav.dart +++ b/lib/frontend/widgets/max_link_nav.dart @@ -11,6 +11,7 @@ import '../../main.dart'; import '../screens/chats/chat_list_screen.dart'; import '../screens/chats/chat_screen.dart'; import '../screens/contacts/open_contact_profile.dart'; +import '../screens/webapp/web_app_bridge.dart'; import '../screens/webapp/web_app_screen.dart'; import 'custom_notification.dart'; import 'sticker_pack_sheet.dart'; @@ -137,6 +138,7 @@ Future openWebAppForBot( context, (_) => WebAppScreen( title: title, + entryPoint: WebAppEntryPoint.url, loader: () => webAppModule.fetchLaunch( botId, startParam: startParam, diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index c3c5e13..f40e655 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -8,6 +8,7 @@ import 'package:flutter/rendering.dart'; import 'package:komet/main.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../backend/modules/messages.dart'; +import '../screens/webapp/web_app_bridge.dart'; import '../screens/webapp/web_app_screen.dart'; import '../../core/config/app_bubble_behavior.dart'; import '../../core/config/app_bubble_shape.dart'; @@ -1444,6 +1445,7 @@ class MessageBubble extends StatelessWidget { MaterialPageRoute( builder: (_) => WebAppScreen( title: button.text, + entryPoint: WebAppEntryPoint.inlineButton, loader: () => webAppModule.fetchLaunch( botId, startParam: startParam, diff --git a/max-deeplinks.md b/max-deeplinks.md deleted file mode 100644 index 16226b9..0000000 --- a/max-deeplinks.md +++ /dev/null @@ -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/` | чат/канал/бота по публичной ссылке; если такой чат уже локально есть — открывает его напрямую, иначе резолвит на сервере | `JoinLink` → `ShowChat` / `ShowContact` / `ConfirmJoin` | -| `max.ru/@` | то же, ник ищется среди ссылок известных чатов | → чат либо `UnknownContact` | -| `max.ru/?start=` | диалог с ботом + автозапуск: отправляется `botStarted` со `startPayload` | `ShowContactDialog(chatId, startPayload, externalCallback)` | -| `max.ru/?startapp=` | мини-приложение бота (payload обрезается по первому `&`, query из ссылки вычищается) | `StartWebAppLink` → `OpenWebApp(botId, startParam)` / `ErrorWebAppNotExist` | -| `max.ru/join/` | вступление по приватной инвайт-ссылке | `JoinLink` → `ConfirmJoin` / `ShowChat` | -| `max.ru/joincall/` | экран входа в звонок по ссылке | `CallJoinLink` → `ShowJoinCall` | -| `max.ru/stickerset/` | стикерпак; `id` берётся до первого `-` | `StickerSet` → `ShowStickerSet` | -| `max.ru//` | чат на конкретном сообщении (второй сегмент должен быть числом) | `MessagestLink` → `ShowChat(chatId, messageId)` | -| `max.ru/c//` | сообщение/пост в чате по числовым id | `MessagestLink` → `ShowChat` | -| `max.ru/:folder?id=` | список чатов в папке | `FolderChatList` → `OpenChatListInFolder`, иначе `UnknownFolderError` | -| `max.ru/?uid=` | контакт/диалог по id пользователя (ищется локально) | `DeepLinkData(contactId)` | -| `max.ru/?cid=` | чат по серверному 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=` | шэринг текста внутрь приложения (выбор чата) | маршрут `: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/`, `@`, `u/` | резолв через `LINK_INFO` → чат, канал или профиль | -| `?start=` | диалог с ботом + автоотправка `botStarted` | -| `?startapp=` | мини-приложение бота (`WebAppScreen`), botId берётся из резолва ссылки | -| `join/` | подтверждение вступления и переход в чат | -| `joincall/` | экран входа в звонок | -| `stickerset/` | шит стикерпака | -| `/`, `c//` | чат с переходом к сообщению (время берётся из `LINK_INFO`, иначе догружаем историю назад) | -| `?uid=` | профиль контакта | -| `?cid=` | чат по серверному id | -| `:folder?id=` | корневой список с выбранной папкой | -| `:auth/` | подтверждение веб-логина (в отличие от 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` не обрабатывается. diff --git a/test/webapp_bridge_test.dart b/test/webapp_bridge_test.dart new file mode 100644 index 0000000..cc13c81 --- /dev/null +++ b/test/webapp_bridge_test.dart @@ -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, 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, + 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', + }); + }); +}