Files
elmer/doc/opus-review-android.md
T

281 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Ответ Opus 4.8 — ревью elmer-android
> v0.35.0-dev, 31 мая 2026
> Проверены файлы: ElmProtocol.kt, ObdDecoder.kt, ScriptRunnerService.kt, ScriptEngine.kt,
> ServerClient.kt, SessionDb.kt, MainActivity.kt, UploadProgress.kt, AndroidManifest.xml
---
## Вопрос 1. Стейт-машина ElmProtocol.kt
### 1.1 `startsWith("ERROR") && !startsWith("DATA ERROR")`
🟡 **Потенциальная проблема.** `handle()` работает с уже распарсенными строками-ответами, а не с PID-именами, поэтому коллизии с «ERROR_xxx» в данных нет — декодирование имён происходит позже в `ObdDecoder`. НО: реальные ELM-ошибки не всегда начинаются с `ERROR`. Например `?` (неизвестная команда), `UNABLE TO CONNECT` (ловится в `isBusError`), `<RX ERROR` (с префиксом `<`). Строка `<DATA ERROR` из-за лидирующего `<` **не** сматчится `startsWith("DATA ERROR")`. ELM327 при ошибке кадра иногда шлёт `<` перед сообщением.
Рекомендация — нормализовать перед классификацией:
```kotlin
val u = raw.uppercase().trim().trimStart('<', '>').trim()
```
### 1.2 `sendCommand()` безусловно ставит READY после exec()
🔴 **Критично — маскирование ошибки.**
```kotlin
fun sendCommand(cmd: String): String {
if (state == State.ERROR) recover()
state = State.BUSY
val result = exec(cmd, timeoutMs) // exec может выставить State.ERROR/DISCONNECTED
state = State.READY // ← затирает ошибку
return result
}
```
`exec()` при исчерпании ретраев ставит `state = State.ERROR`, а `handle()``ERROR`/`DISCONNECTED`. Следующая строка безусловно перетирает это на `READY`. Ошибка «теряется» до следующего вызова. В AndrOBD состояние не сбрасывается слепо.
Рекомендация:
```kotlin
val result = exec(cmd, timeoutMs)
if (state == State.BUSY) state = State.READY // только если не было ошибки
return result
```
### 1.3 `init()` не проверяет результат AT-команд
🟡 **Поведение AndrOBD, но рискованное.** AndrOBD действительно прогоняет init-цепочку «оптимистично», полагаясь на то, что первые реальные OBD-команды отловят BUS ERROR. Для MVP допустимо, но `ATSP0` (выбор протокола) стоит проверять — если адаптер вернул `?`, дальнейшие команды бессмысленны. Минимум — логировать ответ и считать в `errorCount`.
### 1.4 Нет сброса input-буфера перед write()
🟡 **Риск десинхронизации есть.** В `read()` чтение идёт до `>` (prompt), но если предыдущая команда оставила хвост в буфере (например после таймаута пришёл запоздалый ответ), он прилипнет к следующему чтению. Рекомендация — дренировать буфер перед записью:
```kotlin
private fun write(cmd: String) {
while (input.available() > 0) input.read() // drain stale bytes
output.write((cmd + "\r").toByteArray())
output.flush()
}
```
### 1.5 `BUFFER FULL` → warm start
🟡 **Спорно.** В AndrOBD `BUFFER FULL` — это переполнение буфера ELM при большом ответе, лечится **повторным запросом**, а не полным `ATWS` (warm start сбрасывает протокол и теряет адаптацию таймингов). Здесь `BUFFER FULL` попадает в `isDataError``ATWS`, что излишне тяжело. Лучше выделить:
```kotlin
u.startsWith("BUFFER FULL") -> { increaseTimeout() /* retry */ }
```
---
## Вопрос 2. ScriptRunnerService — жизненный цикл
### 2.1 `START_STICKY` + null intent
🔴 **Падение при пересоздании.** При рестарте системой `onStartCommand` получает `intent == null`. Сейчас `when (intent?.action)` отрабатывает в `else`-ветку (ничего не делает) и возвращает `START_STICKY` — краша нет, но сервис висит в foreground без работы и без уведомления о реальной задаче. Лучше:
```kotlin
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent == null) { stopSelf(); return START_NOT_STICKY }
...
}
```
Для разовой диагностики вообще логичнее `START_NOT_STICKY` — нет смысла воскрешать прерванную сессию.
### 2.2 Демон-поток `ScriptRunner`
🟡 Демон-поток живёт пока жив процесс. Если Activity убита, а сервис foreground — процесс жив, поток работает. Но при нехватке памяти система может убить весь процесс (вместе с потоком) несмотря на foreground. Это нормально для разовой задачи. Замечание: исключения внутри потока никуда не пробрасываются — добавьте `try/catch` обёртку с `errorDone()`.
### 2.3 / 2.4 `btSocket` и `onDestroy()`
🟢 **Уже закрывается.** `onDestroy()` вызывает `disconnect()`, который закрывает `btSocket` и снимает foreground. Утечки сокета нет. ✅
### 2.5 Флаг `paused`
🔴 **Мёртвый код / недоделанная фича.** `paused` выставляется в `false` по `ACTION_RESUME`, но **нигде не проверяется** — ни в `ScriptEngine.run()`, ни в `executeScript()`. Механизм паузы «водитель ответил» (broadcast `BROADCAST_PROMPT`, `scriptPromptReceiver` в UI) фактически не реализован на стороне движка. Либо удалить флаг и UI-приёмник промптов, либо доделать: `ScriptEngine` должен уметь блокироваться на шаге до сброса `paused`.
---
## Вопрос 3. ServerClient — ретраи и идемпотентность
### 3.1 Повторное использование тела запроса на ретраях
🟢 **Работает корректно.** Тело создано через `String.toRequestBody(...)` — это `RequestBody` поверх неизменяемой строки. В OkHttp 4.x такой `RequestBody` **stateless**: `writeTo()` вызывается заново на каждой попытке и пишет ту же строку. Пустого тела на 2-3 ретрае **не будет**. (Проблема была бы только с одноразовым стримом, например `InputStream.source()`.)
### 3.2 Exponential backoff
🟡 Для мобильной сети фиксированные 2с приемлемы, но джиттер + рост лучше против «retry storm»:
```kotlin
if (attempt < 3) Thread.sleep(1000L * (1 shl (attempt - 1)) + Random.nextLong(0, 500))
```
### 3.3 `downloadScript()` без ретраев
🟢 Это сознательный и правильный выбор: есть качественный `DEFAULT_SCRIPT` fallback, поэтому мгновенный переход к нему при оффлайне — корректное поведение. 1 ретрай можно добавить, но не критично.
### 3.4 gzip на upload
🟡 При `count * 200` байт типичный батч < 5 KB — выигрыш от gzip минимален, а overhead на сжатие/совместимость с nginx добавляет риск. Не нужно для MVP.
### 3.5 Порядок `.string()` / `.close()`
🟢 **Корректно.** `val body = resp.body?.string()` сначала читает (и закрывает поток тела), затем `resp.close()`. Порядок верный, двойного закрытия нет. ✅
### 3.6 Идемпотентность / `request_id`
🔴 **Критично (подтверждаю отчёт Q2).** При 499/таймауте и ретрае сервер создаёт дубликат сессии и повторно тратит LLM-токен. Клиент должен генерировать UUID **один раз до цикла ретраев** и слать его в теле:
```kotlin
fun uploadSession(...): JSONObject? {
val requestId = java.util.UUID.randomUUID().toString() // один на все 3 попытки
val json = JSONObject().apply {
put("request_id", requestId)
put("session_id", sessionId)
...
}
val req = Request.Builder()
.url("$serverUrl/api/v1/session/upload")
.header("Idempotency-Key", requestId)
.post(json.toString().toRequestBody("application/json".toMediaType()))
.build()
...
}
```
На сервере (`save_session()`): UNIQUE-индекс по `request_id`, при повторе — вернуть **сохранённый** результат (включая готовый диагноз), не вызывая LLM повторно:
```python
existing = db.execute("SELECT diagnosis FROM sessions WHERE request_id=?", [rid]).fetchone()
if existing:
return jsonify(diagnosis=existing["diagnosis"], llm_success=True, cached=True)
```
---
## Вопрос 4. ObdDecoder — корректность декодирования
### 4.1 VIN с пробелами
🟢 **Корректно.** `replace(" ", "")` снимает пробелы до проверки `"490201" in clean`, плюс убраны `:` (ISO-TP индикаторы кадров `0:`, `1:`...). Работает и для multi-frame. ✅
### 4.2 `decodeDtc()` начинает с `i = 2`
🟡 **Не всегда верно.** `hex = clean.substring(2)` снимает байт режима (`43`), затем `i = 2` снимает **байт count** (число DTC). Это корректно для классического формата `43 NN <dtc>...`. Но:
- Multi-frame CAN ISO-TP: ответ может содержать байты длины PCI (`007`, `10 0E`...), которые здесь **не вычищены** (убраны только пробелы и `:`). Тогда `i=2` указывает не на тот байт.
- Некоторые адаптеры на mode 03 не шлют байт count вовсе.
Для надёжности стоит парсить DTC по парам байт от конца режима и отбрасывать `0000`, что код уже делает (фильтр `P0000`). Главный риск — невычищенные PCI-заголовки multi-frame. Для коротких ответов (1-2 DTC, single frame) работает.
### 4.3 PID `0100` (4 байта supported)
🟡 `decodePid()` читает только `b0, b1`. PID `00/20/40...` (битовые маски supported PIDs, 4 байта) не входят в `pidValue()` → вернётся `"PID 00: raw"`. Поскольку скрипт их не запрашивает — не баг сейчас, но при расширении скрипта декодер их не покажет.
### 4.4 Только 10 PID
🟢 **Ок для MVP.** Скрипт `DEFAULT_SCRIPT` запрашивает ровно эти PID. Для неподдерживаемых — `"PID $pid: raw"`, сырьё всё равно уходит на сервер и в LLM. Расширять по мере надобности.
### 4.5 STFT/LTFT формула
🟢 Формула `(A - 128) * 100 / 128` верна по SAE J1979. Для PID 06/07 это однобайтовые значения (банк 1), `b1` игнорируется правильно. ✅ (Замечание: PID 06/07 — это банк 1 short/long; банки 2 — это 08/09, в скрипте их нет.)
---
## Вопрос 5. SessionDb — схема и доступ
### 5.1 `onUpgrade()` DROP TABLE
🔴 **Потеря данных при апдейте.** Любое повышение `DB_VERSION` сотрёт всю историю пользователя. Для продакшена недопустимо. Минимальная безопасная миграция:
```kotlin
override fun onUpgrade(db: SQLiteDatabase, oldV: Int, newV: Int) {
if (oldV < 2) db.execSQL("ALTER TABLE sessions ADD COLUMN server_url TEXT")
// будущие версии — ALTER, не DROP
}
```
### 5.2 Без явного закрытия соединений
🟢 **Ок.** `SQLiteOpenHelper` кэширует одно соединение на хелпер; курсоры закрываются (`cursor.close()`). Не закрывать сам `db` — правильно. ⚠️ Замечание: `SessionDb` создаётся и в сервисе, и в `MainActivity.showHistory()` — два хелпера на одну БД. Лучше один экземпляр (синглтон), иначе при одновременном write возможен `SQLiteDatabaseLockedException`.
### 5.3 `getPendingSessions()` — мёртвый код
🟡 Метод нигде не вызывается. Это задел под «дослать неотправленные сессии при следующем запуске», но фича не реализована. Либо удалить, либо доделать ретрай-аплоад оффлайн-сессий в `onCreate` сервиса.
### 5.4 `created_at` INTEGER vs сервер TEXT
🟡 Несогласованность форматов. На клиенте unix-секунды, на сервере ISO 8601. При синхронизации сервер должен конвертировать. Лучше слать с клиента ISO-8601 (или явно `unix_ts` с понятным именем) в `client_info`/`responses`, чтобы не было путаницы с часовыми поясами. Сейчас `timestamp` ответов уходит как строка unix-секунд — сервер должен это знать.
### 5.5 Индексы
🟡 `WHERE session_id = ?` в `getResponses()` без индекса — full scan. При сотнях ответов на сессию заметно. Добавьте:
```kotlin
db.execSQL("CREATE INDEX idx_resp_session ON responses(session_id)")
```
---
## Вопрос 6. MainActivity — чат и UI
### 6.1 `chatHistory` теряется при повороте
🔴 `onSaveInstanceState` сохраняет только `status_text`, `chatHistory` живёт в поле Activity → при повороте/пересоздании теряется, и LLM теряет контекст диалога. Варианты: сохранить в Bundle (сериализовать в JSON), либо вынести в `ViewModel` (`SavedStateHandle`). Минимум:
```kotlin
outState.putString("chat", JSONArray(chatHistory.map { ... }).toString())
```
### 6.2 Чат на `HttpURLConnection` вместо OkHttp
🟡 Дублирование HTTP-логики и таймаутов. `ServerClient` уже инкапсулирует OkHttp — `sendToLlm()` и `startTest()` стоит перевести на него (общие таймауты, ретраи, будущий `X-Api-Key`). Сейчас три места шлют HTTP по-разному.
### 6.3 `startTest()` дёргает `/ping-llm` (платный токен)
🔴 **Расход денег на каждом «Тест».** `/ping-llm` делает реальный LLM-запрос. Кнопку «Тест» пользователь может жать многократно. Варианты: на сервере сделать `/ping-llm` дешёвой проверкой доступности (HEAD к API провайдера / кэш на 60с), либо на клиенте троттлить (не чаще раза в N минут) и предупреждать.
### 6.4 Двойная регистрация receiver
🟡 `scriptStatusReceiver`/`scriptStageReceiver` защищены флагом `scriptRegistered` — двойной регистрации этих двух нет. НО: `statusReceiver` (отдельный, для `BROADCAST_STATUS`) регистрируется в `onCreate` **и** `scriptStatusReceiver` тоже слушает `BROADCAST_STATUS` — два приёмника на один экшен → **каждое сообщение `log()` обработается дважды** (дублирование строк в UI). Также `scriptPromptReceiver` регистрируется... — на самом деле **нигде не регистрируется**, только разрегистрируется в `onDestroy`. Промпты не приходят (связано с мёртвым `paused`, Q2.5).
Рекомендация: оставить один приёмник на `BROADCAST_STATUS`.
### 6.5 `btnClose` не чистит `chatHistory` и не стопит сервис
🟡 Кнопка ✕ только прячет UI и пишет «Готов». Если сервис ещё работает — он продолжит и пришлёт новые статусы поверх. Для «закрыть» логично слать `ACTION_STOP` в сервис. `chatHistory` чистить не обязательно (диалог отдельный от диагностики), но сервис стоит остановить.
---
## Вопрос 7. UploadProgress — таймер и батарея
### 7.1 Broadcast каждую секунду до 180с
🟡 Незначительно для батареи (≤180 broadcast на сессию), но это локальный `sendBroadcast` с `setPackage` — дёшево. Не проблема.
### 7.2 Поток висит при исключении
🟡 **Реальный риск.** В `executeScript()` `progress.start()``uploadSession()``progress.stop()`. Если `uploadSession()` бросит непойманное исключение, `stop()` не вызовется и `UploadTimer` останется крутиться (демон, до смерти процесса). Оберните в `try/finally`:
```kotlin
val progress = UploadProgress(...); progress.start()
val resp = try { client.uploadSession(...) } finally { progress.stop() }
```
### 7.3 `Handler.postDelayed` вместо потока
🟢 Можно, но текущий вариант с `AtomicBoolean` + демон-поток корректен и проще. Не критично. Главное — гарантировать `stop()` (см. 7.2).
---
## Вопрос 8. Общая архитектура
### 8.1 MainActivity знает про Service и SessionDb
🟡 Нарушение SRP есть, но для MVP с одним экраном терпимо. При росте — вынести историю в `Repository`, а UI-логику в `ViewModel`.
### 8.2 ElmProtocol замокать для тестов
🟡 `ScriptEngine` отлично тестируется (lambdas) — это сильная сторона. `ElmProtocol` жёстко завязан на `InputStream/OutputStream`, но это **тестируемо**: подайте `ByteArrayInputStream`/`ByteArrayOutputStream` с заскриптованными ответами ELM. Интерфейс выделять не нужно, потоки — уже абстракция. Рекомендую написать unit-тест на `handle()`-классификацию и таймаут-адаптацию.
### 8.3 Нет ViewModel/DI/Navigation
🟢 Для MVP с одной кнопкой — ок. ViewModel стоит ввести первым (решает 6.1, 6.4). DI/Navigation — преждевременно.
### 8.4 minSdk 24 + BluetoothAdapter.getDefaultAdapter
🟡 `getDefaultAdapter()` deprecated с API 31, но работает на 24+. `createRfcommSocketToServiceRecord` + reflection-fallback `createRfcommSocket(1)` — стандартный надёжный приём для китайских ELM327, покрывает большинство устройств. Замечание: на Android 12+ (API 31) для `connect()` нужен рантайм-`BLUETOOTH_CONNECT` — в манифесте он есть, проверьте что он реально запрашивается в рантайме (в показанном коде `MainActivity` запрос пермишенов есть в константах, но самого `requestPermissions` в прочитанном фрагменте не видно — убедитесь, что вызывается).
### 8.5 `usesCleartextTraffic` не объявлен
🟢 По умолчанию `false` на API 28+, все запросы на `https://obdai.ru` — ок. ✅ Замечание: жёстко зашитый хост `obdai.ru` в нескольких местах (Service, MainActivity) — вынесите в `BuildConfig`/константу.
### 8.6 Эндпоинты без аутентификации (X-Api-Key)
🔴 **Критично (подтверждаю отчёт Q6).** `/chat`, `/upload`, `/ping-llm` открыты → любой может тратить ваши LLM-токены.
Статический ключ в APK **извлекаем** (reverse engineering), поэтому он защищает только от случайных/ленивых злоупотреблений, не от целевой атаки. Для MVP это разумный первый рубеж:
```kotlin
// BuildConfig.API_KEY из gradle (не в git, через local.properties / CI secret)
val req = Request.Builder()
.url(...)
.header("X-Api-Key", BuildConfig.API_KEY)
.post(...)
.build()
```
build.gradle.kts:
```kotlin
buildConfigField("String", "API_KEY", "\"${project.findProperty("ELMER_API_KEY") ?: ""}\"")
```
Сервер — отклонять без верного `X-Api-Key` (401) + **rate-limit по IP/ключу** + квота на LLM. Для серьёзной защиты позже: подпись запроса (HMAC от тела + nonce + timestamp), либо Play Integrity API / device attestation. Но для MVP: `X-Api-Key` + rate-limit + серверная квота на LLM — достаточный минимум, при этом главную защиту денег даёт именно **серверный лимит**, а не ключ.
---
## Сводка приоритетов
🔴 **Чинить сейчас:**
1. `request_id`/идемпотентность upload (Q3.6) — дубли сессий и двойной расход LLM.
2. `X-Api-Key` + серверный rate-limit/квота (Q8.6) — открытые платные эндпоинты.
3. `sendCommand()` маскирует ERROR-состояние (Q1.2).
4. `onUpgrade()` DROP TABLE — потеря истории (Q5.1).
5. `/ping-llm` тратит токен на каждом «Тест» (Q6.3).
6. Двойной приёмник `BROADCAST_STATUS` → дублирование строк (Q6.4).
🟡 **Желательно:**
- `paused` — мёртвый код / недоделанная пауза (Q2.5, Q6.4-prompt).
- `try/finally` вокруг `UploadProgress` (Q7.2).
- Дренаж BT-буфера перед write (Q1.4).
- `chatHistory` в onSaveInstanceState/ViewModel (Q6.1).
- Индекс `responses(session_id)` (Q5.5).
- Единый HTTP-клиент (OkHttp) для чата/теста (Q6.2).
- null-intent guard в onStartCommand (Q2.1).
🟢 **Хорошо как есть:** закрытие сокета (2.3), повторное тело OkHttp (3.1), порядок string/close (3.5), VIN-декод (4.1), STFT/LTFT (4.5), cleartext off (8.5), fallback-скрипт без ретраев (3.3).