diff --git a/docs/AUDIT-2026-07-07.md b/docs/AUDIT-2026-07-07.md new file mode 100644 index 0000000..e8bbbcb --- /dev/null +++ b/docs/AUDIT-2026-07-07.md @@ -0,0 +1,252 @@ +# Полный аудит f7cloud_mobile — 2026-07-07 + +> Автор: `b-dev-mobile` (ячейка B). Метод: 6 параллельных агентов-исследователей по +> направлениям (архитектура, безопасность, сеть, производительность/UX, качество кода, +> сборка+vendored-форк). Все находки — с `file:line`. +> +> **Объём:** ~38 500 строк собственного Kotlin в 178 файлах (app/core/feature) + +> ~92 600 строк vendored-форка talk-android (~704 файла). Тестов — **0**. Статанализа — нет. CI — нет. + +## Общий вердикт + +Приложение функционально зрелое (8 работающих модулей, дисциплинированные ViewModel+StateFlow, +чистый `.gitignore` без секретов, ноль TODO-долга), но построено как «долгий прототип»: релизной +сборки физически не существует, безопасность на уровне dev-стенда, инфраструктура качества +отсутствует, в календарной логике — баги порчи данных. + +--- + +## 🔴 Критическое (до любой раздачи пользователям) + +### C1. Release-сборки не существует +- Нигде нет `signingConfig`/`storeFile` (grep по всем `*.gradle` пуст) → `assembleRelease` даёт + неподписанный APK (`INSTALL_PARSE_FAILED_NO_CERTIFICATES`). +- `app/build.gradle:28-33` — `minifyEnabled true` + `shrinkResources true`, но + `app/proguard-rules.pro` — **1 строка (только комментарий)**; `vendor/.../proguard-rules.pro` — + всё закомментировано; у `:vendor:talk-app` нет `consumerProguardFiles`; R8 full mode включён + по умолчанию (AGP 8). Точки отказа при R8: LoganSquare `$$JsonObjectMapper` (vendor build.gradle:265), + WebRTC JNI `org.webrtc.**` (:333), Retrofit/gson-модели, EventBus `@Subscribe`, Parceler, + kotlinx.serialization. Вероятен отказ ещё на этапе сборки (missing classes: conscrypt, RxJava `sun.misc`). +- Проект живёт на debug-APK 130 МБ. + +### C2. TLS выключается галочкой навсегда (MITM) +- `core/network/.../UnsafeSsl.kt:12-23` — trust-all `X509TrustManager` (пустые checkServerTrusted/ + checkClientTrusted) + `hostnameVerifier { _, _ -> true }`. +- Применяется ко всем клиентам: `NetworkFactory.kt:18`, `LoginFlowClient.kt:82,168,216` (передача + логина/пароля при QR-логине). В WebView продублировано: `OfficeWebViewClient.kt:91-92` + (`onReceivedSslError → proceed()`). +- Включается чекбоксом «Доверять сертификату (dev)» `AppScaffold.kt:880-888`, сохраняется в prefs + (`AuthStore.kt:13`) навсегда, молча применяется ко всем последующим сессиям, без предупреждения. +- Fix: убрать из release (`BuildConfig.DEBUG`), заменить на certificate pinning, диалог-предупреждение. + +### C3. Пароль аккаунта открытым текстом + бэкапится +- `core/auth/.../AuthStore.kt:6,12` — `app_password` в обычном SharedPreferences (`f7_auth`, + MODE_PRIVATE). EncryptedSharedPreferences/MasterKey/Keystore в проекте нет вообще (grep пуст). +- `app/src/main/AndroidManifest.xml:25` — `allowBackup="true"` без `dataExtractionRules` → + `f7_auth` (пароль) и `f7_app_lock` (pin_hash) уходят в облачный/adb-бэкап. +- Пароль передаётся через Intent extras: `MailScreen.kt:128-131` → MailComposeActivity (утечка в recents/dumpsys). +- Logout (`AppScaffold.kt:175-180`) — только локальная очистка; серверная ревокация + `DELETE /ocs/v2.php/core/apppassword` НЕ вызывается — токен живёт на сервере. + +### C4. ANR-риск на входящем звонке +- `feature/talk-native/.../TalkVendorUserSync.kt:23` — `runBlocking(Dispatchers.IO)` с сетью/БД. +- Вызывается из `F7MobileApp.onCreate` (`F7MobileApp.kt:41` — блокирует холодный старт) и из + `TalkNativeCallLauncher.launchRoomCall` → `MainActivity.onCreate:105,120` (клик «принять звонок» на main). +- `F7MobileApp : F7cloudTalkApplication` (F7MobileApp.kt:27) — `super.onCreate()` инициализирует + весь vendor-стек (Dagger, Room+SQLCipher, WorkManager) на холодном старте. + +### C5. Тихая порча календарных данных (ICS) +- **Два расходящихся парсера дат**: `CalendarIcs.parseIcsInstant` (`CalendarIcs.kt:176-193`) парсит + `...T100000Z` как ЛОКАЛЬНОЕ время; `CalDavClient.parseIcsInstant` (`CalDavClient.kt:584-602`) — как + UTC. `TZID` игнорируют оба (regex `CalendarIcs.kt:47` отрезает параметры). → события со сдвигом зоны, + по-разному в календаре и задачах. +- **ATTENDEE-парсер сломан**: `CalendarIcs.parseLines` (`:195-206`) кладёт `ATTENDEE;CN=...;PARTSTAT=...` + под один ключ `"ATTENDEE"` с отрезанными параметрами, а `parseAttendeeLine` (`:208-216`) ищет + `CN=`/`PARTSTAT=` в ключе, где их уже нет → участники схлопываются в одного, статусы дефолтные. + При редактировании события это PUT'ится на сервер (порча данных). +- **unescape неверного порядка**: `CalendarIcs.kt:268-269` — `\n` заменяется до `\\`. +- `CalDavClient.parseVTodo` (`:925-951`) работает без line-unfolding (длинная свёрнутая SUMMARY обрезается). +- vCard: паттерн `N[^:]*:` (`CardDavClient.kt:32-41`) заматчит и `NOTE:`/`NICKNAME:`. + +### C6. GPL-3.0 нарушается уже сейчас +- У корневого репо **нет LICENSE/COPYING** (в корне только CHANGELOG/INTEGRATION/NOTICE/PROJECT-STATUS/ + README). Vendored GPL-код линкуется в один APK → всё приложение — производная работа под GPL-3.0-or-later. +- Копирайты Nextcloud **заменены** на «F7cloud contributors»: `vendor/.../AndroidManifest.xml:4` + (`SPDX-FileCopyrightText: 2017-2024 F7cloud...`). GPLv3 §4 требует сохранять оригинальные уведомления. +- Раздача даже debug-APK — уже distribution; архив исходников к сборкам не публикуется. +- Проприетарный Firebase слинкован в GPL-APK (`vendor build.gradle:337`, `core/push`). У ячейки есть + свой `f7push` — основной flavor можно сделать без FCM. + +--- + +## 🟠 Архитектура + +- **God-composable**: `app/.../ui/AppScaffold.kt:112-671` — 920 строк, ~35 `mutableStateOf`/34 + `rememberSaveable` в одной функции: роутер табов + логин (`:675-760`) + push-роутинг (`:358-383`) + + FCM-регистрация (`:299-309`) + permissions + QR. Рекомпозиция корня на любое изменение. +- Связь app→feature — самодельные «nonce-каунтеры»: `filesUploadRequest++`, `mailPushRequest++` + (`AppScaffold.kt:159-168`) — теряют события при гонках. Лучше SharedFlow. +- Слой пробивается в UI: `AppSheets.kt:109-150` (NotificationsSheet дёргает repo из composable), + `MailScreen.kt:83-89` (composable сам собирает OkHttpClient). +- **DI нет** (Hilt/Koin отсутствуют, Dagger только в vendor): ~25 `object`-синглтонов с сетью + (`CalDavClient.kt:80` — object на 1050 строк), inline-фабрики VM в composable (`MailScreen.kt:75-81`). +- Утечка контекста: `app/.../OfficeWebViewPool.kt:12-40` держит WebView с Activity-контекстом до 15 мин. +- **navigation-compose:2.8.9 подключён, но НЕ используется** (нет NavHost): навигация — ручной + `when(activeTab)` (`AppScaffold.kt:535-624`) + boolean-флаги + свой BackHandler (`:428-447`). + Deep links снаружи не работают (нет intent-filter со схемой; `f7cloud://talk` в `MainActivity.kt:79` + недостижим из браузера). `SavedStateHandle` — 0 вхождений: process death теряет внутриэкранное состояние. +- **Мёртвый код**: `feature/widgets` (538 строк, 4 файла) НЕ в `settings.gradle` — не компилируется; + `core/data`, `core/ui` — пустые заготовки в `settings.gradle:23,28`. +- Топ god-файлов: `MailComponents.kt` 1898, `TalkComponents.kt` 1591, `FilesComponents.kt` 1457, + `CalendarComponents.kt` 1362, `TasksComponents.kt` 1066, `CalDavClient.kt` 1050, `MailViewModel.kt` 1012, + `MailRepository.kt` 991, `AppScaffold.kt` 920. + +## 🟠 Сетевой слой + +- **Новый `OkHttpClient` на каждый запрос**: `NetworkFactory.kt:14-20` — фабрика строит новый + `.build()`; вызывается в начале каждого метода репозитория (`MailRepository.kt:635-641`, + `TalkRepository.kt:331-332`, `FilesRepository.kt:20,62,79...`, ещё 31 call-site). Нулевое + переиспользование соединений (TLS-handshake на каждый запрос), утечка ExecutorService. + `SupportRepository.kt:340` — вообще без callTimeout. +- HTTP-кэша (`.cache()`) нет; ETag/If-Modified-Since не используется (только If-Match при записи CalDAV). +- **Обработка 401 скопирована ~60 раз** (`if (code == 401) throw UnauthorizedException()` в каждом + методе) вместо interceptor'а. OCS-конверт распарсен независимо 4 раза (`TalkRepository.getOcsResponse:367`, + `WidgetsRepository.getOcsData:124`, `NotificationsRepository:32`, `AppMenuRepository:20`) при живом + `OcsJson.kt`. Заголовок `OCS-APIRequest` вручную в 37 местах. DAV-хелперы (`localTag/readText/toDavPath/ + resolveHref`) скопированы трижды (`DavClient.kt:242`, `CalDavClient.kt:867/1024`, `CardDavClient.kt:288`). +- **Парсинг 100% ручной** org.json (`optString` — 206 вызовов, молчаливые дефолты) + ICS/vCard регэксами. +- `F7PushRegistrar.kt:53-77` — сырой `HttpURLConnection`, дублирует Basic-auth и **игнорирует + trustAllCerts** (push сломан на self-signed). +- **Инкрементальной синхронизации DAV нет**: ни sync-token/sync-collection, ни CTag. Контакты качают + **все vCard с base64-фото** и потом `.take(500)` (`CardDavClient.kt:142-157`). Календарь делает + `listCalendars` PROPFIND перед каждой операцией (`CalendarRepository.kt:188-204`); задачи — + `listTaskLists` на каждый toggle (`TasksRepository.kt:138-145`). +- `WidgetsRepository.kt:65` — `withTimeoutOrNull(2000)` поверх блокирующего `execute()` (отмена не + прерывает сокет). Talk long-poll (`TalkRepository.kt:26-41`) — readTimeout == серверному таймауту (гонка). +- Main-safety на соглашении: репозитории `suspend`, но без `withContext(Dispatchers.IO)` внутри + (`MailRepository`, `TalkRepository.kt:58`); `TasksRepository`/`CalendarRepository`/`AppMenuRepository` — + вообще не suspend. Спасает только дисциплина вызывающих (`viewModelScope.launch(Dispatchers.IO)`). + +## 🟠 Производительность и UX + +- **APK ~130 МБ**: `settings.gradle:35-36` включает весь vendor как библиотеку (`feature/talk-native` + `api project(':vendor:talk-app')`). WebRTC `android-talk-webrtc:132.6834.0` (vendor build.gradle:333) — + `libjingle_peerconnection_so.so` на **4 ABI** (~30-45 МБ × ABI ≈ 60-70 МБ); sqlcipher (:281), + conscrypt (:238) — тоже × 4 ABI; `emoji2-bundled` (~10 МБ); 48 локалей vendor. Ни `splits`, ни + `abiFilters`, ни `bundle{}`, ни `resConfigs` нигде нет. + → `abiFilters 'arm64-v8a','armeabi-v7a'` + `resConfigs 'ru','en'` + рабочий R8 = 130 → **30-45 МБ**. +- Compose: ключи чата Talk включают `$index` (`TalkScreen.kt:597-604`) — новое сообщение пересоздаёт + весь список; `formatEnvelopeDate` (`MailComponents.kt:644,1480-1507`) создаёт 2×Calendar + + SimpleDateFormat на каждую строку/рекомпозицию; `F7RelativeTime.kt:22` — `DateTimeFormatter.ofPattern` + на каждый вызов. `derivedStateOf` — 3 шт. на проект. +- Иконки UI грузятся **с сервера как SVG по сети** (`FilesComponents.kt:265,310,325,356`, + `MailComponents.kt:650` — звезда в каждой строке письма, ~15+ мест) → офлайн списки лысеют. + Coil diskCache = 2% cacheDir (`F7MobileApp.kt:79-94`), `crossfade(false)`. +- Офлайн-кэш: только контакты (Room, с TTL 10 мин — образец), файлы (огрызок: имя+флаг для корня, + `FilesRepository.kt:40-53`), почта (свой JSON-файловый кэш `MailCacheRepository.kt` — третий + самописный механизм). Календарь/задачи/Deck/поддержка/виджеты офлайн — пустой экран. +- `runBlocking` на старте (см. C4). SplashScreen API не используется (белый экран на Android 8-11). +- **Тёмной темы нет**: `F7Theme.kt:29-36` всегда `F7LightScheme`, нет `isSystemInDarkTheme`/`values-night`; + + 77 инлайновых `Color(0xFF...)` мимо `F7Colors` (`FilesComponents.kt:305`, `SupportComponents.kt:64-66`...). +- **Локализация невозможна**: `stringResource` — 6 раз, в `strings.xml` 15 строк; 73+ русских строк + захардкожены в `Text("...")`. +- Pull-to-refresh только в Файлах (`FilesScreen.kt:219`). Ошибки местами Toast без retry + (`MailScreen.kt:348,405`). Skeleton/shimmer нет (51 CircularProgressIndicator). + `contentDescription=null` в 66 местах (в т.ч. кликабельные иконки). WindowSizeClass/sw600dp — нет. + +## 🟠 Качество кода и процессы + +- **0 тестов** (нет `src/test`/`src/androidTest` ни в одном модуле). Мешают: 40+ `object` с логикой, + репозитории без интерфейсов, `LocalDate.now()`/`ZoneId.systemDefault()` внутри логики + (`TasksGrouping.kt:23`, `F7RelativeTime.kt:15`, `CalendarIcs.kt:121,184`) — Clock не инжектируется. + Готовые кандидаты на чистый JUnit: `CalendarIcs`, `TasksGrouping`, `LoginFlowClient`. +- Обработка ошибок: 223 `runCatching` (~65 глотают через getOrNull/getOrDefault), ~121 `error()` как + flow-control (нетипизированный IllegalStateException — «нет сети» и «битый JSON» неразличимы), + молчаливые catch (`CardDavClient.kt:437`, `DavClient.kt:237,252`, `F7PushRegistrar.kt:87-89` — пустое + тело). Логирования почти нет (35 Log.*/println на проект). +- `!!` — 70 шт. (UI-гонки: `MailSettingsScreens.kt:557-558` `draft!!`, `TalkComponents.kt:876`). + QR-логин ломается на пароле с `&` (`LoginFlowClient.kt:196+`, лимит `size > 3`). Курсор пагинации + почты обрезает Long→Int (`MailRepository.kt:54-62`). `loadThread` → `getOrDefault(emptyList())` + (сеть-ошибка = «переписки нет», `MailRepository.kt:88-93`). +- Дубли: относительное время — 4 реализации (`F7RelativeTime.kt`, `TalkFormatting.kt:94`, + `MailComponents.kt:1480`, `SupportComponents.kt:68`); открытие файла с тостом дословно скопировано + (`SupportFileIO.kt:56-66` == `LocalFileOpener.kt:14-25`); `serverUrl.trimEnd('/')` — 40+ мест. +- Магия: 77 `Color(0x...)` вне designsystem; ~1087 `.dp` без spacing-токенов; 52 захардкоженных + `ocs/v2.php|remote.php` пути (нет `ApiPaths`); таймауты литералами (`F7PushRegistrar.kt:54-55` + `15000`, `TalkAttachmentUploader.kt:49-51` 5/5/6 мин). +- **lint/detekt/ktlint не настроены** (нет `lint{}`, baseline, `.editorconfig`). +- **Version catalog отсутствует** (`gradle/libs.versions.toml` нет): coroutines 1.8.1/1.9.0/1.10.1/1.10.2, + Coil 2.6.0/2.7.0 (при Coil 3.x актуальном), два Compose BOM (свой 2025.02 vs vendor 2026.01), + Firebase тремя декларациями (bom 33.7.0 / messaging 25.0.1 / 24.0.1), zxing 3.3.0 (2017 г.), + guava целиком (`app/build.gradle:101`). `org.json:json:20240303` дублирует платформенный (бесполезен). +- `gradle.properties`: `parallel=false` (тормоз 20 модулей), `enableJetifier=true` (legacy), + `nonTransitiveRClass=false`; тестовые `NC_TEST_SERVER_*` — мусор от vendor. +- Git: `.gitignore` полный (build/, local.properties, *.apk, keystores, google-services.json закрыты — + секретов в репо НЕТ). LFS не используется; `design/` — 1.66 МБ (28 PNG); vendor закоммичен целиком + (1484 файла, pack 6.95 МБ) без своей git-истории. + +## Vendored-форк talk-android (v23.0.0) + +- Подключён только модуль app форка (`settings.gradle:37-38`), режим library (`f7cloudAsLibrary=true`, + flavor `f7`). `feature:talk-native` — тонкая обёртка (8 файлов, 364 строки), реально используется + <10% vendor (звонковый стек: CallActivity, CallForegroundService, F7cloudTalkApplication, F7TalkUserSync). + Чат — свой Compose-модуль `feature:talk` (OCS напрямую). Но компилируется и в APK попадает весь vendor. +- Ребрендинг пакетов полный: `com.nextcloud.talk` → `ru.f7cloud.talk` (609/704 файла). Остатки + `com.nextcloud` (34 файла) легитимны (внешняя библиотека nextcloud-android-common:ui, mimeType, Transifex). +- **Git-истории форка НЕТ** (один коммит «Initial import», у vendor нет своего `.git`) → обновление на + новый апстрим = повторный ручной rename 700 файлов. Рекомендация: отдельный git-репо с ветками + `upstream` (чистый v23) / `f7` (rename-скрипт + патчи коммитами). +- `build.f7cloud.gradle:6` — `DEBUG=true` захардкожен и для release (debug-логи vendor в проде). +- `README.F7CLOUD.md:20` — «not yet compiled» устарел (модуль давно в графе). +- Cleartext: `vendor/.../network_security_config.xml:9` — `cleartextTrafficPermitted="true"` + доверие + user-CA; наследуется в app. Fix: переопределить свой networkSecurityConfig. +- Совместимость v23-клиент ↔ сервер spreed 22 (Talk API v4): vendored-клиент договаривается по + capabilities (`ApiUtils.kt:100-131` conversation-v4→v1; `CapabilitiesUtil.kt:336` SERVER_VERSION_MIN=17) — + безопасно, фичи 23 без поддержки просто не включатся. НО собственный `feature:talk` хардкодит `/api/v4` + без capability-гейтов (`TalkRepository.kt:17,37,46...`) — для spreed 22 ок, регресс-тест звонков на + реальном 22 обязателен. + +## GPL-3.0 — что сделать (см. C6) + +1. `LICENSE` (GPL-3.0-or-later) в корень; свой код остаётся вашим (копирайт ваш), лицензируется под GPL. +2. Вернуть копирайты Nextcloud в изменённых заголовках vendor (свои — добавлять рядом). +3. Публиковать архив исходников с каждым распространяемым APK (шаг CI, git-тег в Gitea). +4. Firebase+GPL: основной flavor без FCM на базе `f7push`/UnifiedPush, FCM — опциональный flavor. +5. Бренд/дизайн GPL НЕ затрагивает (это товарный знак) — тема и UI сохраняются полностью. + +--- + +## План действий (приоритизированный) + +### Этап 0 — санитария релиза (дни) = п.1 плана mail/035 +1. Keystore + `signingConfig` из env/CI secrets; proguard-правила (webrtc/LoganSquare/EventBus/ + `-dontwarn`) + `consumerProguardFiles` у vendor; до готовности — `minifyEnabled false` как страховка. +2. `abiFilters 'arm64-v8a','armeabi-v7a'` + `resConfigs 'ru','en'` → минус ~половина APK. +3. Безопасность-минимум: trust-all → только debug; `allowBackup="false"`; `AuthStore` → + EncryptedSharedPreferences с миграцией; ревокация app password при logout; `FLAG_SECURE` на логин/PIN. +4. GPL: LICENSE в корень, вернуть копирайты Nextcloud, публикация исходников с APK. +5. `gradle.properties`: `parallel=true`, `nonTransitiveRClass=true`, убрать jetifier; удалить мёртвые + `core/data`, `core/ui`, решить судьбу `feature/widgets`. + +### Этап 1 — фундамент (2-3 недели) +6. CI (Gitea Actions, инфра B): lint+тесты → assembleDebug на push → по тегу подписанный release + + исходники; versionCode из CI. +7. Единый `OkHttpClient` (пул, `.cache(20MB)`, interceptor 401, OCS-заголовки); `withContext(IO)` + внутрь репозиториев; убрать `runBlocking` из старта и пути звонка. +8. Первые 10 unit-тестов (ICS-парсер первым — зафиксировать баги C5, потом чинить); detekt+ktlint + с baseline; version catalog. +9. Починить баги данных: единый ICS-парсер с TZID/ATTENDEE/unescape; QR-пароль с `&`; курсор почты. + +### Этап 2 — систематика (1-2 месяца) +10. DI (Hilt / ручной AppContainer), интерфейсы репозиториев; разбор AppScaffold на MainViewModel + + LoginScreen; navigation-compose по-настоящему или удалить. +11. kotlinx.serialization вместо org.json; sync-token/CTag для DAV; кэш календаря/задач; локальные иконки. +12. UX: pull-to-refresh везде, единый F7ErrorState+retry, SplashScreen, строки→strings.xml→тёмная тема. +13. Vendor: отдельный git-репо (upstream/f7 ветки); flavor без Firebase на базе f7push. + +## Что хорошо (сохранять) + +ViewModel-слой единообразен (viewModelScope.launch(IO), без блокировок main кроме talk-bootstrap); +просмотр HTML-писем безопасен (`MailMessageBodyView.kt:69` — JS off, domStorage off, фильтр запросов); +кэш контактов с TTL — образец; секретов в git нет; ключи LazyColumn почти везде на месте; ребрендинг +vendor чистый; собственная пагинация почты работает; биометрия корректна (`AppLockGate.kt:67-73`).