Files
f7cloud_mobile/docs/AUDIT-2026-07-07.md
T

24 KiB
Raw Permalink Blame History

Полный аудит 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-33minifyEnabled true + shrinkResources true, но app/proguard-rules.pro1 строка (только комментарий); 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,12app_password в обычном SharedPreferences (f7_auth, MODE_PRIVATE). EncryptedSharedPreferences/MasterKey/Keystore в проекте нет вообще (grep пуст).
  • app/src/main/AndroidManifest.xml:25allowBackup="true" без dataExtractionRulesf7_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:23runBlocking(Dispatchers.IO) с сетью/БД.
  • Вызывается из F7MobileApp.onCreate (F7MobileApp.kt:41 — блокирует холодный старт) и из TalkNativeCallLauncher.launchRoomCallMainActivity.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:65withTimeoutOrNull(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:22DateTimeFormatter.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). loadThreadgetOrDefault(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.talkru.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:6DEBUG=true захардкожен и для release (debug-логи vendor в проде).
  • README.F7CLOUD.md:20 — «not yet compiled» устарел (модуль давно в графе).
  • Cleartext: vendor/.../network_security_config.xml:9cleartextTrafficPermitted="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 недели)

  1. CI (Gitea Actions, инфра B): lint+тесты → assembleDebug на push → по тегу подписанный release + исходники; versionCode из CI.
  2. Единый OkHttpClient (пул, .cache(20MB), interceptor 401, OCS-заголовки); withContext(IO) внутрь репозиториев; убрать runBlocking из старта и пути звонка.
  3. Первые 10 unit-тестов (ICS-парсер первым — зафиксировать баги C5, потом чинить); detekt+ktlint с baseline; version catalog.
  4. Починить баги данных: единый ICS-парсер с TZID/ATTENDEE/unescape; QR-пароль с &; курсор почты.

Этап 2 — систематика (1-2 месяца)

  1. DI (Hilt / ручной AppContainer), интерфейсы репозиториев; разбор AppScaffold на MainViewModel + LoginScreen; navigation-compose по-настоящему или удалить.
  2. kotlinx.serialization вместо org.json; sync-token/CTag для DAV; кэш календаря/задач; локальные иконки.
  3. UX: pull-to-refresh везде, единый F7ErrorState+retry, SplashScreen, строки→strings.xml→тёмная тема.
  4. 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).