Move push_strings.xml generation into Gradle AGP task so XMLs are never committed and direct gradle builds work without npm run android:sync.

This commit is contained in:
v.lagerev 2026-04-25 00:36:07 +03:00
parent 7af04a429d
commit 19d3dc0813
8 changed files with 644 additions and 44 deletions

View file

@ -60,6 +60,44 @@ dependencies {
apply from: 'capacitor.build.gradle'
abstract class GeneratePushStringsTask extends DefaultTask {
@InputFiles
abstract ConfigurableFileCollection getInputFiles()
@OutputDirectory
abstract DirectoryProperty getOutputDir()
@TaskAction
void generate() {
def nodeBin = project.findProperty('NODE_BIN') ?: 'node'
project.exec {
commandLine nodeBin,
new File(project.rootProject.projectDir.parentFile, 'scripts/gen-push-strings.mjs').absolutePath,
'--out', outputDir.get().asFile.absolutePath
}
}
}
androidComponents {
onVariants(selector().all()) { variant ->
def repoRoot = rootProject.projectDir.parentFile
def taskProvider = tasks.register(
"generatePushStrings${variant.name.capitalize()}",
GeneratePushStringsTask
) {
inputFiles.from(
new File(repoRoot, 'public/locales/en.json'),
new File(repoRoot, 'public/locales/ru.json'),
new File(repoRoot, 'scripts/gen-push-strings.mjs')
)
outputDir.set(layout.buildDirectory.dir("generated/res/push/${variant.name}"))
}
variant.sources.res.addGeneratedSourceDirectory(
taskProvider, GeneratePushStringsTask::getOutputDir
)
}
}
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {

View file

@ -1,15 +0,0 @@
<?xml version='1.0' encoding='utf-8'?>
<!--
AUTO-GENERATED by scripts/gen-push-strings.mjs.
DO NOT EDIT — edit public/locales/ru.json and rerun
`npm run gen:push-strings` (or any `npm run android:sync`).
-->
<resources>
<string name="push_new_message">Новое сообщение</string>
<string name="push_new_messages">Новые сообщения</string>
<string name="push_invitation">Приглашение</string>
<string name="push_invite_body" formatted="true">%1$s приглашает вас в %2$s</string>
<string name="push_invite_body_no_room" formatted="true">%1$s приглашает вас в комнату</string>
<string name="push_invite_body_no_inviter" formatted="true">Приглашение в %2$s</string>
<string name="push_invite_body_generic">Новое приглашение</string>
</resources>

View file

@ -1,15 +0,0 @@
<?xml version='1.0' encoding='utf-8'?>
<!--
AUTO-GENERATED by scripts/gen-push-strings.mjs.
DO NOT EDIT — edit public/locales/en.json and rerun
`npm run gen:push-strings` (or any `npm run android:sync`).
-->
<resources>
<string name="push_new_message">New message</string>
<string name="push_new_messages">New messages</string>
<string name="push_invitation">Invitation</string>
<string name="push_invite_body" formatted="true">%1$s invited you to %2$s</string>
<string name="push_invite_body_no_room" formatted="true">%1$s invited you to a room</string>
<string name="push_invite_body_no_inviter" formatted="true">Invited you to %2$s</string>
<string name="push_invite_body_generic">New invitation</string>
</resources>

View file

@ -48,6 +48,12 @@ See [`.vscode/tasks.json`](../../.vscode/tasks.json):
- `Deploy to vojo.chat` (Ctrl+Shift+D) — web deploy
- `Deploy to Android (ADB)` (Ctrl+Shift+A) — build + `adb install`
## Push string resources (generated)
Push notification text for Android is generated from `public/locales/{en,ru}.json` (namespace `Push`) by `scripts/gen-push-strings.mjs`. The Gradle build runs this automatically via `GeneratePushStringsTask` registered in `android/app/build.gradle` through AGP `addGeneratedSourceDirectory` — output goes to `build/generated/res/push/<variant>/values{,-ru}/push_strings.xml`. No manual step needed; `./gradlew assembleDebug` handles it.
The task requires `node` in `PATH`. Terminal builds and CI inherit it from the shell. **macOS Android Studio with nvm/fnm:** the GUI app may not see nvm-managed node. Workaround: set `NODE_BIN=/path/to/node` in `android/gradle.properties` (the task reads it via `project.findProperty('NODE_BIN')`) or launch AS from a shell that sources your node manager (`open -a "Android Studio"`).
## ADB wireless workflow
1. On the phone, enable Wireless debugging, tap "Pair device with pairing code" — note IP, port, 6-digit code.

View file

@ -0,0 +1,579 @@
# DM-звонки — техдолг и замечания по реализации
Живой документ. Дополнение к [dm_calls.md](dm_calls.md). Здесь — **регистр** открытых багов, отложенных тестов и polish-задач. Имплементационные детали уходят в код / коммиты, сюда пишем только: симптом, влияние, направление фикса, когда возвращаться.
Принцип: каждый пункт имеет **статус** (🟢/🟡/🔴), краткое описание, и **где вернуться** (фаза/приоритет).
---
## Содержание
- [1. Phase 1 — статус готовности](#1-phase-1--статус-готовности)
- [2. Активные баги и наблюдения](#2-активные-баги-и-наблюдения)
- [3. Отложенные тесты](#3-отложенные-тесты)
- [4. Polish backlog](#4-polish-backlog)
- [5. Технический долг](#5-технический-долг)
---
## 1. Phase 1 — статус готовности
### 1.1. Проверено живьём ✅
| # | Сценарий | Платформа | Статус |
|---|----------|-----------|--------|
| 1 | Кнопка трубки в DM (Start/Join/disabled) | web | 🟢 |
| 2 | A звонит, аудио двухстороннее | web↔web | 🟢 |
| 3 | CallStatus pill в DM-комнате | web + Android | 🟢 |
| 4 | voiceOnly скрывает Video/ScreenShare | web | 🟢 |
| 5 | Hangup закрывает iframe, атом сбрасывается | web | 🟢 |
| 6 | Full lifecycle: A hangup → B hangup → обе кнопки в Start | web↔web | 🟢 |
| 7 | Mutex: занят в другой комнате → tooltip "Вы уже в другом звонке" | web | 🟢 |
| 8 | Firefox web — звонок проходит, autoplay не блокирует | web | 🟢 |
### 1.2. Проверено только чтением кода
| # | Что | Риск |
|---|-----|------|
| 1 | Privacy-фикс `video: false` при voiceOnly | низкий — Element Call уважает intent |
| 2 | `pointer-events: none` на скрытом iframe | низкий — клики проверены |
| 3 | Autodispose `callEmbedAtom` при logout / `beforeunload` | средний — leak возможен |
### 1.3. Phase 1 — критерии мержа
- [x] Живые сценарии 1.1
- [x] Code review 1.2 (risks accepted)
- [ ] Android mic — перенесено в Phase 3 (см. **2.3**)
**Phase 1 готова к коммиту в `vojo/dev`**.
---
## 2. Активные баги и наблюдения
### 2.1. 🟡 B видит "Start call" пока A уже звонит — race ongoing-резолва
- **Симптом:** у B кнопка показывает **Start**, не **Join**, хотя A уже звонит.
- **Корень:** `MatrixRTCSession.sessionMembershipsForRoom` возвращает 0 у B — state event ещё не обработан при рендере.
- **Когда чинить:** Phase 2 (там в любом случае добавляется RTCNotification listener).
### 2.2. 🟢 Android dropout при lock screen / сворачивании — закрыто 2026-04-22 — 2026-04-23
#### Симптом
Звонок рвался в ~21s после блокировки экрана на Samsung OneUI (и вероятно любом Android 14+). Peer видел нас зависшими в session ещё минутами (связано с §5.21 zombie membership).
#### Изначальный диагноз — неверный
Гипотеза была "Chromium тротлит JS-таймеры в hidden WebView → LiveKit keepalive starvation → peer connection рвётся". Phase 0 instrumentation (1-сек heartbeat с `deltaMs` логом) опроверг: JS event loop работает идеально под lock, 21 тик подряд с `deltaMs=999..1001`. WebView renderer не эвакуируется, процесс не freezed.
#### Реальный диагноз
Две Android platform-policy защиты против background-приложения без FGS:
1. **AppOps revocation of `android:record_audio`** (Android 12+ while-in-use gating). В ~T+5s после lock ОС переводит op в `Active: false`. Мик-track в WebRTC становится muted/ended — peer перестаёт нас слышать.
2. **Background network firewall** через `NetdEventListenerService`. В ~T+13s netd начинает возвращать `isBlocked=true` для DNS/TCP от UID приложения. LiveKit/ICE keepalive не проходят, session экспайрит на сервере.
Evidence из живого capture (Samsung OneUI API 36, один прогон 2026-04-22):
| T+ от lock | Событие | Источник в logcat |
|---|---|---|
| 0s | Lock | `PhoneWindowManager: Going to sleep OFF_BECAUSE_OF_USER` |
| ~5s | Мик отозван | `AiPrivacy::SaSensorRepository: record_audio ... Active: false` |
| ~13s | Сеть заблокирована | `NetdEventListenerService: ... isBlocked=true` |
| ~19s | Audio pipe EPIPE каскад | `chromium: services/audio/sync_reader.cc: Broken pipe` |
| ~21s | Widget teardown | `AS.AudioService: setMode(mode=0)` |
| ~22s (unlock) | Dispose | JS → `callEmbedAtom(undefined)` |
#### Альтернативы, которые рассмотрели и отклонили
- **`KEEP_SCREEN_ON` / `PARTIAL_WAKE_LOCK`** — fake-retention. Wake lock держит CPU, но не снимает AppOps mic revocation и не лифтит netd firewall. Не решает root cause.
- **`ROLE_DIALER` (стать default dialer)** — overkill. Конфликт с реальным dialer пользователя, Play-review риск, не нужны для нашего UX.
- **`ConnectionService` / `MANAGE_OWN_CALLS` / `core-telecom` self-managed VoIP** — правильный long-term путь для native ongoing-call UX (BT routing, ongoing CallStyle, audio focus arbitration), но weeks of work и Play-review re-entry. Для простого "не ронять звонок под lock" — избыточно. Оставлено как опциональный Phase 3 upgrade, если продукту понадобится full native ongoing UX. Ранее тот же tradeoff обсуждался в §5.34 для другой UX-задачи (lockscreen Answer/Decline buttons) и был отклонён по тем же причинам.
- **`foregroundServiceType="mediaPlayback"`** — лифтит netd firewall, но НЕ лифтит AppOps mic revocation. Покрывает половину проблемы.
- **Fallback `foregroundServiceType="none"` при отсутствии RECORD_AUDIO** — на API 34+ Android требует, чтобы тип при `startForeground()` был subset манифестных types. Манифест объявляет только `microphone``NONE` не subset → `SecurityException`/`ForegroundServiceTypeException`. Этот путь рассматривался и был явно забракован рецензером до мержа.
#### Выбор: `microphone` FGS
`foregroundServiceType="microphone"` закрывает обе revocation'а одним шагом:
- держит `android:record_audio` AppOps в `Active: true` всё время жизни сервиса → мик не отзывается;
- переводит процесс в `PROCESS_STATE_FOREGROUND_SERVICE` → netd background firewall не применяется.
Permissions: `FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_MICROPHONE` (normal-level, manifest declaration достаточна) + уже существующий `RECORD_AUDIO`.
#### Ключевое design-решение: lifecycle keyed на widget `joined`, не на `!!callEmbed`
Сервис стартует не при создании embed, а когда widget внутри iframe реально отправил `JoinCall` action (через `useCallJoined` hook). Причина:
- К моменту `JoinCall` Element Call внутри iframe уже вызвал `getUserMedia` → OS-промпт `RECORD_AUDIO` обработан, grant в наличии. Это гарантирует что `startForeground(..., TYPE_MICROPHONE)` не упадёт в `SecurityException`.
- Манифест декларирует только `microphone`, TYPE_NONE не валидный subset на API 34+ → fallback-путь исключён архитектурно, никаких try/retry.
- Если widget вышел в `preparingError`, `joined` никогда не становится true → FGS не стартует → нет ghost-notification.
Tradeoff: 1-3s окно между созданием embed и `JoinCall` без retention. В этом окне media ещё не живой (mic не шлёт данных) → lock там — non-event. См. также §5.41 для answer-from-killed варианта этого окна.
#### Сопутствующие правки
- `isAndroidPlatform()` вместо `isNativePlatform()` — чтобы wrapper не пытался звать несуществующий plugin path на iOS (на будущее).
- `onPreparingError``setCallEmbed(undefined)` handler в `CallEmbedProvider` — для очистки zombie-embed в атоме при failure widget'а; не критично для FGS после joined-gating, но полезно само по себе.
#### Файлы
- Новые: [CallForegroundService.java](../../android/app/src/main/java/chat/vojo/app/CallForegroundService.java), [CallForegroundPlugin.java](../../android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java), [callForegroundService.ts](../../src/app/plugins/call/callForegroundService.ts), [useAndroidCallForegroundSync.ts](../../src/app/hooks/useAndroidCallForegroundSync.ts).
- Изменённые: [AndroidManifest.xml](../../android/app/src/main/AndroidManifest.xml) (permissions + service declaration), [MainActivity.java](../../android/app/src/main/java/chat/vojo/app/MainActivity.java) (plugin register), [CallEmbedProvider.tsx](../../src/app/components/CallEmbedProvider.tsx) (hook mount + preparingError), [capacitor.ts](../../src/app/utils/capacitor.ts) (`isAndroidPlatform`).
- Commit: `3a34a4d` на `vojo/dev`.
#### Валидация
- Samsung OneUI API 36 — подтверждён живьём, 1m42s под lock без revocation'ов и без teardown, hangup нормальный.
- Pixel / stock AOSP — pending, см. §3.7.
- Vendor aggressive battery savers — не тестировалось, см. §5.40.
- Answer-from-killed window 1-3s — известный gap, см. §5.41.
- Double ring UX в 5-sec grace после сворачивания — отдельный bug, см. §5.39.
#### Как потом даблчекнуть
Повторить Phase 0 T1-сценарий: debug APK → `adb logcat -c && adb logcat -v threadtime > ~/phase0-check.log` → звонок A→B, B отвечает, A блокирует 2-3 мин, unlock, hangup. Filter: `grep -E 'CallFgs|AiPrivacy.*record_audio|NetdEventListenerService|chat.vojo.app' ~/phase0-check.log`. Gate проходит если нет `record_audio ... Active: false` и нет `isBlocked=true` во время lock; есть `CallFgs: startForeground ok type=microphone` на JoinCall и `CallFgs: onDestroy` на hangup.
### 2.3. 🟢 Android mute — работает, misdiagnosed — проверено 2026-04-24
- Livetest подтвердил: mute-кнопка глушит mic на Android. Изначальный симптом был, вероятно, непониманием upstream deafen-логики: «Turn Off Sound» auto-мьютит mic и наоборот ([CallControl.ts:157-159, 207-209](../../src/app/plugins/call/CallControl.ts#L207-L209), upstream PR #2680/#2737 Discord-style). UI labels не отражают deafen semantics, отсюда confusion. Оставляем как есть — не наш код, DM-impact минимальный.
### 2.4. 🟡 Stale membership после hangup (~10-15с)
- Серверная задержка очистки membership. Кнопка показывает "Join" ещё ~15с.
- **Фикс:** `recentlyHungUpAtom` с TTL 30s → "Завершение…" локально.
- **Когда чинить:** Phase 3.
### 2.5. 🟡 i18n debt — английские tooltip'ы
- В [CallControl.tsx](../../src/app/features/call-status/CallControl.tsx) и iframe Element Call — зашитые английские строки (`End`, `Turn Off Microphone`, …).
- **Фикс:** `Call.*` в `locales/{en,ru}.json` + `useTranslation`. iframe-строки — апстрим, не наш scope.
- **Когда чинить:** отдельный i18n-тикет.
---
## 3. Отложенные тесты
### 3.1. E2EE-комнаты
- **Почему нельзя:** well-known форсит `force_disable=true`, E2EE-комнату взять негде.
- **Когда:** отдельный продуктовый эпик.
### 3.2. Network drop / iframe reconnect
- **Почему нельзя:** internals Element Call, нужна площадка с throttling + второй живой собеседник.
- **Когда:** Phase 3.
### 3.3. Multi-device
- **Почему нельзя:** зависит от RTCNotification + RTCDecline (Phase 2).
### 3.4. Killed-state push (FCM на закрытом Android)
- **Когда:** Phase 2.5 runbook.
### 3.5. Ringtone / Ringback / callend
- **Когда:** Phase 2 (ring) + Phase 3 (ringback, callend).
### 3.6. "Live" индикатор в списке DM
- **Когда:** отдельный UX-тикет после Phase 2.
### 3.7. Phase 1 FGS retention — multi-vendor validation
- **Статус:** Samsung OneUI API 36 — прошло живьём (см. §2.2). Pixel / stock AOSP — не тестировалось.
- **Почему нужно:** Phase 0 evidence base был single-vendor (Samsung-specific теги `AiPrivacy::SaSensorRepository`, AppOps revocation moment). Нужно подтвердить, что `microphone` FGS лифтит те же revocation'ы и на stock AOSP, иначе риск "починил Samsung-specific, не чинит baseline".
- **Как проверять:** повторить Phase 0 T1-сценарий на Pixel: `npm run build:android:debug` → APK → `adb logcat -c && adb logcat -v threadtime > ...ring.log` → звонок + lock 2-3 мин → проверить в логе отсутствие `record_audio ... Active: false` и `NetdEventListenerService: isBlocked=true` во время lock.
- **Когда:** при доступе к Pixel-классу устройству. Критерий acceptance — Phase 1 (§2.2) закрывается полностью только после multi-vendor подтверждения.
---
## 4. Polish backlog
- Таймер длительности в CallStatus
- Ringback / callend звуки
- Dispose callEmbed при logout / `beforeunload`
- AudioContext silent-catch на autoplay reject
- Android mic sanity (**2.3**)
- `recentlyHungUpAtom` (**2.4**)
- i18n (**2.5**)
- Multi-device self-ring suppression
---
## 5. Технический долг
### 5.1. 🟡 Iframe скрыт `visibility:hidden` — modal-prompts невидимы
- На iOS Safari Element Call может показать permission prompt — в скрытом iframe пользователь не увидит.
- На web Chrome/Firefox и Android — permission уже выдан, не блокер.
### 5.2. 🟡 `guessDmRoomUserId` edge cases
- Некорректно помеченная в `m.direct` DM → резолв партнёра может дать self. Шапка не жалуется.
### 5.3. 🟡 Phase 4 — beta voice-room cleanup
- Legacy CallView/CallChatView/`RoomType.Call` для beta voice-комнат. Удаление — отдельный PR после стабилизации DM.
### 5.7. 🟡 Нет реплея ring после полной потери сессии
- **Краевой:** если A звонила **до** того как B залогинился и `lifetime` истёк до первого sync — тост не появится. Также при реальной потере сети B.
- **Фикс:** при mount хука пройтись по live timeline DM за последние `lifetime` мс.
- **Когда чинить:** Phase 2.5 (вместе с push).
### 5.10. 🟡 В уже в звонке в другой комнате — молчаливый skip
- **Симптом:** А+В, В в звонке в комнате X → никакой индикации дозвона у В.
- **Корень:** ранний return на `inCallRef.current` в [useIncomingRtcNotifications.ts:193](../../src/app/hooks/useIncomingRtcNotifications.ts#L193).
- **Решается вместе с §5.36** (multi-call invariants).
### 5.11. 🟡 Multi-client (Element X одновременно) — не проверено
- Риски: двойной звук, рассинхрон при answer.
- **Когда чинить:** Phase 2.5 / 3.
### 5.14. 🟡 Ключ дедупа ring не поддержит group-ring
- `CallMembership.callId === ""` для всех Element Call звонков → ключ `call_${callId}_${roomId}` сводится к одному на комнату. Сейчас безопасно — group ring шлёт `'notification'`, фильтр отсекает.
- **Когда чинить:** если появится group ring — переделать ключ на `call_${roomId}_${sender}`.
### 5.15. 🟡 `useCallerAutoHangup` — имя не отражает scope
- Хук срабатывает на любой DM callEmbed, не только caller.
- **Фикс:** переименовать в `useDmCallAutoHangup`.
- **Когда чинить:** при следующем touch'е файла.
### 5.16. 🟡 Decline не сверяется с конкретным ring event_id
- **Риск:** два ring'a в одной DM подряд от одного peer'а → decline старого может положить наш текущий звонок. Теоретическая дыра, не воспроизведена.
- **Фикс:** матчить `rel.event_id` с `notifEventId` нашего исходящего ring'а.
- **Решается вместе с §5.36.**
### 5.17. 🟡 Ring audio — нет fallback при блокировке autoplay
- **Симптом (confirmed live 2026-04-24):** после reload страницы входящий звонок — визуал strip есть, звонок active, `ring.ogg` молчит. Chrome autoplay policy блокирует `audio.play()` без user gesture; promise reject'ится, catch silent.
- **Real-world impact:** юзер пропускает входящие после любого page reload пока не кликнет хоть раз по странице. Не corner case — типичный PWA flow.
- **Корень:** в [IncomingCallStripRenderer.tsx](../../src/app/pages/IncomingCallStripRenderer.tsx) `audio.play()` reject swallow'ится без user-visible fallback.
- **Фикс-направления:**
- (a) детектить reject → visible affordance «Tap to enable ringtone» рядом со strip
- (b) pre-unlock audio на первом любом user-gesture после mount (one-shot `click`/`keydown` listener → dummy `audio.play()` → реальный ring потом сможет играть)
- (c) (a)+(b) combo — best-effort unlock + visible fallback если не удалось
- **Когда чинить:** real bug, high priority Phase 3 polish. (b) скорее всего самый низкий код и UX impact — пробовать первым.
### 5.18. 🟡 Magic numbers — grace timeouts подобраны на глаз
- `NO_ANSWER_GRACE_MS=10_000`, `PEER_LEAVE_GRACE_MS=8_000`. Эмпирика без метрик /sync latency и LiveKit reconnect.
- **Фикс:** собрать прод-метрики, вынести в config.
### 5.19. 🟡 Hardcoded event type strings в RoomTimeline filter
- `'org.matrix.msc4075.rtc.notification'` / `'org.matrix.msc4310.rtc.decline'` литералами. После stable-финализации MSC фильтр молча сломается.
- **Фикс:** computed property keys с `EventType.RTCNotification` / `RTCDecline`.
- **Когда чинить:** при следующем touch'е `typeToRenderer` или обновлении matrix-js-sdk.
### 5.20. 🟡 `ClientNonUIFeatures` / `roomToUnread` — не обрабатывают encrypted events
- Baseline cinny, не наш scope. В encrypted DM может теряться notification sound/popup пока событие не расшифровано.
- **Фикс:** `useTimelineAndDecryptedEvents()` helper, применить массово.
### 5.21. 🟠 LiveKit/network reconnect — mitigated, не fully closed — landed 2026-04-24 (commit 91afffc)
- **Симптом:** network blip 3-5с → LiveKit PC умирает, /sync восстанавливается, widget может не эмитить hangup наружу → pill "активный звонок" залипает, FGS остаётся ghost.
- **Корень:** MatrixRTC `DEFAULT_EXPIRE_DURATION=4h` + MembershipManager refresh на minutes-scale. Widget-api contract узкий (JoinCall/HangupCall/Close/DeviceMute) — LiveKit state наружу не exposed.
- **Что landed:**
- `io.element.close` listener в `CallEmbedProvider.tsx` (mirror element-web `Call.ts:901,995-999`) — EC error-state fallback auto-clears atom, даже если widget не emit Hangup.
- Hangup timeout 8с в `CallControl.tsx` (mirror element-web `performDisconnection` TIMEOUT_MS=16s, у нас 8s под mobile UX и matches `PEER_LEAVE_GRACE_MS`). Force-clear локально если widget не ack'ает.
- `try/catch` вокруг `callEmbed.hangup()` — transport-reject (widget dead) не skip'ает fallback polling.
- **Residual gap:** если EC вообще не эмитит Close/Hangup И юзер не жмёт End → pill всё ещё может висеть. Live-tests показывают что EC обычно cleanup'ит за 11с (internal reconnect timeout), так что это narrow edge case.
- **Не взяли (scope):** не патчим minified EC bundle (Element-HQ sanctioned upgrade model важнее), не добавляем Force End button (element-web тоже не делает), не добавляем `StopMessaging` listener — в §5.48 follow-up.
- **Validation:** 2 live-test сценария с adb wifi off/on. Логи подтверждают Close/Hangup firing через 11с → cleanup → FGS stop.
### 5.22. 🟠 После zombie-сессии повторный звонок — mitigated — landed 2026-04-24 (commit 91afffc)
- **Симптом:** после §5.21 попытка позвонить в ту же DM — silent fail через early-return в `useSwitchOrStartDmCall.ts:111` на `prev?.roomId === roomId`.
- **Что landed:** same-room retap теперь разделён — healthy (`prev.joined && peerPresent`) → no-op как раньше, zombie (`!joined || !peerPresent`) → best-effort `prev.hangup()` + direct `prev.dispose()` (skip waitLeave на unresponsive widget) → fresh start.
- **Residual gap:** форма `prev.joined && peerPresent && dead_media` всё ещё возвращает no-op. Закрывается через §5.21 hangup timeout: user жмёт End → 8с → force-clear → retap работает.
- **Не full closure** — retry во время EC reconnect spinner требует сначала End + ожидание 8с. Acceptable UX tradeoff без EC bundle patching.
### 5.23. 🟢 Multi-device decline — works — verified 2026-04-24
- [useIncomingRtcNotifications.ts:220-232](../../src/app/hooks/useIncomingRtcNotifications.ts#L220-L232) subscribe'ится на `MatrixRTCSessionEvent.MembershipsChanged` → если наш `user_id` появился в session (answer на другом device'е) → `removeByRoom`.
- RTCDecline event'ы через /sync обрабатываются в `processEvent:251-267``removeByNotifId`. Decline с device-1 → event через /sync → device-2 drops ring.
- Landed скорее всего вместе с §5.35 MVP decline patch. Остаточной latency после /sync нет.
### 5.24. 🟠 Cold-start SW push — accepted out-of-MVP per [dm_calls.md §2.5.2](dm_calls.md)
- **Симптом:** вкладки закрыты → SW без access_token → `fetchEventDetails` не запускается → показывается "New message" вместо "Incoming call".
- **Почему не чиним сейчас:** [dm_calls.md:540-547](dm_calls.md#L540) явно декларирует auto-join из cold-start вне MVP scope. Требует token-recovery из IndexedDB + RTC session без полного client boot — большое усложнение ради одного corner case'а.
- **Current MVP behavior:** клик по generic push → open client → live session подхватит ring-event из lifetime-окна (30с). Acceptable.
- **Когда пересмотреть:** если real-world feedback покажет что cold-start calls частые (PWA power users).
### 5.26. 🟡 Chrome notification click → «Open Vojo?» промпт
- **Симптом:** на Windows Chrome клик по OS-нотификации показывает popup вместо прямого focus. Аналогично для ring.
- **Гипотезы:** PWA × Chrome, background tab focus policy, late preventDefault.
- **Когда чинить:** Phase 3, требует Windows для repro.
### 5.27. 🟢 Push-уведомления на приглашения в комнату — landed 2026-04-24
- **Симптом (pre-fix):** на Android нативе push приходил, но показывался как «Vojo / New message» (не локализовано, generic). На web push тоже приходил как «Vojo / Новое сообщение». Корректный текст — «X пригласил вас в Y».
- **Root (Android):** [VojoFirebaseMessagingService.showSystemNotification](../../android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java#L220) делал `firstNonEmpty(data.get("content_body"), "New message")`. У invite event нет `content.body` → fallback. Локализации не было ни для invite, ни для fallback «New message». Локаль бралась через `Locale.getDefault()` (device), игнорируя in-app i18next язык — user на англоязычной OS с русским UI увидел бы англ текст.
- **Root (web):** [sw.ts `fetchEventDetails`](../../src/sw.ts#L250) звал `GET /_matrix/client/v3/rooms/{id}/event/{id}`. Spec: endpoint гейтится по history-visibility → **404 M_NOT_FOUND** для invitee (Synapse маскирует `403 M_FORBIDDEN` как 404, чтобы не утекал факт существования комнаты). Параллельный `/state/m.room.name/` тоже 403 для invitee (spec алгоритм state-endpoint'а требует `joined` или `former-joined`). Оба pre-join-эндпоинта бесполезны. Единственный documented путь для SW получить invite context — `GET /_matrix/client/v3/notifications` или `/sync?filter=...&timeout=0` (см. also: Element-web вообще не имеет SW push-path'а, строит уведомления in-tab через live `/sync`, т.е. reference client обходит проблему).
- **Landed architecture:** Push-текст стал DRY через три поверхности — i18next main app, Android FCM service, web SW. Source of truth — новый namespace `Push` в [public/locales/en.json](../../public/locales/en.json) и [public/locales/ru.json](../../public/locales/ru.json) (7 Android-shared keys + 3 SW-only: `encrypted_message`, `incoming_call`, `open_to_answer`). Copy на web-surface и Android-surface идентичен потому что тянется из одного файла; no hand-maintained mirror.
- **Landed fix (Android):**
- Build-time emitter [scripts/gen-push-strings.mjs](../../scripts/gen-push-strings.mjs) читает Push namespace → конвертит `{{inviter}}`/`{{roomName}}` в positional `%1$s`/`%2$s` (stable name→position мапа) → пишет `values{,-ru}/push_strings.xml`. Resource-файлы помечены AUTO-GENERATED; редактируется JSON. Скрипт валидирует parity (keys + placeholder tokens) и падает с ненулевым кодом если в en.json/ru.json расходятся ключи или placeholders. С §5.49 скрипт вызывается Gradle task'ом `GeneratePushStringsTask` с `--out build/generated/res/push/<variant>/`; XMLs не коммитятся.
- Почему build-time, не runtime JSON parse: `VojoFirebaseMessagingService.onMessageReceived` бежит до WebView/JS под killed-процесс; `JSONObject` parse на этом хот-path добавил бы новый класс «uncaught → missed push» failures. aapt валидация + Android Studio tools сохранены.
- [PushStrings.java](../../android/app/src/main/java/chat/vojo/app/PushStrings.java) — тонкий helper, делает `getString(R.string.push_*)` на `createConfigurationContext` с локалью юзера. Локаль берётся из Capacitor SharedPreferences (`CapacitorStorage.xml`, ключ `vojo.appLanguage`), fallback на device locale когда pref отсутствует (killed-cold-push до boot'а JS). Оба источника clamp'ятся в `en|ru` (anything else → `en`, English как lingua franca для user'а с device locale вне этого набора; i18next `fallbackLng` переведён на `'en'` в эту же сессию для consistency между поверхностями). [pushLanguageBridge.ts](../../src/app/utils/pushLanguageBridge.ts) мирроит `i18n.on('languageChanged')` на ОБЕ поверхности: `@capacitor/preferences` для Java + `postMessage({type:'setLanguage', lang})` для SW. Монтируется в [App.tsx](../../src/app/pages/App.tsx#L29).
- `showSystemNotification` в [VojoFirebaseMessagingService.java](../../android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java) — invite-ветка (детект `type=m.room.member` + `content_membership=invite`): title = `PushStrings.inviteTitle(ctx)`, body = 1 из 4 `R.string.push_invite_body_*` вариантов (`invite_body` / `_no_room` / `_no_inviter` / `_generic`) по `(hasInviter × hasRoom)`. Inviter = `sender_display_name` → MXID local-part (strip `@alice:hs.tld``alice`) → пусто. Всегда передаём оба positional arg'а (даже unused), иначе Android's positional formatter throws на `%2$s` без 2-го аргумента.
- **Landed fix (web):**
- [src/sw.ts](../../src/sw.ts) — runtime loader `loadBundle(lang)` делает `fetch(new URL('public/locales/{lang}.json', self.registration.scope))` (scope-relative на случай если Vite `base` станет не-root), кэширует parsed Push namespace в модульной `bundleCache`. Failures НЕ кэшируются — на transient 5xx следующий push попробует снова. `pushFallback()` теперь async, достаёт строки из bundle или из встроенного `HARDCODED` safety-net (8 коротких строк per lang) если bundle unavailable.
- SW language bridge через postMessage `{type:'setLanguage', lang}` + IndexedDB (`vojo-sw-meta` db, `kv` store, key `language`). Main thread постит на каждом `languageChanged` + re-posts через `sw.ready` и `controllerchange` (first-install race: controller ещё null когда bridge инициализируется). SW кэширует в модульной `swLanguage` + персистит в IDB для cold-wake. Fallback chain: `swLanguage` → IDB → `navigator.language` (normalized) → `'en'`.
- При `!evRes.ok` (404 для invitee) — `fetchInviteFromNotifications``GET /_matrix/client/v3/notifications?limit=30`: находит entry по `event_id`, извлекает inviter из `event.unsigned.invite_room_state` (m.room.member с `sender == event.sender`, fallback MXID local-part) и room name (m.room.name оттуда же). Одна retry на 400ms против Synapse-index лага. Fallback-of-fallback: если /notifications 5xx / offline / entry absent → generic брендовый notif.
- Wording семантика (both surfaces): title = «Приглашение»/«Invitation» всегда (WhatsApp/Telegram convention — маркер категории в шейде), inviter + room в body, 4 варианта.
- **Что НЕ сделано (известные gap'ы):**
- **`!isInForeground` гейт для invite path сохранён** (показываем native только в background). Architect предлагал снять для invite'ов (WhatsApp-like: баннер даже при открытом приложении). Отдельное product-решение, не часть text fix.
- **Channel names («Messages», «Incoming calls») в Java захардкожены** и не локализованы — видны юзеру в Settings → Notifications → Channels. Мелкая косметика, отдельный тикет.
- ~~**Drift detection для generated push_strings.xml.**~~ Закрыто §5.49 — XMLs теперь Gradle-generated, не коммитятся, drift невозможен by construction.
- **Diagnostic note:** сначала думали что Android push не приходит вообще — user'у показалось. Живой logcat показал что push доходит, просто текст неправильный (§5.27 investigation 2026-04-24).
### 5.28. 🔴 Edge: OS-уведомления о звонках не приходят
- **Симптом:** на Chrome call push работает, на Edge — Push Messaging пуст или баннер не показывается.
- **Корень:** Edge → WNS вместо FCM; известны нестабильности (410 Gone subscriptions, PWA install state).
- **Направление:** воспроизвести на Edge DevTools + Sygnal логи, re-subscribe retry на `pushsubscriptionchange`, тех-документ как снять проблему в Edge.
- **До фикса** — рекомендовать Chrome.
### 5.29. 🟡 In-tab звук уведомлений срабатывает через раз после 2.5.2-пивота
- **Симптом:** после «SW-only OS-нотификации» пивота звук в открытой вкладке играет неконсистентно.
- **Гипотезы:** autoplay policy gating, unreadCacheRef dedup, гонка SW push × navigation.
- **Направление:** `console.debug` по пути `handleTimelineEvent`, убрать dedup cache для sound path.
- **Когда чинить:** Phase 3.
### 5.30. 🟢 ADR 2.5 v1/v2 — CallStyle подавлялся AOSP — закрыто v3
- Закрыто 2026-04-19 добавлением `setFullScreenIntent(launchPI, true)` + `USE_FULL_SCREEN_INTENT` в манифест. Детали ADR — в истории коммитов.
### 5.31. 🟡 `setBypassDnd(true)` не работает без `ACCESS_NOTIFICATION_POLICY`
- Без permission Android тихо игнорит флаг. На устройствах с DND / Samsung Sleep Mode call заглушен.
- Permission «special» — требует user grant через Settings deep-link.
- **Когда чинить:** Phase 3, или раньше если жалобы на тишину.
### 5.32. 🟢 `vojo_messages` channel терялся на fresh install — закрыто
- Закрыто 2026-04-19 добавлением `ensureMessageChannel()` в Java FCM-сервис (зеркально `ensureCallChannel`).
### 5.33. 🟢 Decline из CallStyle foregrounding'ил app — закрыто через §5.35.
### 5.34. 🟠 Lockscreen Answer/Decline buttons на Samsung OneUI — abandoned
- **Симптом:** на locked screen Samsung OneUI Android 14 показывает CallStyle стабом без кнопок. Tap notification → unlock → MainActivity. Пользователь не может быстро среагировать не разлочив.
- **Попытки фикса:**
- Full native `IncomingCallActivity` (first cut 2026-04-20) — отклонён: WebRTC всё равно в WebView → Answer требует unlock, UX не лучше.
- FGS `foregroundServiceType="phoneCall"` (2026-04-21) — отклонён: на API 34+ требует permission `MANAGE_OWN_CALLS` или dialer role, иначе `startForeground(PHONE_CALL_TYPE)` бросает SecurityException. Мы не диалер, полноценная регистрация как ConnectionService — недели работы + Play-review риск.
- **Текущий компромисс:** живём с §5.35 (Decline работает без unlock через broadcast receiver, но кнопка видна только после unlock). Decline-на-локскрине остаётся UX-проблемой на Samsung.
- **Пути вперёд (не MVP):**
- ConnectionService + TelecomManager регистрация → полноценный «диалерный» путь.
- Попробовать `foregroundServiceType="mediaPlayback"` или `"shortService"` — не требуют спец-прав, но вероятно Samsung не триггерит lockscreen-кнопки на них.
- **Когда чинить:** не MVP. Ждём продуктового решения «становимся серьёзным звонилкой или нет».
### 5.35. 🟢 MVP Decline patch — landed 2026-04-20
- Decline тап из CallStyle уходит через `CallDeclineReceiver` (BroadcastReceiver) → Matrix PUT напрямую из Java по stored access_token → `nm.cancel`. MainActivity не бутится, ни флэша ни unlock-требования.
- Ключевые файлы: [CallDeclineReceiver.java](../../android/app/src/main/java/chat/vojo/app/CallDeclineReceiver.java), [sessionBridge.ts](../../src/app/utils/sessionBridge.ts), [usePendingDeclinesFlusher.ts](../../src/app/hooks/usePendingDeclinesFlusher.ts), [VojoFirebaseMessagingService.java](../../android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java).
- Восстановление при network/процесс-фейлах: tombstone `vojo.pendingDeclines.{notifEventId}` в prefs, flusher на `App.resume` добирает.
- **Известный компромисс:** receiver и flusher генерят разные txnId → при split-success-fail в timeline может сесть два `m.call.decline` (cosmetic, caller-side auto-hangup идемпотентен).
- **Что НЕ закрыто:** lockscreen buttons visibility (§5.34), Answer без WebView (принципиально), full-screen ringing UI.
### 5.36. 🟢 Multi-call UX & call state invariants — landed 2026-04-22
- Enforce'ится **ровно один** активный call embed: новый outgoing / Answer при активном звонке идут через общий switch-flow.
- Несколько входящих ring'ов одновременно разрешены: `useIncomingRtcNotifications` больше не режет их по `inCallRef`.
- Switch ждёт clean handoff старого звонка; blind `dispose()` больше не используется как основной барьер.
- Закрыто параллельно: §5.10, §5.11, §5.16, §5.21-5.22, §5.23.
- Android incoming-call UX теперь split по state:
- foreground → только in-app strip
- background / killed → native `CallStyle`
- если app вернулся foreground во время ещё живого ring, JS dismiss'ит старый `CallStyle`, как только берёт ring в `incomingCallsAtom`
### 5.37. 🟡 Native foreground cold-start window
- **Симптом:** MainActivity уже `foreground`, поэтому Java suppress'ит `CallStyle`, но Matrix client / timeline listener ещё не готовы и in-app strip поднимается только через 1-3с или не успевает до expiry.
- **Контекст:** это не обычный "app already open" case, а handoff между native startup и ещё не прогретым JS bridge.
- **Фикс-направление:** gate не только по `isInForeground`, а по "JS incoming-call UX ready" (bridge JS → Java, fallback на `CallStyle` пока JS ни разу не отрапортовал readiness).
- **Когда чинить:** если live-тесты покажут, что окно воспроизводится достаточно часто, чтобы мешать MVP.
### 5.38. 🟡 Foreground strip latency зависит от /sync
- **Симптом:** когда app уже foregrounded, incoming strip появляется не по FCM, а после прихода `m.rtc.notification` через timeline / decrypted path; на плохой сети это может быть заметно позже push-triggered `CallStyle`.
- **Контекст:** текущая архитектура сознательно избегает второго JS delivery path через `pushNotificationReceived`, чтобы не плодить dedup и race'ы с timeline.
- **Фикс-направление:** отдельный fast-path из foreground push listener в `incomingCallsAtom` с жёстким dedup по `notifEventId` / room.
- **Когда чинить:** post-MVP polish, только если живая задержка реально бьёт по UX.
### 5.39. 🟢 Double ring UX при быстром backgrounded state — landed 2026-04-23
- **Landed fix:** в [IncomingCallStripRenderer.tsx](../../src/app/pages/IncomingCallStripRenderer.tsx) добавлен `App.appStateChange` subscription, JS `audio.play()` гейтится на `hasIncoming && appActive`. Listener регистрируется первым, `sawAppStateEvent` флаг блокирует stale `App.getState()`-снапшот от затирания свежего event (race-guard). Initial state — `document.visibilityState === 'visible'` (синхронный корректный снапшот и на web, и в Capacitor WebView). Визуал / атом / native не тронуты.
- **Симптом (pre-fix):** открыть Vojo, свернуть (Home), в течение 5 секунд получить incoming call → одновременно играют **и** native `CallStyle` ringtone + vibration (через `vojo_calls_v2` channel), **и** JS ring.ogg (через WebView audio element в `IncomingCallStripRenderer`). Если app полностью убит — только native (корректно). Если app foregrounded — только JS (корректно). Окно "just backgrounded" — двойной surface.
- **Корень:** архитектурный инвариант "foreground → JS strip, background → native CallStyle" (см. [usePushNotifications.ts:449-456](../../src/app/hooks/usePushNotifications.ts#L449-L456)) enforced только на native стороне — `VojoFirebaseMessagingService` проверяет `MainActivity.isInForeground`. JS не проверяет. `IncomingCallStripRenderer` запускает `audio.play()` безусловно при любом добавлении в `incomingCallsAtom`, включая те, что прилетают через /sync пока WebView жив но app свёрнут.
- **Визуальный strip и так невидим** в backgrounded состоянии (WebView off-screen), так что "второй banner" — это native, а не JS. Реальная проблема — **два аудиоканала** и vibration от native.
- **Направление фикса:** в [IncomingCallStripRenderer.tsx:35-47](../../src/app/pages/IncomingCallStripRenderer.tsx#L35-L47) добавить `App.isActive` subscription через Capacitor App, гейтить `audio.play()` на `hasIncoming && appActive`. Pause при background. Визуал и так недоступен, `useDismissNativeCallNotifications` при возврате app в foreground продолжит работать как handoff.
- **Размер:** ~10 строк, один useEffect. Не трогает native.
- **Когда чинить:** отдельный commit/PR. Не блокирует Phase 1 retention.
- **После приземления §5.39 становится видим §5.42** — тот же "just backgrounded" window, но уже не double-ring, а **silent ring**: native dismiss'ится JS-side как только /sync донесёт событие, а JS-audio по §5.39 в background молчит.
### 5.40. 🟡 Vendor aggressive battery saver взаимодействие с `microphone` FGS
- **Статус:** не тестировалось. Samsung One UI с default battery settings — FGS живёт (§2.2 confirmed). При включении Samsung "Put app to sleep" / Device Care / Xiaomi MIUI autostart deny / EMUI app launch restrictions — поведение неизвестно.
- **Риск:** на некоторых OEM legitimate FGS убивается или не стартует без explicit user-level battery-optimization whitelist.
- **Митигация-направление:** runtime prompt через `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (standard Android intent для deep-link в Settings), показывать один раз после первой попытки звонка или с onboarding. Vendor-specific API (Samsung Knox whitelist, MIUI autostart) — отдельный эпик.
- **Как проверять:** тест на Samsung с включённым "Put app to sleep" для Vojo, тест на Xiaomi без autostart grant. Воспроизвести Phase 0 T1-сценарий, смотреть в logcat `CallFgs` таги + `ActivityManager killing` события.
- **Когда чинить:** если жалобы живьём или при попадании в vendor-lab testing.
### 5.41. 🟡 Answer-from-killed state retention gap
- **Симптом:** app killed → incoming FCM → CallStyle native → user tap Answer → MainActivity boots → WebView loads → widget `JoinCall` → FGS запускается. Если user блокирует экран между tap-Answer и `JoinCall` (~1-3s окно), FGS запуск из background триггерит `BackgroundServiceStartNotAllowedException` на API 34+.
- **Корень:** Phase 1 lifecycle gate (`joined`, см. [useAndroidCallForegroundSync.ts](../../src/app/hooks/useAndroidCallForegroundSync.ts)) требует foreground старт. Answer-из-killed идёт через foreground MainActivity, но окно от boot до JoinCall не защищено.
- **Текущее поведение:** если user успевает заблокировать до JoinCall — звонок рвётся как Phase 0 baseline.
- **Направление фикса:** либо (а) стартовать FGS из MainActivity.onCreate когда intent содержит `call_action=answer` (предрасположенный старт до WebView load), либо (б) Core-Telecom self-managed path который имеет exempt от background-start restrictions.
- **Когда чинить:** Phase 3 (Telecom UX upgrade) или раньше если жалобы живьём. В MVP явно вне scope.
### 5.42. 🟢 Preждевременный native `CallStyle` dismiss в background — silent ring — landed 2026-04-23
- **Landed fix:** в [useDismissNativeCallNotifications.ts](../../src/app/hooks/useDismissNativeCallNotifications.ts) diff разделён на `added` / `removed`; `removed` дисмиссится всегда, `added` — только при `appActive=true`. Добавлена `App.appStateChange` subscription с race-guard'ом (`sawAppStateEvent` блокирует stale `getState()` от затирания свежего event), initial — `document.visibilityState === 'visible'`. Отдельный handoff-effect: при `appActive: false→true` с непустым `prevRoomsRef` — one-shot sweep всех currently-incoming rooms (закрывает "native raised в background → user вернулся → JS owner → native must go"). Body дисмисса вынесен в `dismissRooms()` helper. Атом / native / FCM listener не тронуты. Инвариант "foreground → JS strip, background → platform surface" теперь enforced и на dismiss-стороне.
- **Residual gap:** см. §5.44 — `App.appStateChange` переключается по BridgeActivity.onStop, а `MainActivity.isInForeground` по onPause → narrow window где Java и JS видят lifecycle по-разному и §5.42 снова уязвим в "только что свернули" интервале. Закрывает главный сценарий (background >секунд), но не onPause→onStop edge.
- **Симптом:** app свёрнут (screen on/off, процесс жив), приходит DM ring → native `CallStyle` поднимается корректно + начинает звенеть через channel ringtone → через ~0.1-1s пропадает и ringtone обрывается. Phone молчит до повторного ring / expiry. После приземления §5.39 — ещё и JS-audio в background gated off, так что user остаётся полностью без сигнала.
- **Корень:** [useDismissNativeCallNotifications.ts:29-66](../../src/app/hooks/useDismissNativeCallNotifications.ts#L29-L66) dismiss'ит native `CallStyle` на **любом** изменении `incomingCallsAtom`, без различения `ADD` vs `REMOVE` и без gate на `App.isActive`. Инвариант "foreground → JS strip, background → platform surface" enforced для native show path ([VojoFirebaseMessagingService.java:71-82](../../android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java#L71-L82)) и для JS-audio (после §5.39), но **не для dismiss path**. Комментарий хука ("OR newly takes ownership of one after the app returns foreground") описывает *желаемое* поведение — реализация шире.
- **Trace сценария:**
1. MainActivity.onPause → `isInForeground=false`.
2. FCM долетает в `VojoFirebaseMessagingService.onMessageReceived` → routed в call-branch → `showIncomingCallNotification` → native CallStyle + channel ringtone.
3. /sync (long-poll в живом но backgrounded WebView) доставляет тот же `m.rtc.notification``useIncomingRtcNotifications.processEvent``setIncoming({type: 'ADD', ...})`.
4. `useDismissNativeCallNotifications` effect fires на изменение атома → `PushNotifications.removeDeliveredNotifications({ tag: "call_<roomId>", id })` → native CallStyle cancelled, ringtone обрывается.
5. §5.39 gate в `IncomingCallStripRenderer` не играет JS-audio (`appActive=false`) → полная тишина.
- **Почему раньше было менее заметно:** до §5.39 fix JS-audio играл безусловно → даже если native dismiss'ился, в ушах хотя бы оставался JS `ring.ogg`. Это маскировало dismiss-side зазор (и само порождало §5.39). После §5.39 маска снята, баг выходит на передний план в тот же window.
- **Влияет только на "WebView жив и /sync долетает" state:** app killed → WebView не запущен → атом не меняется → native звенит нормально до user action / expiry. App foreground → native вообще не показан (foreground skip в сервисе) → нечего dismiss'ить. Зазор именно в "паузнутый но живой" состоянии (обычно первые десятки секунд после Home до того как Android начнёт aggressive suspend'ить WebView).
- **Направление фикса:**
1. В [useDismissNativeCallNotifications.ts](../../src/app/hooks/useDismissNativeCallNotifications.ts) разделить `addedRooms` и `removedRooms` (уже есть diff, только не разводятся по разным bucket'ам).
- `removedRooms` → dismiss'ить всегда (ring ended by accept/decline/expiry/other-device — stale native должен уйти).
- `addedRooms` → dismiss'ить только при `App.isActive === true` (foreground → JS взял ownership; background → native surface остаётся primary).
2. Добавить подписку на `App.appStateChange` в том же хуке. На transition `isActive: false → true` с непустым атомом — one-shot dismiss sweep для всех rooms в атоме (закрывает handoff: "ADD случился в background → native остался → user вернулся в foreground → теперь JS владеет → пора убрать native").
3. Race с initial `getState()` — применять тот же паттерн что в §5.39 fix ([IncomingCallStripRenderer.tsx](../../src/app/pages/IncomingCallStripRenderer.tsx)): listener регистрируется первым, `sawAppStateEvent` флаг блокирует stale `getState()`-снапшот от затирания свежего event.
- **Размер:** ~30-40 строк, один хук. Native не трогать. Атом не трогать.
- **Проверочные сценарии (после фикса):**
1. App foreground → incoming → atom ADD → native не показывался (foreground skip) → dismiss no-op → JS strip + JS audio. **Как сейчас.**
2. App уже > ~30s в background → incoming → native CallStyle + channel ringtone → /sync ADDs атом → dismiss НЕ срабатывает (`appActive=false`) → native звенит дальше. **Фикс: было silent, стало native-ring.**
3. App в background → incoming → native звенит → user возвращает app в foreground → `appActive: false→true` → one-shot dismiss sweep → native уходит → JS strip visible + JS audio (§5.39) начинает играть. **Чистый handoff, не double-ring.**
4. App foreground → user accepts/declines через strip → atom REMOVE → dismiss срабатывает безусловно. **Как сейчас.**
5. App killed (процесс мёртв) → native CallStyle ringtone до answer/decline/expiry. Хук не задействован. **Как сейчас.**
- **Когда чинить:** следом за §5.39 коммитом, приоритет выше чем остаток §5.39-risks — это прямой user-visible regression (silent ring) в том же "just backgrounded" window, который §5.39 должен был починить.
- **Связь с §5.36:** bullet "если app вернулся foreground во время ещё живого ring, JS dismiss'ит старый CallStyle, как только берёт ring в `incomingCallsAtom`" описывает *intended* handoff. Текущая реализация fires этот dismiss и когда app НЕ возвращался — §5.42 чинит именно это расхождение.
### 5.43. 🟢 Stale ring в JS strip после killed-decline в DM — landed 2026-04-23 (commit 9e5fa6b)
- **Landed fix:** в [useIncomingRtcNotifications.ts](../../src/app/hooks/useIncomingRtcNotifications.ts) заведён `declinedTimersRef: Map<notifEventId, timeout>` с self-expiring 30s entries. `rememberDeclined()` helper в decline-branch записывает `rel.event_id` **перед** `removeByNotifId`. Notification-branch имеет **двойной** check `declinedTimers.has(evId)`: pre-await short-circuit (не гоняем `resolveCallId` для уже отклонённых) и post-await race-guard рядом с существующим membership re-check. Unmount cleanup clear'ит pending timers. Атом / native / receiver не тронуты.
- **Изначальная гипотеза (§5.43 pre-fix) оказалась частично ошибочна:** live-логи показали что `m.rtc.notification` летит **cleartext** даже в encrypted DM (нужно для push-сервиса), а не как `m.room.encrypted`. Настоящий race — async внутри processEvent: `resolveCallId` yields до 5s (MembershipsChanged wait) + fetchRoomEvent. За этот yield Timeline decline проходит через handleTimeline → populates declinedTimers → но notification-branch уже миновал pre-await check и после await проверял только membership, не declined set. Fix-через-set всё равно правильный, просто check нужен **с обеих сторон** от await, а не только до.
- **Trace (confirmed by live logcat 2026-04-23):**
1. FCM доставляет notification → native CallStyle → user жмёт Decline → `CallDeclineReceiver` шлёт cleartext `m.call.decline` PUT (200 OK).
2. User открывает app (cold boot или resume). /sync подтягивает оба события.
3. handleTimeline notification → processEvent → notif-branch → pre-await `declinedTimers.has` === false → `await resolveCallId(...)` yields.
4. Во время yield'а: handleTimeline decline → processEvent decline-branch → `rememberDeclined(rel.event_id)``declinedSizeAfter: 1`. `removeByNotifId` на этом моменте no-op (registry.set ещё не произошёл).
5. resolveCallId возвращается → post-await re-check membership (clear) → **post-await re-check `declinedTimers.has(evId)` === true → return**. Atom НЕ получает ADD.
- **Симптом (pre-fix):** app killed/backgrounded, приходит DM-звонок → native `CallStyle` → user жмёт Decline на native-баннере → у звонящего отклоняется, но user открывает app и видит тот же звонок в in-app strip. Если нажать Accept — initiates outgoing call обратно caller'у (на session, которой уже нет).
- **Связь с §5.35:** receiver-path через cleartext raw HTTP — часть §5.35 design. Race возникал из-за async yield в resolveCallId, не из-за encrypt/cleartext asymmetry (обе события cleartext). Fix не меняет receiver design.
- **Не закрывает:** §5.44 (onPause→onStop lifecycle drift), §5.37 (foreground cold-start window).
### 5.44. 🟢 onPause→onStop lifecycle drift — landed 2026-04-23 (commit 649aea7)
- **Landed fix:** в обоих [useDismissNativeCallNotifications.ts](../../src/app/hooks/useDismissNativeCallNotifications.ts) и [IncomingCallStripRenderer.tsx](../../src/app/pages/IncomingCallStripRenderer.tsx) подписка на `App.appStateChange` заменена на пару `pause`+`resume` под `isAndroidPlatform()` branch'ем; iOS/web остался на `appStateChange`. `pause`/`resume` Capacitor App плагина fires в `BridgeActivity.onPause`/`onResume` через `AppPlugin.handleOnPause/Resume` — тот же Activity callback, что переключает `MainActivity.isInForeground`, — и доходит до JS через WebView bridge с латентностью ms. Race-guard переименован `sawAppStateEvent``sawLifecycleEvent`, остальная логика (initial `document.visibilityState`, `App.getState()` race-guarded fallback, handoff sweep на foreground) не тронута.
- **Live-измерение drift'а (test 2026-04-23):** на test-девайсе `onPause → onStop` 27-51ms (меньше предсказанных 100-1000ms, но реальный). `pause` event в JS приходит за 1-2ms после Java `onPause` — практически на том же edge'е.
- **iOS/web semantics intentionally preserved:** `pause`/`resume` на iOS bind to `didEnterBackground`/`willEnterForeground`, а `appStateChange` — to `willResignActive`/`didBecomeActive`. Для iOS `appStateChange` это правильный edge, не меняем.
- **Не закрывает:** §5.45 (FCM-foreground-skip race для уже живого ring'а — exposed test'ом §5.44, но это другой code path и другая природа).
- **Симптом (pre-fix):** в узком окне "app только что свернули" (Home нажат, onPause прошёл, onStop ещё не пришёл) баги §5.39 (double-ring) и §5.42 (silent-ring) снова частично живы, несмотря на landed fix'ы.
- **Корень:** lifecycle-asymmetry между Java-сторой и JS-сторой:
- Java [MainActivity.java:28](../../android/app/src/main/java/chat/vojo/app/MainActivity.java#L28) — `isInForeground=false` ставится в `onPause()`.
- Capacitor App plugin [BridgeActivity.java:113-123](../../node_modules/@capacitor/android/capacitor/src/main/java/com/getcapacitor/BridgeActivity.java#L113-L123) — `fireStatusChange(false)` вызывается в **onStop**, не onPause. `appStateChange` event и `getState().isActive` сэмплируют именно этот edge.
- Между onPause и onStop — десятки мс до секунд (зависит от девайса / анимаций / системной загрузки). В этом окне Java считает app backgrounded (FCM → native CallStyle), JS считает appActive=true (dismiss-ветка в useDismissNativeCallNotifications, audio-play в IncomingCallStripRenderer).
- **Trace (§5.42 residual):**
1. User нажал Home → `onPause``isInForeground=false`, WebView ещё видимый для Capacitor.
2. FCM ring → service видит `isInForeground=false` → native CallStyle.
3. /sync в живом WebView → атом ADD.
4. `useDismissNativeCallNotifications` видит `appActive=true` (onStop ещё не пришёл) → dismiss'ит native. Silent ring.
- **Trace (§5.39 residual):** тот же gap — audio.play() в IncomingCallStripRenderer на `hasIncoming && appActive=true`, играет double-ring вместе с native ringtone пока onStop не переключит.
- **Направление фикса:** варианты:
1. **DOM `visibilitychange`:** в обоих хуках дополнительно слушать `document.addEventListener('visibilitychange', ...)` и считать `active = App.isActive && !document.hidden`. WebView обычно fires visibility hidden ближе к onPause (через `onWindowFocusChanged` / `onPause``WebView.onWindowVisibilityChanged`). Earliest edge выигрывает. Дёшево, но полагается на WebView behavior — требует верификации на реальном девайсе (Samsung / Xiaomi может отличаться).
2. **Custom Capacitor plugin:** свой plugin с `@PluginMethod` + lifecycle hooks (`handleOnPause`, `handleOnResume`) шлёт JS event. Авторитетно, но overhead и требует aar build.
3. **SharedPreferences bridge:** MainActivity пишет `vojo.isInForeground` в CapacitorStorage.xml, JS читает на каждом atom-change через Preferences plugin. Polling-heavy, плохой fit.
- **Приоритет (1)** — дёшево проверить. Если на test devices visibilitychange fires ближе к onPause → закрывает residual одной строкой.
- **Размер:** 5-10 строк в каждом из [useDismissNativeCallNotifications.ts](../../src/app/hooks/useDismissNativeCallNotifications.ts) и [IncomingCallStripRenderer.tsx](../../src/app/pages/IncomingCallStripRenderer.tsx).
- **Проверочные сценарии:**
1. Home → <500ms incoming только native CallStyle, JS-audio молчит, native **не** дисмиссится. (Reproduce §5.395.42 pre-fix в том же окне.)
2. Home → 10s wait → incoming → как §5.42 test 2 (native звенит, JS не трогает).
3. App возвращается в foreground → handoff как §5.42 test 3 (native уходит, JS strip+audio берут).
4. Cold-boot foreground → incoming → JS strip+audio, native skipped. Как сейчас.
- **Когда чинить:** после §5.43. Оба §5.39 и §5.42 landed — это финальная полировка narrow-окна.
### 5.45. 🟢 Foreground-skip race — переезд с FCM-skip-cache на native ring registry — landed 2026-04-24
#### Симптом (pre-fix)
App foreground, incoming DM call. JS strip + audio начинают отрабатывать, через ~500ms user сворачивает app (Home) → JS audio гасится audio-gate'ом → native CallStyle **не появляется** (FCM irreversibly skipped в момент receipt'а как foreground-call) → полная тишина до повторного ring / expiry.
Корень — асимметрия двух контрактов:
- FCM receipt time ≠ user backgrounding time. FCM решает "foreground → JS owns UX, skip native" irreversibly. JS audio-gate reversible. Между ними нет foreground→background takeover.
#### Путь, которым шли (3 итерации)
**Первая попытка — foreground-skip cache.** Java-side `ConcurrentHashMap<roomId, CachedSkipRing>` хранит payload'ы которые FCM skip'нул в foreground. На `onPause` drain'ит cache → постит native. На `onResume` cancel + clear.
Через несколько раундов ревью закрыли серию подзадач: per-room dedupe с latest-wins eviction, `setOnlyAlertOnce(true)`, ownership-проверка через extras + `getActiveNotifications()`, `cachedAt` fallback для expiry, atomic registry+tombstone mutations через lock, forget-on-suppress для всех early-return paths JS-хука, CallDeclineReceiver / CallCancelReceiver cleanup, MessageId merge, BuildConfig.DEBUG log gate, web audio platform-gate, redaction-first симметрия, orphan sweep на onResume.
**Что заставило перейти на registry.** Подряд появлялись две тенденции:
1. Каждый новый suppress-case требовал explicit `forgetCachedSkippedRing` — scattered calls размножались (not-DM / expired / already-joined / decline-first / resolve-fail / redaction-first / hook teardown). Scaling linearly с количеством JS-branches.
2. Cache хранил **историю** ("какие FCM мы skip'нули"), а не **текущее live состояние**. Ownership encoding (`vojo_cache_event_id` extra + `getActiveNotifications`) появился именно чтобы отличать "наш" native от foreign — это хороший fix, но smell: мы трекаем ownership notification-slot'а поверх platform surface.
**Вторая попытка — native ring registry.** Переворот semantics: Java хранит current live rings (eventId-keyed), JS co-produces через happy-path upsert. Cleanup `useDismissNativeCallNotifications.ts` (-151 LOC) — native cancel теперь sole-ownership на Java registry.
#### Итоговая архитектура
- **`ringRegistry: ConcurrentHashMap<eventId, IncomingRing>`** + **`ringTombstones: ConcurrentHashMap<eventId, long>`** — оба под `registryLock` для atomic remove-wins + same-room eviction.
- **FCM fg → upsert only**; **FCM bg → upsert + render**. Race-guard: после put re-check `isInForeground` — если false, drain.
- **JS happy-path ADD → `upsertIncomingRing`** (idempotent с FCM, Java merges metadata append-only). **JS suppress paths + RTCDecline + redaction + removeByKey + sync-cleanup → `removeIncomingRing`**.
- **MainActivity.onPause** → 150ms render debounce → `renderRegistry` (iterate non-expired, render unrendered). **onResume** → 300ms cancel debounce → `cancelRenderedIncomingRings` (ownership-checked via `vojo_ring_event_id` extra + orphan sweep для process-kill recovery).
- **Latest-wins per-room** сравнивается по `content_sender_ts` (не last-observed): older incoming dropped + tombstoned, preserving newer ring in slot.
- **Re-alert cooldown:** `renderedAt` сбрасывается на cancel; `lastAlertedAt` preserved и сравнивается в renderOne → silent re-render (`builder.setSilent(true)`) в пределах 3s от последнего громкого поста. User всегда видит visual banner, без ringtone stutter на rapid toggle. После cooldown — fresh alert.
- **Lifecycle:** `CallDeclineReceiver` (native tap) и `CallCancelReceiver` (AlarmManager expiry) зовут `removeIncomingRing(ctx, evId)` — cleanup + tombstone + ownership-checked native cancel.
- **Tombstone TTL** = `2 × lifetime + grace` (per-event из `content_lifetime`). Покрывает FCM retry окно + sender-clock skew. Purge on upsert, remove, render.
#### Tradeoffs (почему остановились на registry, а не на альтернативах)
- **Vs cache-design:** registry моделирует "current live" вместо "FCM-skip history". Scaling: один `removeIncomingRing` в sync-cleanup effect + `removeByKey` + happy-path upsert покрывают atom-driven state. Scattered suppress-path calls остались (инвариант "FCM может seed'ить до JS", JS должен killing seed), но encoding чище — `atom REMOVE → bridge remove` линейно.
- **Vs Variant A (не skip'ать FCM в foreground):** самый простой diff, но каждый fg call даёт micro-flash native heads-up + OEM ringtone-stutter risk. Invariant "foreground → JS, background → platform" разрушен.
- **Vs Variant C (conditional JS audio gate):** JS не имеет native-state signal без bridge read — упирается в bridge той же формы, только с другой стороны.
- **Vs Telecom/ConnectionService:** long-term correct path для native call UX. Weeks of work + Play-review re-entry. Registry — middle ground между cache и Telecom.
- **Reviewer agent критика:** agent предупредил что `setSilent` при cooldown-skip render pattern ломается (live test подтвердил: user словил полное исчезновение native banner при rapid toggle, logged). Переработали cooldown: не skip render, render silent + `lastAlertedAt` preserved across cancel / `renderedAt` reset — user всегда видит banner, ringtone только при natural fg→bg edge за пределами cooldown.
#### Known residual gaps
- **FCM никогда не прилетает** (broken push infra, Sygnal без push rule для `m.call.decline`): ring приходит через /sync, но при killed-state remote decline не доходит до Java. Registry expire alarm (30s + grace) остаётся safety net. Не блокер, выделено в §5.46 (defer — Sygnal contract uncertain).
- **Cold-start resume:** MainActivity.onResume 300ms debounce cancels native. Если Matrix client hydration > 300ms (typical 1-3s), user briefly видит пустой UI пока atom не populated. tap-native с `call_action` обрабатывается `pendingCallActionConsumer` независимо от atom state, так что это только при generic app launch при live ring.
- **Same-room eviction требует обе стороны знать `content_sender_ts`.** Fallback на last-observed wins если хотя бы одна сторона не имеет ts.
#### Файлы (финальная конфигурация)
- `VojoFirebaseMessagingService.java``IncomingRing`, `ringRegistry`, `ringTombstones`, `registryLock`, `upsertIncomingRing`, `removeIncomingRing(Context, String)`, `renderRegistry`, `cancelRenderedIncomingRings`, `postIncomingCallNotification` (silent-aware).
- `MainActivity.java` — lifecycle debounces (render 150ms, cancel 300ms), handler cleanup on onDestroy.
- `CallForegroundPlugin.java``@PluginMethod upsertIncomingRing` / `removeIncomingRing`.
- `CallDeclineReceiver.java` / `CallCancelReceiver.java` — registry cleanup chain.
- `useIncomingRtcNotifications.ts` — happy-path upsert, suppress removes, latest-wins по senderTs, redaction-first симметрия, teardown sync.
- `callForegroundService.ts` — JS bridge wrappers.
- `IncomingCallStripRenderer.tsx` — web audio platform-gate.
- **Deleted:** `useDismissNativeCallNotifications.ts` — dual dismiss plane eliminated.
- `android/app/build.gradle``buildFeatures.buildConfig = true` для release log gating.
#### Deferred follow-ups (отдельные тикеты)
- FCM decline event handling (зависит от Sygnal push_rule config).
- Rename hook-local `registryRef``ringHandlesRef` чтобы не коллизиться с Java `ringRegistry`.
- Split `CallForegroundPlugin` (FGS retention + ring registry invalidation — orthogonal concerns).
- Unit tests для registry SM transitions (tombstone-rejects-upsert, latest-wins-by-senderTs, merge append-only, cooldown behavior).
### 5.46. 🟡 FCM не push'ит remote decline/hangup
- **Что:** default Sygnal push_rules не содержит правила для `org.matrix.msc4310.rtc.decline` и для membership state changes (other-device answer, caller hangup). Если app killed, remote decline не достигает Java registry через FCM. Ring висит в native CallStyle до AlarmManager expiry (30s + grace).
- **Сейчас работает:** после resume /sync доставит decline → JS `RTCDecline` → bridge `removeIncomingRing` → ownership cancel. Killed-state gap — только window до user next resume или до expiry.
- **Фикс-направления:** server-side push_rule + user account_data config; или client-side "watchdog" polling через SW. Обе опции вне MVP scope.
- **Когда чинить:** если пользователи жалуются на "ring висит после того как caller положил трубку у себя".
### 5.47. 🟡 Widget-api handler ack contract — выявлено ревью §5.21
- **Что:** наш `useClientWidgetApiEvent` в `src/app/plugins/call/hooks.ts:10-20` вызывает callback без `ev.preventDefault()` + `this.widgetApi!.transport.reply(ev.detail, {})`. Element-web ack'ает в `Call.ts:979-998` для Hangup/Close/DeviceMute.
- **Симптом:** PR #2680 commit'е Krishan упоминал "widget spam-hangups". Без ack EC может retrying action'ы в iframe'е. В прод нежалостно не наблюдали, но contract дырявый.
- **Корень:** pre-existing с PR #2680. Наш §5.21 Close listener наследует ту же дыру.
- **Фикс:** refactor `useClientWidgetApiEvent` signature чтобы callback получал `CustomEvent<IWidgetApiRequest>` → вызывать preventDefault + transport.reply.
- **Когда чинить:** scope-separate от §5.21, отдельный PR. Желательно с live-test на EC upgrade где spam-hangup может ломаться.
### 5.48. 🟡 StopMessaging widget death listener — element-web parity
- **Что:** element-web в `Call.ts:191,324-330` слушает `WidgetMessagingStoreEvent.StopMessaging` — "widget died without action". Fallback на случай когда iframe вообще не emit'ит ни Hangup ни Close. У нас этого listener'а нет.
- **Симптом (теоретический):** если iframe полностью умер (crash сценарий, не тривиальный LiveKit reconnect fail), у нас fallback только через user-initiated hangup timeout (§5.21 landed 8с). Без user action — zombie persists.
- **Фикс:** подписаться на аналогичный widget-api stop signal, trigger clearIfCurrent. У нас нет matrix-widget-api `WidgetMessagingStore` абстракции — нужно понять как detect "widget died" в нашем стеке.
- **Когда чинить:** если §5.21 residual gap (EC silent) начинает воспроизводиться в прод. Пока — speculative ticket.
### 5.49. 🟢 Gradle-generated push_strings.xml через AGP variant.sources.res — landed 2026-04-25
- **Что:** перевели генерацию Android push-resource'ов из commit'ящегося артефакта в Gradle-generated resource directory через AGP API `addGeneratedSourceDirectory`. Generated push strings живут в `build/generated/res/push/<variant>/values{,-ru}/push_strings.xml`, не коммитятся.
- **Что landed:**
- `scripts/gen-push-strings.mjs` принимает `--out <dir>` CLI arg; дефолт — прежнее поведение для manual debug.
- `android/app/build.gradle`: `GeneratePushStringsTask` (abstract DefaultTask) + `androidComponents { onVariants }` wiring. `inputs.files` = JSON bundles + скрипт → Gradle up-to-date check работает.
- Committed XMLs удалены из `src/main/res/values{,-ru}/`, добавлены в `.gitignore`.
- `package.json`: `android:sync` упрощён до `npx cap sync android` (Gradle — owner генерации).
- `docs/ai/android.md` — секция «Push string resources (generated)» с note про macOS AS + nvm.
- **Verified:** `./gradlew clean :app:assembleDebug` — build successful, XMLs в `build/generated/res/push/debug/`, повторный build → `UP-TO-DATE`.
- **Capacitor sync risk:** confirmed safe — `cap sync` регенерит только `capacitor.build.gradle`, не трогает `build.gradle`.
- **Связь:** follow-up §5.27 (landed 2026-04-24).

View file

@ -17,7 +17,7 @@
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit",
"gen:push-strings": "node scripts/gen-push-strings.mjs",
"android:sync": "npm run gen:push-strings && npx cap sync android",
"android:sync": "npx cap sync android",
"android:open": "npx cap open android",
"android:apk:debug": "cd android && ./gradlew assembleDebug",
"android:apk:release": "cd android && ./gradlew assembleRelease",

View file

@ -4,14 +4,15 @@
*
* Single source of truth: public/locales/{en,ru}.json under the `Push`
* namespace (the same file i18next loads on web). This script takes the
* Android-relevant subset and writes it to
* android/app/src/main/res/values{,-ru}/push_strings.xml a dedicated
* file that sits next to the app's existing strings.xml without mixing
* with hand-maintained resources.
* Android-relevant subset and writes values{,-ru}/push_strings.xml.
*
* Runs as part of `npm run android:sync` so every Capacitor sync rebuilds
* the push resources from the JSON. Editing push_strings.xml by hand is
* useless it gets overwritten.
* Usage:
* node scripts/gen-push-strings.mjs # default: android/app/build/generated/res/push/manual/
* node scripts/gen-push-strings.mjs --out <dir> # write into <dir>/values{,-ru}/
*
* The Gradle build calls this with --out into build/generated/res/push/<variant>/.
* The no-arg default writes into the build dir too, so generated XMLs never
* appear in src/main/res/.
*
* Why not parse the JSON at runtime in VojoFirebaseMessagingService?
* Because that hot path runs before WebView, before JS, under killed
@ -27,7 +28,7 @@ import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, '..');
const LOCALES_DIR = path.join(ROOT, 'public', 'locales');
const ANDROID_RES = path.join(ROOT, 'android', 'app', 'src', 'main', 'res');
const DEFAULT_OUT = path.join(ROOT, 'android', 'app', 'build', 'generated', 'res', 'push', 'manual');
// Keys from the Push namespace that end up in Android resources.
// SW-only strings (encrypted_message, incoming_call, open_to_answer) are
@ -153,13 +154,13 @@ function verifyParity(bundles) {
}
}
function emitResource(locale, bundle) {
function emitResource(locale, bundle, resDir) {
const lines = [
"<?xml version='1.0' encoding='utf-8'?>",
'<!--',
' AUTO-GENERATED by scripts/gen-push-strings.mjs.',
` DO NOT EDIT — edit public/locales/${locale}.json and rerun`,
' `npm run gen:push-strings` (or any `npm run android:sync`).',
' the Gradle build (or `npm run gen:push-strings` manually).',
'-->',
'<resources>',
];
@ -173,20 +174,26 @@ function emitResource(locale, bundle) {
}
lines.push('</resources>');
lines.push('');
const outPath = path.join(ANDROID_RES, LANGS[locale], 'push_strings.xml');
const outPath = path.join(resDir, LANGS[locale], 'push_strings.xml');
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, lines.join('\n'), 'utf8');
return outPath;
}
function main() {
const outIdx = process.argv.indexOf('--out');
if (outIdx !== -1 && !process.argv[outIdx + 1]) {
throw new Error('--out requires a directory argument');
}
const resDir = outIdx !== -1 ? path.resolve(process.argv[outIdx + 1]) : DEFAULT_OUT;
const bundles = {};
for (const locale of Object.keys(LANGS)) {
bundles[locale] = readBundle(locale);
}
verifyParity(bundles);
for (const locale of Object.keys(LANGS)) {
const outPath = emitResource(locale, bundles[locale]);
const outPath = emitResource(locale, bundles[locale], resDir);
process.stdout.write(` wrote ${path.relative(ROOT, outPath)}\n`);
}
}