По диагностике 0.5.8 («связь пропала» на сервере tinl): статус оставался «Подключено», а свидетельства сбоя стирало переподключение. - StallDetector: uplink растёт, а downlink нет при открытых соединениях ≥15 с (3 окна по 5 с) или серия DNS-таймаутов (≥3 имён за 20 с) → одна проверка через текущий сервер (VpnLivenessProbe) → существующий failover; без замены — «Нет связи с сервером» на Главной («Перезапустить», «Сменить сервер») и в уведомлении. Тишина — не тревога; не чаще раза в 5 мин; только при горящем экране; без периодических запросов. - Единый поток TrafficSample из CommandStatus (5 с при горящем экране). - SessionHistory: последние 5 сессий в памяти — сервер, трафик, последний рост downlink, причина конца, до 24 ключевых событий; важные строки ядра собираются при любом экране; отчёт v9 (session_history, session_traffic), попыток 6; квоты лога вытесняют DEBUG раньше обрывов. - Каждая причина перезапуска — свой триггер и запись в журнал. - Ручной выбор сервера проверяет связь и откатывается на прежний при провале; ManualSelection*InstrumentedTest: выбор не перезапускает VPN. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
829 lines
109 KiB
Markdown
829 lines
109 KiB
Markdown
# Zapret KVN Android — главный план архитектуры
|
||
|
||
Актуальная карта пакетов обеих платформ: [структура исходников](SOURCE_LAYOUT.md).
|
||
|
||
> **Читать первым.** Это главный документ для человека или ИИ, продолжающего проект. Он фиксирует границы MVP, владельцев данных, порядок реализации и запрещённые усложнения. Детали сетевой политики находятся в [ROUTING_ARCHITECTURE.md](ROUTING_ARCHITECTURE.md), детали DNS и Android VPN — в [DNS_ARCHITECTURE.md](DNS_ARCHITECTURE.md), rootless hardening — в [VPN_HIDING_ARCHITECTURE.md](VPN_HIDING_ARCHITECTURE.md), границы входных форматов — в [IMPORT_FORMATS.md](IMPORT_FORMATS.md), рабочие TODO и gates — в [IMPLEMENTATION_PLAN.md](IMPLEMENTATION_PLAN.md).
|
||
|
||
| Поле | Значение |
|
||
|---|---|
|
||
| Статус | Этапы 0–7 и automated Gate 8 реализованы; внешний signing/release и физические release-gates открыты |
|
||
| Последний аудит | 25 июля 2026 года |
|
||
| Минимальная ОС | Android 8.0, API 26 |
|
||
| Устройства MVP | Только телефоны |
|
||
| Ядро | `sing-box-extended` tag `v1.13.14-extended-2.5.2` |
|
||
| Точный commit | `ff11f007ec798136a5de258f947a4f34011a37ea` |
|
||
| Текущий workspace | Android app + изолированные `app-updater`/`network-bootstrap`/import libraries, один VPN service, pinned libbox и packaged `.srs` |
|
||
|
||
## Порядок доверия
|
||
|
||
Если документы кажутся противоречивыми:
|
||
|
||
1. Этот файл определяет продукт, объём MVP и порядок работ.
|
||
2. Routing ADR определяет per-app, domain/IP/rule-set и `direct/proxy/reject`.
|
||
3. DNS ADR определяет `VpnService`, DNS, Private DNS, bootstrap и сетевой lifecycle.
|
||
4. VPN Hiding ADR определяет допустимый rootless hardening effective runtime.
|
||
5. Политика форматов определяет допустимые преобразования URI/subscription в JSON.
|
||
6. Поведение ядра определяется зафиксированным исходным commit и проверяемым patchset,
|
||
SHA-256 которого входит в build/release/diagnostic metadata.
|
||
|
||
Нельзя молча придумывать второй исполняемый формат конфигурации, скрытые правила или
|
||
исправлять профиль без отражения в effective JSON. Ограниченные глобальные
|
||
runtime-intent допустимы только после явной фиксации в ADR, с видимым состоянием в UI,
|
||
тестом и отражением результата в effective diagnostic JSON.
|
||
|
||
## Цель MVP
|
||
|
||
Zapret KVN — независимый нативный Android-клиент sing-box-extended:
|
||
|
||
- импортирует JSON, ссылки, подписки, QR, буфер и файл;
|
||
- позволяет подключиться без ручной работы с JSON;
|
||
- сохраняет настоящий sing-box JSON и неизвестные extended-поля;
|
||
- даёт простой GUI для наиболее частых полей и raw-редактор для остальных;
|
||
- для новых managed-профилей по умолчанию направляет RU и LAN напрямую, а остальное через VPN только у выбранных приложений, экономя VPN-сервер;
|
||
- распространяется готовым APK только через Forgejo Releases.
|
||
|
||
Основной приоритет: корректность → простота → скорость → дополнительные функции.
|
||
|
||
## Канонические решения
|
||
|
||
| Область | Решение MVP |
|
||
|---|---|
|
||
| UI | Kotlin, Jetpack Compose, Material 3 |
|
||
| Навигация | 4 нижние вкладки: Главная, Серверы, Маршруты, Настройки; свой стек экранов (`ui/nav/Navigator`), без navigation-compose |
|
||
| Архитектура проекта | `app` владеет продуктом; `app-updater` изолирует release/download/verification; `network-bootstrap` изолирует Android network/DNS; libraries не зависят от app |
|
||
| Состояние UI | `ViewModel` + `StateFlow` |
|
||
| DI | Один ручной `AppContainer`, без Hilt/Koin |
|
||
| Процесс | Один Android process; `VpnService`, UI и libbox без отдельного `android:process` |
|
||
| VPN | Один foreground `VpnService`, один Android TUN, один libbox instance |
|
||
| Конфигурация | Настоящий sing-box JSON — база транспорта профиля; общие destination-rules хранятся как ограниченный UI intent и компилируются в единственный effective sing-box JSON |
|
||
| Импорт | Вход сразу преобразуется в JSON; Clash YAML и неподтверждённые URI не входят в MVP |
|
||
| Настройки | DataStore только для глобальных/UI-настроек |
|
||
| Профили | JSON-файлы через `AtomicFile`, одна резервная копия |
|
||
| Метаданные | Маленький app-private index без копии route/DNS-конфигурации |
|
||
| Приложения | Android allowlist применяется один раз в `VpnService.Builder` |
|
||
| Маршруты | sing-box `route.rules`: `direct`, selected proxy или `reject` |
|
||
| GEO/списки | Локальные binary `.srs` rule-set внутри APK |
|
||
| DNS | FakeIP выключен; Android bootstrap; Auto: DNS профиля → DNS узла → Android |
|
||
| Rootless hardening | Один compile-time/runtime overlay без process/thread/polling; localhost endpoints закрыты по умолчанию |
|
||
| Go runtime | `Libbox.SetMemoryLimit(false)`: стандартный GC, без Android GC=10 |
|
||
| Телеметрия | Только session totals; 1 Гц лишь пока главная видима |
|
||
| Ядро | Встроено в APK и обновляется только вместе с приложением |
|
||
| Обновления | Forgejo Releases, Stable/Beta, APK + SHA-256 |
|
||
| Фоновая работа | Только включённый пользователем VPN service, включая видимую сетевую паузу без TUN; нет фоновой синхронизации |
|
||
|
||
Для targetSdk 34+ единственный VPN service объявляет foreground type `systemExempted`: официальная таблица Android прямо включает в допустимые случаи VPN-приложения, настроенные через системный VPN consent. `specialUse` не нужен и не создаёт лишнюю Play Console review-категорию. Lint пока требует `SCHEDULE_EXACT_ALARM` без учёта VPN-исключения, поэтому только на service стоит точечный `tools:ignore="ForegroundServicePermission"`; alarm-разрешение намеренно не добавляется. См. [Android foreground service types](https://developer.android.com/develop/background-work/services/fgs/service-types#system-exempted).
|
||
|
||
## Неподвижные инварианты
|
||
|
||
1. Физически существует ровно один Android TUN.
|
||
2. Невыбранный UID не попадает в TUN и не виден libbox.
|
||
3. Выбранный UID полностью попадает в TUN по IPv4 и IPv6.
|
||
4. Android решает только область приложений; sing-box решает только назначения и outbound.
|
||
5. Быстрые пресеты не создают дублирующие `package_name` route-правила.
|
||
6. В управляемом Android TUN нет GEO/LAN route exclusions.
|
||
7. `direct` проходит через локальный core, но не использует VPN-сервер.
|
||
8. Новая блокировка использует `action: reject`, а не legacy block outbound.
|
||
9. Блокировка действует только на приложения, уже включённые в область VPN.
|
||
10. FakeIP, глобальный sniff, root и второй VPN/TUN по умолчанию запрещены.
|
||
11. Любая ошибка после открытия TUN полностью закрывает PFD и core.
|
||
12. Статус «Подключено» появляется только после DNS и HTTPS health-check.
|
||
13. Приложение не держит `WakeLock`, не использует alarm и WorkManager и не делает периодический health-check. Единственный планировщик — платформенный `JobScheduler` с суточной проверкой обновлений (`updates/UpdateCheckJob.kt`); он не сохраняется между перезагрузками и ставится заново при каждом запуске процесса.
|
||
14. «Автоматически» сначала пробует DNS профиля, затем DNS узла через туннель и только после подтверждённой DNS-ошибки — DNS Android; каждый переход создаёт чистую bounded-сессию без фонового retry.
|
||
15. Скорость не показывается в уведомлении; status/log streams существуют только пока нужен соответствующий экран.
|
||
16. Управляемый профиль не создаёт NTP, remote rule-set, периодический URL-test или явный persistent keepalive сверх defaults выбранного протокола.
|
||
17. `VpnService.Builder.allowBypass()` не вызывается; rootless hardening не обещает скрыть системный TUN/`TRANSPORT_VPN`.
|
||
18. Updater не создаёт постоянного Forgejo route: VPN overlay существует только пока операция (проверка и последующая загрузка) идёт через VPN, совпадает одновременно по package приложения и Forgejo hostname и всегда снимается через восстановление сессии. Фоновое задание не запускает выключенный VPN.
|
||
|
||
## Реальный сетевой путь
|
||
|
||
```text
|
||
невыбранное приложение
|
||
└─ Android system network
|
||
TUN, libbox, DNS и VPN-сервер его не видят
|
||
|
||
выбранное приложение
|
||
└─ Android VpnService allowlist
|
||
└─ один TUN
|
||
└─ libbox / sing-box
|
||
├─ reject → локальный отказ
|
||
├─ direct → underlying Android network
|
||
└─ proxy → выбранный VPN outbound
|
||
```
|
||
|
||
Это максимально раннее безопасное отсечение на обычном Android без root. Приложение можно исключить до TUN, но нельзя запретить ему сеть публичным `VpnService` API: исключение означает direct. Домен появляется только после DNS/sniff/reverse mapping, поэтому доменное решение до sing-box невозможно.
|
||
|
||
## Последовательность подключения
|
||
|
||
Один `VpnController` и один service-lock выполняют запуск строго последовательно:
|
||
|
||
1. Проверить выбранный профиль и непустую allowlist.
|
||
2. Получить системное VPN-разрешение при необходимости.
|
||
3. Зафиксировать generation token и текущую underlying сеть через event-driven Android callback с
|
||
`NET_CAPABILITY_NOT_VPN`; собственный TUN не может стать underlying network.
|
||
4. Прочитать профиль и создать runtime-копию JSON.
|
||
5. Проверить один TUN, полные IPv4/IPv6 routes, rule-set assets и запрещённые socket bind-поля.
|
||
6. Очистить импортированные `include_package`/`exclude_package` только в runtime-копии и применить одну глобальную allowlist.
|
||
7. Добавить только разрешённые runtime overlays: health-check, runtime-only hosts bootstrap из свежего pre-VPN resolve или допустимого LKG строго для выбранного member, минимальный Android DNS для профиля без DNS и внутренний WireGuard MTU, если он отсутствует.
|
||
8. Выполнить `libbox CheckConfig()` до `establish()`.
|
||
9. Проверить captive portal/Private DNS и разрешить адрес активного proxy-сервера через underlying Android network.
|
||
10. Создать platform adapter и локальный libbox command server в том же Android process.
|
||
11. Вызвать `startOrReloadService()`: libbox синхронно вызывает `OpenTun(TunOptions)`, а adapter создаёт один `VpnService.Builder`, применяет приложения, адреса, полные routes и внутренний DNS.
|
||
12. `Builder.establish()` возвращает Android PFD; libbox дублирует его FD и запускает единственный core поверх этого же TUN. Второго адаптера или второго TUN здесь нет.
|
||
13. Проверить DNS и HTTPS через Android TUN. Для HTTPS runtime-копия включает TLS sniff только для package Zapret KVN и порта 443, после чего точные probe-hostnames направляются в выбранный outbound. Это доказывает настоящий путь через proxy без глобального sniff и без отдельного WireGuard-only gate. В Auto подтверждённая именно DNS-ошибка полностью закрывает текущие core/PFD/callbacks и запускает следующий кандидат `профиль → DNS узла → Android`; максимум три попытки внутри общего deadline 45 секунд. Ошибка data-plane, JSON или HTTPS-пути не считается DNS-ошибкой и не запускает переключение.
|
||
14. Только после успеха показать «Подключено»; сбор 1 Hz session-only статистики начать лишь при видимой главной.
|
||
|
||
Ожидание физической сети выполняется до шага 4 и не входит в общий deadline 45 секунд: сначала ждём зрелую сеть 10 секунд, затем любую пригодную ещё 15. Бюджет подключения не должен тратиться на то, что Android ещё поднимает Wi-Fi.
|
||
|
||
Любая ошибка или revoke выполняет один идемпотентный stop без ожидания общего lifecycle mutex: generation немедленно инвалидируется, текущий lifecycle job отменяется, Android TUN/PFD закрывается первым, затем последовательно закрываются callbacks, command clients, libbox service и command server. Монитор сети принадлежит сервису и переживает сессию: сессия снимает только свою подписку. Терминальный `Error`/`Stopped` публикуется только после этого cleanup, чтобы немедленный повторный запуск не пересёкся со старым foreground startId на Android 8–9. Невыбранные приложения всё это время продолжают работать напрямую.
|
||
|
||
### Автоматическое восстановление
|
||
|
||
Транзиентный отказ не терминален. `NET-101` переводит сервис в состояние «Переподключение» и ждёт события `ConnectivityManager` о пригодной сети; `NET-102`, `DNS-101` и `DNS-105` повторяются через ограниченный backoff 1 → 2 → 4 секунды с потолком 15. Отказ без стабильного кода тоже считается транзиентным, если физическая сеть сменилась под уже работающей попыткой: её результат ничего не говорит о профиле.
|
||
|
||
Ожидание физической сети не ограничено по времени: выключенная на ночь сеть не должна превращаться в ошибку, которую пользователь потом чинит руками. Это ожидание события `ConnectivityManager`, а не серия попыток — ни таймера, ни опроса, ни wakelock оно не создаёт, а состояние и кнопка «Остановить» всё это время видны.
|
||
|
||
Границы применяются к числу попыток подключения: три подряд идущих на одной сети и восемь на всю серию. Счётчик подряд идущих обнуляется при появлении другой физической сети, общий — только при успешном подключении или действии пользователя; он же и есть страховка от ping-pong Wi-Fi ↔ cellular. По исчерпании публикуется исходный код отказа и сервис останавливается.
|
||
|
||
Всё остальное остаётся терминальным немедленно: профиль, allowlist, политика Android, strict Private DNS, deadline подключения, отказ data-plane и captive portal (`NET-110`). Captive portal исключён сознательно — без авторизации в браузере сеть не станет пригодной, и держать foreground-сервис в ожидании действия пользователя дороже честной ошибки.
|
||
|
||
На время восстановления TUN закрыт, поэтому выбранные приложения временно идут напрямую — ровно как и при терминальной ошибке. Callback сети закрывается до публикации терминального состояния, чтобы «остановлено» означало полностью освобождённые ресурсы.
|
||
|
||
Включённая по умолчанию настройка «Только IPv4 через VPN» влияет только на сгенерированные DNS-правила доменов, которые effective route отправит в proxy. Она действует в явных Secure/DNS Android и после перехода Auto к этим managed-этапам, но не меняет первую попытку с DNS профиля, direct/LAN, IPv6-маршрут TUN, сохранённый профиль или режим «Из JSON». Это предотвращает выбор AAAA у dual-stack сайтов для WireGuard-профиля только с внутренним IPv4. IPv6-only трафик требует настоящего IPv6-адреса внутри WireGuard-профиля; отключение фильтра не может добавить его автоматически.
|
||
|
||
Baseline underlying-сети остаётся неизменяемым на всю сессию. Callback-flap
|
||
`A → B → A` отменяет pending restart; после debounce итоговый policy key
|
||
проверяется ещё раз. Контролируемый restart выполняется только если сеть или
|
||
DNS/captive policy действительно остались отличными от baseline.
|
||
|
||
Debounce зависит от зрелости новой сети: 750 мс, если Android уже отдал резолверы
|
||
и вердикт о доступе в интернет, иначе 3 секунды с потолком ожидания 12 секунд.
|
||
Перезапуск на ещё не настроенном кандидате заканчивался `NET-101`/`NET-102`, а сети,
|
||
которые Android никогда не пометит validated, не должны навсегда удерживать сессию
|
||
на мёртвом линке.
|
||
|
||
### Автоматизация по типу сети
|
||
|
||
По умолчанию автоматизация выключена и lifecycle полностью совпадает с прежним:
|
||
пользователь запускает VPN вручную, а активная сессия переживает смену underlying
|
||
сети контролируемым restart. После явного включения пользователь независимо выбирает,
|
||
должен ли KVN работать в Wi-Fi, cellular, Ethernet и неизвестных Android transport.
|
||
Дополнительный bounded-список содержит не более 32 доверенных SSID; его правило можно
|
||
временно отключить без удаления списка.
|
||
|
||
Автоматика вооружается только явным запуском VPN. Если текущая сеть запрещена правилом,
|
||
TUN/PFD, core и command clients полностью закрываются, а тот же foreground `VpnService`
|
||
остаётся в видимом состоянии `Paused` с одним event-driven `NetworkCallback`. При появлении
|
||
разрешённой сети сервис запускает последний профиль; ручная остановка закрывает callback,
|
||
notification и сам сервис. Кнопка «Подключить сейчас» временно игнорирует правило только
|
||
для текущей identity underlying-сети; следующая смена сети снова применяет сохранённую
|
||
политику. Polling, alarm, job, WorkManager и WakeLock не создаются.
|
||
|
||
Правило «весь Wi-Fi» использует только transport и не требует runtime-разрешения. Точное
|
||
правило доверенного Wi-Fi использует SSID из `WifiInfo`; Android может открыть его только
|
||
после явного разрешения точного местоположения и при включённой системной геолокации.
|
||
Если SSID недоступен или отозван, политика fail-open относительно сети: VPN остаётся
|
||
включённым. SSID хранится только локально; diagnostic JSON содержит лишь число сетей и
|
||
флаги политики. Временный updater route считается явным пользовательским действием,
|
||
может кратко обойти сетевую паузу и затем восстанавливает прежнее paused/connected состояние.
|
||
|
||
## Экраны
|
||
|
||
```text
|
||
Главная
|
||
├─ Статус, большая кнопка и «умный» заголовок по состоянию
|
||
├─ Текущий сервер: флаг, протокол, задержка словом
|
||
├─ Сеть: IP выхода (по нажатию «Показать IP»), Relay HTTPS; время сессии
|
||
├─ Трафик: скорость, лёгкий график 60 секунд, итоги сессии
|
||
└─ Режим (выбор пресета на месте) и приложения
|
||
|
||
Серверы
|
||
├─ Шапка активного профиля → экран «Профили» (подписки, импортированные,
|
||
│ файлы; добавить: файл / буфер / ссылка / QR)
|
||
├─ Поиск и чипы стран
|
||
└─ Группы человеческими словами и строки серверов
|
||
|
||
Маршруты
|
||
├─ Что идёт через VPN: выбранные приложения или все кроме
|
||
├─ Режим (preset) и блокировка приложений
|
||
├─ Правила предложениями: VPN / напрямую / блокировать
|
||
└─ Дополнительно: итог, diff, raw JSON
|
||
|
||
Настройки (каждая страница — отдельный экран со своей шапкой)
|
||
├─ Сеть и DNS, автоматизация VPN, автопереключение сервера
|
||
├─ Оформление, скрытие адресов
|
||
├─ Обновления, сообщество, о приложении
|
||
└─ Дополнительно: скрытие VPN, диагностика
|
||
```
|
||
|
||
UI разделён на `*Route` (собирает потоки через `collectAsStateWithLifecycle`) и
|
||
stateless-экран; преобразование domain → текст живёт в чистых презентерах с
|
||
юнит-тестами. Частые данные (трафик, таймер, график) читаются только листовыми
|
||
элементами, поэтому экран целиком раз в секунду не перерисовывается.
|
||
|
||
### Главная
|
||
|
||
Компактная карточка показывает максимум полезного без тяжёлого dashboard:
|
||
|
||
- состояние;
|
||
- профиль/сервер;
|
||
- IP выхода (только по нажатию) и Relay HTTPS как независимые показатели;
|
||
- время;
|
||
- получено/отправлено;
|
||
- две тонкие линии скорости за 60 секунд.
|
||
|
||
Статистика хранится только в памяти текущей сессии. Суммарные счётчики читаются раз в секунду только пока главная находится в lifecycle `STARTED`; при уходе с экрана stream и ticker закрываются. IP выхода сам не запрашивается: строка «IP выхода» показывает «Показать IP», запрос уходит только по нажатию, результат живёт до смены сервера или переподключения; Relay HTTPS запускается только явной командой проверки задержек. Последний результат каждой пробы по каждому серверу сохраняется в `noBackupFilesDir/server-latency.json` (ключ — профиль и стабильный тег сервера плюс SHA-256-отпечаток типа и адреса, сам адрес не пишется) и показывается сразу после запуска приложения с возрастом замера; записи исчезнувших профилей и серверов удаляются при пересчёте списка серверов. Постоянное уведомление показывает только состояние, а не живую скорость.
|
||
|
||
В production runtime всегда создаётся внутренний `clash_api` traffic manager, но
|
||
без `external_controller` или сетевого listener. Постоянный command client слушает
|
||
только event-driven группы. Отдельный `CommandStatus` client с интервалом 1 секунду
|
||
существует лишь при одновременно `STARTED` Activity и выбранной вкладке «Главная»;
|
||
его закрытие прекращает ticker в самом core. `CommandConnections` не создаётся.
|
||
`CommandLog` кратко захватывает bounded core-лог во время connect health-check,
|
||
затем существует только при открытом экране диагностики.
|
||
|
||
Исключение — единый поток счётчиков трафика сессии (`CoreSession.trafficSamples()`,
|
||
`TrafficSample`: uplink/downlink total, число соединений ядра — трекеры traffic manager
|
||
и route ConnectionManager — и monotonic-время отсчёта; накопительные с начала сессии).
|
||
Он открыт, пока сессия подключена и экран горит (или открыта главная/диагностика), с
|
||
интервалом 5 секунд: при погашенном экране клиента нет и VPN в простое не имеет
|
||
периодической работы, при включении экрана клиент открывается снова. Поток питает один
|
||
`CommandStatus` client: если главная держит свой 1 Гц client (`statusClients`), отсчёты
|
||
берутся из него, прореженные до одного в ~5 с, и отдельный клиент закрыт; иначе
|
||
открывается отдельный 5-секундный client со своим счётчиком `speedMonitorClients`, и
|
||
`statusClients` при скрытой главной остаётся 0. Потребители: история сессий
|
||
(`session_history` в diagnostic JSON), детектор «подключено, но данные не идут» и
|
||
«умная проверка».
|
||
|
||
«Умная проверка» (настройка «Переключать при низкой скорости», по умолчанию включена)
|
||
подписана на тот же поток (`downlinkTotal` и число соединений) и своего клиента не
|
||
открывает. По отсчётам детектор
|
||
ищет 20 секунд подряд спроса при скорости ниже 128 КБ/с. Сам по себе он
|
||
ничего не переключает: запускается один эпизод с ограниченной загрузкой
|
||
тестового файла через туннель (3 МиБ / 6 с, тот же URL, что у ПК). libbox не
|
||
меряет скорость outbound без переключения селектора, поэтому кандидат из той же
|
||
группы (лучший по сохранённому пингу) проверяется пробным переключением тем же
|
||
selector hot-switch, что у failover, и закрепляется только если он не ниже
|
||
порога и минимум вдвое быстрее; иначе селектор возвращается. Антифлап: 5 минут
|
||
после ложной тревоги или неясного замера, 10 минут после любого переключения,
|
||
30 минут метки «медленный», 30 минут после ручного выбора сервера (failover это не затрагивает, переключение настройки сбрасывает), не больше 3 переключений в час, дедлайн эпизода
|
||
120 секунд. Экран погашен или настройка выключена — подписки нет. Фактическое переключение показывается сообщением
|
||
в приложении и в тексте постоянного уведомления; решения пишутся в диагностику.
|
||
|
||
ICMP-пинга серверов нет совсем (решение владельца 15.10.2026): прямой Echo до адреса сервера
|
||
мимо туннеля выдавал бы VPN-серверы провайдеру и ТСПУ. Замер задержки — только Relay HTTPS через ядро.
|
||
Relay выполняет `HEAD https://www.gstatic.com/generate_204` через конкретный leaf-outbound:
|
||
тестовое TCP/TLS/HTTP-соединение создаётся, но selector/urltest selection, history и
|
||
активные соединения не изменяются.
|
||
Интерфейс различает «Не проверено», running, success, timeout/error, unsupported и
|
||
stale; успешное значение устаревает через пять минут или сразу после смены сети.
|
||
Один session-owned coordinator допускает только один отменяемый запуск: Relay идёт
|
||
очередью по 10, вложенная группа открывается отдельно. Периодического
|
||
ping-loop нет. Внешний IP запрашивается только по нажатию «Показать IP» на карточке «Сеть»
|
||
через dual-stack [ipify endpoint](https://www.ipify.org/): ни при подключении, ни при смене
|
||
сервера, ни по таймеру. Ошибка запроса не отключает VPN и не запускает retry; адрес
|
||
кэшируется в рамках сессии и сбрасывается при переподключении и смене сервера. Значения, 60 точек
|
||
графика и URL-test history живут только до остановки текущей сессии.
|
||
|
||
Состояния: нет профиля, нет выбранных приложений, выключено, проверка, подключение, подключено, переподключение, отключение, ошибка. Переподключение показывает причину и номер попытки, кнопка остаётся «Остановить»: сервис жив и поднимет VPN сам. Ошибка ведёт в «Диагностику».
|
||
|
||
### Маршрутизация
|
||
|
||
Пользователь видит не две конкурирующие «маршрутизации», а две карточки:
|
||
|
||
```text
|
||
Область VPN
|
||
Только выбранные приложения · YouTube, Discord · 7 >
|
||
Блокировать приложения · 0 >
|
||
|
||
Правило трафика
|
||
Россия напрямую · остальное через VPN >
|
||
Общая политика · для всех профилей
|
||
|
||
Итог
|
||
выбранные: RU → напрямую, остальное → VPN
|
||
остальные приложения: напрямую, вне VPN
|
||
destination-блокировка: только для выбранных приложений
|
||
полная блокировка приложений: отдельный пустой по умолчанию список
|
||
```
|
||
|
||
Список приложений открывается отдельным полноэкранным picker с поиском. Общие
|
||
destination-rules остаются на том же экране; raw-редактор меняет только базовый JSON
|
||
активного профиля. При запуске общая политика накладывается в памяти до
|
||
`Libbox.CheckConfig()`, а сохранённый профиль не переписывается. Кнопка редактора
|
||
называется «Исходный JSON профиля» и рядом явно объясняет эту границу. Выбор другого
|
||
профиля при подключённом VPN заменяет текущую сессию новой сессией выбранного
|
||
профиля; повторный выбор уже подключённого профиля ничего не перезапускает.
|
||
|
||
Редактор правила имеет ровно три обычных действия:
|
||
|
||
- «Через VPN»;
|
||
- «Напрямую»;
|
||
- «Блокировать».
|
||
|
||
Глобальные presets:
|
||
|
||
- Всё через VPN;
|
||
- Обход LAN;
|
||
- Только выбранные сайты;
|
||
- Россия напрямую, остальное через VPN;
|
||
- Россия через VPN, остальное напрямую;
|
||
- Пользовательский.
|
||
|
||
Whole-app block не входит в основной MVP: он потребовал бы третьего состояния каждого приложения и `package_name → reject`. Advanced JSON может содержать такие правила под ответственность пользователя.
|
||
|
||
## Хранение и источник истины
|
||
|
||
```text
|
||
DataStore
|
||
├─ тема / канал обновлений / bounded DNS runtime-настройки
|
||
├─ сетевая автоматика / не более 32 локальных доверенных SSID
|
||
├─ активный профиль
|
||
├─ глобальная per-app allowlist и include/exclude mode
|
||
└─ routing_policy: preset + bounded destination-rules без credentials
|
||
|
||
files/profiles/
|
||
├─ index.json — только id, название, source и created/updated timestamps
|
||
├─ <id>.json — настоящий sing-box JSON
|
||
└─ <id>.json.bak — одна предыдущая версия
|
||
|
||
files/rule-sets/
|
||
├─ manifest.json
|
||
└─ *.srs — проверенные встроенные binary rule-set
|
||
|
||
cache/
|
||
└─ временный APK и диагностический экспорт
|
||
```
|
||
|
||
`index.json` содержит только UI/import metadata и не дублирует DNS, outbound или route
|
||
rules. `routing_policy` содержит явный GUI-intent, но не копию профиля или исполняемый
|
||
JSON; единственной конфигурацией ядра остаётся собранный effective sing-box JSON.
|
||
|
||
Профиль и index записываются атомарно. Для профиля `AtomicFile` сначала формирует отдельный `<id>.json.atomic`, затем два rename оставляют либо прежний читаемый `<id>.json`, либо новый `<id>.json` и ровно один `<id>.json.bak`. Отдельный staging нужен потому, что служебный suffix `.bak` самого platform `AtomicFile` конфликтовал бы с постоянным пользовательским backup. При запуске незавершённый staging удаляется, а отсутствующий основной файл восстанавливается из backup.
|
||
|
||
До create/update/restore вызывается native `Libbox.checkConfig()`. Файл и буфер ограничены 4 МБ; файл читается только после ответа системного `OpenDocument`, явного Android `ACTION_VIEW` либо `ACTION_SEND` с одним потоковым URI, буфер — только в обработчике явного нажатия и на main thread для совместимости с API 26. Внешний intent принимает только выданный другим приложением URI и ведёт в тот же preview, без автосохранения и автоподключения. Credentials никогда не копируются в index или DataStore.
|
||
|
||
Глобальный include allowlist хранится отдельно в DataStore `vpn_scope`. Флаг `initialized` отличает первый запуск от уже созданного списка: установленные popular suggestions, Android-браузеры, обработчики схемы `tg://` и ссылок YouTube выбираются при первой инициализации. Версия suggestions разрешает ровно одну миграцию новой группы известных package: установленные ChatGPT/Claude/Gemini и связанные AI/media/productivity-приложения один раз добавляются к существующему include-списку после обновления. В exclude-режиме они не добавляются к прямому обходу, потому что неотмеченные приложения уже входят в VPN. После миграции ручное снятие не отменяется и приложение не возвращается автоматически. Каталог пакетов читается один раз при создании `AppsViewModel` и вручную при открытии picker; фонового polling и таймера нет. Собственный package приложения отбрасывается при чтении/записи DataStore и скрыт из каталога. Полный список нужен основной per-app функции, обрабатывается только локально и никогда не попадает в сеть, аналитику или диагностику.
|
||
|
||
Перед будущим `Builder.establish()` единственный `VpnAppScopePreflight` нормализует пользовательский список и отбрасывает отсутствующие/отключённые package до вызова Builder. Они попадают только в bounded diagnostic log и не мешают запуску, если осталось хотя бы одно доступное приложение. Нулевой итоговый список блокирует запуск. Затем preflight добавляет доступные пользовательские package и внутренний package Zapret KVN через `addAllowedApplication()`. Любое исключение Builder для существующего package возвращает блокирующий результат; частично заполненный Builder после ошибки не используется. Внутренний package существует только в effective platform allowlist для health-check и никогда не сохраняется как пользовательский выбор.
|
||
|
||
JSON редактируется через `kotlinx.serialization.json` как дерево. Неизвестные поля сохраняются. После GUI-изменения могут исчезнуть комментарии и исходные отступы; raw editor показывает это заранее.
|
||
|
||
Managed presets и основные destination-rules атомарно сохраняются как общая bounded
|
||
policy и компилируются в runtime-копию активного JSON. Raw/неизвестные route-поля
|
||
остаются только в профиле. Итоговый overlay всегда доступен в redacted-диагностике.
|
||
|
||
Подписки обновляются только вручную. Нет WorkManager, таймеров или скрытого сетевого refresh.
|
||
|
||
## Профили, серверы и переключение
|
||
|
||
Один профиль — один настоящий sing-box base JSON. Proxy-серверы находятся в его
|
||
массиве `outbounds`; WireGuard/AmneziaWG 2.0 использует нативный массив sing-box
|
||
`endpoints`. Отдельной таблицы серверов, INI runtime-слоя и связанного набора
|
||
полноразмерных JSON-шаблонов нет.
|
||
|
||
Профиль, созданный из одиночной ссылки, всё равно получает managed `selector` с одним сервером. Следующую одиночную ссылку можно сохранить новым профилем либо явно добавить сервером в существующий managed-профиль. Одна подписка по умолчанию создаёт один профиль-группу: несколько server outbounds и один основной selector. В предпросмотре импорта её можно вместо этого разложить по одному профилю на сервер (не более 200): профили создаются только после того, как ядро приняло каждый JSON, имя берётся из имени сервера, а URL и настройки источника сохраняются как одна split-группа вне `profiles/index.json`.
|
||
|
||
Ручное обновление любого участника split-группы загружает подписку один раз и атомарно согласует все связанные профили. Стабильный credential-free ключ строится из протокола, endpoint и транспорта: сменившиеся UUID, password и obfs обновляют существующий JSON с сохранением его индивидуальных маршрутов; новые endpoint создают профили с `RussiaDirect`, исчезнувшие удаляются после preview. Удалённый пользователем профиль остаётся исключённым и не появляется снова при следующем refresh. Обновление настроек клиента/HWID через любой профиль применяется ко всей группе. У создаваемого приложением selector стабильный tag `zapret-proxy`; managed route и managed DNS ссылаются на него. Tag сервера строится из его имени в ссылке и сохраняет буквы любого алфавита (похожие на секреты строки вырезает `SecretRedactor`), потому что именно он виден пользователю в списке серверов.
|
||
|
||
Упрощённый фрагмент:
|
||
|
||
```json
|
||
{
|
||
"outbounds": [
|
||
{
|
||
"type": "selector",
|
||
"tag": "zapret-proxy",
|
||
"outbounds": ["server-a", "server-b"],
|
||
"default": "server-a",
|
||
"interrupt_exist_connections": true
|
||
},
|
||
{
|
||
"type": "vless",
|
||
"tag": "server-a",
|
||
"server": "vpn-a.example",
|
||
"server_port": 443,
|
||
"uuid": "<UUID>"
|
||
},
|
||
{
|
||
"type": "vless",
|
||
"tag": "server-b",
|
||
"server": "vpn-b.example",
|
||
"server_port": 443,
|
||
"uuid": "<UUID>"
|
||
}
|
||
],
|
||
"route": {
|
||
"final": "zapret-proxy"
|
||
}
|
||
}
|
||
```
|
||
|
||
GUI получает список групп и текущий выбор из libbox. При работающем VPN переключение вызывает `CommandClient.SelectOutbound("zapret-proxy", serverTag)` без пересоздания TUN/core. `interrupt_exist_connections: true` закрывает только соединения из внутренней `interruptGroup` этого selector. Отдельный route в outbound `direct` в эту группу не входит, а невыбранные UID вообще отсутствуют в TUN; поэтому оба пути не затрагиваются. Это подтверждено [реализацией exact selector](https://github.com/shtorm-7/sing-box-extended/blob/ff11f007ec798136a5de258f947a4f34011a37ea/protocol/group/selector.go#L145-L202) и Android integration-тестом switch без смены TUN.
|
||
|
||
Без запущенного ядра список серверов берётся из сохранённого JSON: `ProfileServerCatalog`
|
||
собирает selector-группы и описания outbounds, поэтому карточка профиля и главный экран
|
||
показывают «Серверов: N · выбран: tag» и открывают тот же выбор сервера, что и при
|
||
подключённом VPN. Выключенный VPN меняет только `selector.default` в профиле через
|
||
`ProfileStore.update()` (с `CheckConfig()` внутри) и ничего не запускает; подключённый VPN
|
||
идёт прежним путём через libbox. Отдельного хранилища выбора нет ни в одном из режимов.
|
||
|
||
Выбор сохраняется атомарным изменением `selector.default` в самом JSON после
|
||
`CheckConfig()`; отдельного DataStore для выбранного сервера нет. Ядро при этом ведёт
|
||
собственный `cache.db`, и его запись приходится нейтрализовать явно: `needCacheFile`
|
||
включается уже от одного `PlatformLogWriter`, то есть на Android всегда, а
|
||
[`Selector.outboundSelect`](https://github.com/shtorm-7/sing-box-extended/blob/ff11f007ec798136a5de258f947a4f34011a37ea/protocol/group/selector.go#L268-L291)
|
||
читает `cache.db` раньше `default` и полностью его игнорирует при попадании. Кэш общий
|
||
для всего приложения и ключуется тегом группы, а `zapret-proxy` одинаков у всех
|
||
управляемых профилей, поэтому застрявшая запись переживала и выбор сервера при
|
||
остановленном ядре, и смену профиля: пользователь видел один сервер, а трафик шёл через
|
||
другой. Поэтому сразу после старта ядра, до проверки DNS/HTTPS,
|
||
`SelectorCacheReconciliation` навязывает каждой группе сервер из JSON —
|
||
`default`, а при его отсутствии первый член группы, то есть ровно то, что ядро выбрало бы
|
||
без кэша. Вызов идемпотентен: совпадающий выбор ядро не переписывает и соединения не рвёт.
|
||
Если runtime-переключение не удалось, выполняется один контролируемый
|
||
restart уже с проверенным JSON. Переключение целого профиля всегда делает restart,
|
||
потому что у профиля могут отличаться transport, DNS, расширенные raw-routes и
|
||
TUN-настройки; общая GUI-policy при этом сохраняется.
|
||
|
||
Raw JSON не нормализуется скрыто. В режиме «Из JSON» GUI показывает существующие
|
||
selector-группы как есть. Общая routing-policy и rootless overlay из
|
||
[VPN Hiding ADR](VPN_HIDING_ARCHITECTURE.md) являются явно показанными
|
||
runtime-исключениями: policy компилирует destination-rules, а hardening удаляет
|
||
локальные control endpoints и блокирует non-TUN inbound. Сохранённый JSON не меняется,
|
||
effective-результат отражается в диагностике.
|
||
|
||
Полные JSON-шаблоны не являются долгоживущими данными. `ManagedProfileFactory` только
|
||
один раз собирает начальный JSON из маленького base builder, protocol outbound builder
|
||
и selector builder; после сохранения он становится источником истины для transport,
|
||
outbounds и расширенных raw-полей, а общая destination-policy остаётся отдельным
|
||
bounded intent. При ручном обновлении подписки выбранный server tag сохраняется, если
|
||
он всё ещё существует; иначе выбирается первый доступный сервер и показывается
|
||
уведомление.
|
||
|
||
Профили с credentials находятся только в app-private storage; Android Auto Backup для них выключен. Экспорт диагностики всегда redacted.
|
||
|
||
## Приложения
|
||
|
||
Режим по умолчанию — include:
|
||
|
||
- пользователь выбирает приложения, которые должны войти в VPN;
|
||
- Zapret KVN добавляет собственный package внутренне для health-check и служебных операций через VPN;
|
||
- пустой итоговый список запрещает запуск;
|
||
- исчезнувший/отключённый package пропускается с записью в bounded diagnostic log; запуск блокируется, только если доступных выбранных приложений не осталось;
|
||
- Builder-ошибка существующего package отменяет запуск целиком;
|
||
- остальные приложения идут напрямую.
|
||
|
||
Advanced exclude-mode поддерживается, потому что он был выбран в требованиях, но не является default. Пустой exclude-list всегда блокирует запуск: иначе весь телефон неожиданно войдёт в VPN.
|
||
|
||
В APK хранится небольшой список подсказок: Instagram, YouTube и YouTube Music (официальные, ReVanced, ReVanced Extended, Vanced и Morphe), Telegram Stable/Beta/Direct, Telegram X, ZaStoGram, распространённые Telegram-форки, WhatsApp, Discord, Signal, ChatGPT, Claude, Gemini, Perplexity, Copilot, DeepSeek, Grok, Suno, Spotify, Notion и известные браузеры, включая Chromium/Ultimatum; TikTok исключён. Дополнительно при первой инициализации Android сообщает все установленные обработчики `CATEGORY_APP_BROWSER`, браузерных HTTPS-ссылок, схемы `tg://` и ссылок `youtube.com`/`youtu.be`/`vnd.youtube`, поэтому неизвестный заранее браузер, Telegram- или YouTube-клиент тоже выбирается. Произвольные неизвестные приложения автоматически не добавляются. Системные скрыты до нажатия «Показать системные».
|
||
|
||
После импорта профиль сохраняется без автоматического подключения. Диалог «Выбрать приложения» показывается только при инициализированной allowlist с нулём выбранных package. Снятие выбора с одного приложения при наличии остальных окно не вызывает. Исчезнувший или отключённый package пропускается с bounded diagnostic warning; запуск блокируется только тогда, когда доступных выбранных приложений не осталось. Если хотя бы одно приложение выбрано, импорт завершается обычным snackbar без лишнего окна. После очистки defaults скрыто не восстанавливаются.
|
||
|
||
## Routing и rule-set
|
||
|
||
- Legacy `geoip`/`geosite` не генерируются: в sing-box 1.13 они удалены.
|
||
- Используются domain/IP `route.rule_set`.
|
||
- Встроенные presets зависят только от локальных `.srs`, входящих в APK.
|
||
- Нет managed remote rule-set, отдельного updater или `experimental.cache_file`.
|
||
- `.srs` обновляются только вместе с APK и проверяются SHA-256.
|
||
- Rule-set содержит совпадения, но не выбирает outbound: действие и порядок
|
||
компилируются в effective JSON из общей политики.
|
||
- Пользовательские inline/local/remote rule-set разрешены только как явный JSON-сценарий.
|
||
|
||
Domain block создаёт DNS `reject` и route `reject`. IP block создаёт только route `reject`. Глобальный sniff ради блокировки не включается. Встроенный DoH приложения может скрыть domain-only блокировку; полноценный firewall/ad blocker не обещается.
|
||
|
||
Полная блокировка приложения — отдельная политика DataStore, а не правило профиля.
|
||
В include-режиме её пакеты добавляются к Android TUN boundary, в exclude-режиме не
|
||
могут одновременно быть direct-исключениями. Runtime-копия ставит для них первые
|
||
DNS/route `package_name → reject`; сохранённый JSON не изменяется.
|
||
|
||
## DNS
|
||
|
||
Режимы GUI:
|
||
|
||
- **Автоматически** — выбранный DNS профиля; при подтверждённой DNS-ошибке чистая попытка с DNS узла через proxy, затем последний резерв — DNS Android с предупреждением;
|
||
- **DNS Android** — системная DNS/Private DNS политика;
|
||
- **DNS узла через VPN** — стандартный DNS выбранных приложений уходит на 53 через proxy, где его забирает resolver узла;
|
||
- **Из JSON** — существующая DNS-секция не переписывается; если секции/серверов нет, runtime-копия получает один local DNS Android, `hijack-dns` и `default_domain_resolver`, а сохранённый JSON остаётся прежним.
|
||
|
||
FakeIP выключен. Системные настройки Android приложение не меняет и Private DNS не обходит.
|
||
|
||
При strict Private DNS Auto не падает, а сужает цепочку кандидатов до «DNS Android» с предупреждением в логе — системный DoT уважается, пользователь ничего не настраивает. Secure при strict не запускается (`DNS-110`): он явно обещает собственный резолвер, а системный DoT обошёл бы port-53 hijack и reverse mapping. Явные режимы не получают скрытого fallback. Полная политика, bootstrap cache и fail-close описаны в DNS ADR.
|
||
|
||
## Проверка простоты и скорости
|
||
|
||
| Возможная «оптимизация» | Результат аудита |
|
||
|---|---|
|
||
| Android allowlist | Используем: это единственное бесплатное отсечение до TUN по приложению |
|
||
| GEO через Android routes | Отклонено: 10 842 exclusions на API 33+ и около 35 041 complement routes на API 26–32 |
|
||
| `route_exclude_address_set` | Exact libbox не разворачивает set в Android platform TUN |
|
||
| Отдельные TUN для direct/proxy/block | Нельзя и архитектурно лишнее |
|
||
| Global sniff | Отклонён: читает первые пакеты и замедляет весь трафик |
|
||
| FakeIP | Отклонён по умолчанию: кэш и per-app edge cases |
|
||
| Root/TProxy/iptables | Вне неинвазивного Android MVP |
|
||
| Proxy-only mode | Быстрее только для приложений с ручным SOCKS/HTTP; ломает «скачал и работай» |
|
||
| Dynamic core/rule updates | Отклонены: больше кода, мусора и точек отказа |
|
||
| Remote managed rule-set | Отклонены: сеть может заблокировать запуск VPN |
|
||
| Fork ради owner lookup | Пока отклонён: exact core вызывает lookup безусловно, но сначала нужен device benchmark |
|
||
| DoH через proxy для трафика туннеля | Отклонён: узел перенаправляет на свой resolver только DNS на 53, а зашифрованный запрос проходит мимо и возвращает настоящий origin — управляемые имена переставали работать. Managed-этап использует UDP/TCP 53 через proxy; DoH остаётся только вне туннеля, в bootstrap |
|
||
| Отдельный VPN-процесс | Отклонён до профилирования: добавляет IPC/process overhead и усложняет lifecycle |
|
||
|
||
Pinned Android AAR собирается с `with_gvisor`. Для managed TUN поле `stack` не переопределяем: exact core выбирает upstream default `mixed`. Exact pinned core при пустом `tun.mtu` выбирает на Android `9000`, а userspace WireGuard — собственный меньший MTU. Поэтому стандартный runtime задаёт внешний TUN явно: `1500` для остальных профилей и `min(1500, effective userspace WireGuard endpoint MTU)` для конфигураций с userspace WireGuard. Endpoint без MTU получает Android fallback `1280`; это совпадает с default официального AmneziaWG Android. Режим «По профилю» сохраняет явно заданный TUN MTU, но при отсутствии поля также использует MTU userspace WireGuard endpoint и не возвращается к несовместимому 9000. Stored JSON не меняется. Исправление основано на системном logcat stable 0.2.3, где фактический Android TUN был `9000` при endpoint `1280`; окончательный физический A/B `1280 ↔ 9000` остаётся обязательным.
|
||
|
||
Runtime-копия каждого профиля, включая raw JSON и endpoint-only WireGuard/AWG, всегда получает `log.level=warn`; явно более строгие `error`, `fatal` и `panic` сохраняются. `trace`, `debug`, `info`, `notice` и отсутствие уровня ограничиваются до `warn`, а `log.output` удаляется. Это обязательно, потому что exact core при отсутствующем `log.level` выбирает `trace`, что создаёт ненужную работу на data-plane. Сохранённый JSON не переписывается.
|
||
|
||
Итог аудита: безопасного более раннего фильтра для домена/IP на stock Android не найдено. Текущая схема использует самый ранний доступный UID-фильтр, binary IP/domain lookup и не гонит `direct/reject` на VPN-сервер. Дальнейшее ускорение возможно только ценой root, proxy-only, огромных system routes или собственного patch ядра.
|
||
|
||
## Политика CPU и батареи
|
||
|
||
Экономичный default является частью MVP, а не будущей оптимизацией:
|
||
|
||
- приложение, `VpnService`, локальный libbox command server и UI работают в одном Android process; управляемый профиль не создаёт второй service/process или внешний Clash controller;
|
||
- `Libbox.setup()` выполняется лениво при первом запуске VPN, а не в `Application.onCreate`; обычное открытие UI не поднимает core, command socket или сетевые callbacks;
|
||
- внутренний Clash traffic manager создаётся самим libbox из-за `PlatformLogWriter` и даёт session totals без сетевого listener; в managed JSON `experimental.clash_api.external_controller` и external UI отсутствуют;
|
||
- `Libbox.SetMemoryLimit(false)` вызывается явно. В pinned core значение `true` на Android меняет Go `GCPercent` со `100` на `10`, не устанавливая Android memory limit;
|
||
- runtime-копия managed JSON использует `log.level = "warn"`; raw JSON сохраняет явно выбранный уровень. Libbox хранит не более 256 строк в памяти, а `log.output` удаляется только из runtime-копии, поэтому запись runtime-лога на диск отсутствует;
|
||
- `CommandStatus` с интервалом 1 секунда включается только для видимой главной; второй `CommandStatus` с интервалом 5 секунд — только для «умной проверки» при включённой настройке и горящем экране; `CommandConnections` не включается никогда; bounded `CommandLog` открывается на время connect health-check и затем только при видимой диагностике;
|
||
- 60 значений графика — обычный кольцевой массив в памяти. Уведомление, фон и закрытый UI не обновляют график;
|
||
- health-check выполняется при подключении и значимой смене сети, IP выхода и Relay — только явной командой пользователя. Периодических проверок «на всякий случай» нет;
|
||
- автоматическое восстановление после транзиентного отказа ждёт сеть по событию `ConnectivityManager`, а не опросом; единственный таймер — ограниченный backoff повтора. Простой без сети не стоит ничего и потому не ограничен, а число попыток подключения ограничено жёстко; wakelock и alarm не используются;
|
||
- DNS cache остаётся включённым с capacity 4096. Явный Secure или managed-этап Auto использует `fallback/sequential` над двумя транспортами к одному resolver'у узла: UDP, затем TCP для сетей, где UDP через прокси не проходит; успешный DNS профиля managed-трафика не создаёт;
|
||
- подписки и импорт работают только после действия пользователя; updater проверяет обновления при запуске и раз в сутки (JobScheduler, без wakelock/alarm), а Stable скачивает само;
|
||
- rootless hardening выполняется один раз при сборке runtime JSON и не создаёт scanner, timer, listener или отдельный process;
|
||
- приложение не запрашивает `WAKE_LOCK` и исключение из battery optimization;
|
||
- managed presets используют selector и явную read-only проверку Relay, а не policy `urltest`; не создают NTP, remote rule-set и явные persistent keepalive.
|
||
|
||
Импортированный JSON остаётся источником истины и может сам содержать `urltest`, NTP, remote rule-set, внешний Clash controller, verbose log или keepalive. Перед запуском GUI показывает единое предупреждение «Профиль содержит фоновую или внешнюю активность». Включённая по умолчанию и видимая в настройках localhost-защита удаляет control listener только из effective runtime; остальные поля не меняются.
|
||
|
||
### Обязательный release-gate энергии
|
||
|
||
Измеряется release/profileable APK, без debugger, минимум пять повторов каждого сценария при одинаковых яркости, температуре, сети и объёме данных:
|
||
|
||
1. VPN выключен, экран погашен, трафика нет — baseline устройства.
|
||
2. VPN подключён, экран погашен, managed-профиль без трафика — idle overhead.
|
||
3. Невыбранное приложение передаёт фиксированный объём — Zapret KVN не должен получать его пакеты или расти по CPU пропорционально трафику.
|
||
4. Выбранное приложение с `direct` — цена TUN/core без VPN-сервера.
|
||
5. Выбранное приложение с proxy — полный пользовательский путь.
|
||
6. Видимая главная против закрытой — цена status stream и Compose-графика.
|
||
7. Серия уникальных DNS-имён — принятый `parallel` против контрольного `sequential` по надёжности, числу запросов, CPU и энергии.
|
||
8. Wi-Fi ↔ mobile, reconnect и сломанный DNS — отсутствие бесконечного retry и утечки jobs/threads/fd.
|
||
|
||
Сохраняются Perfetto/System Trace, process CPU time, RSS/PSS, GC count/pause, wakeups, wakelocks, network bytes/packets и энергия. На поддерживаемом устройстве используется [Android Power Profiler/ODPM](https://developer.android.com/studio/profile/power-profiler) и Macrobenchmark [`PowerMetric`](https://developer.android.com/reference/androidx/benchmark/macro/PowerMetric); на API 26 — system trace и batterystats. APK не выпускается, если managed idle создаёт периодические сетевые запросы/таймеры, обнаружен app-owned wakelock/job/alarm, невыбранный трафик создаёт per-packet работу Zapret KVN или выбранный вариант не проходит стабильность DNS/IPv6/QUIC.
|
||
|
||
Жёсткий порог mW заранее не выдумываем: первый воспроизводимый прогон фиксирует baseline. Из двух совместимых вариантов принимается более экономичный; разница менее 5% считается шумом и не оправдывает усложнение архитектуры.
|
||
|
||
## Структура модулей
|
||
|
||
```text
|
||
app/ product orchestration, UI, profile/config, libbox и VpnService
|
||
app-updater/ Forgejo release API, download, checksum/APK/signing policy и retry callback
|
||
network-bootstrap/ Android underlying Network, согласованный snapshot, bootstrap DNS и коды NET/DNS
|
||
wireguard-import/ независимый parser WireGuard/AWG без зависимости от app
|
||
```
|
||
|
||
Новые Gradle-модули создаются только для самостоятельной границы с направленной зависимостью на `app`, а не для каждого слоя/класса. `app-updater` не знает о Compose, профилях, `libbox` и VPN lifecycle: app передаёт ему только installer factory и одноразовый VPN lease callback. `network-bootstrap` не знает о Compose, профилях, `libbox` и VPN lifecycle; `app` не реализует повторно bootstrap resolver или выбор физической сети. Конкретные продуктовые классы передаются через один `AppContainer`. `VpnService` остаётся единственным владельцем активного PFD/core lifecycle.
|
||
|
||
## Что сознательно не входит в MVP
|
||
|
||
- Room/SQLite;
|
||
- Hilt, Koin или другой DI framework;
|
||
- WebView;
|
||
- аналитика и telemetry;
|
||
- WorkManager и периодическая синхронизация;
|
||
- отдельный Android process для VPN;
|
||
- `WAKE_LOCK`, alarm/job и запрос исключения из battery optimization;
|
||
- live speed в уведомлении и фоновый status/connection polling;
|
||
- динамическая загрузка или смена core;
|
||
- второй TUN/VPN service;
|
||
- root, iptables, eBPF и Device Owner режим;
|
||
- Always-on/Lockdown;
|
||
- FakeIP по умолчанию;
|
||
- глобальный traffic sniff;
|
||
- полноценный firewall/ad blocker;
|
||
- whole-app block в основном picker;
|
||
- планшетный/desktop layout;
|
||
- собственный параллельный движок правил;
|
||
- автоматическое исправление неизвестных JSON-полей.
|
||
|
||
## Разрешения
|
||
|
||
- `INTERNET`, `ACCESS_NETWORK_STATE`;
|
||
- VPN consent при первом подключении;
|
||
- foreground VPN service и соответствующий Android 14+ service type/permission;
|
||
- `POST_NOTIFICATIONS` на Android 13+;
|
||
- камера только при открытии QR scanner;
|
||
- `QUERY_ALL_PACKAGES` для полного per-app picker на Android 11+; основной
|
||
`getInstalledApplications()` сверяется с явными `MAIN`/`LAUNCHER` queries, а
|
||
`getInstalledPackages()` вызывается только при ошибке, почти пустом результате
|
||
или доказанном launcher-расхождении. Типизированный discovery-report различает
|
||
полный, восстановленный, частичный и неудачный каталог без сохранения package list.
|
||
Ошибка источника не заменяется придуманным сообщением: picker показывает имя
|
||
операции, исходный `Throwable.toString()` и фактические counts остальных источников;
|
||
- `REQUEST_INSTALL_PACKAGES` и `UPDATE_PACKAGES_WITHOUT_USER_ACTION` (без неё Android 12+ не принимает `USER_ACTION_NOT_REQUIRED`) только для сессии `PackageInstaller` с APK, проверенным updater; `RECEIVE_BOOT_COMPLETED` не запрашивается;
|
||
- системный file picker без broad storage permission;
|
||
- буфер только после явного нажатия.
|
||
|
||
`WAKE_LOCK` и `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` не запрашиваются. Usage Access, Accessibility, root и изменение системного Private DNS не используются.
|
||
|
||
## Оформление, диагностика и обновления
|
||
|
||
Material 3 следует системной светлой/тёмной теме. Dynamic Color применяется на Android 12+; Android 8–11 получают встроенную согласованную палитру.
|
||
|
||
Диагностика остаётся обычным пакетом внутри `app`, а не отдельным Gradle-модулем или сервисом. Каждая попытка подключения/контролируемого restart создаёт в памяти bounded timeline максимум из 20 этапов с монотонной длительностью: профиль и scope, сеть Android, bootstrap DNS/TCP, runtime overlay, `CheckConfig`, platform adapter, command server, запуск core/TUN, selector/log clients, ожидание VPN network, отдельные UDP/TCP/Android DNS probes, HTTPS probe и финализация. Экран и diagnostic JSON v5 показывают итог, candidate attempt ID, причину отмены, leased VPN Network identity/loss, текущий этап, elapsed/remaining budget, socket path, фактический outer TUN MTU, status/detail каждого этапа и самый долгий этап. Отдельный stop timeline фиксирует отмену запуска, закрытие Android TUN, callback/job, command clients, libbox service, command server и network monitor; незавершённый этап экспортируется со статусом `running` и текущей длительностью. Замеры происходят только при переходах между этапами: ticker, polling, worker и дополнительный сетевой запрос для них не создаются.
|
||
|
||
Диагностика и главная показывают стабильный support-код (`NET-*`, `DNS-*`, `SRV-*`, `CFG-*`, `CORE-*`, `VPN-*`, `AUTH-*`) рядом с понятным сообщением. `VPN-200` означает, что DNS-проверка уже пройдена, но полезный HTTPS-трафик через выбранный VPN outbound не прошёл. `AUTH-100` назначается только по явному отказу авторизации в ошибке или startup-логе ядра; `timeout`, `EOF` и молчаливое закрытие VLESS не считаются доказательством отключённого UUID и остаются `VPN-200`. Для WireGuard startup core-лог различает два паттерна поверх `VPN-200`/`DNS-200`: `VPN-210` — «received handshake response» есть, но данные через туннель не возвращаются (блокировка протокола DPI или сломанный форвардинг/NAT на сервере — с клиента неразличимо), `VPN-211` — рукопожатие отправляется, но ответ не приходит; сырые строки-доказательства из core-лога (redacted) попадают в техническую деталь ошибки. `VPN-210` требует явных доказательств мёртвого data-plane (retry «stopped hearing back» или «dns: exchange failed … deadline exceeded»), а успешный «dns: exchanged» через туннель отменяет вердикт: полевой случай июля 2026 показал, что strict Private DNS (DoT :853 refused) валит DNS-пробу при полностью живом туннеле. Сам паттерн VPN-210 подтверждён полевыми отчётами: тот же WG-профиль работал из другой сети и с AmneziaWG-обфускацией, то есть DPI режет именно чистый WireGuard после рукопожатия. Для bootstrap код задаёт типизированная ошибка `network-bootstrap`; для остальных старых путей app назначает стабильный код категории. Вся suspend-цепочка запуска имеет один 45-секундный monotonic deadline, а DNS+HTTPS health-gate — вложенный предел 20 секунд: по истечении VPN fail-close останавливается с `VPN-120`, без retry-loop. Diagnostic JSON хранит redacted техническую деталь (`rcode`, `errno`, timeout), но endpoint и credentials туда не попадают. В памяти остаются шесть последних попыток подключения, до 20 этапов и до 48 startup core-записей на попытку. При нехватке места в core-логе первыми вытесняются DEBUG/TRACE, затем обычный INFO, строки с признаками обрыва (уровень не выше WARN, `reconnect`, `closed`, `dial`, `handshake`, `quic`, `no recent network activity`, `i/o timeout`, `exchange failed`) — последними; соседние обычные DNS-строки схлопываются в одну со счётчиком. Одинаковые соседние сообщения схлопываются, а handshake/endpoint/TUN/timeout/warn/error имеют приоритет над обычным packet/DNS-шумом; counters показывают полученные, схлопнутые и отброшенные строки. Вход одного callback ограничен 48 записями, общий журнал — 80, внутренний backlog libbox — 256. `CommandLog` кратко работает во время connect health-check; после подключения он остаётся только пока экран диагностики видим и Activity находится в `STARTED`. Runtime/core traffic log не попадает в Logcat и на диск. На диск атомарно записывается только один последний uncaught Kotlin/Java crash: timestamp, тип, redacted message и максимум 16 сокращённых stack frames в `noBackupFilesDir`; следующий crash заменяет предыдущий. На API 30+ дополнительно читается одна системная запись о прошлом завершении процесса (включая native crash/ANR), но большой system trace намеренно не копируется.
|
||
|
||
История сессий (`SessionHistory`, report v9) живёт в памяти процесса независимо от экрана диагностики и не пишется на диск: последние 5 сессий с generation, ссылкой на профиль, тегом и протоколом сервера (адреса маскируются), временем подключения и конца, причиной конца (`user_stop`, `restart:<trigger>`, `server_switch`, `network_change`, `error:<code>`, `stop:<trigger>`, `paused`; гибель процесса неизвестна), байтами uplink/downlink, пиковой скоростью downlink, временем последнего роста downlink и кольцом из 24 ключевых событий (ручной/автоматический выбор сервера, перезапуски с причиной, решения автоматики, тревоги детектора, важные строки core-лога — собираются всегда, повторы схлопываются). Каждая причина перезапуска имеет собственный триггер (`RestartTriggers`), общий `restart` остаётся только для неизвестного текста. В `session_traffic` экспортируются счётчики текущей сессии и секунды с последнего роста downlink.
|
||
|
||
Diagnostic JSON создаётся только явной кнопкой, не содержит raw profile, package list, endpoint, внешний IP или credentials и включает app/core revision+patch SHA-256, Android/API/device ABI, non-VPN network/Private DNS, runtime resource counters, connection timeline, одну прошлую process-exit запись, последний app crash, log counters и структурную сводку effective `zapret-*` overlay. Временный файл перезаписывает предыдущий, передаётся системным Sharesheet через non-exported `FileProvider` с read grant и удаляется при следующем запуске.
|
||
|
||
Updater проверяет только Forgejo Releases Stable/Beta: при запуске приложения, из суточного `JobScheduler`-задания и по кнопке «Проверить сейчас». По `Build.SUPPORTED_ABIS` он выбирает один APK из `release-metadata-v2.json` (`arm64-v8a`, `armeabi-v7a` или `x86_64`), требует отдельный SHA-256, ограничивает HTTPS/redirect/размер и доверяет только точному хосту и пути репозитория; запросы идут с нейтральным User-Agent браузера. Маршрут выбирается без сообщений пользователю: при включённом VPN прямой запрос мимо VPN и запрос через временный VPN-маршрут идут наперегонки (второй стартует с задержкой), при выключенном — сначала прямой, VPN только после маршрутной ошибки; победивший маршрут запоминается (`updater_prefs`) и используется для загрузки. Stable скачивается сам (на сети с лимитом — только если по сети придёт не больше 25 МБ), Beta — по нажатию. Загрузка берёт из установленного APK неизменённые записи (имя, CRC-32, размеры, метод совпали), а остальное — центральный каталог, заголовки, изменённые записи и APK Signing Block — докачивает HTTP Range (до 3 параллельных диапазонов); план, разбор ZIP и склейка диапазонов — чистый Kotlin (`ZipDirectory`, `DeltaPlan`, `ApkDownloader`). Сомнение в структуре (ZIP64, другой ABI, дескрипторы данных, сервер без Range) или несовпадение SHA-256 даёт полную загрузку с докачкой `.part`. Затем проверяются package, повышение versionCode, minSdk и signing history содержимого APK, и перед установкой SHA-256 сверяется ещё раз по байтам, уходящим в сессию `PackageInstaller` (`USER_ACTION_NOT_REQUIRED` на Android 12+). Тихая попытка (одна на версию, только из фона) возможна, когда приложение не на экране, VPN выключен и установщиком по записи является само приложение; если она не довела до установки, а также во всех остальных случаях система просит подтверждение, и оно открывается только по нажатию на плашку, кнопку в настройках или тихое уведомление. Установка по нажатию всегда идёт с `USER_ACTION_REQUIRED`. Кэшированный APK удаляется после передачи установщику, отмены, ошибки и при следующем запуске; исключение — проверенный APK более новой версии, ожидающий установки (SHA-256 и подпись сверяются заново). Core никогда не скачивается отдельно: libbox меняется только вместе с подписанным APK. Legacy `release-metadata.json` сохраняется для одного переходного arm64-обновления старых клиентов.
|
||
|
||
Локальный release publisher строит CLI/AAR/APK из одного полного commit и одного tracked patchset, проверяет embedded revision и patch SHA-256,
|
||
подписывает постоянным owner-only ключом и публикует три одно-ABI APK, checksums,
|
||
metadata и release notes в Forgejo. APK с чужим ABI, отсутствующим `libbox` или debug symbols отклоняется. Assets существующего tag не заменяются. Порядок создания и
|
||
восстановления офлайн-ключа зафиксирован в [SIGNING.md](SIGNING.md).
|
||
|
||
Ссылки находятся только в «Настройки → Сообщество»:
|
||
|
||
- [Zapret KVN](https://t.me/bypassblock)
|
||
- [VPN Discord YouTube](https://t.me/vpndiscordyooutube)
|
||
- [Zapret VPN bot](https://t.me/zapretvpns_bot)
|
||
|
||
## План реализации
|
||
|
||
Каждый этап должен оставлять собираемое приложение и не начинать следующий до своего gate.
|
||
|
||
### Этап 0 — воспроизводимая основа
|
||
|
||
- создать один Compose `app` с minSdk 26;
|
||
- настроить Material 3, fallback palette и четыре нижние вкладки;
|
||
- добавить CI-сборку pinned libbox/CLI и проверку revision;
|
||
- запускать `sing-box check` для всех эталонных JSON;
|
||
- зафиксировать `Libbox.SetMemoryLimit(false)`, release `Debug=false` и bounded log на 256 строк.
|
||
|
||
Gate: debug APK собирается локально и в CI; revision ядра совпадает.
|
||
|
||
### Этап 1 — хранение и минимальный импорт
|
||
|
||
- `ProfileStore`, metadata index и `AtomicFile` backup;
|
||
- импорт raw JSON из файла/буфера;
|
||
- список профилей и raw editor;
|
||
- `CheckConfig()` без запуска VPN.
|
||
|
||
Gate: JSON round-trip сохраняет неизвестные extended-поля; backup восстанавливается.
|
||
|
||
### Этап 2 — вертикальный VPN slice
|
||
|
||
- global per-app picker/include mode;
|
||
- foreground `VpnService` и один lifecycle owner;
|
||
- один Android process, без второго VPN/daemon service;
|
||
- platform adapter, protect callback, один TUN;
|
||
- подключение одного валидного JSON-профиля;
|
||
- постоянное уведомление и полный stop/revoke cleanup.
|
||
|
||
Gate: выбранное приложение проходит через TUN, контрольное невыбранное не появляется в TUN.
|
||
|
||
### Этап 3 — DNS и отказоустойчивость
|
||
|
||
- underlying network monitor;
|
||
- Android bootstrap resolver и LKG;
|
||
- четыре DNS-режима;
|
||
- port-53 hijack, fallback/sequential над UDP/TCP DNS узла;
|
||
- captive portal, strict Private DNS preflight;
|
||
- health-check state machine.
|
||
|
||
Gate: Android device matrix из DNS ADR; мёртвый DNS никогда не оставляет активный TUN.
|
||
|
||
### Этап 4 — маршрутизация
|
||
|
||
- экран с карточками «Область VPN» и «Правило трафика»;
|
||
- presets и читаемый effective summary;
|
||
- packaged `.srs` + manifest/hash;
|
||
- действия proxy/direct/reject;
|
||
- DNS + route block;
|
||
- advanced include/exclude mode и JSON rules.
|
||
|
||
Gate: Routing ADR matrix, RU/non-RU IPv4/IPv6, selected/unselected apps и block fixture.
|
||
|
||
### Этап 5 — полный импорт и подписки
|
||
|
||
- QR scanner, URL и connection URI parsers;
|
||
- subscription groups и ручное обновление;
|
||
- понятный preview результата без автоподключения;
|
||
- популярные package suggestions без TikTok;
|
||
- предупреждение о `urltest`/NTP/remote rule-set/external Clash/verbose log/keepalive в импортированном JSON.
|
||
|
||
Реализация хранит refresh URL отдельно в `noBackupFilesDir/subscriptions/index.json`:
|
||
`profiles/index.json` остаётся UI-only и не получает token/credentials. Parser создаёт
|
||
настоящий sing-box JSON сразу; transient import candidate не становится вторым
|
||
источником routing. QR использует отдельную неэкспортируемую Activity и запрашивает
|
||
CAMERA только после явного действия. Ни updater, ни subscription refresh не имеют
|
||
фонового scheduler.
|
||
|
||
Точный core содержит Clash subscription parser, но текущий libbox AAR его не
|
||
экспортирует. Kotlin YAML parser намеренно не добавляется: подробная семантика,
|
||
кандидат Hysteria v1 и критерии возврата описаны в
|
||
[политике форматов импорта](IMPORT_FORMATS.md).
|
||
|
||
Gate: malformed input не меняет существующие профили; секреты не попадают в UI/log.
|
||
|
||
### Этап 6 — продуктовый минимум
|
||
|
||
- главная карточка, IP, ping, session timer и график;
|
||
- lifecycle-gated `CommandStatus` 1 Гц; без `CommandConnections` и live-speed notification;
|
||
- диагностика/redacted share;
|
||
- тема, сообщество и About.
|
||
|
||
Gate: полный пользовательский путь «установил → импортировал → выбрал приложения → подключился → отправил диагностику».
|
||
|
||
### Этап 7 — updater и Forgejo Release
|
||
|
||
- Stable/Beta check при запуске, раз в сутки и вручную; фоновая синхронизация подписок не используется;
|
||
- checksum/package/version/signing-history validation до installer;
|
||
- подписанные exact-core `arm64-v8a`, `armeabi-v7a`, `x86_64` APK, metadata, SHA-256 и notes;
|
||
- same-key upgrade с сохранением app-private данных.
|
||
|
||
Updater находится в отдельном `app-updater` library-модуле. Маршрутную ошибку (сеть, блокировка, 5xx)
|
||
он гасит молча: при включённом VPN прямой запрос и запрос через временный VPN-маршрут идут наперегонки,
|
||
при выключенном VPN временный маршрут включается только после прямой неудачи и только из интерфейса
|
||
(фоновое задание VPN не запускает). `app` временно перезапускает/поднимает текущий VPN с runtime-only
|
||
правилом для package Zapret KVN и хоста Forgejo. После операции предыдущее состояние VPN восстанавливается даже
|
||
при отмене; stored JSON и трафик других приложений не меняются. Отдельного VPN service и постоянного правила
|
||
нет; единственный планировщик — суточное `JobScheduler`-задание проверки.
|
||
|
||
Gate: битый/прерванный/чужой/downgrade APK не запускает installer и не оставляет cache.
|
||
|
||
### Этап 8 — выпускная матрица
|
||
|
||
- выполнить весь список «Потом проверить» ниже на реальных устройствах;
|
||
- сохранить результаты, версии ОС, модели устройств и сырые замеры как release artifact;
|
||
- перенести в канонические решения только выводы, прошедшие критерий соответствующего пункта.
|
||
|
||
Gate: APK не выпускается при failed fixture, instrumented test, ABI/revision mismatch или провале обязательного release-gate энергии.
|
||
|
||
## Текущая проверка
|
||
|
||
Локально реализованы Этапы 0–7:
|
||
|
||
- 7/7 JSON приняты CLI, собранным из точного commit;
|
||
- 7/7 приняты compatibility release CLI;
|
||
- [Android WireGuard ClientBind fixture](../testdata/routing/wireguard-android-client-bind.json),
|
||
SHA-256 `822dcc9c6a138418b8adc51be44260ac3ff0dceb10187506e1506a5c710974c7`,
|
||
фиксирует Android WireGuard endpoint, health-route и раздельные TUN/endpoint MTU;
|
||
- `go test ./dns/... ./route/rule ./experimental/libbox` проходит; наш воспроизводимый audit test дополнительно проверяет exact pinned fallback success/error/hang/RCODE внутри исходного Go package;
|
||
- Gradle-проект с направленными library-модулями собирает одно-ABI debug и R8 release-матрицу; каждый APK содержит ровно один ABI, один process и один `VpnService`;
|
||
- 165/165 JVM unit-тестов проходят, включая DNS/routing/import/updater/signing,
|
||
Always-on policy и полную блокировку приложений;
|
||
- полный текущий набор 67/67 Android instrumented-тестов проходит на AVD API 36; API 26
|
||
прошёл предыдущую матрицу 66/66 и security delta 3/3, API 29 — 66/66; API 29/36
|
||
дополнительно прошли 100 connect/stop и 50 Wi-Fi/cellular transitions;
|
||
- API 36 AVD прошёл 16 performance-сценариев по пять повторов: невыбранные 8 MiB дали median TUN=0, System Trace/batterystats/raw metrics сохранены, а `mixed`, MTU 9000 и GCPercent 100 оставлены без изменений по порогу 5%; физическая энергия не считается доказанной;
|
||
- same-key upgrade probe на API 26/36 сохраняет настоящий профиль, active id, DataStore и allowlist между versionCode 701001→701002; Android отклоняет downgrade и APK с другим ключом без потери данных;
|
||
- подписанный R8 release bundle с временным тестовым ключом локально прошёл `apksigner`, package/version/core metadata и SHA-256 consistency; постоянный production key и внешний Forgejo Release остаются действиями владельца;
|
||
- отдельный debug-only adb probe на API 26/36 подтверждает hard process contract: после смерти процесса Android снимает service/TUN/core и новый UI показывает `Stopped`, а следующий connect создаёт ровно один экземпляр; receiver отсутствует в release manifest;
|
||
- exact CLI проверяет packaged RU domain/IP `.srs` на RU/non-RU domain, IPv4 и IPv6; manifest закрепляет источник, commit/license и SHA-256, а installer атомарно восстанавливает повреждённый asset до запуска VPN;
|
||
- exact core benchmark: загрузка `.srs` 1 114 мкс/758 624 allocation bytes, lookup 329 нс/op, 1 104 B/op и 2 allocs/op; полный Android debug-прогон дал cold connect 41/63 мс и 2,5/7,5 мс CPU на flow на API 36/26 без продолжающегося роста PSS;
|
||
- в каждом lifecycle-цикле внутренние счётчики PFD/TUN, adapter, callback и libbox instance возвращаются в ноль; process FD/thread trend не показывает продолжающегося роста;
|
||
- Markdown, локальные ссылки, fixture hashes, lint и полный локальный аналог CI проверены;
|
||
- P12–P14, физическая/энергетическая часть P15 и обязательный release-gate энергии на устройстве ещё не выполнены.
|
||
|
||
Эти результаты закрывают локальные автоматизированные Gate 2/4/6/7, но не закрывают физический Gate 3 и выпускную OEM/energy matrix: ещё нужны настоящий captive portal, IPv6-only/NAT64, blocked-DNS/LKG/DNS узла и повтор per-app/routing на физических сетях/устройствах. Они также не заменяют внешний Forgejo Actions run, постоянный signing key и фактически опубликованный Release. Приложение нельзя считать готовым к выпуску до этапа 8.
|
||
|
||
## Потом проверить — открытые вопросы
|
||
|
||
Это единственный список недоказанного. Пункты ниже **не являются принятыми решениями** и не разрешают ИИ заранее добавлять переключатели, новые слои, зависимости или fork ядра. До получения измерений сохраняются канонические defaults выше. После теста результат переносится в соответствующую ADR, а пункт отмечается закрытым с датой, устройствами и ссылкой на лог.
|
||
|
||
| ID | Что пока не доказано | Как проверить | Что делать до результата |
|
||
|---|---|---|---|
|
||
| P1 | Реальная per-app изоляция на всех целевых API/OEM | API 26, 28, 29 и актуальный Android: выбранное приложение видно в TUN, невыбранное не видно; отдельно shared UID, удаление package во время запуска, Always-on/Lockdown | Использовать include allowlist; при пустом/ошибочном списке не запускаться |
|
||
| P2 | Какой userspace stack быстрее именно на наших устройствах | Одинаковые TCP/UDP/QUIC сценарии для `mixed` и `system`: throughput, CPU, RAM, connect time, потери и стабильность на Wi‑Fi/mobile | Оставить поле `stack` пустым, то есть upstream `mixed`; не показывать выбор в обычном GUI |
|
||
| P3 | Отсутствие OEM/операторских проблем у default `1500`, а для userspace WireGuard — `min(1500, endpoint MTU)` | Проверить IPv4, IPv6, NAT64, QUIC, крупные загрузки, PMTU/fragmentation и смену Wi‑Fi/mobile; отдельно A/B WireGuard 1280 ↔ core 9000 | Оставить вычисляемый runtime MTU с явным откатом «По профилю»; менять только по воспроизводимой регрессии |
|
||
| P4 | Цена безусловного Android owner/process lookup в pinned core | Профилирование connect latency, CPU и throughput с текущим core; отдельная экспериментальная сборка без lookup допустима только для сравнения | Не форкать ядро. Patch рассматривается лишь при повторяемом существенном выигрыше и полном повторе матрицы |
|
||
| P5 | Реальное поведение DNS на Android/OEM | Private DNS off/automatic/strict working/broken, captive portal, заблокированный system DNS, DNS узла через proxy, port-53 hijack, DNS/HTTPS health-check | Следовать fail-close DNS ADR; системные настройки не менять и strict Private DNS скрыто не обходить |
|
||
| P6 | Сетевой lifecycle без гонок | Wi‑Fi ↔ mobile, потеря underlying network, быстрые callback bursts, reconnect 8–10 секунд, LKG fresh/stale, revoke, kill process и повторный старт | Один generation token, один service-lock и идемпотентный stop |
|
||
| P7 | Production RU/domain/block rule-set | Выбрать источники и лицензии, закрепить commit/hash, собрать настоящие `.srs`, проверить RU/non-RU IPv4/IPv6, память, cold start и повреждённый asset | Эталонные fixtures считать только schema/graph тестами, не production-базой |
|
||
| P8 | Практическая граница domain block | Стандартный DNS, кэшированный ответ, direct IP и приложения со встроенным DoH; TCP/UDP/ICMP reject | Не обещать firewall. Для гарантии IP-блокировки требовать IP/CIDR set; глобальный sniff не включать |
|
||
| P9 | Android/libbox lifecycle и ABI release build | Собрать AAR/APK из exact commit для выбранных ABI; проверить PFD ownership, `protect(fd)`, cancel/revoke, process death, отсутствие утечек fd/threads и совпадение embedded revision | Не выпускать APK только на основании CLI/unit-тестов |
|
||
| P10 | Достаточность диагностики и redaction | Экспорт конфигов всех поддержанных типов; проверить удаление UUID/password/token/URL credentials и возможность воспроизвести ошибку по очищенному файлу | Не логировать исходные секреты; подробный экспорт только по действию пользователя |
|
||
| P12 | Idle CPU и энергия ещё не измерены | Сценарии 1–8 из release-gate на слабом API 26 и современном устройстве; пять повторов, одинаковые условия | Никаких polling/wakelock/jobs; один process; managed background полностью event-driven |
|
||
| P13 | Цена status/traffic tracking и bounded logs | Сравнить главную видимую/закрытую, status 1 Гц/выключен, diagnostics stream закрыт/открыт; измерить CPU, allocations и GC | Только totals на главной; никогда не подписываться на connections; log stream только в диагностике |
|
||
| P14 | GC=100 против GC=10 и риск памяти | Сравнить CPU, GC count/pause и PSS/RSS под длительной TCP/UDP/QUIC нагрузкой | `SetMemoryLimit(false)`; менять только при доказанном OOM и выигрыше без роста энергии |
|
||
| P15 | Цена надёжного DNS fallback на физических устройствах | Cache burst и уникальные имена; сравнить принятый `parallel` с `sequential` по энергии и запросам, не возвращая заведомо сломанный hang-path | Managed default `parallel`; кэш 4096; без периодических проверок и plaintext fallback |
|
||
| P16 | Стабильность нового Android WireGuard/AWG data-plane | Test 23 доказал старую неисправность после handshake; проверить новый split engine во всех DNS-режимах, затем длительную сессию, mobile/network switch и IPv4/IPv6-capable profiles | Один Android TUN и pinned patch; до физического теста исправление не объявлять подтверждённым |
|
||
|
||
Критерий для P2–P4 и P12–P15: одного удачного speed test недостаточно. Нужны минимум пять повторов на слабом API 26 устройстве и одном современном устройстве без регрессии DNS, IPv6, QUIC и стабильности. Разница менее 5% не оправдывает усложнение архитектуры.
|
||
|
||
## Источники мастер-плана
|
||
|
||
- [Android: per-app VPN contract](https://developer.android.com/develop/connectivity/vpn)
|
||
- [Android: Power Profiler/ODPM](https://developer.android.com/studio/profile/power-profiler)
|
||
- [AndroidX Macrobenchmark: PowerMetric](https://developer.android.com/reference/androidx/benchmark/macro/PowerMetric)
|
||
- [Android PackageManager: APK/signing contract](https://developer.android.com/reference/android/content/pm/PackageManager)
|
||
- [Android SigningInfo: rotation and multiple signers](https://developer.android.com/reference/android/content/pm/SigningInfo)
|
||
- [Android installer intent contract](https://developer.android.com/reference/android/content/Intent#ACTION_INSTALL_PACKAGE)
|
||
- [Forgejo API](https://forgejo.org/docs/latest/user/api-usage/)
|
||
- [Pinned core: Android memory/GC policy](https://github.com/shtorm-7/sing-box-extended/blob/ff11f007ec798136a5de258f947a4f34011a37ea/experimental/libbox/memory.go)
|
||
- [Pinned core: status/log command server](https://github.com/shtorm-7/sing-box-extended/blob/ff11f007ec798136a5de258f947a4f34011a37ea/daemon/started_service.go)
|
||
- [Pinned core: internal Clash API creation from PlatformLogWriter](https://github.com/shtorm-7/sing-box-extended/blob/ff11f007ec798136a5de258f947a4f34011a37ea/box.go)
|
||
- [Pinned core: traffic manager](https://github.com/shtorm-7/sing-box-extended/blob/ff11f007ec798136a5de258f947a4f34011a37ea/experimental/clashapi/trafficontrol/manager.go)
|