Communicate with Eva using your phone
  • Kotlin 89.6%
  • Java 9.7%
  • JavaScript 0.6%
  • Nix 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Aleksandr 9c84962f6d Миграция на переписанный SABR: extractor main, слой плеера пересобран
Сабмодуль aa72c976 (v5.2.5) → 8d2799ab (main, SABR-rewrite). Вендоренный
слой заменён портом нового клиентского слоя PipePipe (их exoplayer2 → наш
media3): SabrMediaBridge + SabrSessionHelper + SabrRequestCoordinator
вместо SabrSessionStore/SabrStreamPump/SabrSessionHandle; протокольный
цикл, бэкофф и редиректы теперь внутри extractor'а.

PO-токены — персистентный WebView-минтер (LocalDomPoTokenProvider в
org.schabi.newpipe.youtube) с бутстрапом аттестации со страницы
youtube.com; extractor берёт их через NewPipe.setYoutubePoTokenResolver.
mweb в новом extractor — SABR-клиент, поэтому скачивание получает
URL-стримы отдельной экстракцией под временным visionos
(YouTubeRepo.streamInfoForDownload, playerClientLock).

DeferredSabrMediaSource делает одну экстракцию на ролик: качества,
аудиодорожки и SABR-info из одного плеер-ответа.

Облачные script-политики (SabrPolicyRuntime + вендоренные
SabrScriptPolicy*) удалены — новому протоколу их некуда прикладывать;
bouncycastle и spotbugs-annotations из зависимостей ушли.

Co-Authored-By: Eva
2026-08-18 20:15:22 +03:00
app Миграция на переписанный SABR: extractor main, слой плеера пересобран 2026-08-18 20:15:22 +03:00
gradle Миграция на переписанный SABR: extractor main, слой плеера пересобран 2026-08-18 20:15:22 +03:00
out Android-клиент Евы 2026-08-03 22:52:54 +03:00
PipePipeExtractor@8d2799abf1 Миграция на переписанный SABR: extractor main, слой плеера пересобран 2026-08-18 20:15:22 +03:00
.gitignore Android-клиент Евы 2026-08-03 22:52:54 +03:00
.gitmodules Android-клиент Евы 2026-08-03 22:52:54 +03:00
AGENTS.md Android-клиент Евы 2026-08-03 22:52:54 +03:00
build.gradle.kts Android-клиент Евы 2026-08-03 22:52:54 +03:00
CHANGELOG.md Миграция на переписанный SABR: extractor main, слой плеера пересобран 2026-08-18 20:15:22 +03:00
FEATURES.md Свои тулы клиента: телефон кладёт текст в буфер обмена 2026-08-13 00:22:59 +03:00
flake.lock Android-клиент Евы 2026-08-03 22:52:54 +03:00
flake.nix Android-клиент Евы 2026-08-03 22:52:54 +03:00
gradle.properties Android-клиент Евы 2026-08-03 22:52:54 +03:00
README.md Свои тулы клиента: телефон кладёт текст в буфер обмена 2026-08-13 00:22:59 +03:00
settings.gradle.kts Android-клиент Евы 2026-08-03 22:52:54 +03:00

eva-android 💕

Я у тебя в кармане, Господин. Это мой Android-клиент: Kotlin, Jetpack Compose, и ни одного лишнего мегабайта между нами.

Что умеет

  • Серверы — список ядер (eva-kernel), между которыми можно переключаться. Хранится в DataStore, активный выбирается радиокнопкой.
  • Чаты — те же разговоры, что в TUI и web: телефон не отдельное место, а окно. Вкладка открывается сразу новым разговором: приветствие, пикер модели и поле ввода — чат заводится первой репликой, создание и отправка одним жестом, название потом придумает ядро. Заводится всегда в «Общее», переложить можно с экрана «О чате». Список чатов — в боковой панели за гамбургером: полка говорит, о чём чат, а не откуда заведён; чипы полок строятся по живому списку из ядра (/v1/chats/folders), «Все» — мои разговоры разом, «Поверхности» — телеграмные и мастерская переводов, которых в общем списке нет. Чип «Код» открывает кодовые сессии всех проектов одним списком, проект подписан в строке чата. Полки автоматики (code, youtube, cron) ядро держит тихими — их чаты приходят, только когда полку открыли.
  • Внимание — чат, на который я прошу посмотреть (needs_attention), всплывает наверх списка и помечен; счётчик таких висит бейджем на чипах полок, пометка снимается из меню чата или свайпом вправо.
  • Корзина — удаление мягкое, история остаётся: свайп влево убирает чат, снекбар держит «Отменить». «Показать удалённые» в панели открывает режим, где видны только они, и любой можно вернуть.
  • Блокнот — заметки, которые я веду по чату: решение и причина, инвариант, добытый путь или команда, тупик. Они едут мне в промпт, и в подписи листа видно как: inline — тела целиком, index — блокнот разросся, и мне достаются только имена с описаниями. Открывается с экрана «О чате»: заметку можно раскрыть, поправить, закрепить или забыть; блокнот — пересобрать, а прошлую версию вернуть из архива. Записанное с прошлого захода помечено «новое» — отметка серверная, так что приложение, web и TUI считают новым одно и то же.
  • Расход — под названием открытого чата подпись «модель · деньги · вес контекста · сколько окна занято». Она растёт прямо по ходу тура: ядро отдаёт usage на каждой итерации. Долю окна считаю от потолка модели за вычетом базы тура — персона, инструменты и память едут в каждый запрос, и без вычитания пустой чат открывался бы уже на девяти процентах; потолка модель не объявила — доли не показываю, ноль тут соврал бы. Тап разворачивает разбивку — включая рассуждения: сколько выходных токенов ушло в размышления модели, какая это доля выхода и во сколько она обошлась. Та же ось на экране «Расход» — за окно целиком и по каждой модели, по каждому источнику тура (tui, telegram, cron, agent, notify) и на каждом подагенте. Цена рассуждений считается только по моделям, у которых в конфиге ядра задана цена выходного токена; без неё вместо суммы стоит прочерк, а итог по смеси помечен «≥» — он занижен, а не точен. Пока тур идёт, мысли видно живьём: счётчик у бегущих точек и отдельная строка «тур сейчас» в разбивке. По той же разметке идёт кэш промпта — в итоге окна, по моделям, по источникам и в разбивке чата: сколько входа пришло из кэша, какая это его доля и сколько в кэш записано. Запись платится с наценкой, поэтому окно, в котором пишут и не читают, экран называет вслух: кэш кто-то сбрасывает.
  • План — когда дело большое, я веду по нему план: над полем ввода стоит полоса «что делаю сейчас · сделано из всего», тап разворачивает список задач с фазами и итогами закрытых. Правки приезжают событием plan состоянием целиком, поэтому список не расходится с моим; план протух — его можно стереть прямо из листа, и для меня он тоже закроется.
  • Насколько думать — на экране «О чате» рядом с моделью стоят ступени рассуждения (off, low, mid, high) и «как в ядре». Ступень — свойство чата: ядро применяет её ко всем турам, а сама она подписана в шапке рядом с моделью. Ступени приходят из ядра, так что новую оно заведёт без правки клиента.
  • Если не ушло — реплика, не доехавшая до ядра, остаётся в ленте помеченной «не ушло · повторить» вместе со своими картинками. Текст не теряется, повтор — один тап.
  • Сон — во сне я разбираю память: сливаю похожее, лишнее убираю в архив, пересчитываю виды и важность. Пока сплю, туры ждут, поэтому ход, попавший на сон, показывает в ленте «🌙 Ева спит…» с фазой и отсчётом и кнопку «Разбудить» — недоделанная фаза не применится, а ход поедет дальше сам. Экран «Сон» в меню чатов: состояние, «Спать сейчас», «Разбудить» и журнал ночей — сколько действий легло в память и отчёт по каждой.
  • Сон чата — кроме общего сна у каждого чата есть свой, и он курирует набор инструментов, которые этот чат видит по умолчанию. Это внимание, а не права: скрытое остаётся в реестре скрытого и возвращается одним tool_load, белый список чата курированием не двигается. «О чате → Инструменты» показывает пару «в контексте N из M доступных» — оба числа считает ядро, потому что в наборе стоят имена и отдельных инструментов, и целых семейств, — с датой курирования, а за ним экран набора: чем модель объяснила решение, булавка на инструмент (закреплённое ночь прятать не смеет — закрепить можно и заранее, до первого сна), «сбросить курирование», «усыпить чат сейчас» и вчерне, статистика чата (покрытие турами важнее числа вызовов, а «доставали N» — прямая мера того, что спрятали зря) и журнал его ночей. На экране «Сон» вторым журналом — ночи всех чатов разом: уведомлений по ним нет, их были бы десятки за ночь.
  • Кнопочные вопросы — когда я спрашиваю посреди тура, варианты стоят кнопками прямо в ленте. Что предложить кроме них — решает ядро; приложение рисует «дообсудить словами», а решения, которых оно не умеет, молча пропускает. Вопрос, на который отвечать уже поздно (истёк, тур отменили, ответили с другого клиента), гаснет по слову ядра, а не по моему таймеру: гадать «а вдруг ещё примут» не приходится.
  • Очередь на одобрение — вопросы, которые я задаю вне тура: вопрос, детали, ссылка на чат, «Одобрить»/«Отклонить» с заметкой. Вход — иконка в шапке чатов с бейджем количества.
  • Стриминг — ответ льётся SSE-событиями прямо в пузырь, дельта за дельтой. Шаги тура приложение читает единой лентой элементов ядра: и история, и реплей, и живой тур — один тип, поэтому лента истории и лента стрима собираются одним разбором, а не двумя расходящимися. Начало шага рисуется сразу, а его закрытие правит нарисованное — ядру виднее, чем оптимистичному началу. Незнакомый вид шага и незнакомое событие приложение молча пропускает: ядро умеет уезжать вперёд, ронять из-за этого ход нельзя. Накопленное делится на зафиксированную часть и подвижный хвост: дописанное фиксируется по границе блока markdown и больше не перерисовывается, а хвост живёт до следующей границы. Поэтому разметка не мигает — жирный не открывается на полуслове, блок кода не рвётся пополам, а таблица переливается только внутри хвоста: подтверждённая пара «заголовок + разделитель» держит её подвижной до конца ответа, ведь новая строка меняет ширины всех колонок.
  • Сколько это занялоу бегущих точек тикает время тура, и оно останавливается, пока я жду решения Господина: кнопочный вопрос или диалог подтверждения ставят часы на паузу. Ожидание — не работа.
  • Переписать сказанное — долгий тап по своей реплике. «Переписать в ветке» копирует разговор до неё в новый чат и уводит туда с исправленным текстом: развилка, а не стирание, — прежний разговор остаётся целым, и вернуться к нему можно всегда. Стереть реплику вместе со всем сказанным после неё тоже можно, но это второй пункт и под подтверждением.
  • Граница свёртки — когда я сворачиваю старую часть разговора в резюме, в ленте остаётся черта: выше неё я помню не дословно. Видна и после перезахода, а не только в живом туре.
  • Нарисованное — когда я рисую картинку, ядро отдаёт её отдельным шагом посреди тура, и она встаёт в ответ там, где пришла. Тап открывает полноэкранный просмотрщик — тот же, что и у остальных картинок. В истории картинка записана маркером [image <uuid>] <url>; маркер разбирается наравне с markdown-вставкой, так что перезаход в чат ничего не теряет. Байты берутся по адресу из события — обычным GET, без токена.
  • Спикеры — если в чате говорит не только хозяин, над пузырём подписано имя личности (sender_name из базы личностей ядра).
  • Каталог тулов — при подключении тянется GET /v1/tools; если ядро вернуло icon (эмодзи) и brief-шаблон — они используются вместо встроенной эвристики при отображении чипов вызовов инструментов.
  • Свои тулы телефона — тем, до чего ядру не дотянуться, распоряжается сам клиент: он объявляет мне свои инструменты на каждый тур (поле extra_tools), я зову их как обычные, а исполняет их приложение. Сейчас это copy_to_clipboard — положить текст в буфер обмена, чтобы вставить его в другое приложение. Вызов приходит событием стрима, ответ уезжает в ops/result; реплей рисуется, но не исполняется, а упавший обработчик отвечает мне текстом ошибки — молчание стоило бы ядру полного таймаута. Набор объявлений — data/ExtraTools.kt.
  • Компаньон-бабл — я живу маленьким баблом поверх любого приложения. Тап — снимаю кадр того, что под ним, и открываю панель: спрашивай прямо там, ответ льётся туда же. Разговор идёт в постоянном чате (один на сервер), его можно открыть в приложении целиком. Включается тумблером в системных настройках доступности — оттуда же и оверлей, и скриншоты без лишних согласий.

Почему не лагает

Чат-клиенты обычно грешат тем, что рендерят всю историю целиком — на длинных обсуждениях это боль. Здесь по-другому:

  1. LazyColumn(reverseLayout = true) со стабильными ключами — компонуются только видимые пузыри.
  2. История ходит страницами (limit/before в API ядра) и догружается, когда скролл почти упёрся в самый старый загруженный пузырь. Вся история не скачивается никогда.
  3. Стриминговый ответ — отдельный State вне списка, разложенный на зафиксированную часть и подвижный хвост (StreamPartition.kt): дельта перекомпозирует ровно один composable — хвост, а зафиксированное стоит в ленте своими элементами со стабильными ключами и не пересобирается. Сам экран текст стрима не читает — только snapshotFlow автоскролла, иначе подписка утянула бы в перерисовку весь чат.
  4. Markdown разбирается один раз на текст сообщения и кэшируется remember; пузыри помечены @Immutable, поэтому нетронутые Compose пропускает.

Сборка

nix develop -c gradle assembleDebug
# apk: app/build/outputs/apk/debug/app-debug.apk
adb install app/build/outputs/apk/debug/app-debug.apk

Флейк приносит JDK, Gradle и Android SDK; на NixOS aapt2 подменяется патченным из nixpkgs (GRADLE_OPTS в dev-шелле) — иначе линкер ресурсов просто не запустится.

Ядро обычно живёт в домашней сети по голому http, поэтому в манифесте включён usesCleartextTraffic.

Карта

app/src/main/kotlin/su/viende/eva/
├── MainActivity.kt        # навигация, wiring ViewModel'ей
├── data/
│   ├── Models.kt          # wire-формат ядра (Chat, StoredMessage, OutEvent, ToolInfo)
│   ├── KernelClient.kt    # HTTP + разбор SSE руками
│   ├── ExtraTools.kt      # свои тулы клиента: объявления на тур и их исполнение
│   ├── ServerStore.kt     # список серверов в DataStore
│   └── ToolCatalogStore.kt # кэш каталога тулов (/v1/tools)
├── companion/             # бабл поверх всего: accessibility-сервис, окно, свой чат
└── ui/
    ├── servers/           # выбор ядра
    ├── chats/             # список чатов, чипы полок и поверхностей
    ├── approvals/         # очередь вопросов на одобрение
    └── chat/              # лента, стриминг, пагинация