62 KiB
Zapret KVN Android — главный план архитектуры
Читать первым. Это главный документ для человека или ИИ, продолжающего проект. Он фиксирует границы MVP, владельцев данных, порядок реализации и запрещённые усложнения. Детали сетевой политики находятся в ROUTING_ARCHITECTURE.md, детали DNS и Android VPN — в DNS_ARCHITECTURE.md, границы входных форматов — в IMPORT_FORMATS.md, рабочие TODO и gates — в IMPLEMENTATION_PLAN.md.
| Поле | Значение |
|---|---|
| Статус | Этапы 0–7 и automated Gate 8 реализованы; внешний signing/release и физические release-gates открыты |
| Последний аудит | 22 июля 2026 года |
| Минимальная ОС | Android 8.0, API 26 |
| Устройства MVP | Только телефоны |
| Ядро | sing-box-extended tag v1.13.14-extended-2.5.2 |
| Точный commit | ff11f007ec798136a5de258f947a4f34011a37ea |
| Текущий workspace | Один Android-модуль, profile/JSON/routing/session UI, один VPN service, pinned libbox и packaged .srs |
Порядок доверия
Если документы кажутся противоречивыми:
- Этот файл определяет продукт, объём MVP и порядок работ.
- Routing ADR определяет per-app, domain/IP/rule-set и
direct/proxy/reject. - DNS ADR определяет
VpnService, DNS, Private DNS, bootstrap и сетевой lifecycle. - Политика форматов определяет допустимые преобразования URI/subscription в JSON.
- Поведение ядра определяется только зафиксированным исходным commit.
Нельзя молча придумывать второй формат конфигурации, скрытые правила или исправлять профиль без отражения в effective JSON. Обнаруженное расхождение сначала фиксируется в соответствующей ADR и тесте.
Цель MVP
Zapret KVN — независимый нативный Android-клиент sing-box-extended:
- импортирует JSON, ссылки, подписки, QR, буфер и файл;
- позволяет подключиться без ручной работы с JSON;
- сохраняет настоящий sing-box JSON и неизвестные extended-поля;
- даёт простой GUI для наиболее частых полей и raw-редактор для остальных;
- по умолчанию пропускает через VPN только выбранные приложения, экономя VPN-сервер;
- распространяется готовым APK только через GitHub Releases.
Основной приоритет: корректность → простота → скорость → дополнительные функции.
Канонические решения
| Область | Решение MVP |
|---|---|
| UI | Kotlin, Jetpack Compose, Material 3 |
| Навигация | 4 нижние вкладки: Главная, Профили, Маршрутизация, Настройки |
| Архитектура проекта | Один Gradle-модуль 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 — единственная сетевая политика профиля |
| Импорт | Вход сразу преобразуется в 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 + managed DNS; DoH fallback sequential |
| Go runtime | Libbox.SetMemoryLimit(false): стандартный GC, без Android GC=10 |
| Телеметрия | Только session totals; 1 Гц лишь пока главная видима |
| Ядро | Встроено в APK и обновляется только вместе с приложением |
| Обновления | GitHub Releases, Stable/Beta, APK + SHA-256 |
| Фоновая работа | Только активный VPN service; нет фоновой синхронизации |
Для 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.
Неподвижные инварианты
- Физически существует ровно один Android TUN.
- Невыбранный UID не попадает в TUN и не виден libbox.
- Выбранный UID полностью попадает в TUN по IPv4 и IPv6.
- Android решает только область приложений; sing-box решает только назначения и outbound.
- Быстрые пресеты не создают дублирующие
package_nameroute-правила. - В управляемом Android TUN нет GEO/LAN route exclusions.
directпроходит через локальный core, но не использует VPN-сервер.- Новая блокировка использует
action: reject, а не legacy block outbound. - Блокировка действует только на приложения, уже включённые в область VPN.
- FakeIP, глобальный sniff, root и второй VPN/TUN по умолчанию запрещены.
- Любая ошибка после открытия TUN полностью закрывает PFD и core.
- Статус «Подключено» появляется только после DNS и HTTPS health-check.
- Приложение не держит
WakeLock, не использует alarm/job/WorkManager и не делает периодический health-check. - Управляемый DNS не дублирует успешный запрос двум DoH: основной и резервный вызываются последовательно.
- Скорость не показывается в уведомлении; status/log streams существуют только пока нужен соответствующий экран.
- Управляемый профиль не создаёт NTP, remote rule-set, периодический URL-test или явный persistent keepalive сверх defaults выбранного протокола.
Реальный сетевой путь
невыбранное приложение
└─ 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 выполняют запуск строго последовательно:
- Проверить выбранный профиль и непустую allowlist.
- Получить системное VPN-разрешение при необходимости.
- Зафиксировать generation token и текущую underlying сеть через event-driven Android callback с
NET_CAPABILITY_NOT_VPN; собственный TUN не может стать underlying network. - Прочитать профиль и создать runtime-копию JSON.
- Проверить один TUN, полные IPv4/IPv6 routes, rule-set assets и запрещённые socket bind-поля.
- Очистить импортированные
include_package/exclude_packageтолько в runtime-копии и применить одну глобальную allowlist. - Добавить только разрешённые overlays: health-check и при необходимости bootstrap LKG.
- Выполнить
libbox CheckConfig()доestablish(). - Проверить captive portal/Private DNS и разрешить адрес активного proxy-сервера через underlying Android network.
- Создать platform adapter и локальный libbox command server в том же Android process.
- Вызвать
startOrReloadService(): libbox синхронно вызываетOpenTun(TunOptions), а adapter создаёт одинVpnService.Builder, применяет приложения, адреса, полные routes и внутренний DNS. Builder.establish()возвращает Android PFD; libbox дублирует его FD и запускает единственный core поверх этого же TUN. Второго адаптера или второго TUN здесь нет.- Проверить соединение с сервером, DNS через VPN и один из двух HTTPS endpoints.
- Только после успеха показать «Подключено»; сбор 1 Hz session-only статистики начать лишь при видимой главной.
Любая ошибка или revoke выполняет один идемпотентный stop: отмена callback/job, остановка core, закрытие PFD, очистка памяти, foreground stop. Терминальный Error/Stopped публикуется только после этого cleanup, чтобы немедленный повторный запуск не пересёкся со старым foreground startId на Android 8–9. Невыбранные приложения всё это время продолжают работать напрямую.
Экраны
Главная
├─ Состояние и большая кнопка подключения
├─ Профиль и выбранный сервер
├─ Внешний IP, пинг, время сессии
├─ Download / Upload
└─ Лёгкий график последних 60 секунд
Профили
├─ Подписки
├─ Импортированные
├─ Файлы
└─ Добавить: QR / буфер / URL / файл
Маршрутизация
├─ Область VPN: выбранные приложения
├─ Правило трафика: preset
├─ Читаемый итог двух решений
├─ Правила: VPN / напрямую / блокировать
└─ Расширенные: rule-set / raw JSON
Настройки
├─ Оформление и DNS
├─ Обновления
├─ Диагностика
├─ Сообщество
└─ О приложении
Главная
Компактная карточка показывает максимум полезного без тяжёлого dashboard:
- состояние;
- профиль/сервер;
- IP и пинг;
- время;
- получено/отправлено;
- две тонкие линии скорости за 60 секунд.
Статистика хранится только в памяти текущей сессии. Суммарные счётчики читаются раз в секунду только пока главная находится в lifecycle STARTED; при уходе с экрана stream и ticker закрываются. IP запрашивается один раз после подключения; пинг — при подключении и вручную. Постоянное уведомление показывает только состояние, а не живую скорость.
В production runtime всегда создаётся внутренний clash_api traffic manager, но
без external_controller или сетевого listener. Постоянный command client слушает
только event-driven группы. Отдельный CommandStatus client с интервалом 1 секунду
существует лишь при одновременно STARTED Activity и выбранной вкладке «Главная»;
его закрытие прекращает ticker в самом core. CommandConnections не создаётся,
CommandLog до экрана диагностики не создаётся.
Пинг на карточке — время одного HTTPS round-trip через фактически выбранный маршрут, а не ICMP. Первый результат переиспользует обязательный connect health-check; дальше проверка выполняется только кнопкой пользователя. Внешний IP дополнительно запрашивается один раз после успешного подключения или ручной смены сервера через dual-stack ipify endpoint; ошибка этого неблокирующего запроса не отключает VPN и не запускает retry. Значения, 60 точек графика и URL-test history живут только до остановки текущей сессии.
Состояния: нет профиля, нет выбранных приложений, выключено, проверка, подключение, подключено, отключение, ошибка. Ошибка ведёт в «Диагностику».
Маршрутизация
Пользователь видит не две конкурирующие «маршрутизации», а две карточки:
Область VPN
Только выбранные приложения · YouTube, Discord · 7 >
Правило трафика
Россия напрямую · остальное через VPN >
Итог
выбранные: RU → напрямую, остальное → VPN
остальные приложения: напрямую, вне VPN
блокировка: только для выбранных приложений
Список приложений открывается отдельным полноэкранным picker с поиском. Список правил остаётся на том же экране в «Расширенных настройках».
Редактор правила имеет ровно три обычных действия:
- «Через VPN»;
- «Напрямую»;
- «Блокировать».
Глобальные presets:
- Всё через VPN;
- Обход LAN;
- Только выбранные сайты;
- Россия напрямую, остальное через VPN;
- Россия через VPN, остальное напрямую;
- Пользовательский.
Whole-app block не входит в основной MVP: он потребовал бы третьего состояния каждого приложения и package_name → reject. Advanced JSON может содержать такие правила под ответственность пользователя.
Хранение и источник истины
DataStore
├─ тема / канал обновлений / DNS-режим
├─ активный профиль
└─ глобальная per-app allowlist и include/exclude mode
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. Поэтому он не является вторым сетевым форматом.
Профиль и 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, буфер — только в обработчике явного нажатия и на main thread для совместимости с API 26. Credentials никогда не копируются в index или DataStore.
Глобальный include allowlist хранится отдельно в DataStore vpn_scope. Флаг initialized отличает первый запуск от осознанно очищенного списка: установленные popular suggestions выбираются только один раз и больше не возвращаются после ручного снятия. Каталог пакетов читается лениво при открытии picker, а не фоново и не по таймеру. Собственный package приложения отбрасывается при чтении/записи DataStore и скрыт из каталога. Полный список нужен основной per-app функции, обрабатывается только локально и никогда не попадает в сеть, аналитику или диагностику.
Перед будущим Builder.establish() единственный VpnAppScopePreflight нормализует пользовательский список, отклоняет пустой список и отсутствующие/отключённые пакеты, затем добавляет пользовательские package и внутренний package Zapret KVN через addAllowedApplication(). Любое исключение Builder возвращает блокирующий результат; частично заполненный Builder после ошибки не используется. Внутренний package существует только в effective platform allowlist для health-check и никогда не сохраняется как пользовательский выбор.
JSON редактируется через kotlinx.serialization.json как дерево. Неизвестные поля сохраняются. После GUI-изменения могут исчезнуть комментарии и исходные отступы; raw editor показывает это заранее.
Managed presets атомарно изменяют настоящий JSON. Runtime overlay не сохраняет произвольные пользовательские правила и всегда доступен в redacted-диагностике.
Подписки обновляются только вручную. Нет WorkManager, таймеров или скрытого сетевого refresh.
Профили, серверы и переключение
Один профиль — один настоящий sing-box JSON. Серверы находятся в его массиве outbounds; отдельной таблицы серверов и связанного набора полноразмерных JSON-шаблонов нет.
Профиль, созданный из одиночной ссылки, всё равно получает managed selector с одним сервером. Следующую одиночную ссылку можно сохранить новым профилем либо явно добавить сервером в существующий managed-профиль. Одна подписка создаёт один профиль-группу: несколько server outbounds и один основной selector. У создаваемого приложением selector стабильный tag zapret-proxy; managed route и DoH ссылаются на него.
Упрощённый фрагмент:
{
"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 и Android integration-тестом switch без смены TUN.
Выбор сохраняется атомарным изменением selector.default в самом JSON после CheckConfig(). experimental.cache_file и DataStore для выбранного сервера не используются. Если runtime-переключение не удалось, выполняется один контролируемый restart уже с проверенным JSON. Переключение целого профиля всегда делает restart, потому что у профиля могут отличаться DNS, routes и TUN-настройки.
Raw JSON не нормализуется скрыто. В режиме «Из JSON» GUI показывает существующие selector-группы как есть. Для включения managed DNS/routing пользователь явно выбирает существующий основной selector либо разрешает создать zapret-proxy.
Полные JSON-шаблоны не являются долгоживущими данными. ManagedProfileFactory только один раз собирает начальный JSON из маленького base builder, protocol outbound builder и selector builder; после сохранения источником истины становится получившийся JSON. При ручном обновлении подписки выбранный server tag сохраняется, если он всё ещё существует; иначе выбирается первый доступный сервер и показывается уведомление.
Профили с credentials находятся только в app-private storage; Android Auto Backup для них выключен. Экспорт диагностики всегда redacted.
Приложения
Режим по умолчанию — include:
- пользователь выбирает приложения, которые должны войти в VPN;
- Zapret KVN добавляет собственный package внутренне для health-check и служебных операций через VPN;
- пустой итоговый список запрещает запуск;
- исчезнувший package или Builder-ошибка отменяет запуск целиком;
- остальные приложения идут напрямую.
Advanced exclude-mode поддерживается, потому что он был выбран в требованиях, но не является default. Пустой exclude-list всегда блокирует запуск: иначе весь телефон неожиданно войдёт в VPN.
В APK хранится только небольшой список подсказок: Instagram, YouTube, Telegram, WhatsApp, Discord, Signal и популярные браузеры; TikTok исключён. Неизвестные приложения автоматически не добавляются. Системные скрыты до нажатия «Показать системные».
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: действие и порядок остаются в JSON.
- Пользовательские inline/local/remote rule-set разрешены только как явный JSON-сценарий.
Domain block создаёт DNS reject и route reject. IP block создаёт только route reject. Глобальный sniff ради блокировки не включается. Встроенный DoH приложения может скрыть domain-only блокировку; полноценный firewall/ad blocker не обещается.
DNS
Режимы GUI:
- Автоматически — Android DNS для bootstrap/LAN/direct, DoH через proxy для proxy-доменов;
- DNS Android — системная DNS/Private DNS политика;
- Защищённый через VPN — стандартный DNS выбранных приложений через DoH/proxy;
- Из JSON — профиль не переписывается.
FakeIP выключен. Системные настройки Android приложение не меняет и Private DNS не обходит.
Managed Auto/Secure не запускаются при strict Private DNS, потому что системный DoT может обойти port-53 hijack и reverse mapping. Полная политика, 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 параллельно | Отклонено для managed default: два запроса и две активные HTTPS-сессии на cache miss |
| Отдельный VPN-процесс | Отклонён до профилирования: добавляет IPC/process overhead и усложняет lifecycle |
Pinned Android AAR собирается с with_gvisor. Для managed TUN поле stack не переопределяем: exact core выбирает upstream default mixed; пустой mtu получает Android-specific default ядра 9000. Отдельные переключатели stack/MTU в обычном GUI не добавляем до измерений на API 26 и современной ОС. Пользовательский 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 JSONexperimental.clash_api.external_controllerи external UI отсутствуют; Libbox.SetMemoryLimit(false)вызывается явно. В pinned core значениеtrueна Android меняет GoGCPercentсо100на10, не устанавливая Android memory limit;- runtime-копия managed JSON использует
log.level = "warn"; raw JSON сохраняет явно выбранный уровень. Libbox хранит не более 256 строк в памяти, аlog.outputудаляется только из runtime-копии, поэтому запись runtime-лога на диск отсутствует; CommandStatusс интервалом 1 секунда включается только для видимой главной;CommandConnectionsне включается никогда,CommandLog— только на открытом экране диагностики;- 60 значений графика — обычный кольцевой массив в памяти. Уведомление, фон и закрытый UI не обновляют график;
- health-check выполняется при подключении и значимой смене сети, IP — один раз за соединение, ping — при подключении и вручную. Периодических проверок «на всякий случай» нет;
- DNS cache остаётся включённым с capacity 4096. Managed fallback использует
sequential: второй DoH вызывается только после transport-ошибки первого;parallelдоступен только в явном пользовательском JSON; - updater, подписки и импорт работают только после действия пользователя;
- приложение не запрашивает
WAKE_LOCKи исключение из battery optimization; - managed presets используют selector и ручной ping, а не
urltest; не создают NTP, remote rule-set и явные persistent keepalive.
Импортированный JSON остаётся источником истины и может сам содержать urltest, NTP, remote rule-set, внешний Clash controller, verbose log или keepalive. Перед запуском GUI показывает единое предупреждение «Профиль содержит фоновую или внешнюю активность» со списком найденных полей, но ничего не удаляет скрыто.
Обязательный release-gate энергии
Измеряется release/profileable APK, без debugger, минимум пять повторов каждого сценария при одинаковых яркости, температуре, сети и объёме данных:
- VPN выключен, экран погашен, трафика нет — baseline устройства.
- VPN подключён, экран погашен, managed-профиль без трафика — idle overhead.
- Невыбранное приложение передаёт фиксированный объём — Zapret KVN не должен получать его пакеты или расти по CPU пропорционально трафику.
- Выбранное приложение с
direct— цена TUN/core без VPN-сервера. - Выбранное приложение с proxy — полный пользовательский путь.
- Видимая главная против закрытой — цена status stream и Compose-графика.
- Серия уникальных DNS-имён —
sequentialпротив контрольногоparallelпо числу запросов, CPU и энергии. - 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 и Macrobenchmark PowerMetric; на API 26 — system trace и batterystats. APK не выпускается, если managed idle создаёт периодические сетевые запросы/таймеры, обнаружен app-owned wakelock/job/alarm, невыбранный трафик создаёт per-packet работу Zapret KVN или выбранный вариант не проходит стабильность DNS/IPv6/QUIC.
Жёсткий порог mW заранее не выдумываем: первый воспроизводимый прогон фиксирует baseline. Из двух совместимых вариантов принимается более экономичный; разница менее 5% считается шумом и не оправдывает усложнение архитектуры.
Структура одного модуля
app/
└─ src/main/java/.../
├─ ui/ Compose screens, components, ViewModels
├─ vpn/ VpnService, VpnController, lifecycle/platform adapter
├─ profiles/ ProfileStore и metadata index
├─ config/ JSON tree editor, validation, presets, rule-set assets
├─ importer/ QR/clipboard/URL/file/subscription parsing
├─ updates/ GitHub Release check/download/install handoff
└─ diagnostics/ redaction, logs, export
Не создаём domain/, usecase/, repository interfaces для каждого файла или отдельные Gradle-модули. Конкретные небольшие классы передаются через один 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+;REQUEST_INSTALL_PACKAGESтолько для явно запущенного GitHub updater и штатного installer;- системный 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 получают встроенную согласованную палитру.
Диагностика показывает короткий тип ошибки и раскрываемые последние 80 redacted-строк текущего запуска. Отдельный CommandLog существует только пока экран видим и Activity находится в STARTED; runtime-лог на диск не пишется. Diagnostic JSON создаётся только явной кнопкой, не содержит raw profile, package list, endpoint, внешний IP или credentials и включает app/core revision, Android/API, non-VPN network/Private DNS и структурную сводку effective zapret-* overlay. Временный файл передаётся системным Sharesheet через non-exported FileProvider с read grant и удаляется при следующем запуске.
Updater проверяет только GitHub Releases Stable/Beta и только после явной кнопки. Он требует согласованные release-metadata.json, отдельный SHA-256 и GitHub digest, ограничивает HTTPS/redirect/размер, затем проверяет package, повышение versionCode, minSdk и signing history содержимого APK. Лишь после этого APK из cache/updates передаётся штатному Android installer через bounded non-exported FileProvider. Ошибка, отмена и следующий запуск удаляют временный APK. Core никогда не скачивается отдельно: libbox меняется только вместе с подписанным APK.
Release workflow строит CLI/AAR/APK из одного полного commit, проверяет embedded revision, подписывает постоянным ключом из GitHub environment и публикует arm64 APK, checksum, metadata и release notes. Assets существующего tag не заменяются. Порядок создания и восстановления офлайн-ключа зафиксирован в SIGNING.md.
Ссылки находятся только в «Настройки → Сообщество»:
План реализации
Каждый этап должен оставлять собираемое приложение и не начинать следующий до своего gate.
Этап 0 — воспроизводимая основа
- создать один Compose
appс minSdk 26; - настроить Material 3, fallback palette и четыре нижние вкладки;
- добавить CI-сборку pinned libbox/CLI и проверку revision;
- запускать
sing-box checkдля всех эталонных JSON; - зафиксировать
Libbox.SetMemoryLimit(false), releaseDebug=falseи bounded log на 256 строк.
Gate: debug APK собирается локально и в CI; revision ядра совпадает.
Этап 1 — хранение и минимальный импорт
ProfileStore, metadata index иAtomicFilebackup;- импорт 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, DoH fallback/sequential;
- 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 и критерии возврата описаны в политике форматов импорта.
Gate: malformed input не меняет существующие профили; секреты не попадают в UI/log.
Этап 6 — продуктовый минимум
- главная карточка, IP, ping, session timer и график;
- lifecycle-gated
CommandStatus1 Гц; безCommandConnectionsи live-speed notification; - диагностика/redacted share;
- тема, сообщество и About.
Gate: полный пользовательский путь «установил → импортировал → выбрал приложения → подключился → отправил диагностику».
Этап 7 — updater и GitHub Release
- ручной Stable/Beta check без фоновой синхронизации;
- checksum/package/version/signing-history validation до installer;
- подписанный exact-core arm64 APK, metadata, SHA-256 и notes;
- same-key upgrade с сохранением app-private данных.
Gate: битый/прерванный/чужой/downgrade APK не запускает installer и не оставляет cache.
Этап 8 — выпускная матрица
- выполнить весь список «Потом проверить» ниже на реальных устройствах;
- сохранить результаты, версии ОС, модели устройств и сырые замеры как release artifact;
- перенести в канонические решения только выводы, прошедшие критерий соответствующего пункта.
Gate: APK не выпускается при failed fixture, instrumented test, ABI/revision mismatch или провале обязательного release-gate энергии.
Текущая проверка
Локально реализованы Этапы 0–7:
- 6/6 JSON приняты CLI, собранным из точного commit;
- 6/6 приняты compatibility release CLI;
go test ./dns/... ./route/rule ./experimental/libboxпроходит; наш воспроизводимый audit test дополнительно проверяет exact pinned fallback success/error/hang/RCODE внутри исходного Go package;- одномодульный Gradle-проект собирает debug и R8 release APK; release содержит только
arm64-v8a, один process и одинVpnService; - 85/85 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 и внешний GitHub 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: загрузка
.srs1 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/DoH и повтор per-app/routing на физических сетях/устройствах. Они также не заменяют внешний GitHub 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 | Оптимальный MTU и отсутствие OEM/операторских проблем у default 9000 |
Проверить IPv4, IPv6, NAT64, QUIC, крупные загрузки, PMTU/fragmentation и смену Wi‑Fi/mobile; сравнить default с безопасными кандидатами только при воспроизводимом сбое | Не задавать managed mtu и не добавлять UI-настройку |
| 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, DoH через 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 | Primary success/error/hang, backup success/error, RCODE и cache burst; сравнить sequential с parallel по энергии и запросам |
Managed default sequential; при полном timeout — fail-close, без скрытого перехода на plaintext DNS |
Критерий для P2–P4 и P12–P15: одного удачного speed test недостаточно. Нужны минимум пять повторов на слабом API 26 устройстве и одном современном устройстве без регрессии DNS, IPv6, QUIC и стабильности. Разница менее 5% не оправдывает усложнение архитектуры.
Источники мастер-плана
- Android: per-app VPN contract
- Android: Power Profiler/ODPM
- AndroidX Macrobenchmark: PowerMetric
- Android PackageManager: APK/signing contract
- Android SigningInfo: rotation and multiple signers
- Android installer intent contract
- GitHub REST API: releases and assets
- Pinned core: Android memory/GC policy
- Pinned core: status/log command server
- Pinned core: internal Clash API creation from PlatformLogWriter
- Pinned core: traffic manager