Android-приложение, которое запускает opencode serve прямо на устройстве (без Termux, без root) и даёт полноценный чат с AI-моделью в нативном интерфейсе.
Kotlin · Jetpack Compose · WebView · ncnn(STT)·musl`
Обычно opencode работает на десктопе (CLI/TUI) или на сервере. Это приложение упаковывает сам бинарь opencode внутрь APK и поднимает локальный HTTP-сервер прямо на телефоне/планшете. Поверх сервера — два слоя UI:
- WebView с веб-интерфейсом opencode (тот же, что в браузере).
- Нативный чат-оверлей поверх WebView — так как официальная SPA не рендерит ленту сообщений в окружении WebView, чат реализован нативно: приложение опрашивает локальный API opencode каждые 2 секунды и рисует переписку сам (Compose).
Итого: «всё в один тап» — открыл приложение, написал сообщение, получил ответ от модели, которая считается локально на устройстве.
Это экспериментальный / демонстрационный проект (MVP). Подробности по параметрам сборки и известным ограничениям — в конце.
- Полноценная переписка с моделью в нативном интерфейсе (Compose).
- Поле ввода, оптимистичная отправка, автопрокрутка (с умной остановкой, когда пользователь сам листает историю вверх).
- Индикатор «думает» (анимированные полоски) пока модель размышляет.
- Вибрация при завершении ответа.
- Поддержка вопросов от модели: если модель спрашивает выбор/уточнение — приложение показывает варианты-кнопки и поле для своего ответа.
- Вместе с serve поднимается второй процесс — memory.js: MCP-сервер
локальной памяти (
127.0.0.1:4199/mcp, streamable HTTP), хранилище в$XDG_CONFIG_HOME/opencode/memory/. Модель получает memory-инструменты (запоминать/искать по прошлым сессиям). - Индикатор «N MCP» в шапке чата — сколько MCP-серверов зарегистрировано
в serve (запрос
GET /mcp). Если конфиг потерялся —ensureMcpConfig()сам допишет секциюmcp.memoryвopencode.jsoncдо старта serve (фикс «0 MCP»). Проверка в диагностике — протокольная:GET /mcp→ 2xx (не просто TCP-порт).
- Системный Android (Google) — встроенный распознаватель, не требует загрузки моделей, нужен доступ к сети.
- NCNN (локально, int8-encoder) — тот же whisper через ncnn, офлайн:
CPU int8 encoder (block-quant) ~2× быстрее fp32, KV-cache в decoder.
Движок whisper.cpp/ggml убран из настроек (legacy:
use_gpu=false, скрыт) — рабочий локальный путь только через ncnn.
Движок выбирается в настройках (шестерёнка в шапке чата).
Потоковый пайплайн (ЭКСП-5): VAD-сегментация + чанкинг — длинная речь
режется на сегменты, каждый распознаётся отдельно (первые слова видны на
1–2 с, без потери хвоста на записи >30 с, меньше галлюцинаций).
Классы: SpeechSegmenter, ChunkedTranscriber. Подробности —
docs/EXPERIMENTS-STT-LATENCY.md.
Сегментация и цена encoder'а. ncnn-encoder платит фиксированные ~6–12 с
на любой вход, поэтому количество сегментов — прямое умножение этой цены.
Порог разрыва вынесен в SpeechSegmenter.splitGapMs (900 мс): раньше
PAD_MS=450 служил и порогом разрыва, и padding'ом, из-за чего запись в
37 с резалась на 12 сегментов (145616 мс) вместо 9 (60070 мс, замер
27.09.2026). Девять сегментов на настоящей записи — корректное разбиение, а не
недорез: паузы в живой речи длиннее 900 мс. «Два сегмента» из регрессионного
теста — свойство синтетики с равномерными паузами 0.61 с. Клипы ≤28 с идут
одним прогоном без VAD — делить короткую речь дороже, чем распознать её целиком.
Определение языка. Язык больше не зашит как ru (из-за чего английская
речь переводилась на русский). Язык решается один раз на запись и
защёлкивается — граница сессии это одна запись
(ChunkedTranscriber.transcribe()), которая сбрасывает защёлку.
encoder_states при этом считается один раз: язык читается
дополнительным префиллом [sot] (+~40 мс), а не вторым прогоном encoder'а
(−6.5 с на каждый сегмент без защёлки). Fallback — ru. Порог по
уверенности намеренно не введён: референсный whisper_lang_auto_detect
тоже делает голый argmax, а неоткалиброванный порог ломает определение
языка сильнее, чем помогает. В logcat (VOICE) видно вероятность выбора.
Решение о языке принимает первый проход движка, а не первый сегмент VAD.
Детектор отказывается работать на кусках короче 3 с — на коротких он выбирает
язык по шуму, — поэтому ChunkedTranscriber.planParts() склеивает ведущие
короткие сегменты до порога и определяет язык уже на достаточном куске.
Раньше такой сегмент уходил в декодер с токеном ru, и запись начиналась с
русской расшифровки иностранной речи: на long.wav первые два сегмента из
девяти давали И так, мои дорогие американцы и «Аск not!», остальные семь
были верными. Склейка бесплатна — VAD разрезал один непрерывный кусок, и
объединённый кусок от целого ничем не отличается; попутно девять проходов
движка стали восемью, long.wav ускорился с 64667 до 52458 мс.
Проверено на устройстве 27.09.2026: jfk.wav (11 с) и long.wav (37 с) через
сервис определяются как en с латинским выводом, и на чанковом пути весь текст
с первого сегмента английский.
Подавление повторов. Декодер шёл жадно и на коротких клипах с
микропаузами любил выдать одну фразу трижды подряд. Перед argmax
применяется apply_repetition_penalty(): токен, закрывающий уже
встречавшуюся тройку, получает -INF, а выданный языковой токен мягко
штрафуется (делится на 1.15). Штраф делением, а не запретом — жёсткий
бан выкашивал бы легитимные повторы («да, именно»). Оговорка: на long.wav
тройное повторение приходит из самого материала (файл — это ~3 повтора
jfk.wav), так что этот фикст подавление повторов не проверяет; нужен
отдельный wav, где фраза звучит один раз.
Статус: правки собраны, гейт зелёный (ktlint + detekt + 179 unit-тестов). Device-верификация выполнена 27.09.2026, 4/4 тестов,
BUILD SUCCESSFUL: чанкингlong.wav60070 мс / 9 сегментов, авто-языкenна обоих клипах, int8 быстрее fp32 в 1.50×–1.70× (конфиги чередуются, иначе троттлинг рисует ложные 2–4×), lazy-регрессия выгрузки модели устранена. Ручных WER-эталонов по-прежнему нет — WER остаётся-.
- large-v3-turbo (574 МБ) — единственная рабочая модель; скачивается по
требованию из панели настроек (прогресс, докачка при разрыве, проверка
целостности — SHA-256 + размер в sidecar
.size). - base (141 МБ) — убрана/не выбирается: конвертация в ncnn даёт мусор
на выходе; код остался в
ModelDownloader, но в UI отсутствует.
- Шрифт ответов модели — 20+ встроенных шрифтов (тап по образцу — сразу применяется и сохраняется; кнопка — иконка шрифта).
- Цвет ответов модели — градиент-пикер (тап/драг по квадрату; кнопка — иконка-капля, тонируется текущим цветом ответов).
- Диагностика STT — «TTS-тест»: синтезирует фразу системным TTS и гонит её
через выбранный движок распознавания. Позволяет понять — проблема в модели/пайплайне
или в микрофоне (результат в logcat по тегу
VOICE).
- Foreground-сервис держит процесс opencode serve, рестартует его с backoff при падении, проверяет доступность по HTTP.
- Basic Auth: serve доступен наружу (HTTP на
127.0.0.1:4096), поэтому все запросы идут с заголовком Authorization; пароль генерируется при первом запуске и лежит в зашифрованных prefs (ServerAuth). Без пароля —401 Unauthorized(проверено на чистой установке). - Рабочая директория (workspace, конфиг, сессии, память) — на внешнем
хранилище
/sdcard/Documents/OpencodeTerminal/opencode/, переживает переустановку приложения (в отличие от prefs/моделей). - Уведомление с кнопкой «Stop» и статусом процесса.
- Диагностика (
RuntimeValidation): порты serve/память, протокольная проверка MCP (/mcp→ 2xx), наличие и валидность ncnn-моделей — экран «Диагностика» в настройках.
Android-ядро позволяет execve только с PIE-бинарём, и только из
nativeLibraryDir для untrusted_app. Поэтому:
Файл (app/src/main/jniLibs/arm64-v8a/) |
Роль |
|---|---|
libopencode.so |
Бинарь opencode (musl-сборка, переименован в lib*.so) |
libldmusl.so |
musl-лоадер (это ld-musl-aarch64.so.1, он же libc) |
libc_musl.so, libstdcxx.so, libgcc_s.so |
musl-libs под placeholder-именами |
Ключевые моменты:
useLegacyPackaging = trueвapp/build.gradle.kts— заставляетextractNativeLibs=true. Без этого.soне извлекаются на диск (маппятся из APK для dlopen) иexecveневозможен.- Бинарь — динамический musl: в нём
PT_INTERP=/lib/ld-musl-aarch64.so.1, которого в системе нет. Поэтому лоадер запускается первым аргументом:ld-musl ./libopencode.so serve --port 4096. - Имена
lib*.so— PackageManager извлекает только такие файлы; при старте зависимые либы копируются вfilesDir/muslс правильными DT_NEEDED-именами, на них указываетLD_LIBRARY_PATH.
| Процесс | Порт | Роль |
|---|---|---|
libopencode.so serve |
127.0.0.1:4096 |
сам сервер opencode (HTTP API, Basic Auth) |
libbun-musl.so memory.js |
127.0.0.1:4199 |
MCP-сервер локальной памяти (streamable HTTP/SSE) |
- Модель видит память, только если serve зарегистрировал MCP-сервер.
Регистрация — секция
"mcp": { "memory": { "type": "remote", "url": ... } }в$XDG_CONFIG_HOME/opencode/opencode.jsonc(тот же формат, что на десктопе:~/.config/opencode/opencode.json).OpencodeRuntime.ensureMcpConfig()дописывает её идемпотентно (атомарно tmp+rename) до старта serve — фикс «0 MCP» (пустой конфиг, когда память слушала порт, но не была зарегистрирована). - Индикатор «N MCP» в шапке чата = ответ
GET /mcpс сервера (черезLocalOpenCodeClientс авторизацией; без заголовков serve отвечает 401). - Память хранится в
$XDG_CONFIG_HOME/opencode/memory/(json-каталог прошлых сессий), переживает переустановку приложения.
На ColorOS/OPPO фоновые compute-потоки душатся до 1–5% CPU. Поэтому
распознавание выполняется в foreground-сервисе (WhisperTranscribeService) —
так ОС даёт процессу нормальный приоритет. Модель кэшируется в память между
вызовами (грузится один раз), что критично для тяжёлого turbo (574 МБ).
Производительность (OPPO, 02.09.2026): encoder int8 ≈ 8.82 s (fp32 — 17.9 s;
Vulkan-эксперимент 6.45 s — стабилен, но в проде выключен), decoder с KV-cache
~0.5–0.6 s → полное распознавание ~10 s.
Бенч 27.09.2026 с чередованием конфигов: int8 6.5–8.2 s vs fp32 11.0–13.2 s,
то есть 1.50×–1.70×. Блоковый порядок («все int8, потом все fp32») давал
2.0×–3.9×, но это был троттлинг: fp32 всегда шёл вторым на горячем телефоне и
деградировал 37 с → 69 с по ходу прогона. Подробности:
docs/EXPERIMENTS-STT-LATENCY.md, docs/stt-bench-2026-09-24.csv,
docs/stt-bench-2026-09-27.csv.
Запись: AudioRecord → PCM16 16кГц → нормализация пика (телефоны пишут тихо) →
подкладка тишины (whisper врёт на очень коротких клипах) → распознавание →
текст в поле ввода.
- Android Studio (или Android SDK + JDK 17) с SDK Platform 36.
- ARM64-устройство (физический телефон) или arm64-эмулятор.
- Файл
local.propertiesс путём к SDK (не коммитится в git).
- Открыть папку проекта в Android Studio, дождаться синка Gradle.
- Собрать:
Build → Build APK(s)или из терминала:На Windows есть обёртка./gradlew :app:assembleDebug
build.ps1(Android Studio JDK, offline, лог вbuild-inst.log):powershell -ExecutionPolicy Bypass -File build.ps1 -Task debug
- APK появится в
app/build/outputs/apk/debug/app-debug.apk. - Установить:
или просто скопировать APK на телефон и открыть.
adb install -r app/build/outputs/apk/debug/app-debug.apk
app/src/main/jniLibs/arm64-v8a/— в git: prebuiltlibopencode.so(~184МБ, opencode, musl-сборка), musl-libs,libbun-musl.so; приложение собирается и запускается из них напрямую (CI проверяет их наличие).- whisper-нативка (
whisperlib) собирается при сборке приложения из исходников:third_party/ncnn(in-repo) + внешнийwhisper.cpp(вне репо) и VulkanSDK (Windows). Пути конфигурируются:-PwhisperCppDir=...,-PvulkanSdkDir=..., отключение нативки —-PskipNativeBuild=true(используется в CI — ubuntu не имеет ни whisper.cpp, ни VulkanSDK). - Модели whisper (turbo) в assets не хранятся — качаются по требованию через
ModelDownloaderвfilesDir/models/(ncnn-формат:.ncnn.param/.bin, int8-вариант энкодера — automatically).
Установка:
⚠️ Два пакета сосуществуют: release (org.opencode.mobile, «OpenCode Mobile») и debug (org.opencode.mobile.debug, «OpenCode Mobile · Debug»). Отличаются по названию под значком.
-
Debug («OpenCode Mobile · Debug»):
adb install -r app/build/outputs/apk/debug/app-debug.apk adb shell monkey -p org.opencode.mobile.debug -c android.intent.category.LAUNCHER 1
-
Release («OpenCode Mobile», R8-минифицирован, подписан release-ключом):
# Один раз: создать ключ и заполнить шаблон (сам файл — секрет, в .gitignore). keytool -genkeypair -v -keystore ~/keys/opencode-release.jks -storetype PKCS12 \ -keyalg RSA -keysize 4096 -validity 10000 -alias opencode-release cp keystore.properties.example keystore.properties # вписать путь и пароли gradlew.bat :app:assembleRelease -x lint adb install -r app/build/outputs/apk/release/app-release.apk adb shell monkey -p org.opencode.mobile -c android.intent.category.LAUNCHER 1
Ключ и пароли никогда не коммитятся.
keystore.propertiesи*.jks/*.keystoreв.gitignore. Потерянный ключ = невозможность выпустить обновление: Play потребует нового приложения с другимapplicationId. Держи бэкап ключа отдельно от репозитория.Если
keystore.propertiesнет (например, на CI), release собирается с debug-подписью и печатает предупреждениеrelease-key НЕ НАЙДЕН ... публиковать её НЕЛЬЗЯ. Такую сборку публиковать нельзя — она совместима только с уже установленным debug. Проверить, чем подписан APK:$ANDROID_HOME/build-tools/36.0.0/apksigner.bat verify --print-certs app/build/outputs/apk/release/app-release.apk
Обычные device-тесты гоняют debug, где R8 выключен. Поэтому есть отдельный
build type minVerify: release с R8 и shrinkResources, но debuggable и с
суффиксом пакета — встаёт рядом с debug, ничего не ломая.
# обычный цикл разработки (быстро, без R8)
gradlew.bat connectedAndroidTest
# проверка того, что R8 не сломал STT
gradlew.bat -PsttTestBuildType=minVerify connectedMinVerifyAndroidTesttestBuildType переключается через -P, поэтому androidTest-вариант
существует только для одного build type за раз, и оба пути остаются
доступными.
Keep-правила — в app/proguard-minverify-rules.pro, на release они не
влияют. Без них инструментация не запустится: R8 оптимизирует по графу
вызовов приложения и не видит тестовый APK, поэтому выкидывает members,
которые вызывает только тест. Отсюда -keep,allowoptimization — имена нужны
для линковки теста, но оптимизация остаётся включённой, её мы и проверяем.
Перед выкладкой стоит убедиться, что JNI-класс уцелел при обфускации:
# Lcom/whispercpp/whisper/NcnnWhisperLib; должен ПРИСУТСТВОВАТЬ в classes.dex
# Lcom/whispercpp/whisper/NcnnWhisperContext; будет обфусцирован - это нормальноЕго спасает правило из дефолтного proguard-android-optimize.txt:
-keepclasseswithmembernames,includedescriptorclasses class * { native <methods>; }
Подробности — CHANGELOG, п. 18.
Логи смотреть:
adb logcat -s OpencodeRuntime OpencodeServerService OpencodeWebView VOICE RuntimeValidation ChatOverlay
⚠️ После переустановки (uninstall → install): сессии/история/память на внешнем хранилище сохраняются, но авторизация сбрасывается — serve сгенерирует новый пароль, и ncnn-модели STT нужно раскатать заново (filesDirстирается). Проверка: «OpenCode server — Running», мониторинг чата работает с новым паролем автоматически.
app/
src/main/
java/org/opencode/mobile/
MainActivity.kt # точка входа, WebView, статус сервера
OpencodeApp.kt # глобальный конфиг каталогов (sandbox + $XDG_CONFIG_HOME)
server/
OpencodeServerService.kt # foreground-сервис: жизненный цикл serve
OpencodeRuntime.kt # запуск musl-лоадером + ensureMcpConfig (память MCP)
RuntimeManager.kt # стейт-машина запуска: память → serve → HEALTHY (backoff)
RuntimeState.kt # состояния рантайма
RuntimeValidation.kt # диагностика: порты, memoryHttpOk() (протокол MCP), модели
ProcessSupervisor.kt # рестарты с backoff + контроль процессов
Workspace.kt # пути к workspace/конфигу (external storage)
ServerAuth.kt # Basic Auth: генерация пароля, заголовки, Keystore/prefs
LocalOpenCodeClient.kt # единый HTTP-клиент serve (get/post/postAsync/delete + auth)
Ipv4Proxy.kt # исходящая сеть (CONNECT-туннель, IPv4-first)
stt/
WhisperTranscribeService.kt # foreground-сервис распознавания
SpeechSegmenter.kt # VAD-сегментация речи (ЭКСП-5)
ChunkedTranscriber.kt # чанкинг: сегменты → отдельные распознавания
NcnnModelValidator.kt # валидация набора ncnn-моделей
ModelDownloader.kt # скачивание turbo (lazy, resume, integrity SHA-256)
NcnnModelDownloader.kt # ncnn-turbo набор (16 файлов) из GitHub Releases
ui/
ChatOverlay.kt # нативный чат поверх WebView + настройки
DiagnosticsScreen.kt # экран диагностики (порты/MCP/модели)
theme/Theme.kt
assets/ # ТОЛЬКО шрифты + MCP-память (моделей нет)
jniLibs/arm64-v8a/ # prebuilt opencode-бинарь + musl-libs (в git)
whisperlib/ # JNI-обёртки: ncnn (рабочий) + whisper.cpp (legacy)
src/main/jni/
whisper/ # whisper.cpp-gw (использует ggml; use_gpu=false)
whisper/CMakeLists.txt # переменные внешних путей: WHISPER_LIB_DIR, NCNN_DIR
ncnn/ # ncnn-whisper (int8 encoder, KV-cache) — основной путь
docs/
ROADMAP.md # три трека к релизу + статусы
EXPERIMENTS-STT-LATENCY.md # замеры ncnn: int8/Vulkan/KV, ЭКСП-5 (чанкинг)
stt-bench-2026-09-24.csv # бенч int8 vs fp32 (24.09.2026)
tools/
ncnn-whisper-plan.md # план интеграции ncnn-whisper
ncnn-int8-plan.md # int8-квантование (внедрено) + тулзы квантования
ncnn-int8/ # quantize_block.py, compare_encoder.cpp (хост-бенч)
build.ps1 # сборка на Windows: debug/release (Android Studio JDK)
Актуальные работы по производительности UI/рендера (подробно — CHANGELOG.md):
| # | Что сделали | Результат |
|---|---|---|
| 1 | Инкрементный парсинг ленты + адаптивный поллинг (400/900 мс) | Парсинг ушёл (58 cached / 0 parsed) |
| 2 | Приостановка фонового WebView (SPA) | Frame-спайки: 99-перц. 21→9 мс, janky 6.87→0.33% |
| 3 | Убраны вечные анимации индикаторов | UI CPU 33→2%, RenderThread 10→0% |
| 4 | Пауза поллинга и анимаций, когда Activity в фоне | Фоновый CPU ~0% |
| 5 | Эффективное мигание MCP (дискретный пульс вместо 60fps) | Мигание возвращено, CPU ~8% (фон 0%) |
| 6 | Все UI-кнопки на готовых Material-иконках (extended) | Микрофон, отправка, stop, шестерёнка, шрифт, цвет — векторные, читаемые |
| 7 | R8-минификация + shrinkResources в release | APK 374 → 274 MB (R8), затем lazy-вынос base → 147 MB (release-подпись; пакет чистый org.opencode.mobile) |
| 8 | base-модель вынесена из assets в lazy-скачивание | base/turbo качаются по требованию; APK больше не тащит 141MB whisper |
Итог: UI CPU ~38% → ~1.5-8% (×5–25), рендер 99-перц. 9 мс, janky 0.33%,
фон ~0%. Единственный хвост — внутренний opencode serve (~35%, код движка, не наш).
План по доведению клиента до «продукта для чужих телефонов» (release-контур, сеть без ручного прокси, STT turbo на CPU) — см. docs/ROADMAP.md.
-
DNS / исходящая сеть — решено встроенным прокси: бинарь opencode (musl/bun) не резолвит IPv6->IPv4 fallback и не читает
/etc/resolv.confна Android. В приложении поднимается встроенныйIpv4Proxy(CONNECT-туннель на127.0.0.1:3128), который резолвит строго по IPv4 через системный стек Netd;HTTPS_PROXYставится автоматически при запускеserve. Внешние сервисы (mcp.context7.com,mcp.grep.app,registry.npmjs.org, модели) работают без ручного прокси-конфига. См.Ipv4Proxy.kt. -
Сабпроцессы opencode (
rg,git, языковые серверы) на устройстве отсутствуют — часть фич (/find, git-интеграция) не работает. -
ForegroundServiceType
specialUse: сервера и STT используютspecialUse(+PROPERTY_SPECIAL_USE_FGS_SUBTYPE). Для side-loaded MVP допустимо; для публикации в Play требуется прохождение ревью этого типа. -
SELinux: маппинг либ из
filesDir(mmap PROT_EXEC) может быть ограничен политикойuntrusted_app— проверено на реальном OPPO, на других устройствах стоит делать smoke-тест. -
Performance STT: полное распознавание turbo ncnn ~10 s (encoder int8 8.82 s). Внедрены foreground-сервис + int8-encoder + VAD-чанкинг (первые слова 1–2 s); бенч на устройстве: int8 ~2.1× fp32 (см.
docs/EXPERIMENTS-STT-LATENCY.md,docs/stt-bench-2026-09-24.csv). Осталось: наполнить корпус эталонами (см. WER ниже). -
WER: счётка готова и покрыта юнит-тестами (
stt/Wer.kt), но эталонных транскриптов в репозитории нет, поэтому реальных цифр WER пока не существует. Чтобы получить их:- Положить wav в
app/src/androidTest/assets/bench/. - Положить вручную написанную расшифровку этого wav в
app/src/androidTest/assets/bench/reference/<имя wav без .txt>.txt. - Запустить
BenchSttTest.benchInt8AndFp32на устройстве; в CSV появится колонкаwer.
Тест требует обе модели:
ncnn-turbo(int8) иncnn-bench-fp32. Отсутствие fp32 раньше логалось warning'ом и тест рапортовался зелёным при двух фактических конфигах («3/3 passed») — теперь это падение. Дополнительно проверяется, что в каталоге fp32 нет int8-файлов: иначе ncnn загрузил бы int8, и строкаfp32в CSV была бы ложью. Вариант энкодера доступен какNcnnModelValidator.EncoderVariant.Эталон должен быть написан до прогона, независимо от вывода модели. Если составить его по выводу модели, WER получится около нуля и будет выглядеть как отличный результат, не измеряя ничего. Пустые файлы и эталоны без парного wav отбрасываются с warning. Сравнение word-level, текст нормализуется (регистр,
ё→е, пунктуация); слова-паразиты (ну,вот,это) сохраняются, иначе метрика подгоняется под удобный результат. - Положить wav в
-
Vulkan (GPU): экспериментально (int8-Vulkan 6.45 s, стабилен), но в проде выключен — включение =
encoder.opt.use_vulkan_compute=trueвncnn_jni.cpp.
Экспериментальный личный проект. API и внутренности могут меняться. Не является официальным продуктом opencode.
