24 KiB
Полный аудит 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 JNIorg.webrtc.**(:333), Retrofit/gson-модели, EventBus@Subscribe, Parceler, kotlinx.serialization. Вероятен отказ ещё на этапе сборки (missing classes: conscrypt, RxJavasun.misc).- Проект живёт на debug-APK 130 МБ.
C2. TLS выключается галочкой навсегда (MITM)
core/network/.../UnsafeSsl.kt:12-23— trust-allX509TrustManager(пустые 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игнорируют оба (regexCalendarIcs.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 строк, ~35mutableStateOf/34rememberSaveableв одной функции: роутер табов + логин (: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.kt1898,TalkComponents.kt1591,FilesComponents.kt1457,CalendarComponents.kt1362,TasksComponents.kt1066,CalDavClient.kt1050,MailViewModel.kt1012,MailRepository.kt991,AppScaffold.kt920.
🟠 Сетевой слой
- Новый
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). Календарь делаетlistCalendarsPROPFIND перед каждой операцией (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-nativeapi project(':vendor:talk-app')). WebRTCandroid-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...).
- 77 инлайновых
- Локализация невозможна:
stringResource— 6 раз, вstrings.xml15 строк; 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), ~121error()как flow-control (нетипизированный IllegalStateException — «нет сети» и «битый JSON» неразличимы), молчаливые catch (CardDavClient.kt:437,DavClient.kt:237,252,F7PushRegistrar.kt:87-89— пустое тело). Логирования почти нет (35 Log.*/println на проект). !!— 70 шт. (UI-гонки:MailSettingsScreens.kt:557-558draft!!,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-5515000,TalkAttachmentUploader.kt:49-515/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, flavorf7).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-131conversation-v4→v1;CapabilitiesUtil.kt:336SERVER_VERSION_MIN=17) — безопасно, фичи 23 без поддержки просто не включатся. НО собственныйfeature:talkхардкодит/api/v4без capability-гейтов (TalkRepository.kt:17,37,46...) — для spreed 22 ок, регресс-тест звонков на реальном 22 обязателен.
GPL-3.0 — что сделать (см. C6)
LICENSE(GPL-3.0-or-later) в корень; свой код остаётся вашим (копирайт ваш), лицензируется под GPL.- Вернуть копирайты Nextcloud в изменённых заголовках vendor (свои — добавлять рядом).
- Публиковать архив исходников с каждым распространяемым APK (шаг CI, git-тег в Gitea).
- Firebase+GPL: основной flavor без FCM на базе
f7push/UnifiedPush, FCM — опциональный flavor. - Бренд/дизайн GPL НЕ затрагивает (это товарный знак) — тема и UI сохраняются полностью.
План действий (приоритизированный)
Этап 0 — санитария релиза (дни) = п.1 плана mail/035
- Keystore +
signingConfigиз env/CI secrets; proguard-правила (webrtc/LoganSquare/EventBus/-dontwarn) +consumerProguardFilesу vendor; до готовности —minifyEnabled falseкак страховка. abiFilters 'arm64-v8a','armeabi-v7a'+resConfigs 'ru','en'→ минус ~половина APK.- Безопасность-минимум: trust-all → только debug;
allowBackup="false";AuthStore→ EncryptedSharedPreferences с миграцией; ревокация app password при logout;FLAG_SECUREна логин/PIN. - GPL: LICENSE в корень, вернуть копирайты Nextcloud, публикация исходников с APK.
gradle.properties:parallel=true,nonTransitiveRClass=true, убрать jetifier; удалить мёртвыеcore/data,core/ui, решить судьбуfeature/widgets.
Этап 1 — фундамент (2-3 недели)
- CI (Gitea Actions, инфра B): lint+тесты → assembleDebug на push → по тегу подписанный release + исходники; versionCode из CI.
- Единый
OkHttpClient(пул,.cache(20MB), interceptor 401, OCS-заголовки);withContext(IO)внутрь репозиториев; убратьrunBlockingиз старта и пути звонка. - Первые 10 unit-тестов (ICS-парсер первым — зафиксировать баги C5, потом чинить); detekt+ktlint с baseline; version catalog.
- Починить баги данных: единый ICS-парсер с TZID/ATTENDEE/unescape; QR-пароль с
&; курсор почты.
Этап 2 — систематика (1-2 месяца)
- DI (Hilt / ручной AppContainer), интерфейсы репозиториев; разбор AppScaffold на MainViewModel + LoginScreen; navigation-compose по-настоящему или удалить.
- kotlinx.serialization вместо org.json; sync-token/CTag для DAV; кэш календаря/задач; локальные иконки.
- UX: pull-to-refresh везде, единый F7ErrorState+retry, SplashScreen, строки→strings.xml→тёмная тема.
- 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).