Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 92e0e9690b | |||
| 2cf5977dd4 | |||
| ab864aa7d7 | |||
| 2df46667e0 | |||
| 9ec3114506 | |||
| 8204e605e2 | |||
| 84a1ea9f27 | |||
| 753b574e1a | |||
| 3da61da45d | |||
| 999b52eb80 | |||
| 4c7566bbb2 | |||
| 65805a15af | |||
| bc9a31a026 | |||
| bbfb6ae424 | |||
| 1bc568b059 | |||
| 6895f1bffd | |||
| e5bbf0e123 | |||
| 2758d07e47 | |||
| c6732546f1 | |||
| dd5bdbc177 | |||
| 47534ceb59 | |||
| 8c90e83cef | |||
| a674721353 | |||
| 8f7eaff611 | |||
| 83e071673c | |||
| d1d61d0cf8 | |||
| 286ab9c8bf | |||
| b03cb09a6c | |||
| fb57156ee9 | |||
| 4c403e5ec2 | |||
| 00daa980a8 | |||
| 2709dba03f | |||
| c53885b230 | |||
| a696d45f2e | |||
| 2ae03264ff | |||
| 47c274e441 | |||
| 34bb16c59d | |||
| 0fcc575e76 | |||
| fff360fe39 | |||
| d047dea210 | |||
| cfa9fe2ffd | |||
| e25dcbc403 | |||
| 342e1e497d | |||
| 6f82a3fc56 | |||
| fe330be510 | |||
| 58d56feb76 | |||
| 58eed74b03 | |||
| 8186031676 | |||
| 9e5a16a605 | |||
| b56c9a5998 | |||
| d50eaaed3d | |||
| 3824ad86f2 | |||
| 89e32d2875 | |||
| a30af98a8d | |||
| fda34a5b69 | |||
| 3d9f3819d9 | |||
| 04f67e071b | |||
| b83d3dc872 | |||
| 530663754a | |||
| 8cee4d61bf | |||
| 2220fe4a89 | |||
| a727e2dbd1 | |||
| 335468b80f | |||
| b15a97d30e | |||
| 79e6b4ec83 | |||
| 8c3d422c85 | |||
| 483e4107d7 | |||
| 696ed8d802 | |||
| f280e19418 | |||
| 078fffd405 | |||
| 04227fa5c8 | |||
| 312537cd8a | |||
| fd96ca89e6 | |||
| f006a69b5c | |||
| ac438269c5 | |||
| ab8fa70e51 | |||
| c45ebd8d49 | |||
| d9cacb8c9c | |||
| f0708a30ac | |||
| b90107a7fc | |||
| cef526e778 | |||
| 5eb6efb36b |
@@ -0,0 +1,72 @@
|
||||
name: CI
|
||||
|
||||
# ⏸️ ПАРКОВКА (2026-07-09): CI отложен. Раннер на инфре B развёрнут, но
|
||||
# host-исполнение act_runner 0.2.13 в нашем окружении сломано (криво резолвит
|
||||
# пути к шагам/JS-экшенам: MODULE_NOT_FOUND / "No such file", задвоение
|
||||
# hostexecutor/.cache/act). Рабочий путь — Docker-исполнение с кастомным образом
|
||||
# (JDK 21 + Android SDK 36) и volume для ~/.gradle. До этого — сборки/релизы вручную
|
||||
# на билд-машине (см. docs/CI.md).
|
||||
#
|
||||
# Триггер временно ручной (workflow_dispatch), чтобы push'и не создавали падающие
|
||||
# прогоны. Когда настроим Docker-раннер — вернуть блок on: push/pull_request ниже.
|
||||
#
|
||||
# on:
|
||||
# push:
|
||||
# branches: [main]
|
||||
# tags: ['v*']
|
||||
# pull_request:
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
GRADLE_OPTS: "-Dorg.gradle.daemon=false"
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
# detekt + unit-тесты + Android Lint + debug-сборка на каждый push в main и на PR
|
||||
runs-on: [self-hosted, f7-android]
|
||||
if: github.ref_type != 'tag'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Статанализ (detekt + ktlint-форматирование)
|
||||
run: ./gradlew detekt --no-daemon
|
||||
- name: Unit-тесты
|
||||
run: ./gradlew testDebugUnitTest --no-daemon
|
||||
- name: Android Lint (release)
|
||||
run: ./gradlew :app:lintRelease --no-daemon
|
||||
- name: Debug-сборка (артефакт для QA)
|
||||
run: ./gradlew :app:assembleDebug --no-daemon
|
||||
- name: Публикация debug-APK
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: f7cloud-mobile-debug
|
||||
path: app/build/outputs/apk/debug/*.apk
|
||||
|
||||
release:
|
||||
# по тегу v*: подписанный release + архив исходников (GPL corresponding source) в Gitea Release
|
||||
runs-on: [self-hosted, f7-android]
|
||||
if: github.ref_type == 'tag'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Развернуть keystore из secret
|
||||
run: |
|
||||
echo "${{ secrets.F7_KEYSTORE_BASE64 }}" | base64 -d > "$RUNNER_TEMP/release.jks"
|
||||
- name: Release-сборка (подписанная)
|
||||
env:
|
||||
F7_KEYSTORE_FILE: ${{ runner.temp }}/release.jks
|
||||
F7_KEYSTORE_PASSWORD: ${{ secrets.F7_KEYSTORE_PASSWORD }}
|
||||
F7_KEY_ALIAS: ${{ secrets.F7_KEY_ALIAS }}
|
||||
F7_KEY_PASSWORD: ${{ secrets.F7_KEY_PASSWORD }}
|
||||
run: ./gradlew :app:assembleRelease --no-daemon
|
||||
- name: Архив исходников (GPL corresponding source)
|
||||
run: bash scripts/package-source.sh "$RUNNER_TEMP/dist"
|
||||
- name: Публикация Gitea Release (APK + исходники)
|
||||
uses: akkuman/gitea-release-action@v1
|
||||
with:
|
||||
files: |
|
||||
app/build/outputs/apk/release/*.apk
|
||||
${{ runner.temp }}/dist/*-source.tar.gz
|
||||
@@ -24,3 +24,4 @@
|
||||
*.log
|
||||
*.tmp
|
||||
.cxx/
|
||||
dist/
|
||||
|
||||
@@ -2,7 +2,62 @@
|
||||
|
||||
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
|
||||
|
||||
- 2026-07-07 | v0.5.115 (123) | Этап 0 п.3 (доп): биометрическая привязка app password поверх шифрования в покое. Auth-bound RSA-пара `f7_auth_master_bio` в AndroidKeystore (`setUserAuthenticationRequired`, `setInvalidatedByBiometricEnrollment`, per-use CryptoObject, префикс `v2b:`): ПУБЛИЧНЫЙ ключ шифрует без авторизации (в т.ч. в фоне — миграция/сохранение после входа), ПРИВАТНЫЙ расшифровывает ТОЛЬКО через BiometricPrompt+CryptoObject. Расшифровка ОДИН раз при разблокировке (AppLockGate) → `AppPasswordHolder` в памяти процесса (не на диске). Разрешает конфликт с фоновыми push/звонками: `AuthStore.load()` в фоне возвращает null «холодным» → существующие `?: return` деградируют сами; экраны Accept открывают приложение для разблокировки вместо тихого отказа. ВЫКЛ по умолчанию (`AppLockStore.isPasswordBindingEnabled`, только при биометрии-без-PIN); UI-тумблер и включение по умолчанию — ПОСЛЕ подтверждения владельцем поведения «после ребута до разблокировки звонки/push не работают» (как в Signal). Проверено сборкой assembleRelease на инфре B | контракты не менялись | средний: затрагивает хранилище пароля и путь разблокировки; по умолчанию поведение прежнее
|
||||
- 2026-07-10 | v0.5.126 (134) | СВОДНЫЙ РЕЛИЗ (включает непубликовавшиеся v0.5.124/125). **Пуши/звонки**: клиент собран под верный Firebase-проект f7push (google-services.json владельца; раньше токены уходили в чужой проект → сервер получал «SenderId mismatch», звонки не доходили); входящий звонок звонит 30 с, затем авто-сброс и уведомление «Пропущенный звонок» (на локскрине обезличенно, после разблокировки — с названием комнаты; страховочный alarm на случай смерти процесса). **Дизайн 1:1 по живой теме forbion** (мобильный CSS боевого сервера — новый источник истины): Файлы (строки 44dp с колонками размер/дата, фирменные чекбоксы/иконки офлайн, навигация с кнопкой «Создать или загрузить», панель выделения, свойства, поповеры), Почта (карточки писем с превью-строкой, навигация, sender-card, композер с чипами получателей, модалка меток, пустая папка), Конференции (автор внутри пузыря, вложения-карточки с иконкой типа), Календарь (месяц: ячейки-карточки 120dp, «сегодня»-плашка, пилюли событий), Контакты (типографика), логин (фон, локальный логотип, QR-кнопка), векторный сплэш вместо мыльного растра, семантика ошибок #D74642. **Новые функции**: «Создать задачу» и «Создать событие» из письма (меню ⋮, префилл темой, CalDAV); фильтры почты — общие с веб-версией (штатный API /api/filter: все правила видны, вкл/выкл/удаление, редактирование простых) | +GET/PUT /apps/mail/api/filter/{id}; feature:mail → зависимости feature:tasks, feature:calendar | средний: большой объём UI-правок + новый auth-жизненный цикл (MainViewModel) — проверить вход/выход/поворот, звонки, фильтры на устройстве
|
||||
|
||||
- 2026-07-09 | (в разработке) | Этап 2 (DAV, ч.2 — календарь): кэш событий по CTag. `listCalendars` дополнительно забирает `cs:getctag` (тот же PROPFIND — лишних запросов нет). Новый `CalendarEventsCache` — дисковый кэш сырых REPORT-ответов per (аккаунт, календарь, диапазон) с ключом-CTag: календарь не менялся → события парсятся с диска, REPORT в сеть не идёт; менялся → сеть + обновление кэша (+чистка записей >30 дней). Сырой XML вместо сериализации моделей — переиспользуется боевой парсер. Эффект: переключение месяцев/периодический рефреш/возврат в календарь при неизменных данных — 1 PROPFIND вместо 1+N REPORT. +5 unit-тестов | контракты не менялись | низкий: при любом изменении CTag меняется — данные всегда свежие
|
||||
|
||||
- 2026-07-09 | (в разработке) | Этап 2 (DAV, ч.1 — контакты): инкрементальная синхронизация по CTag. `CardDavClient.collectionSignature()` — дешёвый PROPFIND depth:1 за CTag адресных книг (расширение CalendarServer, поддерживается Nextcloud; фолбэк на sync-token), без address-data. `ContactsRepository.syncContacts` перед полной закачкой всех vCard сверяет подпись коллекций: не менялось → отдаём кэш (Room), сеть не грузим. Ускоряет pull-to-refresh (было — полный PROPFIND ~500 vCard каждый раз). +4 unit-теста (regex-парсер подписи, без Android XmlPull). Дальше по Этапу 2: кэш событий календаря + CTag (сейчас экран календаря тянет события живьём по диапазону — под него сначала нужен кэш) | контракты не менялись | низкий: только оптимизация, поведение то же при изменениях
|
||||
|
||||
- 2026-07-09 | v0.5.123 (131) | Первый тегированный релиз в Gitea (тег `v0.5.123` + Release с APK и архивом исходников GPL). Включает хвосты этапов 0/1: FLAG_SECURE на экранах входа/PIN (аудит п.3, коммит cfa9fe2) + активированный CI-раннер на инфре B (docs/CI). Функциональных изменений в приложении относительно v0.5.122 нет, кроме FLAG_SECURE | контракты не менялись | низкий
|
||||
|
||||
- 2026-07-08 | v0.5.122 (130) | Убрана кнопка «чаты» из нижней панели (слева теперь всегда «назад», единый layout во всех разделах, включая Конференции) + убрана кнопка обновления ↻ сверху справа в Конференциях (onRefresh у F7ModuleScreen; список грузится при открытии/по push) | контракты не менялись | низкий: только UI
|
||||
|
||||
- 2026-07-08 | v0.5.121 (129) | Нижняя панель: убрана зелёная подложка под активной кнопкой (фон всегда нейтральный). Активный раздел больше не выделяется фоном (зелёных вариантов иконок разделов нет). Зелёные иконки чатов/бургера при открытии остаются | контракты не менялись | низкий: только фон кнопок
|
||||
|
||||
- 2026-07-08 | v0.5.120 (128) | Нижняя панель: иконки-разделы (Почта/Карточки/Конференции) крупнее — 46dp (у серверных SVG внутренний «воздух», глиф оставался мелким). Стрелка/бургер — прежние 34dp | контракты не менялись | низкий: только размер иконок
|
||||
|
||||
- 2026-07-08 | v0.5.119 (127) | Убран чекбокс «Доверять сертификату» с экрана входа (раньше только в debug, теперь совсем). trustAllCerts=false по умолчанию; trust-all на сети/WebView в release и так отключён | контракты не менялись | низкий: только UI входа
|
||||
|
||||
- 2026-07-08 | v0.5.118 (126) | Первая RELEASE-сборка на боевом ключе владельца (release-keystore.jks, alias f7cloud, SHA-256 5F:E5:F0:6C…). Раньше отдавались debug-сборки (debug-ключ сервера) → Play Защита ругалась «незнакомый разработчик». Теперь release, подписан ключом владельца → предупреждения нет, обновления ставятся поверх. signing.env читает креды из /root/.f7cloud-keys/key.properties (в git НЕ коммитится). + иконки нижней панели крупнее (24dp→34dp, заполняют кружок) | контракты не менялись | средний: смена контура подписи на боевой ключ; debug-сборку v117 надо удалить перед установкой (иная подпись)
|
||||
|
||||
- 2026-07-08 | v0.5.117 (125) | Нижняя панель → навигационная по дизайну «Новое меню» (other/Новое меню.png): слева назад (в Конференциях — чаты), ярлыки-разделы Почта · Карточки · Конференции (активный подсвечен), справа выдвижное меню. Профиль/Уведомления/Настройки/Создать убраны из панели (первые три — в выдвижном меню, бейдж уведомлений на кнопке меню). `F7BottomBarConfig` упрощён, `F7MobileBottomBar` +слоты SectionMail/Cards/Conferences +activeTabKey. Создание Файлов/Контактов вызывалось ТОЛЬКО из панели → новый `F7CreateButton` (зелёный «+») в шапках Файлов и Контактов (Задачи/Поддержка/Почта свои имели) | контракты не менялись | средний: изменена навигация нижней панели; проверить переходы разделов и создание в Файлах/Контактах
|
||||
|
||||
- 2026-07-08 | v0.5.117 (125) | Дизайн выдвижного меню: сетка 3 колонки (было 4), ячейка кликабельна целиком, подпись во всю ширину. Состав пунктов точно по дизайну — Файлы/Календарь/Контакты/Карточки/Заметки + Bitrix/1C/Личный кабинет + Уведомления/Настройки (убраны Почта/Конференции/Задачи/Поддержка — они в нижней панели). Уведомления→шит, Настройки→настройки активного таба (нативные пункты рисуются локальной иконкой в круглом бейдже) | контракты не менялись | низкий: состав/вид меню
|
||||
|
||||
- 2026-07-08 | v0.5.117 (125) | Нативная панель профиля вместо веб-ЛК forbion: «Личный кабинет» открывал внешнюю ссылку (десктопный дропдаун forbion, криво на моб.) → теперь нативный bottom-card (ProfileSheet): аватар-инициал + имя (OCS cloud/user) + Открыть профиль/QR + Установить статус/Личные настройки/Учётные записи (deep-link веб) + О программе (нативный About) + Выйти. Точки входа: пункт меню + кнопка профиля | контракты не менялись; +GET ocs/cloud/user (имя) | низкий: только панель профиля
|
||||
|
||||
- 2026-07-08 | v0.5.117 (125) | Fix insets (edge-to-edge, targetSdk 36): контент залезал под системную навигацию снизу — добавлен нижний inset (`f7SafeBottomInsets`) к контенту в F7AppScaffold (верх уже был). | контракты не менялись | низкий: отступы контента
|
||||
|
||||
- 2026-07-08 | v0.5.117 (125) | Fix отображения писем: HTML-письма (широкие таблицы Битрикс/рассылок) уезжали за правый край — WebView без useWideViewPort игнорировал viewport width=device-width. Включены useWideViewPort+loadWithOverviewMode+пинч-зум | контракты не менялись | низкий: только рендер письма
|
||||
|
||||
- 2026-07-08 | v0.5.116 (124) | Меню-лончер (сетка приложений) → bottom-sheet на пол-экрана (требование владельца «выходить снизу вверх только на пол экрана»). `F7AppMenuSheet` был `fillMaxSize` с непрозрачным фоном → закрывал весь экран. Теперь: якорь снизу (`Alignment.BottomCenter`), высота `fillMaxHeight(0.6f)`, скруглённый верх (20dp) + тень, ручка-хендл, лёгкий скрим (0.18) над листом с тапом-для-закрытия, зазор `BottomBarReserve` под нижнюю панель, сетка иконок скроллится (`weight(1f)`). Экран под меню теперь виден сверху. Компилируется | контракты не менялись | низкий: только оверлей меню
|
||||
|
||||
- 2026-07-08 | v0.5.115 (123) | Бамп версии под дизайн-веху (0.5.114→0.5.115, code 122→123, стиль проекта: патч +1 / code +1). До этого Этапы 0/1 + весь дизайн ошибочно шли одной WIP-линией 0.5.114 (122) — теперь ревью-сборки дизайна помечены отдельной версией. Дальше — бампить на каждую заметную веху/ревью-сборку | версионирование | нет: только versionCode/Name
|
||||
|
||||
- 2026-07-08 | v0.5.114 (122) | Дизайн Календаря (месячная сетка): по «Календарь · Месяц» — дни недели вынесены в серые пилюли; сетка стала безрамочной с тонкими гридлайнами (0.5dp Grey3) вместо скруглённых боксов; номер дня сверху-слева; сегодня — белое число в зелёном круге; событие — зелёная пилюля PrimaryLight (до 2 шт + «+N») вместо одного превью/точки. Компактная мобильная модель (месяц сверху + список событий выбранного дня снизу) сохранена — не десктопная таблица. `CalendarMonthGrid`/`CalendarDayCell`, убран неиспользуемый параметр daysWithEvents. Проверено сборкой | контракты не менялись | низкий: только вид месяца
|
||||
|
||||
- 2026-07-08 | v0.5.114 (122) | Дизайн Файлов (правая панель свойств/доступа): `FilesDetailsSheet` по «Правая панель свойства/доступ» — табы из текст+подчёркивание → две иконки-пилюли (Обсуждение 💬 / Доступ 👥, активная — зелёная заливка PrimaryLight); шапка: крестик в правом верхнем углу, чип владельца (аватар-инициал + логин), дата/размер справа, звезда-избранное (wired на vm.toggleFavorite) вместо строки «Владелец…»; кнопка отправки комментария → зелёный круг-градиент с белой стрелкой. Иконки — Material (extended уже в модуле). Проверено сборкой | контракты не менялись | низкий: визуал панели, логика (шэры/события/избранное) та же
|
||||
|
||||
- 2026-07-08 | v0.5.114 (122) | Дизайн Конференций (подложка чата): `TalkChatBackground` грузил chat-pattern.svg с сервера поверх плоского фона → заменён на бандл-ресурс `R.drawable.chat_background` (Подложка2 из дизайна — светлый градиент + водяной паттерн иконок, владелец выгрузил). PNG 481КБ → WebP 49КБ (q82, ×10), `feature/talk/res/drawable-nodpi`. ContentScale.Crop, full-bleed. Офлайн + точно из макета. Проверено сборкой | контракты не менялись | низкий: только фон экрана чата
|
||||
|
||||
- 2026-07-08 | v0.5.114 (122) | Дизайн Почты (теги + сверка экрана сообщения): цвета тегов-пилюль сняты пипеткой с макета «Почта · Список сообщений» — Работа #DAEDD6/#28A413, Позже #F1EEE0/#C1B464, Личное #EFE4FB/#B574F7 (fallback tagChipColors, когда сервер не отдаёт colorHex; раньше приблизительные). Экран СООБЩЕНИЯ сверен с «Почта · Окно сообщения» — уже совпадает (тема-заголовок, sender-card, тело, плавающая пилюля «↩ Ответить» MailReplyFab на градиенте Primary), правок не требует. Проверено сборкой | контракты не менялись | низкий: только цвета тегов
|
||||
|
||||
- 2026-07-08 | v0.5.114 (122) | Дизайн-палитра из Figma «Брендбук» (владелец) в F7Colors: точные Base-токены (Black #151515, Grey1 #808080, Grey2 #F5F5F5, Grey3 #E6E6E6, Green #70B62B +30%/10%, Yellow #F6E120, Purple #9747FF, Red #FF7A66 +10%). Семантические токены (TextPrimary/Secondary/Border/SurfaceMuted/Primary) переведены на Base — основа сверена, совпала. **ИСПРАВЛЕНО расхождение: Error #D74642 → #FF7A66 (дизайн-красный)** — влияет на кнопки удаления/ошибки во ВСЁМ приложении. Yellow/Purple — под теги. Пропагируется на все экраны; шаг к закрытию аудитного долга «77 захардкоженных Color(0x…)» | дизайн-система | средний: Error-красный меняется app-wide (по дизайну); проверить экраны удаления/ошибок
|
||||
|
||||
- 2026-07-08 | v0.5.114 (122) | Дизайн Файлов (чанк 3 — каркас списка): рескин главного экрана под мобильный макет (Figma files/*.png на инфре B). Новый FilesBrowserChrome.kt: поиск-пилюля + кнопка-переключатель список⇄сетка, хлебные крошки (🏠 › путь, навигация navigateToPath), фильтр-чипы (Тип файла/Участники/Изменен) ВМЕСТО десктопной колоночной шапки, вид СЕТКА (LazyVerticalGrid 2 кол., карточки), пустое состояние (иконка+заголовок+подзаголовок). Строки без разделителей (по макету). На токенах F7Spacing/F7Radius. material-icons-extended → feature:files (иконки chrome, потом точные из дизайна). Компилируется | контракты не менялись | средний: заметный UI-рескин Файлов; логика (загрузка/выбор/открытие) та же, сверка на устройстве
|
||||
|
||||
- 2026-07-08 | v0.5.114 (122) | Дизайн Файлов (чанк 2): локальные иконки типов файлов. 13 SVG из дизайна (PDF/Word/Spreadsheet/Presentation/Image/Audio/Video/Archive/Code/Text/Certificate/Eml/file) в assets/filetypes/, FileIcons переключён на них (file:///android_asset, coil-svg). Раньше иконки типов тянулись с сервера → офлайн ломался (аудит) + не гарантированно совпадали с дизайном. Папка пока с сервера (иконки папки в наборе нет — TODO из Figma). Дизайн-макеты (109 PNG) — на инфре B /home/foradmin/figma/, в git не бандлю (вес) | контракты не менялись | низкий: файловые иконки теперь офлайн и точно из дизайна
|
||||
|
||||
- 2026-07-08 | v0.5.114 (122) | Трек дизайна (старт): снятие дизайн-токенов из Figma роли design (файл ocVUCYCrfFUoqYYvlcLPBF, доступ по API-токену вне git). Цвета сверены — совпадают с F7Colors; типографика — Raleway 14/18/20/24; заведены F7Spacing (4dp-сетка) + F7Radius (аудит: не было spacing-токенов, ~1087 инлайновых .dp). docs/DESIGN-SOURCE.md — карта Figma + процесс попиксельной вёрстки. Источник — Figma, боевой forbion НЕ трогаю. Координация — mail/044 | дизайн-система (аддитивно, пока не используется) | нет: новые токены, поведение не меняется
|
||||
|
||||
- 2026-07-07 | v0.5.114 (122) | Этап 1 (version catalog): заведён gradle/libs.versions.toml, 15 module build.gradle мигрированы на libs.* (скриптом). Устранён дрейф версий: coroutines 1.8.1/1.10.1→1.10.1, coil 2.6.0/2.7.0→2.7.0, lifecycle 2.8.1/2.8.7→2.8.7, activity-compose 1.9.0/1.10.1→1.10.1, core-ktx 1.13.1/1.15.0→1.15.0. Firebase в core/push переведён на bom (был explicit 24.0.1). Compose BOM в каталоге вместо дубля в каждом модуле. Проверено сборкой | контракты не менялись; версии унифицированы вверх (minor-бампы) | низкий: сборочная гигиена, унификация версий
|
||||
- 2026-07-07 | v0.5.114 (122) | Этап 1 (статанализ): detekt 1.23.8 + formatting-ruleset (ktlint) на все наши модули (vendor исключён), совместим с Kotlin 2.3. config/detekt/detekt.yml + per-module detekt-baseline.xml (~403 текущих замечаний зафиксированы → зелёный, ловит только новые). Добавлен в CI verify-джобу. Нормальные сборки (assembleDebug/Release) не затрагивает (detekt только в check/CI) | процесс качества | нет: тулинг, кода приложения не трогает
|
||||
- 2026-07-07 | v0.5.114 (122) | Этап 1 (единый OkHttpClient, ч.2 — 401 централизован): добавлен UnauthorizedInterceptor (401→UnauthorizedException) в NetworkFactory (флаг throwOnUnauthorized, по умолч. true). Удалены 75 ручных проверок `if(code==401) throw` в 16 репозиториях. Login-верификация (AuthVerifier) исключена (throwOnUnauthorized=false: 401=«неверный пароль», не session-expired). Побочно: теперь 401 ловится единообразно ВЕЗДЕ, включая пути, что раньше проверку забывали. +3 теста (MockWebServer: 401 бросает, 200/403 проходят) — всего 18. Проверено сборкой | контракты не менялись; поведение 401 сохранено (interceptor бросает то же исключение) | средний: затрагивает обработку 401 во всех репозиториях; login-путь исключён явно, покрыто тестами
|
||||
- 2026-07-07 | v0.5.114 (122) | Этап 1 (единый OkHttpClient, ч.1): NetworkFactory переписан — раньше создавал НОВЫЙ OkHttpClient на КАЖДЫЙ запрос (~32 места) → TLS-handshake + новый пул/диспатчер на запрос + утечка ExecutorService. Теперь один базовый клиент (общий ConnectionPool/Dispatcher + диск-кэш 20МБ), авторизованные варианты через base.newBuilder() (шарят пул/кэш), кэшируются по кредам+таймаутам. Публичный API НЕ менялся — 32 вызова не тронуты. Init диск-кэша — F7MobileApp.onCreate. +3 unit-теста (переиспользование клиента, общий пул). 401→interceptor НЕ делал (меняет семантику исключений — отдельный шаг) | контракты не менялись | средний: затрагивает ВСЕ сетевые вызовы (переиспользование соединений); поведение запросов то же, проверено 15 тестами + assembleRelease
|
||||
- 2026-07-07 | v0.5.114 (122) | Этап 1 (CI + GPL-публикация): добавлен Gitea Actions workflow .gitea/workflows/ci.yml (push/PR → тесты+lint+assembleDebug; тег v* → подписанный release + архив исходников в Gitea Release) — Gitea 1.26.1 поддерживает Actions. Скрипт scripts/package-source.sh (GPL corresponding source §6: архив всего дерева вкл. vendor GPL/LICENSE/NOTICE — проверен, 6.7 МБ) закрывает п.4c, работает и вручную. docs/CI.md. НЕ активно до регистрации self-hosted раннера на инфре B (нужен Gitea-админ для токена — инфра-шаг) | процесс сборки/релиза | нет: конфиг CI, кода приложения не трогает
|
||||
- 2026-07-07 | v0.5.114 (122) | Этап 1 (баги данных, test-first): заведена тестовая инфраструктура (core:network testImplementation junit) + первые 12 unit-тестов. Починено: (1) ICS-парсер дат — суффикс `Z` теперь UTC (был локальным), учитывается TZID, floating→локаль; (2) parseProps сохраняет параметры свойств — несколько ATTENDEE больше НЕ схлопываются, CN/PARTSTAT/ROLE читаются (была порча участников при редактировании→PUT на сервер); (3) unescape single-pass (последовательные replace ломались на `\\n`); (4) CalDavClient.parseIcsInstant делегирует в CalendarIcs — устранён рассинхрон календарь↔задачи; (5) QR-логин: маркер-парсер вместо split('&') — пароль с `&`/`:` больше не теряется; (6) курсор пагинации почты Int→Long (обрезка Unix-времени, ломалось ~2038). Проверено: 12 тестов зелёные + assembleRelease | контракты не менялись; исправлена запись битых ATTENDEE на сервер | низкий: чинит порчу данных, покрыто тестами
|
||||
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.5 (gradle-гигиена): org.gradle.parallel=true (ускорение сборки 20 модулей); удалены мёртвые модули core:data, core:ui (пустые заготовки) и feature:widgets (не был в settings.gradle, не компилировался) + чистка settings.gradle. nonTransitiveRClass и убирание jetifier — ПРОБОВАЛ, откатил: vendor-форк talk-android ломается (R транзитивно + legacy android.support.* в его depS); причины в комментах gradle.properties. Проверено сборкой: assembleRelease BUILD SUCCESSFUL. **Этап 0 завершён** (кроме п.4c — публикация исходников, к CI этапа 1) | контракты не менялись | низкий: удалён неиспользуемый код, ускорена сборка
|
||||
- 2026-07-07 | v0.5.114 (122) | Требование владельца: звонки/push ДОЛЖНЫ работать при заблокированном телефоне. (1) Биометрическая привязка app password ОТКЛОНЕНА (вариант А) — она неустранимо блокировала приём звонка без разблокировки; откачена из main, сохранена на ветке wip/biometric-binding. App password остаётся защищён шифрованием в покое (Keystore AES-256-GCM из п.3, работает в фоне → звонки/push/приём при блокировке ОК). (2) Приватность локскрина: уведомления сообщений и звонков → VISIBILITY_PRIVATE + generic public-версия («Новое сообщение»/«Входящий звонок»), канал lockscreenVisibility=PRIVATE. Полный контент (отправитель/текст/имя звонящего) — только после разблокировки. Push у приложения plaintext, пароль для показа/звонка НЕ нужен. Проверено сборкой: assembleRelease 53 МБ | контракты не менялись | низкий: только UX уведомлений; звонки/push в фоне работают как раньше
|
||||
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.4b (GPL): восстановлены затёртые копирайты Nextcloud в vendor/talk-android — точной сверкой с апстримом v23.0.0 (скачан с GitHub). 102 файла: искажённые email реальных разработчиков NC (@f7cloud.com → настоящие), холдер «F7cloud»/«F7cloud and F7cloud contributors» → «Nextcloud GmbH and Nextcloud contributors». Название продукта в заголовках оставлено «F7cloud Talk» (ребренд форка, требование trademark-политики NC). Менялись ТОЛЬКО строки копирайта в комментах, код не тронут (103+/103−). Проверено сборкой: assembleRelease 53 МБ OK | лицензирование/атрибуция (не код) | нет: функционально нейтрально, снимает нарушение GPLv3 §4 (атрибуция)
|
||||
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.4a (GPL): добавлен LICENSE (GPL-3.0-or-later) в корень репо — приложение производно от vendored Nextcloud Talk (GPL), значит весь APK под GPL; раздел «Лицензия» в README (обязательство corresponding source по тегу релиза). Восстановление затёртых копирайтов Nextcloud в vendor (п.4b) — отдельно, сверкой с апстримом v23.0.0 | лицензирование (не код) | нет
|
||||
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.3 (безопасность-минимум): (1) trust-all TLS отключён в release — гейт `if(!BuildConfig.DEBUG)` в UnsafeSsl + обе WebView-точки (OfficeWebViewClient, TalkCallActivity), чекбокс «доверять сертификату» скрыт в release (buildConfig включён в core:network и feature:talk); (2) allowBackup=false (переопределяет vendor true, подтверждено в собранном манифесте); (3) app password шифруется в покое ключом AndroidKeystore AES-256-GCM (KeystoreCrypto) + одноразовая миграция legacy-plaintext при load; (4) ревокация app password при logout (AppPasswordRevoker: DELETE /ocs/v2.php/core/apppassword, best-effort). Биометрическая привязка ключа — отдельная задача (конфликтует с фоновыми push/звонками, нужен кэш в памяти). Проверено сборкой: assembleRelease 53 МБ, подпись v2+v3, allowBackup=false OK | НОВЫЙ вызов OCS: DELETE core/apppassword при logout (стандартный NC endpoint) | средний: миграция хранилища и logout затрагивают все сессии — проверить на устройстве вход/выход/перезапуск
|
||||
|
||||
@@ -62,19 +62,10 @@ legacy WebView-оболочки `android-webview`.
|
||||
## Секреты (где, НЕ значения)
|
||||
- В git секретов нет. Учётные данные пользователя — только на устройстве
|
||||
(session storage `core/auth`).
|
||||
- App password шифруется в покое ключом AndroidKeystore AES-256-GCM (`KeystoreCrypto`,
|
||||
ключ `f7_auth_master`, префикс `v1:`). Опционально — **биометрическая привязка**
|
||||
(`AppLockStore.isPasswordBindingEnabled`, ВЫКЛ по умолчанию): пароль шифруется
|
||||
auth-bound RSA-парой `f7_auth_master_bio` (`setUserAuthenticationRequired`, префикс
|
||||
`v2b:`), расшифровывается ТОЛЬКО через BiometricPrompt+CryptoObject один раз при
|
||||
разблокировке и живёт в памяти процесса (`AppPasswordHolder`, не на диске). Фоновые
|
||||
сценарии (push/входящие Talk-звонки) до первой разблокировки после ребута деградируют
|
||||
корректно («Разблокируйте приложение…»). Включение по умолчанию/UI-тумблер — ПОСЛЕ
|
||||
подтверждения владельцем допустимости этого поведения (как в Signal).
|
||||
- `google-services.json` (Firebase) — вне git; путь сборки документируется в
|
||||
PROJECT-STATUS.md.
|
||||
- Release-keystore для подписи — вне git (пока не настроен, сборки debug).
|
||||
|
||||
## Версия / changelog
|
||||
- Текущее: v0.5.115 (code 123) — биопривязка app password (Этап 0 п.3 доп). История — CHANGELOG.md
|
||||
- Текущее: v0.5.113 (code 121) — модуль «Задачи». История — CHANGELOG.md
|
||||
и git-история `app/build.gradle`.
|
||||
|
||||
@@ -39,16 +39,14 @@ android {
|
||||
applicationId 'ru.forbion.f7cloud.mobile'
|
||||
minSdk 26
|
||||
targetSdk 36
|
||||
versionCode 123
|
||||
versionName '0.5.115'
|
||||
versionCode 134
|
||||
versionName '0.5.126'
|
||||
missingDimensionStrategy 'default', 'f7'
|
||||
multiDexEnabled true
|
||||
|
||||
// Универсальный APK для реальных устройств: только ARM (arm64 + armeabi-v7a),
|
||||
// без эмуляторных x86/x86_64 — режет ~половину native-веса (WebRTC .so).
|
||||
ndk {
|
||||
abiFilters 'arm64-v8a', 'armeabi-v7a'
|
||||
}
|
||||
// ABI задаём ПОБИЛДТИПОВО (ниже): release — ARM-only (компактно), debug — все ABI
|
||||
// (включая x86/x86_64), чтобы debug-сборка нативно шла на эмуляторах (BlueStacks и т.п.)
|
||||
// и на физических устройствах.
|
||||
// Оставляем только нужные локали (у vendor-форка ~48 языков) — минус несколько МБ.
|
||||
// resourceConfigurations — не-deprecated бэкинг resConfigs.
|
||||
resourceConfigurations += ['ru', 'en']
|
||||
@@ -62,11 +60,16 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {}
|
||||
debug {
|
||||
// Все ABI — чтобы debug нативно работал на x86-эмуляторах (BlueStacks) И на ARM-девайсах.
|
||||
ndk { abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64' }
|
||||
}
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
// прод — только ARM (реальные устройства), компактно
|
||||
ndk { abiFilters 'arm64-v8a', 'armeabi-v7a' }
|
||||
// боевой keystore — если задан env/-P; иначе debug-ключ (не для распространения)
|
||||
signingConfig f7HasReleaseSigning ? signingConfigs.release : signingConfigs.debug
|
||||
}
|
||||
@@ -108,30 +111,31 @@ dependencies {
|
||||
implementation project(':feature:mail')
|
||||
implementation project(':feature:f7support')
|
||||
|
||||
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
|
||||
def composeBom = platform(libs.compose.bom)
|
||||
implementation composeBom
|
||||
androidTestImplementation composeBom
|
||||
implementation 'androidx.activity:activity-compose:1.10.1'
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.navigation:navigation-compose:2.8.9'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.8.7'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.7'
|
||||
implementation 'io.coil-kt:coil-compose:2.7.0'
|
||||
implementation 'io.coil-kt:coil-svg:2.7.0'
|
||||
implementation platform('com.google.firebase:firebase-bom:33.7.0')
|
||||
implementation 'com.google.firebase:firebase-messaging'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0'
|
||||
implementation 'androidx.multidex:multidex:2.0.1'
|
||||
implementation 'androidx.biometric:biometric:1.1.0'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.8.6'
|
||||
implementation 'androidx.camera:camera-camera2:1.5.2'
|
||||
implementation 'androidx.camera:camera-lifecycle:1.5.2'
|
||||
implementation 'androidx.camera:camera-view:1.5.2'
|
||||
implementation 'com.google.zxing:core:3.3.0'
|
||||
implementation 'androidx.compose.material:material-icons-extended'
|
||||
implementation 'com.google.guava:guava:33.3.1-android'
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
implementation libs.activity.compose
|
||||
implementation libs.compose.ui
|
||||
implementation libs.compose.ui.tooling.preview
|
||||
implementation libs.compose.material3
|
||||
implementation libs.navigation.compose
|
||||
implementation libs.lifecycle.viewmodel.compose
|
||||
implementation libs.lifecycle.runtime.compose
|
||||
implementation libs.lifecycle.process
|
||||
implementation libs.coil.compose
|
||||
implementation libs.coil.svg
|
||||
implementation platform(libs.firebase.bom)
|
||||
implementation libs.firebase.messaging
|
||||
implementation libs.coroutines.play.services
|
||||
implementation libs.multidex
|
||||
implementation libs.core.splashscreen
|
||||
implementation libs.biometric
|
||||
implementation libs.fragment.ktx
|
||||
implementation libs.camera.camera2
|
||||
implementation libs.camera.lifecycle
|
||||
implementation libs.camera.view
|
||||
implementation libs.zxing
|
||||
implementation libs.compose.material.icons.extended
|
||||
implementation libs.guava
|
||||
debugImplementation libs.compose.ui.tooling
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
android:resource="@mipmap/ic_launcher" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:theme="@style/Theme.F7.Splash"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
|
After Width: | Height: | Size: 57 KiB |
@@ -25,6 +25,8 @@ import ru.forbion.f7cloud.feature.talknative.TalkNativeCallLauncher
|
||||
*/
|
||||
class CallIncomingActivity : ComponentActivity() {
|
||||
|
||||
private var callEndedListener: ((String) -> Unit)? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
@@ -55,6 +57,20 @@ class CallIncomingActivity : ComponentActivity() {
|
||||
finish()
|
||||
}
|
||||
|
||||
// Звонок сняли извне (30-с таймаут «не берут трубку», принят/отклонён из
|
||||
// уведомления) — закрываем полноэкранный входящий.
|
||||
launch.roomToken?.takeIf { it.isNotBlank() }?.let { token ->
|
||||
val listener: (String) -> Unit = { ended ->
|
||||
if (ended == token) {
|
||||
runOnUiThread {
|
||||
if (!isFinishing) finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
callEndedListener = listener
|
||||
F7IncomingCallQueue.addCallEndedListener(listener)
|
||||
}
|
||||
|
||||
setContent {
|
||||
F7Theme {
|
||||
IncomingCallScreen(
|
||||
@@ -74,6 +90,12 @@ class CallIncomingActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
callEndedListener?.let(F7IncomingCallQueue::removeCallEndedListener)
|
||||
callEndedListener = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
@@ -86,25 +108,7 @@ class CallIncomingActivity : ComponentActivity() {
|
||||
|
||||
private fun acceptCall(launch: IncomingCallLaunch) {
|
||||
F7IncomingCallRinger.stop()
|
||||
val authStore = AuthStore(this)
|
||||
val session = authStore.load()
|
||||
if (session == null) {
|
||||
// При биопривязке пароль расшифрован только после разблокировки приложения. Если
|
||||
// «холодно» (после ребута/повторной блокировки) — открываем приложение, чтобы человек
|
||||
// прошёл биометрию, а не молча роняем звонок.
|
||||
if (authStore.isPasswordLocked()) {
|
||||
android.widget.Toast.makeText(
|
||||
this,
|
||||
"Разблокируйте приложение, чтобы принять звонок",
|
||||
android.widget.Toast.LENGTH_LONG,
|
||||
).show()
|
||||
packageManager.getLaunchIntentForPackage(packageName)?.let {
|
||||
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
startActivity(it)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
val session = AuthStore(this).load() ?: return
|
||||
F7IncomingCallQueue.dismissAndShowNext(this, launch.roomToken)
|
||||
TalkNativeCallLauncher.launchIncomingCall(
|
||||
this,
|
||||
|
||||
@@ -16,6 +16,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.forbion.f7cloud.core.auth.AuthStore
|
||||
import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker
|
||||
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||
import ru.forbion.f7cloud.core.push.F7NotificationChannels
|
||||
import ru.forbion.f7cloud.core.push.F7PushRegistrar
|
||||
import ru.forbion.f7cloud.feature.talknative.TalkVendorBootstrap
|
||||
@@ -37,6 +38,7 @@ class F7MobileApp : F7cloudTalkApplication(), ImageLoaderFactory {
|
||||
}
|
||||
})
|
||||
F7NotificationChannels.ensureAll(this)
|
||||
NetworkFactory.init(this) // общий HTTP-клиент + диск-кэш (переиспользование соединений)
|
||||
TalkVendorBootstrap.onApplicationCreate(this)
|
||||
val auth = AuthStore(this).load() ?: return
|
||||
try {
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.util.Log
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -43,6 +44,8 @@ class MainActivity : FragmentActivity() {
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Системный сплэш (фон + иконка) на холодном старте вместо белого экрана
|
||||
installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
if (!handleIncomingIntent(intent)) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package ru.forbion.f7cloud.mobile.ui
|
||||
|
||||
import android.util.Log
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.foundation.Image
|
||||
@@ -52,11 +51,9 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import ru.forbion.f7cloud.core.auth.AppLockStore
|
||||
import ru.forbion.f7cloud.core.auth.AppPasswordHolder
|
||||
import ru.forbion.f7cloud.core.auth.AuthStore
|
||||
import ru.forbion.f7cloud.core.auth.KeystoreCrypto
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7SecureScreen
|
||||
import ru.forbion.f7cloud.core.designsystem.F7TextButton
|
||||
import ru.forbion.f7cloud.mobile.R
|
||||
|
||||
@@ -67,7 +64,6 @@ private enum class AppLockSetupStage {
|
||||
}
|
||||
|
||||
private const val PIN_LENGTH = 4
|
||||
private const val TAG = "AppLockGate"
|
||||
|
||||
private fun biometricAuthenticators(): Int =
|
||||
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||
@@ -81,7 +77,6 @@ private fun canUseBiometric(context: android.content.Context): Boolean =
|
||||
fun AppLockGate(
|
||||
lockStore: AppLockStore,
|
||||
unlockNonce: Int = 0,
|
||||
onUnlocked: () -> Unit = {},
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
if (!lockStore.isEnabled()) {
|
||||
@@ -91,17 +86,10 @@ fun AppLockGate(
|
||||
var unlocked by remember { mutableStateOf(false) }
|
||||
var lockSession by remember { mutableIntStateOf(0) }
|
||||
|
||||
// При повторной блокировкe забываем расшифрованный пароль из памяти: заблокированное
|
||||
// приложение не должно держать секрет резидентно (фоновые сценарии деградируют до разблокировки).
|
||||
fun relock() {
|
||||
AppPasswordHolder.clear()
|
||||
unlocked = false
|
||||
lockSession++
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (lockStore.consumeColdStart()) {
|
||||
relock()
|
||||
unlocked = false
|
||||
lockSession++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,8 +100,14 @@ fun AppLockGate(
|
||||
Lifecycle.Event.ON_STOP -> lockStore.markBackgrounded()
|
||||
Lifecycle.Event.ON_START -> {
|
||||
when {
|
||||
lockStore.consumeColdStart() -> relock()
|
||||
lockStore.shouldRequireUnlock() -> relock()
|
||||
lockStore.consumeColdStart() -> {
|
||||
unlocked = false
|
||||
lockSession++
|
||||
}
|
||||
lockStore.shouldRequireUnlock() -> {
|
||||
unlocked = false
|
||||
lockSession++
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
@@ -140,7 +134,6 @@ fun AppLockGate(
|
||||
onUnlocked = {
|
||||
lockStore.clearBackgroundMarker()
|
||||
unlocked = true
|
||||
onUnlocked()
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -153,6 +146,7 @@ fun AppLockSetupDialog(
|
||||
onLockConfigured: () -> Unit = {},
|
||||
) {
|
||||
if (!visible) return
|
||||
F7SecureScreen() // создание/подтверждение PIN — не в скриншотах/recents
|
||||
|
||||
val context = LocalContext.current
|
||||
val activity = context.findFragmentActivity()
|
||||
@@ -542,6 +536,7 @@ private fun AppLockUnlockScreen(
|
||||
lockSession: Int,
|
||||
onUnlocked: () -> Unit,
|
||||
) {
|
||||
F7SecureScreen() // экран разблокировки (PIN) — не в скриншотах/recents
|
||||
val context = LocalContext.current
|
||||
val activity = context.findFragmentActivity()
|
||||
var pin by remember { mutableStateOf("") }
|
||||
@@ -550,12 +545,6 @@ private fun AppLockUnlockScreen(
|
||||
val biometricOnly = lockStore.isBiometricOnly()
|
||||
val biometricAvailable = remember { canUseBiometric(context) }
|
||||
|
||||
// Биометрическая привязка пароля: при разблокировке расшифровываем app password через
|
||||
// CryptoObject и кладём в память (AppPasswordHolder), чтобы фоновые сценарии его читали.
|
||||
val authStore = remember { AuthStore(context) }
|
||||
val bindingEnabled = lockStore.isPasswordBindingEnabled()
|
||||
val boundCipherText = remember(lockSession) { authStore.boundPasswordCiphertext() }
|
||||
|
||||
fun verifyPinInput() {
|
||||
if (lockStore.verifyPin(pin)) {
|
||||
onUnlocked()
|
||||
@@ -586,39 +575,12 @@ private fun AppLockUnlockScreen(
|
||||
error = "Биометрия недоступна"
|
||||
return
|
||||
}
|
||||
// Режим привязки: готовим Cipher для расшифровки пароля. Если ключ инвалидирован (сменилась
|
||||
// биометрия устройства) — привязку сбрасываем, чтобы не запереть пользователя навсегда;
|
||||
// приложение попросит войти заново (пароль восстановить нельзя).
|
||||
val useCrypto = bindingEnabled && boundCipherText != null
|
||||
val decryptCipher = if (useCrypto) {
|
||||
runCatching { KeystoreCrypto.initBoundDecryptCipher(boundCipherText) }.getOrElse {
|
||||
Log.w(TAG, "Bound key invalidated, resetting binding", it)
|
||||
lockStore.setPasswordBinding(false)
|
||||
authStore.clear()
|
||||
onUnlocked()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val executor = ContextCompat.getMainExecutor(context)
|
||||
val prompt = BiometricPrompt(
|
||||
host,
|
||||
executor,
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
val cipher = result.cryptoObject?.cipher
|
||||
if (useCrypto && cipher != null) {
|
||||
val ok = runCatching {
|
||||
AppPasswordHolder.set(
|
||||
KeystoreCrypto.finishBoundDecrypt(cipher, boundCipherText),
|
||||
)
|
||||
}.isSuccess
|
||||
if (!ok) {
|
||||
error = "Не удалось расшифровать пароль, войдите заново"
|
||||
return
|
||||
}
|
||||
}
|
||||
onUnlocked()
|
||||
}
|
||||
|
||||
@@ -635,23 +597,14 @@ private fun AppLockUnlockScreen(
|
||||
}
|
||||
},
|
||||
)
|
||||
// CryptoObject требует СИЛЬНОЙ биометрии (Class 3); без привязки допускаем и weak.
|
||||
val authenticators = if (useCrypto) {
|
||||
BiometricManager.Authenticators.BIOMETRIC_STRONG
|
||||
} else {
|
||||
biometricAuthenticators()
|
||||
}
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
prompt.authenticate(
|
||||
BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle("Разблокировка F7cloud")
|
||||
.setSubtitle("Прикоснитесь к сканеру отпечатка")
|
||||
.setAllowedAuthenticators(authenticators)
|
||||
.setAllowedAuthenticators(biometricAuthenticators())
|
||||
.setNegativeButtonText(if (biometricOnly) "Отмена" else "Ввести PIN")
|
||||
.build()
|
||||
if (decryptCipher != null) {
|
||||
prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(decryptCipher))
|
||||
} else {
|
||||
prompt.authenticate(promptInfo)
|
||||
}
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(lockSession, lockStore.useBiometric(), activity) {
|
||||
|
||||
@@ -19,7 +19,6 @@ object AppMenuRepository {
|
||||
val request = Request.Builder().url(url).applyOcsJson().get().build()
|
||||
return runCatching {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package ru.forbion.f7cloud.mobile.ui
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import ru.forbion.f7cloud.core.designsystem.F7AppMenuItem
|
||||
|
||||
enum class AppTab(
|
||||
@@ -29,16 +32,14 @@ private data class AppMenuEntry(
|
||||
val webPath: String? = null,
|
||||
)
|
||||
|
||||
// Состав меню — по дизайну выдвижного меню (Почта/Конференции/Задачи/Поддержка — только
|
||||
// в нижней панели; Уведомления/Настройки добавлены как нативные пункты, см. appMenuItems).
|
||||
private val coreAppMenuEntries = listOf(
|
||||
AppMenuEntry(AppTab.Mail, "Почта", "mail-glass.svg"),
|
||||
AppMenuEntry(AppTab.Files, "Файлы", "files-glass.svg"),
|
||||
AppMenuEntry(AppTab.Calendar, "Календарь", "calendar-glass.svg"),
|
||||
AppMenuEntry(AppTab.Contacts, "Контакты", "contact-glass.svg"),
|
||||
AppMenuEntry(AppTab.Talk, "Конференции", "spreed-glass.svg"),
|
||||
AppMenuEntry(AppTab.Deck, "Карточки", "deck-glass.svg"),
|
||||
AppMenuEntry(AppTab.Tasks, "Задачи", "task-glass.svg"),
|
||||
AppMenuEntry(null, "Заметки", "notes-glass.svg", webPath = "/apps/notes/"),
|
||||
AppMenuEntry(AppTab.Support, "Поддержка", "icon-header-f7support.svg"),
|
||||
)
|
||||
|
||||
data class AppMenuExternalSite(
|
||||
@@ -71,7 +72,22 @@ fun appMenuItems(
|
||||
externalUrl = site.openUrl,
|
||||
)
|
||||
}
|
||||
return core + external
|
||||
// Нативные пункты по дизайну — действия обрабатываются в AppScaffold по label
|
||||
val native = listOf(
|
||||
F7AppMenuItem(
|
||||
label = "Уведомления",
|
||||
iconUrl = "",
|
||||
selected = false,
|
||||
localIcon = Icons.Filled.Notifications,
|
||||
),
|
||||
F7AppMenuItem(
|
||||
label = "Настройки",
|
||||
iconUrl = "",
|
||||
selected = false,
|
||||
localIcon = Icons.Filled.Settings,
|
||||
),
|
||||
)
|
||||
return core + external + native
|
||||
}
|
||||
|
||||
fun appTabFromMenuIndex(index: Int): AppTab? {
|
||||
|
||||
@@ -18,28 +18,12 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -54,12 +38,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
@@ -69,13 +50,10 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import ru.forbion.f7cloud.mobile.BuildConfig
|
||||
import ru.forbion.f7cloud.core.auth.AppLockStore
|
||||
import ru.forbion.f7cloud.core.auth.AppPasswordRevoker
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.auth.AuthStore
|
||||
import ru.forbion.f7cloud.core.auth.AuthVerifier
|
||||
import ru.forbion.f7cloud.core.auth.normalizeServerUrl
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import ru.forbion.f7cloud.core.network.LoginFlowClient
|
||||
import ru.forbion.f7cloud.core.push.F7PushEvent
|
||||
import ru.forbion.f7cloud.core.push.F7PushEventHub
|
||||
@@ -88,14 +66,10 @@ import ru.forbion.f7cloud.core.designsystem.F7BottomBarActions
|
||||
import ru.forbion.f7cloud.core.designsystem.F7BottomBarConfig
|
||||
import ru.forbion.f7cloud.core.designsystem.F7MobileBottomBar
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
|
||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7TextButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Theme
|
||||
import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets
|
||||
import ru.forbion.f7cloud.mobile.OfficeEditorActivity
|
||||
import ru.forbion.f7cloud.mobile.qr.F7QrScannerActivity
|
||||
import ru.forbion.f7cloud.mobile.OfficeWebViewPool
|
||||
import ru.forbion.f7cloud.feature.files.OfficeWarmup
|
||||
import ru.forbion.f7cloud.mobile.permissions.F7AppPermissions
|
||||
import ru.forbion.f7cloud.mobile.permissions.F7PermissionRationaleDialog
|
||||
@@ -117,10 +91,8 @@ fun AppScaffold(
|
||||
onRequestRuntimePermissions: (onFinished: (() -> Unit)?) -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val authStore = remember { AuthStore(context) }
|
||||
// loadMetadata (не load): при биопривязке до разблокировки пароля нет, но сессия ЕСТЬ —
|
||||
// показываем экран блокировки, а не логин. Полную сессию с паролем берём после разблокировки.
|
||||
var session by remember { mutableStateOf(authStore.loadMetadata()) }
|
||||
val mainVm: MainViewModel = viewModel()
|
||||
val session by mainVm.session.collectAsState()
|
||||
var activeTab by rememberSaveable(
|
||||
saver = Saver(
|
||||
save = { state -> state.value.name },
|
||||
@@ -175,17 +147,7 @@ fun AppScaffold(
|
||||
context.applicationContext.getSharedPreferences("f7_permissions", android.content.Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
val logoutScope = rememberCoroutineScope()
|
||||
val forceLogout = {
|
||||
// Best-effort ревокация app password на сервере ДО очистки локальной сессии.
|
||||
session?.let { current ->
|
||||
logoutScope.launch { AppPasswordRevoker.revoke(current) }
|
||||
}
|
||||
OfficeWarmup.clear()
|
||||
OfficeWebViewPool.dispose()
|
||||
authStore.clear()
|
||||
session = null
|
||||
}
|
||||
val forceLogout = { mainVm.logout() }
|
||||
LaunchedEffect(session?.serverUrl, session?.username) {
|
||||
session?.let { OfficeWarmup.warm(it) }
|
||||
}
|
||||
@@ -198,10 +160,7 @@ fun AppScaffold(
|
||||
|
||||
F7Theme {
|
||||
if (session == null) {
|
||||
LoginScreen(onLogin = {
|
||||
authStore.save(it)
|
||||
session = it
|
||||
})
|
||||
LoginScreen(onLogin = { mainVm.login(it) })
|
||||
return@F7Theme
|
||||
}
|
||||
|
||||
@@ -230,13 +189,7 @@ fun AppScaffold(
|
||||
onDismiss = { showAppLockSetup = false },
|
||||
onLockConfigured = { lockUnlockNonce++ },
|
||||
)
|
||||
AppLockGate(
|
||||
lockStore = lockStore,
|
||||
unlockNonce = lockUnlockNonce,
|
||||
// После разблокировки (в т.ч. биометрией с CryptoObject) пароль уже в памяти —
|
||||
// перечитываем полную сессию, чтобы сетевые вызовы получили app password.
|
||||
onUnlocked = { authStore.load()?.let { session = it } },
|
||||
) {
|
||||
AppLockGate(lockStore = lockStore, unlockNonce = lockUnlockNonce) {
|
||||
fun applyAppLink(target: AppLinkTarget) {
|
||||
activeTab = target.tab
|
||||
pendingTalkRoomToken = target.talkRoomToken
|
||||
@@ -491,6 +444,7 @@ fun AppScaffold(
|
||||
serverUrl = currentSession.serverUrl,
|
||||
userId = userId,
|
||||
config = bottomBarConfig,
|
||||
activeTabKey = activeTab.name,
|
||||
menuOpen = menuOpen,
|
||||
chatsHighlighted = activeTab == AppTab.Talk && !talkInRoom,
|
||||
navBackHighlighted = (activeTab == AppTab.Mail && mailSidebarOpen) ||
|
||||
@@ -507,36 +461,22 @@ fun AppScaffold(
|
||||
else -> Unit
|
||||
}
|
||||
},
|
||||
onCreateClick = {
|
||||
when (activeTab) {
|
||||
AppTab.Files -> {
|
||||
if (F7AppPermissions.missing(context).isNotEmpty()) {
|
||||
onRequestRuntimePermissions { filesUploadRequest++ }
|
||||
} else {
|
||||
filesUploadRequest++
|
||||
}
|
||||
}
|
||||
AppTab.Contacts -> contactsCreateRequest++
|
||||
AppTab.Tasks -> tasksCreateRequest++
|
||||
AppTab.Support -> supportCreateRequest++
|
||||
else -> Unit
|
||||
onMailClick = {
|
||||
if (activeTab != AppTab.Mail) {
|
||||
pushTabHistory(activeTab)
|
||||
activeTab = AppTab.Mail
|
||||
}
|
||||
},
|
||||
onProfileClick = {
|
||||
menuOpen = false
|
||||
profileOpen = true
|
||||
onCardsClick = {
|
||||
if (activeTab != AppTab.Deck) {
|
||||
pushTabHistory(activeTab)
|
||||
activeTab = AppTab.Deck
|
||||
}
|
||||
},
|
||||
onNotificationsClick = {
|
||||
menuOpen = false
|
||||
hasNotificationBadge = false
|
||||
notificationsOpen = true
|
||||
},
|
||||
onSettingsClick = {
|
||||
when (activeTab) {
|
||||
AppTab.Mail -> mailSettingsOpen = true
|
||||
AppTab.Calendar -> calendarSettingsRequest++
|
||||
AppTab.Files -> filesSettingsOpen = true
|
||||
else -> Unit
|
||||
onConferencesClick = {
|
||||
if (activeTab != AppTab.Talk) {
|
||||
pushTabHistory(activeTab)
|
||||
activeTab = AppTab.Talk
|
||||
}
|
||||
},
|
||||
onMenuClick = { menuOpen = !menuOpen },
|
||||
@@ -645,12 +585,35 @@ fun AppScaffold(
|
||||
onItemClick = { index ->
|
||||
val item = appMenuItemsList.getOrNull(index) ?: return@F7AppMenuSheet
|
||||
val external = item.externalUrl
|
||||
if (!external.isNullOrBlank()) {
|
||||
val label = item.label.trim()
|
||||
when {
|
||||
// Профиль — нативная панель вместо веб-ЛК forbion
|
||||
label.equals("Личный кабинет", ignoreCase = true) -> {
|
||||
menuOpen = false
|
||||
profileOpen = true
|
||||
}
|
||||
// Нативные пункты по дизайну меню
|
||||
label.equals("Уведомления", ignoreCase = true) -> {
|
||||
menuOpen = false
|
||||
hasNotificationBadge = false
|
||||
notificationsOpen = true
|
||||
}
|
||||
label.equals("Настройки", ignoreCase = true) -> {
|
||||
menuOpen = false
|
||||
when (activeTab) {
|
||||
AppTab.Mail -> mailSettingsOpen = true
|
||||
AppTab.Calendar -> calendarSettingsRequest++
|
||||
AppTab.Files -> filesSettingsOpen = true
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
!external.isNullOrBlank() -> {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(external)))
|
||||
}
|
||||
menuOpen = false
|
||||
} else {
|
||||
}
|
||||
else -> {
|
||||
appTabFromMenuIndex(index)?.let { tab ->
|
||||
if (tab != activeTab) {
|
||||
pushTabHistory(activeTab)
|
||||
@@ -659,6 +622,7 @@ fun AppScaffold(
|
||||
}
|
||||
menuOpen = false
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
ProfileSheet(
|
||||
@@ -685,254 +649,3 @@ fun AppScaffold(
|
||||
}
|
||||
|
||||
private const val PERMISSIONS_PROMPTED_KEY = "runtime_permissions_prompted_v2"
|
||||
@Composable
|
||||
private fun LoginScreen(onLogin: (AuthSession) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val serverFocus = remember { FocusRequester() }
|
||||
val usernameFocus = remember { FocusRequester() }
|
||||
val passwordFocus = remember { FocusRequester() }
|
||||
var serverUrl by rememberSaveable { mutableStateOf(BuildConfig.DEFAULT_SERVER_URL) }
|
||||
var username by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
var trustAllCerts by rememberSaveable { mutableStateOf(false) }
|
||||
var loading by rememberSaveable { mutableStateOf(false) }
|
||||
var error by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
|
||||
val qrLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
|
||||
val qrData = result.data?.getStringExtra(F7QrScannerActivity.RESULT_EXTRA)
|
||||
?: return@rememberLauncherForActivityResult
|
||||
scope.launch {
|
||||
loading = true
|
||||
error = null
|
||||
val loginResult = LoginFlowClient.completeQrLogin(qrData, trustAllCerts)
|
||||
if (loginResult == null) {
|
||||
error = if (qrData.contains("/login/v2/flow/")) {
|
||||
"Для входа в браузер откройте профиль в приложении и выберите «Сканировать QR браузера»"
|
||||
} else {
|
||||
"Не удалось распознать QR-код"
|
||||
}
|
||||
loading = false
|
||||
return@launch
|
||||
}
|
||||
val newSession = AuthSession(
|
||||
serverUrl = normalizeServerUrl(loginResult.serverUrl),
|
||||
username = loginResult.username,
|
||||
appPassword = loginResult.appPassword,
|
||||
trustAllCerts = trustAllCerts,
|
||||
)
|
||||
AuthVerifier.verify(newSession)
|
||||
.onSuccess { verified -> onLogin(verified) }
|
||||
.onFailure { error = it.message ?: "Ошибка входа по QR" }
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
qrLauncher.launch(F7QrScannerActivity.intent(context))
|
||||
}
|
||||
}
|
||||
|
||||
fun launchQrScan() {
|
||||
if (androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
android.Manifest.permission.CAMERA,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
qrLauncher.launch(F7QrScannerActivity.intent(context))
|
||||
} else {
|
||||
cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
fun submitLogin() {
|
||||
if (loading || serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
|
||||
return
|
||||
}
|
||||
val newSession = AuthSession(
|
||||
serverUrl = normalizeServerUrl(serverUrl),
|
||||
username = username.trim(),
|
||||
appPassword = password,
|
||||
trustAllCerts = trustAllCerts,
|
||||
)
|
||||
scope.launch {
|
||||
loading = true
|
||||
error = null
|
||||
AuthVerifier.verify(newSession)
|
||||
.onSuccess { verified -> onLogin(verified) }
|
||||
.onFailure { error = it.message ?: "Ошибка входа" }
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
val logoUrl = remember(serverUrl) {
|
||||
"${normalizeServerUrl(serverUrl).trimEnd('/')}/themes/forbion/images/login/big-forbion.svg"
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.f7SafeTopInsets()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 24.dp),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 440.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = logoUrl,
|
||||
contentDescription = "Forbion",
|
||||
modifier = Modifier
|
||||
.width(300.dp)
|
||||
.height(70.dp)
|
||||
.padding(bottom = 48.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = F7Colors.Background,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
"Вход",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
lineHeight = 20.sp,
|
||||
color = F7Colors.TextPrimary,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
Text(
|
||||
"Используйте тот же пароль, что и для входа в веб-интерфейс. " +
|
||||
"Если включена двухфакторная аутентификация — нужен пароль приложения.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
F7OutlinedField(
|
||||
value = serverUrl,
|
||||
onValueChange = { serverUrl = it },
|
||||
label = "Адрес сервера",
|
||||
modifier = Modifier
|
||||
.focusRequester(serverFocus)
|
||||
.focusProperties { next = usernameFocus },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Next,
|
||||
autoCorrectEnabled = false,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { usernameFocus.requestFocus() },
|
||||
),
|
||||
onEnter = { usernameFocus.requestFocus() },
|
||||
)
|
||||
F7OutlinedField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = "Имя пользователя",
|
||||
modifier = Modifier
|
||||
.focusRequester(usernameFocus)
|
||||
.focusProperties { next = passwordFocus },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next,
|
||||
autoCorrectEnabled = false,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { passwordFocus.requestFocus() },
|
||||
),
|
||||
onEnter = { passwordFocus.requestFocus() },
|
||||
)
|
||||
F7OutlinedField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = "Пароль",
|
||||
modifier = Modifier.focusRequester(passwordFocus),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done,
|
||||
autoCorrectEnabled = false,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
submitLogin()
|
||||
},
|
||||
onGo = {
|
||||
focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
submitLogin()
|
||||
},
|
||||
),
|
||||
onEnter = {
|
||||
focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
submitLogin()
|
||||
},
|
||||
)
|
||||
// Чекбокс trust-all показываем только в debug-сборках: в release он
|
||||
// всё равно игнорируется на уровне сети/WebView (см. UnsafeSsl).
|
||||
if (BuildConfig.DEBUG) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = trustAllCerts,
|
||||
onCheckedChange = { trustAllCerts = it },
|
||||
)
|
||||
Text(
|
||||
text = "Доверять сертификату (dev)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
F7PrimaryButton(
|
||||
text = if (loading) "…" else "Войти",
|
||||
onClick = { submitLogin() },
|
||||
enabled = !loading && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
F7TextButton(
|
||||
text = "Сканировать QR для входа",
|
||||
onClick = { launchQrScan() },
|
||||
enabled = !loading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (loading) {
|
||||
CircularProgressIndicator(color = F7Colors.Primary)
|
||||
}
|
||||
if (error != null) {
|
||||
Text(text = error ?: "", color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
|
||||
F7TextButton(
|
||||
text = "Очистить",
|
||||
onClick = {
|
||||
error = null
|
||||
password = ""
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,38 @@
|
||||
package ru.forbion.f7cloud.mobile.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.Group
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material.icons.filled.TaskAlt
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -21,22 +42,30 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7FloatingPanel
|
||||
import ru.forbion.f7cloud.core.designsystem.F7NotificationRow
|
||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7TextButton
|
||||
import ru.forbion.f7cloud.core.designsystem.formatNotificationRelativeTime
|
||||
import ru.forbion.f7cloud.core.network.F7Notification
|
||||
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||
import ru.forbion.f7cloud.core.network.NotificationsRepository
|
||||
import ru.forbion.f7cloud.core.network.UnauthorizedException
|
||||
import ru.forbion.f7cloud.core.network.applyOcsJson
|
||||
|
||||
private fun themeHeaderAsset(serverUrl: String, fileName: String): String =
|
||||
"${serverUrl.trimEnd('/')}/themes/forbion/images/header/$fileName"
|
||||
@@ -54,46 +83,178 @@ fun ProfileSheet(
|
||||
onLogout: () -> Unit,
|
||||
onScanBrowserQr: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val base = session.serverUrl.trimEnd('/')
|
||||
val userId = session.davUserId ?: session.username
|
||||
var displayName by remember(session.username) { mutableStateOf(userId) }
|
||||
var aboutOpen by remember { mutableStateOf(false) }
|
||||
|
||||
// Отображаемое имя тянем с сервера (OCS), пока — логин
|
||||
LaunchedEffect(visible, session.username) {
|
||||
if (visible) displayName = fetchDisplayName(session).ifBlank { userId }
|
||||
}
|
||||
|
||||
fun openWeb(path: String) {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("$base$path")))
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
F7FloatingPanel(
|
||||
visible = visible,
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
Text("Профиль", style = MaterialTheme.typography.titleMedium, color = F7Colors.TextPrimary)
|
||||
// Шапка: аватар-инициал + имя/«Открыть профиль» + кнопка QR
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(F7Colors.PrimaryLight),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = session.username,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
Text(
|
||||
text = session.serverUrl,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
session.davUserId?.let { davId ->
|
||||
Text(
|
||||
text = "ID: $davId",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = F7Colors.Primary,
|
||||
)
|
||||
}
|
||||
F7PrimaryButton(
|
||||
text = "Сканировать QR браузера",
|
||||
onClick = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { openWeb("/u/$userId") }
|
||||
.padding(vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
displayName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = F7Colors.TextPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
"Открыть профиль",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.Primary,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(F7Colors.Grey2)
|
||||
.clickable {
|
||||
onDismiss()
|
||||
onScanBrowserQr()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.QrCodeScanner,
|
||||
contentDescription = "Сканировать QR браузера",
|
||||
tint = F7Colors.TextSecondary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
F7PrimaryButton(
|
||||
text = "Выйти",
|
||||
onClick = {
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
HorizontalDivider(color = F7Colors.Border.copy(alpha = 0.5f))
|
||||
Spacer(Modifier.height(4.dp))
|
||||
ProfileMenuRow(Icons.Filled.TaskAlt, "Установить статус") { openWeb("/settings/user") }
|
||||
ProfileMenuRow(Icons.Filled.Person, "Личные настройки") { openWeb("/settings/user") }
|
||||
ProfileMenuRow(Icons.Filled.Group, "Учётные записи") { openWeb("/settings/user/security") }
|
||||
ProfileMenuRow(Icons.Outlined.Info, "О программе и что нового") { aboutOpen = true }
|
||||
ProfileMenuRow(
|
||||
icon = Icons.AutoMirrored.Filled.Logout,
|
||||
label = "Выйти",
|
||||
tint = F7Colors.Error,
|
||||
) {
|
||||
onDismiss()
|
||||
onLogout()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
F7TextButton(text = "Закрыть", onClick = onDismiss, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
|
||||
if (aboutOpen) {
|
||||
F7AboutDialog(onDismiss = { aboutOpen = false })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileMenuRow(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
tint: Color = F7Colors.TextPrimary,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 12.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge, color = tint)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun F7AboutDialog(onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val version = remember {
|
||||
runCatching {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||
}.getOrNull() ?: "—"
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Закрыть") }
|
||||
},
|
||||
title = { Text("F7cloud Mobile") },
|
||||
text = {
|
||||
Column {
|
||||
Text("Версия $version", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Нативный клиент F7cloud: почта, файлы, календарь, контакты, задачи, конференции, поддержка.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Отображаемое имя пользователя из OCS (fallback — пусто). */
|
||||
private suspend fun fetchDisplayName(session: AuthSession): String = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val client = NetworkFactory.newAuthedClient(
|
||||
session.username,
|
||||
session.appPassword,
|
||||
session.trustAllCerts,
|
||||
)
|
||||
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/user?format=json"
|
||||
val request = Request.Builder().url(url).applyOcsJson().get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
val body = response.body?.string().orEmpty()
|
||||
JSONObject(body)
|
||||
.optJSONObject("ocs")
|
||||
?.optJSONObject("data")
|
||||
?.optString("displayname")
|
||||
.orEmpty()
|
||||
}
|
||||
}.getOrDefault("")
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
package ru.forbion.f7cloud.mobile.ui
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.auth.AuthVerifier
|
||||
import ru.forbion.f7cloud.core.auth.normalizeServerUrl
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
|
||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7SecureScreen
|
||||
import ru.forbion.f7cloud.core.designsystem.F7TextButton
|
||||
import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets
|
||||
import ru.forbion.f7cloud.core.network.LoginFlowClient
|
||||
import ru.forbion.f7cloud.mobile.BuildConfig
|
||||
import ru.forbion.f7cloud.mobile.qr.F7QrScannerActivity
|
||||
|
||||
@Composable
|
||||
internal fun LoginScreen(onLogin: (AuthSession) -> Unit) {
|
||||
F7SecureScreen() // ввод пароля — не в скриншотах/recents
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val serverFocus = remember { FocusRequester() }
|
||||
val usernameFocus = remember { FocusRequester() }
|
||||
val passwordFocus = remember { FocusRequester() }
|
||||
var serverUrl by rememberSaveable { mutableStateOf(BuildConfig.DEFAULT_SERVER_URL) }
|
||||
var username by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
var trustAllCerts by rememberSaveable { mutableStateOf(false) }
|
||||
var loading by rememberSaveable { mutableStateOf(false) }
|
||||
var error by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
|
||||
val qrLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
|
||||
val qrData = result.data?.getStringExtra(F7QrScannerActivity.RESULT_EXTRA)
|
||||
?: return@rememberLauncherForActivityResult
|
||||
scope.launch {
|
||||
loading = true
|
||||
error = null
|
||||
val loginResult = LoginFlowClient.completeQrLogin(qrData, trustAllCerts)
|
||||
if (loginResult == null) {
|
||||
error = if (qrData.contains("/login/v2/flow/")) {
|
||||
"Для входа в браузер откройте профиль в приложении и выберите «Сканировать QR браузера»"
|
||||
} else {
|
||||
"Не удалось распознать QR-код"
|
||||
}
|
||||
loading = false
|
||||
return@launch
|
||||
}
|
||||
val newSession = AuthSession(
|
||||
serverUrl = normalizeServerUrl(loginResult.serverUrl),
|
||||
username = loginResult.username,
|
||||
appPassword = loginResult.appPassword,
|
||||
trustAllCerts = trustAllCerts,
|
||||
)
|
||||
AuthVerifier.verify(newSession)
|
||||
.onSuccess { verified -> onLogin(verified) }
|
||||
.onFailure { error = it.message ?: "Ошибка входа по QR" }
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
qrLauncher.launch(F7QrScannerActivity.intent(context))
|
||||
}
|
||||
}
|
||||
|
||||
fun launchQrScan() {
|
||||
if (androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
android.Manifest.permission.CAMERA,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
qrLauncher.launch(F7QrScannerActivity.intent(context))
|
||||
} else {
|
||||
cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
fun submitLogin() {
|
||||
if (loading || serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
|
||||
return
|
||||
}
|
||||
val newSession = AuthSession(
|
||||
serverUrl = normalizeServerUrl(serverUrl),
|
||||
username = username.trim(),
|
||||
appPassword = password,
|
||||
trustAllCerts = trustAllCerts,
|
||||
)
|
||||
scope.launch {
|
||||
loading = true
|
||||
error = null
|
||||
AuthVerifier.verify(newSession)
|
||||
.onSuccess { verified -> onLogin(verified) }
|
||||
.onFailure { error = it.message ?: "Ошибка входа" }
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// Логотип — локальный (assets/login/big-forbion.svg из живой темы): виден офлайн и до
|
||||
// ввода адреса сервера.
|
||||
val logoUrl = "file:///android_asset/login/big-forbion.svg"
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// Живая тема логина: фон страницы --backgroud-color-main-darkgray
|
||||
.background(Color(0xFFE5EFE8))
|
||||
.f7SafeTopInsets()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 24.dp),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 440.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = logoUrl,
|
||||
contentDescription = "Forbion",
|
||||
modifier = Modifier
|
||||
.width(300.dp)
|
||||
.height(70.dp)
|
||||
.padding(bottom = 48.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = F7Colors.Background,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
"Вход",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
lineHeight = 20.sp,
|
||||
color = F7Colors.TextPrimary,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
Text(
|
||||
"Используйте тот же пароль, что и для входа в веб-интерфейс. " +
|
||||
"Если включена двухфакторная аутентификация — нужен пароль приложения.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
F7OutlinedField(
|
||||
value = serverUrl,
|
||||
onValueChange = { serverUrl = it },
|
||||
label = "Адрес сервера",
|
||||
modifier = Modifier
|
||||
.focusRequester(serverFocus)
|
||||
.focusProperties { next = usernameFocus },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Next,
|
||||
autoCorrectEnabled = false,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { usernameFocus.requestFocus() },
|
||||
),
|
||||
onEnter = { usernameFocus.requestFocus() },
|
||||
)
|
||||
F7OutlinedField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = "Имя пользователя",
|
||||
modifier = Modifier
|
||||
.focusRequester(usernameFocus)
|
||||
.focusProperties { next = passwordFocus },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next,
|
||||
autoCorrectEnabled = false,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { passwordFocus.requestFocus() },
|
||||
),
|
||||
onEnter = { passwordFocus.requestFocus() },
|
||||
)
|
||||
F7OutlinedField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = "Пароль",
|
||||
modifier = Modifier.focusRequester(passwordFocus),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done,
|
||||
autoCorrectEnabled = false,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
submitLogin()
|
||||
},
|
||||
onGo = {
|
||||
focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
submitLogin()
|
||||
},
|
||||
),
|
||||
onEnter = {
|
||||
focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
submitLogin()
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
F7PrimaryButton(
|
||||
text = if (loading) "…" else "Войти",
|
||||
onClick = { submitLogin() },
|
||||
enabled = !loading && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
// Живая тема: вторичная кнопка-ссылка 40dp, фон #F0F1F4, r8, текст 14 чёрный
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color(0xFFF0F1F4))
|
||||
.clickable(enabled = !loading, onClick = { launchQrScan() }),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
"Сканировать QR для входа",
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
}
|
||||
if (loading) {
|
||||
CircularProgressIndicator(color = F7Colors.Primary)
|
||||
}
|
||||
if (error != null) {
|
||||
Text(text = error ?: "", color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
|
||||
F7TextButton(
|
||||
text = "Очистить",
|
||||
onClick = {
|
||||
error = null
|
||||
password = ""
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package ru.forbion.f7cloud.mobile.ui
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.forbion.f7cloud.core.auth.AppPasswordRevoker
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.auth.AuthStore
|
||||
import ru.forbion.f7cloud.feature.files.OfficeWarmup
|
||||
import ru.forbion.f7cloud.mobile.OfficeWebViewPool
|
||||
|
||||
/**
|
||||
* Держит session/auth-жизненный цикл верхнего уровня (вынесено из AppScaffold).
|
||||
* В ViewModel сессия переживает пересоздание Activity (поворот и т.п.) — раньше
|
||||
* жила в remember и перечитывалась из AuthStore.
|
||||
*/
|
||||
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private val authStore = AuthStore(app)
|
||||
private val _session = MutableStateFlow(authStore.load())
|
||||
val session: StateFlow<AuthSession?> = _session.asStateFlow()
|
||||
|
||||
fun login(newSession: AuthSession) {
|
||||
authStore.save(newSession)
|
||||
_session.value = newSession
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
// Best-effort ревокация app password на сервере ДО очистки локальной сессии.
|
||||
_session.value?.let { current ->
|
||||
viewModelScope.launch { runCatching { AppPasswordRevoker.revoke(current) } }
|
||||
}
|
||||
OfficeWarmup.clear()
|
||||
OfficeWebViewPool.dispose()
|
||||
authStore.clear()
|
||||
_session.value = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Логотип F7 для сплэша: вектор из темы forbion (logo-header.svg) вместо растянутого
|
||||
растра лаунчер-иконки (мыло). Глиф (35×32, контент x 2.26–33.53 / y 5.01–25.99)
|
||||
отцентрирован в центральных 2/3 квадрата 48 — безопасная зона круглой маски сплэша. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
<group
|
||||
android:scaleX="1.0233"
|
||||
android:scaleY="1.0233"
|
||||
android:translateX="5.69"
|
||||
android:translateY="8.14">
|
||||
<path
|
||||
android:fillColor="#151515"
|
||||
android:pathData="M8.33398 18.9808V25.9711C8.33398 25.9761 8.33209 25.981 8.32873 25.9845C8.32537 25.9881 8.3208 25.9901 8.31605 25.9901H2.27416C2.2718 25.9901 2.26947 25.9896 2.2673 25.9887C2.26512 25.9877 2.26314 25.9863 2.26148 25.9845C2.25812 25.981 2.25623 25.9761 2.25623 25.9711V5.02973C2.25623 5.02724 2.25669 5.02477 2.25759 5.02246C2.25849 5.02016 2.25981 5.01807 2.26148 5.0163C2.26484 5.01274 2.2694 5.01074 2.27416 5.01074H19.7382C19.7415 5.01074 19.7447 5.01166 19.7475 5.01343C19.7503 5.0152 19.7526 5.01773 19.7541 5.02076C19.7556 5.0238 19.7564 5.02722 19.7562 5.03065C19.756 5.03409 19.755 5.03742 19.7532 5.04028L16.2595 10.7647C16.2578 10.7674 16.2555 10.7696 16.2528 10.771C16.2501 10.7725 16.2471 10.7732 16.244 10.7731H8.35192C8.34716 10.7731 8.3426 10.7751 8.33924 10.7787C8.33587 10.7823 8.33398 10.7871 8.33398 10.7921V13.1672C8.33398 13.1723 8.33587 13.1771 8.33924 13.1807C8.3426 13.1842 8.34716 13.1862 8.35192 13.1862H14.9303C14.9328 13.1862 14.9352 13.1867 14.9374 13.1878C14.9397 13.1888 14.9417 13.1903 14.9434 13.1922C14.9451 13.194 14.9464 13.1963 14.9472 13.1987C14.948 13.2011 14.9484 13.2037 14.9483 13.2063L14.6937 18.9439C14.6935 18.9487 14.6915 18.9533 14.6881 18.9566C14.6848 18.96 14.6804 18.9618 14.6758 18.9618H8.35192C8.34716 18.9618 8.3426 18.9638 8.33924 18.9674C8.33587 18.9709 8.33398 18.9758 8.33398 18.9808Z" />
|
||||
<path
|
||||
android:fillColor="#70B62B"
|
||||
android:pathData="M26.2675 10.8048C26.2877 10.7837 26.2836 10.7731 26.255 10.7731H17.7975C17.7762 10.7731 17.7714 10.7638 17.783 10.7452L21.3221 5.03395C21.3317 5.01848 21.3453 5.01074 21.3629 5.01074H33.5155C33.5188 5.01074 33.5219 5.01213 33.5243 5.0146C33.5266 5.01708 33.5279 5.02043 33.5279 5.02393V10.7494C33.5279 10.7673 33.5218 10.7826 33.5095 10.7953C31.0839 13.3392 28.8027 16.2518 27.1169 19.4365C26.3193 20.944 25.6677 22.6434 25.4834 24.3571C25.4296 24.8592 25.4445 25.4268 25.445 25.9632C25.445 25.9811 25.4367 25.9901 25.4201 25.9901H18.4337C18.4164 25.9901 18.4072 25.981 18.4063 25.9627C18.3571 24.9535 18.4315 23.9955 18.6294 23.0886C19.0753 21.0468 20.0567 19.0346 21.1572 17.2724C22.6248 14.9221 24.3809 12.7806 26.2675 10.8048Z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Сплэш при холодном старте (androidx core-splashscreen): фон в цвет
|
||||
F7Colors.Background + векторный логотип F7 (лаунчер-иконка — растянутый
|
||||
растр, мылится на сплэше); после — прежняя тема. -->
|
||||
<style name="Theme.F7.Splash" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">#FBFBFB</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/f7_splash_logo</item>
|
||||
<item name="postSplashScreenTheme">@android:style/Theme.Material.Light.NoActionBar</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -8,6 +8,23 @@ plugins {
|
||||
id 'org.jetbrains.kotlin.plugin.parcelize' version '2.3.0' apply false
|
||||
id 'com.google.devtools.ksp' version '2.3.4' apply false
|
||||
id 'com.google.gms.google-services' version '4.4.2' apply false
|
||||
id 'io.gitlab.arturbosch.detekt' version '1.23.8' apply false
|
||||
}
|
||||
|
||||
ext.kotlinVersion = '2.3.0'
|
||||
|
||||
// Detekt на НАШИХ модулях (vendor-форк talk-android не линтуем — чужой код).
|
||||
subprojects {
|
||||
if (path != ':vendor:talk-app') {
|
||||
apply plugin: 'io.gitlab.arturbosch.detekt'
|
||||
detekt {
|
||||
buildUponDefaultConfig = true
|
||||
parallel = true
|
||||
config.setFrom(rootProject.file('config/detekt/detekt.yml'))
|
||||
baseline = file("$projectDir/detekt-baseline.xml")
|
||||
}
|
||||
dependencies {
|
||||
detektPlugins 'io.gitlab.arturbosch.detekt:detekt-formatting:1.23.8'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Detekt — статанализ + форматирование (formatting-ruleset = обёртка ktlint).
|
||||
# buildUponDefaultConfig=true в build.gradle → это дополняет дефолты detekt.
|
||||
# Подход адаптации на существующий код: baseline фиксирует текущие замечания,
|
||||
# сборка падает только на НОВЫХ. Постепенно расчищаем baseline.
|
||||
|
||||
build:
|
||||
maxIssues: 0 # с baseline: только новые замечания валят
|
||||
|
||||
complexity:
|
||||
LongMethod:
|
||||
active: true
|
||||
threshold: 120 # у нас есть большие composable — не хотим шум сверх меры
|
||||
LongParameterList:
|
||||
active: false # Compose-функции с многими колбэками — норма
|
||||
TooManyFunctions:
|
||||
active: false
|
||||
CyclomaticComplexMethod:
|
||||
threshold: 25
|
||||
|
||||
style:
|
||||
MaxLineLength:
|
||||
maxLineLength: 140
|
||||
MagicNumber:
|
||||
active: false # в UI-коде много dp/цветов — отдельная задача (дизайн-токены)
|
||||
ReturnCount:
|
||||
active: false
|
||||
UnusedPrivateMember:
|
||||
active: true
|
||||
|
||||
exceptions:
|
||||
TooGenericExceptionCaught:
|
||||
active: false # много catch(Throwable) в фоновых путях — осознанно
|
||||
SwallowedException:
|
||||
active: false
|
||||
|
||||
naming:
|
||||
FunctionNaming:
|
||||
active: false # @Composable функции с Заглавной — стандарт Compose
|
||||
@@ -21,5 +21,5 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation project(':core:network')
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||
implementation libs.coroutines.android
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" ?>
|
||||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<ID>ComplexCondition:AuthErrors.kt$this is SSLPeerUnverifiedException || this is SSLHandshakeException || this is CertPathValidatorException || this is SSLException || msg.contains("not verified", ignoreCase = true) || msg.contains("CertificateException", ignoreCase = true) || msg.contains("Trust anchor", ignoreCase = true)</ID>
|
||||
<ID>NoUnusedImports:OcsUserResolver.kt$ru.forbion.f7cloud.core.auth.OcsUserResolver.kt</ID>
|
||||
</CurrentIssues>
|
||||
</SmellBaseline>
|
||||
@@ -66,27 +66,12 @@ class AppLockStore(context: Context) {
|
||||
|
||||
fun isBiometricOnly(): Boolean = prefs.getBoolean(KEY_BIOMETRIC_ONLY, false)
|
||||
|
||||
/**
|
||||
* Биометрическая привязка app password (auth-bound ключ + кэш в памяти). ВЫКЛ по умолчанию.
|
||||
* Разрешена только при биометрии-без-PIN: единственный путь разблокировки — биометрия, значит
|
||||
* при каждой разблокировке гарантированно прогревается [AppPasswordHolder] через CryptoObject.
|
||||
* Меняет фоновое поведение (звонки/push после ребута до первой разблокировки не работают) —
|
||||
* включать через настройку только после подтверждения владельцем.
|
||||
*/
|
||||
fun isPasswordBindingEnabled(): Boolean =
|
||||
prefs.getBoolean(KEY_BIND_PASSWORD, false) && isEnabled() && isBiometricOnly()
|
||||
|
||||
fun setPasswordBinding(enabled: Boolean) {
|
||||
prefs.edit().putBoolean(KEY_BIND_PASSWORD, enabled).apply()
|
||||
}
|
||||
|
||||
fun disable() {
|
||||
prefs.edit()
|
||||
.putBoolean(KEY_ENABLED, false)
|
||||
.remove(KEY_PIN_HASH)
|
||||
.putBoolean(KEY_BIOMETRIC, false)
|
||||
.putBoolean(KEY_BIOMETRIC_ONLY, false)
|
||||
.putBoolean(KEY_BIND_PASSWORD, false)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -110,7 +95,6 @@ class AppLockStore(context: Context) {
|
||||
private const val KEY_BIOMETRIC_ONLY = "biometric_only"
|
||||
private const val KEY_PIN_HASH = "pin_hash"
|
||||
private const val KEY_SETUP_OFFERED = "setup_offered"
|
||||
private const val KEY_BIND_PASSWORD = "bind_password"
|
||||
|
||||
@Volatile
|
||||
private var coldStartPending = true
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package ru.forbion.f7cloud.core.auth
|
||||
|
||||
/**
|
||||
* Process-memory кэш расшифрованного app password для режима биометрической привязки
|
||||
* (см. [KeystoreCrypto] auth-bound ключ и [AppLockStore.isPasswordBindingEnabled]).
|
||||
*
|
||||
* Когда пароль зашифрован ключом, требующим биометрию, его нельзя расшифровать в фоне
|
||||
* (push, входящие звонки, холодный старт) — там некому пройти BiometricPrompt. Поэтому пароль
|
||||
* расшифровывается ОДИН раз при разблокировке приложения (CryptoObject в AppLockGate) и живёт
|
||||
* здесь, в памяти процесса. НИКОГДА не сериализуется на диск.
|
||||
*
|
||||
* «Тёплый» = пароль в памяти (приложение разблокировано после старта/ребута). «Холодный» =
|
||||
* процесс только поднялся или приложение снова заблокировано → фоновые сценарии деградируют
|
||||
* корректно («Разблокируйте приложение, чтобы принимать звонки»).
|
||||
*
|
||||
* При выключенной привязке (дефолт) holder не используется вовсе.
|
||||
*/
|
||||
object AppPasswordHolder {
|
||||
@Volatile
|
||||
private var password: String? = null
|
||||
|
||||
/** True, если пароль расшифрован и лежит в памяти (приложение разблокировано). */
|
||||
val isWarm: Boolean
|
||||
get() = password != null
|
||||
|
||||
/** Кладёт расшифрованный пароль в память (вызывается после успешной биометрии при разблокировке). */
|
||||
fun set(value: String) {
|
||||
password = value
|
||||
}
|
||||
|
||||
/** Возвращает пароль из памяти или null, если «холодно» (не разблокировано в этом процессе). */
|
||||
fun get(): String? = password
|
||||
|
||||
/** Забывает пароль (logout или повторная блокировка приложения). */
|
||||
fun clear() {
|
||||
password = null
|
||||
}
|
||||
}
|
||||
@@ -3,23 +3,14 @@ package ru.forbion.f7cloud.core.auth
|
||||
import android.content.Context
|
||||
|
||||
class AuthStore(context: Context) {
|
||||
private val appContext = context.applicationContext
|
||||
private val prefs = appContext.getSharedPreferences("f7_auth", Context.MODE_PRIVATE)
|
||||
private val lockStore by lazy { AppLockStore(appContext) }
|
||||
private val prefs = context.getSharedPreferences("f7_auth", Context.MODE_PRIVATE)
|
||||
|
||||
fun save(session: AuthSession) {
|
||||
// app password шифруется в покое ключом из AndroidKeystore (см. KeystoreCrypto).
|
||||
// При включённой биометрической привязке — auth-bound ключом (расшифровка требует
|
||||
// биометрии), иначе — обычным AES-256-GCM.
|
||||
val encrypted = if (lockStore.isPasswordBindingEnabled()) {
|
||||
KeystoreCrypto.encryptAuthBound(session.appPassword)
|
||||
} else {
|
||||
KeystoreCrypto.encrypt(session.appPassword)
|
||||
}
|
||||
prefs.edit()
|
||||
.putString("server_url", session.serverUrl.trimEnd('/'))
|
||||
.putString("username", session.username.trim())
|
||||
.putString("app_password", encrypted)
|
||||
// app password шифруется в покое ключом из AndroidKeystore (см. KeystoreCrypto)
|
||||
.putString("app_password", KeystoreCrypto.encrypt(session.appPassword))
|
||||
.putBoolean("trust_all_certs", session.trustAllCerts)
|
||||
.putString("dav_user_id", session.davUserId)
|
||||
.apply()
|
||||
@@ -30,19 +21,13 @@ class AuthStore(context: Context) {
|
||||
val username = prefs.getString("username", null) ?: return null
|
||||
val storedPassword = prefs.getString("app_password", null) ?: return null
|
||||
|
||||
val appPassword = when {
|
||||
KeystoreCrypto.isAuthBound(storedPassword) -> {
|
||||
// Привязано к биометрии: расшифровать в фоне нельзя. Берём из памяти, если
|
||||
// приложение уже разблокировано в этом процессе; иначе «холодно» → сессии нет,
|
||||
// фоновый вызывающий деградирует (`?: return`). Прогрев — в AppLockGate.
|
||||
AppPasswordHolder.get() ?: return null
|
||||
}
|
||||
KeystoreCrypto.isEncrypted(storedPassword) -> {
|
||||
val appPassword = if (KeystoreCrypto.isEncrypted(storedPassword)) {
|
||||
// Расшифровка может упасть, если Keystore-ключ инвалидирован (сброс учётных
|
||||
// данных устройства) — трактуем как «сессии нет», пользователь войдёт заново.
|
||||
runCatching { KeystoreCrypto.decrypt(storedPassword) }.getOrNull() ?: return null
|
||||
}
|
||||
else -> storedPassword // Legacy plaintext (до шифрования) — используем и МИГРИРУЕМ ниже.
|
||||
} else {
|
||||
// Legacy: пароль сохранён plaintext (до шифрования) — используем и МИГРИРУЕМ ниже.
|
||||
storedPassword
|
||||
}
|
||||
|
||||
val session = AuthSession(
|
||||
@@ -52,43 +37,13 @@ class AuthStore(context: Context) {
|
||||
trustAllCerts = prefs.getBoolean("trust_all_certs", false),
|
||||
davUserId = prefs.getString("dav_user_id", null),
|
||||
)
|
||||
if (!KeystoreCrypto.isEncrypted(storedPassword) && !KeystoreCrypto.isAuthBound(storedPassword)) {
|
||||
if (!KeystoreCrypto.isEncrypted(storedPassword)) {
|
||||
runCatching { save(session) } // одноразовая миграция plaintext → ciphertext
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Метаданные сессии для гейта блокировки: возвращает сессию даже когда пароль привязан и
|
||||
* ещё не расшифрован (поле [AuthSession.appPassword] тогда пустое). null — только если входа
|
||||
* нет вовсе. Отличает «залогинен, но заблокирован» от «не залогинен» для экрана блокировки.
|
||||
*/
|
||||
fun loadMetadata(): AuthSession? {
|
||||
val serverUrl = prefs.getString("server_url", null) ?: return null
|
||||
val username = prefs.getString("username", null) ?: return null
|
||||
prefs.getString("app_password", null) ?: return null
|
||||
return load() ?: AuthSession(
|
||||
serverUrl = serverUrl,
|
||||
username = username,
|
||||
appPassword = "",
|
||||
trustAllCerts = prefs.getBoolean("trust_all_certs", false),
|
||||
davUserId = prefs.getString("dav_user_id", null),
|
||||
)
|
||||
}
|
||||
|
||||
/** Пароль привязан к биометрии, но ещё не расшифрован в память (залогинен, но заблокирован). */
|
||||
fun isPasswordLocked(): Boolean {
|
||||
val stored = prefs.getString("app_password", null) ?: return false
|
||||
return KeystoreCrypto.isAuthBound(stored) && !AppPasswordHolder.isWarm
|
||||
}
|
||||
|
||||
/** Сырой auth-bound шифртекст пароля для расшифровки через CryptoObject (или null). */
|
||||
fun boundPasswordCiphertext(): String? =
|
||||
prefs.getString("app_password", null)?.takeIf { KeystoreCrypto.isAuthBound(it) }
|
||||
|
||||
fun clear() {
|
||||
AppPasswordHolder.clear()
|
||||
KeystoreCrypto.deleteBoundKey()
|
||||
prefs.edit().clear().apply()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ object AuthVerifier {
|
||||
session.username,
|
||||
session.appPassword,
|
||||
session.trustAllCerts,
|
||||
// login-верификация: 401 = «неверный пароль» со своим сообщением, НЕ session-expired
|
||||
throwOnUnauthorized = false,
|
||||
)
|
||||
val request = Request.Builder()
|
||||
.url("${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/user?format=json")
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
package ru.forbion.f7cloud.core.auth
|
||||
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.KeyStore
|
||||
import java.security.PrivateKey
|
||||
import java.security.spec.MGF1ParameterSpec
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.OAEPParameterSpec
|
||||
import javax.crypto.spec.PSource
|
||||
|
||||
/**
|
||||
* Шифрование секретов в покое ключом из AndroidKeystore (AES-256-GCM).
|
||||
@@ -39,19 +31,8 @@ object KeystoreCrypto {
|
||||
private const val TAG_BITS = 128
|
||||
const val PREFIX = "v1:"
|
||||
|
||||
// --- Биометрически привязанный (auth-bound) вариант ---------------------------------------
|
||||
// RSA-пара: ПУБЛИЧНЫЙ ключ шифрует без авторизации (можно в фоне — миграция/save после входа),
|
||||
// ПРИВАТНЫЙ требует биометрию (setUserAuthenticationRequired) и расшифровывает ТОЛЬКО через
|
||||
// BiometricPrompt+CryptoObject. Симметричный AES не подошёл бы: там auth нужен и на шифрование.
|
||||
private const val BIO_KEY_ALIAS = "f7_auth_master_bio"
|
||||
private const val RSA_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding"
|
||||
const val PREFIX_BIO = "v2b:"
|
||||
|
||||
fun isEncrypted(value: String?): Boolean = value != null && value.startsWith(PREFIX)
|
||||
|
||||
/** True для шифртекста, привязанного к биометрии (расшифровка требует BiometricPrompt). */
|
||||
fun isAuthBound(value: String?): Boolean = value != null && value.startsWith(PREFIX_BIO)
|
||||
|
||||
/** Шифрует строку; результат — с префиксом [PREFIX]. */
|
||||
fun encrypt(plaintext: String): String {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
@@ -95,87 +76,4 @@ object KeystoreCrypto {
|
||||
)
|
||||
return generator.generateKey()
|
||||
}
|
||||
|
||||
// --- Auth-bound (RSA) ----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Шифрует пароль публичным ключом привязанной пары. Авторизация НЕ требуется (публичный ключ),
|
||||
* поэтому вызывается откуда угодно, включая фон. Результат — с префиксом [PREFIX_BIO].
|
||||
*/
|
||||
fun encryptAuthBound(plaintext: String): String {
|
||||
val cipher = Cipher.getInstance(RSA_TRANSFORMATION)
|
||||
// Публичный ключ из Keystore переупаковываем через дефолтный провайдер: иначе Cipher из
|
||||
// AndroidKeyStore на init может «прицепить» auth-требование и к шифрованию.
|
||||
val publicKey = getOrCreateBioEntry().certificate.publicKey
|
||||
val unrestricted = KeyFactory.getInstance(publicKey.algorithm)
|
||||
.generatePublic(X509EncodedKeySpec(publicKey.encoded))
|
||||
cipher.init(Cipher.ENCRYPT_MODE, unrestricted, oaepParams())
|
||||
val ct = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
|
||||
return PREFIX_BIO + Base64.encodeToString(ct, Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
/**
|
||||
* Готовит Cipher для расшифровки привязанного шифртекста (DECRYPT + приватный ключ). Его нужно
|
||||
* обернуть в `BiometricPrompt.CryptoObject`, показать промпт и на успехе вызвать
|
||||
* [finishBoundDecrypt]. Бросает, если ключ инвалидирован (сменилась биометрия/сброс) —
|
||||
* вызывающий трактует как «нужен повторный вход».
|
||||
*/
|
||||
fun initBoundDecryptCipher(stored: String): Cipher {
|
||||
require(isAuthBound(stored)) { "not an auth-bound value" }
|
||||
val privateKey = getOrCreateBioEntry().privateKey as PrivateKey
|
||||
val cipher = Cipher.getInstance(RSA_TRANSFORMATION)
|
||||
cipher.init(Cipher.DECRYPT_MODE, privateKey, oaepParams())
|
||||
return cipher
|
||||
}
|
||||
|
||||
/**
|
||||
* Завершает расшифровку после успешной биометрии — [cipher] должен прийти из
|
||||
* `result.cryptoObject.cipher`, авторизованного пользователем.
|
||||
*/
|
||||
fun finishBoundDecrypt(cipher: Cipher, stored: String): String {
|
||||
require(isAuthBound(stored)) { "not an auth-bound value" }
|
||||
val ct = Base64.decode(stored.removePrefix(PREFIX_BIO), Base64.NO_WRAP)
|
||||
return String(cipher.doFinal(ct), Charsets.UTF_8)
|
||||
}
|
||||
|
||||
/** Удаляет привязанный ключ (при отключении привязки/повторном входе). */
|
||||
fun deleteBoundKey() {
|
||||
runCatching {
|
||||
KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }.deleteEntry(BIO_KEY_ALIAS)
|
||||
}
|
||||
}
|
||||
|
||||
// OAEP с MGF1-SHA1 задаём явно на обеих сторонах: AndroidKeyStore внутри использует SHA-1 для
|
||||
// MGF1 даже при digest SHA-256 — без этого расшифровка падает несовпадением параметров.
|
||||
private fun oaepParams(): OAEPParameterSpec =
|
||||
OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT)
|
||||
|
||||
private fun getOrCreateBioEntry(): KeyStore.PrivateKeyEntry {
|
||||
val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||
(ks.getEntry(BIO_KEY_ALIAS, null) as? KeyStore.PrivateKeyEntry)?.let { return it }
|
||||
val generator = KeyPairGenerator.getInstance(
|
||||
KeyProperties.KEY_ALGORITHM_RSA,
|
||||
ANDROID_KEYSTORE,
|
||||
)
|
||||
val builder = KeyGenParameterSpec.Builder(
|
||||
BIO_KEY_ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||
)
|
||||
.setKeySize(2048)
|
||||
.setDigests(KeyProperties.DIGEST_SHA256)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP)
|
||||
.setUserAuthenticationRequired(true)
|
||||
// Смена/добавление биометрии инвалидирует ключ → пароль недоступен → повторный вход.
|
||||
.setInvalidatedByBiometricEnrollment(true)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
// Окно валидности 0 = авторизация на КАЖДОЕ использование (через CryptoObject),
|
||||
// только сильная биометрия (Class 3) — обязательна для CryptoObject в Keystore.
|
||||
builder.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
|
||||
}
|
||||
// На API < 30 отсутствие setUserAuthenticationValidityDurationSeconds(>0) уже означает
|
||||
// per-use auth, совместимую с CryptoObject.
|
||||
generator.initialize(builder.build())
|
||||
generator.generateKeyPair()
|
||||
return ks.getEntry(BIO_KEY_ALIAS, null) as KeyStore.PrivateKeyEntry
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,6 @@ object OcsUserResolver {
|
||||
.applyOcsJson()
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) {
|
||||
throw UnauthorizedException()
|
||||
}
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
error("User profile HTTP ${response.code}")
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'ru.forbion.f7cloud.core.data'
|
||||
compileSdk 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk 26
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':core:auth')
|
||||
implementation project(':core:database')
|
||||
implementation project(':core:network')
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
||||
@@ -21,7 +21,7 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api 'androidx.room:room-runtime:2.7.2'
|
||||
implementation 'androidx.room:room-ktx:2.7.2'
|
||||
ksp 'androidx.room:room-compiler:2.7.2'
|
||||
api libs.room.runtime
|
||||
implementation libs.room.ktx
|
||||
ksp libs.room.compiler
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" ?>
|
||||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<ID>ArgumentListWrapping:FilesDao.kt$FilesDao$("SELECT * FROM files WHERE serverUrl = :serverUrl AND username = :username ORDER BY isDirectory DESC, name ASC")</ID>
|
||||
<ID>MaximumLineLength:FilesDao.kt$FilesDao$ </ID>
|
||||
</CurrentIssues>
|
||||
</SmellBaseline>
|
||||
@@ -25,11 +25,11 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
|
||||
def composeBom = platform(libs.compose.bom)
|
||||
implementation composeBom
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.foundation:foundation'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'io.coil-kt:coil-compose:2.6.0'
|
||||
implementation 'io.coil-kt:coil-svg:2.6.0'
|
||||
implementation libs.compose.ui
|
||||
implementation libs.compose.foundation
|
||||
implementation libs.compose.material3
|
||||
implementation libs.coil.compose
|
||||
implementation libs.coil.svg
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" ?>
|
||||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<ID>ArgumentListWrapping:F7Typography.kt$(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 14.sp)</ID>
|
||||
<ID>ArgumentListWrapping:F7Typography.kt$(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp)</ID>
|
||||
<ID>ArgumentListWrapping:F7Typography.kt$(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp)</ID>
|
||||
<ID>ArgumentListWrapping:F7Typography.kt$(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 20.sp)</ID>
|
||||
<ID>ArgumentListWrapping:F7Typography.kt$(fontFamily = RalewayFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 28.sp)</ID>
|
||||
<ID>ImportOrdering:F7Components.kt$import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.OutlinedButton import androidx.compose.material3.TextButton import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog</ID>
|
||||
<ID>LoopWithTooManyJumpStatements:F7OverlayNavigation.kt$while</ID>
|
||||
<ID>MatchingDeclarationName:F7MobileBottomBar.kt$F7BottomBarActions</ID>
|
||||
<ID>MatchingDeclarationName:F7OverlayNavigation.kt$F7OverlayNavigationScope</ID>
|
||||
<ID>MaximumLineLength:F7Typography.kt$ </ID>
|
||||
<ID>NoUnusedImports:F7NotificationRow.kt$ru.forbion.f7cloud.core.designsystem.F7NotificationRow.kt</ID>
|
||||
<ID>NoUnusedImports:F7Typography.kt$ru.forbion.f7cloud.core.designsystem.F7Typography.kt</ID>
|
||||
<ID>UnusedParameter:F7AppMenuSheet.kt$onDismiss: () -> Unit</ID>
|
||||
<ID>UnusedParameter:F7MobileBottomBar.kt$userId: String</ID>
|
||||
</CurrentIssues>
|
||||
</SmellBaseline>
|
||||
@@ -9,10 +9,12 @@ import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -23,8 +25,10 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -35,8 +39,10 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -51,11 +57,18 @@ private val BottomBarReserve: Dp = 90.dp
|
||||
private val MenuIconSize = 62.dp
|
||||
private val MenuGridGap = 20.dp
|
||||
|
||||
// Меню-лист выезжает снизу и занимает ~пол-экрана (не весь), по требованию владельца
|
||||
private const val MenuSheetHeightFraction = 0.6f
|
||||
private val MenuSheetShape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
|
||||
|
||||
data class F7AppMenuItem(
|
||||
val label: String,
|
||||
val iconUrl: String,
|
||||
val selected: Boolean,
|
||||
val externalUrl: String? = null,
|
||||
// Локальная иконка вместо серверной (для нативных пунктов — Уведомления/Настройки),
|
||||
// рисуется в круглом бейдже с зелёной обводкой под стиль glass-иконок.
|
||||
val localIcon: ImageVector? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -78,6 +91,26 @@ fun F7AppMenuSheet(
|
||||
}
|
||||
val base = serverUrl.trimEnd('/')
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
// Затемнение над листом — тап закрывает меню (лист занимает только низ экрана)
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(tween(200)),
|
||||
exit = fadeOut(tween(200)),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.18f))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onDismiss,
|
||||
),
|
||||
)
|
||||
}
|
||||
// Сам лист — выезжает снизу вверх на ~пол-экрана, скруглённый верх + тень
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(tween(250)) + slideInVertically(
|
||||
@@ -88,30 +121,45 @@ fun F7AppMenuSheet(
|
||||
animationSpec = tween(300),
|
||||
targetOffsetY = { it },
|
||||
),
|
||||
modifier = modifier,
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(MenuSheetHeightFraction)
|
||||
.padding(bottom = BottomBarReserve)
|
||||
.navigationBarsPadding()
|
||||
.shadow(16.dp, MenuSheetShape, clip = false)
|
||||
.clip(MenuSheetShape)
|
||||
.background(F7Colors.Background)
|
||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp),
|
||||
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
// Ручка-хендл
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(top = 2.dp, bottom = 10.dp)
|
||||
.width(40.dp)
|
||||
.height(4.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(F7Colors.Border),
|
||||
)
|
||||
F7AppMenuSearchField(
|
||||
serverUrl = base,
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp),
|
||||
.padding(bottom = 20.dp),
|
||||
)
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(4),
|
||||
columns = GridCells.Fixed(3),
|
||||
horizontalArrangement = Arrangement.spacedBy(MenuGridGap),
|
||||
verticalArrangement = Arrangement.spacedBy(MenuGridGap),
|
||||
contentPadding = PaddingValues(horizontal = 2.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = filteredItems,
|
||||
@@ -135,6 +183,7 @@ fun F7AppMenuSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -195,17 +244,39 @@ private fun F7AppMenuGridItem(
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(MenuIconSize)
|
||||
.clickable(onClick = onClick),
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val localIcon = item.localIcon
|
||||
if (localIcon != null) {
|
||||
// Нативный пункт: иконка в круглом бейдже с зелёной обводкой (стиль glass)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(MenuIconSize)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White)
|
||||
.border(1.5.dp, F7Colors.Green30, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
localIcon,
|
||||
contentDescription = item.label,
|
||||
tint = F7Colors.Primary,
|
||||
modifier = Modifier.size(MenuIconSize * 0.46f),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = item.iconUrl,
|
||||
contentDescription = item.label,
|
||||
modifier = Modifier.size(MenuIconSize),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.labelLarge.copy(
|
||||
|
||||
@@ -3,10 +3,9 @@ package ru.forbion.f7cloud.core.designsystem
|
||||
enum class F7BottomBarSlot {
|
||||
Chats,
|
||||
NavBack,
|
||||
Create,
|
||||
Profile,
|
||||
Notifications,
|
||||
Settings,
|
||||
SectionMail,
|
||||
SectionCards,
|
||||
SectionConferences,
|
||||
Menu,
|
||||
}
|
||||
|
||||
@@ -16,72 +15,22 @@ data class F7BottomBarConfig(
|
||||
val buttonCount: Int get() = slots.size
|
||||
|
||||
companion object {
|
||||
// Единая навигационная панель по дизайну «Новое меню»:
|
||||
// слева «назад», затем ярлыки-разделы Почта · Карточки · Конференции,
|
||||
// справа — выдвижное меню. Кнопки «чаты» (по просьбе владельца убрана),
|
||||
// профиль/уведомления/настройки/создать в панели нет (меню и шапки экранов).
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun forContext(
|
||||
tabKey: String,
|
||||
talkInRoom: Boolean,
|
||||
): F7BottomBarConfig = when (tabKey) {
|
||||
"Talk" -> if (talkInRoom) {
|
||||
F7BottomBarConfig(listOf(F7BottomBarSlot.Profile, F7BottomBarSlot.Notifications, F7BottomBarSlot.Menu))
|
||||
} else {
|
||||
F7BottomBarConfig(
|
||||
listOf(
|
||||
F7BottomBarSlot.Chats,
|
||||
F7BottomBarSlot.Profile,
|
||||
F7BottomBarSlot.Notifications,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
)
|
||||
}
|
||||
"Files" -> F7BottomBarConfig(
|
||||
): F7BottomBarConfig = F7BottomBarConfig(
|
||||
listOf(
|
||||
F7BottomBarSlot.NavBack,
|
||||
F7BottomBarSlot.Profile,
|
||||
F7BottomBarSlot.Notifications,
|
||||
F7BottomBarSlot.Create,
|
||||
F7BottomBarSlot.Settings,
|
||||
F7BottomBarSlot.SectionMail,
|
||||
F7BottomBarSlot.SectionCards,
|
||||
F7BottomBarSlot.SectionConferences,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
)
|
||||
"Contacts" -> F7BottomBarConfig(
|
||||
listOf(
|
||||
F7BottomBarSlot.Create,
|
||||
F7BottomBarSlot.Profile,
|
||||
F7BottomBarSlot.Notifications,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
)
|
||||
"Tasks" -> F7BottomBarConfig(
|
||||
listOf(
|
||||
F7BottomBarSlot.Create,
|
||||
F7BottomBarSlot.Profile,
|
||||
F7BottomBarSlot.Notifications,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
)
|
||||
"Support" -> F7BottomBarConfig(
|
||||
listOf(
|
||||
F7BottomBarSlot.Create,
|
||||
F7BottomBarSlot.Profile,
|
||||
F7BottomBarSlot.Notifications,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
)
|
||||
"Mail", "Calendar" -> F7BottomBarConfig(
|
||||
listOf(
|
||||
F7BottomBarSlot.NavBack,
|
||||
F7BottomBarSlot.Profile,
|
||||
F7BottomBarSlot.Notifications,
|
||||
F7BottomBarSlot.Settings,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
)
|
||||
else -> F7BottomBarConfig(
|
||||
listOf(
|
||||
F7BottomBarSlot.Profile,
|
||||
F7BottomBarSlot.Notifications,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,25 @@ package ru.forbion.f7cloud.core.designsystem
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Palette from themes/forbion (mobile + f7support), light theme.
|
||||
* Palette from themes/forbion. Base-токены — из Figma «Брендбук» (палитра владельца, 2026-07-08),
|
||||
* семантические — поверх Base. Светлая тема.
|
||||
*/
|
||||
object F7Colors {
|
||||
val Primary = Color(0xFF70B62B)
|
||||
// --- Base palette (Figma «Брендбук») — точные значения ---
|
||||
val Black = Color(0xFF151515) // Base/Black — основной текст
|
||||
val Black2 = Color(0xFF1F1F1F) // Base/Black 2
|
||||
val Grey1 = Color(0xFF808080) // Base/Grey 1 — вторичный текст
|
||||
val Grey2 = Color(0xFFF5F5F5) // Base/Grey 2 — приглушённый фон
|
||||
val Grey3 = Color(0xFFE6E6E6) // Base/Grey 3 — границы
|
||||
val Green = Color(0xFF70B62B) // Base/Green — primary
|
||||
val Green30 = Color(0x4D70B62B) // Base/Green 30%
|
||||
val Green10 = Color(0x1A70B62B) // Base/Green 10%
|
||||
val Yellow = Color(0xFFF6E120) // Base/Yellow (тег «Позже» и т.п.)
|
||||
val Purple = Color(0xFF9747FF) // Base/Purple (тег «Личное» и т.п.)
|
||||
val Red = Color(0xFFFF7A66) // Base/Red — ошибки/удаление (дизайн!)
|
||||
val Red10 = Color(0x1AFF7A66) // Base/Red 10% — фон ошибки
|
||||
|
||||
val Primary = Green
|
||||
val PrimaryHover = Color(0xFF6FAF2E)
|
||||
val PrimaryDark = Color(0xFF5E922B)
|
||||
val PrimaryLight = Color(0xFFECF9DE)
|
||||
@@ -15,19 +30,22 @@ object F7Colors {
|
||||
|
||||
val Background = Color(0xFFFBFBFB)
|
||||
val Surface = Color(0xFFFFFFFF)
|
||||
val SurfaceMuted = Color(0xFFF5F5F5)
|
||||
val SurfaceMuted = Grey2
|
||||
|
||||
val TextPrimary = Color(0xFF151515)
|
||||
val TextSecondary = Color(0xFF808080)
|
||||
val TextPrimary = Black
|
||||
val TextSecondary = Grey1
|
||||
val TextMuted = Color(0xFF8C8C8C)
|
||||
val TextOnPrimary = Color(0xFFFFFFFF)
|
||||
|
||||
val Border = Color(0xFFE6E6E6)
|
||||
val Border = Grey3
|
||||
val BorderLight = Color(0xFFE0E0E0)
|
||||
val SecondaryButtonBg = Color(0xFFFDFDFD)
|
||||
val SecondaryButtonBorder = Color(0xFFE6E6E6)
|
||||
val SecondaryButtonBorder = Grey3
|
||||
|
||||
// Семантика ошибок — по боевой теме forbion (_base.css: --color-element-error /
|
||||
// --color-text-error / --color-error); базовый Red #FF7A66 из брендбука остаётся для тегов.
|
||||
val Error = Color(0xFFD74642)
|
||||
val ErrorText = Color(0xFFD42722)
|
||||
val ErrorBg = Color(0xFFFFE2E2)
|
||||
|
||||
val StatusNew = Color(0xFF2B9AB6)
|
||||
|
||||
@@ -16,8 +16,11 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@@ -43,11 +46,14 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
/**
|
||||
@@ -69,6 +75,46 @@ fun F7ScreenBackground(modifier: Modifier = Modifier, content: @Composable () ->
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Единый экран ошибки (по центру): иконка + сообщение + кнопка «Повторить».
|
||||
* Используется везде, где загрузка упала и данных нет — вместо россыпи красного текста.
|
||||
*/
|
||||
@Composable
|
||||
fun F7ErrorState(
|
||||
message: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(CircleShape)
|
||||
.background(F7Colors.Error.copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("!", color = F7Colors.Error, fontSize = 30.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = F7Colors.TextSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (onRetry != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
F7SecondaryButton(text = "Повторить", onClick = onRetry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun F7ModuleScreen(
|
||||
title: String? = null,
|
||||
@@ -76,6 +122,9 @@ fun F7ModuleScreen(
|
||||
loading: Boolean = false,
|
||||
error: String? = null,
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
// Действие «Повторить» в экране ошибки; по умолчанию — как onRefresh.
|
||||
// Позволяет дать retry, не показывая хедер-кнопку ↻ (когда onRefresh не задан).
|
||||
onErrorRetry: (() -> Unit)? = onRefresh,
|
||||
headerActions: @Composable RowScope.() -> Unit = {},
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
@@ -126,8 +175,15 @@ fun F7ModuleScreen(
|
||||
CircularProgressIndicator(color = F7Colors.Primary)
|
||||
}
|
||||
if (!error.isNullOrBlank()) {
|
||||
Text(text = error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
// Ошибка без данных → центрированный экран с «Повторить»
|
||||
F7ErrorState(
|
||||
message = error,
|
||||
onRetry = onErrorRetry,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -135,6 +191,7 @@ fun F7ModuleScreen(
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -570,7 +627,10 @@ fun F7AppScaffold(
|
||||
content(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.f7SafeTopInsets(),
|
||||
// Отступы под статус-бар (сверху) И системную навигацию (снизу),
|
||||
// иначе на edge-to-edge (targetSdk 36) контент залезает под панели.
|
||||
.f7SafeTopInsets()
|
||||
.f7SafeBottomInsets(),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package ru.forbion.f7cloud.core.designsystem
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Зелёная круглая кнопка «+» — единый элемент создания в шапках экранов
|
||||
* (Почта/Файлы/Контакты/…). Градиент Primary, как у других зелёных кнопок.
|
||||
*/
|
||||
@Composable
|
||||
fun F7CreateButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 44.dp,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.shadow(3.dp, CircleShape, spotColor = F7Colors.Primary.copy(alpha = 0.25f))
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(F7Colors.PrimaryGradientStart, F7Colors.PrimaryGradientEnd),
|
||||
),
|
||||
)
|
||||
.border(1.dp, F7Colors.Primary.copy(alpha = 0.22f), CircleShape)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "+",
|
||||
color = Color.White,
|
||||
fontSize = 26.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -21,21 +21,25 @@ import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
|
||||
data class F7BottomBarActions(
|
||||
val onChatsClick: () -> Unit = {},
|
||||
val onNavBackClick: () -> Unit = {},
|
||||
val onCreateClick: () -> Unit = {},
|
||||
val onProfileClick: () -> Unit = {},
|
||||
val onNotificationsClick: () -> Unit = {},
|
||||
val onSettingsClick: () -> Unit = {},
|
||||
val onMailClick: () -> Unit = {},
|
||||
val onCardsClick: () -> Unit = {},
|
||||
val onConferencesClick: () -> Unit = {},
|
||||
val onMenuClick: () -> Unit = {},
|
||||
)
|
||||
|
||||
private val BottomBarButtonSize = 55.dp
|
||||
private val BottomBarIconSize = 24.dp
|
||||
private val BottomBarIconSize = 34.dp
|
||||
|
||||
// Иконки-разделы (Почта/Карточки/Конференции) — серверные SVG с внутренним
|
||||
// «воздухом», поэтому рендерим крупнее, чтобы заполняли кружок.
|
||||
private val BottomBarSectionIconSize = 46.dp
|
||||
private val BottomBarGap = 8.dp
|
||||
private val BottomBarOuterPaddingH = 6.dp
|
||||
private val BottomBarOuterPaddingV = 6.dp
|
||||
@@ -43,9 +47,6 @@ private val BottomBarButtonShape = RoundedCornerShape(100.dp)
|
||||
private val BottomBarBorderBrush = Brush.linearGradient(
|
||||
listOf(Color(0x33C0FF7B), Color(0x3370B62B)),
|
||||
)
|
||||
private val BottomBarHighlightBrush = Brush.linearGradient(
|
||||
listOf(Color(0x33C0FF7B), Color(0x3370B62B)),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun F7MobileBottomBar(
|
||||
@@ -53,6 +54,7 @@ fun F7MobileBottomBar(
|
||||
userId: String,
|
||||
config: F7BottomBarConfig,
|
||||
actions: F7BottomBarActions,
|
||||
activeTabKey: String = "",
|
||||
menuOpen: Boolean = false,
|
||||
chatsHighlighted: Boolean = false,
|
||||
navBackHighlighted: Boolean = false,
|
||||
@@ -87,36 +89,31 @@ fun F7MobileBottomBar(
|
||||
"$base/themes/forbion/images/header/chat-icon-gray.svg"
|
||||
},
|
||||
contentDescription = "Чаты",
|
||||
highlighted = chatsHighlighted,
|
||||
onClick = actions.onChatsClick,
|
||||
)
|
||||
F7BottomBarSlot.NavBack -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/sidebar-chevron-left.svg",
|
||||
contentDescription = "Папки",
|
||||
highlighted = navBackHighlighted,
|
||||
iconRotation = if (navBackHighlighted) 180f else 0f,
|
||||
onClick = actions.onNavBackClick,
|
||||
)
|
||||
F7BottomBarSlot.Create -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/green-plus.svg",
|
||||
contentDescription = "Создать",
|
||||
onClick = actions.onCreateClick,
|
||||
F7BottomBarSlot.SectionMail -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/mail-header-icon.svg",
|
||||
contentDescription = "Почта",
|
||||
iconSize = BottomBarSectionIconSize,
|
||||
onClick = actions.onMailClick,
|
||||
)
|
||||
F7BottomBarSlot.Profile -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/profile-menu-icon-big.svg",
|
||||
contentDescription = "Профиль",
|
||||
onClick = actions.onProfileClick,
|
||||
F7BottomBarSlot.SectionCards -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/deck-header-icon.svg",
|
||||
contentDescription = "Карточки",
|
||||
iconSize = BottomBarSectionIconSize,
|
||||
onClick = actions.onCardsClick,
|
||||
)
|
||||
F7BottomBarSlot.Notifications -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/not-menu-icon-big.svg",
|
||||
contentDescription = "Уведомления",
|
||||
showBadge = showNotificationBadge,
|
||||
onClick = actions.onNotificationsClick,
|
||||
)
|
||||
F7BottomBarSlot.Settings -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/setting-menu-icon.svg",
|
||||
contentDescription = "Настройки",
|
||||
onClick = actions.onSettingsClick,
|
||||
F7BottomBarSlot.SectionConferences -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/spreed-header-icon.svg",
|
||||
contentDescription = "Конференции",
|
||||
iconSize = BottomBarSectionIconSize,
|
||||
onClick = actions.onConferencesClick,
|
||||
)
|
||||
F7BottomBarSlot.Menu -> F7BottomBarIconSlot(
|
||||
iconUrl = if (menuOpen) {
|
||||
@@ -125,7 +122,7 @@ fun F7MobileBottomBar(
|
||||
"$base/themes/forbion/images/header/menu-burger-gray.svg"
|
||||
},
|
||||
contentDescription = "Меню",
|
||||
highlighted = menuOpen,
|
||||
showBadge = showNotificationBadge,
|
||||
onClick = actions.onMenuClick,
|
||||
)
|
||||
}
|
||||
@@ -138,22 +135,16 @@ private fun F7BottomBarIconSlot(
|
||||
iconUrl: String,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
highlighted: Boolean = false,
|
||||
iconRotation: Float = 0f,
|
||||
showBadge: Boolean = false,
|
||||
iconSize: Dp = BottomBarIconSize,
|
||||
) {
|
||||
val bg = if (highlighted) BottomBarHighlightBrush else null
|
||||
// Зелёной подложки под активной кнопкой быть не должно — фон всегда нейтральный.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(BottomBarButtonSize)
|
||||
.clip(BottomBarButtonShape)
|
||||
.then(
|
||||
if (bg != null) {
|
||||
Modifier.background(bg, BottomBarButtonShape)
|
||||
} else {
|
||||
Modifier.background(Color(0x99FFFFFF), BottomBarButtonShape)
|
||||
},
|
||||
)
|
||||
.background(Color(0x99FFFFFF), BottomBarButtonShape)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
brush = BottomBarBorderBrush,
|
||||
@@ -170,7 +161,7 @@ private fun F7BottomBarIconSlot(
|
||||
model = iconUrl,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier
|
||||
.size(BottomBarIconSize)
|
||||
.size(iconSize)
|
||||
.graphicsLayer { rotationZ = iconRotation },
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package ru.forbion.f7cloud.core.designsystem
|
||||
|
||||
import android.app.Activity
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
/**
|
||||
* Пока этот экран на виду — окно помечено FLAG_SECURE: содержимое не попадает
|
||||
* в скриншоты, превью в «недавних» и запись экрана. Вешать на чувствительные
|
||||
* экраны (ввод пароля/PIN). Флаг снимается при уходе с экрана.
|
||||
*/
|
||||
@Composable
|
||||
fun F7SecureScreen() {
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(Unit) {
|
||||
val window = (context as? Activity)?.window
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
onDispose {
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package ru.forbion.f7cloud.core.designsystem
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Токены отступов и радиусов (4dp-сетка). До этого отступы задавались числами по месту
|
||||
* (аудит: ~1087 инлайновых .dp без токенов) — единая шкала для попиксельной вёрстки по Figma.
|
||||
* Точные значения конкретных экранов сверяются с макетом; эти — базовая сетка.
|
||||
*/
|
||||
object F7Spacing {
|
||||
val none = 0.dp
|
||||
val xxs = 2.dp
|
||||
val xs = 4.dp
|
||||
val sm = 8.dp
|
||||
val md = 12.dp
|
||||
val lg = 16.dp
|
||||
val xl = 20.dp
|
||||
val xxl = 24.dp
|
||||
val xxxl = 32.dp
|
||||
|
||||
/** Горизонтальные поля экрана (мобильный контент). */
|
||||
val screenHorizontal = 16.dp
|
||||
}
|
||||
|
||||
object F7Radius {
|
||||
val none = 0.dp
|
||||
val sm = 6.dp
|
||||
val md = 10.dp
|
||||
val lg = 14.dp
|
||||
val pill = 999.dp
|
||||
}
|
||||
@@ -23,7 +23,10 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||
implementation 'org.json:json:20240303'
|
||||
api 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||
implementation libs.coroutines.android
|
||||
implementation libs.json
|
||||
api libs.okhttp
|
||||
|
||||
testImplementation libs.junit
|
||||
testImplementation libs.okhttp.mockwebserver
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" ?>
|
||||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<ID>ArgumentListWrapping:CalendarIcs.kt$CalendarIcs$(',')</ID>
|
||||
<ID>ArgumentListWrapping:CalendarIcs.kt$CalendarIcs$(it.trim())</ID>
|
||||
<ID>ArgumentListWrapping:LoginFlowClient.kt$LoginFlowClient$("""name=["']requesttoken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)</ID>
|
||||
<ID>ArgumentListWrapping:LoginFlowClient.kt$LoginFlowClient$("""name=["']stateToken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)</ID>
|
||||
<ID>CyclomaticComplexMethod:DavClient.kt$DavClient$private fun parseMultiStatus(xml: String, folderUrl: String): List<DavEntry></ID>
|
||||
<ID>ImportOrdering:CalendarIcsTest.kt$import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId import org.junit.Test</ID>
|
||||
<ID>LargeClass:CalDavClient.kt$CalDavClient</ID>
|
||||
<ID>LoopWithTooManyJumpStatements:NotificationsRepository.kt$NotificationsRepository$for</ID>
|
||||
<ID>MaximumLineLength:CalendarIcs.kt$CalendarIcs$ </ID>
|
||||
<ID>MaximumLineLength:LoginFlowClient.kt$LoginFlowClient$ </ID>
|
||||
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun <T> parseCalendarData(xml: String, map: (String) -> T?): List<T></ID>
|
||||
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendarQueryResponses(xml: String, calendar: DavCalendar): List<DavEvent></ID>
|
||||
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendarQueryResponsesForTasks(xml: String, calendar: DavCalendar): List<DavTask></ID>
|
||||
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendars(xml: String, baseUrl: String): List<DavCalendar></ID>
|
||||
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseTrashResponses(xml: String, trashUrl: String): List<DavTrashEvent></ID>
|
||||
<ID>NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseAddressBooks(xml: String, baseUrl: String): List<Pair<String, String>></ID>
|
||||
<ID>NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseContacts(xml: String, bookName: String): List<DavContact></ID>
|
||||
<ID>NestedBlockDepth:DavClient.kt$DavClient$private fun parseMultiStatus(xml: String, folderUrl: String): List<DavEntry></ID>
|
||||
<ID>NoMultipleSpaces:CalendarIcs.kt$CalendarIcs$ </ID>
|
||||
<ID>PropertyWrapping:LoginFlowClient.kt$LoginFlowClient$private val REQUEST_TOKEN_REGEX = Regex("""name=["']requesttoken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)</ID>
|
||||
<ID>PropertyWrapping:LoginFlowClient.kt$LoginFlowClient$private val STATE_TOKEN_REGEX = Regex("""name=["']stateToken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)</ID>
|
||||
<ID>SpacingBetweenDeclarationsWithAnnotations:UnauthorizedInterceptorTest.kt$UnauthorizedInterceptorTest$@After fun tearDown()</ID>
|
||||
<ID>UnusedParameter:CalDavClient.kt$CalDavClient$calendar: DavCalendar</ID>
|
||||
<ID>UnusedPrivateMember:CalDavClient.kt$CalDavClient$private fun <T> parseCalendarData(xml: String, map: (String) -> T?): List<T></ID>
|
||||
<ID>UnusedPrivateMember:CalDavClient.kt$CalDavClient$private fun formatIcsLocal(zoned: ZonedDateTime): String</ID>
|
||||
<ID>UnusedPrivateMember:CalDavClient.kt$CalDavClient$private fun unescapeIcsText(text: String): String</ID>
|
||||
<ID>UnusedPrivateProperty:CalDavClient.kt$CalDavClient$private val icsDtEnd = Pattern.compile("DTEND[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)</ID>
|
||||
<ID>UnusedPrivateProperty:CalDavClient.kt$CalDavClient$private val icsLocation = Pattern.compile("LOCATION:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)</ID>
|
||||
<ID>UnusedPrivateProperty:LoginFlowClient.kt$LoginFlowClient$val payload = extractPayload(qrData)</ID>
|
||||
<ID>Wrapping:CalendarIcsTest.kt$CalendarIcsTest$"""</ID>
|
||||
<ID>Wrapping:LoginFlowClient.kt$LoginFlowClient$(</ID>
|
||||
</CurrentIssues>
|
||||
</SmellBaseline>
|
||||
@@ -22,6 +22,9 @@ data class DavCalendar(
|
||||
val href: String,
|
||||
val displayName: String,
|
||||
val color: String? = null,
|
||||
// CTag коллекции (CalendarServer-расширение, Nextcloud поддерживает):
|
||||
// меняется при любом изменении в календаре → ключ инкрементального кэша событий.
|
||||
val ctag: String = "",
|
||||
)
|
||||
|
||||
data class DavEvent(
|
||||
@@ -103,7 +106,7 @@ object CalDavClient {
|
||||
val body = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/">
|
||||
<d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/></d:prop>
|
||||
<d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/><cs:getctag/></d:prop>
|
||||
</d:propfind>
|
||||
""".trimIndent()
|
||||
val xml = propfind(client, baseUrl, depth = 1, body)
|
||||
@@ -129,7 +132,15 @@ object CalDavClient {
|
||||
calendar: DavCalendar,
|
||||
rangeStart: Instant,
|
||||
rangeEnd: Instant,
|
||||
): List<DavEvent> {
|
||||
): List<DavEvent> = parseEventsXml(queryEventsRawXml(client, calendar, rangeStart, rangeEnd), calendar)
|
||||
|
||||
/** Сырой REPORT-ответ (для кэширования на диске с ключом по CTag). */
|
||||
fun queryEventsRawXml(
|
||||
client: OkHttpClient,
|
||||
calendar: DavCalendar,
|
||||
rangeStart: Instant,
|
||||
rangeEnd: Instant,
|
||||
): String {
|
||||
val startStr = formatCalDavTime(rangeStart)
|
||||
val endStr = formatCalDavTime(rangeEnd)
|
||||
val body = """
|
||||
@@ -146,10 +157,13 @@ object CalDavClient {
|
||||
</c:calendar-query>
|
||||
""".trimIndent()
|
||||
val href = calendar.href.trimEnd('/') + "/"
|
||||
val xml = report(client, href, body)
|
||||
return parseCalendarQueryResponses(xml, calendar)
|
||||
return report(client, href, body)
|
||||
}
|
||||
|
||||
/** Парсинг REPORT-ответа (в т.ч. взятого из кэша). */
|
||||
fun parseEventsXml(xml: String, calendar: DavCalendar): List<DavEvent> =
|
||||
parseCalendarQueryResponses(xml, calendar)
|
||||
|
||||
fun createEvent(
|
||||
client: OkHttpClient,
|
||||
calendar: DavCalendar,
|
||||
@@ -189,7 +203,6 @@ object CalDavClient {
|
||||
.put(ics.toRequestBody("text/calendar; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
|
||||
error("CalDAV create event HTTP ${response.code}")
|
||||
}
|
||||
@@ -244,7 +257,6 @@ object CalDavClient {
|
||||
.delete()
|
||||
.build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (response.code !in 200..299 && response.code != 204 && response.code != 404) {
|
||||
error("CalDAV delete event HTTP ${response.code}")
|
||||
}
|
||||
@@ -278,7 +290,6 @@ object CalDavClient {
|
||||
builder.header("If-None-Match", "*")
|
||||
}
|
||||
client.newCall(builder.build()).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
|
||||
error("CalDAV put event HTTP ${response.code}")
|
||||
}
|
||||
@@ -314,7 +325,6 @@ object CalDavClient {
|
||||
.method("MKCALENDAR", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
|
||||
error("CalDAV create calendar HTTP ${response.code}")
|
||||
}
|
||||
@@ -347,7 +357,6 @@ object CalDavClient {
|
||||
.method("MKCOL", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
|
||||
error("CalDAV subscribe calendar HTTP ${response.code}")
|
||||
}
|
||||
@@ -405,7 +414,6 @@ object CalDavClient {
|
||||
.header("Destination", destinationHref)
|
||||
.build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
|
||||
error("CalDAV move HTTP ${response.code}")
|
||||
}
|
||||
@@ -581,25 +589,9 @@ object CalDavClient {
|
||||
)
|
||||
}
|
||||
|
||||
fun parseIcsInstant(raw: String): Instant? {
|
||||
val value = raw.trim()
|
||||
if (value.isBlank()) return null
|
||||
return runCatching {
|
||||
when {
|
||||
value.contains('T') -> {
|
||||
val clean = value.replace("Z", "", ignoreCase = true).take(15)
|
||||
LocalDateTime.parse(clean, DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss"))
|
||||
.toInstant(ZoneOffset.UTC)
|
||||
}
|
||||
value.length >= 8 -> {
|
||||
LocalDate.parse(value.take(8), DateTimeFormatter.BASIC_ISO_DATE)
|
||||
.atStartOfDay(ZoneOffset.UTC)
|
||||
.toInstant()
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
// Единый парсер дат — в CalendarIcs (учитывает Z/TZID/floating). Раньше здесь была
|
||||
// расходящаяся копия (трактовала всё как UTC) — источник рассинхрона календаря и задач.
|
||||
fun parseIcsInstant(raw: String): Instant? = CalendarIcs.parseIcsInstant(raw)
|
||||
|
||||
private fun formatIcsUtc(instant: Instant): String = formatCalDavTime(instant)
|
||||
|
||||
@@ -660,7 +652,6 @@ object CalDavClient {
|
||||
.put(ics.toRequestBody("text/calendar; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
|
||||
error("CalDAV create task HTTP ${response.code}")
|
||||
}
|
||||
@@ -707,7 +698,6 @@ object CalDavClient {
|
||||
.put(ics.toRequestBody("text/calendar; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (response.code !in 200..299 && response.code != 204) {
|
||||
error("CalDAV update task HTTP ${response.code}")
|
||||
}
|
||||
@@ -721,7 +711,6 @@ object CalDavClient {
|
||||
.delete()
|
||||
.build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (response.code !in 200..299 && response.code != 204 && response.code != 404) {
|
||||
error("CalDAV delete task HTTP ${response.code}")
|
||||
}
|
||||
@@ -781,7 +770,6 @@ object CalDavClient {
|
||||
|
||||
private fun execute(client: OkHttpClient, req: Request): String {
|
||||
client.newCall(req).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
val code = response.code
|
||||
if (code !in 200..299 && code != 207) {
|
||||
error("CalDAV error HTTP $code")
|
||||
@@ -808,6 +796,7 @@ object CalDavClient {
|
||||
var href = ""
|
||||
var displayName = ""
|
||||
var calendarColor: String? = null
|
||||
var ctag = ""
|
||||
var isCollection = false
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (parser.eventType) {
|
||||
@@ -817,11 +806,13 @@ object CalDavClient {
|
||||
href = ""
|
||||
displayName = ""
|
||||
calendarColor = null
|
||||
ctag = ""
|
||||
isCollection = false
|
||||
}
|
||||
"collection" -> if (inResponse) isCollection = true
|
||||
"displayname" -> if (inResponse) displayName = parser.readText().trim()
|
||||
"calendar-color" -> if (inResponse) calendarColor = parser.readText().trim()
|
||||
"getctag" -> if (inResponse) ctag = parser.readText().trim()
|
||||
"href" -> if (inResponse) href = parser.readText().trim()
|
||||
}
|
||||
XmlPullParser.END_TAG -> if (parser.localTag() == "response" && inResponse) {
|
||||
@@ -835,7 +826,7 @@ object CalDavClient {
|
||||
val name = displayName.ifBlank {
|
||||
fullPath.removePrefix(basePath).trim('/').substringAfterLast('/')
|
||||
}
|
||||
out += DavCalendar(href = full, displayName = name, color = calendarColor)
|
||||
out += DavCalendar(href = full, displayName = name, color = calendarColor, ctag = ctag)
|
||||
}
|
||||
}
|
||||
inResponse = false
|
||||
|
||||
@@ -44,7 +44,10 @@ data class CalendarEventData(
|
||||
|
||||
object CalendarIcs {
|
||||
private val veventBlock = Pattern.compile("BEGIN:VEVENT([\\s\\S]*?)END:VEVENT", Pattern.CASE_INSENSITIVE)
|
||||
private val linePattern = Pattern.compile("^([A-Z0-9-]+)(?:;[^:]*)?:(.*)$", Pattern.MULTILINE)
|
||||
|
||||
/** Одно ICS-свойство: имя, параметры (TZID/CN/PARTSTAT…) и значение. Параметры НЕ теряем —
|
||||
* раньше их срезал regex, из-за чего пропадал TZID и схлопывались ATTENDEE. */
|
||||
private data class IcsProp(val name: String, val params: Map<String, String>, val value: String)
|
||||
|
||||
fun parseAll(ics: String): List<CalendarEventData> {
|
||||
val unfolded = unfold(ics)
|
||||
@@ -70,23 +73,22 @@ object CalendarIcs {
|
||||
}
|
||||
|
||||
private fun parseVEventBlock(block: String): CalendarEventData? {
|
||||
val lines = parseLines(block)
|
||||
val props = parseProps(block)
|
||||
// первое значение на имя — для простых одиночных свойств (SUMMARY/UID/…)
|
||||
val lines = props.associate { it.name to it.value }
|
||||
val uid = lines["UID"]?.trim().orEmpty()
|
||||
if (uid.isBlank()) return null
|
||||
val dtStartRaw = lines["DTSTART"].orEmpty()
|
||||
val start = parseIcsInstant(dtStartRaw) ?: return null
|
||||
val allDay = !dtStartRaw.contains('T')
|
||||
val endRaw = lines["DTEND"]
|
||||
val end = if (endRaw != null) {
|
||||
parseIcsInstant(endRaw) ?: start.plusSeconds(if (allDay) 86400 else 3600)
|
||||
} else {
|
||||
start.plusSeconds(if (allDay) 86400 else 3600)
|
||||
}
|
||||
val attendees = lines.entries
|
||||
.filter { it.key.startsWith("ATTENDEE") }
|
||||
.mapNotNull { parseAttendeeLine(it.key, it.value) }
|
||||
val organizer = lines["ORGANIZER"].orEmpty()
|
||||
val (orgEmail, orgName) = parseOrganizer(organizer)
|
||||
val dtStart = props.firstOrNull { it.name == "DTSTART" } ?: return null
|
||||
val start = parseIcsInstant(dtStart.value, dtStart.params["TZID"]) ?: return null
|
||||
val allDay = dtStart.params["VALUE"].equals("DATE", ignoreCase = true) || !dtStart.value.contains('T')
|
||||
val dtEnd = props.firstOrNull { it.name == "DTEND" }
|
||||
val end = dtEnd?.let { parseIcsInstant(it.value, it.params["TZID"]) }
|
||||
?: start.plusSeconds(if (allDay) 86400 else 3600)
|
||||
// каждый ATTENDEE — со своими параметрами (CN/PARTSTAT/ROLE), не схлопываем
|
||||
val attendees = props.filter { it.name == "ATTENDEE" }.mapNotNull { parseAttendee(it) }
|
||||
val organizerProp = props.firstOrNull { it.name == "ORGANIZER" }
|
||||
val orgEmail = organizerProp?.value?.substringAfter("mailto:", organizerProp.value)?.trim().orEmpty()
|
||||
val orgName = organizerProp?.params?.get("CN")?.let(::unescape).orEmpty()
|
||||
val alarms = parseAlarms(block)
|
||||
val conference = lines.entries
|
||||
.firstOrNull { it.key.startsWith("CONFERENCE") }
|
||||
@@ -173,52 +175,71 @@ object CalendarIcs {
|
||||
|
||||
fun newUid(): String = "${UUID.randomUUID()}@f7cloud.mobile"
|
||||
|
||||
fun parseIcsInstant(raw: String): Instant? {
|
||||
/**
|
||||
* ICS date-time → Instant с учётом зоны:
|
||||
* - суффикс `Z` → UTC (раньше срезался и трактовался как локальное — баг);
|
||||
* - параметр TZID → указанная зона;
|
||||
* - иначе (floating) → локальная зона устройства;
|
||||
* - только дата (VALUE=DATE, 8 цифр) → начало дня в зоне (TZID или локальной).
|
||||
*/
|
||||
fun parseIcsInstant(raw: String, tzId: String? = null): Instant? {
|
||||
val value = raw.trim()
|
||||
if (value.isBlank()) return null
|
||||
val paramZone = tzId?.let { runCatching { ZoneId.of(it) }.getOrNull() }
|
||||
return runCatching {
|
||||
when {
|
||||
value.contains('T') -> {
|
||||
val clean = value.replace("Z", "", ignoreCase = true).take(15)
|
||||
LocalDateTime.parse(clean, DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss"))
|
||||
.atZone(ZoneId.systemDefault()).toInstant()
|
||||
val hasZ = value.endsWith("Z", ignoreCase = true)
|
||||
val clean = value.trimEnd('Z', 'z').take(15)
|
||||
val ldt = LocalDateTime.parse(clean, DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss"))
|
||||
val zone = when {
|
||||
hasZ -> ZoneOffset.UTC
|
||||
else -> paramZone ?: ZoneId.systemDefault()
|
||||
}
|
||||
ldt.atZone(zone).toInstant()
|
||||
}
|
||||
value.length >= 8 -> {
|
||||
LocalDate.parse(value.take(8), DateTimeFormatter.BASIC_ISO_DATE)
|
||||
.atStartOfDay(ZoneId.systemDefault()).toInstant()
|
||||
.atStartOfDay(paramZone ?: ZoneId.systemDefault()).toInstant()
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun parseLines(block: String): Map<String, String> {
|
||||
val map = mutableMapOf<String, String>()
|
||||
unfold(block).lineSequence().forEach { line ->
|
||||
val m = linePattern.matcher(line.trim())
|
||||
if (m.find()) {
|
||||
val key = m.group(1)?.uppercase().orEmpty()
|
||||
val value = m.group(2).orEmpty()
|
||||
map[key] = if (map.containsKey(key)) "${map[key]}\n$value" else value
|
||||
/** Разбор ICS-строк в свойства с параметрами. Сворачивание строк уже снято в [unfold]. */
|
||||
private fun parseProps(block: String): List<IcsProp> {
|
||||
val out = mutableListOf<IcsProp>()
|
||||
unfold(block).lineSequence().forEach { raw ->
|
||||
val line = raw.trim()
|
||||
val colon = line.indexOf(':')
|
||||
if (colon <= 0) return@forEach
|
||||
val head = line.substring(0, colon) // NAME;PARAM=VAL;PARAM2=VAL
|
||||
val value = line.substring(colon + 1)
|
||||
val parts = head.split(';')
|
||||
val name = parts[0].uppercase()
|
||||
val params = parts.drop(1).mapNotNull { p ->
|
||||
val eq = p.indexOf('=')
|
||||
if (eq <= 0) null else p.substring(0, eq).uppercase() to p.substring(eq + 1).trim('"')
|
||||
}.toMap()
|
||||
out += IcsProp(name, params, value)
|
||||
}
|
||||
}
|
||||
return map
|
||||
return out
|
||||
}
|
||||
|
||||
private fun parseAttendeeLine(key: String, value: String): CalendarAttendeeData? {
|
||||
val email = value.substringAfter("mailto:", value).trim()
|
||||
private fun parseLines(block: String): Map<String, String> =
|
||||
parseProps(block).associate { it.name to it.value }
|
||||
|
||||
private fun parseAttendee(prop: IcsProp): CalendarAttendeeData? {
|
||||
val email = prop.value.substringAfter("mailto:", prop.value).trim()
|
||||
if (email.isBlank()) return null
|
||||
val cn = Regex("CN=([^;:]+)", RegexOption.IGNORE_CASE).find(key)?.groupValues?.get(1)?.let(::unescape)
|
||||
val partStat = Regex("PARTSTAT=([^;:]+)", RegexOption.IGNORE_CASE).find(key)?.groupValues?.get(1) ?: "NEEDS-ACTION"
|
||||
val role = Regex("ROLE=([^;:]+)", RegexOption.IGNORE_CASE).find(key)?.groupValues?.get(1) ?: "REQ-PARTICIPANT"
|
||||
val rsvp = !key.contains("RSVP=FALSE", ignoreCase = true)
|
||||
return CalendarAttendeeData(email = email, displayName = cn.orEmpty(), partStat = partStat, role = role, rsvp = rsvp)
|
||||
}
|
||||
|
||||
private fun parseOrganizer(value: String): Pair<String, String> {
|
||||
val email = value.substringAfter("mailto:", value).trim()
|
||||
val cn = Regex("CN=([^;:]+)", RegexOption.IGNORE_CASE).find(value)?.groupValues?.get(1)?.let(::unescape).orEmpty()
|
||||
return email to cn
|
||||
return CalendarAttendeeData(
|
||||
email = email,
|
||||
displayName = prop.params["CN"]?.let(::unescape).orEmpty(),
|
||||
partStat = prop.params["PARTSTAT"] ?: "NEEDS-ACTION",
|
||||
role = prop.params["ROLE"] ?: "REQ-PARTICIPANT",
|
||||
rsvp = !prop.params["RSVP"].equals("FALSE", ignoreCase = true),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseAlarms(block: String): List<CalendarAlarmData> {
|
||||
@@ -265,8 +286,29 @@ object CalendarIcs {
|
||||
private fun escape(text: String): String =
|
||||
text.replace("\\", "\\\\").replace("\n", "\\n").replace(",", "\\,").replace(";", "\\;")
|
||||
|
||||
private fun unescape(text: String): String =
|
||||
text.replace("\\n", "\n").replace("\\,", ",").replace("\\;", ";").replace("\\\\", "\\")
|
||||
/** Single-pass: последовательные replace ломались на экранированном бэкслеше (`\\n` → перенос). */
|
||||
private fun unescape(text: String): String {
|
||||
if (text.indexOf('\\') < 0) return text
|
||||
val sb = StringBuilder(text.length)
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
val c = text[i]
|
||||
if (c == '\\' && i + 1 < text.length) {
|
||||
when (val n = text[i + 1]) {
|
||||
'n', 'N' -> sb.append('\n')
|
||||
',' -> sb.append(',')
|
||||
';' -> sb.append(';')
|
||||
'\\' -> sb.append('\\')
|
||||
else -> sb.append(n)
|
||||
}
|
||||
i += 2
|
||||
} else {
|
||||
sb.append(c)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun formatUtc(instant: Instant): String =
|
||||
instant.atZone(ZoneOffset.UTC).format(DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'"))
|
||||
|
||||
@@ -69,6 +69,53 @@ object CardDavClient {
|
||||
return out.distinctBy { "${it.uid}|${it.email}" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Дешёвая «подпись» состояния адресных книг — CTag коллекций (расширение
|
||||
* CalendarServer, поддерживается Nextcloud), с фолбэком на sync-token.
|
||||
* Один PROPFIND depth:1 без address-data. Если подпись не изменилась с прошлой
|
||||
* синхронизации — контакты качать не нужно. null при ошибке/неподдержке → полный sync.
|
||||
*/
|
||||
fun collectionSignature(
|
||||
client: OkHttpClient,
|
||||
serverUrl: String,
|
||||
userId: String,
|
||||
): String? = runCatching {
|
||||
val base = davAddressBooksBaseUrl(serverUrl, userId)
|
||||
val body = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">
|
||||
<d:prop><cs:getctag/><d:sync-token/></d:prop>
|
||||
</d:propfind>
|
||||
""".trimIndent()
|
||||
val xml = propfind(client, base, depth = 1, body)
|
||||
parseCollectionSignature(xml).ifBlank { null }
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* Парсит PROPFIND-ответ в стабильную подпись «href=ctag» (по книгам, отсортировано).
|
||||
* Regex-парсинг (как ICS в проекте) — чистая функция, тестируется без Android XmlPull.
|
||||
*/
|
||||
internal fun parseCollectionSignature(xml: String): String {
|
||||
val entries = sortedSetOf<String>()
|
||||
for (m in responseBlockPattern.findAll(xml)) {
|
||||
val block = m.value
|
||||
val href = hrefTagPattern.find(block)?.groupValues?.get(1)?.trim().orEmpty()
|
||||
val tag = (
|
||||
getctagPattern.find(block)?.groupValues?.get(1)
|
||||
?: syncTokenPattern.find(block)?.groupValues?.get(1)
|
||||
)?.trim().orEmpty()
|
||||
if (href.isNotBlank() && tag.isNotBlank()) {
|
||||
entries += "$href=$tag"
|
||||
}
|
||||
}
|
||||
return entries.joinToString("\n")
|
||||
}
|
||||
|
||||
private val responseBlockPattern = Regex("(?is)<(?:\\w+:)?response\\b.*?</(?:\\w+:)?response>")
|
||||
private val hrefTagPattern = Regex("(?is)<(?:\\w+:)?href>(.*?)</(?:\\w+:)?href>")
|
||||
private val getctagPattern = Regex("(?is)<(?:\\w+:)?getctag>(.*?)</(?:\\w+:)?getctag>")
|
||||
private val syncTokenPattern = Regex("(?is)<(?:\\w+:)?sync-token>(.*?)</(?:\\w+:)?sync-token>")
|
||||
|
||||
fun createContact(
|
||||
client: OkHttpClient,
|
||||
serverUrl: String,
|
||||
@@ -90,7 +137,6 @@ object CardDavClient {
|
||||
.put(vcard.toRequestBody("text/vcard; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
|
||||
error("Не удалось создать контакт (HTTP ${response.code})")
|
||||
}
|
||||
@@ -163,7 +209,6 @@ object CardDavClient {
|
||||
.method("PROPFIND", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
val code = response.code
|
||||
if (code !in 200..299 && code != 207) {
|
||||
error("CardDAV error HTTP $code")
|
||||
|
||||
@@ -27,9 +27,6 @@ object DavClient {
|
||||
.method("MKCOL", null)
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) {
|
||||
throw UnauthorizedException()
|
||||
}
|
||||
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
|
||||
error("DAV MKCOL HTTP ${response.code}")
|
||||
}
|
||||
@@ -46,9 +43,6 @@ object DavClient {
|
||||
.put(body)
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) {
|
||||
throw UnauthorizedException()
|
||||
}
|
||||
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
|
||||
error("DAV upload HTTP ${response.code}")
|
||||
}
|
||||
@@ -76,9 +70,6 @@ object DavClient {
|
||||
.method("PROPFIND", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) {
|
||||
throw UnauthorizedException()
|
||||
}
|
||||
val code = response.code
|
||||
if (code !in 200..299 && code != 207) {
|
||||
error("DAV error HTTP $code")
|
||||
@@ -182,7 +173,6 @@ object DavClient {
|
||||
.delete()
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (response.code !in 200..299 && response.code != 204) {
|
||||
error("DAV DELETE HTTP ${response.code}")
|
||||
}
|
||||
@@ -197,7 +187,6 @@ object DavClient {
|
||||
.header("Overwrite", "T")
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
|
||||
error("DAV MOVE HTTP ${response.code}")
|
||||
}
|
||||
@@ -221,7 +210,6 @@ object DavClient {
|
||||
.method("PROPPATCH", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (response.code !in 200..299 && response.code != 207) {
|
||||
error("DAV PROPPATCH HTTP ${response.code}")
|
||||
}
|
||||
|
||||
@@ -187,25 +187,40 @@ object LoginFlowClient {
|
||||
}
|
||||
}
|
||||
|
||||
private data class CredentialParams(
|
||||
internal data class CredentialParams(
|
||||
val server: String,
|
||||
val user: String,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
private fun parseCredentialParams(params: String): CredentialParams? {
|
||||
val values = params.split('&')
|
||||
if (values.isEmpty() || values.size > 3) return null
|
||||
var server = ""
|
||||
var user = ""
|
||||
var password = ""
|
||||
values.forEach { value ->
|
||||
when {
|
||||
value.startsWith("user:") -> user = decode(value.removePrefix("user:"))
|
||||
value.startsWith("server:") -> server = decode(value.removePrefix("server:"))
|
||||
value.startsWith("password:") -> password = decode(value.removePrefix("password:"))
|
||||
/**
|
||||
* Разбор `server:...&user:...&password:...` (формат nc-login). Значения НЕ split('&'):
|
||||
* пароль может содержать `&` и `:`, а раньше `split('&')`+`size>3` его резал/ронял вход.
|
||||
* Ищем маркеры `key:` (в начале или после `&`) и берём значение до следующего маркера.
|
||||
*/
|
||||
internal fun parseCredentialParams(params: String): CredentialParams? {
|
||||
val keys = listOf("server", "user", "password")
|
||||
data class Marker(val key: String, val at: Int, val valueAt: Int)
|
||||
val markers = mutableListOf<Marker>()
|
||||
for (key in keys) {
|
||||
if (params.startsWith("$key:")) markers += Marker(key, 0, key.length + 1)
|
||||
var idx = params.indexOf("&$key:")
|
||||
while (idx >= 0) {
|
||||
markers += Marker(key, idx, idx + 1 + key.length + 1)
|
||||
idx = params.indexOf("&$key:", idx + 1)
|
||||
}
|
||||
}
|
||||
if (markers.isEmpty()) return null
|
||||
markers.sortBy { it.at }
|
||||
val map = mutableMapOf<String, String>()
|
||||
markers.forEachIndexed { i, m ->
|
||||
val end = if (i + 1 < markers.size) markers[i + 1].at else params.length
|
||||
// первое вхождение ключа выигрывает (не перезатираем более поздним мусором)
|
||||
map.putIfAbsent(m.key, decode(params.substring(m.valueAt, end)))
|
||||
}
|
||||
val server = map["server"].orEmpty()
|
||||
val user = map["user"].orEmpty()
|
||||
val password = map["password"].orEmpty()
|
||||
if (server.isBlank() || user.isBlank() || password.isBlank()) return null
|
||||
return CredentialParams(server, user, password)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,58 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
import android.content.Context
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Фабрика HTTP-клиентов. Раньше создавала НОВЫЙ OkHttpClient на КАЖДЫЙ запрос (~32 места) →
|
||||
* TLS-handshake и новый пул/диспатчер на запрос, утечка ExecutorService. Теперь:
|
||||
* - один базовый клиент (общий ConnectionPool + Dispatcher + диск-кэш);
|
||||
* - авторизованные варианты — через `base.newBuilder()` (шарят пул/диспатчер/кэш);
|
||||
* - экземпляры кэшируются по кредам+таймаутам → переиспользуются, соединения живут.
|
||||
*
|
||||
* Публичный API совместим — вызывающие (`newAuthedClient*`) работают как прежде.
|
||||
* 401→UnauthorizedException централизован через interceptor (`throwOnUnauthorized`, по умолч.
|
||||
* true) — ручные проверки в репозиториях убраны. Login-пути (AuthVerifier), где 401 = «неверный
|
||||
* пароль» со своим сообщением, передают `throwOnUnauthorized = false`.
|
||||
*/
|
||||
object NetworkFactory {
|
||||
@Volatile
|
||||
private var base: OkHttpClient = OkHttpClient()
|
||||
private val clients = ConcurrentHashMap<String, OkHttpClient>()
|
||||
|
||||
/** Вызывать один раз из Application.onCreate — добавляет диск-кэш (нужен cacheDir). */
|
||||
fun init(context: Context) {
|
||||
val cacheDir = File(context.applicationContext.cacheDir, "http-cache")
|
||||
base = OkHttpClient.Builder()
|
||||
.cache(Cache(cacheDir, 20L * 1024 * 1024)) // 20 МБ
|
||||
.build()
|
||||
clients.clear() // пересобрать производные клиенты уже с кэшем
|
||||
}
|
||||
|
||||
fun newAuthedClient(
|
||||
username: String,
|
||||
appPassword: String,
|
||||
trustAllCerts: Boolean = false,
|
||||
callTimeoutSeconds: Long = 30,
|
||||
readTimeoutSeconds: Long = 30,
|
||||
throwOnUnauthorized: Boolean = true,
|
||||
): OkHttpClient {
|
||||
return OkHttpClient.Builder()
|
||||
val key = "$username|$appPassword|$trustAllCerts|$callTimeoutSeconds|$readTimeoutSeconds|$throwOnUnauthorized"
|
||||
return clients.getOrPut(key) {
|
||||
base.newBuilder() // общий пул/диспатчер/кэш базового клиента
|
||||
.callTimeout(callTimeoutSeconds, TimeUnit.SECONDS)
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(readTimeoutSeconds, TimeUnit.SECONDS)
|
||||
.applyUnsafeSslIfNeeded(trustAllCerts)
|
||||
.addInterceptor(BasicAuthInterceptor(username, appPassword))
|
||||
.apply { if (throwOnUnauthorized) addInterceptor(UnauthorizedInterceptor) }
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
/** Collabora / richdocuments: cold start and WOPI can be slow on mobile networks. */
|
||||
fun newAuthedClientForOffice(
|
||||
|
||||
@@ -30,7 +30,6 @@ class NotificationsRepository {
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
error("Уведомления HTTP ${response.code}")
|
||||
}
|
||||
@@ -86,7 +85,6 @@ class NotificationsRepository {
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (!response.isSuccessful) {
|
||||
error("Уведомления HTTP ${response.code}")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* Единая точка обработки 401 для авторизованных запросов: бросает [UnauthorizedException]
|
||||
* (session expired → logout). Заменяет ~70 ручных `if (code == 401) throw ...` по репозиториям.
|
||||
*
|
||||
* Подключается в [NetworkFactory] при `throwOnUnauthorized = true` (по умолчанию). Login-пути
|
||||
* (проверка пароля при входе) НЕ используют этот interceptor — там 401 = «неверный пароль».
|
||||
*/
|
||||
object UnauthorizedInterceptor : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val response = chain.proceed(chain.request())
|
||||
if (response.code == 401) {
|
||||
response.close() // не течём телом — дальше по цепочке оно не читается
|
||||
throw UnauthorizedException()
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Тесты парсера ICS. Фиксируют баги из аудита (2026-07-07): зоны/TZID, ATTENDEE, unescape.
|
||||
* Даты с суффиксом Z дают абсолютный Instant → не зависят от таймзоны машины.
|
||||
*/
|
||||
class CalendarIcsTest {
|
||||
|
||||
// --- parseIcsInstant: зоны ---
|
||||
|
||||
@Test fun `Z-суффикс парсится как UTC`() {
|
||||
// Баг: раньше Z срезался и время трактовалось как локальное.
|
||||
val expected = Instant.parse("2026-07-07T10:00:00Z")
|
||||
assertEquals(expected, CalendarIcs.parseIcsInstant("20260707T100000Z"))
|
||||
}
|
||||
|
||||
@Test fun `floating без Z — локальная зона`() {
|
||||
val expected = LocalDateTime.of(2026, 7, 7, 10, 0, 0)
|
||||
.atZone(ZoneId.systemDefault()).toInstant()
|
||||
assertEquals(expected, CalendarIcs.parseIcsInstant("20260707T100000"))
|
||||
}
|
||||
|
||||
@Test fun `TZID учитывается`() {
|
||||
// 12:00 в Москве (UTC+3) == 09:00 UTC
|
||||
val expected = Instant.parse("2026-07-07T09:00:00Z")
|
||||
assertEquals(expected, CalendarIcs.parseIcsInstant("20260707T120000", "Europe/Moscow"))
|
||||
}
|
||||
|
||||
@Test fun `VALUE=DATE — начало дня`() {
|
||||
val expected = java.time.LocalDate.of(2026, 7, 7)
|
||||
.atStartOfDay(ZoneId.systemDefault()).toInstant()
|
||||
assertEquals(expected, CalendarIcs.parseIcsInstant("20260707"))
|
||||
}
|
||||
|
||||
// --- ATTENDEE: несколько участников с параметрами ---
|
||||
|
||||
@Test fun `несколько ATTENDEE не схлопываются и хранят CN-PARTSTAT`() {
|
||||
val ics = """
|
||||
BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
UID:test-1
|
||||
DTSTART:20260707T100000Z
|
||||
SUMMARY:Встреча
|
||||
ATTENDEE;CN=Иван Петров;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT:mailto:ivan@f7.ru
|
||||
ATTENDEE;CN=Мария Сидорова;PARTSTAT=DECLINED;ROLE=OPT-PARTICIPANT:mailto:maria@f7.ru
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
""".trimIndent()
|
||||
val event = CalendarIcs.parseSingle(ics)!!
|
||||
assertEquals(2, event.attendees.size)
|
||||
val ivan = event.attendees.first { it.email == "ivan@f7.ru" }
|
||||
assertEquals("Иван Петров", ivan.displayName)
|
||||
assertEquals("ACCEPTED", ivan.partStat)
|
||||
val maria = event.attendees.first { it.email == "maria@f7.ru" }
|
||||
assertEquals("Мария Сидорова", maria.displayName)
|
||||
assertEquals("DECLINED", maria.partStat)
|
||||
}
|
||||
|
||||
// --- escape/unescape: круговой round-trip со спецсимволами ---
|
||||
|
||||
@Test fun `summary со спецсимволами переживает build и parse`() {
|
||||
val summary = """Смета: 50%, скидка; путь C:\tmp\файл и
|
||||
перенос""".trimIndent()
|
||||
val data = CalendarEventData(
|
||||
uid = "rt-1",
|
||||
summary = summary,
|
||||
startEpochMilli = Instant.parse("2026-07-07T10:00:00Z").toEpochMilli(),
|
||||
endEpochMilli = Instant.parse("2026-07-07T11:00:00Z").toEpochMilli(),
|
||||
)
|
||||
val parsed = CalendarIcs.parseSingle(CalendarIcs.build(data))!!
|
||||
assertEquals(summary, parsed.summary)
|
||||
}
|
||||
|
||||
@Test fun `unescape не превращает экранированный бэкслеш-n в перенос строки`() {
|
||||
// literal "\n" (бэкслеш+n) в тексте: escape → "\\n", unescape должен вернуть "\n" (2 символа),
|
||||
// а не перевод строки. Проверяем через round-trip.
|
||||
val summary = """путь\name"""
|
||||
val data = CalendarEventData(
|
||||
uid = "rt-2",
|
||||
summary = summary,
|
||||
startEpochMilli = 0L,
|
||||
endEpochMilli = 3600_000L,
|
||||
)
|
||||
val parsed = CalendarIcs.parseSingle(CalendarIcs.build(data))!!
|
||||
assertEquals(summary, parsed.summary)
|
||||
assertTrue("не должно быть переноса строки", !parsed.summary.contains('\n'))
|
||||
}
|
||||
|
||||
// --- согласованность двух парсеров ---
|
||||
|
||||
@Test fun `CalDavClient и CalendarIcs парсят одинаково`() {
|
||||
val values = listOf("20260707T100000Z", "20260707", "20251231T235900Z")
|
||||
for (v in values) {
|
||||
assertEquals("рассинхрон на $v", CalendarIcs.parseIcsInstant(v), CalDavClient.parseIcsInstant(v))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Тесты подписи коллекций CardDAV (CTag/sync-token) — основа инкрементальной
|
||||
* синхронизации: если подпись не изменилась, контакты не перекачиваются.
|
||||
*/
|
||||
class CardDavSignatureTest {
|
||||
|
||||
private val ns = """xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/""""
|
||||
|
||||
private fun body(vararg responses: String) =
|
||||
"""<?xml version="1.0"?><d:multistatus $ns>${responses.joinToString("")}</d:multistatus>"""
|
||||
|
||||
private fun resp(href: String, ctag: String? = null, token: String? = null) = """
|
||||
<d:response>
|
||||
<d:href>$href</d:href>
|
||||
<d:propstat><d:prop>
|
||||
${ctag?.let { "<cs:getctag>$it</cs:getctag>" } ?: ""}
|
||||
${token?.let { "<d:sync-token>$it</d:sync-token>" } ?: ""}
|
||||
</d:prop></d:propstat>
|
||||
</d:response>
|
||||
""".trimIndent()
|
||||
|
||||
@Test fun ctag_signature_is_stable_and_sorted() {
|
||||
val a = CardDavClient.parseCollectionSignature(
|
||||
body(resp("/dav/addressbooks/u/b1/", ctag = "111"), resp("/dav/addressbooks/u/b2/", ctag = "222")),
|
||||
)
|
||||
// порядок ответов не влияет на подпись
|
||||
val b = CardDavClient.parseCollectionSignature(
|
||||
body(resp("/dav/addressbooks/u/b2/", ctag = "222"), resp("/dav/addressbooks/u/b1/", ctag = "111")),
|
||||
)
|
||||
assertEquals(a, b)
|
||||
assertTrue(a.contains("/dav/addressbooks/u/b1/=111"))
|
||||
assertTrue(a.contains("/dav/addressbooks/u/b2/=222"))
|
||||
}
|
||||
|
||||
@Test fun ctag_change_changes_signature() {
|
||||
val before = CardDavClient.parseCollectionSignature(body(resp("/b1/", ctag = "111")))
|
||||
val after = CardDavClient.parseCollectionSignature(body(resp("/b1/", ctag = "999")))
|
||||
assertNotEquals(before, after)
|
||||
}
|
||||
|
||||
@Test fun sync_token_fallback_when_no_ctag() {
|
||||
val sig = CardDavClient.parseCollectionSignature(body(resp("/b1/", token = "http://sabre/sync/42")))
|
||||
assertEquals("/b1/=http://sabre/sync/42", sig)
|
||||
}
|
||||
|
||||
@Test fun empty_when_no_tags() {
|
||||
assertEquals("", CardDavClient.parseCollectionSignature(body(resp("/b1/"))))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/** Разбор параметров nc-login QR. Фиксирует баг: пароль с `&` терялся (split('&')+size>3). */
|
||||
class LoginFlowClientTest {
|
||||
|
||||
@Test fun `пароль с амперсандом и двоеточием не теряется`() {
|
||||
val r = LoginFlowClient.parseCredentialParams(
|
||||
"server:https://cloud.f7.ru&user:ivan&password:a&b:c&d",
|
||||
)!!
|
||||
assertEquals("https://cloud.f7.ru", r.server)
|
||||
assertEquals("ivan", r.user)
|
||||
assertEquals("a&b:c&d", r.password)
|
||||
}
|
||||
|
||||
@Test fun `порядок полей произвольный`() {
|
||||
val r = LoginFlowClient.parseCredentialParams(
|
||||
"user:masha&password:p@ss&server:https://x.ru",
|
||||
)!!
|
||||
assertEquals("https://x.ru", r.server)
|
||||
assertEquals("masha", r.user)
|
||||
assertEquals("p@ss", r.password)
|
||||
}
|
||||
|
||||
@Test fun `простой пароль без спецсимволов`() {
|
||||
val r = LoginFlowClient.parseCredentialParams(
|
||||
"server:https://x.ru&user:u&password:simple",
|
||||
)!!
|
||||
assertEquals("simple", r.password)
|
||||
}
|
||||
|
||||
@Test fun `нет обязательного поля — null`() {
|
||||
assertNull(LoginFlowClient.parseCredentialParams("server:https://x.ru&user:u"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
import org.junit.Assert.assertNotSame
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
|
||||
/** Фиксирует переиспользование клиента и общий пул соединений (было: новый клиент на запрос). */
|
||||
class NetworkFactoryTest {
|
||||
|
||||
@Test fun `одинаковые креды дают ТОТ ЖЕ экземпляр клиента`() {
|
||||
val a = NetworkFactory.newAuthedClient("user", "pass", false)
|
||||
val b = NetworkFactory.newAuthedClient("user", "pass", false)
|
||||
assertSame("клиент должен переиспользоваться, а не создаваться заново", a, b)
|
||||
}
|
||||
|
||||
@Test fun `клиенты шарят общий ConnectionPool и Dispatcher`() {
|
||||
val a = NetworkFactory.newAuthedClient("u1", "p1", false)
|
||||
val b = NetworkFactory.newAuthedClient("u2", "p2", false) // другие креды
|
||||
assertSame(a.connectionPool, b.connectionPool)
|
||||
assertSame(a.dispatcher, b.dispatcher)
|
||||
}
|
||||
|
||||
@Test fun `разные таймауты — разные клиенты, но общий пул`() {
|
||||
val normal = NetworkFactory.newAuthedClient("u", "p", false)
|
||||
val office = NetworkFactory.newAuthedClientForOffice("u", "p", false)
|
||||
assertNotSame(normal, office)
|
||||
assertSame(normal.connectionPool, office.connectionPool)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/** Централизованная обработка 401: interceptor бросает UnauthorizedException вместо ручных проверок. */
|
||||
class UnauthorizedInterceptorTest {
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before fun setUp() { server = MockWebServer().apply { start() } }
|
||||
@After fun tearDown() { server.shutdown() }
|
||||
|
||||
private fun call(client: OkHttpClient) =
|
||||
client.newCall(Request.Builder().url(server.url("/x")).build()).execute()
|
||||
|
||||
@Test(expected = UnauthorizedException::class)
|
||||
fun `401 бросает UnauthorizedException`() {
|
||||
server.enqueue(MockResponse().setResponseCode(401))
|
||||
val client = OkHttpClient.Builder().addInterceptor(UnauthorizedInterceptor).build()
|
||||
call(client)
|
||||
}
|
||||
|
||||
@Test fun `не-401 проходит как обычно`() {
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody("ok"))
|
||||
val client = OkHttpClient.Builder().addInterceptor(UnauthorizedInterceptor).build()
|
||||
call(client).use { assertEquals(200, it.code) }
|
||||
}
|
||||
|
||||
@Test fun `403 не трактуется как Unauthorized`() {
|
||||
server.enqueue(MockResponse().setResponseCode(403))
|
||||
val client = OkHttpClient.Builder().addInterceptor(UnauthorizedInterceptor).build()
|
||||
call(client).use { assertEquals(403, it.code) }
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,9 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation project(':core:auth')
|
||||
implementation 'com.google.firebase:firebase-messaging:24.0.1'
|
||||
implementation 'androidx.core:core-ktx:1.15.0'
|
||||
implementation 'androidx.core:core:1.15.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||
implementation platform(libs.firebase.bom)
|
||||
implementation libs.firebase.messaging
|
||||
implementation libs.core.ktx
|
||||
implementation libs.core
|
||||
implementation libs.coroutines.android
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" ?>
|
||||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<ID>ArgumentListWrapping:F7IncomingCallQueue.kt$F7IncomingCallQueue$(R.string.incoming_call_subtitle)</ID>
|
||||
<ID>ArgumentListWrapping:F7IncomingCallQueue.kt$F7IncomingCallQueue$(call.displayName.ifBlank { call.title.ifBlank { context.getString(R.string.incoming_call_subtitle) } })</ID>
|
||||
<ID>ArgumentListWrapping:F7NotificationChannels.kt$F7NotificationChannels$(SERVER_MESSAGES, messagesName, NotificationManager.IMPORTANCE_HIGH, msgVibration, notificationSound, audio)</ID>
|
||||
<ID>ComplexCondition:F7PushEventParser.kt$F7PushEventParser$type == "mail" || source == "mail" || lowerUrl.contains("/apps/f7mail") || lowerUrl.contains("/apps/mail")</ID>
|
||||
<ID>MaximumLineLength:F7IncomingCallQueue.kt$F7IncomingCallQueue$ </ID>
|
||||
<ID>MaximumLineLength:F7NotificationChannels.kt$F7NotificationChannels$ </ID>
|
||||
<ID>NestedBlockDepth:F7FirebaseMessagingService.kt$F7FirebaseMessagingService$override fun onMessageReceived(message: RemoteMessage)</ID>
|
||||
<ID>SpacingBetweenDeclarationsWithComments:F7NotificationChannels.kt$F7NotificationChannels$/** Silent channel: ringtone is played only by [F7IncomingCallRinger]. */</ID>
|
||||
<ID>UnusedParameter:F7IncomingCallQueue.kt$F7IncomingCallQueue$alert: Boolean</ID>
|
||||
<ID>UnusedPrivateProperty:F7NotificationChannels.kt$F7NotificationChannels$val callVibration = longArrayOf(0, 500, 200, 500)</ID>
|
||||
<ID>UnusedPrivateProperty:F7NotificationChannels.kt$F7NotificationChannels$val ringtone = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)</ID>
|
||||
<ID>UnusedPrivateProperty:F7NotificationChannels.kt$F7NotificationChannels$val ringtoneAudio = AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .build()</ID>
|
||||
<ID>UseCheckOrError:F7IncomingCallQueue.kt$F7IncomingCallQueue$throw IllegalStateException("NotificationManager unavailable")</ID>
|
||||
</CurrentIssues>
|
||||
</SmellBaseline>
|
||||
@@ -4,16 +4,19 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
/** Handles Decline on incoming Talk call notifications. */
|
||||
/** Handles Decline and ring-timeout on incoming Talk call notifications. */
|
||||
class F7CallActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != ACTION_DECLINE) return
|
||||
val roomToken = intent.getStringExtra(EXTRA_ROOM_TOKEN)
|
||||
F7IncomingCallQueue.dismissAndShowNext(context, roomToken)
|
||||
when (intent.action) {
|
||||
ACTION_DECLINE -> F7IncomingCallQueue.dismissAndShowNext(context, roomToken)
|
||||
ACTION_CALL_TIMEOUT -> F7IncomingCallQueue.timeoutActive(context, roomToken)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_DECLINE = "ru.forbion.f7cloud.action.DECLINE_CALL"
|
||||
const val ACTION_CALL_TIMEOUT = "ru.forbion.f7cloud.action.CALL_TIMEOUT"
|
||||
const val EXTRA_NOTIFICATION_ID = "notificationId"
|
||||
const val EXTRA_ROOM_TOKEN = "roomToken"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package ru.forbion.f7cloud.core.push
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.Person
|
||||
@@ -21,10 +24,36 @@ object F7IncomingCallQueue {
|
||||
private const val KEY_QUEUE = "queue"
|
||||
private const val KEY_ACTIVE_TOKEN = "active_token"
|
||||
private const val KEY_ACTIVE_AT = "active_at"
|
||||
private const val KEY_ACTIVE_CALL = "active_call"
|
||||
const val ACTIVE_NOTIFICATION_ID = 5000
|
||||
private const val MISSED_NOTIFICATION_BASE = 5100
|
||||
private const val ACTIVE_RING_TTL_MS = 3 * 60 * 1000L
|
||||
|
||||
/** Входящий звонит 30 с; дальше — авто-сброс и уведомление «Пропущенный звонок». */
|
||||
private const val RING_TIMEOUT_MS = 30_000L
|
||||
|
||||
/** Запас страховочного alarm поверх основного in-process таймера. */
|
||||
private const val TIMEOUT_ALARM_SLACK_MS = 5_000L
|
||||
|
||||
private val lock = Any()
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var timeoutRunnable: Runnable? = null
|
||||
private val callEndedListeners = java.util.concurrent.CopyOnWriteArraySet<(String) -> Unit>()
|
||||
|
||||
/** Уведомляет UI (полноэкранный входящий), что активный звонок снят: принят/отклонён/протух. */
|
||||
fun addCallEndedListener(listener: (String) -> Unit) {
|
||||
callEndedListeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeCallEndedListener(listener: (String) -> Unit) {
|
||||
callEndedListeners.remove(listener)
|
||||
}
|
||||
|
||||
private fun notifyCallEnded(token: String) {
|
||||
callEndedListeners.forEach { listener ->
|
||||
runCatching { listener(token) }
|
||||
}
|
||||
}
|
||||
|
||||
fun enqueue(
|
||||
context: Context,
|
||||
@@ -58,10 +87,10 @@ object F7IncomingCallQueue {
|
||||
}
|
||||
|
||||
if (active.isEmpty()) {
|
||||
setActive(prefs, token)
|
||||
val shown = showNotification(context, call, waiting = 0)
|
||||
if (!shown) {
|
||||
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
|
||||
if (shown) {
|
||||
setActive(prefs, call)
|
||||
scheduleTimeout(context, token)
|
||||
}
|
||||
return shown
|
||||
}
|
||||
@@ -74,6 +103,7 @@ object F7IncomingCallQueue {
|
||||
}
|
||||
|
||||
fun dismissAndShowNext(context: Context, roomToken: String?) {
|
||||
var ended: String? = null
|
||||
synchronized(lock) {
|
||||
val prefs = prefs(context)
|
||||
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
|
||||
@@ -88,14 +118,60 @@ object F7IncomingCallQueue {
|
||||
}
|
||||
}
|
||||
|
||||
cancelTimeout(context)
|
||||
cancelNotification(context)
|
||||
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
|
||||
clearActive(prefs)
|
||||
ended = active.takeIf { it.isNotEmpty() }
|
||||
advanceQueueLocked(context, prefs, queue)
|
||||
}
|
||||
ended?.let { notifyCallEnded(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Авто-сброс не отвеченного звонка (истекли [RING_TIMEOUT_MS]): снять входящий,
|
||||
* показать «Пропущенный звонок» и следующий звонок из очереди, если есть.
|
||||
* Принятые/отклонённые звонки сюда не попадают — их снимает [dismissAndShowNext].
|
||||
*/
|
||||
fun timeoutActive(context: Context, roomToken: String?) {
|
||||
var ended: String? = null
|
||||
synchronized(lock) {
|
||||
val prefs = prefs(context)
|
||||
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
|
||||
if (active.isEmpty()) return
|
||||
if (!roomToken.isNullOrBlank() && roomToken.trim() != active) return
|
||||
|
||||
val call = readActiveCall(prefs)
|
||||
cancelTimeout(context)
|
||||
cancelNotification(context)
|
||||
clearActive(prefs)
|
||||
ended = active
|
||||
Log.i(TAG, "Call timed out (unanswered): $active")
|
||||
if (call != null) {
|
||||
showMissedNotification(context, call)
|
||||
}
|
||||
advanceQueueLocked(context, prefs, readQueue(prefs))
|
||||
}
|
||||
ended?.let { notifyCallEnded(it) }
|
||||
}
|
||||
|
||||
fun clearAll(context: Context) {
|
||||
synchronized(lock) {
|
||||
val prefs = prefs(context)
|
||||
cancelTimeout(context)
|
||||
cancelNotification(context)
|
||||
prefs.edit().remove(KEY_QUEUE).remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).remove(KEY_ACTIVE_CALL).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun advanceQueueLocked(
|
||||
context: Context,
|
||||
prefs: android.content.SharedPreferences,
|
||||
queue: JSONArray,
|
||||
) {
|
||||
if (queue.length() == 0) {
|
||||
prefs.edit().remove(KEY_QUEUE).apply()
|
||||
return
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val next = PendingCall.fromJson(queue.getJSONObject(0))
|
||||
val rest = JSONArray()
|
||||
@@ -104,22 +180,14 @@ object F7IncomingCallQueue {
|
||||
}
|
||||
prefs.edit().putString(KEY_QUEUE, rest.toString()).apply()
|
||||
if (showNotification(context, next, rest.length())) {
|
||||
setActive(prefs, next.roomToken)
|
||||
setActive(prefs, next)
|
||||
scheduleTimeout(context, next.roomToken)
|
||||
}
|
||||
}.onFailure {
|
||||
Log.w(TAG, "Failed to parse queued call", it)
|
||||
prefs.edit().remove(KEY_QUEUE).apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAll(context: Context) {
|
||||
synchronized(lock) {
|
||||
val prefs = prefs(context)
|
||||
cancelNotification(context)
|
||||
prefs.edit().remove(KEY_QUEUE).remove(KEY_ACTIVE_TOKEN).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showNotification(
|
||||
context: Context,
|
||||
@@ -241,7 +309,8 @@ object F7IncomingCallQueue {
|
||||
.setStyle(callStyle)
|
||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setPublicVersion(genericCallPublic(context, intents.preview))
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
.setOnlyAlertOnce(true)
|
||||
@@ -282,7 +351,8 @@ object F7IncomingCallQueue {
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setPublicVersion(genericCallPublic(context, intents.preview))
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
.setOnlyAlertOnce(true)
|
||||
@@ -304,6 +374,21 @@ object F7IncomingCallQueue {
|
||||
?: throw IllegalStateException("NotificationManager unavailable")
|
||||
}
|
||||
|
||||
/**
|
||||
* Обезличенная (public) версия уведомления звонка для локскрина: показывает лишь «Входящий
|
||||
* звонок» без имени звонящего/комнаты (минимум информации, решение владельца). Полное имя —
|
||||
* в полноэкранном UI звонка/после разблокировки. Full-screen intent работает независимо от visibility.
|
||||
*/
|
||||
private fun genericCallPublic(context: Context, preview: PendingIntent): android.app.Notification =
|
||||
NotificationCompat.Builder(context, F7NotificationChannels.CALLS)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_phone_call)
|
||||
.setContentTitle("F7cloud")
|
||||
.setContentText("Входящий звонок")
|
||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
.setContentIntent(preview)
|
||||
.build()
|
||||
|
||||
private fun resolveJoinUrl(context: Context, call: PendingCall): String? {
|
||||
val raw = call.acceptUrl?.takeIf { it.isNotBlank() }
|
||||
?: AuthStore(context).load()?.let { session ->
|
||||
@@ -326,13 +411,106 @@ object F7IncomingCallQueue {
|
||||
private fun prefs(context: Context) =
|
||||
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
private fun setActive(prefs: android.content.SharedPreferences, token: String) {
|
||||
private fun setActive(prefs: android.content.SharedPreferences, call: PendingCall) {
|
||||
prefs.edit()
|
||||
.putString(KEY_ACTIVE_TOKEN, token)
|
||||
.putString(KEY_ACTIVE_TOKEN, call.roomToken)
|
||||
.putLong(KEY_ACTIVE_AT, System.currentTimeMillis())
|
||||
// Персистим весь звонок: страховочный alarm после смерти процесса должен уметь
|
||||
// показать «Пропущенный» с названием комнаты.
|
||||
.putString(KEY_ACTIVE_CALL, call.toJson().toString())
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun clearActive(prefs: android.content.SharedPreferences) {
|
||||
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).remove(KEY_ACTIVE_CALL).apply()
|
||||
}
|
||||
|
||||
private fun readActiveCall(prefs: android.content.SharedPreferences): PendingCall? {
|
||||
val raw = prefs.getString(KEY_ACTIVE_CALL, null) ?: return null
|
||||
return runCatching { PendingCall.fromJson(JSONObject(raw)) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun scheduleTimeout(context: Context, token: String) {
|
||||
val app = context.applicationContext
|
||||
timeoutRunnable?.let(handler::removeCallbacks)
|
||||
val runnable = Runnable { timeoutActive(app, token) }
|
||||
timeoutRunnable = runnable
|
||||
handler.postDelayed(runnable, RING_TIMEOUT_MS)
|
||||
// Страховка: если процесс умрёт до срабатывания таймера, alarm поднимет его и
|
||||
// заменит зависшее уведомление звонка на «Пропущенный». timeoutActive идемпотентен.
|
||||
runCatching {
|
||||
app.getSystemService(AlarmManager::class.java)?.setAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP,
|
||||
System.currentTimeMillis() + RING_TIMEOUT_MS + TIMEOUT_ALARM_SLACK_MS,
|
||||
timeoutAlarmIntent(app, token),
|
||||
)
|
||||
}.onFailure { Log.w(TAG, "Timeout alarm schedule failed", it) }
|
||||
}
|
||||
|
||||
private fun cancelTimeout(context: Context) {
|
||||
timeoutRunnable?.let(handler::removeCallbacks)
|
||||
timeoutRunnable = null
|
||||
runCatching {
|
||||
context.applicationContext.getSystemService(AlarmManager::class.java)
|
||||
?.cancel(timeoutAlarmIntent(context.applicationContext, null))
|
||||
}
|
||||
}
|
||||
|
||||
private fun timeoutAlarmIntent(context: Context, token: String?): PendingIntent {
|
||||
val intent = Intent(context, F7CallActionReceiver::class.java).apply {
|
||||
action = F7CallActionReceiver.ACTION_CALL_TIMEOUT
|
||||
if (token != null) {
|
||||
putExtra(F7CallActionReceiver.EXTRA_ROOM_TOKEN, token)
|
||||
}
|
||||
}
|
||||
return PendingIntent.getBroadcast(
|
||||
context,
|
||||
ACTIVE_NOTIFICATION_ID + 2,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun showMissedNotification(context: Context, call: PendingCall) {
|
||||
F7NotificationChannels.ensureAll(context)
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
val roomName = call.displayName.ifBlank { call.title }
|
||||
val notificationId = MISSED_NOTIFICATION_BASE + kotlin.math.abs(call.roomToken.hashCode() % 1000)
|
||||
val pending = context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { launch ->
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
notificationId,
|
||||
Intent(launch).putExtra(PushIntents.EXTRA_ROOM_TOKEN, call.roomToken),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
val iconRes = context.applicationInfo.icon.takeIf { it != 0 }
|
||||
?: android.R.drawable.sym_call_missed
|
||||
// Локскрин — обезличенно (минимум информации, решение владельца); название комнаты
|
||||
// видно после разблокировки.
|
||||
val publicVersion = NotificationCompat.Builder(context, F7NotificationChannels.MESSAGES)
|
||||
.setSmallIcon(iconRes)
|
||||
.setContentTitle("F7cloud")
|
||||
.setContentText(context.getString(R.string.call_missed_public))
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.apply { if (pending != null) setContentIntent(pending) }
|
||||
.build()
|
||||
val notification = NotificationCompat.Builder(context, F7NotificationChannels.MESSAGES)
|
||||
.setSmallIcon(iconRes)
|
||||
.setContentTitle(context.getString(R.string.call_missed_title))
|
||||
.setContentText(roomName)
|
||||
.setCategory(NotificationCompat.CATEGORY_MISSED_CALL)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setPublicVersion(publicVersion)
|
||||
.apply { if (pending != null) setContentIntent(pending) }
|
||||
.build()
|
||||
runCatching { manager.notify(notificationId, notification) }
|
||||
.onFailure { Log.w(TAG, "Missed-call notification failed", it) }
|
||||
}
|
||||
|
||||
private fun touchActive(prefs: android.content.SharedPreferences) {
|
||||
prefs.edit().putLong(KEY_ACTIVE_AT, System.currentTimeMillis()).apply()
|
||||
}
|
||||
@@ -343,7 +521,7 @@ object F7IncomingCallQueue {
|
||||
val activeAt = prefs.getLong(KEY_ACTIVE_AT, 0L)
|
||||
if (activeAt <= 0L || System.currentTimeMillis() - activeAt > ACTIVE_RING_TTL_MS) {
|
||||
Log.w(TAG, "Clearing stale active call: $active")
|
||||
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
|
||||
clearActive(prefs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,10 @@ object F7NotificationChannels {
|
||||
setSound(sound, soundAttrs)
|
||||
}
|
||||
setShowBadge(true)
|
||||
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
|
||||
// Приватно на локскрине: система покажет public-версию (generic), а не полный
|
||||
// контент (минимум информации, решение владельца). Public-версия задаётся
|
||||
// per-notification в F7PushNotificationHelper / F7IncomingCallQueue.
|
||||
lockscreenVisibility = android.app.Notification.VISIBILITY_PRIVATE
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,21 @@ object F7PushNotificationHelper {
|
||||
}
|
||||
val iconRes = context.applicationInfo.icon.takeIf { it != 0 }
|
||||
?: android.R.drawable.stat_notify_chat
|
||||
// На локскрине — ОБЕЗЛИЧЕННАЯ версия (минимум информации, решение владельца): отправитель
|
||||
// и текст не утекают на заблокированном экране. Полный контент — после разблокировки.
|
||||
val genericText = when (type) {
|
||||
"chat", "room", "message" -> "Новое сообщение"
|
||||
"call" -> "Входящий звонок"
|
||||
else -> "Новое уведомление"
|
||||
}
|
||||
val publicVersion = NotificationCompat.Builder(context, channel)
|
||||
.setSmallIcon(iconRes)
|
||||
.setContentTitle("F7cloud")
|
||||
.setContentText(genericText)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(priority)
|
||||
.setContentIntent(pending)
|
||||
.build()
|
||||
val notification = NotificationCompat.Builder(context, channel)
|
||||
.setSmallIcon(iconRes)
|
||||
.setContentTitle(title)
|
||||
@@ -56,6 +71,8 @@ object F7PushNotificationHelper {
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setAutoCancel(true)
|
||||
.setPriority(priority)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setPublicVersion(publicVersion)
|
||||
.setContentIntent(pending)
|
||||
.build()
|
||||
manager.notify((System.currentTimeMillis() % Int.MAX_VALUE).toInt(), notification)
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<string name="call_action_accept">Принять</string>
|
||||
<string name="call_action_decline">Отклонить</string>
|
||||
<string name="incoming_call_subtitle">F7cloud звонок</string>
|
||||
<string name="call_missed_title">Пропущенный звонок</string>
|
||||
<string name="call_missed_public">Вы пропустили звонок</string>
|
||||
<plurals name="call_queue_waiting">
|
||||
<item quantity="one">Ещё %d звонок в очереди</item>
|
||||
<item quantity="few">Ещё %d звонка в очереди</item>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# CI — Gitea Actions
|
||||
|
||||
Пайплайн: [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml). Gitea на git.f7cloud.ru — **1.26.1**
|
||||
(Actions поддерживаются).
|
||||
|
||||
## Что делает
|
||||
|
||||
| Триггер | Джоба | Действия |
|
||||
|---|---|---|
|
||||
| push в `main`, любой PR | `verify` | `testDebugUnitTest` + `:app:lintRelease` + `:app:assembleDebug` (+ артефакт debug-APK) |
|
||||
| тег `v*` | `release` | подписанный `assembleRelease` + архив исходников (GPL §6) → Gitea Release |
|
||||
|
||||
## Предпосылка: self-hosted раннер (инфра ячейки B)
|
||||
|
||||
Проект тяжёлый (vendor talk-android + WebRTC), поэтому раннер — **self-hosted** с тёплым
|
||||
Gradle-кэшем, а не контейнер (иначе WebRTC качается каждый прогон). Требования к машине раннера —
|
||||
как у билд-машины: **JDK 21** (`/usr/lib/jvm/java-21-openjdk-amd64`) + **Android SDK 36**
|
||||
(`/opt/android-sdk`, platform android-36, build-tools 36.0.0).
|
||||
|
||||
Регистрация — **repo-токен** из Gitea → репозиторий `root/f7cloud_mobile` → Settings → Actions →
|
||||
Runners → Create new Runner (НЕ site-admin токен — тот регистрирует раннер общеинстансно и он
|
||||
подхватывает джобы чужих ячеек; проверено — брал `glb_adm/f7_talk`). Метки `self-hosted`+`f7-android`
|
||||
соответствуют `runs-on: [self-hosted, f7-android]`.
|
||||
|
||||
**Развёрнуто на инфре B (2026-07-09):** `act_runner` v0.2.11 + Node 20 (`/opt/nodejs`) + Docker
|
||||
(`docker.io`, нужен act_runner для старта, но джобы идут на **хосте** — метки `:host`, тёплый кэш
|
||||
`/root/.gradle`). Конфиг `/opt/act_runner/config.yaml` (labels `self-hosted:host`,`f7-android:host`,
|
||||
`container.docker_host: "-"`). systemd-сервис `act_runner.service` (enabled, Restart=always,
|
||||
env JAVA_HOME/ANDROID_HOME/PATH+node). Регистрация — repo-scope (только f7cloud_mobile).
|
||||
```bash
|
||||
systemctl status act_runner # состояние
|
||||
journalctl -u act_runner -f # логи
|
||||
```
|
||||
|
||||
## Secrets репозитория (Gitea → Settings → Actions → Secrets)
|
||||
|
||||
Для джобы `release`:
|
||||
- `F7_KEYSTORE_BASE64` — боевой keystore, `base64 -w0 release.jks`
|
||||
- `F7_KEYSTORE_PASSWORD`, `F7_KEY_ALIAS`, `F7_KEY_PASSWORD` — см. [RELEASE-SIGNING.md](RELEASE-SIGNING.md)
|
||||
|
||||
## GPL corresponding source
|
||||
|
||||
`scripts/package-source.sh` собирает архив всего исходника (вкл. vendor/talk-android GPL, LICENSE,
|
||||
NOTICE) — обязательство GPLv3 §6. Работает и вручную:
|
||||
```bash
|
||||
scripts/package-source.sh dist/ # → dist/f7cloud-mobile-v<ver>-<rev>-source.tar.gz
|
||||
```
|
||||
В CI прикладывается к каждому Gitea Release. До появления раннера — публиковать исходник
|
||||
вручную этим скриптом к каждой распространяемой сборке.
|
||||
|
||||
## Статус — ⏸️ ОТЛОЖЕНО (2026-07-09)
|
||||
|
||||
Раннер развёрнут на инфре B (act_runner 0.2.13 + Node 20 + Docker + systemd), диспетчеризация
|
||||
Gitea работает (джобы доходят до раннера). **НО host-исполнение act_runner 0.2.13 в нашем
|
||||
окружении сломано**: криво резолвит путь к шагам и JS-экшенам —
|
||||
`No such file or directory` на `.cache/act/<id>/act/workflow/0.sh` и `MODULE_NOT_FOUND` на
|
||||
`actions/checkout` (задвоение `hostexecutor/.cache/act`). Обойти на уровне workflow нельзя
|
||||
(ломается сам запуск шага). Workflow **запаркован** (триггер `workflow_dispatch`), сервис
|
||||
раннера остановлен и отключён от автозапуска — чтобы push'и не плодили падающие прогоны.
|
||||
|
||||
**Рабочий путь (когда вернёмся):** Docker-исполнение вместо host —
|
||||
- кастомный образ с JDK 21 + Android SDK 36 (в дефолтном ubuntu их нет);
|
||||
- volume для тёплого `~/.gradle` (vendor/WebRTC);
|
||||
- раннер с меткой на docker-образ (не `:host`); в docker-режиме пути корректны, JS-экшены работают;
|
||||
- регистрировать repo-токеном именно из **`f7cloud_mobile`** (id 24) — при отладке «repo-токены»
|
||||
ошибочно регистрировали к чужому `f7cloud_client` (id 4) или в user-scope; в Gitea осталось
|
||||
~5 дублей раннера `f7-b-android` (удалить в админке).
|
||||
|
||||
**Пока:** сборки и релизы — **вручную** на билд-машине (боевой ключ владельца из
|
||||
`/root/.f7cloud-keys/`), исходники — `scripts/package-source.sh`, Release в Gitea — через API.
|
||||
Что и делаем: релиз `v0.5.123` опубликован (APK + source) вручную.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Источник дизайна — Figma (попиксельная мобильная вёрстка)
|
||||
|
||||
Задача: мобильный клиент = мобильная версия F7 попиксельно. Источник истины — **Figma роли `design`**
|
||||
(не боевой forbion; glb устарел). Координация — mail/044 (через integrator-b). Приоритет:
|
||||
**Файлы → Почта → Конференции**, референс **360dp**, тема светлая.
|
||||
|
||||
## Доступ к Figma API
|
||||
|
||||
Токен — вне git: `/root/.f7cloud-keys/figma.env` (`FIGMA_TOKEN`, `FIGMA_FILE=ocVUCYCrfFUoqYYvlcLPBF`).
|
||||
```bash
|
||||
source /root/.f7cloud-keys/figma.env
|
||||
curl -H "X-Figma-Token: $FIGMA_TOKEN" "https://api.figma.com/v1/files/$FIGMA_FILE/nodes?ids=<ID>&depth=2"
|
||||
```
|
||||
⚠️ **Rate limit:** глубокие сканы всей страницы (`1:90` depth≥3) ловят **429**. Работать ТОЧЕЧНО —
|
||||
запрашивать конкретные node ID нужного экрана с малой глубиной. Рендер кадра в PNG:
|
||||
`GET /v1/images/$FIGMA_FILE?ids=<ID>&format=png&scale=2`.
|
||||
|
||||
## Карта файла (страницы)
|
||||
|
||||
| ID | Страница |
|
||||
|---|---|
|
||||
| `1:90` | 🟢 Дизайн облачного рабочего места — ОСНОВНОЙ дизайн (65 секций по модулям) |
|
||||
| `0:1` | Брендбук (токены: TYPOGRAPHY 1:116, цвета 97:8269) |
|
||||
| `2095:46379` | ⚫️ Пробная тёмная тема (на будущее) |
|
||||
|
||||
Мобильные экраны — кадры **360px** (полноэкранные ~360x800), разбросаны по секциям модулей и по
|
||||
верхнему уровню `1:90`. Секции: Файлы `17:4427`/`948:65419`, Почта `97:16912`/`1609:71537`,
|
||||
Конференции/Встречи `4:1166`, Конференция `1002:67493`. NB: ссылка владельца `1327:29454` —
|
||||
ДЕСКТОП (1440), не мобиль.
|
||||
|
||||
## Снятые токены (сверено с Figma)
|
||||
|
||||
**Цвета** — совпадают с текущим `F7Colors` (палитра снята точно ранее): Primary `#70B62B`,
|
||||
Background `#FBFBFB`, Surface `#FFFFFF`, SurfaceMuted `#F5F5F5`, TextPrimary `#151515`,
|
||||
TextSecondary `#808080`, Border `#E6E6E6`, PrimaryLight `#F1F8EA`.
|
||||
|
||||
**Типографика** — шрифт **Raleway** (medium 500 / semibold 600), шкала 14/18/20/24px
|
||||
(lh 20/22/24/26). В `F7Typography` есть база; точные размеры конкретных экранов сверяются поэкранно.
|
||||
|
||||
**Spacing/радиусы** — раньше НЕ было токенов (аудит: ~1087 инлайновых .dp). Заведены
|
||||
`F7Spacing` (4dp-сетка) + `F7Radius`. Точные значения экрана — с макета.
|
||||
|
||||
## Процесс попиксельной вёрстки (на экран)
|
||||
|
||||
1. Найти node ID мобильного экрана (360) в Figma, отрендерить PNG-эталон (scale=2).
|
||||
2. Извлечь измерения: размеры/паддинги/цвета/текст-стили дочерних узлов (точечный nodes-запрос).
|
||||
3. Свести к токенам (`F7Colors`/`F7Spacing`/`F7Typography`), реализовать в Compose.
|
||||
4. Верификация side-by-side: PNG Figma ↔ скриншот приложения (владелец — физ.телефон + BlueStacks;
|
||||
universal debug-APK со всеми ABI готов, дефолт-URL glb).
|
||||
@@ -27,15 +27,16 @@ dependencies {
|
||||
implementation project(':core:auth')
|
||||
implementation project(':core:network')
|
||||
implementation project(':core:designsystem')
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
|
||||
implementation libs.coroutines.android
|
||||
def composeBom = platform(libs.compose.bom)
|
||||
implementation composeBom
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.compose.foundation:foundation'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
|
||||
implementation 'androidx.activity:activity-compose:1.10.1'
|
||||
implementation 'androidx.core:core-ktx:1.15.0'
|
||||
implementation 'io.coil-kt:coil-compose:2.7.0'
|
||||
implementation 'io.coil-kt:coil-svg:2.7.0'
|
||||
implementation libs.compose.ui
|
||||
implementation libs.compose.material3
|
||||
implementation libs.compose.foundation
|
||||
implementation libs.lifecycle.viewmodel.compose
|
||||
implementation libs.activity.compose
|
||||
implementation libs.core.ktx
|
||||
implementation libs.coil.compose
|
||||
implementation libs.coil.svg
|
||||
testImplementation libs.junit
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<?xml version="1.0" ?>
|
||||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<ID>ArgumentListWrapping:CalendarApiClient.kt$CalendarApiClient$('/')</ID>
|
||||
<ID>ArgumentListWrapping:CalendarApiClient.kt$CalendarApiClient$(query.trim(), Charsets.UTF_8.name())</ID>
|
||||
<ID>ArgumentListWrapping:CalendarComponents.kt$("$base/themes/forbion/images/calendar/blank-box-gray.svg", "Незапланированные задачи")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarComponents.kt$("$base/themes/forbion/images/calendar/calendar-edit-export-white.svg", "Синхронизация с телефоном")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarComponents.kt$("+${dayEvents.size - 4}", style = MaterialTheme.typography.labelSmall, color = F7Colors.TextMuted)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarComponents.kt$("\n")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarComponents.kt$(1.dp, if (day == selectedDay) F7Colors.Primary else F7Colors.Border, RoundedCornerShape(12.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarComponents.kt$(12.dp)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarComponents.kt$(cal.accountName, style = MaterialTheme.typography.labelSmall, color = F7Colors.TextMuted)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarComponents.kt$(event.location, style = MaterialTheme.typography.labelSmall, color = F7Colors.TextMuted, maxLines = 1)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("$count событ.", style = MaterialTheme.typography.labelSmall, color = F7Colors.TextSecondary)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("${s.name} · ${s.email}", modifier = Modifier.clickable { onAddAttendee(s) }.padding(start = 8.dp), color = F7Colors.Primary)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("ru")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Весь день")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Комната Talk")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Корзина пуста", color = F7Colors.TextSecondary, modifier = Modifier.padding(top = 12.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Нет задач без срока", color = F7Colors.TextSecondary, modifier = Modifier.padding(top = 12.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(10.dp)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(16.dp)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(8.dp)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.clickable { onDraftChange(draft.copy(calendarHref = cal.href)) }, verticalAlignment = Alignment.CenterVertically)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.clickable { onDraftChange(draft.copy(classification = cl)) }, verticalAlignment = Alignment.CenterVertically)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.clickable { onDraftChange(draft.copy(recurrence = preset, customRrule = "")) }, verticalAlignment = Alignment.CenterVertically)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.clickable { onDraftChange(draft.copy(status = st)) }, verticalAlignment = Alignment.CenterVertically)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.fillMaxWidth().clickable { onDraftChange(draft.copy(mode = mode)) }, verticalAlignment = Alignment.CenterVertically)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.fillMaxWidth().heightIn(max = 520.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.padding(16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(addTalkRoom = it)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(allDay = it)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(calendarHref = cal.href)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(categories = it)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(checked = draft.addTalkRoom, onCheckedChange = { onDraftChange(draft.copy(addTalkRoom = it)) })</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(checked = draft.allDay, onCheckedChange = { onDraftChange(draft.copy(allDay = it)) })</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(classification = cl)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(description = it)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(addTalkRoom = it))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(allDay = it))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(calendarHref = cal.href))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(categories = it))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(classification = cl))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(description = it))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(endTime = it))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(mode = mode))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(name = it))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(recurrence = preset))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(recurrence = preset, customRrule = ""))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(reminderMinutes = it))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(startTime = it))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(status = st))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(subscriptionUrl = it))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(title = it))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(endTime = it)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(if (month == YearMonth.from(selectedDay)) F7Colors.PrimaryLight else F7Colors.Surface)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(if (state.deleting) "Удаление…" else "Удалить", color = F7Colors.Error)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(java.time.format.TextStyle.FULL_STANDALONE, java.util.Locale.forLanguageTag("ru"))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(max = 520.dp)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(mode = mode)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(month.month.getDisplayName(java.time.format.TextStyle.FULL_STANDALONE, java.util.Locale.forLanguageTag("ru")), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(name = it)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(onClick = onDelete, enabled = !state.deleting)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(recurrence = preset)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(recurrence = preset, customRrule = "")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(rememberScrollState())</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(reminderMinutes = it)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(s)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(s.address.ifBlank { s.name }, modifier = Modifier.clickable { onApplyLocation(s) }.padding(start = 8.dp), color = F7Colors.Primary)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(selected = draft.calendarHref == cal.href, onClick = { onDraftChange(draft.copy(calendarHref = cal.href)) })</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(selected = draft.classification == cl, onClick = { onDraftChange(draft.copy(classification = cl)) })</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(selected = draft.recurrence == preset, onClick = { onDraftChange(draft.copy(recurrence = preset)) })</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(start = 8.dp)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(startTime = it)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(status = st)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(subscriptionUrl = it)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(text = if (saving) "Сохранение…" else "Создать", onClick = onSave, enabled = !saving)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(text = if (state.saving) "Сохранение…" else "Сохранить", onClick = onSave, enabled = !state.saving)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(title = it)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(top = 12.dp)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.attendeeQuery, onValueChange = onSearchAttendees, label = "Поиск участника")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.categories, onValueChange = { onDraftChange(draft.copy(categories = it)) }, label = "Категории")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.description, onValueChange = { onDraftChange(draft.copy(description = it)) }, label = "Описание")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.endTime, onValueChange = { onDraftChange(draft.copy(endTime = it)) }, label = "Окончание")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.name, onValueChange = { onDraftChange(draft.copy(name = it)) }, label = "Название")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.reminderMinutes.toString(), onValueChange = { v -> v.toIntOrNull()?.let { onDraftChange(draft.copy(reminderMinutes = it)) } }, label = "Напоминание (мин)")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.startTime, onValueChange = { onDraftChange(draft.copy(startTime = it)) }, label = "Начало")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.subscriptionUrl, onValueChange = { onDraftChange(draft.copy(subscriptionUrl = it)) }, label = "URL подписки (WebCal)")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.title, onValueChange = { onDraftChange(draft.copy(title = it)) }, label = "Название")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$("MMM d", Locale.ENGLISH)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$("MMM d, yyyy", Locale.ENGLISH)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$("dd.MM.yyyy HH:mm")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(DateTimeFormatter.ofPattern("MMM d", Locale.ENGLISH))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(ZoneId.systemDefault())</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(epoch)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(session, LocalDate.of(st.visibleYear, 1, 1), LocalDate.of(st.visibleYear, 12, 31), visibleCalendarHrefs)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(session, st.selectedDay, st.selectedDay, visibleCalendarHrefs)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(st.visibleYear, 1, 1)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(st.visibleYear, 12, 31)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(start)</ID>
|
||||
<ID>CyclomaticComplexMethod:CalendarScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun CalendarScreen( session: AuthSession, modifier: Modifier = Modifier, focusEventUid: String? = null, settingsRequest: Int = 0, sidebarOpen: Boolean = false, onSidebarOpenChange: (Boolean) -> Unit = {}, onFocusEventConsumed: () -> Unit = {}, onUnauthorized: () -> Unit = {}, )</ID>
|
||||
<ID>CyclomaticComplexMethod:CalendarSyncEngine.kt$CalendarSyncEngine$fun run( session: AuthSession, deviceCalendarId: Long, ): CalendarSyncResult</ID>
|
||||
<ID>ForEachOnRange:CalendarComponents.kt$0..23</ID>
|
||||
<ID>ForEachOnRange:CalendarComponents.kt$0..24</ID>
|
||||
<ID>ImportOrdering:CalendarComponents.kt$import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.offset import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.sp import java.time.LocalTime import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7ListCard import ru.forbion.f7cloud.core.designsystem.F7OutlinedField import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton import ru.forbion.f7cloud.core.designsystem.F7TextButton import java.time.Instant import java.time.LocalDate import java.time.YearMonth import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale</ID>
|
||||
<ID>ImportOrdering:CalendarRepository.kt$import okhttp3.OkHttpClient import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.auth.OcsUserResolver import ru.forbion.f7cloud.core.network.CalendarAlarmData import ru.forbion.f7cloud.core.network.CalendarAttendeeData import ru.forbion.f7cloud.core.network.CalendarEventData import ru.forbion.f7cloud.core.network.CalendarIcs import ru.forbion.f7cloud.core.network.CalDavClient import ru.forbion.f7cloud.core.network.DavCalendar import ru.forbion.f7cloud.core.network.DavEvent import ru.forbion.f7cloud.core.network.DavTask import ru.forbion.f7cloud.core.network.DavTrashEvent import ru.forbion.f7cloud.core.network.NetworkFactory import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime import java.time.YearMonth import java.time.ZoneId import java.time.ZoneOffset</ID>
|
||||
<ID>ImportOrdering:CalendarScreen.kt$import android.Manifest import android.content.pm.PackageManager import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshotFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.delay import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler import java.time.format.DateTimeFormatter import java.util.Locale</ID>
|
||||
<ID>Indentation:CalendarScreen.kt$ </ID>
|
||||
<ID>LongMethod:CalendarComponents.kt$@Composable fun CalendarDayTimeline( session: AuthSession, day: LocalDate, events: List<CalendarEventItem>, viewMode: CalendarViewMode, onViewMode: (CalendarViewMode) -> Unit, onEventClick: (CalendarEventItem) -> Unit, modifier: Modifier = Modifier, )</ID>
|
||||
<ID>LongMethod:CalendarScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun CalendarScreen( session: AuthSession, modifier: Modifier = Modifier, focusEventUid: String? = null, settingsRequest: Int = 0, sidebarOpen: Boolean = false, onSidebarOpenChange: (Boolean) -> Unit = {}, onFocusEventConsumed: () -> Unit = {}, onUnauthorized: () -> Unit = {}, )</ID>
|
||||
<ID>LongMethod:CalendarSyncEngine.kt$CalendarSyncEngine$fun run( session: AuthSession, deviceCalendarId: Long, ): CalendarSyncResult</ID>
|
||||
<ID>LoopWithTooManyJumpStatements:CalendarApiClient.kt$CalendarApiClient$for</ID>
|
||||
<ID>LoopWithTooManyJumpStatements:CalendarSyncEngine.kt$CalendarSyncEngine$for</ID>
|
||||
<ID>MaxLineLength:CalendarApiClient.kt$CalendarApiClient$val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/calendar/v1/autocompletion/location?search=${java.net.URLEncoder.encode(query.trim(), Charsets.UTF_8.name())}"</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Column</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$F7OutlinedField(value = draft.categories, onValueChange = { onDraftChange(draft.copy(categories = it)) }, label = "Категории")</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$F7OutlinedField(value = draft.description, onValueChange = { onDraftChange(draft.copy(description = it)) }, label = "Описание")</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$F7OutlinedField(value = draft.reminderMinutes.toString(), onValueChange = { v -> v.toIntOrNull()?.let { onDraftChange(draft.copy(reminderMinutes = it)) } }, label = "Напоминание (мин)")</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$F7OutlinedField(value = draft.startTime, onValueChange = { onDraftChange(draft.copy(startTime = it)) }, label = "Начало")</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$F7OutlinedField(value = draft.subscriptionUrl, onValueChange = { onDraftChange(draft.copy(subscriptionUrl = it)) }, label = "URL подписки (WebCal)")</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$RadioButton(selected = draft.calendarHref == cal.href, onClick = { onDraftChange(draft.copy(calendarHref = cal.href)) })</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Row</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Text("${s.name} · ${s.email}", modifier = Modifier.clickable { onAddAttendee(s) }.padding(start = 8.dp), color = F7Colors.Primary)</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Text("Комната Talk"); Switch(checked = draft.addTalkRoom, onCheckedChange = { onDraftChange(draft.copy(addTalkRoom = it)) })</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Text(month.month.getDisplayName(java.time.format.TextStyle.FULL_STANDALONE, java.util.Locale.forLanguageTag("ru")), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Text(s.address.ifBlank { s.name }, modifier = Modifier.clickable { onApplyLocation(s) }.padding(start = 8.dp), color = F7Colors.Primary)</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$confirmButton = { F7PrimaryButton(text = if (state.saving) "Сохранение…" else "Сохранить", onClick = onSave, enabled = !state.saving) }</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$if (isEdit) TextButton(onClick = onDelete, enabled = !state.deleting) { Text(if (state.deleting) "Удаление…" else "Удалить", color = F7Colors.Error) }</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$"${start.format(DateTimeFormatter.ofPattern("MMM d", Locale.ENGLISH))} – ${end.format(DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH))}"</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$.</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.DAY -> st.copy(selectedDay = st.selectedDay.minusDays(1), visibleMonth = YearMonth.from(st.selectedDay.minusDays(1)))</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.DAY -> st.copy(selectedDay = st.selectedDay.plusDays(1), visibleMonth = YearMonth.from(st.selectedDay.plusDays(1)))</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.LIST -> st.copy(selectedDay = st.selectedDay.minusDays(14), visibleMonth = YearMonth.from(st.selectedDay.minusDays(14)))</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.LIST -> st.copy(selectedDay = st.selectedDay.plusDays(14), visibleMonth = YearMonth.from(st.selectedDay.plusDays(14)))</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.MONTH -> st.copy(visibleMonth = st.visibleMonth.minusMonths(1), selectedDay = st.visibleMonth.minusMonths(1).atDay(1))</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.MONTH -> st.copy(visibleMonth = st.visibleMonth.plusMonths(1), selectedDay = st.visibleMonth.plusMonths(1).atDay(1))</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.WEEK -> st.copy(selectedDay = st.selectedDay.minusWeeks(1), visibleMonth = YearMonth.from(st.selectedDay.minusWeeks(1)))</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.WEEK -> st.copy(selectedDay = st.selectedDay.plusWeeks(1), visibleMonth = YearMonth.from(st.selectedDay.plusWeeks(1)))</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.YEAR -> repository.loadRange(session, LocalDate.of(st.visibleYear, 1, 1), LocalDate.of(st.visibleYear, 12, 31), visibleCalendarHrefs)</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(deleting = false, createDialogOpen = false, eventDetail = null, focusedEventUid = null, snackMessage = "Удалено")</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(selectedDay = day, visibleMonth = YearMonth.from(day), eventDetail = event, focusedEventUid = event.uid)</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(selectedDay = day, visibleMonth = YearMonth.from(day), visibleYear = day.year, datePickerOpen = false)</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$classification = EventClassification.entries.firstOrNull { it.icsValue == event.classification } ?: EventClassification.PUBLIC</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$private fun formatLastSync(epoch: Long)</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$return if (visible.isEmpty()) _state.value.events else _state.value.events.filter { it.calendarHref in visible || it.calendarHref.isBlank() }</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$snackMessage = if (showSnack) "Синхронизация завершена (${result.pushedToDevice + result.pushedToServer + result.updated + result.linked})" else _state.value.snackMessage</ID>
|
||||
<ID>MaximumLineLength:CalendarApiClient.kt$CalendarApiClient$ </ID>
|
||||
<ID>MaximumLineLength:CalendarComponents.kt$ </ID>
|
||||
<ID>MaximumLineLength:CalendarExtendedUi.kt$ </ID>
|
||||
<ID>MaximumLineLength:CalendarRepository.kt$CalendarRepository$ </ID>
|
||||
<ID>MaximumLineLength:CalendarViewModel.kt$CalendarViewModel$ </ID>
|
||||
<ID>NoMultipleSpaces:CalendarSyncEngine.kt$CalendarSyncEngine.Companion$ </ID>
|
||||
<ID>NoUnusedImports:CalendarApiClient.kt$ru.forbion.f7cloud.feature.calendar.CalendarApiClient.kt</ID>
|
||||
<ID>NoUnusedImports:CalendarComponents.kt$ru.forbion.f7cloud.feature.calendar.CalendarComponents.kt</ID>
|
||||
<ID>NoUnusedImports:CalendarExtendedUi.kt$ru.forbion.f7cloud.feature.calendar.CalendarExtendedUi.kt</ID>
|
||||
<ID>NoUnusedImports:CalendarModels.kt$ru.forbion.f7cloud.feature.calendar.CalendarModels.kt</ID>
|
||||
<ID>NoUnusedImports:CalendarScreen.kt$ru.forbion.f7cloud.feature.calendar.CalendarScreen.kt</ID>
|
||||
<ID>NoUnusedImports:CalendarSyncEngine.kt$ru.forbion.f7cloud.feature.calendar.CalendarSyncEngine.kt</ID>
|
||||
<ID>NoUnusedImports:DeviceCalendarClient.kt$ru.forbion.f7cloud.feature.calendar.DeviceCalendarClient.kt</ID>
|
||||
<ID>ParameterListWrapping:CalendarViewModel.kt$CalendarViewModel$(draft: CalendarCreateBookDraft)</ID>
|
||||
<ID>UnusedPrivateMember:CalendarComponents.kt$@Composable private fun CalendarIconButton( iconUrl: String, contentDescription: String?, onClick: () -> Unit, )</ID>
|
||||
<ID>UnusedPrivateMember:CalendarComponents.kt$@Composable private fun CalendarNavButton(text: String, onClick: () -> Unit)</ID>
|
||||
<ID>UnusedPrivateProperty:CalendarComponents.kt$private val toolbarDateFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH)</ID>
|
||||
<ID>UnusedPrivateProperty:CalendarSyncEngine.kt$CalendarSyncEngine$val mappedDevIds2 = mappings.map { it.deviceEventId }.toSet()</ID>
|
||||
<ID>UseCheckOrError:CalendarRepository.kt$CalendarRepository$throw IllegalStateException("Календари не найдены")</ID>
|
||||
<ID>UseCheckOrError:CalendarRepository.kt$CalendarRepository$throw IllegalStateException("Не удалось загрузить события календаря: $lastError")</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$;</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$F7PrimaryButton(text = if (saving) "Сохранение…" else "Создать", onClick = onSave, enabled = !saving)</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$F7PrimaryButton(text = if (state.saving) "Сохранение…" else "Сохранить", onClick = onSave, enabled = !state.saving)</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$Text(if (state.deleting) "Удаление…" else "Удалить", color = F7Colors.Error)</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onAddAttendee(s)</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onApplyLocation(s)</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(addTalkRoom = it))</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(calendarHref = cal.href))</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(categories = it))</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(classification = cl))</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(description = it))</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(mode = mode))</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(recurrence = preset, customRrule = ""))</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(reminderMinutes = it))</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(startTime = it))</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(subscriptionUrl = it))</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$s.name</ID>
|
||||
<ID>Wrapping:CalendarRepository.kt$CalendarRepository$;</ID>
|
||||
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$;</ID>
|
||||
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(loading = false, error = it.message, unauthorized = it is UnauthorizedException)</ID>
|
||||
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$it.calendarHref in visible || it.calendarHref.isBlank()</ID>
|
||||
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$it.icsValue == event.classification</ID>
|
||||
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel${ _state.value = _state.value.copy(createBookOpen = false, createBookDraft = CalendarCreateBookDraft()) }</ID>
|
||||
</CurrentIssues>
|
||||
</SmellBaseline>
|
||||
@@ -46,7 +46,6 @@ class CalendarApiClient {
|
||||
.applyOcsJson()
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
val json = parseJsonObject(response.body?.string().orEmpty(), "autocomplete attendee")
|
||||
val data = json.optJSONArray("data") ?: json.optJSONObject("ocs")?.optJSONArray("data") ?: return emptyList()
|
||||
return buildList {
|
||||
@@ -77,7 +76,6 @@ class CalendarApiClient {
|
||||
.applyOcsJson()
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
val json = parseJsonObject(response.body?.string().orEmpty(), "autocomplete location")
|
||||
val data = json.optJSONArray("data") ?: return emptyList()
|
||||
return buildList {
|
||||
@@ -104,7 +102,6 @@ class CalendarApiClient {
|
||||
.applyOcsJson()
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (!response.isSuccessful) error("Calendar config failed HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
@@ -124,7 +121,6 @@ class CalendarApiClient {
|
||||
.applyOcsJson()
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
val json = parseJsonObject(response.body?.string().orEmpty(), "create talk room")
|
||||
val meta = json.ocsMeta()
|
||||
if (!isOcsSuccess(meta)) error("Не удалось создать комнату Talk")
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -388,46 +387,60 @@ private fun CalendarIconButton(
|
||||
fun CalendarMonthGrid(
|
||||
month: YearMonth,
|
||||
selectedDay: LocalDate,
|
||||
daysWithEvents: Set<LocalDate>,
|
||||
eventsByDay: Map<LocalDate, List<CalendarEventItem>>,
|
||||
onSelectDay: (LocalDate) -> Unit,
|
||||
) {
|
||||
val days = CalendarRepository.monthGridDays(month)
|
||||
val today = LocalDate.now()
|
||||
// Живая тема (_calendar-month-view-mobile.css): месяц — белая карточка r12 с рамкой,
|
||||
// внутри дни недели — круги 40 (#F5F5F5, 16/500) и ячейки-карточки #FDFDFD r4
|
||||
// с тенью, зазор 4.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(F7Colors.Surface)
|
||||
.background(Color.White)
|
||||
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
|
||||
.padding(8.dp),
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
weekDayLabels.forEach { label ->
|
||||
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(F7Colors.Grey2),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = F7Colors.TextMuted,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
days.chunked(7).forEach { week ->
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
week.forEach { day ->
|
||||
val inMonth = day.month == month.month
|
||||
val selected = day == selectedDay
|
||||
val hasEvents = daysWithEvents.contains(day)
|
||||
val dayEvents = eventsByDay[day].orEmpty()
|
||||
CalendarDayCell(
|
||||
day = day.dayOfMonth,
|
||||
inMonth = inMonth,
|
||||
isToday = day == today,
|
||||
selected = selected,
|
||||
hasEvents = hasEvents,
|
||||
preview = dayEvents.firstOrNull()?.summary.orEmpty(),
|
||||
selected = day == selectedDay,
|
||||
events = dayEvents,
|
||||
onClick = { onSelectDay(day) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
@@ -443,63 +456,86 @@ private fun CalendarDayCell(
|
||||
inMonth: Boolean,
|
||||
isToday: Boolean,
|
||||
selected: Boolean,
|
||||
hasEvents: Boolean,
|
||||
preview: String,
|
||||
events: List<CalendarEventItem>,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val bg = when {
|
||||
selected -> F7Colors.PrimaryLight
|
||||
isToday -> F7Colors.PrimaryLight.copy(alpha = 0.55f)
|
||||
else -> F7Colors.Surface
|
||||
}
|
||||
val borderColor = when {
|
||||
selected -> F7Colors.Primary
|
||||
isToday -> F7Colors.PrimaryDark
|
||||
else -> F7Colors.BorderLight
|
||||
}
|
||||
Box(
|
||||
// Живая тема: ячейка — карточка #FDFDFD r4 с тонкой тенью, высота 120, паддинг 4;
|
||||
// номер дня 14/500 СПРАВА-сверху (чужой месяц — серый); «сегодня» — зелёная плашка
|
||||
// 40×24 r4 с белым числом; выбранный день подсвечиваем #ECF9DE (наша адаптация тапа).
|
||||
Column(
|
||||
modifier = modifier
|
||||
.aspectRatio(1f)
|
||||
.padding(2.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(bg)
|
||||
.border(1.dp, borderColor, RoundedCornerShape(8.dp))
|
||||
.height(120.dp)
|
||||
.shadow(
|
||||
elevation = 1.dp,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
spotColor = F7Colors.Border,
|
||||
)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(if (selected) F7Colors.PrimaryLight else Color(0xFFFDFDFD))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(2.dp),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
.padding(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = day.toString(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
||||
color = when {
|
||||
!inMonth -> F7Colors.TextMuted
|
||||
selected -> F7Colors.PrimaryDark
|
||||
else -> F7Colors.TextPrimary
|
||||
},
|
||||
)
|
||||
if (hasEvents && inMonth && preview.isNotBlank()) {
|
||||
Text(
|
||||
preview,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = F7Colors.PrimaryDark,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 1.dp),
|
||||
)
|
||||
} else if (hasEvents && inMonth) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
if (isToday) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp)
|
||||
.size(5.dp)
|
||||
.clip(CircleShape)
|
||||
.width(40.dp)
|
||||
.height(24.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(F7Colors.Primary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = day.toString(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = day.toString(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (inMonth) F7Colors.TextPrimary else F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
events.take(3).forEach { event ->
|
||||
CalendarDayEventPill(event.summary.ifBlank { "Событие" }, muted = !inMonth)
|
||||
}
|
||||
if (events.size > 3) {
|
||||
Text(
|
||||
"+${events.size - 3}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = F7Colors.Primary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CalendarDayEventPill(text: String, muted: Boolean = false) {
|
||||
// Живая тема: пилюля события — фон #ECF9DE, рамка #70B62B, r4, текст 10/12;
|
||||
// вне текущего месяца — серые с белым текстом.
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(if (muted) F7Colors.TextSecondary else F7Colors.PrimaryLight)
|
||||
.border(1.dp, if (muted) F7Colors.TextSecondary else F7Colors.Primary, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
fontSize = 10.sp,
|
||||
lineHeight = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (muted) Color.White else F7Colors.TextPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -793,12 +829,14 @@ fun CalendarEventRow(
|
||||
|
||||
@Composable
|
||||
private fun CalendarEventChip(event: CalendarEventItem, onClick: () -> Unit) {
|
||||
// Живая тема: пилюля события — #ECF9DE с рамкой #70B62B, r4, текст 12/16
|
||||
Text(
|
||||
event.summary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(F7Colors.Primary.copy(alpha = 0.15f))
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(F7Colors.PrimaryLight)
|
||||
.border(1.dp, F7Colors.Primary, RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -1057,7 +1095,13 @@ fun CalendarEventDetailSheet(
|
||||
.padding(bottom = 28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(event.summary, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
// Живая тема: заголовки модалок 18/600
|
||||
Text(
|
||||
event.summary,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
CalendarDetailRow(
|
||||
iconUrl = "$base/themes/forbion/images/calendar/event-clock.svg",
|
||||
label = "Время",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package ru.forbion.f7cloud.feature.calendar
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Дисковый кэш «сырых» REPORT-ответов CalDAV (события по диапазону), ключуемый
|
||||
* CTag'ом календаря: CTag не изменился → события читаются с диска, сеть не трогаем.
|
||||
*
|
||||
* Храним именно сырой XML (а не сериализованные модели) — переиспользуем боевой
|
||||
* парсер CalDavClient и не заводим хрупкий JSON-маппинг ~20 полей события.
|
||||
* Формат файла: первая строка — CTag на момент загрузки, дальше XML.
|
||||
*/
|
||||
class CalendarEventsCache(private val dir: File) {
|
||||
|
||||
constructor(context: Context) : this(File(context.cacheDir, "caldav_events"))
|
||||
|
||||
/** XML событий, если кэш есть и его CTag совпадает с текущим; иначе null. */
|
||||
fun get(accountKey: String, calendarHref: String, rangeKey: String, ctag: String): String? {
|
||||
if (ctag.isBlank()) return null
|
||||
val f = entryFile(accountKey, calendarHref, rangeKey)
|
||||
if (!f.isFile) return null
|
||||
return runCatching {
|
||||
val text = f.readText()
|
||||
val nl = text.indexOf('\n')
|
||||
if (nl > 0 && text.substring(0, nl) == ctag) text.substring(nl + 1) else null
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun put(accountKey: String, calendarHref: String, rangeKey: String, ctag: String, xml: String) {
|
||||
if (ctag.isBlank()) return
|
||||
runCatching {
|
||||
dir.mkdirs()
|
||||
entryFile(accountKey, calendarHref, rangeKey).writeText("$ctag\n$xml")
|
||||
prune()
|
||||
}
|
||||
}
|
||||
|
||||
/** Чистка устаревших записей (диапазоны уезжают со временем — файлы копятся). */
|
||||
private fun prune(maxAgeMillis: Long = MAX_AGE_MILLIS) {
|
||||
val now = System.currentTimeMillis()
|
||||
dir.listFiles()?.forEach { f ->
|
||||
if (now - f.lastModified() > maxAgeMillis) f.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun entryFile(accountKey: String, calendarHref: String, rangeKey: String): File =
|
||||
File(dir, md5("$accountKey|$calendarHref|$rangeKey") + ".xml")
|
||||
|
||||
private fun md5(s: String): String =
|
||||
MessageDigest.getInstance("MD5").digest(s.toByteArray())
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
private companion object {
|
||||
const val MAX_AGE_MILLIS = 30L * 24 * 3600 * 1000 // 30 дней
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import java.time.ZoneOffset
|
||||
|
||||
class CalendarRepository(
|
||||
private val apiClient: CalendarApiClient = CalendarApiClient(),
|
||||
// Кэш событий по CTag (null — без кэша, поведение как раньше)
|
||||
private val eventsCache: CalendarEventsCache? = null,
|
||||
) {
|
||||
fun listCalendars(session: AuthSession): List<DavCalendar> = openDavContext(session).calendars
|
||||
|
||||
@@ -38,7 +40,7 @@ class CalendarRepository(
|
||||
val ctx = openDavContext(session)
|
||||
val startInstant = rangeStart.atStartOfDay(ZoneOffset.UTC).toInstant()
|
||||
val endInstant = rangeEnd.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()
|
||||
return fetchEvents(ctx, startInstant, endInstant, visibleHrefs)
|
||||
return fetchEvents(ctx, startInstant, endInstant, visibleHrefs, accountKey(session))
|
||||
}
|
||||
|
||||
fun loadUnscheduledTasks(session: AuthSession): List<CalendarTaskItem> {
|
||||
@@ -210,13 +212,25 @@ class CalendarRepository(
|
||||
rangeStart: Instant,
|
||||
rangeEnd: Instant,
|
||||
visibleHrefs: Set<String>,
|
||||
accountKey: String,
|
||||
): List<CalendarEventItem> {
|
||||
val calendars = if (visibleHrefs.isEmpty()) ctx.calendars else ctx.calendars.filter { it.href in visibleHrefs }
|
||||
val rangeKey = "${rangeStart.epochSecond}-${rangeEnd.epochSecond}"
|
||||
val events = mutableListOf<DavEvent>()
|
||||
var successCount = 0
|
||||
var lastError: String? = null
|
||||
for (cal in calendars.take(12)) {
|
||||
runCatching { CalDavClient.queryEventsInRange(ctx.client, cal, rangeStart, rangeEnd) }
|
||||
runCatching {
|
||||
// CTag календаря не менялся → сырой REPORT-ответ берём с диска, сеть не трогаем
|
||||
val cachedXml = eventsCache?.get(accountKey, cal.href, rangeKey, cal.ctag)
|
||||
if (cachedXml != null) {
|
||||
CalDavClient.parseEventsXml(cachedXml, cal)
|
||||
} else {
|
||||
val xml = CalDavClient.queryEventsRawXml(ctx.client, cal, rangeStart, rangeEnd)
|
||||
eventsCache?.put(accountKey, cal.href, rangeKey, cal.ctag, xml)
|
||||
CalDavClient.parseEventsXml(xml, cal)
|
||||
}
|
||||
}
|
||||
.onSuccess { successCount++; events += it }
|
||||
.onFailure { lastError = it.message }
|
||||
}
|
||||
@@ -226,6 +240,9 @@ class CalendarRepository(
|
||||
return events.distinctBy { it.uid }.sortedBy { it.startEpochMilli }.map { it.toItem() }
|
||||
}
|
||||
|
||||
private fun accountKey(session: AuthSession): String =
|
||||
"${session.serverUrl}|${session.username}"
|
||||
|
||||
private fun DavEvent.toItem() = CalendarEventItem(
|
||||
uid = uid,
|
||||
href = href,
|
||||
|
||||
@@ -343,7 +343,6 @@ fun CalendarScreen(
|
||||
CalendarMonthGrid(
|
||||
month = state.visibleMonth,
|
||||
selectedDay = state.selectedDay,
|
||||
daysWithEvents = vm.daysWithEvents(),
|
||||
eventsByDay = eventsByDay,
|
||||
onSelectDay = vm::selectDay,
|
||||
)
|
||||
|
||||
@@ -62,7 +62,9 @@ data class CalendarUiState(
|
||||
|
||||
class CalendarViewModel(
|
||||
context: Context,
|
||||
private val repository: CalendarRepository = CalendarRepository(),
|
||||
private val repository: CalendarRepository = CalendarRepository(
|
||||
eventsCache = CalendarEventsCache(context.applicationContext),
|
||||
),
|
||||
) : ViewModel() {
|
||||
private val appContext = context.applicationContext
|
||||
private val deviceClient = DeviceCalendarClient(appContext)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package ru.forbion.f7cloud.feature.calendar
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
/**
|
||||
* Кэш «сырых» REPORT-ответов CalDAV: CTag совпал → XML с диска; изменился/пусто → null.
|
||||
*/
|
||||
class CalendarEventsCacheTest {
|
||||
|
||||
@get:Rule
|
||||
val tmp = TemporaryFolder()
|
||||
|
||||
private fun cache() = CalendarEventsCache(tmp.newFolder("caldav"))
|
||||
|
||||
@Test fun hit_when_ctag_matches() {
|
||||
val c = cache()
|
||||
c.put("acc", "/cal/personal/", "r1", "ctag-1", "<xml>events</xml>")
|
||||
assertEquals("<xml>events</xml>", c.get("acc", "/cal/personal/", "r1", "ctag-1"))
|
||||
}
|
||||
|
||||
@Test fun miss_when_ctag_changed() {
|
||||
val c = cache()
|
||||
c.put("acc", "/cal/personal/", "r1", "ctag-1", "<xml/>")
|
||||
assertNull(c.get("acc", "/cal/personal/", "r1", "ctag-2"))
|
||||
}
|
||||
|
||||
@Test fun miss_on_blank_ctag_and_unknown_range() {
|
||||
val c = cache()
|
||||
c.put("acc", "/cal/personal/", "r1", "", "<xml/>") // пустой ctag не кэшируется
|
||||
assertNull(c.get("acc", "/cal/personal/", "r1", ""))
|
||||
assertNull(c.get("acc", "/cal/personal/", "other-range", "ctag-1"))
|
||||
}
|
||||
|
||||
@Test fun entries_are_isolated_by_account_calendar_range() {
|
||||
val c = cache()
|
||||
c.put("acc1", "/cal/a/", "r1", "t", "<a1/>")
|
||||
c.put("acc2", "/cal/a/", "r1", "t", "<a2/>")
|
||||
c.put("acc1", "/cal/b/", "r1", "t", "<b1/>")
|
||||
assertEquals("<a1/>", c.get("acc1", "/cal/a/", "r1", "t"))
|
||||
assertEquals("<a2/>", c.get("acc2", "/cal/a/", "r1", "t"))
|
||||
assertEquals("<b1/>", c.get("acc1", "/cal/b/", "r1", "t"))
|
||||
}
|
||||
|
||||
@Test fun multiline_xml_preserved() {
|
||||
val c = cache()
|
||||
val xml = "<multistatus>\n <response>\n <href>/e1.ics</href>\n </response>\n</multistatus>"
|
||||
c.put("acc", "/cal/", "r", "ct", xml)
|
||||
assertEquals(xml, c.get("acc", "/cal/", "r", "ct"))
|
||||
}
|
||||
}
|
||||
@@ -28,13 +28,13 @@ dependencies {
|
||||
implementation project(':core:network')
|
||||
implementation project(':core:database')
|
||||
implementation project(':core:designsystem')
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
|
||||
implementation libs.coroutines.android
|
||||
def composeBom = platform(libs.compose.bom)
|
||||
implementation composeBom
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.compose.foundation:foundation'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
|
||||
implementation 'io.coil-kt:coil-compose:2.7.0'
|
||||
implementation 'io.coil-kt:coil-svg:2.7.0'
|
||||
implementation libs.compose.ui
|
||||
implementation libs.compose.material3
|
||||
implementation libs.compose.foundation
|
||||
implementation libs.lifecycle.viewmodel.compose
|
||||
implementation libs.coil.compose
|
||||
implementation libs.coil.svg
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" ?>
|
||||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<ID>ComplexCondition:ContactsRepository.kt$ContactsRepository$!force && !needsPhotoBackfill && !needsDetailsBackfill && System.currentTimeMillis() - lastSync < SYNC_INTERVAL_MS</ID>
|
||||
<ID>LongMethod:ContactDetailSheet.kt$@Composable private fun ContactDetailContent( contact: ContactItem, onDismiss: () -> Unit, )</ID>
|
||||
<ID>MultiLineIfElse:ContactDetailSheet.kt$"https://$it"</ID>
|
||||
<ID>MultiLineIfElse:ContactDetailSheet.kt$it</ID>
|
||||
</CurrentIssues>
|
||||
</SmellBaseline>
|
||||
@@ -74,6 +74,18 @@ class ContactsRepository(context: Context) {
|
||||
) {
|
||||
return dao.getAll(key).map { it.toItem() }
|
||||
}
|
||||
// Дешёвая проверка CTag: если адресные книги не менялись (и бэкфиллы сделаны) —
|
||||
// полный PROPFIND всех vCard не нужен, отдаём кэш. Это ускоряет pull-to-refresh.
|
||||
val needsBackfill = needsPhotoBackfill || needsDetailsBackfill
|
||||
val client = authedClient(session)
|
||||
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
|
||||
val currentSig = CardDavClient.collectionSignature(client, session.serverUrl, userId)
|
||||
if (!needsBackfill && currentSig != null &&
|
||||
currentSig == prefs.getString(ctagKey(key), null)
|
||||
) {
|
||||
prefs.edit().putLong(lastSyncKey(key), System.currentTimeMillis()).apply()
|
||||
return dao.getAll(key).map { it.toItem() }
|
||||
}
|
||||
val remote = fetchRemoteContacts(session)
|
||||
val entities = remote.map { contact ->
|
||||
ContactEntity(
|
||||
@@ -95,11 +107,12 @@ class ContactsRepository(context: Context) {
|
||||
)
|
||||
}
|
||||
dao.replaceAll(key, entities)
|
||||
prefs.edit()
|
||||
val editor = prefs.edit()
|
||||
.putLong(lastSyncKey(key), System.currentTimeMillis())
|
||||
.putBoolean(photoSyncDoneKey(key), true)
|
||||
.putBoolean(detailsSyncDoneKey(key), true)
|
||||
.apply()
|
||||
if (currentSig != null) editor.putString(ctagKey(key), currentSig) else editor.remove(ctagKey(key))
|
||||
editor.apply()
|
||||
return entities.map { it.toItem() }
|
||||
}
|
||||
|
||||
@@ -190,6 +203,8 @@ class ContactsRepository(context: Context) {
|
||||
|
||||
private fun lastSyncKey(accountKey: String) = "last_sync_$accountKey"
|
||||
|
||||
private fun ctagKey(accountKey: String) = "ctag_$accountKey"
|
||||
|
||||
private fun photoSyncDoneKey(accountKey: String) = "photo_sync_done_$accountKey"
|
||||
|
||||
private fun detailsSyncDoneKey(accountKey: String) = "details_sync_done_$accountKey"
|
||||
|
||||
@@ -21,6 +21,8 @@ import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -48,8 +50,10 @@ import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7CreateButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ContactsScreen(
|
||||
session: AuthSession,
|
||||
@@ -75,20 +79,32 @@ fun ContactsScreen(
|
||||
modifier = modifier,
|
||||
loading = state.loading && state.contacts.isEmpty(),
|
||||
error = state.error,
|
||||
onErrorRetry = { vm.refresh(session, force = true) },
|
||||
) {
|
||||
// Поиск + кнопка создания контакта (создание раньше вызывалось из нижней
|
||||
// панели, теперь — из шапки, панель стала навигационной)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ContactsSearchBar(
|
||||
serverUrl = session.serverUrl,
|
||||
query = state.searchQuery,
|
||||
onQueryChange = vm::setSearchQuery,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (state.syncing && state.contacts.isNotEmpty()) {
|
||||
Text(
|
||||
"Обновление…",
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
F7CreateButton(onClick = vm::openAddSheet, size = 40.dp)
|
||||
}
|
||||
PullToRefreshBox(
|
||||
isRefreshing = state.syncing && state.contacts.isNotEmpty(),
|
||||
onRefresh = { vm.refresh(session, force = true) },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(state.filteredContacts, key = { "${it.uid}|${it.email}" }) { contact ->
|
||||
ContactListRow(
|
||||
@@ -99,6 +115,7 @@ fun ContactsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.selectedContact?.let { contact ->
|
||||
ContactDetailSheet(
|
||||
@@ -264,17 +281,19 @@ fun ContactListRow(
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
// Живая тема: имя 16/500, детали 14/500 серым
|
||||
Text(
|
||||
contact.displayName.ifBlank { contact.email },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = F7Colors.TextPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (contact.email.isNotBlank() && contact.displayName.isNotBlank()) {
|
||||
Text(
|
||||
contact.email,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = F7Colors.TextSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -282,7 +301,7 @@ fun ContactListRow(
|
||||
} else if (contact.phone.isNotBlank()) {
|
||||
Text(
|
||||
contact.phone,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = F7Colors.TextSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -297,12 +316,11 @@ private fun ContactsSearchBar(
|
||||
serverUrl: String,
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val base = serverUrl.trimEnd('/')
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp)
|
||||
modifier = modifier
|
||||
.height(40.dp)
|
||||
.shadow(2.dp, RoundedCornerShape(100.dp), spotColor = Color(0xFFCBCBCB))
|
||||
.clip(RoundedCornerShape(100.dp))
|
||||
|
||||
@@ -99,14 +99,16 @@ class ContactsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh(session: AuthSession, showLoading: Boolean = false) {
|
||||
fun refresh(session: AuthSession, showLoading: Boolean = false, force: Boolean = false) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
if (showLoading && _state.value.contacts.isEmpty()) {
|
||||
_state.update { it.copy(loading = true, error = null) }
|
||||
} else {
|
||||
_state.update { it.copy(syncing = true, error = null) }
|
||||
}
|
||||
runCatching { repository.syncContacts(session) }
|
||||
// force (pull-to-refresh) с CTag-проверкой дешёвый: 1 лёгкий PROPFIND,
|
||||
// полная закачка vCard — только если адресные книги реально менялись
|
||||
runCatching { repository.syncContacts(session, force = force) }
|
||||
.onFailure { t ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
|
||||
@@ -27,12 +27,12 @@ dependencies {
|
||||
implementation project(':core:auth')
|
||||
implementation project(':core:network')
|
||||
implementation project(':core:designsystem')
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||
implementation 'org.json:json:20240303'
|
||||
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
|
||||
implementation libs.coroutines.android
|
||||
implementation libs.json
|
||||
def composeBom = platform(libs.compose.bom)
|
||||
implementation composeBom
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.compose.foundation:foundation'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
|
||||
implementation libs.compose.ui
|
||||
implementation libs.compose.material3
|
||||
implementation libs.compose.foundation
|
||||
implementation libs.lifecycle.viewmodel.compose
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" ?>
|
||||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<ID>NoUnusedImports:DeckRepository.kt$ru.forbion.f7cloud.feature.deck.DeckRepository.kt</ID>
|
||||
</CurrentIssues>
|
||||
</SmellBaseline>
|
||||
@@ -79,7 +79,6 @@ class DeckRepository {
|
||||
private fun getJson(client: okhttp3.OkHttpClient, url: String): Any {
|
||||
val request = Request.Builder().url(url).build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
error("Deck API HTTP ${response.code}")
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package ru.forbion.f7cloud.feature.deck
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -17,6 +20,7 @@ import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
|
||||
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
|
||||
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DeckScreen(
|
||||
session: AuthSession,
|
||||
@@ -57,6 +61,15 @@ fun DeckScreen(
|
||||
},
|
||||
) {
|
||||
val detail = state.boardDetail
|
||||
PullToRefreshBox(
|
||||
isRefreshing = state.loading && (state.boards.isNotEmpty() || detail != null),
|
||||
onRefresh = {
|
||||
if (detail != null) vm.openBoard(session, detail.boardId) else vm.load(session)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
if (detail != null) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(detail.stacks, key = { it.id }) { stack ->
|
||||
@@ -79,4 +92,5 @@ fun DeckScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,15 +27,15 @@ dependencies {
|
||||
implementation project(':core:auth')
|
||||
implementation project(':core:network')
|
||||
implementation project(':core:designsystem')
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||
implementation 'org.json:json:20240303'
|
||||
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
|
||||
implementation libs.coroutines.android
|
||||
implementation libs.json
|
||||
def composeBom = platform(libs.compose.bom)
|
||||
implementation composeBom
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.compose.foundation:foundation'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
|
||||
implementation 'androidx.activity:activity-compose:1.10.1'
|
||||
implementation 'io.coil-kt:coil-compose:2.7.0'
|
||||
implementation 'io.coil-kt:coil-svg:2.7.0'
|
||||
implementation libs.compose.ui
|
||||
implementation libs.compose.material3
|
||||
implementation libs.compose.foundation
|
||||
implementation libs.lifecycle.viewmodel.compose
|
||||
implementation libs.activity.compose
|
||||
implementation libs.coil.compose
|
||||
implementation libs.coil.svg
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" ?>
|
||||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<ID>ComplexCondition:SupportRepository.kt$SupportRepository$config.isSupportAdmin && r == "support" && u.isNotEmpty() && a == u</ID>
|
||||
<ID>ComplexCondition:SupportScreen.kt$state.loading && state.tickets.isNotEmpty() && state.selectedTicket == null && !state.showCreate</ID>
|
||||
<ID>ImportOrdering:SupportComponents.kt$import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import coil.compose.AsyncImage import androidx.compose.foundation.layout.heightIn import androidx.compose.material3.Surface import androidx.compose.ui.graphics.Brush import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale</ID>
|
||||
<ID>LongMethod:SupportScreen.kt$@Composable fun SupportScreen( session: AuthSession, modifier: Modifier = Modifier, createRequest: Int = 0, openTicketNumber: String? = null, onOpenTicketConsumed: () -> Unit = {}, onUnauthorized: () -> Unit = {}, )</ID>
|
||||
<ID>LoopWithTooManyJumpStatements:SupportRepository.kt$SupportRepository$for</ID>
|
||||
<ID>LoopWithTooManyJumpStatements:SupportViewModel.kt$SupportViewModel$while</ID>
|
||||
<ID>MaximumLineLength:SupportComponents.kt$ </ID>
|
||||
<ID>NoUnusedImports:SupportRepository.kt$ru.forbion.f7cloud.feature.f7support.SupportRepository.kt</ID>
|
||||
<ID>UnusedParameter:SupportRepository.kt$SupportRepository$config: SupportConfig</ID>
|
||||
<ID>UnusedParameter:SupportViewModel.kt$SupportViewModel$session: AuthSession</ID>
|
||||
</CurrentIssues>
|
||||
</SmellBaseline>
|
||||
@@ -26,7 +26,6 @@ class SupportRepository {
|
||||
.applyOcsJson()
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
error("Support config HTTP ${response.code}")
|
||||
}
|
||||
@@ -266,7 +265,6 @@ class SupportRepository {
|
||||
.post(payload.toRequestBody("application/json; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (!response.isSuccessful) {
|
||||
val body = response.body?.string().orEmpty()
|
||||
val err = runCatching {
|
||||
|
||||
@@ -29,17 +29,18 @@ dependencies {
|
||||
implementation project(':core:network')
|
||||
implementation project(':core:designsystem')
|
||||
|
||||
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
|
||||
def composeBom = platform(libs.compose.bom)
|
||||
implementation composeBom
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.compose.foundation:foundation'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||
implementation 'org.json:json:20240303'
|
||||
implementation 'androidx.core:core-ktx:1.13.1'
|
||||
implementation 'androidx.activity:activity-compose:1.9.0'
|
||||
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||
implementation 'io.coil-kt:coil-compose:2.6.0'
|
||||
implementation 'io.coil-kt:coil-svg:2.6.0'
|
||||
implementation libs.compose.ui
|
||||
implementation libs.compose.material3
|
||||
implementation libs.compose.material.icons.extended
|
||||
implementation libs.compose.foundation
|
||||
implementation libs.lifecycle.viewmodel.compose
|
||||
implementation libs.coroutines.android
|
||||
implementation libs.json
|
||||
implementation libs.core.ktx
|
||||
implementation libs.activity.compose
|
||||
implementation libs.documentfile
|
||||
implementation libs.coil.compose
|
||||
implementation libs.coil.svg
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" ?>
|
||||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<ID>ArgumentListWrapping:FilesScreen.kt$("/", " › ")</ID>
|
||||
<ID>ArgumentListWrapping:FilesViewModel.kt$FilesViewModel$(session, "Новая диаграмма", ".odg", openAfterCreate = true)</ID>
|
||||
<ID>ArgumentListWrapping:FilesViewModel.kt$FilesViewModel$(session, "Новая доска", ".whiteboard", openAfterCreate = false)</ID>
|
||||
<ID>ArgumentListWrapping:FilesViewModel.kt$FilesViewModel$(session, "Новая презентация", ".pptx", openAfterCreate = true)</ID>
|
||||
<ID>ArgumentListWrapping:FilesViewModel.kt$FilesViewModel$(session, "Новая таблица", ".xlsx", openAfterCreate = true)</ID>
|
||||
<ID>ArgumentListWrapping:FilesViewModel.kt$FilesViewModel$(session, "Новый документ", ".docx", openAfterCreate = true)</ID>
|
||||
<ID>ArgumentListWrapping:FilesViewModel.kt$FilesViewModel$(session, "Новый текстовый файл", ".txt", openAfterCreate = false)</ID>
|
||||
<ID>CyclomaticComplexMethod:FilesScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FilesScreen( session: AuthSession, modifier: Modifier = Modifier, uploadRequest: Int = 0, pushRefreshRequest: Int = 0, openFileId: Long? = null, sidebarOpen: Boolean = false, onSidebarOpenChange: (Boolean) -> Unit = {}, settingsOpen: Boolean = false, onSettingsOpenChange: (Boolean) -> Unit = {}, onOpenFileConsumed: () -> Unit = {}, onUnauthorized: () -> Unit = {}, onOpenOfficeEditor: (OfficeEditorLaunch) -> Unit = {}, )</ID>
|
||||
<ID>ImportOrdering:FilesComponents.kt$import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Checkbox import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButtonDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.foundation.text.BasicTextField import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.DialogProperties import coil.compose.AsyncImage import ru.forbion.f7cloud.core.designsystem.F7AlertDialog import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7OutlinedField import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton import java.text.SimpleDateFormat import java.util.Date import java.util.Locale</ID>
|
||||
<ID>ImportOrdering:FilesRepository.kt$import android.content.Context import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.database.F7Database import ru.forbion.f7cloud.core.database.FileEntity import ru.forbion.f7cloud.core.network.DavClient import ru.forbion.f7cloud.core.network.NetworkFactory import ru.forbion.f7cloud.core.auth.OcsUserResolver import ru.forbion.f7cloud.core.network.UnauthorizedException import ru.forbion.f7cloud.core.network.davFileUrl import ru.forbion.f7cloud.core.network.davFolderUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody</ID>
|
||||
<ID>ImportOrdering:FilesScreen.kt$import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.designsystem.F7AlertDialog import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler import ru.forbion.f7cloud.core.designsystem.F7OutlinedField import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton import java.io.File</ID>
|
||||
<ID>ImportOrdering:ImageViewerActivity.kt$import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import ru.forbion.f7cloud.core.designsystem.F7Theme import java.io.File</ID>
|
||||
<ID>LargeClass:FilesViewModel.kt$FilesViewModel : ViewModel</ID>
|
||||
<ID>LongMethod:FilesComponents.kt$@Composable fun FilesNavigationSidebar( serverUrl: String, visible: Boolean, browseMode: FilesBrowseMode, storageStats: FilesStorageStats, folderTree: List<FilesFolderTreeNode>, expandedTreePaths: Set<String>, expandedSections: Set<String>, onDismiss: () -> Unit, onBrowseModeClick: (FilesBrowseMode) -> Unit, onFolderPathClick: (String) -> Unit, onToggleTreePath: (String) -> Unit, onToggleSection: (String) -> Unit, onWebOnlyClick: (String) -> Unit, )</ID>
|
||||
<ID>LongMethod:FilesScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FilesScreen( session: AuthSession, modifier: Modifier = Modifier, uploadRequest: Int = 0, pushRefreshRequest: Int = 0, openFileId: Long? = null, sidebarOpen: Boolean = false, onSidebarOpenChange: (Boolean) -> Unit = {}, settingsOpen: Boolean = false, onSettingsOpenChange: (Boolean) -> Unit = {}, onOpenFileConsumed: () -> Unit = {}, onUnauthorized: () -> Unit = {}, onOpenOfficeEditor: (OfficeEditorLaunch) -> Unit = {}, )</ID>
|
||||
<ID>MatchingDeclarationName:FilesCreateMenu.kt$FilesCreateAction</ID>
|
||||
<ID>MaximumLineLength:FilesScreen.kt$ </ID>
|
||||
<ID>MaximumLineLength:FilesViewModel.kt$FilesViewModel$ </ID>
|
||||
<ID>NoUnusedImports:FileDownloadRepository.kt$ru.forbion.f7cloud.feature.files.FileDownloadRepository.kt</ID>
|
||||
<ID>NoUnusedImports:FilesApiRepository.kt$ru.forbion.f7cloud.feature.files.FilesApiRepository.kt</ID>
|
||||
<ID>NoUnusedImports:FilesComponents.kt$ru.forbion.f7cloud.feature.files.FilesComponents.kt</ID>
|
||||
<ID>NoUnusedImports:FilesRepository.kt$ru.forbion.f7cloud.feature.files.FilesRepository.kt</ID>
|
||||
<ID>NoUnusedImports:FilesScreen.kt$ru.forbion.f7cloud.feature.files.FilesScreen.kt</ID>
|
||||
<ID>NoUnusedImports:FilesTemplatesRepository.kt$ru.forbion.f7cloud.feature.files.FilesTemplatesRepository.kt</ID>
|
||||
<ID>NoUnusedImports:RichdocumentsRepository.kt$ru.forbion.f7cloud.feature.files.RichdocumentsRepository.kt</ID>
|
||||
<ID>UnusedParameter:FilesComponents.kt$selectionMode: Boolean</ID>
|
||||
<ID>UnusedParameter:FilesViewModel.kt$FilesViewModel$context: Context</ID>
|
||||
<ID>Wrapping:FilesCreateMenu.kt$;</ID>
|
||||
</CurrentIssues>
|
||||
</SmellBaseline>
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<g clip-path="url(#clip0_3969_9720)">
|
||||
<g clip-path="url(#clip1_3969_9720)">
|
||||
<rect x="1.75" y="1.75" width="12.5" height="12.5" rx="2.25" stroke="#808080" stroke-width="1.5"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3969_9720">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_3969_9720">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 592 B |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<g clip-path="url(#clip0_3969_9737)">
|
||||
<g clip-path="url(#clip1_3969_9737)">
|
||||
<rect x="1.75" y="1.75" width="12.5" height="12.5" rx="2.25" fill="#70B62B" stroke="#70B62B" stroke-width="1.5"/>
|
||||
<path d="M4 9.14286L7.80954 11.864C7.90955 11.9354 8.04971 11.9006 8.10467 11.7907L12 4" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3969_9737">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_3969_9737">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 768 B |
@@ -0,0 +1,13 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_655_2917)">
|
||||
<path d="M2.5 5C2.5 3.61929 3.61929 2.5 5 2.5H10H15C16.3807 2.5 17.5 3.61929 17.5 5V12.2727C17.5 13.6534 16.3807 14.7727 15 14.7727H11.6654C11.3957 14.7727 11.1333 14.86 10.9172 15.0214L6.32676 18.4514C6.16189 18.5746 5.92712 18.4569 5.92712 18.2511V15.0227C5.92712 14.8847 5.81519 14.7727 5.67712 14.7727H5C3.61929 14.7727 2.5 13.6534 2.5 12.2727V5Z" stroke="black" stroke-width="1.5"/>
|
||||
<path d="M6.25 8.75H6.25972" stroke="black" stroke-width="1.875" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.0098 8.75H10.0195" stroke="black" stroke-width="1.875" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.7695 8.75H13.7793" stroke="black" stroke-width="1.875" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_655_2917">
|
||||
<rect width="20" height="20" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 983 B |
@@ -0,0 +1,12 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_10995_59807)">
|
||||
<circle cx="8.5" cy="13.5" r="1.5" transform="rotate(-90 8.5 13.5)" fill="#808080"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5" transform="rotate(-90 8.5 8.5)" fill="#808080"/>
|
||||
<circle cx="8.5" cy="3.5" r="1.5" transform="rotate(-90 8.5 3.5)" fill="#808080"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_10995_59807">
|
||||
<rect width="16" height="16" fill="white" transform="matrix(0 -1 1 0 0 16)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 538 B |
@@ -0,0 +1,17 @@
|
||||
<svg width="75" height="75" viewBox="0 0 75 75" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_17_4782)">
|
||||
<circle cx="37.5" cy="37.5" r="37.5" fill="#E6E6E6"/>
|
||||
<path d="M61.875 32.8754V45.3395C61.875 48.1108 59.6284 50.3574 56.857 50.3574H16.268C13.4966 50.3574 11.25 48.1108 11.25 45.3395V27.2504C11.25 24.479 13.4966 22.2324 16.268 22.2324H37.2965C38.6273 22.2324 39.9037 22.7611 40.8447 23.7021L43.5303 26.3877C44.4713 27.3287 45.7477 27.8574 47.0785 27.8574H56.857C59.6284 27.8574 61.875 30.104 61.875 32.8754Z" fill="#F5F5F5" stroke="black" stroke-width="1.67266"/>
|
||||
<path d="M18.75 10.9824V50.3574H56.25V10.9824H18.75Z" fill="#F5F5F5" stroke="black" stroke-width="1.67266"/>
|
||||
<path d="M16.875 14.7324V55.9824H52.5V14.7324H16.875Z" fill="#FCFEFC" stroke="black" stroke-width="1.67266"/>
|
||||
<path d="M11.25 42.2504V58.4645C11.25 61.2358 13.4966 63.4824 16.268 63.4824H58.732C61.5034 63.4824 63.75 61.2358 63.75 58.4645V36.6254C63.75 33.854 61.5034 31.6074 58.732 31.6074H33.3359C32.0202 31.6074 30.7571 32.1242 29.8186 33.0464L27.0235 35.7934C26.085 36.7157 24.8219 37.2324 23.5062 37.2324H16.268C13.4966 37.2324 11.25 39.479 11.25 42.2504Z" fill="#F5F5F5" stroke="black" stroke-width="1.67266"/>
|
||||
<path d="M20.625 20.3574H48.75" stroke="#70B62B" stroke-width="1.67266" stroke-linecap="square"/>
|
||||
<path d="M20.625 24.1074H48.75" stroke="#70B62B" stroke-width="1.67266" stroke-linecap="square"/>
|
||||
<path d="M20.625 27.8574H48.75" stroke="#70B62B" stroke-width="1.67266" stroke-linecap="square"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_17_4782">
|
||||
<rect width="75" height="75" rx="15" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 4.72274V12.6619C15 13.4009 14.4009 14 13.6619 14H2.33813C1.5991 14 1 13.4009 1 12.6619L1 3.33812C1 2.5991 1.5991 2 2.33813 2H8.17409C8.55792 2 8.92327 2.16483 9.17728 2.45259L9.60049 2.93203C9.8545 3.21979 10.2199 3.38462 10.6037 3.38462H13.6619C14.4009 3.38462 15 3.98371 15 4.72274Z" stroke="#ffffffff" stroke-width="1.5"/>
|
||||
<path d="M1 8.74989V12.6619C1 13.4009 1.5991 14 2.33813 14H13.6619C14.4009 14 15 13.4009 15 12.6619V7.33812C15 6.5991 14.4009 6 13.6619 6H6.86607C6.52921 6 6.20475 6.12705 5.95745 6.35578L5.20044 7.05598C4.95315 7.28472 4.62868 7.41176 4.29183 7.41176H2.33812C1.5991 7.41176 1 8.01086 1 8.74989Z" stroke="#ffffffff" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 779 B |
@@ -0,0 +1,15 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_17_4585)">
|
||||
<path d="M4.25 2L4.25 6C4.25 6.13807 4.13807 6.25 4 6.25L2 6.25C1.86193 6.25 1.75 6.13807 1.75 6L1.75 2C1.75 1.86193 1.86193 1.75 2 1.75L4 1.75C4.13807 1.75 4.25 1.86193 4.25 2Z" stroke="#808080" stroke-width="1.5"/>
|
||||
<path d="M4.25 10L4.25 14C4.25 14.1381 4.13807 14.25 4 14.25H2C1.86193 14.25 1.75 14.1381 1.75 14L1.75 10C1.75 9.86193 1.86193 9.75 2 9.75H4C4.13807 9.75 4.25 9.86193 4.25 10Z" stroke="#808080" stroke-width="1.5"/>
|
||||
<path d="M9.25 2V6C9.25 6.13807 9.13807 6.25 9 6.25L7 6.25C6.86193 6.25 6.75 6.13807 6.75 6L6.75 2C6.75 1.86193 6.86193 1.75 7 1.75L9 1.75C9.13807 1.75 9.25 1.86193 9.25 2Z" stroke="#808080" stroke-width="1.5"/>
|
||||
<path d="M9.25 10V14C9.25 14.1381 9.13807 14.25 9 14.25H7C6.86193 14.25 6.75 14.1381 6.75 14L6.75 10C6.75 9.86193 6.86193 9.75 7 9.75H9C9.13807 9.75 9.25 9.86193 9.25 10Z" stroke="#808080" stroke-width="1.5"/>
|
||||
<path d="M14.25 2V6C14.25 6.13807 14.1381 6.25 14 6.25L12 6.25C11.8619 6.25 11.75 6.13807 11.75 6V2C11.75 1.86193 11.8619 1.75 12 1.75L14 1.75C14.1381 1.75 14.25 1.86193 14.25 2Z" stroke="#808080" stroke-width="1.5"/>
|
||||
<path d="M14.25 10V14C14.25 14.1381 14.1381 14.25 14 14.25H12C11.8619 14.25 11.75 14.1381 11.75 14V10C11.75 9.86193 11.8619 9.75 12 9.75H14C14.1381 9.75 14.25 9.86193 14.25 10Z" stroke="#808080" stroke-width="1.5"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_17_4585">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1460_24542)">
|
||||
<g clip-path="url(#clip1_1460_24542)">
|
||||
<g clip-path="url(#clip2_1460_24542)">
|
||||
<path d="M10.2102 14.9989V9.10405C10.2102 8.90862 10.1326 8.7212 9.99441 8.58301C9.85623 8.44482 9.6688 8.36719 9.47337 8.36719H6.52592C6.3305 8.36719 6.14307 8.44482 6.00488 8.58301C5.8667 8.7212 5.78906 8.90862 5.78906 9.10405V14.9989" stroke="#808080" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M1.36719 6.89451C1.36714 6.68014 1.41386 6.46833 1.50409 6.27387C1.59432 6.07941 1.72589 5.90697 1.88962 5.76859L7.04766 1.34815C7.31366 1.12334 7.65068 1 7.99895 1C8.34722 1 8.68424 1.12334 8.95024 1.34815L14.1083 5.76859C14.272 5.90697 14.4036 6.07941 14.4938 6.27387C14.584 6.46833 14.6308 6.68014 14.6307 6.89451V13.5263C14.6307 13.9171 14.4754 14.292 14.1991 14.5684C13.9227 14.8447 13.5478 15 13.157 15H2.84091C2.45006 15 2.07521 14.8447 1.79883 14.5684C1.52245 14.292 1.36719 13.9171 1.36719 13.5263V6.89451Z" stroke="#808080" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1460_24542">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_1460_24542">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip2_1460_24542">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |