Initial import of f7cloud-mobile native Android app.
Kotlin/Compose client for F7cloud (mail, files, talk, calendar, contacts, tasks, support). Current version: 0.5.113 (build 121).
@@ -0,0 +1,26 @@
|
|||||||
|
# Gradle / Android build
|
||||||
|
/.gradle/
|
||||||
|
/build/
|
||||||
|
**/build/
|
||||||
|
!**/src/**/build/
|
||||||
|
/local.properties
|
||||||
|
/key.properties
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
/.idea/
|
||||||
|
*.iml
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Firebase (положите свой google-services.json локально)
|
||||||
|
/app/google-services.json
|
||||||
|
|
||||||
|
# APK / артефакты
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Логи и временные файлы
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
.cxx/
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Third-Party Notices — F7cloud Mobile
|
||||||
|
|
||||||
|
## Nextcloud Talk for Android
|
||||||
|
|
||||||
|
F7cloud Mobile integrates components derived from and/or intended to interoperate with
|
||||||
|
[Nextcloud Talk for Android](https://github.com/nextcloud/talk-android).
|
||||||
|
|
||||||
|
- **Version vendored:** v23.0.0
|
||||||
|
- **Location:** `vendor/talk-android/`
|
||||||
|
- **License:** GNU General Public License v3.0 or later (GPL-3.0-or-later)
|
||||||
|
- **Copyright:** Nextcloud GmbH and Nextcloud contributors
|
||||||
|
|
||||||
|
The full source code of Nextcloud Talk for Android is available at:
|
||||||
|
https://github.com/nextcloud/talk-android/tree/v23.0.0
|
||||||
|
|
||||||
|
When native Talk modules from this codebase are linked into F7cloud Mobile, the combined
|
||||||
|
work is distributed under the terms of the GPL-3.0-or-later. Corresponding source code
|
||||||
|
will be made available alongside this application.
|
||||||
|
|
||||||
|
## Firebase Cloud Messaging
|
||||||
|
|
||||||
|
Push notifications use Google Firebase Cloud Messaging (proprietary Google SDK).
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
# F7cloud Mobile — статус проекта и журнал работ
|
||||||
|
|
||||||
|
Документ описывает текущее состояние нативного Android-приложения **f7cloud-mobile**, выполненные задачи, процесс сборки APK и актуальную версию.
|
||||||
|
|
||||||
|
**Дата обновления:** 7 июля 2026
|
||||||
|
**Расположение проекта:** `/var/www/f7cloud/f7cloud-mobile`
|
||||||
|
**Сервер:** `https://forbion.f7cloud.ru`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Текущая версия
|
||||||
|
|
||||||
|
| Параметр | Значение |
|
||||||
|
|----------|----------|
|
||||||
|
| **versionName** | `0.5.113` |
|
||||||
|
| **versionCode** | `121` |
|
||||||
|
| **applicationId** | `ru.forbion.f7cloud.mobile` |
|
||||||
|
| **Последний APK (debug)** | `app/build/outputs/apk/debug/f7cloud-mobile-v0.5.113-121-debug.apk` |
|
||||||
|
| **Размер APK** | ~130 МБ |
|
||||||
|
|
||||||
|
Версия задаётся в `app/build.gradle`:
|
||||||
|
|
||||||
|
```gradle
|
||||||
|
versionCode 121
|
||||||
|
versionName '0.5.113'
|
||||||
|
```
|
||||||
|
|
||||||
|
При каждой значимой сборке `versionCode` увеличивается на 1, `versionName` — по схеме `0.5.xxx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сборка APK
|
||||||
|
|
||||||
|
### Чем собирается
|
||||||
|
|
||||||
|
Сборка выполняется **на сервере** в каталоге проекта с помощью **Gradle Wrapper** — отдельный от legacy-проекта `android-webview`.
|
||||||
|
|
||||||
|
| Компонент | Версия |
|
||||||
|
|-----------|--------|
|
||||||
|
| Gradle | **9.3.0** (`gradle/wrapper/gradle-wrapper.properties`) |
|
||||||
|
| Android Gradle Plugin (AGP) | **8.13.2** |
|
||||||
|
| Kotlin | **2.3.0** |
|
||||||
|
| Jetpack Compose BOM | **2025.02.00** |
|
||||||
|
| compileSdk / targetSdk | **36** |
|
||||||
|
| minSdk | **26** |
|
||||||
|
| JDK на сервере | **OpenJDK 21** |
|
||||||
|
|
||||||
|
> С v0.5.27 используется собственный wrapper в `f7cloud-mobile`. Старый способ через `android-webview` (Gradle 8.7, AGP 8.2) **не поддерживается** — несовместим с Kotlin 2.3.
|
||||||
|
|
||||||
|
### Команды
|
||||||
|
|
||||||
|
**Debug APK** (основной способ для тестирования на устройстве):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/f7cloud/f7cloud-mobile
|
||||||
|
./gradlew assembleDebug
|
||||||
|
chown www-data:www-data app/build/outputs/apk/debug/*.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
**Release APK** (с minify и shrink resources):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/f7cloud/f7cloud-mobile
|
||||||
|
./gradlew assembleRelease
|
||||||
|
```
|
||||||
|
|
||||||
|
**Сборка отдельного модуля** (проверка компиляции без полной сборки app):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :feature:tasks:compileDebugKotlin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Имя выходного файла
|
||||||
|
|
||||||
|
Шаблон задаётся в `app/build.gradle`:
|
||||||
|
|
||||||
|
```
|
||||||
|
f7cloud-mobile-v{versionName}-{versionCode}-{variant}.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
Пример: `f7cloud-mobile-v0.5.113-121-debug.apk`
|
||||||
|
|
||||||
|
### Где лежит APK
|
||||||
|
|
||||||
|
```
|
||||||
|
/var/www/f7cloud/f7cloud-mobile/app/build/outputs/apk/debug/
|
||||||
|
/var/www/f7cloud/f7cloud-mobile/app/build/outputs/apk/release/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Как собирает ассистент (Cursor Agent)
|
||||||
|
|
||||||
|
1. Вносит изменения в Kotlin/Compose-код.
|
||||||
|
2. При необходимости увеличивает `versionCode` / `versionName` в `app/build.gradle`.
|
||||||
|
3. Запускает `./gradlew assembleDebug` (или `:app:assembleDebug`).
|
||||||
|
4. Проверяет, что сборка завершилась с `BUILD SUCCESSFUL`.
|
||||||
|
5. Сообщает путь к APK.
|
||||||
|
|
||||||
|
Коммиты в git создаются **только по явной просьбе** пользователя.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Архитектура проекта
|
||||||
|
|
||||||
|
Нативное приложение на **Kotlin + Jetpack Compose**. Не WebView-оболочка (legacy: `android-webview`).
|
||||||
|
|
||||||
|
```
|
||||||
|
f7cloud-mobile/
|
||||||
|
├── app/ # Точка входа, навигация (AppScaffold, AppNavigation)
|
||||||
|
├── core/
|
||||||
|
│ ├── auth/ # Сессия, авторизация
|
||||||
|
│ ├── network/ # HTTP, WebDAV, CalDAV, OCS API
|
||||||
|
│ ├── designsystem/ # F7Colors, компоненты UI, нижняя панель, меню
|
||||||
|
│ ├── push/ # Firebase push-уведомления
|
||||||
|
│ ├── database/ # Room (локальный кэш)
|
||||||
|
│ └── ui/ # Общие UI-утилиты
|
||||||
|
├── feature/
|
||||||
|
│ ├── files/ # Файлы (WebDAV)
|
||||||
|
│ ├── talk/ # Talk UI, офлайн-чат
|
||||||
|
│ ├── talk-native/ # Нативные звонки (обёртка vendor/talk-android)
|
||||||
|
│ ├── mail/ # Почта
|
||||||
|
│ ├── calendar/ # Календарь (CalDAV)
|
||||||
|
│ ├── contacts/ # Контакты (CardDAV)
|
||||||
|
│ ├── tasks/ # Задачи (CalDAV)
|
||||||
|
│ ├── deck/ # Карточки (Deck)
|
||||||
|
│ └── f7support/ # Техподдержка
|
||||||
|
├── vendor/talk-android/ # Форк Nextcloud Talk (нативные конференции)
|
||||||
|
└── design/ # Макеты PNG, токены, CSS-эталоны
|
||||||
|
```
|
||||||
|
|
||||||
|
### Навигация
|
||||||
|
|
||||||
|
- Нижняя панель зависит от активной вкладки (`F7BottomBarConfig.kt`).
|
||||||
|
- Меню приложений — полноэкранная панель `F7AppMenuSheet` (кнопка «гамбургер»).
|
||||||
|
- Стартовая вкладка после входа — **Файлы**.
|
||||||
|
- В активной конференции нижняя панель **скрывается** (`talkInRoom`).
|
||||||
|
|
||||||
|
### Дизайн-система
|
||||||
|
|
||||||
|
Цвета и компоненты из темы forbion (`core/designsystem/F7Colors.kt`):
|
||||||
|
|
||||||
|
| Токен | Значение |
|
||||||
|
|-------|----------|
|
||||||
|
| Primary | `#70B62B` |
|
||||||
|
| Background | `#FBFBFB` |
|
||||||
|
| Surface | `#FFFFFF` |
|
||||||
|
| Text primary | `#151515` |
|
||||||
|
| Text secondary | `#808080` |
|
||||||
|
| Border | `#E6E6E6` |
|
||||||
|
|
||||||
|
Иконки подгружаются с сервера: `https://forbion.f7cloud.ru/themes/forbion/images/...`
|
||||||
|
|
||||||
|
Макеты для вёрстки: `design/screens/`, эталонный CSS веб-версии: `themes/forbion/css/pages/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Выполненные работы (хронология)
|
||||||
|
|
||||||
|
Ниже — основные блоки работ, выполненных в ходе разработки нативного приложения (сессии Cursor Agent, весна–лето 2026).
|
||||||
|
|
||||||
|
### Общая платформа
|
||||||
|
|
||||||
|
- Переход на Kotlin 2.3 + Compose + Gradle 9.3 / AGP 8.13.
|
||||||
|
- Модульная архитектура `app` / `core` / `feature` / `vendor`.
|
||||||
|
- Глобальный safe area (`enableEdgeToEdge`, `F7WindowInsets`) — исправление перекрытия статус-бара на Samsung Z и других устройствах с вырезом.
|
||||||
|
- Swipe-to-dismiss для оверлеев (`F7OverlayNavigation.kt`, `f7SwipeFromRightToDismiss`).
|
||||||
|
- Push-уведомления через Firebase (`core/push`).
|
||||||
|
|
||||||
|
### Меню приложений (`F7AppMenuSheet`)
|
||||||
|
|
||||||
|
- Полноэкранная белая панель `#FBFBFB` без затемнения (по `_header.css`).
|
||||||
|
- Поиск приложений сверху.
|
||||||
|
- Сетка 4 колонки, иконки 62×62 dp, подписи 12sp bold.
|
||||||
|
- Нижняя панель остаётся видимой (90 dp снизу).
|
||||||
|
- Внешние сайты (Bitrix, 1C UNF и др.) подгружаются через `AppMenuRepository`.
|
||||||
|
|
||||||
|
### Конференции / Talk (`talk-native`, `vendor/talk-android`)
|
||||||
|
|
||||||
|
- Нативные звонки через форк Nextcloud Talk.
|
||||||
|
- **Исправление повторного подключения к звонку** после завершения предыдущего (`CallActivity.onNewIntent`, сброс `ApplicationWideCurrentRoomHolder`, очистка в `TalkNativeCallLauncher`) — баг с конференцией «Топ Дейли» у markov.aa.
|
||||||
|
|
||||||
|
### Почта (`feature/mail`)
|
||||||
|
|
||||||
|
- Нативный список писем, папки, поиск, переписки (threads).
|
||||||
|
- Просмотр письма: HTML в WebView, текстовые письма в Compose.
|
||||||
|
- Меню действий «⋯» с иконками на белом фоне (ответить, избранное, спам, метки, переместить и т.д.).
|
||||||
|
- Свайп справа налево для закрытия письма.
|
||||||
|
- Исправления крашей при открытии HTML-писем (вложенная прокрутка, `fillMaxSize` в `Column`).
|
||||||
|
- **Фоновая предзагрузка тел писем** (до 60 писем, 3 параллельно) — v0.5.104.
|
||||||
|
- **Параметры эл. почты** (`MailSettingsScreens.kt`) по макету `AppSettingsMenu.vue`:
|
||||||
|
- Основные, учётные записи, внешний вид, текстовые шаблоны, конфиденциальность, безопасность.
|
||||||
|
- API: preferences, trusted senders, text blocks в `MailRepository`.
|
||||||
|
- **Запланированная отправка** при просмотре письма — открывает compose-ответ с выбранным временем (вместо snooze входящего).
|
||||||
|
|
||||||
|
### Календарь (`feature/calendar`)
|
||||||
|
|
||||||
|
- Виды: День, Неделя, Месяц, Год, Список.
|
||||||
|
- Боковая панель навигации по календарям.
|
||||||
|
- Расширенный редактор событий: повторения, участники, Talk, напоминания, RSVP.
|
||||||
|
- Корзина, незапланированные задачи, создание календаря, импорт `.ics`, настройки.
|
||||||
|
- Исправление краша `LazyColumn` с бесконечной высотой (v0.5.92).
|
||||||
|
- Вёрстка по макетам из `design/screens/calendar/` и CSS `_calendar-*-mobile.css`.
|
||||||
|
|
||||||
|
### Контакты (`feature/contacts`)
|
||||||
|
|
||||||
|
- Список контактов CardDAV, поиск, группы.
|
||||||
|
- Карточка контакта: email, телефон, адрес, организация, аватар.
|
||||||
|
|
||||||
|
### Техподдержка (`feature/f7support`)
|
||||||
|
|
||||||
|
- Нативный канбан тикетов, чат, создание обращений, вложения.
|
||||||
|
- **Composer чата** (`SupportChatComposerBar`) — pill-поле с эмодзи, скрепкой и круглой кнопкой отправки (как в Talk).
|
||||||
|
- Исправление выравнивания кнопки отправки (`Alignment.CenterVertically`).
|
||||||
|
|
||||||
|
### Задачи (`feature/tasks`) — последняя крупная доработка (v0.5.113)
|
||||||
|
|
||||||
|
Дизайн по макетам `design/screens/tasks/` и CSS `_tasks-main-mobile.css`, `_tasks-sidebar-mobile.css`:
|
||||||
|
|
||||||
|
| Компонент | Файл | Описание |
|
||||||
|
|-----------|------|----------|
|
||||||
|
| `TasksToolbar` | `TasksComponents.kt` | Кнопка «+», inline-создание, фильтр, сортировка |
|
||||||
|
| `TasksRow` | `TasksComponents.kt` | Строка 48dp, чекбокс 16px, приоритет, дата |
|
||||||
|
| `TasksGroupHeader` | `TasksComponents.kt` | Группы по дате (Сегодня, Вчера…) |
|
||||||
|
| `TasksEmptyState` | `TasksComponents.kt` | Пустой список + «Загрузить» |
|
||||||
|
| `TasksCompletedSection` | `TasksComponents.kt` | Завершённые + удаление |
|
||||||
|
| `TaskDetailSheet` | `TasksComponents.kt` | Полноэкранная панель свойств/заметок |
|
||||||
|
| `TasksSettingsSheet` | `TasksComponents.kt` | Параметры задач |
|
||||||
|
| `TasksGrouping` | `TasksGrouping.kt` | Группировка по сроку |
|
||||||
|
|
||||||
|
Данные — CalDAV (`TasksRepository`, `CalDavClient`). Умные списки (Сегодня, На неделе) пока только в UI настроек, без отдельной серверной логики.
|
||||||
|
|
||||||
|
### Файлы (`feature/files`)
|
||||||
|
|
||||||
|
- WebDAV: список папок/файлов, навигация, загрузка.
|
||||||
|
- Office-документы через Collabora (`OfficeEditorActivity`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## История версий (ключевые вехи)
|
||||||
|
|
||||||
|
| versionName | versionCode | Основные изменения |
|
||||||
|
|-------------|-------------|-------------------|
|
||||||
|
| 0.5.88 | 96 | Базовая навигация, модули |
|
||||||
|
| 0.5.89–0.5.90 | 97–98 | Техподдержка, карточка контакта, повторный звонок |
|
||||||
|
| 0.5.91–0.5.92 | 99–100 | Расширенный календарь, composer поддержки, фикс краша календаря |
|
||||||
|
| 0.5.100 | 108 | Меню «⋯» в почте с иконками |
|
||||||
|
| 0.5.101–0.5.102 | 109–110 | Вёрстка просмотра письма, swipe-to-dismiss |
|
||||||
|
| 0.5.103–0.5.105 | 111–113 | Фиксы крашей HTML-писем |
|
||||||
|
| 0.5.104 | 112 | Предзагрузка тел писем |
|
||||||
|
| 0.5.106 | 114 | Свайп при открытом sidebar |
|
||||||
|
| 0.5.110 | 118 | Параметры эл. почты |
|
||||||
|
| 0.5.111–0.5.112 | 119–120 | Запланированная отправка, меню приложений |
|
||||||
|
| **0.5.113** | **121** | **Дизайн модуля Задачи** |
|
||||||
|
|
||||||
|
Полный список версий — в git-истории `app/build.gradle`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Серверные зависимости
|
||||||
|
|
||||||
|
Приложение работает с инстансом F7cloud / Nextcloud:
|
||||||
|
|
||||||
|
| Сервис | Назначение |
|
||||||
|
|--------|------------|
|
||||||
|
| OCS API | Авторизация, настройки, Dashboard |
|
||||||
|
| WebDAV | Файлы |
|
||||||
|
| CalDAV | Календарь, задачи |
|
||||||
|
| CardDAV | Контакты |
|
||||||
|
| Mail (IMAP/SMTP через API) | Почта |
|
||||||
|
| Spreed / Talk | Конференции, записи |
|
||||||
|
| Richdocuments + Collabora | Редактирование Office |
|
||||||
|
| f7support | Техподдержка |
|
||||||
|
| Deck | Карточки |
|
||||||
|
|
||||||
|
Иконки и CSS-эталоны: `themes/forbion/` на сервере `forbion.f7cloud.ru`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Макеты и дизайн
|
||||||
|
|
||||||
|
```
|
||||||
|
design/
|
||||||
|
├── screens/ # PNG-макеты экранов (tasks, calendar, mail, …)
|
||||||
|
├── tokens/ # colors.txt, typography
|
||||||
|
├── icons/ # Локальные иконки (если нет на сервере)
|
||||||
|
└── references/ # Ссылки на Figma
|
||||||
|
```
|
||||||
|
|
||||||
|
При вёрстке ориентируемся на **мобильный CSS** веб-версии: `themes/forbion/css/pages/*-mobile.css`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Известные ограничения
|
||||||
|
|
||||||
|
1. **Задачи** — только CalDAV-календари как списки; умные списки и метки в UI есть, серверной логики нет.
|
||||||
|
2. **Заметки** — открываются в браузере, не нативно.
|
||||||
|
3. **Release-подпись** — для production нужен настроенный keystore (сейчас основная сборка — debug).
|
||||||
|
4. **Размер APK** ~130 МБ — включает vendor/talk-android и все feature-модули.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Связанные документы
|
||||||
|
|
||||||
|
| Файл | Содержание |
|
||||||
|
|------|------------|
|
||||||
|
| [README.md](README.md) | Краткая справка по сборке и модулям |
|
||||||
|
| [design/README.md](design/README.md) | Структура макетов Figma |
|
||||||
|
| [vendor/talk-android/README.F7CLOUD.md](vendor/talk-android/README.F7CLOUD.md) | Форк Talk для F7cloud |
|
||||||
|
| [../docs/SERVER-PERFORMANCE.md](../docs/SERVER-PERFORMANCE.md) | Производительность сервера |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Быстрый чеклист перед выдачей APK
|
||||||
|
|
||||||
|
1. `./gradlew assembleDebug` — без ошибок.
|
||||||
|
2. `versionCode` / `versionName` обновлены в `app/build.gradle`.
|
||||||
|
3. APK существует в `app/build/outputs/apk/debug/`.
|
||||||
|
4. При необходимости: `chown www-data:www-data app/build/outputs/apk/debug/*.apk`.
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# F7cloud Mobile (native)
|
||||||
|
|
||||||
|
Нативное Android-приложение F7cloud на Kotlin + Jetpack Compose.
|
||||||
|
|
||||||
|
| Параметр | Значение |
|
||||||
|
|----------|----------|
|
||||||
|
| applicationId | `ru.forbion.f7cloud.mobile` |
|
||||||
|
| minSdk | 26 |
|
||||||
|
| compileSdk / targetSdk | 36 |
|
||||||
|
| Gradle | 9.3.0 |
|
||||||
|
| AGP | 8.13.2 |
|
||||||
|
| Kotlin | 2.3.0 |
|
||||||
|
| Модули | app, core (auth, network, designsystem, push), feature (widgets, files, talk, mail, …) |
|
||||||
|
|
||||||
|
## Сборка APK
|
||||||
|
|
||||||
|
С **v0.5.27** используется собственный Gradle wrapper в этом каталоге (Gradle 9.3, AGP 8.13, Kotlin 2.3):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/f7cloud/f7cloud-mobile
|
||||||
|
./gradlew assembleDebug
|
||||||
|
chown www-data:www-data app/build/outputs/apk/debug/*.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
> Старый способ через `android-webview` (Gradle 8.7) больше не поддерживается — там AGP 8.2, несовместим с Kotlin 2.3.
|
||||||
|
|
||||||
|
Release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew assembleRelease
|
||||||
|
```
|
||||||
|
|
||||||
|
APK с версией в имени:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/build/outputs/apk/debug/f7cloud-mobile-v{versionName}-{versionCode}-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура навигации
|
||||||
|
|
||||||
|
Мобильный UI повторяет **мобильную** версию сайта (тема forbion):
|
||||||
|
|
||||||
|
- нижняя панель: Talk, Профиль, Уведомления, Создать, Меню;
|
||||||
|
- меню приложений — bottom sheet со списком модулей;
|
||||||
|
- стартовый экран после входа — **Главная** (виджеты / Dashboard).
|
||||||
|
|
||||||
|
## Модуль «Файлы»
|
||||||
|
|
||||||
|
- Список папок и файлов через WebDAV PROPFIND (`oc:fileid` для идентификации).
|
||||||
|
- Навигация по папкам, кнопка «Назад».
|
||||||
|
|
||||||
|
### Word / Excel / PowerPoint (Richdocuments)
|
||||||
|
|
||||||
|
Office-документы открываются в **Collabora Online** через Richdocuments:
|
||||||
|
|
||||||
|
1. По `fileId` (WebDAV или ссылка `/f/{id}` с виджетов) → Direct Editing API:
|
||||||
|
`POST /ocs/v2.php/apps/richdocuments/api/v1/document`.
|
||||||
|
2. Полноэкранный WebView (`OfficeEditorActivity`) с:
|
||||||
|
- `RichDocumentsMobileInterface` (см. `apps/richdocuments/docs/mobile_flow.md` на сервере) — скрывает оболочку F7cloud;
|
||||||
|
- desktop User-Agent — полная панель инструментов Collabora;
|
||||||
|
- поддержка iframe Collabora (`coll.f7cloud.ru`).
|
||||||
|
|
||||||
|
**Где работает:** модуль **Файлы** и виджет **Рекомендации** на **Главной** (клик по `.docx` / `.xlsx`).
|
||||||
|
|
||||||
|
Поддерживаемые расширения: см. `OfficeFiles.kt`.
|
||||||
|
|
||||||
|
**Требования на сервере:** `richdocuments` + Collabora (у вас: `https://coll.f7cloud.ru`).
|
||||||
|
|
||||||
|
## Модуль «Главная» (Widgets)
|
||||||
|
|
||||||
|
Данные с Dashboard API F7cloud:
|
||||||
|
|
||||||
|
| Endpoint | Назначение |
|
||||||
|
|----------|------------|
|
||||||
|
| `/ocs/v2.php/apps/dashboard/api/v1/widgets` | метаданные виджетов |
|
||||||
|
| `/ocs/v2.php/apps/dashboard/api/v3/layout` | порядок виджетов пользователя |
|
||||||
|
| `/ocs/v2.php/apps/dashboard/api/v2/widget-items` | элементы (Talk, Mail, Calendar) |
|
||||||
|
| `/ocs/v2.php/apps/dashboard/api/v1/widget-items?widgets[]=recommendations` | блок «Рекомендации» |
|
||||||
|
|
||||||
|
Запросы выполняются **параллельно** (`WidgetsRepository`, с v0.5.4).
|
||||||
|
|
||||||
|
## Производительность
|
||||||
|
|
||||||
|
### Клиент (приложение)
|
||||||
|
|
||||||
|
| Версия | Изменение |
|
||||||
|
|--------|-----------|
|
||||||
|
| ≤ 0.5.3 | 4 последовательных запроса → до ~30 с |
|
||||||
|
| ≥ 0.5.4 | параллельные запросы, layout с таймаутом 2 с, v1 только для recommendations → ~2–4 с |
|
||||||
|
|
||||||
|
### Сервер
|
||||||
|
|
||||||
|
Перед релизом или при жалобах на медленную «Главную» проверьте сервер:
|
||||||
|
|
||||||
|
**[docs/SERVER-PERFORMANCE.md](../docs/SERVER-PERFORMANCE.md)**
|
||||||
|
|
||||||
|
Кратко для `forbion.f7cloud.ru` (2026-05-30):
|
||||||
|
|
||||||
|
- ✅ OPcache включён (FPM: 256 MB, 100k файлов)
|
||||||
|
- ✅ Redis + APCu для кеша F7cloud
|
||||||
|
- ✅ PHP-FPM: min 18 spare workers, старт 30
|
||||||
|
|
||||||
|
Первый API-запрос после простоя сервера может занимать 15–20 с — это холодный bootstrap PHP/F7cloud, не ошибка приложения.
|
||||||
|
|
||||||
|
## Авторизация
|
||||||
|
|
||||||
|
- Используется тот же пароль, что и для веб-входа (или пароль приложения при 2FA).
|
||||||
|
- OCS `/ocs/v2.php/cloud/user` — проверка и получение `davUserId` для WebDAV.
|
||||||
|
|
||||||
|
## Связанные проекты
|
||||||
|
|
||||||
|
- [android-webview](../android-webview/README_ANDROID_BUILD.md) — WebView-оболочка (legacy)
|
||||||
|
- [f7push](../apps/f7push/README.md) — push-уведомления
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
id 'org.jetbrains.kotlin.android'
|
||||||
|
id 'org.jetbrains.kotlin.plugin.compose'
|
||||||
|
}
|
||||||
|
|
||||||
|
def googleServicesFile = file('google-services.json')
|
||||||
|
if (googleServicesFile.exists()) {
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'ru.forbion.f7cloud.mobile'
|
||||||
|
compileSdk 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId 'ru.forbion.f7cloud.mobile'
|
||||||
|
minSdk 26
|
||||||
|
targetSdk 36
|
||||||
|
versionCode 121
|
||||||
|
versionName '0.5.113'
|
||||||
|
missingDimensionStrategy 'default', 'f7'
|
||||||
|
multiDexEnabled true
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
debug {}
|
||||||
|
release {
|
||||||
|
minifyEnabled true
|
||||||
|
shrinkResources true
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose true
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '17'
|
||||||
|
}
|
||||||
|
|
||||||
|
applicationVariants.configureEach { variant ->
|
||||||
|
variant.outputs.configureEach { output ->
|
||||||
|
def vName = variant.versionName ?: "0.0.0"
|
||||||
|
def vCode = variant.versionCode ?: 0
|
||||||
|
output.outputFileName = "f7cloud-mobile-v${vName}-${vCode}-${variant.name}.apk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation project(':core:auth')
|
||||||
|
implementation project(':core:network')
|
||||||
|
implementation project(':core:push')
|
||||||
|
implementation project(':core:designsystem')
|
||||||
|
implementation project(':feature:files')
|
||||||
|
implementation project(':feature:talk')
|
||||||
|
implementation project(':feature:talk-native')
|
||||||
|
implementation project(':feature:calendar')
|
||||||
|
implementation project(':feature:contacts')
|
||||||
|
implementation project(':feature:tasks')
|
||||||
|
implementation project(':feature:deck')
|
||||||
|
implementation project(':feature:mail')
|
||||||
|
implementation project(':feature:f7support')
|
||||||
|
|
||||||
|
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
|
||||||
|
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'
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "YOUR_PROJECT_NUMBER",
|
||||||
|
"project_id": "f7push",
|
||||||
|
"storage_bucket": "f7push.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:PROJECT_NUMBER:android:WEBVIEW_APP_HASH",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "ru.forbion.f7cloud"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"api_key": [{ "current_key": "YOUR_API_KEY" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:PROJECT_NUMBER:android:MOBILE_APP_HASH",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "ru.forbion.f7cloud.mobile"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"api_key": [{ "current_key": "YOUR_API_KEY" }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Project specific ProGuard rules.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.BLUETOOTH_CONNECT"
|
||||||
|
android:minSdkVersion="31" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".F7MobileApp"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||||
|
tools:replace="android:label,android:theme,android:icon,android:roundIcon">
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||||
|
android:value="f7cloud_messages_v2" />
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||||
|
android:resource="@mipmap/ic_launcher" />
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.app.shortcuts"
|
||||||
|
android:resource="@xml/shortcuts" />
|
||||||
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".OfficeEditorActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:configChanges="orientation|screenSize|keyboardHidden" />
|
||||||
|
<activity
|
||||||
|
android:name=".qr.F7QrScannerActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
|
android:theme="@android:style/Theme.Material.NoActionBar" />
|
||||||
|
<activity
|
||||||
|
android:name=".CallIncomingActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:launchMode="singleInstance"
|
||||||
|
android:showWhenLocked="true"
|
||||||
|
android:turnScreenOn="true"
|
||||||
|
android:theme="@android:style/Theme.Material.NoActionBar"
|
||||||
|
android:taskAffinity="" />
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package ru.forbion.f7cloud.mobile
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.addCallback
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import ru.forbion.f7cloud.core.auth.AuthStore
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7Theme
|
||||||
|
import ru.forbion.f7cloud.core.push.F7IncomingCallQueue
|
||||||
|
import ru.forbion.f7cloud.core.push.F7IncomingCallRinger
|
||||||
|
import ru.forbion.f7cloud.core.push.PushIntents
|
||||||
|
import ru.forbion.f7cloud.core.push.TalkCallPushLabels
|
||||||
|
import ru.forbion.f7cloud.feature.talknative.TalkNativeCallLauncher
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen incoming call UI (Telegram-style).
|
||||||
|
* Notification body tap / lock-screen → preview with Accept/Decline.
|
||||||
|
* Notification Accept button → joins call immediately.
|
||||||
|
*/
|
||||||
|
class CallIncomingActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||||
|
setShowWhenLocked(true)
|
||||||
|
setTurnScreenOn(true)
|
||||||
|
}
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
window.addFlags(
|
||||||
|
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
|
||||||
|
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||||
|
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON,
|
||||||
|
)
|
||||||
|
|
||||||
|
val launch = parseLaunch(intent)
|
||||||
|
if (launch == null) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (launch.autoAccept) {
|
||||||
|
acceptCall(launch)
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onBackPressedDispatcher.addCallback(this) {
|
||||||
|
declineCall(launch)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
F7Theme {
|
||||||
|
IncomingCallScreen(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
callerName = launch.displayName,
|
||||||
|
subtitle = launch.subtitle,
|
||||||
|
onAccept = {
|
||||||
|
acceptCall(launch)
|
||||||
|
finish()
|
||||||
|
},
|
||||||
|
onDecline = {
|
||||||
|
declineCall(launch)
|
||||||
|
finish()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
val launch = parseLaunch(intent) ?: return
|
||||||
|
if (launch.autoAccept) {
|
||||||
|
acceptCall(launch)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun acceptCall(launch: IncomingCallLaunch) {
|
||||||
|
F7IncomingCallRinger.stop()
|
||||||
|
val session = AuthStore(this).load() ?: return
|
||||||
|
F7IncomingCallQueue.dismissAndShowNext(this, launch.roomToken)
|
||||||
|
TalkNativeCallLauncher.launchIncomingCall(
|
||||||
|
this,
|
||||||
|
session,
|
||||||
|
launch.acceptUrl,
|
||||||
|
roomDisplayName = launch.displayName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun declineCall(launch: IncomingCallLaunch) {
|
||||||
|
F7IncomingCallQueue.dismissAndShowNext(this, launch.roomToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseLaunch(intent: Intent?): IncomingCallLaunch? {
|
||||||
|
if (intent == null) return null
|
||||||
|
val acceptUrl = intent.getStringExtra(PushIntents.EXTRA_ACCEPT_URL)?.takeIf { it.isNotBlank() }
|
||||||
|
?: return null
|
||||||
|
return IncomingCallLaunch(
|
||||||
|
acceptUrl = acceptUrl,
|
||||||
|
roomToken = intent.getStringExtra(PushIntents.EXTRA_ROOM_TOKEN),
|
||||||
|
title = intent.getStringExtra(PushIntents.EXTRA_CALL_TITLE).orEmpty(),
|
||||||
|
displayName = intent.getStringExtra(PushIntents.EXTRA_ROOM_DISPLAY_NAME)
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: TalkCallPushLabels.resolveRoomDisplayName(
|
||||||
|
intent.getStringExtra(PushIntents.EXTRA_CALL_TITLE).orEmpty(),
|
||||||
|
intent.getStringExtra(PushIntents.EXTRA_CALL_BODY).orEmpty(),
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
subtitle = intent.getStringExtra(PushIntents.EXTRA_CALL_BODY)
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: getString(ru.forbion.f7cloud.core.push.R.string.incoming_call_subtitle),
|
||||||
|
autoAccept = intent.getBooleanExtra(PushIntents.EXTRA_AUTO_ACCEPT, false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class IncomingCallLaunch(
|
||||||
|
val acceptUrl: String,
|
||||||
|
val roomToken: String?,
|
||||||
|
val title: String,
|
||||||
|
val displayName: String,
|
||||||
|
val subtitle: String,
|
||||||
|
val autoAccept: Boolean,
|
||||||
|
)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package ru.forbion.f7cloud.mobile
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.ImageLoaderFactory
|
||||||
|
import coil.decode.SvgDecoder
|
||||||
|
import coil.disk.DiskCache
|
||||||
|
import coil.memory.MemoryCache
|
||||||
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
|
import ru.f7cloud.talk.application.F7cloudTalkApplication
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
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.push.F7NotificationChannels
|
||||||
|
import ru.forbion.f7cloud.core.push.F7PushRegistrar
|
||||||
|
import ru.forbion.f7cloud.feature.talknative.TalkVendorBootstrap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F7cloud application entry. Extends talk-android [F7cloudTalkApplication] so native
|
||||||
|
* WebRTC (CallActivity, Dagger, Room) can initialize when [TalkVendorBootstrap] is enabled.
|
||||||
|
*/
|
||||||
|
class F7MobileApp : F7cloudTalkApplication(), ImageLoaderFactory {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||||
|
override fun onStart(owner: LifecycleOwner) {
|
||||||
|
AppForegroundTracker.setForeground(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop(owner: LifecycleOwner) {
|
||||||
|
AppForegroundTracker.setForeground(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
F7NotificationChannels.ensureAll(this)
|
||||||
|
TalkVendorBootstrap.onApplicationCreate(this)
|
||||||
|
val auth = AuthStore(this).load() ?: return
|
||||||
|
try {
|
||||||
|
bootstrapFcmRegistration(auth)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.w("F7MobileApp", "Firebase unavailable, push bootstrap skipped", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-register after Firebase config changes (e.g. google-services.json for .mobile package).
|
||||||
|
*/
|
||||||
|
private fun bootstrapFcmRegistration(auth: ru.forbion.f7cloud.core.auth.AuthSession) {
|
||||||
|
val prefs = getSharedPreferences("f7push", MODE_PRIVATE)
|
||||||
|
val configGeneration = 2
|
||||||
|
val needsRefresh = prefs.getInt("fcm_config_generation", 0) < configGeneration
|
||||||
|
|
||||||
|
fun registerToken(token: String) {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
val code = F7PushRegistrar.registerBlocking(this@F7MobileApp, auth, token)
|
||||||
|
Log.d("F7MobileApp", "push register result: $code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsRefresh) {
|
||||||
|
FirebaseMessaging.getInstance().deleteToken().addOnCompleteListener {
|
||||||
|
FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
|
||||||
|
prefs.edit().putInt("fcm_config_generation", configGeneration).apply()
|
||||||
|
registerToken(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
FirebaseMessaging.getInstance().token.addOnSuccessListener { registerToken(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun newImageLoader(): ImageLoader {
|
||||||
|
return ImageLoader.Builder(this)
|
||||||
|
.components { add(SvgDecoder.Factory()) }
|
||||||
|
.crossfade(false)
|
||||||
|
.memoryCache {
|
||||||
|
MemoryCache.Builder(this)
|
||||||
|
.maxSizePercent(0.12)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.diskCache {
|
||||||
|
DiskCache.Builder()
|
||||||
|
.directory(cacheDir.resolve("coil_image_cache"))
|
||||||
|
.maxSizePercent(0.02)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package ru.forbion.f7cloud.mobile
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
private val IncomingBgTop = Color(0xFF3D8FD1)
|
||||||
|
private val IncomingBgBottom = Color(0xFF1B4F82)
|
||||||
|
private val AvatarFill = Color(0xFF5BA8E8)
|
||||||
|
private val AcceptGreen = Color(0xFF2ECC71)
|
||||||
|
private val DeclineRed = Color(0xFFE74C3C)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IncomingCallScreen(
|
||||||
|
callerName: String,
|
||||||
|
subtitle: String,
|
||||||
|
onAccept: () -> Unit,
|
||||||
|
onDecline: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val displayName = callerName.ifBlank { "Звонок" }
|
||||||
|
val initial = displayName.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(IncomingBgTop, IncomingBgBottom),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(72.dp))
|
||||||
|
IncomingAvatar(initial = initial)
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 28.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
color = Color.White.copy(alpha = 0.82f),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(bottom = 56.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(72.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IncomingCallAction(
|
||||||
|
label = "Принять",
|
||||||
|
background = AcceptGreen,
|
||||||
|
iconRes = R.drawable.ic_call_accept,
|
||||||
|
onClick = onAccept,
|
||||||
|
)
|
||||||
|
IncomingCallAction(
|
||||||
|
label = "Отклонить",
|
||||||
|
background = DeclineRed,
|
||||||
|
iconRes = R.drawable.ic_call_decline,
|
||||||
|
onClick = onDecline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IncomingAvatar(initial: String) {
|
||||||
|
val transition = rememberInfiniteTransition(label = "ring")
|
||||||
|
val ringAlpha by transition.animateFloat(
|
||||||
|
initialValue = 0.45f,
|
||||||
|
targetValue = 0.08f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 1800, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse,
|
||||||
|
),
|
||||||
|
label = "ringAlpha",
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(168.dp)
|
||||||
|
.alpha(ringAlpha)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.White.copy(alpha = 0.18f)),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(136.dp)
|
||||||
|
.alpha(ringAlpha * 0.8f)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.White.copy(alpha = 0.14f)),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(112.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(AvatarFill),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = initial,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 44.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IncomingCallAction(
|
||||||
|
label: String,
|
||||||
|
background: Color,
|
||||||
|
iconRes: Int,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(72.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(background),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(iconRes),
|
||||||
|
contentDescription = label,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(30.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package ru.forbion.f7cloud.mobile
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import ru.forbion.f7cloud.core.auth.AuthStore
|
||||||
|
import ru.forbion.f7cloud.core.push.F7PushIntentExtras
|
||||||
|
import ru.forbion.f7cloud.core.push.PushIntents
|
||||||
|
import ru.forbion.f7cloud.feature.talk.TalkHelper
|
||||||
|
import ru.forbion.f7cloud.feature.talknative.TalkNativeCallLauncher
|
||||||
|
import ru.forbion.f7cloud.mobile.permissions.F7AppPermissions
|
||||||
|
import ru.forbion.f7cloud.mobile.ui.AppScaffold
|
||||||
|
|
||||||
|
class MainActivity : FragmentActivity() {
|
||||||
|
private var openUrl by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
private val runtimePermissionsLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions(),
|
||||||
|
) { results ->
|
||||||
|
Log.d(TAG, "Runtime permissions result: $results")
|
||||||
|
onPermissionsResult?.invoke()
|
||||||
|
onPermissionsResult = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Вызывается после системного диалога разрешений (для регистрации FCM и т.д.). */
|
||||||
|
var onPermissionsResult: (() -> Unit)? = null
|
||||||
|
|
||||||
|
fun launchMissingRuntimePermissions(onFinished: (() -> Unit)? = null) {
|
||||||
|
val missing = F7AppPermissions.missing(this)
|
||||||
|
if (missing.isEmpty()) {
|
||||||
|
onFinished?.invoke()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onPermissionsResult = onFinished
|
||||||
|
runtimePermissionsLauncher.launch(missing.toTypedArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
if (!handleIncomingIntent(intent)) {
|
||||||
|
openUrl = resolveOpenUrl(intent)
|
||||||
|
}
|
||||||
|
setContent {
|
||||||
|
AppScaffold(
|
||||||
|
openUrl = openUrl,
|
||||||
|
onOpenUrlConsumed = { openUrl = null },
|
||||||
|
onRequestRuntimePermissions = { finished ->
|
||||||
|
launchMissingRuntimePermissions(finished)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
if (!handleIncomingIntent(intent)) {
|
||||||
|
openUrl = resolveOpenUrl(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveOpenUrl(intent: Intent?): String? {
|
||||||
|
if (intent == null) return null
|
||||||
|
if (F7PushIntentExtras.isFcmLaunch(intent)) {
|
||||||
|
F7PushIntentExtras.publishEventFromIntent(intent)
|
||||||
|
}
|
||||||
|
if (intent.data?.toString() == "f7cloud://talk") {
|
||||||
|
val session = AuthStore(this).load() ?: return null
|
||||||
|
return "${session.serverUrl.trimEnd('/')}/apps/spreed/"
|
||||||
|
}
|
||||||
|
val url = F7PushIntentExtras.resolveOpenUrl(intent)
|
||||||
|
val room = F7PushIntentExtras.resolveRoomToken(intent)
|
||||||
|
val messageId = F7PushIntentExtras.resolveMessageId(intent)
|
||||||
|
if (!url.isNullOrBlank()) {
|
||||||
|
if (!messageId.isNullOrBlank() && !url.contains("#message_")) {
|
||||||
|
return "$url#message_$messageId"
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
if (!room.isNullOrBlank()) {
|
||||||
|
val session = AuthStore(this).load() ?: return null
|
||||||
|
val base = "${session.serverUrl.trimEnd('/')}/index.php/apps/spreed/$room"
|
||||||
|
return if (!messageId.isNullOrBlank()) "$base#message_$messageId" else base
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return true if intent was fully handled (call launched). */
|
||||||
|
private fun handleIncomingIntent(intent: Intent?): Boolean {
|
||||||
|
if (intent == null) return false
|
||||||
|
val session = AuthStore(this).load()
|
||||||
|
when {
|
||||||
|
intent.action == PushIntents.ACTION_OPEN_CALL -> {
|
||||||
|
val acceptUrl = intent.getStringExtra(PushIntents.EXTRA_ACCEPT_URL)
|
||||||
|
if (!acceptUrl.isNullOrBlank() && session != null) {
|
||||||
|
if (intent.getBooleanExtra(PushIntents.EXTRA_AUTO_ACCEPT, false)) {
|
||||||
|
TalkNativeCallLauncher.launchIncomingCall(this, session, acceptUrl)
|
||||||
|
} else {
|
||||||
|
startActivity(
|
||||||
|
Intent(intent).apply {
|
||||||
|
setClass(this@MainActivity, CallIncomingActivity::class.java)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val url = intent.getStringExtra(PushIntents.EXTRA_OPEN_URL)
|
||||||
|
if (!url.isNullOrBlank() && TalkHelper.isCallRoomUrl(url) && session != null) {
|
||||||
|
TalkNativeCallLauncher.launchIncomingCall(this, session, url)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "MainActivity"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
package ru.forbion.f7cloud.mobile
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Message
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.webkit.CookieManager
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebSettings
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.padding
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7Theme
|
||||||
|
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||||
|
import ru.forbion.f7cloud.feature.files.OfficeEditorLaunch
|
||||||
|
|
||||||
|
class OfficeEditorActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private var webViewRef: WebView? = null
|
||||||
|
private var editorVisible by mutableStateOf(false)
|
||||||
|
private var loadError by mutableStateOf<String?>(null)
|
||||||
|
private var launch by mutableStateOf<OfficeEditorLaunch?>(null)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val initial = readLaunch() ?: run {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
launch = initial
|
||||||
|
bindUi(initial)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
val next = readLaunch() ?: return
|
||||||
|
if (OfficeWebViewPool.sessionKey(next) != launch?.let { OfficeWebViewPool.sessionKey(it) }) {
|
||||||
|
OfficeWebViewPool.dispose()
|
||||||
|
webViewRef = null
|
||||||
|
}
|
||||||
|
launch = next
|
||||||
|
editorVisible = false
|
||||||
|
loadError = null
|
||||||
|
loadDocument(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
private fun bindUi(initial: OfficeEditorLaunch) {
|
||||||
|
val httpClient = NetworkFactory.newAuthedClientForOffice(
|
||||||
|
initial.username,
|
||||||
|
initial.password,
|
||||||
|
initial.trustAllCerts,
|
||||||
|
)
|
||||||
|
val authHosts = OfficeWebViewClient.buildAuthHosts(initial)
|
||||||
|
val interceptPrefixes = OfficeWebViewClient.buildInterceptPrefixes(initial)
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
F7Theme {
|
||||||
|
val currentLaunch = launch
|
||||||
|
if (currentLaunch == null) return@F7Theme
|
||||||
|
|
||||||
|
LaunchedEffect(currentLaunch.url, editorVisible) {
|
||||||
|
if (editorVisible) return@LaunchedEffect
|
||||||
|
delay(LOAD_TIMEOUT_MS)
|
||||||
|
if (!editorVisible && loadError == null) {
|
||||||
|
loadError = "Редактор не ответил вовремя. Проверьте интернет и попробуйте снова."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = currentLaunch.title,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { finish() }) {
|
||||||
|
Text("←", style = MaterialTheme.typography.titleLarge)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = F7Colors.Surface,
|
||||||
|
titleContentColor = F7Colors.TextPrimary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
containerColor = F7Colors.Background,
|
||||||
|
) { padding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.background(androidx.compose.ui.graphics.Color.White),
|
||||||
|
) {
|
||||||
|
if (loadError != null) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
loadError!!,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
|
)
|
||||||
|
F7PrimaryButton(
|
||||||
|
text = "Закрыть",
|
||||||
|
onClick = { finish() },
|
||||||
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
OfficeWebView(
|
||||||
|
launch = currentLaunch,
|
||||||
|
httpClient = httpClient,
|
||||||
|
authHosts = authHosts,
|
||||||
|
interceptPrefixes = interceptPrefixes,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
if (!editorVisible) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(androidx.compose.ui.graphics.Color.White),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
CircularProgressIndicator(color = F7Colors.Primary)
|
||||||
|
Text(
|
||||||
|
"Загрузка редактора…",
|
||||||
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = F7Colors.TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onCollaboraDocumentLoaded(webView: WebView?) {
|
||||||
|
editorVisible = true
|
||||||
|
loadError = null
|
||||||
|
webView?.evaluateJavascript(HIDE_F7CLOUD_CHROME_JS, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadDocument(target: OfficeEditorLaunch) {
|
||||||
|
val view = webViewRef ?: return
|
||||||
|
val current = view.url?.trimEnd('/').orEmpty()
|
||||||
|
val next = target.url.trimEnd('/')
|
||||||
|
if (current == next) return
|
||||||
|
editorVisible = false
|
||||||
|
loadError = null
|
||||||
|
view.loadUrl(target.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readLaunch(): OfficeEditorLaunch? {
|
||||||
|
val url = intent.getStringExtra(EXTRA_URL) ?: return null
|
||||||
|
val title = intent.getStringExtra(EXTRA_TITLE).orEmpty()
|
||||||
|
val username = intent.getStringExtra(EXTRA_USERNAME).orEmpty()
|
||||||
|
val password = intent.getStringExtra(EXTRA_PASSWORD).orEmpty()
|
||||||
|
val serverUrl = intent.getStringExtra(EXTRA_SERVER_URL).orEmpty()
|
||||||
|
if (url.isBlank() || username.isBlank()) return null
|
||||||
|
return OfficeEditorLaunch(
|
||||||
|
url = url,
|
||||||
|
title = title,
|
||||||
|
username = username,
|
||||||
|
password = password,
|
||||||
|
trustAllCerts = intent.getBooleanExtra(EXTRA_TRUST_ALL_CERTS, false),
|
||||||
|
serverUrl = serverUrl,
|
||||||
|
collaboraBaseUrl = intent.getStringExtra(EXTRA_COLLABORA_URL).orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
@Composable
|
||||||
|
private fun OfficeWebView(
|
||||||
|
launch: OfficeEditorLaunch,
|
||||||
|
httpClient: OkHttpClient,
|
||||||
|
authHosts: Set<String>,
|
||||||
|
interceptPrefixes: List<String>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
AndroidView(
|
||||||
|
modifier = modifier,
|
||||||
|
factory = { context ->
|
||||||
|
OfficeWebViewPool.obtain(context, launch) { webView ->
|
||||||
|
configureOfficeWebView(
|
||||||
|
webView = webView,
|
||||||
|
launch = launch,
|
||||||
|
httpClient = httpClient,
|
||||||
|
authHosts = authHosts,
|
||||||
|
interceptPrefixes = interceptPrefixes,
|
||||||
|
)
|
||||||
|
}.also { webView ->
|
||||||
|
webViewRef = webView
|
||||||
|
webView.layoutParams = FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
)
|
||||||
|
val current = webView.url?.trimEnd('/').orEmpty()
|
||||||
|
val target = launch.url.trimEnd('/')
|
||||||
|
if (current != target) {
|
||||||
|
webView.loadUrl(launch.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = { webView ->
|
||||||
|
webViewRef = webView
|
||||||
|
},
|
||||||
|
onRelease = { webViewRef = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
private fun configureOfficeWebView(
|
||||||
|
webView: WebView,
|
||||||
|
launch: OfficeEditorLaunch,
|
||||||
|
httpClient: OkHttpClient,
|
||||||
|
authHosts: Set<String>,
|
||||||
|
interceptPrefixes: List<String>,
|
||||||
|
) {
|
||||||
|
webView.setBackgroundColor(android.graphics.Color.WHITE)
|
||||||
|
val cookieManager = CookieManager.getInstance()
|
||||||
|
cookieManager.setAcceptCookie(true)
|
||||||
|
cookieManager.setAcceptThirdPartyCookies(webView, true)
|
||||||
|
webView.settings.apply {
|
||||||
|
javaScriptEnabled = true
|
||||||
|
domStorageEnabled = true
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
databaseEnabled = true
|
||||||
|
javaScriptCanOpenWindowsAutomatically = true
|
||||||
|
setSupportMultipleWindows(true)
|
||||||
|
loadWithOverviewMode = true
|
||||||
|
useWideViewPort = true
|
||||||
|
builtInZoomControls = true
|
||||||
|
displayZoomControls = false
|
||||||
|
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||||
|
cacheMode = WebSettings.LOAD_CACHE_ELSE_NETWORK
|
||||||
|
mediaPlaybackRequiresUserGesture = false
|
||||||
|
userAgentString = MOBILE_USER_AGENT
|
||||||
|
}
|
||||||
|
webView.removeJavascriptInterface("RichDocumentsMobileInterface")
|
||||||
|
webView.addJavascriptInterface(
|
||||||
|
RichDocumentsMobileBridge(this@OfficeEditorActivity) { webViewRef },
|
||||||
|
"RichDocumentsMobileInterface",
|
||||||
|
)
|
||||||
|
webView.webChromeClient = object : WebChromeClient() {
|
||||||
|
override fun onCreateWindow(
|
||||||
|
view: WebView?,
|
||||||
|
isDialog: Boolean,
|
||||||
|
isUserGesture: Boolean,
|
||||||
|
resultMsg: Message?,
|
||||||
|
): Boolean {
|
||||||
|
val transport = resultMsg?.obj as? WebView.WebViewTransport ?: return false
|
||||||
|
val popup = WebView(webView.context).apply {
|
||||||
|
applyOfficePopupSettings(launch, httpClient, authHosts, interceptPrefixes)
|
||||||
|
}
|
||||||
|
transport.webView = popup
|
||||||
|
resultMsg.sendToTarget()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
webView.webViewClient = createClient(launch, httpClient, authHosts, interceptPrefixes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
private fun WebView.applyOfficePopupSettings(
|
||||||
|
launch: OfficeEditorLaunch,
|
||||||
|
httpClient: OkHttpClient,
|
||||||
|
authHosts: Set<String>,
|
||||||
|
interceptPrefixes: List<String>,
|
||||||
|
) {
|
||||||
|
settings.javaScriptEnabled = true
|
||||||
|
settings.domStorageEnabled = true
|
||||||
|
settings.setSupportMultipleWindows(true)
|
||||||
|
settings.userAgentString = MOBILE_USER_AGENT
|
||||||
|
webViewClient = createClient(launch, httpClient, authHosts, interceptPrefixes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createClient(
|
||||||
|
launch: OfficeEditorLaunch,
|
||||||
|
httpClient: OkHttpClient,
|
||||||
|
authHosts: Set<String>,
|
||||||
|
interceptPrefixes: List<String>,
|
||||||
|
): WebViewClient = OfficeWebViewClient(
|
||||||
|
launch = launch,
|
||||||
|
httpClient = httpClient,
|
||||||
|
authHosts = authHosts,
|
||||||
|
interceptPrefixes = interceptPrefixes,
|
||||||
|
onMainFrameError = { msg ->
|
||||||
|
runOnUiThread {
|
||||||
|
if (!editorVisible) {
|
||||||
|
loadError = "Не удалось загрузить редактор: $msg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPageReady = { view ->
|
||||||
|
runOnUiThread {
|
||||||
|
view?.evaluateJavascript(HIDE_F7CLOUD_CHROME_JS, null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
OfficeWebViewPool.recycle(webViewRef)
|
||||||
|
webViewRef = null
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val EXTRA_URL = "url"
|
||||||
|
private const val EXTRA_TITLE = "title"
|
||||||
|
private const val EXTRA_USERNAME = "username"
|
||||||
|
private const val EXTRA_PASSWORD = "password"
|
||||||
|
private const val EXTRA_TRUST_ALL_CERTS = "trust_all_certs"
|
||||||
|
private const val EXTRA_SERVER_URL = "server_url"
|
||||||
|
private const val EXTRA_COLLABORA_URL = "collabora_url"
|
||||||
|
|
||||||
|
private const val LOAD_TIMEOUT_MS = 90_000L
|
||||||
|
|
||||||
|
const val MOBILE_USER_AGENT =
|
||||||
|
"Mozilla/5.0 (Linux; Android 13; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) " +
|
||||||
|
"Chrome/131.0.0.0 Mobile Safari/537.36"
|
||||||
|
|
||||||
|
const val HIDE_F7CLOUD_CHROME_JS = """
|
||||||
|
(function() {
|
||||||
|
var hide = function(el) { if (el) { el.style.display = 'none'; el.remove(); } };
|
||||||
|
hide(document.getElementById('loadingContainer'));
|
||||||
|
hide(document.getElementById('proxyLoadingContainer'));
|
||||||
|
hide(document.getElementById('header'));
|
||||||
|
hide(document.getElementById('app-navigation'));
|
||||||
|
hide(document.getElementById('app-navigation-vue'));
|
||||||
|
hide(document.querySelector('#body-user'));
|
||||||
|
hide(document.querySelector('footer'));
|
||||||
|
var main = document.getElementById('content');
|
||||||
|
if (main) { main.style.margin = '0'; main.style.padding = '0'; }
|
||||||
|
document.documentElement.style.overflow = 'hidden';
|
||||||
|
document.body.style.margin = '0';
|
||||||
|
document.body.style.padding = '0';
|
||||||
|
document.body.style.background = '#fff';
|
||||||
|
var doc = document.getElementById('documents-content');
|
||||||
|
if (doc) {
|
||||||
|
doc.style.position = 'fixed';
|
||||||
|
doc.style.top = '0';
|
||||||
|
doc.style.left = '0';
|
||||||
|
doc.style.right = '0';
|
||||||
|
doc.style.bottom = '0';
|
||||||
|
doc.style.width = '100%';
|
||||||
|
doc.style.height = '100%';
|
||||||
|
doc.style.zIndex = '99999';
|
||||||
|
doc.style.background = '#fff';
|
||||||
|
}
|
||||||
|
var frame = document.getElementById('loleafletframe') || document.querySelector('iframe');
|
||||||
|
if (frame) {
|
||||||
|
frame.style.width = '100%';
|
||||||
|
frame.style.height = '100%';
|
||||||
|
frame.style.minHeight = '100vh';
|
||||||
|
frame.style.border = 'none';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
fun intent(context: Context, launch: OfficeEditorLaunch): Intent =
|
||||||
|
Intent(context, OfficeEditorActivity::class.java).apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
putExtra(EXTRA_URL, launch.url)
|
||||||
|
putExtra(EXTRA_TITLE, launch.title)
|
||||||
|
putExtra(EXTRA_USERNAME, launch.username)
|
||||||
|
putExtra(EXTRA_PASSWORD, launch.password)
|
||||||
|
putExtra(EXTRA_TRUST_ALL_CERTS, launch.trustAllCerts)
|
||||||
|
putExtra(EXTRA_SERVER_URL, launch.serverUrl)
|
||||||
|
putExtra(EXTRA_COLLABORA_URL, launch.collaboraBaseUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package ru.forbion.f7cloud.mobile
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.net.http.SslError
|
||||||
|
import android.webkit.HttpAuthHandler
|
||||||
|
import android.webkit.SslErrorHandler
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebResourceResponse
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import ru.forbion.f7cloud.feature.files.OfficeEditorLaunch
|
||||||
|
import java.io.FilterInputStream
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
|
internal class OfficeWebViewClient(
|
||||||
|
private val launch: OfficeEditorLaunch,
|
||||||
|
private val httpClient: OkHttpClient,
|
||||||
|
private val authHosts: Set<String>,
|
||||||
|
private val interceptPrefixes: List<String>,
|
||||||
|
private val onMainFrameError: (String) -> Unit,
|
||||||
|
private val onPageReady: (WebView?) -> Unit,
|
||||||
|
) : WebViewClient() {
|
||||||
|
|
||||||
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
|
view?.evaluateJavascript(OfficeEditorActivity.HIDE_F7CLOUD_CHROME_JS, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
|
view?.evaluateJavascript(OfficeEditorActivity.HIDE_F7CLOUD_CHROME_JS, null)
|
||||||
|
onPageReady(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedHttpAuthRequest(
|
||||||
|
view: WebView?,
|
||||||
|
handler: HttpAuthHandler?,
|
||||||
|
host: String?,
|
||||||
|
realm: String?,
|
||||||
|
) {
|
||||||
|
if (host != null && authHosts.any { host.equals(it, ignoreCase = true) }) {
|
||||||
|
handler?.proceed(launch.username, launch.password)
|
||||||
|
} else {
|
||||||
|
super.onReceivedHttpAuthRequest(view, handler, host, realm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun shouldInterceptRequest(
|
||||||
|
view: WebView?,
|
||||||
|
request: WebResourceRequest,
|
||||||
|
): WebResourceResponse? {
|
||||||
|
val url = request.url?.toString() ?: return null
|
||||||
|
if (!shouldIntercept(url)) return null
|
||||||
|
return runCatching {
|
||||||
|
val builder = Request.Builder().url(url)
|
||||||
|
val method = request.method.uppercase()
|
||||||
|
when (method) {
|
||||||
|
"GET", "HEAD" -> builder.method(method, null)
|
||||||
|
else -> builder.method(method, null)
|
||||||
|
}
|
||||||
|
request.requestHeaders.forEach { (k, v) ->
|
||||||
|
if (!k.equals("Authorization", ignoreCase = true)) {
|
||||||
|
builder.header(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val response = httpClient.newCall(builder.build()).execute()
|
||||||
|
if (!response.isSuccessful || response.body == null) {
|
||||||
|
response.close()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val body = response.body!!
|
||||||
|
val stream = object : FilterInputStream(body.byteStream()) {
|
||||||
|
override fun close() {
|
||||||
|
super.close()
|
||||||
|
response.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WebResourceResponse(
|
||||||
|
body.contentType()?.let { "${it.type}/${it.subtype}" },
|
||||||
|
body.contentType()?.charset()?.name() ?: "utf-8",
|
||||||
|
stream,
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedSslError(
|
||||||
|
view: WebView?,
|
||||||
|
handler: SslErrorHandler?,
|
||||||
|
error: SslError?,
|
||||||
|
) {
|
||||||
|
if (launch.trustAllCerts) {
|
||||||
|
handler?.proceed()
|
||||||
|
} else {
|
||||||
|
super.onReceivedSslError(view, handler, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedError(
|
||||||
|
view: WebView?,
|
||||||
|
request: WebResourceRequest,
|
||||||
|
error: android.webkit.WebResourceError?,
|
||||||
|
) {
|
||||||
|
if (request.isForMainFrame) {
|
||||||
|
val msg = error?.description?.toString().orEmpty().ifBlank { "Ошибка сети" }
|
||||||
|
onMainFrameError(msg)
|
||||||
|
}
|
||||||
|
super.onReceivedError(view, request, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldIntercept(url: String): Boolean =
|
||||||
|
interceptPrefixes.any { prefix -> url.startsWith(prefix, ignoreCase = true) }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun buildAuthHosts(launch: OfficeEditorLaunch): Set<String> {
|
||||||
|
val hosts = mutableSetOf<String>()
|
||||||
|
runCatching { URI(launch.url).host }.getOrNull()?.let { hosts += it }
|
||||||
|
runCatching { URI(launch.serverUrl).host }.getOrNull()?.let { hosts += it }
|
||||||
|
if (launch.collaboraBaseUrl.isNotBlank()) {
|
||||||
|
runCatching { URI(launch.collaboraBaseUrl).host }.getOrNull()?.let { hosts += it }
|
||||||
|
}
|
||||||
|
return hosts
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildInterceptPrefixes(launch: OfficeEditorLaunch): List<String> {
|
||||||
|
val prefixes = mutableListOf(launch.serverUrl.trimEnd('/'))
|
||||||
|
if (launch.collaboraBaseUrl.isNotBlank()) {
|
||||||
|
prefixes += launch.collaboraBaseUrl.trimEnd('/')
|
||||||
|
}
|
||||||
|
return prefixes.distinct()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package ru.forbion.f7cloud.mobile
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.webkit.WebView
|
||||||
|
import ru.forbion.f7cloud.feature.files.OfficeEditorLaunch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Держит один настроенный WebView между открытиями редактора (тот же пользователь/сервер).
|
||||||
|
* Статика Collabora подтягивается из HTTP-кэша WebView при повторных loadUrl.
|
||||||
|
*/
|
||||||
|
internal object OfficeWebViewPool {
|
||||||
|
private var webView: WebView? = null
|
||||||
|
private var sessionKey: String? = null
|
||||||
|
private var recycledAtMs: Long = 0L
|
||||||
|
|
||||||
|
private const val MAX_IDLE_MS = 15 * 60 * 1000L
|
||||||
|
|
||||||
|
fun sessionKey(launch: OfficeEditorLaunch): String =
|
||||||
|
"${launch.serverUrl.trimEnd('/')}|${launch.username}"
|
||||||
|
|
||||||
|
fun obtain(
|
||||||
|
context: Context,
|
||||||
|
launch: OfficeEditorLaunch,
|
||||||
|
configure: (WebView) -> Unit,
|
||||||
|
): WebView {
|
||||||
|
evictIfStale()
|
||||||
|
val key = sessionKey(launch)
|
||||||
|
val existing = webView
|
||||||
|
if (existing != null && sessionKey == key) {
|
||||||
|
detach(existing)
|
||||||
|
configure(existing)
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
dispose()
|
||||||
|
val created = WebView(context).apply { configure(this) }
|
||||||
|
webView = created
|
||||||
|
sessionKey = key
|
||||||
|
recycledAtMs = 0L
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
fun recycle(view: WebView?) {
|
||||||
|
if (view == null || view !== webView) return
|
||||||
|
detach(view)
|
||||||
|
view.stopLoading()
|
||||||
|
recycledAtMs = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dispose() {
|
||||||
|
webView?.destroy()
|
||||||
|
webView = null
|
||||||
|
sessionKey = null
|
||||||
|
recycledAtMs = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
fun evictIfStale() {
|
||||||
|
if (webView == null) return
|
||||||
|
if (recycledAtMs > 0L && System.currentTimeMillis() - recycledAtMs > MAX_IDLE_MS) {
|
||||||
|
dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detach(view: WebView) {
|
||||||
|
(view.parent as? ViewGroup)?.removeView(view)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package ru.forbion.f7cloud.mobile
|
||||||
|
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.WebView
|
||||||
|
|
||||||
|
class RichDocumentsMobileBridge(
|
||||||
|
private val host: OfficeEditorActivity,
|
||||||
|
private val webViewProvider: () -> WebView?,
|
||||||
|
) {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun documentLoaded() {
|
||||||
|
host.runOnUiThread {
|
||||||
|
host.onCollaboraDocumentLoaded(webViewProvider())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun close() {
|
||||||
|
host.runOnUiThread { host.finish() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun close(json: String?) {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun share(json: String?) {
|
||||||
|
// Не используется в нативной оболочке
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun insertGraphic(json: String?) {
|
||||||
|
// Не используется
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun fileRename(json: String?) {
|
||||||
|
// Не используется
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun downloadAs(json: String?) {
|
||||||
|
// Не используется
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun paste(json: String?) {
|
||||||
|
// Не используется
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package ru.forbion.f7cloud.mobile.navigation
|
||||||
|
|
||||||
|
import ru.forbion.f7cloud.feature.files.OfficeFileLinks
|
||||||
|
import ru.forbion.f7cloud.feature.talk.TalkDeepLink
|
||||||
|
import ru.forbion.f7cloud.mobile.ui.AppTab
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps F7cloud web URLs (dashboard widgets, push, notifications) to in-app navigation.
|
||||||
|
*/
|
||||||
|
object AppLinkResolver {
|
||||||
|
fun resolve(url: String?, serverUrl: String = ""): AppLinkTarget? {
|
||||||
|
if (url.isNullOrBlank()) return null
|
||||||
|
val lower = url.lowercase()
|
||||||
|
|
||||||
|
TalkDeepLink.extractRoomToken(url)?.let { token ->
|
||||||
|
return AppLinkTarget(
|
||||||
|
tab = AppTab.Talk,
|
||||||
|
talkRoomToken = token,
|
||||||
|
talkMessageId = TalkDeepLink.extractMessageId(url),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
parseMail(url)?.let { return it }
|
||||||
|
|
||||||
|
parseCalendar(url)?.let { return it }
|
||||||
|
|
||||||
|
parseDeck(url)?.let { return it }
|
||||||
|
|
||||||
|
parseTasks(url)?.let { return it }
|
||||||
|
|
||||||
|
OfficeFileLinks.parseFileId(url, serverUrl)?.let { fileId ->
|
||||||
|
return AppLinkTarget(tab = AppTab.Files, fileId = fileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.contains("/apps/files") || lower.contains("/remote.php/dav/files") ||
|
||||||
|
lower.contains("fileid=") || lower.contains("/f/")
|
||||||
|
) {
|
||||||
|
val fileId = OfficeFileLinks.parseFileId(url, serverUrl)
|
||||||
|
?: Regex("""fileid=(\d+)""", RegexOption.IGNORE_CASE).find(url)
|
||||||
|
?.groupValues?.get(1)?.toLongOrNull()
|
||||||
|
return AppLinkTarget(tab = AppTab.Files, fileId = fileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return when {
|
||||||
|
lower.contains("/apps/f7mail") || lower.contains("/apps/mail") -> AppLinkTarget(tab = AppTab.Mail)
|
||||||
|
lower.contains("/apps/calendar") -> AppLinkTarget(tab = AppTab.Calendar)
|
||||||
|
lower.contains("/apps/tasks") -> parseTasks(url) ?: AppLinkTarget(tab = AppTab.Tasks)
|
||||||
|
lower.contains("/apps/deck") -> AppLinkTarget(tab = AppTab.Deck)
|
||||||
|
lower.contains("/apps/spreed") -> AppLinkTarget(tab = AppTab.Talk)
|
||||||
|
lower.contains("/apps/f7support") -> {
|
||||||
|
AppLinkTarget(tab = AppTab.Support, supportTicket = extractSupportTicket(url))
|
||||||
|
}
|
||||||
|
lower.contains("/apps/dashboard") -> AppLinkTarget(tab = AppTab.Files)
|
||||||
|
lower.contains("/apps/contacts") -> AppLinkTarget(tab = AppTab.Contacts)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseMail(url: String): AppLinkTarget? {
|
||||||
|
if (!url.contains("/apps/f7mail", ignoreCase = true) &&
|
||||||
|
!url.contains("/apps/mail", ignoreCase = true)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val thread = Regex("""/thread/(\d+)""", RegexOption.IGNORE_CASE).find(url)
|
||||||
|
?.groupValues?.get(1)?.toIntOrNull()
|
||||||
|
val mailbox = Regex("""/box/(\d+)""", RegexOption.IGNORE_CASE).find(url)
|
||||||
|
?.groupValues?.get(1)?.toIntOrNull()
|
||||||
|
return AppLinkTarget(
|
||||||
|
tab = AppTab.Mail,
|
||||||
|
mailMessageId = thread,
|
||||||
|
mailMailboxId = mailbox,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseCalendar(url: String): AppLinkTarget? {
|
||||||
|
if (!url.contains("/apps/calendar", ignoreCase = true)) return null
|
||||||
|
val uid = Regex("""/edit/([^/?#]+)""", RegexOption.IGNORE_CASE).find(url)
|
||||||
|
?.groupValues?.get(1)
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
return AppLinkTarget(tab = AppTab.Calendar, calendarEventUid = uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDeck(url: String): AppLinkTarget? {
|
||||||
|
if (!url.contains("/apps/deck", ignoreCase = true)) return null
|
||||||
|
val cardId = Regex("""/card/(\d+)""", RegexOption.IGNORE_CASE).find(url)
|
||||||
|
?.groupValues?.get(1)?.toIntOrNull()
|
||||||
|
return AppLinkTarget(tab = AppTab.Deck, deckCardId = cardId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseTasks(url: String): AppLinkTarget? {
|
||||||
|
if (!url.contains("/apps/tasks", ignoreCase = true)) return null
|
||||||
|
val slug = Regex("""/calendars/([^/?#]+)""", RegexOption.IGNORE_CASE).find(url)
|
||||||
|
?.groupValues?.get(1)
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
return AppLinkTarget(tab = AppTab.Tasks, tasksListSlug = slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractSupportTicket(url: String): String? {
|
||||||
|
val marker = "ticket="
|
||||||
|
val idx = url.indexOf(marker, ignoreCase = true)
|
||||||
|
if (idx < 0) return null
|
||||||
|
return url.substring(idx + marker.length).takeWhile { it.isDigit() }.ifBlank { null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AppLinkTarget(
|
||||||
|
val tab: AppTab,
|
||||||
|
val talkRoomToken: String? = null,
|
||||||
|
val talkMessageId: Long? = null,
|
||||||
|
val mailMessageId: Int? = null,
|
||||||
|
val mailMailboxId: Int? = null,
|
||||||
|
val calendarEventUid: String? = null,
|
||||||
|
val deckCardId: Int? = null,
|
||||||
|
val fileId: Long? = null,
|
||||||
|
val supportTicket: String? = null,
|
||||||
|
val tasksListSlug: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package ru.forbion.f7cloud.mobile.permissions
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
object F7AppPermissions {
|
||||||
|
|
||||||
|
/** Все runtime-разрешения, которые нужны приложению после входа. */
|
||||||
|
fun requiredRuntimePermissions(): List<String> = buildList {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
add(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
add(Manifest.permission.READ_MEDIA_IMAGES)
|
||||||
|
add(Manifest.permission.READ_MEDIA_VIDEO)
|
||||||
|
add(Manifest.permission.READ_MEDIA_AUDIO)
|
||||||
|
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
add(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||||
|
}
|
||||||
|
add(Manifest.permission.CAMERA)
|
||||||
|
add(Manifest.permission.RECORD_AUDIO)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
add(Manifest.permission.BLUETOOTH_CONNECT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun missing(context: Context): List<String> =
|
||||||
|
requiredRuntimePermissions().filter {
|
||||||
|
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasAll(context: Context): Boolean = missing(context).isEmpty()
|
||||||
|
|
||||||
|
fun talkCallPermissions(): Array<String> = arrayOf(
|
||||||
|
Manifest.permission.CAMERA,
|
||||||
|
Manifest.permission.RECORD_AUDIO,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun missingTalkCall(context: Context): List<String> =
|
||||||
|
talkCallPermissions().filter {
|
||||||
|
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package ru.forbion.f7cloud.mobile.permissions
|
||||||
|
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun F7PermissionRationaleDialog(
|
||||||
|
visible: Boolean,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
if (!visible) return
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "Разрешения для F7cloud",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = "Чтобы работали уведомления о звонках и сообщениях, видеозвонки в конференциях " +
|
||||||
|
"и загрузка файлов, разрешите доступ к уведомлениям, камере, микрофону и хранилищу.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = F7Colors.TextSecondary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onConfirm) {
|
||||||
|
Text("Разрешить", color = F7Colors.Primary)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Позже", color = F7Colors.TextSecondary)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
package ru.forbion.f7cloud.mobile.qr
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.camera.core.Camera
|
||||||
|
import androidx.camera.core.CameraSelector
|
||||||
|
import androidx.camera.core.ImageAnalysis
|
||||||
|
import androidx.camera.core.ImageProxy
|
||||||
|
import androidx.camera.core.Preview
|
||||||
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||||
|
import androidx.camera.view.PreviewView
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
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.CornerRadius
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.geometry.RoundRect
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.PathFillType
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.google.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.BinaryBitmap
|
||||||
|
import com.google.zxing.DecodeHintType
|
||||||
|
import com.google.zxing.MultiFormatReader
|
||||||
|
import com.google.zxing.common.HybridBinarizer
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7Theme
|
||||||
|
import ru.forbion.f7cloud.core.network.LoginFlowClient
|
||||||
|
import ru.forbion.f7cloud.mobile.R
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
class F7QrScannerActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val finishing = AtomicBoolean(false)
|
||||||
|
private val analysisExecutor = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContent {
|
||||||
|
F7Theme {
|
||||||
|
QrScannerScreen(
|
||||||
|
onClose = { finish() },
|
||||||
|
onStableResult = ::finishWithStableResult,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
analysisExecutor.shutdown()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finishWithStableResult(payload: String) {
|
||||||
|
if (!finishing.compareAndSet(false, true)) return
|
||||||
|
setResult(
|
||||||
|
RESULT_OK,
|
||||||
|
Intent().putExtra(RESULT_EXTRA, payload),
|
||||||
|
)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val RESULT_EXTRA = "ru.forbion.f7cloud.mobile.qr_scan_result"
|
||||||
|
private const val REQUIRED_STABLE_READS = 5
|
||||||
|
|
||||||
|
fun intent(context: Context): Intent =
|
||||||
|
Intent(context, F7QrScannerActivity::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun QrScannerScreen(
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onStableResult: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val idleHint = stringResource(R.string.qr_scan_hint_idle)
|
||||||
|
var hint by remember(idleHint) { mutableStateOf(idleHint) }
|
||||||
|
var stableCount by remember { mutableIntStateOf(0) }
|
||||||
|
var torchEnabled by remember { mutableStateOf(false) }
|
||||||
|
var camera by remember { mutableStateOf<Camera?>(null) }
|
||||||
|
val previewView = remember { PreviewView(context).apply { implementationMode = PreviewView.ImplementationMode.COMPATIBLE } }
|
||||||
|
|
||||||
|
val reader = remember {
|
||||||
|
MultiFormatReader().apply {
|
||||||
|
setHints(
|
||||||
|
mapOf(
|
||||||
|
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
||||||
|
DecodeHintType.CHARACTER_SET to "UTF-8",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val mainExecutor = remember { ContextCompat.getMainExecutor(context) }
|
||||||
|
var lastPayload by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
fun onDecode(text: String?) {
|
||||||
|
if (finishing.get()) return
|
||||||
|
if (text.isNullOrBlank()) {
|
||||||
|
lastPayload = null
|
||||||
|
stableCount = 0
|
||||||
|
hint = context.getString(R.string.qr_scan_hint_idle)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val trimmed = text.trim()
|
||||||
|
if (!LoginFlowClient.isCompleteQrPayload(trimmed)) {
|
||||||
|
lastPayload = null
|
||||||
|
stableCount = 0
|
||||||
|
hint = context.getString(R.string.qr_scan_hint_align)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (trimmed == lastPayload) {
|
||||||
|
stableCount += 1
|
||||||
|
} else {
|
||||||
|
lastPayload = trimmed
|
||||||
|
stableCount = 1
|
||||||
|
}
|
||||||
|
hint = if (stableCount >= REQUIRED_STABLE_READS) {
|
||||||
|
context.getString(R.string.qr_scan_hint_done)
|
||||||
|
} else {
|
||||||
|
context.getString(R.string.qr_scan_hint_progress, stableCount, REQUIRED_STABLE_READS)
|
||||||
|
}
|
||||||
|
if (stableCount >= REQUIRED_STABLE_READS) {
|
||||||
|
onStableResult(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(previewView) {
|
||||||
|
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
||||||
|
cameraProviderFuture.addListener({
|
||||||
|
val cameraProvider = cameraProviderFuture.get()
|
||||||
|
val preview = Preview.Builder().build().also {
|
||||||
|
it.surfaceProvider = previewView.surfaceProvider
|
||||||
|
}
|
||||||
|
val analysis = ImageAnalysis.Builder()
|
||||||
|
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||||
|
.build()
|
||||||
|
analysis.setAnalyzer(analysisExecutor) { imageProxy ->
|
||||||
|
val decoded = decodeQr(reader, imageProxy)
|
||||||
|
imageProxy.close()
|
||||||
|
mainExecutor.execute { onDecode(decoded) }
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
cameraProvider.unbindAll()
|
||||||
|
camera = cameraProvider.bindToLifecycle(
|
||||||
|
this@F7QrScannerActivity,
|
||||||
|
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||||
|
preview,
|
||||||
|
analysis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, mainExecutor)
|
||||||
|
onDispose {
|
||||||
|
runCatching { cameraProviderFuture.get().unbindAll() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.qr_scan_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.qr_scan_close),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
val next = !torchEnabled
|
||||||
|
camera?.cameraControl?.enableTorch(next)
|
||||||
|
torchEnabled = next
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (torchEnabled) {
|
||||||
|
stringResource(R.string.qr_scan_torch_off)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.qr_scan_torch_on)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = Color.Black.copy(alpha = 0.55f),
|
||||||
|
titleContentColor = Color.White,
|
||||||
|
navigationIconContentColor = Color.White,
|
||||||
|
actionIconContentColor = Color.White,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
containerColor = Color.Black,
|
||||||
|
) { padding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
) {
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
factory = { previewView },
|
||||||
|
)
|
||||||
|
|
||||||
|
QrFinderOverlay(modifier = Modifier.fillMaxSize())
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = hint,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(horizontal = 24.dp, vertical = 32.dp),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = Color.White,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QrFinderOverlay(modifier: Modifier = Modifier) {
|
||||||
|
val frameColor = F7Colors.Primary
|
||||||
|
Canvas(modifier = modifier) {
|
||||||
|
val frameSize = minOf(size.width, size.height) * 0.68f
|
||||||
|
val left = (size.width - frameSize) / 2f
|
||||||
|
val top = (size.height - frameSize) / 2f
|
||||||
|
val frameRect = Rect(Offset(left, top), Size(frameSize, frameSize))
|
||||||
|
|
||||||
|
val overlayPath = Path().apply {
|
||||||
|
fillType = PathFillType.EvenOdd
|
||||||
|
addRect(Rect(Offset.Zero, size))
|
||||||
|
addRoundRect(RoundRect(frameRect, CornerRadius(16f, 16f)))
|
||||||
|
}
|
||||||
|
drawPath(
|
||||||
|
path = overlayPath,
|
||||||
|
color = Color.Black.copy(alpha = 0.55f),
|
||||||
|
)
|
||||||
|
drawRoundRect(
|
||||||
|
color = frameColor,
|
||||||
|
topLeft = frameRect.topLeft,
|
||||||
|
size = frameRect.size,
|
||||||
|
cornerRadius = CornerRadius(16f, 16f),
|
||||||
|
style = Stroke(width = 4f),
|
||||||
|
blendMode = BlendMode.SrcOver,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeQr(reader: MultiFormatReader, imageProxy: ImageProxy): String? {
|
||||||
|
if (imageProxy.format != android.graphics.ImageFormat.YUV_420_888) return null
|
||||||
|
val yBuffer = imageProxy.planes[0].buffer
|
||||||
|
val ySize = yBuffer.remaining()
|
||||||
|
val yuv = ByteArray(ySize)
|
||||||
|
yBuffer.get(yuv)
|
||||||
|
|
||||||
|
val width = imageProxy.width
|
||||||
|
val height = imageProxy.height
|
||||||
|
val source = com.google.zxing.PlanarYUVLuminanceSource(
|
||||||
|
yuv,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
return runCatching {
|
||||||
|
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))).text
|
||||||
|
}.getOrNull()?.also {
|
||||||
|
reader.reset()
|
||||||
|
} ?: run {
|
||||||
|
reader.reset()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,704 @@
|
|||||||
|
package ru.forbion.f7cloud.mobile.ui
|
||||||
|
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.biometric.BiometricPrompt
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
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.ColumnScope
|
||||||
|
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.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Fingerprint
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
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.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
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 androidx.core.content.ContextCompat
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
|
import ru.forbion.f7cloud.core.auth.AppLockStore
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7TextButton
|
||||||
|
import ru.forbion.f7cloud.mobile.R
|
||||||
|
|
||||||
|
private enum class AppLockSetupStage {
|
||||||
|
Choose,
|
||||||
|
PinCreate,
|
||||||
|
PinConfirm,
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val PIN_LENGTH = 4
|
||||||
|
|
||||||
|
private fun biometricAuthenticators(): Int =
|
||||||
|
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||||
|
BiometricManager.Authenticators.BIOMETRIC_WEAK
|
||||||
|
|
||||||
|
private fun canUseBiometric(context: android.content.Context): Boolean =
|
||||||
|
BiometricManager.from(context).canAuthenticate(biometricAuthenticators()) ==
|
||||||
|
BiometricManager.BIOMETRIC_SUCCESS
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppLockGate(
|
||||||
|
lockStore: AppLockStore,
|
||||||
|
unlockNonce: Int = 0,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
if (!lockStore.isEnabled()) {
|
||||||
|
content()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var unlocked by remember { mutableStateOf(false) }
|
||||||
|
var lockSession by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (lockStore.consumeColdStart()) {
|
||||||
|
unlocked = false
|
||||||
|
lockSession++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val processLifecycle = ProcessLifecycleOwner.get()
|
||||||
|
DisposableEffect(processLifecycle, lockStore) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
when (event) {
|
||||||
|
Lifecycle.Event.ON_STOP -> lockStore.markBackgrounded()
|
||||||
|
Lifecycle.Event.ON_START -> {
|
||||||
|
when {
|
||||||
|
lockStore.consumeColdStart() -> {
|
||||||
|
unlocked = false
|
||||||
|
lockSession++
|
||||||
|
}
|
||||||
|
lockStore.shouldRequireUnlock() -> {
|
||||||
|
unlocked = false
|
||||||
|
lockSession++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
processLifecycle.lifecycle.addObserver(observer)
|
||||||
|
onDispose { processLifecycle.lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(unlockNonce) {
|
||||||
|
if (unlockNonce > 0) {
|
||||||
|
unlocked = true
|
||||||
|
lockStore.clearBackgroundMarker()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unlocked) {
|
||||||
|
content()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
AppLockUnlockScreen(
|
||||||
|
lockStore = lockStore,
|
||||||
|
lockSession = lockSession,
|
||||||
|
onUnlocked = {
|
||||||
|
lockStore.clearBackgroundMarker()
|
||||||
|
unlocked = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppLockSetupDialog(
|
||||||
|
visible: Boolean,
|
||||||
|
lockStore: AppLockStore,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onLockConfigured: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
if (!visible) return
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val activity = context.findFragmentActivity()
|
||||||
|
var stage by remember { mutableStateOf(AppLockSetupStage.Choose) }
|
||||||
|
var firstPin by remember { mutableStateOf("") }
|
||||||
|
var currentPin by remember { mutableStateOf("") }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
val biometricAvailable = remember { canUseBiometric(context) }
|
||||||
|
|
||||||
|
fun finishSkip() {
|
||||||
|
lockStore.markSetupOffered()
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finishPinSetup(pin: String) {
|
||||||
|
lockStore.enable(pin, biometric = false)
|
||||||
|
lockStore.markSetupOffered()
|
||||||
|
onLockConfigured()
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finishBiometricSetup() {
|
||||||
|
lockStore.enableBiometricOnly()
|
||||||
|
lockStore.markSetupOffered()
|
||||||
|
onLockConfigured()
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun launchBiometricSetup() {
|
||||||
|
val host = activity ?: run {
|
||||||
|
error = "Биометрия недоступна на этом устройстве"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val executor = ContextCompat.getMainExecutor(context)
|
||||||
|
val prompt = BiometricPrompt(
|
||||||
|
host,
|
||||||
|
executor,
|
||||||
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||||
|
finishBiometricSetup()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||||
|
if (errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
|
||||||
|
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON
|
||||||
|
) {
|
||||||
|
error = errString.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
prompt.authenticate(
|
||||||
|
BiometricPrompt.PromptInfo.Builder()
|
||||||
|
.setTitle("Биометрия")
|
||||||
|
.setSubtitle("Подтвердите отпечаток пальца для защиты приложения")
|
||||||
|
.setNegativeButtonText("Отмена")
|
||||||
|
.setAllowedAuthenticators(biometricAuthenticators())
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun appendDigit(digit: String) {
|
||||||
|
if (currentPin.length >= PIN_LENGTH) return
|
||||||
|
currentPin += digit
|
||||||
|
error = null
|
||||||
|
if (currentPin.length == PIN_LENGTH) {
|
||||||
|
when (stage) {
|
||||||
|
AppLockSetupStage.PinCreate -> {
|
||||||
|
firstPin = currentPin
|
||||||
|
currentPin = ""
|
||||||
|
stage = AppLockSetupStage.PinConfirm
|
||||||
|
}
|
||||||
|
AppLockSetupStage.PinConfirm -> {
|
||||||
|
if (currentPin == firstPin) {
|
||||||
|
finishPinSetup(currentPin)
|
||||||
|
} else {
|
||||||
|
error = "PIN-коды не совпадают"
|
||||||
|
currentPin = ""
|
||||||
|
firstPin = ""
|
||||||
|
stage = AppLockSetupStage.PinCreate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeDigit() {
|
||||||
|
if (currentPin.isNotEmpty()) {
|
||||||
|
currentPin = currentPin.dropLast(1)
|
||||||
|
error = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
properties = DialogProperties(
|
||||||
|
dismissOnBackPress = false,
|
||||||
|
dismissOnClickOutside = false,
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 20.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = Color.White,
|
||||||
|
) {
|
||||||
|
when (stage) {
|
||||||
|
AppLockSetupStage.Choose -> AppLockSetupChoiceContent(
|
||||||
|
canUseBiometric = biometricAvailable,
|
||||||
|
error = error,
|
||||||
|
onChoosePin = {
|
||||||
|
error = null
|
||||||
|
currentPin = ""
|
||||||
|
firstPin = ""
|
||||||
|
stage = AppLockSetupStage.PinCreate
|
||||||
|
},
|
||||||
|
onChooseBiometric = {
|
||||||
|
error = null
|
||||||
|
launchBiometricSetup()
|
||||||
|
},
|
||||||
|
onSkip = ::finishSkip,
|
||||||
|
)
|
||||||
|
AppLockSetupStage.PinCreate,
|
||||||
|
AppLockSetupStage.PinConfirm,
|
||||||
|
-> AppLockPinKeypadContent(
|
||||||
|
title = if (stage == AppLockSetupStage.PinCreate) {
|
||||||
|
"Придумайте PIN-код"
|
||||||
|
} else {
|
||||||
|
"Повторите PIN-код"
|
||||||
|
},
|
||||||
|
subtitle = if (stage == AppLockSetupStage.PinCreate) {
|
||||||
|
"Введите $PIN_LENGTH цифры на клавиатуре ниже"
|
||||||
|
} else {
|
||||||
|
"Подтвердите PIN-код ещё раз"
|
||||||
|
},
|
||||||
|
pinLength = currentPin.length,
|
||||||
|
maxPinLength = PIN_LENGTH,
|
||||||
|
error = error,
|
||||||
|
onDigit = ::appendDigit,
|
||||||
|
onBackspace = ::removeDigit,
|
||||||
|
onBack = {
|
||||||
|
error = null
|
||||||
|
currentPin = ""
|
||||||
|
firstPin = ""
|
||||||
|
stage = AppLockSetupStage.Choose
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppLockSetupChoiceContent(
|
||||||
|
canUseBiometric: Boolean,
|
||||||
|
error: String?,
|
||||||
|
onChoosePin: () -> Unit,
|
||||||
|
onChooseBiometric: () -> Unit,
|
||||||
|
onSkip: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 28.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Защита приложения",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Вы хотите использовать PIN-код или отпечаток пальца?",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = F7Colors.TextSecondary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
if (!error.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = error,
|
||||||
|
color = F7Colors.Error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
F7PrimaryButton(
|
||||||
|
text = "PIN-код",
|
||||||
|
onClick = onChoosePin,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
if (canUseBiometric) {
|
||||||
|
F7PrimaryButton(
|
||||||
|
text = "Отпечаток пальца",
|
||||||
|
onClick = onChooseBiometric,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
F7TextButton(
|
||||||
|
text = "Пропустить",
|
||||||
|
onClick = onSkip,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppLockPinKeypadContent(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
pinLength: Int,
|
||||||
|
maxPinLength: Int,
|
||||||
|
error: String?,
|
||||||
|
onDigit: (String) -> Unit,
|
||||||
|
onBackspace: () -> Unit,
|
||||||
|
onBack: (() -> Unit)? = null,
|
||||||
|
extraAction: (@Composable () -> Unit)? = null,
|
||||||
|
header: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
if (onBack != null) {
|
||||||
|
F7TextButton(
|
||||||
|
text = "← Назад",
|
||||||
|
onClick = onBack,
|
||||||
|
modifier = Modifier.align(Alignment.Start),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
header?.invoke()
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = F7Colors.TextSecondary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(top = 8.dp, bottom = 20.dp),
|
||||||
|
)
|
||||||
|
PinDots(
|
||||||
|
filledCount = pinLength,
|
||||||
|
totalCount = maxPinLength,
|
||||||
|
)
|
||||||
|
if (!error.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = error,
|
||||||
|
color = F7Colors.Error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
PinNumericKeypad(
|
||||||
|
onDigit = onDigit,
|
||||||
|
onBackspace = onBackspace,
|
||||||
|
)
|
||||||
|
extraAction?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PinDots(filledCount: Int, totalCount: Int) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
repeat(totalCount) { index ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(14.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
if (index < filledCount) {
|
||||||
|
F7Colors.Primary
|
||||||
|
} else {
|
||||||
|
Color(0xFFE6E6E6)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PinNumericKeypad(
|
||||||
|
onDigit: (String) -> Unit,
|
||||||
|
onBackspace: () -> Unit,
|
||||||
|
) {
|
||||||
|
val rows = listOf(
|
||||||
|
listOf("1", "2", "3"),
|
||||||
|
listOf("4", "5", "6"),
|
||||||
|
listOf("7", "8", "9"),
|
||||||
|
listOf("", "0", "⌫"),
|
||||||
|
)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
rows.forEach { row ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
) {
|
||||||
|
row.forEach { key ->
|
||||||
|
when (key) {
|
||||||
|
"" -> Spacer(modifier = Modifier.size(72.dp))
|
||||||
|
"⌫" -> PinKey(
|
||||||
|
label = "⌫",
|
||||||
|
onClick = onBackspace,
|
||||||
|
)
|
||||||
|
else -> PinKey(
|
||||||
|
label = key,
|
||||||
|
onClick = { onDigit(key) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PinKey(
|
||||||
|
label: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(72.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFFF3F3F3))
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
fontSize = if (label == "⌫") 24.sp else 28.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppLockBrandedBackdrop(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable ColumnScope.() -> Unit = {},
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.White)
|
||||||
|
.padding(horizontal = 32.dp, vertical = 48.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.f7_app_lock_logo),
|
||||||
|
contentDescription = "F7cloud",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.88f)
|
||||||
|
.padding(bottom = 28.dp),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppLockUnlockScreen(
|
||||||
|
lockStore: AppLockStore,
|
||||||
|
lockSession: Int,
|
||||||
|
onUnlocked: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val activity = context.findFragmentActivity()
|
||||||
|
var pin by remember { mutableStateOf("") }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var showPinEntry by remember(lockSession) { mutableStateOf(!lockStore.useBiometric()) }
|
||||||
|
val biometricOnly = lockStore.isBiometricOnly()
|
||||||
|
val biometricAvailable = remember { canUseBiometric(context) }
|
||||||
|
|
||||||
|
fun verifyPinInput() {
|
||||||
|
if (lockStore.verifyPin(pin)) {
|
||||||
|
onUnlocked()
|
||||||
|
} else {
|
||||||
|
error = "Неверный PIN"
|
||||||
|
pin = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun appendDigit(digit: String) {
|
||||||
|
if (pin.length >= PIN_LENGTH) return
|
||||||
|
pin += digit
|
||||||
|
error = null
|
||||||
|
if (pin.length == PIN_LENGTH) {
|
||||||
|
verifyPinInput()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeDigit() {
|
||||||
|
if (pin.isNotEmpty()) {
|
||||||
|
pin = pin.dropLast(1)
|
||||||
|
error = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun launchBiometricUnlock() {
|
||||||
|
val host = activity ?: run {
|
||||||
|
error = "Биометрия недоступна"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val executor = ContextCompat.getMainExecutor(context)
|
||||||
|
val prompt = BiometricPrompt(
|
||||||
|
host,
|
||||||
|
executor,
|
||||||
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||||
|
onUnlocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||||
|
when (errorCode) {
|
||||||
|
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> {
|
||||||
|
if (!biometricOnly) {
|
||||||
|
showPinEntry = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BiometricPrompt.ERROR_USER_CANCELED -> Unit
|
||||||
|
else -> error = errString.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
prompt.authenticate(
|
||||||
|
BiometricPrompt.PromptInfo.Builder()
|
||||||
|
.setTitle("Разблокировка F7cloud")
|
||||||
|
.setSubtitle("Прикоснитесь к сканеру отпечатка")
|
||||||
|
.setAllowedAuthenticators(biometricAuthenticators())
|
||||||
|
.setNegativeButtonText(if (biometricOnly) "Отмена" else "Ввести PIN")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(lockSession, lockStore.useBiometric(), activity) {
|
||||||
|
if (lockStore.useBiometric() && biometricAvailable && activity != null && !showPinEntry) {
|
||||||
|
launchBiometricUnlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showPinEntry && !biometricOnly) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.White),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
AppLockPinKeypadContent(
|
||||||
|
title = "Введите PIN-код",
|
||||||
|
subtitle = "Для доступа к приложению",
|
||||||
|
pinLength = pin.length,
|
||||||
|
maxPinLength = PIN_LENGTH,
|
||||||
|
error = error,
|
||||||
|
onDigit = ::appendDigit,
|
||||||
|
onBackspace = ::removeDigit,
|
||||||
|
onBack = if (lockStore.useBiometric() && biometricAvailable) {
|
||||||
|
{
|
||||||
|
error = null
|
||||||
|
pin = ""
|
||||||
|
showPinEntry = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
header = {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.f7_app_lock_logo),
|
||||||
|
contentDescription = "F7cloud",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.75f)
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLockBrandedBackdrop {
|
||||||
|
if (lockStore.useBiometric() && biometricAvailable) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Fingerprint,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = F7Colors.Primary,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Прикоснитесь к сканеру отпечатка",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = F7Colors.TextSecondary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
|
)
|
||||||
|
if (!error.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = error ?: "",
|
||||||
|
color = F7Colors.Error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
F7PrimaryButton(
|
||||||
|
text = "Сканер отпечатка",
|
||||||
|
onClick = ::launchBiometricUnlock,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!biometricOnly && lockStore.useBiometric() && biometricAvailable) {
|
||||||
|
F7TextButton(
|
||||||
|
text = "Ввести PIN",
|
||||||
|
onClick = { showPinEntry = true },
|
||||||
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun android.content.Context.findFragmentActivity(): FragmentActivity? {
|
||||||
|
var ctx: android.content.Context = this
|
||||||
|
while (true) {
|
||||||
|
when (ctx) {
|
||||||
|
is FragmentActivity -> return ctx
|
||||||
|
is android.content.ContextWrapper -> ctx = ctx.baseContext
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package ru.forbion.f7cloud.mobile.ui
|
||||||
|
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||||
|
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||||
|
import ru.forbion.f7cloud.core.network.UnauthorizedException
|
||||||
|
import ru.forbion.f7cloud.core.network.applyOcsJson
|
||||||
|
|
||||||
|
object AppMenuRepository {
|
||||||
|
fun fetchExternalSites(session: AuthSession): List<AppMenuExternalSite> {
|
||||||
|
val client = NetworkFactory.newAuthedClient(
|
||||||
|
session.username,
|
||||||
|
session.appPassword,
|
||||||
|
session.trustAllCerts,
|
||||||
|
)
|
||||||
|
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/external/api/v1?format=json"
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
val body = response.body!!.string()
|
||||||
|
val array = when {
|
||||||
|
body.trimStart().startsWith("[") -> JSONArray(body)
|
||||||
|
else -> {
|
||||||
|
val data = JSONObject(body).optJSONObject("ocs")?.opt("data")
|
||||||
|
when (data) {
|
||||||
|
is JSONArray -> data
|
||||||
|
else -> JSONArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(0 until array.length()).mapNotNull { index ->
|
||||||
|
val site = array.optJSONObject(index) ?: return@mapNotNull null
|
||||||
|
val name = site.optString("name").ifBlank { return@mapNotNull null }
|
||||||
|
val icon = site.optString("icon").ifBlank { null }
|
||||||
|
val redirect = site.optInt("redirect", 0) == 1
|
||||||
|
val rawUrl = site.optString("url")
|
||||||
|
val siteId = site.optInt("id", -1)
|
||||||
|
val openUrl = when {
|
||||||
|
redirect && rawUrl.isNotBlank() -> rawUrl
|
||||||
|
siteId >= 0 -> "${session.serverUrl.trimEnd('/')}/index.php/apps/external/$siteId/"
|
||||||
|
rawUrl.isNotBlank() -> rawUrl
|
||||||
|
else -> return@mapNotNull null
|
||||||
|
}
|
||||||
|
AppMenuExternalSite(
|
||||||
|
name = name,
|
||||||
|
iconUrl = icon ?: defaultExternalIcon(session.serverUrl, name),
|
||||||
|
openUrl = openUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultExternalIcon(serverUrl: String, name: String): String {
|
||||||
|
val base = serverUrl.trimEnd('/')
|
||||||
|
return when {
|
||||||
|
name.contains("bitrix", ignoreCase = true) -> "$base/themes/forbion/images/header/bitrix-glass.svg"
|
||||||
|
name.contains("1c", ignoreCase = true) -> "$base/themes/forbion/images/header/1c-glass.svg"
|
||||||
|
else -> "$base/index.php/apps/external/img/external.svg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package ru.forbion.f7cloud.mobile.ui
|
||||||
|
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7AppMenuItem
|
||||||
|
|
||||||
|
enum class AppTab(
|
||||||
|
val title: String,
|
||||||
|
val headerIconPath: String,
|
||||||
|
val menuIconPath: String,
|
||||||
|
) {
|
||||||
|
Mail("Почта", "mail-header-icon.svg", "mail-glass.svg"),
|
||||||
|
Files("Файлы", "files-header-icon.svg", "files-glass.svg"),
|
||||||
|
Calendar("Календарь", "calendar-header-icon.svg", "calendar-glass.svg"),
|
||||||
|
Contacts("Контакты", "contacts-header-icon.svg", "contact-glass.svg"),
|
||||||
|
Talk("Конференции", "spreed-header-icon.svg", "spreed-glass.svg"),
|
||||||
|
Deck("Карточки", "deck-header-icon.svg", "deck-glass.svg"),
|
||||||
|
Tasks("Задачи", "task-header-icon.svg", "task-glass.svg"),
|
||||||
|
Support("Поддержка", "icon-header-f7support.svg", "icon-header-f7support.svg"),
|
||||||
|
;
|
||||||
|
|
||||||
|
fun menuIconUrl(serverUrl: String): String {
|
||||||
|
return "${serverUrl.trimEnd('/')}/themes/forbion/images/header/$menuIconPath"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class AppMenuEntry(
|
||||||
|
val tab: AppTab?,
|
||||||
|
val label: String,
|
||||||
|
val iconPath: String,
|
||||||
|
val webPath: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
val name: String,
|
||||||
|
val iconUrl: String,
|
||||||
|
val openUrl: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
val appMenuTabs: List<AppTab> = AppTab.entries
|
||||||
|
|
||||||
|
fun appMenuItems(
|
||||||
|
serverUrl: String,
|
||||||
|
active: AppTab,
|
||||||
|
externalSites: List<AppMenuExternalSite> = emptyList(),
|
||||||
|
): List<F7AppMenuItem> {
|
||||||
|
val base = serverUrl.trimEnd('/')
|
||||||
|
val core = coreAppMenuEntries.map { entry ->
|
||||||
|
F7AppMenuItem(
|
||||||
|
label = entry.label,
|
||||||
|
iconUrl = "$base/themes/forbion/images/header/${entry.iconPath}",
|
||||||
|
selected = entry.tab == active,
|
||||||
|
externalUrl = entry.webPath?.let { "$base$it" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val external = externalSites.map { site ->
|
||||||
|
F7AppMenuItem(
|
||||||
|
label = site.name,
|
||||||
|
iconUrl = site.iconUrl,
|
||||||
|
selected = false,
|
||||||
|
externalUrl = site.openUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return core + external
|
||||||
|
}
|
||||||
|
|
||||||
|
fun appTabFromMenuIndex(index: Int): AppTab? {
|
||||||
|
return coreAppMenuEntries.getOrNull(index)?.tab
|
||||||
|
}
|
||||||
@@ -0,0 +1,919 @@
|
|||||||
|
package ru.forbion.f7cloud.mobile.ui
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
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.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
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
|
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
|
||||||
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.tasks.await
|
||||||
|
import ru.forbion.f7cloud.core.auth.AppLockStore
|
||||||
|
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 ru.forbion.f7cloud.core.network.LoginFlowClient
|
||||||
|
import ru.forbion.f7cloud.core.push.F7PushEvent
|
||||||
|
import ru.forbion.f7cloud.core.push.F7PushEventHub
|
||||||
|
import ru.forbion.f7cloud.core.push.F7PushRegistrar
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7OverlayNavigationProvider
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7AppMenuSheet
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7AppScaffold
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7AutoHideBottomBar
|
||||||
|
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
|
||||||
|
import ru.forbion.f7cloud.feature.calendar.CalendarScreen
|
||||||
|
import ru.forbion.f7cloud.feature.contacts.ContactsScreen
|
||||||
|
import ru.forbion.f7cloud.feature.deck.DeckScreen
|
||||||
|
import ru.forbion.f7cloud.feature.files.FilesScreen
|
||||||
|
import ru.forbion.f7cloud.feature.f7support.SupportScreen
|
||||||
|
import ru.forbion.f7cloud.feature.mail.MailScreen
|
||||||
|
import ru.forbion.f7cloud.feature.tasks.TasksScreen
|
||||||
|
import ru.forbion.f7cloud.feature.talk.TalkScreen
|
||||||
|
import ru.forbion.f7cloud.mobile.navigation.AppLinkResolver
|
||||||
|
import ru.forbion.f7cloud.mobile.navigation.AppLinkTarget
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppScaffold(
|
||||||
|
openUrl: String? = null,
|
||||||
|
onOpenUrlConsumed: () -> Unit = {},
|
||||||
|
onRequestRuntimePermissions: (onFinished: (() -> Unit)?) -> Unit = {},
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val authStore = remember { AuthStore(context) }
|
||||||
|
var session by remember { mutableStateOf(authStore.load()) }
|
||||||
|
var activeTab by rememberSaveable(
|
||||||
|
saver = Saver(
|
||||||
|
save = { state -> state.value.name },
|
||||||
|
restore = { name ->
|
||||||
|
mutableStateOf(
|
||||||
|
if (name == "Widgets") AppTab.Files
|
||||||
|
else runCatching { AppTab.valueOf(name) }.getOrDefault(AppTab.Files),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) { mutableStateOf(AppTab.Files) }
|
||||||
|
var pendingOpenUrl by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var pendingTalkRoomToken by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var pendingTalkMessageId by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||||
|
var pendingSupportTicket by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var pendingMailMessageId by rememberSaveable { mutableIntStateOf(-1) }
|
||||||
|
var pendingMailMailboxId by rememberSaveable { mutableIntStateOf(-1) }
|
||||||
|
var pendingCalendarEventUid by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var pendingDeckCardId by rememberSaveable { mutableIntStateOf(-1) }
|
||||||
|
var pendingTasksListSlug by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var pendingFileId by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||||
|
var tabHistory by rememberSaveable { mutableStateOf(emptyList<String>()) }
|
||||||
|
var menuOpen by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var talkInRoom by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var mailInMessage by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var bottomBarActivity by remember { mutableIntStateOf(0) }
|
||||||
|
var lastBottomBarPulse by remember { mutableLongStateOf(0L) }
|
||||||
|
var profileOpen by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var notificationsOpen by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var mailSidebarOpen by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var calendarSidebarOpen by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var filesSidebarOpen by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var mailSettingsOpen by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var filesSettingsOpen by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val bottomBarPinned = menuOpen || profileOpen || notificationsOpen ||
|
||||||
|
(activeTab == AppTab.Mail && (mailSidebarOpen || mailSettingsOpen)) ||
|
||||||
|
(activeTab == AppTab.Calendar && calendarSidebarOpen) ||
|
||||||
|
(activeTab == AppTab.Files && (filesSidebarOpen || filesSettingsOpen))
|
||||||
|
var filesUploadRequest by remember { mutableIntStateOf(0) }
|
||||||
|
var contactsCreateRequest by remember { mutableIntStateOf(0) }
|
||||||
|
var tasksCreateRequest by remember { mutableIntStateOf(0) }
|
||||||
|
var supportCreateRequest by remember { mutableIntStateOf(0) }
|
||||||
|
var calendarSettingsRequest by remember { mutableIntStateOf(0) }
|
||||||
|
var talkChatsRequest by remember { mutableIntStateOf(0) }
|
||||||
|
var mailPushRequest by remember { mutableIntStateOf(0) }
|
||||||
|
var talkPushRequest by remember { mutableIntStateOf(0) }
|
||||||
|
var filesPushRequest by remember { mutableIntStateOf(0) }
|
||||||
|
var notificationsPushRequest by remember { mutableIntStateOf(0) }
|
||||||
|
var pushTalkRoomToken by remember { mutableStateOf<String?>(null) }
|
||||||
|
var hasNotificationBadge by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val permissionPrefs = remember {
|
||||||
|
context.applicationContext.getSharedPreferences("f7_permissions", android.content.Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
val forceLogout = {
|
||||||
|
OfficeWarmup.clear()
|
||||||
|
OfficeWebViewPool.dispose()
|
||||||
|
authStore.clear()
|
||||||
|
session = null
|
||||||
|
}
|
||||||
|
LaunchedEffect(session?.serverUrl, session?.username) {
|
||||||
|
session?.let { OfficeWarmup.warm(it) }
|
||||||
|
}
|
||||||
|
LaunchedEffect(openUrl) {
|
||||||
|
if (!openUrl.isNullOrBlank()) {
|
||||||
|
pendingOpenUrl = openUrl
|
||||||
|
onOpenUrlConsumed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
F7Theme {
|
||||||
|
if (session == null) {
|
||||||
|
LoginScreen(onLogin = {
|
||||||
|
authStore.save(it)
|
||||||
|
session = it
|
||||||
|
})
|
||||||
|
return@F7Theme
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentSession = session!!
|
||||||
|
var externalMenuSites by remember { mutableStateOf<List<AppMenuExternalSite>>(emptyList()) }
|
||||||
|
val appMenuItemsList = remember(currentSession.serverUrl, activeTab, externalMenuSites) {
|
||||||
|
appMenuItems(currentSession.serverUrl, activeTab, externalMenuSites)
|
||||||
|
}
|
||||||
|
LaunchedEffect(menuOpen, currentSession.serverUrl, currentSession.username) {
|
||||||
|
if (!menuOpen) return@LaunchedEffect
|
||||||
|
externalMenuSites = withContext(Dispatchers.IO) {
|
||||||
|
AppMenuRepository.fetchExternalSites(currentSession)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val lockStore = remember { AppLockStore(context) }
|
||||||
|
var showAppLockSetup by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var lockUnlockNonce by remember { mutableIntStateOf(0) }
|
||||||
|
LaunchedEffect(currentSession.serverUrl, currentSession.username) {
|
||||||
|
if (lockStore.shouldOfferSetup()) {
|
||||||
|
showAppLockSetup = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppLockSetupDialog(
|
||||||
|
visible = showAppLockSetup,
|
||||||
|
lockStore = lockStore,
|
||||||
|
onDismiss = { showAppLockSetup = false },
|
||||||
|
onLockConfigured = { lockUnlockNonce++ },
|
||||||
|
)
|
||||||
|
AppLockGate(lockStore = lockStore, unlockNonce = lockUnlockNonce) {
|
||||||
|
fun applyAppLink(target: AppLinkTarget) {
|
||||||
|
activeTab = target.tab
|
||||||
|
pendingTalkRoomToken = target.talkRoomToken
|
||||||
|
pendingTalkMessageId = target.talkMessageId
|
||||||
|
pendingSupportTicket = target.supportTicket
|
||||||
|
pendingMailMessageId = target.mailMessageId ?: -1
|
||||||
|
pendingMailMailboxId = target.mailMailboxId ?: -1
|
||||||
|
pendingCalendarEventUid = target.calendarEventUid
|
||||||
|
pendingDeckCardId = target.deckCardId ?: -1
|
||||||
|
pendingFileId = target.fileId
|
||||||
|
pendingTasksListSlug = target.tasksListSlug
|
||||||
|
}
|
||||||
|
fun openAppLink(url: String) {
|
||||||
|
AppLinkResolver.resolve(url, currentSession.serverUrl)?.let { applyAppLink(it) }
|
||||||
|
}
|
||||||
|
LaunchedEffect(pendingOpenUrl) {
|
||||||
|
val url = pendingOpenUrl ?: return@LaunchedEffect
|
||||||
|
openAppLink(url)
|
||||||
|
pendingOpenUrl = null
|
||||||
|
}
|
||||||
|
val userId = currentSession.davUserId ?: currentSession.username
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var browserQrBusy by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val browserQrLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult(),
|
||||||
|
) { result ->
|
||||||
|
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
|
||||||
|
val qrData = result.data?.getStringExtra(F7QrScannerActivity.RESULT_EXTRA)
|
||||||
|
?: return@rememberLauncherForActivityResult
|
||||||
|
scope.launch {
|
||||||
|
browserQrBusy = true
|
||||||
|
val approved = LoginFlowClient.approveBrowserLoginFromQr(
|
||||||
|
qrData = qrData,
|
||||||
|
username = currentSession.username,
|
||||||
|
appPassword = currentSession.appPassword,
|
||||||
|
trustAllCerts = currentSession.trustAllCerts,
|
||||||
|
)
|
||||||
|
browserQrBusy = false
|
||||||
|
val message = if (approved) {
|
||||||
|
"Браузер авторизован"
|
||||||
|
} else {
|
||||||
|
"Не удалось подтвердить вход в браузере"
|
||||||
|
}
|
||||||
|
Toast.makeText(context.applicationContext, message, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val browserQrCameraLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
if (granted) {
|
||||||
|
browserQrLauncher.launch(F7QrScannerActivity.intent(context))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun launchBrowserQrScan() {
|
||||||
|
if (browserQrBusy) return
|
||||||
|
if (androidx.core.content.ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
android.Manifest.permission.CAMERA,
|
||||||
|
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
browserQrLauncher.launch(F7QrScannerActivity.intent(context))
|
||||||
|
} else {
|
||||||
|
browserQrCameraLauncher.launch(android.Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var showPermissionRationale by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var permissionFlowStarted by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
fun registerFcmPush() {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val token = FirebaseMessaging.getInstance().token.await()
|
||||||
|
val code = F7PushRegistrar.registerBlocking(context, currentSession, token)
|
||||||
|
android.util.Log.i("F7Push", "AppScaffold register result: $code")
|
||||||
|
}.onFailure {
|
||||||
|
android.util.Log.e("F7Push", "AppScaffold register failed", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startPermissionRequest() {
|
||||||
|
permissionFlowStarted = true
|
||||||
|
onRequestRuntimePermissions { registerFcmPush() }
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(lifecycleOwner, currentSession.serverUrl, currentSession.username) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
if (event != Lifecycle.Event.ON_RESUME) return@LifecycleEventObserver
|
||||||
|
if (permissionFlowStarted) return@LifecycleEventObserver
|
||||||
|
if (F7AppPermissions.hasAll(context)) {
|
||||||
|
permissionFlowStarted = true
|
||||||
|
registerFcmPush()
|
||||||
|
return@LifecycleEventObserver
|
||||||
|
}
|
||||||
|
val alreadyPrompted = permissionPrefs.getBoolean(PERMISSIONS_PROMPTED_KEY, false)
|
||||||
|
if (!alreadyPrompted) {
|
||||||
|
permissionFlowStarted = true
|
||||||
|
showPermissionRationale = true
|
||||||
|
} else {
|
||||||
|
startPermissionRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
|
||||||
|
F7PermissionRationaleDialog(
|
||||||
|
visible = showPermissionRationale,
|
||||||
|
onConfirm = {
|
||||||
|
showPermissionRationale = false
|
||||||
|
permissionPrefs.edit().putBoolean(PERMISSIONS_PROMPTED_KEY, true).apply()
|
||||||
|
startPermissionRequest()
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
showPermissionRationale = false
|
||||||
|
permissionPrefs.edit().putBoolean(PERMISSIONS_PROMPTED_KEY, true).apply()
|
||||||
|
registerFcmPush()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(activeTab) {
|
||||||
|
if (activeTab == AppTab.Talk && !F7AppPermissions.hasAll(context)) {
|
||||||
|
delay(400)
|
||||||
|
onRequestRuntimePermissions(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
F7PushEventHub.events.collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is F7PushEvent.Mail -> {
|
||||||
|
mailPushRequest++
|
||||||
|
event.messageId?.let { pendingMailMessageId = it }
|
||||||
|
event.mailboxId?.let { pendingMailMailboxId = it }
|
||||||
|
}
|
||||||
|
is F7PushEvent.Talk -> {
|
||||||
|
pushTalkRoomToken = event.roomToken
|
||||||
|
talkPushRequest++
|
||||||
|
event.messageId?.let { pendingTalkMessageId = it }
|
||||||
|
event.roomToken?.let { pendingTalkRoomToken = it }
|
||||||
|
}
|
||||||
|
is F7PushEvent.Files -> {
|
||||||
|
filesPushRequest++
|
||||||
|
event.fileId?.let { pendingFileId = it }
|
||||||
|
}
|
||||||
|
is F7PushEvent.Notification -> {
|
||||||
|
notificationsPushRequest++
|
||||||
|
hasNotificationBadge = true
|
||||||
|
}
|
||||||
|
is F7PushEvent.Call -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(activeTab) {
|
||||||
|
if (activeTab != AppTab.Mail) {
|
||||||
|
mailInMessage = false
|
||||||
|
}
|
||||||
|
if (activeTab != AppTab.Calendar) {
|
||||||
|
calendarSidebarOpen = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(mailInMessage) {
|
||||||
|
if (mailInMessage) {
|
||||||
|
bottomBarActivity++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val bottomBarConfig = remember(activeTab, talkInRoom) {
|
||||||
|
F7BottomBarConfig.forContext(activeTab.name, talkInRoom)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pushTabHistory(from: AppTab) {
|
||||||
|
tabHistory = (tabHistory + from.name).takeLast(20)
|
||||||
|
}
|
||||||
|
fun popTabHistory(): AppTab? {
|
||||||
|
if (tabHistory.isEmpty()) return null
|
||||||
|
val name = tabHistory.last()
|
||||||
|
tabHistory = tabHistory.dropLast(1)
|
||||||
|
return runCatching { AppTab.valueOf(name) }.getOrNull()
|
||||||
|
}
|
||||||
|
fun dismissSwipeOverlay(): Boolean {
|
||||||
|
when {
|
||||||
|
menuOpen -> menuOpen = false
|
||||||
|
profileOpen -> profileOpen = false
|
||||||
|
notificationsOpen -> notificationsOpen = false
|
||||||
|
mailSettingsOpen -> mailSettingsOpen = false
|
||||||
|
filesSettingsOpen -> filesSettingsOpen = false
|
||||||
|
mailSidebarOpen -> mailSidebarOpen = false
|
||||||
|
calendarSidebarOpen -> calendarSidebarOpen = false
|
||||||
|
filesSidebarOpen -> filesSidebarOpen = false
|
||||||
|
else -> return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler {
|
||||||
|
when {
|
||||||
|
menuOpen -> menuOpen = false
|
||||||
|
profileOpen -> profileOpen = false
|
||||||
|
notificationsOpen -> notificationsOpen = false
|
||||||
|
mailSidebarOpen -> mailSidebarOpen = false
|
||||||
|
calendarSidebarOpen -> calendarSidebarOpen = false
|
||||||
|
filesSidebarOpen -> filesSidebarOpen = false
|
||||||
|
mailSettingsOpen -> mailSettingsOpen = false
|
||||||
|
filesSettingsOpen -> filesSettingsOpen = false
|
||||||
|
else -> {
|
||||||
|
val previous = popTabHistory()
|
||||||
|
if (previous != null) {
|
||||||
|
activeTab = previous
|
||||||
|
} else {
|
||||||
|
(context as? Activity)?.moveTaskToBack(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
F7OverlayNavigationProvider(
|
||||||
|
onSwipeDismiss = ::dismissSwipeOverlay,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
awaitEachGesture {
|
||||||
|
awaitFirstDown(pass = PointerEventPass.Initial)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastBottomBarPulse >= 800L) {
|
||||||
|
lastBottomBarPulse = now
|
||||||
|
bottomBarActivity++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
F7AppScaffold(
|
||||||
|
bottomBar = {
|
||||||
|
if ((activeTab != AppTab.Talk || !talkInRoom)) {
|
||||||
|
F7AutoHideBottomBar(
|
||||||
|
enabled = true,
|
||||||
|
pinned = bottomBarPinned,
|
||||||
|
activityNonce = bottomBarActivity,
|
||||||
|
hideDelayMs = 4000L,
|
||||||
|
) {
|
||||||
|
F7MobileBottomBar(
|
||||||
|
serverUrl = currentSession.serverUrl,
|
||||||
|
userId = userId,
|
||||||
|
config = bottomBarConfig,
|
||||||
|
menuOpen = menuOpen,
|
||||||
|
chatsHighlighted = activeTab == AppTab.Talk && !talkInRoom,
|
||||||
|
navBackHighlighted = (activeTab == AppTab.Mail && mailSidebarOpen) ||
|
||||||
|
(activeTab == AppTab.Calendar && calendarSidebarOpen) ||
|
||||||
|
(activeTab == AppTab.Files && filesSidebarOpen),
|
||||||
|
showNotificationBadge = hasNotificationBadge,
|
||||||
|
actions = F7BottomBarActions(
|
||||||
|
onChatsClick = { talkChatsRequest++ },
|
||||||
|
onNavBackClick = {
|
||||||
|
when (activeTab) {
|
||||||
|
AppTab.Mail -> mailSidebarOpen = !mailSidebarOpen
|
||||||
|
AppTab.Calendar -> calendarSidebarOpen = !calendarSidebarOpen
|
||||||
|
AppTab.Files -> filesSidebarOpen = !filesSidebarOpen
|
||||||
|
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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onProfileClick = {
|
||||||
|
menuOpen = false
|
||||||
|
profileOpen = true
|
||||||
|
},
|
||||||
|
onNotificationsClick = {
|
||||||
|
menuOpen = false
|
||||||
|
hasNotificationBadge = false
|
||||||
|
notificationsOpen = true
|
||||||
|
},
|
||||||
|
onSettingsClick = {
|
||||||
|
when (activeTab) {
|
||||||
|
AppTab.Mail -> mailSettingsOpen = true
|
||||||
|
AppTab.Calendar -> calendarSettingsRequest++
|
||||||
|
AppTab.Files -> filesSettingsOpen = true
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMenuClick = { menuOpen = !menuOpen },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { contentModifier ->
|
||||||
|
when (activeTab) {
|
||||||
|
AppTab.Mail -> MailScreen(
|
||||||
|
session = currentSession,
|
||||||
|
modifier = contentModifier,
|
||||||
|
openMessageId = pendingMailMessageId.takeIf { it > 0 },
|
||||||
|
openMailboxId = pendingMailMailboxId.takeIf { it > 0 },
|
||||||
|
sidebarOpen = mailSidebarOpen,
|
||||||
|
onSidebarOpenChange = { mailSidebarOpen = it },
|
||||||
|
settingsOpen = mailSettingsOpen,
|
||||||
|
onSettingsOpenChange = { mailSettingsOpen = it },
|
||||||
|
pushRefreshRequest = mailPushRequest,
|
||||||
|
onOpenMessageConsumed = { pendingMailMessageId = -1; pendingMailMailboxId = -1 },
|
||||||
|
onMessageOpenStateChange = { mailInMessage = it },
|
||||||
|
onUnauthorized = forceLogout,
|
||||||
|
)
|
||||||
|
AppTab.Files -> FilesScreen(
|
||||||
|
session = currentSession,
|
||||||
|
modifier = contentModifier,
|
||||||
|
uploadRequest = filesUploadRequest,
|
||||||
|
pushRefreshRequest = filesPushRequest,
|
||||||
|
openFileId = pendingFileId,
|
||||||
|
sidebarOpen = filesSidebarOpen,
|
||||||
|
onSidebarOpenChange = { filesSidebarOpen = it },
|
||||||
|
settingsOpen = filesSettingsOpen,
|
||||||
|
onSettingsOpenChange = { filesSettingsOpen = it },
|
||||||
|
onOpenFileConsumed = { pendingFileId = null },
|
||||||
|
onUnauthorized = forceLogout,
|
||||||
|
onOpenOfficeEditor = { launch ->
|
||||||
|
context.startActivity(OfficeEditorActivity.intent(context, launch))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
AppTab.Calendar -> CalendarScreen(
|
||||||
|
session = currentSession,
|
||||||
|
modifier = contentModifier,
|
||||||
|
focusEventUid = pendingCalendarEventUid,
|
||||||
|
settingsRequest = calendarSettingsRequest,
|
||||||
|
sidebarOpen = calendarSidebarOpen,
|
||||||
|
onSidebarOpenChange = { calendarSidebarOpen = it },
|
||||||
|
onFocusEventConsumed = { pendingCalendarEventUid = null },
|
||||||
|
onUnauthorized = forceLogout,
|
||||||
|
)
|
||||||
|
AppTab.Contacts -> ContactsScreen(
|
||||||
|
session = currentSession,
|
||||||
|
modifier = contentModifier,
|
||||||
|
createRequest = contactsCreateRequest,
|
||||||
|
onUnauthorized = forceLogout,
|
||||||
|
)
|
||||||
|
AppTab.Talk -> {
|
||||||
|
val onTalkConsumed = {
|
||||||
|
pendingTalkRoomToken = null
|
||||||
|
pendingTalkMessageId = null
|
||||||
|
}
|
||||||
|
TalkScreen(
|
||||||
|
session = currentSession,
|
||||||
|
modifier = contentModifier,
|
||||||
|
openRoomToken = pendingTalkRoomToken,
|
||||||
|
scrollToMessageId = pendingTalkMessageId,
|
||||||
|
chatsListRequest = talkChatsRequest,
|
||||||
|
pushSyncRequest = talkPushRequest,
|
||||||
|
pushRoomToken = pushTalkRoomToken,
|
||||||
|
onOpenRoomConsumed = onTalkConsumed,
|
||||||
|
onRoomOpenStateChange = { talkInRoom = it },
|
||||||
|
onOpenCalendar = { activeTab = AppTab.Calendar },
|
||||||
|
onUnauthorized = forceLogout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AppTab.Deck -> DeckScreen(
|
||||||
|
session = currentSession,
|
||||||
|
modifier = contentModifier,
|
||||||
|
openCardId = pendingDeckCardId.takeIf { it > 0 },
|
||||||
|
onOpenCardConsumed = { pendingDeckCardId = -1 },
|
||||||
|
onUnauthorized = forceLogout,
|
||||||
|
)
|
||||||
|
AppTab.Tasks -> TasksScreen(
|
||||||
|
session = currentSession,
|
||||||
|
modifier = contentModifier,
|
||||||
|
createRequest = tasksCreateRequest,
|
||||||
|
openListSlug = pendingTasksListSlug,
|
||||||
|
onOpenListConsumed = { pendingTasksListSlug = null },
|
||||||
|
onUnauthorized = forceLogout,
|
||||||
|
)
|
||||||
|
AppTab.Support -> SupportScreen(
|
||||||
|
session = currentSession,
|
||||||
|
modifier = contentModifier,
|
||||||
|
createRequest = supportCreateRequest,
|
||||||
|
openTicketNumber = pendingSupportTicket,
|
||||||
|
onOpenTicketConsumed = { pendingSupportTicket = null },
|
||||||
|
onUnauthorized = forceLogout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
F7AppMenuSheet(
|
||||||
|
visible = menuOpen,
|
||||||
|
serverUrl = currentSession.serverUrl,
|
||||||
|
items = appMenuItemsList,
|
||||||
|
onDismiss = { menuOpen = false },
|
||||||
|
onItemClick = { index ->
|
||||||
|
val item = appMenuItemsList.getOrNull(index) ?: return@F7AppMenuSheet
|
||||||
|
val external = item.externalUrl
|
||||||
|
if (!external.isNullOrBlank()) {
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(external)))
|
||||||
|
}
|
||||||
|
menuOpen = false
|
||||||
|
} else {
|
||||||
|
appTabFromMenuIndex(index)?.let { tab ->
|
||||||
|
if (tab != activeTab) {
|
||||||
|
pushTabHistory(activeTab)
|
||||||
|
activeTab = tab
|
||||||
|
}
|
||||||
|
}
|
||||||
|
menuOpen = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ProfileSheet(
|
||||||
|
visible = profileOpen,
|
||||||
|
session = currentSession,
|
||||||
|
onDismiss = { profileOpen = false },
|
||||||
|
onLogout = forceLogout,
|
||||||
|
onScanBrowserQr = ::launchBrowserQrScan,
|
||||||
|
)
|
||||||
|
NotificationsSheet(
|
||||||
|
visible = notificationsOpen,
|
||||||
|
session = currentSession,
|
||||||
|
refreshRequest = notificationsPushRequest,
|
||||||
|
onDismiss = { notificationsOpen = false },
|
||||||
|
onUnauthorized = forceLogout,
|
||||||
|
onNotificationClick = { notification ->
|
||||||
|
openAppLink(notification.link)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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("https://forbion.f7cloud.ru") }
|
||||||
|
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()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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 = ""
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package ru.forbion.f7cloud.mobile.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
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.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
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 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.NotificationsRepository
|
||||||
|
import ru.forbion.f7cloud.core.network.UnauthorizedException
|
||||||
|
|
||||||
|
private fun themeHeaderAsset(serverUrl: String, fileName: String): String =
|
||||||
|
"${serverUrl.trimEnd('/')}/themes/forbion/images/header/$fileName"
|
||||||
|
|
||||||
|
private fun resolveNotificationIcon(serverUrl: String, icon: String): String? {
|
||||||
|
if (icon.isBlank()) return null
|
||||||
|
return if (icon.startsWith("http")) icon else "${serverUrl.trimEnd('/')}$icon"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProfileSheet(
|
||||||
|
visible: Boolean,
|
||||||
|
session: AuthSession,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
onScanBrowserQr: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
F7FloatingPanel(
|
||||||
|
visible = visible,
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
) {
|
||||||
|
Text("Профиль", style = MaterialTheme.typography.titleMedium, color = F7Colors.TextPrimary)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
F7PrimaryButton(
|
||||||
|
text = "Сканировать QR браузера",
|
||||||
|
onClick = {
|
||||||
|
onDismiss()
|
||||||
|
onScanBrowserQr()
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
F7PrimaryButton(
|
||||||
|
text = "Выйти",
|
||||||
|
onClick = {
|
||||||
|
onDismiss()
|
||||||
|
onLogout()
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
F7TextButton(text = "Закрыть", onClick = onDismiss, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NotificationsSheet(
|
||||||
|
visible: Boolean,
|
||||||
|
session: AuthSession,
|
||||||
|
refreshRequest: Int = 0,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onNotificationClick: (F7Notification) -> Unit,
|
||||||
|
onUnauthorized: () -> Unit,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val repository = remember { NotificationsRepository() }
|
||||||
|
var loading by remember { mutableStateOf(true) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var items by remember { mutableStateOf<List<F7Notification>>(emptyList()) }
|
||||||
|
val authHeader = remember(session.username, session.appPassword) {
|
||||||
|
Credentials.basic(session.username, session.appPassword)
|
||||||
|
}
|
||||||
|
val closeIconUrl = remember(session.serverUrl) {
|
||||||
|
themeHeaderAsset(session.serverUrl, "close-modal.svg")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadNotifications() {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
repository.load(
|
||||||
|
serverUrl = session.serverUrl,
|
||||||
|
username = session.username,
|
||||||
|
appPassword = session.appPassword,
|
||||||
|
trustAllCerts = session.trustAllCerts,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onSuccess { list ->
|
||||||
|
items = list
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
.onFailure { t ->
|
||||||
|
loading = false
|
||||||
|
if (t is UnauthorizedException) {
|
||||||
|
onDismiss()
|
||||||
|
onUnauthorized()
|
||||||
|
} else {
|
||||||
|
error = t.message ?: "Не удалось загрузить уведомления"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(visible, refreshRequest, session.serverUrl, session.username) {
|
||||||
|
if (!visible && refreshRequest == 0) return@LaunchedEffect
|
||||||
|
loadNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissNotification(notification: F7Notification) {
|
||||||
|
items = items.filter { it.id != notification.id }
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
repository.dismiss(
|
||||||
|
serverUrl = session.serverUrl,
|
||||||
|
username = session.username,
|
||||||
|
appPassword = session.appPassword,
|
||||||
|
notificationId = notification.id,
|
||||||
|
trustAllCerts = session.trustAllCerts,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.onFailure { t ->
|
||||||
|
if (t is UnauthorizedException) {
|
||||||
|
onDismiss()
|
||||||
|
onUnauthorized()
|
||||||
|
} else {
|
||||||
|
loadNotifications()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
F7FloatingPanel(
|
||||||
|
visible = visible,
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
fullHeight = true,
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
loading -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = F7Colors.Primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
!error.isNullOrBlank() -> {
|
||||||
|
Text(
|
||||||
|
error ?: "",
|
||||||
|
color = F7Colors.Error,
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontSize = MaterialTheme.typography.bodyLarge.fontSize * 2,
|
||||||
|
lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 2,
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items.isEmpty() -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = themeHeaderAsset(session.serverUrl, "nof-not-icon.svg"),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(120.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Нет уведомлений",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontSize = MaterialTheme.typography.titleMedium.fontSize * 2,
|
||||||
|
lineHeight = MaterialTheme.typography.titleMedium.lineHeight * 2,
|
||||||
|
),
|
||||||
|
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
items.forEachIndexed { index, item ->
|
||||||
|
F7NotificationRow(
|
||||||
|
subject = item.subject,
|
||||||
|
message = item.message,
|
||||||
|
relativeTime = formatNotificationRelativeTime(item.datetime),
|
||||||
|
iconUrl = resolveNotificationIcon(session.serverUrl, item.icon),
|
||||||
|
closeIconUrl = closeIconUrl,
|
||||||
|
authHeader = authHeader,
|
||||||
|
onClick = {
|
||||||
|
onDismiss()
|
||||||
|
onNotificationClick(item)
|
||||||
|
},
|
||||||
|
onDismiss = { dismissNotification(item) },
|
||||||
|
showDivider = index < items.lastIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M6.62,10.79c1.44,2.83 3.76,5.14 6.59,6.59l2.2,-2.2c0.27,-0.27 0.67,-0.36 1.02,-0.24 1.12,0.37 2.33,0.57 3.57,0.57 0.55,0 1,0.45 1,1V20c0,0.55 -0.45,1 -1,1 -9.39,0 -17,-7.61 -17,-17 0,-0.55 0.45,-1 1,-1h3.5c0.55,0 1,0.45 1,1 0,1.25 0.2,2.45 0.57,3.57 0.11,0.35 0.03,0.74 -0.25,1.02l-2.2,2.2z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M12,9c-1.6,0 -3.15,0.25 -4.6,0.72v3.1c0,0.55 -0.45,1 -1,1H4.01c-0.55,0 -1,0.45 -1,1v4c0,0.55 0.45,1 1,1h4c0.55,0 1,-0.45 1,-1v-3.1c1.45,0.47 3,0.72 4.6,0.72s3.15,-0.25 4.6,-0.72v3.1c0,0.55 0.45,1 1,1h4c0.55,0 1,-0.45 1,-1v-4c0,-0.55 -0.45,-1 -1,-1h-2.39c-0.55,0 -1,-0.45 -1,-1v-3.1C15.15,9.25 13.6,9 12,9z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 776 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 483 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#FFFFFFFF</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">F7cloud Mobile</string>
|
||||||
|
<string name="shortcut_talk_short">Конференции</string>
|
||||||
|
<string name="shortcut_talk_long">Открыть F7cloud Talk</string>
|
||||||
|
|
||||||
|
<string name="qr_scan_title">Сканирование QR-кода</string>
|
||||||
|
<string name="qr_scan_close">Закрыть</string>
|
||||||
|
<string name="qr_scan_hint_idle">Наведите камеру на QR-код</string>
|
||||||
|
<string name="qr_scan_hint_align">Держите QR-код целиком в рамке</string>
|
||||||
|
<string name="qr_scan_hint_progress">Распознавание… (%1$d из %2$d)</string>
|
||||||
|
<string name="qr_scan_hint_done">Готово</string>
|
||||||
|
<string name="qr_scan_torch_on">Вспышка</string>
|
||||||
|
<string name="qr_scan_torch_off">Выкл.</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<shortcut
|
||||||
|
android:shortcutId="talk"
|
||||||
|
android:enabled="true"
|
||||||
|
android:icon="@android:drawable/stat_notify_chat"
|
||||||
|
android:shortcutShortLabel="@string/shortcut_talk_short"
|
||||||
|
android:shortcutLongLabel="@string/shortcut_talk_long">
|
||||||
|
<intent
|
||||||
|
android:action="android.intent.action.VIEW"
|
||||||
|
android:targetPackage="ru.forbion.f7cloud.mobile"
|
||||||
|
android:targetClass="ru.forbion.f7cloud.mobile.MainActivity"
|
||||||
|
android:data="f7cloud://talk" />
|
||||||
|
</shortcut>
|
||||||
|
</shortcuts>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application' version '8.13.2' apply false
|
||||||
|
id 'com.android.library' version '8.13.2' apply false
|
||||||
|
id 'org.jetbrains.kotlin.android' version '2.3.0' apply false
|
||||||
|
id 'org.jetbrains.kotlin.plugin.compose' version '2.3.0' apply false
|
||||||
|
id 'org.jetbrains.kotlin.kapt' version '2.3.0' apply false
|
||||||
|
id 'org.jetbrains.kotlin.plugin.serialization' version '2.3.0' apply false
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
ext.kotlinVersion = '2.3.0'
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.library'
|
||||||
|
id 'org.jetbrains.kotlin.android'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'ru.forbion.f7cloud.core.auth'
|
||||||
|
compileSdk 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk 26
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '17'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation project(':core:network')
|
||||||
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest />
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package ru.forbion.f7cloud.core.auth
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
class AppLockStore(context: Context) {
|
||||||
|
private val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
fun isEnabled(): Boolean = prefs.getBoolean(KEY_ENABLED, false)
|
||||||
|
|
||||||
|
fun useBiometric(): Boolean = prefs.getBoolean(KEY_BIOMETRIC, false)
|
||||||
|
|
||||||
|
fun shouldOfferSetup(): Boolean =
|
||||||
|
!prefs.getBoolean(KEY_SETUP_OFFERED, false) && !isEnabled()
|
||||||
|
|
||||||
|
fun markSetupOffered() {
|
||||||
|
prefs.edit().putBoolean(KEY_SETUP_OFFERED, true).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markBackgrounded(at: Long = System.currentTimeMillis()) {
|
||||||
|
prefs.edit().putLong(KEY_BACKGROUND_AT, at).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearBackgroundMarker() {
|
||||||
|
prefs.edit().remove(KEY_BACKGROUND_AT).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lock after the app stayed in background (process still alive) for at least [lockDelayMs]. */
|
||||||
|
fun shouldRequireUnlock(
|
||||||
|
now: Long = System.currentTimeMillis(),
|
||||||
|
lockDelayMs: Long = DEFAULT_LOCK_DELAY_MS,
|
||||||
|
): Boolean {
|
||||||
|
if (!isEnabled()) return false
|
||||||
|
val backgroundAt = prefs.getLong(KEY_BACKGROUND_AT, 0L)
|
||||||
|
if (backgroundAt <= 0L) return false
|
||||||
|
return now - backgroundAt >= lockDelayMs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True once per process start (app was killed / fully closed and opened again).
|
||||||
|
* Always requires lock even if background timeout has not elapsed.
|
||||||
|
*/
|
||||||
|
fun consumeColdStart(): Boolean {
|
||||||
|
if (!coldStartPending) return false
|
||||||
|
coldStartPending = false
|
||||||
|
return isEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enable(pin: String, biometric: Boolean) {
|
||||||
|
prefs.edit()
|
||||||
|
.putBoolean(KEY_ENABLED, true)
|
||||||
|
.putString(KEY_PIN_HASH, hashPin(pin))
|
||||||
|
.putBoolean(KEY_BIOMETRIC, biometric)
|
||||||
|
.putBoolean(KEY_BIOMETRIC_ONLY, false)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enableBiometricOnly() {
|
||||||
|
prefs.edit()
|
||||||
|
.putBoolean(KEY_ENABLED, true)
|
||||||
|
.putBoolean(KEY_BIOMETRIC, true)
|
||||||
|
.putBoolean(KEY_BIOMETRIC_ONLY, true)
|
||||||
|
.remove(KEY_PIN_HASH)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isBiometricOnly(): Boolean = prefs.getBoolean(KEY_BIOMETRIC_ONLY, false)
|
||||||
|
|
||||||
|
fun disable() {
|
||||||
|
prefs.edit()
|
||||||
|
.putBoolean(KEY_ENABLED, false)
|
||||||
|
.remove(KEY_PIN_HASH)
|
||||||
|
.putBoolean(KEY_BIOMETRIC, false)
|
||||||
|
.putBoolean(KEY_BIOMETRIC_ONLY, false)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun verifyPin(pin: String): Boolean {
|
||||||
|
val stored = prefs.getString(KEY_PIN_HASH, null) ?: return false
|
||||||
|
return stored == hashPin(pin)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hashPin(pin: String): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
val bytes = digest.digest(pin.toByteArray(Charsets.UTF_8))
|
||||||
|
return bytes.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFS = "f7_app_lock"
|
||||||
|
const val DEFAULT_LOCK_DELAY_MS = 60_000L
|
||||||
|
private const val KEY_ENABLED = "enabled"
|
||||||
|
private const val KEY_BACKGROUND_AT = "background_at"
|
||||||
|
private const val KEY_BIOMETRIC = "biometric"
|
||||||
|
private const val KEY_BIOMETRIC_ONLY = "biometric_only"
|
||||||
|
private const val KEY_PIN_HASH = "pin_hash"
|
||||||
|
private const val KEY_SETUP_OFFERED = "setup_offered"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var coldStartPending = true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package ru.forbion.f7cloud.core.auth
|
||||||
|
|
||||||
|
import java.net.ConnectException
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
import java.security.cert.CertPathValidatorException
|
||||||
|
import javax.net.ssl.SSLException
|
||||||
|
import javax.net.ssl.SSLHandshakeException
|
||||||
|
import javax.net.ssl.SSLPeerUnverifiedException
|
||||||
|
|
||||||
|
fun Throwable.toLoginErrorMessage(): String {
|
||||||
|
val msg = message.orEmpty()
|
||||||
|
if (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)
|
||||||
|
) {
|
||||||
|
return "Ошибка HTTPS: сертификат не совпадает с адресом сервера. " +
|
||||||
|
"Проверьте URL (https://ваш-домен). " +
|
||||||
|
"Для внутреннего dev-сервера без Let's Encrypt включите «Доверять сертификату»."
|
||||||
|
}
|
||||||
|
if (this is UnknownHostException) {
|
||||||
|
return "Сервер не найден. Проверьте адрес (например https://forbion.f7cloud.ru)."
|
||||||
|
}
|
||||||
|
if (this is ConnectException) {
|
||||||
|
return "Не удалось подключиться к серверу. Проверьте интернет и адрес."
|
||||||
|
}
|
||||||
|
if (this is SocketTimeoutException) {
|
||||||
|
return "Сервер не отвечает (таймаут)."
|
||||||
|
}
|
||||||
|
if (msg.contains("401", ignoreCase = true) ||
|
||||||
|
msg.contains("Unauthorised", ignoreCase = true) ||
|
||||||
|
msg.contains("997", ignoreCase = true)
|
||||||
|
) {
|
||||||
|
return "Неверный логин или пароль. " +
|
||||||
|
"Проверьте учётные данные. При включённой 2FA нужен пароль приложения из настроек безопасности."
|
||||||
|
}
|
||||||
|
if (msg.startsWith("Auth failed:")) {
|
||||||
|
return msg.removePrefix("Auth failed: ").ifBlank { "Ошибка авторизации" }
|
||||||
|
}
|
||||||
|
return msg.ifBlank { "Ошибка входа" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun normalizeServerUrl(raw: String): String {
|
||||||
|
val trimmed = raw.trim().trimEnd('/')
|
||||||
|
if (trimmed.isEmpty()) return trimmed
|
||||||
|
return when {
|
||||||
|
trimmed.startsWith("http://", ignoreCase = true) -> trimmed
|
||||||
|
trimmed.startsWith("https://", ignoreCase = true) -> trimmed
|
||||||
|
else -> "https://$trimmed"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package ru.forbion.f7cloud.core.auth
|
||||||
|
|
||||||
|
data class AuthSession(
|
||||||
|
val serverUrl: String,
|
||||||
|
val username: String,
|
||||||
|
/** Пароль учётной записи или пароль приложения (при 2FA). */
|
||||||
|
val appPassword: String,
|
||||||
|
/** ID для WebDAV (`/remote.php/dav/files/{id}/`), из OCS cloud/user. */
|
||||||
|
val davUserId: String? = null,
|
||||||
|
/** Только для тестовых серверов с самоподписанным сертификатом. */
|
||||||
|
val trustAllCerts: Boolean = false,
|
||||||
|
)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package ru.forbion.f7cloud.core.auth
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
class AuthStore(context: Context) {
|
||||||
|
private val prefs = context.getSharedPreferences("f7_auth", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
fun save(session: AuthSession) {
|
||||||
|
prefs.edit()
|
||||||
|
.putString("server_url", session.serverUrl.trimEnd('/'))
|
||||||
|
.putString("username", session.username.trim())
|
||||||
|
.putString("app_password", session.appPassword)
|
||||||
|
.putBoolean("trust_all_certs", session.trustAllCerts)
|
||||||
|
.putString("dav_user_id", session.davUserId)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load(): AuthSession? {
|
||||||
|
val serverUrl = prefs.getString("server_url", null) ?: return null
|
||||||
|
val username = prefs.getString("username", null) ?: return null
|
||||||
|
val appPassword = prefs.getString("app_password", null) ?: return null
|
||||||
|
return AuthSession(
|
||||||
|
serverUrl = serverUrl,
|
||||||
|
username = username,
|
||||||
|
appPassword = appPassword,
|
||||||
|
trustAllCerts = prefs.getBoolean("trust_all_certs", false),
|
||||||
|
davUserId = prefs.getString("dav_user_id", null),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
prefs.edit().clear().apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package ru.forbion.f7cloud.core.auth
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.Request
|
||||||
|
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||||
|
import ru.forbion.f7cloud.core.network.applyOcsJson
|
||||||
|
import ru.forbion.f7cloud.core.network.isOcsSuccess
|
||||||
|
import ru.forbion.f7cloud.core.network.ocsData
|
||||||
|
import ru.forbion.f7cloud.core.network.ocsMeta
|
||||||
|
import ru.forbion.f7cloud.core.network.parseJsonObject
|
||||||
|
|
||||||
|
object AuthVerifier {
|
||||||
|
suspend fun verify(session: AuthSession): Result<AuthSession> {
|
||||||
|
return runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val client = NetworkFactory.newAuthedClient(
|
||||||
|
session.username,
|
||||||
|
session.appPassword,
|
||||||
|
session.trustAllCerts,
|
||||||
|
)
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/user?format=json")
|
||||||
|
.applyOcsJson()
|
||||||
|
.build()
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful || response.body == null) {
|
||||||
|
error("Auth failed: HTTP ${response.code}")
|
||||||
|
}
|
||||||
|
val root = parseJsonObject(response.body!!.string(), "профиль пользователя")
|
||||||
|
val meta = root.ocsMeta()
|
||||||
|
if (!isOcsSuccess(meta)) {
|
||||||
|
val message = meta?.optString("message").orEmpty()
|
||||||
|
error("Auth failed: ${message.ifBlank { "HTTP ${response.code}" }}")
|
||||||
|
}
|
||||||
|
val userId = root.ocsData()?.optString("id").orEmpty().trim()
|
||||||
|
session.copy(
|
||||||
|
davUserId = userId.ifBlank { session.username },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.fold(
|
||||||
|
onSuccess = { Result.success(it) },
|
||||||
|
onFailure = { Result.failure(Exception(it.toLoginErrorMessage(), it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package ru.forbion.f7cloud.core.auth
|
||||||
|
|
||||||
|
import okhttp3.Request
|
||||||
|
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||||
|
import ru.forbion.f7cloud.core.network.UnauthorizedException
|
||||||
|
import ru.forbion.f7cloud.core.network.applyOcsJson
|
||||||
|
import ru.forbion.f7cloud.core.network.isOcsSuccess
|
||||||
|
import ru.forbion.f7cloud.core.network.ocsData
|
||||||
|
import ru.forbion.f7cloud.core.network.ocsMeta
|
||||||
|
import ru.forbion.f7cloud.core.network.parseJsonObject
|
||||||
|
|
||||||
|
object OcsUserResolver {
|
||||||
|
fun resolveDavUserId(session: AuthSession): String {
|
||||||
|
session.davUserId?.let { return it }
|
||||||
|
val client = NetworkFactory.newAuthedClient(
|
||||||
|
session.username,
|
||||||
|
session.appPassword,
|
||||||
|
session.trustAllCerts,
|
||||||
|
)
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/user?format=json")
|
||||||
|
.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}")
|
||||||
|
}
|
||||||
|
val root = parseJsonObject(response.body!!.string(), "профиль пользователя")
|
||||||
|
val meta = root.ocsMeta()
|
||||||
|
if (!isOcsSuccess(meta)) {
|
||||||
|
error("User profile OCS error")
|
||||||
|
}
|
||||||
|
val id = root.ocsData()?.optString("id").orEmpty().trim()
|
||||||
|
return id.ifBlank { session.username }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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')
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest />
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.library'
|
||||||
|
id 'org.jetbrains.kotlin.android'
|
||||||
|
id 'com.google.devtools.ksp'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'ru.forbion.f7cloud.core.database'
|
||||||
|
compileSdk 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk 26
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '17'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest />
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package ru.forbion.f7cloud.core.database
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "contacts",
|
||||||
|
primaryKeys = ["accountKey", "uid"],
|
||||||
|
indices = [Index("accountKey")],
|
||||||
|
)
|
||||||
|
data class ContactEntity(
|
||||||
|
val accountKey: String,
|
||||||
|
val uid: String,
|
||||||
|
val displayName: String,
|
||||||
|
val email: String,
|
||||||
|
val phone: String,
|
||||||
|
val bookName: String,
|
||||||
|
val photoBase64: String = "",
|
||||||
|
val photoMimeType: String = "",
|
||||||
|
val organization: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val address: String = "",
|
||||||
|
val website: String = "",
|
||||||
|
val birthday: String = "",
|
||||||
|
val emails: String = "",
|
||||||
|
val phones: String = "",
|
||||||
|
)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package ru.forbion.f7cloud.core.database
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ContactsDao {
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT * FROM contacts
|
||||||
|
WHERE accountKey = :accountKey
|
||||||
|
ORDER BY displayName COLLATE NOCASE
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
fun observeAll(accountKey: String): Flow<List<ContactEntity>>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT * FROM contacts
|
||||||
|
WHERE accountKey = :accountKey
|
||||||
|
ORDER BY displayName COLLATE NOCASE
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
suspend fun getAll(accountKey: String): List<ContactEntity>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertAll(contacts: List<ContactEntity>)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insert(contact: ContactEntity)
|
||||||
|
|
||||||
|
@Query("DELETE FROM contacts WHERE accountKey = :accountKey")
|
||||||
|
suspend fun deleteAll(accountKey: String)
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
suspend fun replaceAll(accountKey: String, contacts: List<ContactEntity>) {
|
||||||
|
deleteAll(accountKey)
|
||||||
|
if (contacts.isNotEmpty()) {
|
||||||
|
insertAll(contacts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package ru.forbion.f7cloud.core.database
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [FileEntity::class, ContactEntity::class],
|
||||||
|
version = 4,
|
||||||
|
exportSchema = false,
|
||||||
|
)
|
||||||
|
abstract class F7Database : RoomDatabase() {
|
||||||
|
abstract fun filesDao(): FilesDao
|
||||||
|
abstract fun contactsDao(): ContactsDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var INSTANCE: F7Database? = null
|
||||||
|
|
||||||
|
private val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
|
accountKey TEXT NOT NULL,
|
||||||
|
uid TEXT NOT NULL,
|
||||||
|
displayName TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
phone TEXT NOT NULL,
|
||||||
|
bookName TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(accountKey, uid)
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS index_contacts_accountKey ON contacts(accountKey)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"ALTER TABLE contacts ADD COLUMN photoBase64 TEXT NOT NULL DEFAULT ''",
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"ALTER TABLE contacts ADD COLUMN photoMimeType TEXT NOT NULL DEFAULT ''",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE contacts ADD COLUMN organization TEXT NOT NULL DEFAULT ''")
|
||||||
|
db.execSQL("ALTER TABLE contacts ADD COLUMN title TEXT NOT NULL DEFAULT ''")
|
||||||
|
db.execSQL("ALTER TABLE contacts ADD COLUMN address TEXT NOT NULL DEFAULT ''")
|
||||||
|
db.execSQL("ALTER TABLE contacts ADD COLUMN website TEXT NOT NULL DEFAULT ''")
|
||||||
|
db.execSQL("ALTER TABLE contacts ADD COLUMN birthday TEXT NOT NULL DEFAULT ''")
|
||||||
|
db.execSQL("ALTER TABLE contacts ADD COLUMN emails TEXT NOT NULL DEFAULT ''")
|
||||||
|
db.execSQL("ALTER TABLE contacts ADD COLUMN phones TEXT NOT NULL DEFAULT ''")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun get(context: Context): F7Database {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
INSTANCE ?: Room.databaseBuilder(
|
||||||
|
context.applicationContext,
|
||||||
|
F7Database::class.java,
|
||||||
|
"f7cloud-mobile.db",
|
||||||
|
)
|
||||||
|
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
||||||
|
.build()
|
||||||
|
.also { INSTANCE = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package ru.forbion.f7cloud.core.database
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "files")
|
||||||
|
data class FileEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
val id: Long = 0,
|
||||||
|
val serverUrl: String,
|
||||||
|
val username: String,
|
||||||
|
val name: String,
|
||||||
|
val isDirectory: Boolean,
|
||||||
|
)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package ru.forbion.f7cloud.core.database
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface FilesDao {
|
||||||
|
@Query("SELECT * FROM files WHERE serverUrl = :serverUrl AND username = :username ORDER BY isDirectory DESC, name ASC")
|
||||||
|
suspend fun list(serverUrl: String, username: String): List<FileEntity>
|
||||||
|
|
||||||
|
@Query("DELETE FROM files WHERE serverUrl = :serverUrl AND username = :username")
|
||||||
|
suspend fun clear(serverUrl: String, username: String)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertAll(items: List<FileEntity>)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.library'
|
||||||
|
id 'org.jetbrains.kotlin.android'
|
||||||
|
id 'org.jetbrains.kotlin.plugin.compose'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'ru.forbion.f7cloud.core.designsystem'
|
||||||
|
compileSdk 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk 26
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose true
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '17'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
|
||||||
|
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'
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest />
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package ru.forbion.f7cloud.core.designsystem
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updated from [F7MobileApp] via ProcessLifecycleOwner.
|
||||||
|
* Background polling loops should check this before hitting the network.
|
||||||
|
*/
|
||||||
|
object AppForegroundTracker {
|
||||||
|
@Volatile
|
||||||
|
var isForeground: Boolean = true
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun setForeground(foreground: Boolean) {
|
||||||
|
isForeground = foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package ru.forbion.f7cloud.core.designsystem
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
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.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
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.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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.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.SolidColor
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
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.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
|
||||||
|
private val BottomBarReserve: Dp = 90.dp
|
||||||
|
private val MenuIconSize = 62.dp
|
||||||
|
private val MenuGridGap = 20.dp
|
||||||
|
|
||||||
|
data class F7AppMenuItem(
|
||||||
|
val label: String,
|
||||||
|
val iconUrl: String,
|
||||||
|
val selected: Boolean,
|
||||||
|
val externalUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun F7AppMenuSheet(
|
||||||
|
visible: Boolean,
|
||||||
|
serverUrl: String,
|
||||||
|
items: List<F7AppMenuItem>,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onItemClick: (Int) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var searchQuery by remember(visible) { mutableStateOf("") }
|
||||||
|
val filteredItems = remember(items, searchQuery) {
|
||||||
|
val query = searchQuery.trim()
|
||||||
|
if (query.isBlank()) {
|
||||||
|
items
|
||||||
|
} else {
|
||||||
|
items.filter { it.label.contains(query, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val base = serverUrl.trimEnd('/')
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = fadeIn(tween(250)) + slideInVertically(
|
||||||
|
animationSpec = tween(350),
|
||||||
|
initialOffsetY = { it },
|
||||||
|
),
|
||||||
|
exit = fadeOut(tween(200)) + slideOutVertically(
|
||||||
|
animationSpec = tween(300),
|
||||||
|
targetOffsetY = { it },
|
||||||
|
),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(bottom = BottomBarReserve)
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.background(F7Colors.Background)
|
||||||
|
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp),
|
||||||
|
) {
|
||||||
|
F7AppMenuSearchField(
|
||||||
|
serverUrl = base,
|
||||||
|
value = searchQuery,
|
||||||
|
onValueChange = { searchQuery = it },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 24.dp),
|
||||||
|
)
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(4),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(MenuGridGap),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(MenuGridGap),
|
||||||
|
contentPadding = PaddingValues(horizontal = 2.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
itemsIndexed(
|
||||||
|
items = filteredItems,
|
||||||
|
key = { index, item -> "${item.label}-$index" },
|
||||||
|
) { index, item ->
|
||||||
|
val originalIndex = items.indexOf(item)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.TopCenter,
|
||||||
|
) {
|
||||||
|
F7AppMenuGridItem(
|
||||||
|
item = item,
|
||||||
|
onClick = {
|
||||||
|
if (originalIndex >= 0) {
|
||||||
|
onItemClick(originalIndex)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun F7AppMenuSearchField(
|
||||||
|
serverUrl: String,
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.height(40.dp)
|
||||||
|
.clip(RoundedCornerShape(20.dp))
|
||||||
|
.background(Color.White)
|
||||||
|
.border(1.dp, Color(0xFFE6E6E6), RoundedCornerShape(20.dp)),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = "$serverUrl/themes/forbion/images/header/search-glass.svg",
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 10.dp)
|
||||||
|
.size(18.dp),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
BasicTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
|
),
|
||||||
|
cursorBrush = SolidColor(F7Colors.Primary),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 40.dp, end = 14.dp),
|
||||||
|
decorationBox = { innerTextField ->
|
||||||
|
Box(contentAlignment = Alignment.CenterStart) {
|
||||||
|
if (value.isBlank()) {
|
||||||
|
Text(
|
||||||
|
text = "Поиск...",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color(0xFF808080),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerTextField()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun F7AppMenuGridItem(
|
||||||
|
item: F7AppMenuItem,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(MenuIconSize)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = item.iconUrl,
|
||||||
|
contentDescription = item.label,
|
||||||
|
modifier = Modifier.size(MenuIconSize),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = item.label,
|
||||||
|
style = MaterialTheme.typography.labelLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 14.sp,
|
||||||
|
color = Color(0xFF151515),
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package ru.forbion.f7cloud.core.designsystem
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
|
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.unit.dp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
private val BottomBarFloatOffset = 6.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom bar visibility — mirrors forbion [mobileBottomBarAutoHide] (4s idle hide).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun F7AutoHideBottomBar(
|
||||||
|
enabled: Boolean,
|
||||||
|
pinned: Boolean,
|
||||||
|
activityNonce: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
hideDelayMs: Long = 4000L,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
var visible by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
LaunchedEffect(enabled, pinned, activityNonce) {
|
||||||
|
if (pinned) {
|
||||||
|
visible = true
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
visible = true
|
||||||
|
delay(hideDelayMs)
|
||||||
|
if (!pinned) {
|
||||||
|
visible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(bottom = BottomBarFloatOffset),
|
||||||
|
contentAlignment = Alignment.BottomCenter,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = fadeIn() + slideInVertically(initialOffsetY = { it / 2 }),
|
||||||
|
exit = fadeOut() + slideOutVertically(targetOffsetY = { it / 2 }),
|
||||||
|
modifier = Modifier.wrapContentWidth(),
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.BottomCenter) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package ru.forbion.f7cloud.core.designsystem
|
||||||
|
|
||||||
|
enum class F7BottomBarSlot {
|
||||||
|
Chats,
|
||||||
|
NavBack,
|
||||||
|
Create,
|
||||||
|
Profile,
|
||||||
|
Notifications,
|
||||||
|
Settings,
|
||||||
|
Menu,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class F7BottomBarConfig(
|
||||||
|
val slots: List<F7BottomBarSlot>,
|
||||||
|
) {
|
||||||
|
val buttonCount: Int get() = slots.size
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
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(
|
||||||
|
listOf(
|
||||||
|
F7BottomBarSlot.NavBack,
|
||||||
|
F7BottomBarSlot.Profile,
|
||||||
|
F7BottomBarSlot.Notifications,
|
||||||
|
F7BottomBarSlot.Create,
|
||||||
|
F7BottomBarSlot.Settings,
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package ru.forbion.f7cloud.core.designsystem
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Palette from themes/forbion (mobile + f7support), light theme.
|
||||||
|
*/
|
||||||
|
object F7Colors {
|
||||||
|
val Primary = Color(0xFF70B62B)
|
||||||
|
val PrimaryHover = Color(0xFF6FAF2E)
|
||||||
|
val PrimaryDark = Color(0xFF5E922B)
|
||||||
|
val PrimaryLight = Color(0xFFECF9DE)
|
||||||
|
val PrimaryGradientStart = Color(0xFFC0FF7B)
|
||||||
|
val PrimaryGradientEnd = Color(0xFF7CBC3D)
|
||||||
|
|
||||||
|
val Background = Color(0xFFFBFBFB)
|
||||||
|
val Surface = Color(0xFFFFFFFF)
|
||||||
|
val SurfaceMuted = Color(0xFFF5F5F5)
|
||||||
|
|
||||||
|
val TextPrimary = Color(0xFF151515)
|
||||||
|
val TextSecondary = Color(0xFF808080)
|
||||||
|
val TextMuted = Color(0xFF8C8C8C)
|
||||||
|
val TextOnPrimary = Color(0xFFFFFFFF)
|
||||||
|
|
||||||
|
val Border = Color(0xFFE6E6E6)
|
||||||
|
val BorderLight = Color(0xFFE0E0E0)
|
||||||
|
val SecondaryButtonBg = Color(0xFFFDFDFD)
|
||||||
|
val SecondaryButtonBorder = Color(0xFFE6E6E6)
|
||||||
|
|
||||||
|
val Error = Color(0xFFD74642)
|
||||||
|
val ErrorBg = Color(0xFFFFE2E2)
|
||||||
|
|
||||||
|
val StatusNew = Color(0xFF2B9AB6)
|
||||||
|
val StatusProgress = Color(0xFF70B62B)
|
||||||
|
val StatusClosed = Color(0xFF808080)
|
||||||
|
|
||||||
|
val ChatBubbleIn = Color(0xFFFDFDFD)
|
||||||
|
val ChatBubbleOut = Color(0xFFE0F8C9)
|
||||||
|
val ChatBubbleSupport = Color(0xFFECF9DE)
|
||||||
|
val ChatText = Color(0xFF3F3F3F)
|
||||||
|
val ChatBackground = Color(0xFFE8EFE0)
|
||||||
|
val ChatDatePill = Color(0xFFF5F5F5)
|
||||||
|
val TalkComposerBorder = Color(0x3370B62B)
|
||||||
|
val TalkTopBarBorder = Color(0x3370B62B)
|
||||||
|
}
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
package ru.forbion.f7cloud.core.designsystem
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кнопки forbion (mobile):
|
||||||
|
* - [F7PrimaryButton] — градиент, CTA («Создать», «Войти», «Отправить»)
|
||||||
|
* - [F7SolidPrimaryButton] — сплошной зелёный, диалоги NC
|
||||||
|
* - [F7SecondaryButton] — outline, «Обновить», «Назад», «Отмена»
|
||||||
|
* - [F7TextButton] — tertiary, текст без фона
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun F7ScreenBackground(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(F7Colors.Background),
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun F7ModuleScreen(
|
||||||
|
title: String? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
loading: Boolean = false,
|
||||||
|
error: String? = null,
|
||||||
|
onRefresh: (() -> Unit)? = null,
|
||||||
|
headerActions: @Composable RowScope.() -> Unit = {},
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
val showTitle = !title.isNullOrBlank()
|
||||||
|
val showHeader = showTitle || onRefresh != null
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
if (showHeader) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (showTitle) {
|
||||||
|
Text(
|
||||||
|
text = title.orEmpty(),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(end = 8.dp),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
headerActions()
|
||||||
|
if (onRefresh != null) {
|
||||||
|
F7HeaderActionButton(
|
||||||
|
text = "↻",
|
||||||
|
onClick = onRefresh,
|
||||||
|
enabled = !loading,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (loading) {
|
||||||
|
CircularProgressIndicator(color = F7Colors.Primary)
|
||||||
|
}
|
||||||
|
if (!error.isNullOrBlank()) {
|
||||||
|
Text(text = error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun F7PrimaryButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
) {
|
||||||
|
val shape = RoundedCornerShape(100.dp)
|
||||||
|
val gradient = Brush.linearGradient(
|
||||||
|
colors = listOf(F7Colors.PrimaryGradientStart, F7Colors.PrimaryGradientEnd),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = modifier
|
||||||
|
.heightIn(min = 44.dp)
|
||||||
|
.shadow(
|
||||||
|
elevation = 4.dp,
|
||||||
|
shape = shape,
|
||||||
|
spotColor = F7Colors.Primary.copy(alpha = 0.18f),
|
||||||
|
ambientColor = F7Colors.Primary.copy(alpha = 0.10f),
|
||||||
|
),
|
||||||
|
shape = shape,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
disabledContainerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.border(1.dp, F7Colors.Primary.copy(alpha = 0.22f), shape)
|
||||||
|
.background(brush = gradient, shape = shape)
|
||||||
|
.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
color = F7Colors.TextOnPrimary,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun F7SolidPrimaryButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = modifier.heightIn(min = 40.dp),
|
||||||
|
shape = RoundedCornerShape(100.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = F7Colors.Primary,
|
||||||
|
contentColor = F7Colors.TextOnPrimary,
|
||||||
|
disabledContainerColor = F7Colors.Border,
|
||||||
|
disabledContentColor = F7Colors.TextSecondary,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(text = text, style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun F7HeaderActionButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = modifier
|
||||||
|
.heightIn(min = 36.dp)
|
||||||
|
.widthIn(min = 36.dp, max = 52.dp),
|
||||||
|
shape = RoundedCornerShape(100.dp),
|
||||||
|
border = BorderStroke(1.dp, F7Colors.SecondaryButtonBorder),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
containerColor = F7Colors.SecondaryButtonBg,
|
||||||
|
contentColor = F7Colors.TextPrimary,
|
||||||
|
),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun F7SecondaryButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = modifier.heightIn(min = 40.dp),
|
||||||
|
shape = RoundedCornerShape(100.dp),
|
||||||
|
border = BorderStroke(1.dp, F7Colors.SecondaryButtonBorder),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
containerColor = F7Colors.SecondaryButtonBg,
|
||||||
|
contentColor = F7Colors.TextPrimary,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(text = text, style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun F7TextButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = modifier,
|
||||||
|
colors = ButtonDefaults.textButtonColors(
|
||||||
|
contentColor = F7Colors.TextPrimary,
|
||||||
|
disabledContentColor = F7Colors.TextSecondary,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(text = text, style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun F7AlertDialog(
|
||||||
|
title: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
confirmText: String,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
dismissText: String = "Отмена",
|
||||||
|
confirmEnabled: Boolean = true,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier.widthIn(max = 400.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = F7Colors.Surface,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text(text = title, style = MaterialTheme.typography.titleMedium, color = F7Colors.TextPrimary)
|
||||||
|
content()
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||||
|
) {
|
||||||
|
F7SecondaryButton(text = dismissText, onClick = onDismiss)
|
||||||
|
F7SolidPrimaryButton(
|
||||||
|
text = confirmText,
|
||||||
|
onClick = onConfirm,
|
||||||
|
enabled = confirmEnabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun F7MessageComposer(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
onSend: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
sending: Boolean = false,
|
||||||
|
label: String = "Сообщение",
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.Bottom,
|
||||||
|
) {
|
||||||
|
F7OutlinedField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
label = label,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
F7PrimaryButton(
|
||||||
|
text = if (sending) "…" else "Отправить",
|
||||||
|
onClick = onSend,
|
||||||
|
enabled = value.isNotBlank() && !sending,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val LINE_BREAK_CHARS = Regex("[\\r\\n\\u000B\\u000C\\u2028\\u2029\\u0085]")
|
||||||
|
|
||||||
|
private fun stripLineBreaks(text: String): String = text.replace(LINE_BREAK_CHARS, "")
|
||||||
|
|
||||||
|
private fun Modifier.consumeEnterKey(onEnter: () -> Unit): Modifier = this
|
||||||
|
.onPreviewKeyEvent { event ->
|
||||||
|
if (event.type == KeyEventType.KeyDown && event.isEnterKey()) {
|
||||||
|
onEnter()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onKeyEvent { event ->
|
||||||
|
if (event.type == KeyEventType.KeyDown && event.isEnterKey()) {
|
||||||
|
onEnter()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun androidx.compose.ui.input.key.KeyEvent.isEnterKey(): Boolean =
|
||||||
|
key == Key.Enter || key == Key.NumPadEnter
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun F7OutlinedField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
label: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
minLines: Int = 1,
|
||||||
|
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||||
|
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||||
|
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||||
|
onEnter: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val singleLine = minLines <= 1
|
||||||
|
val mergedKeyboardOptions = if (singleLine) {
|
||||||
|
keyboardOptions.copy(
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
keyboardOptions
|
||||||
|
}
|
||||||
|
OutlinedTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
if (!singleLine) {
|
||||||
|
onValueChange(newValue)
|
||||||
|
return@OutlinedTextField
|
||||||
|
}
|
||||||
|
val hadLineBreak = LINE_BREAK_CHARS.containsMatchIn(newValue)
|
||||||
|
val stripped = stripLineBreaks(newValue)
|
||||||
|
if (stripped != value) {
|
||||||
|
onValueChange(stripped)
|
||||||
|
} else if (stripped != newValue) {
|
||||||
|
onValueChange(stripped)
|
||||||
|
}
|
||||||
|
if (hadLineBreak) {
|
||||||
|
onEnter?.invoke()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(if (onEnter != null && singleLine) Modifier.consumeEnterKey(onEnter) else Modifier),
|
||||||
|
label = { Text(label) },
|
||||||
|
minLines = if (singleLine) 1 else minLines,
|
||||||
|
visualTransformation = visualTransformation,
|
||||||
|
keyboardOptions = mergedKeyboardOptions,
|
||||||
|
keyboardActions = keyboardActions,
|
||||||
|
singleLine = singleLine,
|
||||||
|
maxLines = if (singleLine) 1 else minLines,
|
||||||
|
shape = RoundedCornerShape(100.dp),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = F7Colors.Primary,
|
||||||
|
unfocusedBorderColor = F7Colors.Border,
|
||||||
|
focusedContainerColor = Color(0xFFFDFDFD),
|
||||||
|
unfocusedContainerColor = Color(0xFFFDFDFD),
|
||||||
|
cursorColor = F7Colors.Primary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun F7ListCard(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
selected: Boolean = false,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
val shape = RoundedCornerShape(8.dp)
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(shape)
|
||||||
|
.background(F7Colors.Surface)
|
||||||
|
.border(
|
||||||
|
width = if (selected) 2.dp else 1.dp,
|
||||||
|
color = if (selected) F7Colors.PrimaryGradientEnd else F7Colors.Border,
|
||||||
|
shape = shape,
|
||||||
|
)
|
||||||
|
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||||
|
.padding(12.dp),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun F7ChatBubble(
|
||||||
|
text: String,
|
||||||
|
outgoing: Boolean,
|
||||||
|
subtitle: String? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val bg = if (outgoing) F7Colors.ChatBubbleOut else F7Colors.ChatBubbleIn
|
||||||
|
val align = if (outgoing) Alignment.CenterEnd else Alignment.CenterStart
|
||||||
|
Box(modifier = modifier.fillMaxWidth(), contentAlignment = align) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.88f)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(bg)
|
||||||
|
.border(1.dp, F7Colors.Border.copy(alpha = 0.5f), RoundedCornerShape(8.dp))
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
if (!subtitle.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = F7Colors.TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(text = text, style = MaterialTheme.typography.bodyMedium, color = F7Colors.ChatText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun F7TicketCard(
|
||||||
|
ticketNumber: String,
|
||||||
|
subject: String,
|
||||||
|
status: String,
|
||||||
|
preview: String,
|
||||||
|
hasUnread: Boolean,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val statusColor = when {
|
||||||
|
status.equals("Новый", ignoreCase = true) -> F7Colors.StatusNew
|
||||||
|
status.equals("В работе", ignoreCase = true) -> F7Colors.StatusProgress
|
||||||
|
else -> F7Colors.StatusClosed
|
||||||
|
}
|
||||||
|
F7ListCard(modifier = modifier, selected = selected, onClick = onClick) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(F7Colors.SurfaceMuted)
|
||||||
|
.padding(8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = subject,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
F7StatusChip(text = status, color = statusColor)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "#$ticketNumber",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = F7Colors.TextSecondary,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
if (preview.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = preview,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = F7Colors.TextSecondary,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (hasUnread) {
|
||||||
|
Text(
|
||||||
|
text = "Новое",
|
||||||
|
color = F7Colors.Primary,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun F7StatusChip(text: String, color: Color) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(100.dp))
|
||||||
|
.background(color)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
color = F7Colors.TextOnPrimary,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun F7AppScaffold(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
bottomBar: @Composable () -> Unit = {},
|
||||||
|
content: @Composable (Modifier) -> Unit,
|
||||||
|
) {
|
||||||
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
|
F7ScreenBackground(modifier = Modifier.fillMaxSize()) {
|
||||||
|
content(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.f7SafeTopInsets(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.BottomCenter,
|
||||||
|
) {
|
||||||
|
bottomBar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package ru.forbion.f7cloud.core.designsystem
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
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.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
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.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
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.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Popup panel above the bottom bar — matches forbion mobile web
|
||||||
|
* (#header-menu-notifications, #header-menu-user-menu).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun F7FloatingPanel(
|
||||||
|
visible: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
bottomOffset: Dp = 80.dp,
|
||||||
|
fullHeight: Boolean = false,
|
||||||
|
contentPadding: PaddingValues = PaddingValues(16.dp),
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = fadeIn() + slideInVertically(initialOffsetY = { it / 4 }),
|
||||||
|
exit = fadeOut() + slideOutVertically(targetOffsetY = { it / 4 }),
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(bottom = bottomOffset)
|
||||||
|
.background(Color.Black.copy(alpha = 0.18f))
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onDismiss,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.padding(start = 16.dp, end = 12.dp, bottom = bottomOffset)
|
||||||
|
.then(if (fullHeight) Modifier.statusBarsPadding() else Modifier)
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(if (fullHeight) Modifier.fillMaxHeight() else Modifier)
|
||||||
|
.shadow(
|
||||||
|
elevation = 12.dp,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
spotColor = Color.Black.copy(alpha = 0.12f),
|
||||||
|
)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(F7Colors.SecondaryButtonBg)
|
||||||
|
.border(1.dp, F7Colors.Border, RoundedCornerShape(16.dp))
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = {},
|
||||||
|
)
|
||||||
|
.padding(contentPadding),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
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.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
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.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
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 onMenuClick: () -> Unit = {},
|
||||||
|
)
|
||||||
|
|
||||||
|
private val BottomBarButtonSize = 55.dp
|
||||||
|
private val BottomBarIconSize = 24.dp
|
||||||
|
private val BottomBarGap = 8.dp
|
||||||
|
private val BottomBarOuterPaddingH = 6.dp
|
||||||
|
private val BottomBarOuterPaddingV = 6.dp
|
||||||
|
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(
|
||||||
|
serverUrl: String,
|
||||||
|
userId: String,
|
||||||
|
config: F7BottomBarConfig,
|
||||||
|
actions: F7BottomBarActions,
|
||||||
|
menuOpen: Boolean = false,
|
||||||
|
chatsHighlighted: Boolean = false,
|
||||||
|
navBackHighlighted: Boolean = false,
|
||||||
|
showNotificationBadge: Boolean = false,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val base = serverUrl.trimEnd('/')
|
||||||
|
val pillShape = RoundedCornerShape(percent = 50)
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.wrapContentWidth()
|
||||||
|
.shadow(
|
||||||
|
elevation = 2.dp,
|
||||||
|
shape = pillShape,
|
||||||
|
spotColor = Color(0xFFE6E6E6),
|
||||||
|
)
|
||||||
|
.clip(pillShape)
|
||||||
|
.background(Color(0xFFF5F5F5))
|
||||||
|
.padding(
|
||||||
|
horizontal = BottomBarOuterPaddingH,
|
||||||
|
vertical = BottomBarOuterPaddingV,
|
||||||
|
),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(BottomBarGap),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
config.slots.forEach { slot ->
|
||||||
|
when (slot) {
|
||||||
|
F7BottomBarSlot.Chats -> F7BottomBarIconSlot(
|
||||||
|
iconUrl = if (chatsHighlighted) {
|
||||||
|
"$base/themes/forbion/images/header/chat-icon-green.svg"
|
||||||
|
} else {
|
||||||
|
"$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.Profile -> F7BottomBarIconSlot(
|
||||||
|
iconUrl = "$base/themes/forbion/images/header/profile-menu-icon-big.svg",
|
||||||
|
contentDescription = "Профиль",
|
||||||
|
onClick = actions.onProfileClick,
|
||||||
|
)
|
||||||
|
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.Menu -> F7BottomBarIconSlot(
|
||||||
|
iconUrl = if (menuOpen) {
|
||||||
|
"$base/themes/forbion/images/header/menu-burger-green.svg"
|
||||||
|
} else {
|
||||||
|
"$base/themes/forbion/images/header/menu-burger-gray.svg"
|
||||||
|
},
|
||||||
|
contentDescription = "Меню",
|
||||||
|
highlighted = menuOpen,
|
||||||
|
onClick = actions.onMenuClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun F7BottomBarIconSlot(
|
||||||
|
iconUrl: String,
|
||||||
|
contentDescription: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
highlighted: Boolean = false,
|
||||||
|
iconRotation: Float = 0f,
|
||||||
|
showBadge: Boolean = false,
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.border(
|
||||||
|
width = 1.dp,
|
||||||
|
brush = BottomBarBorderBrush,
|
||||||
|
shape = BottomBarButtonShape,
|
||||||
|
)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onClick,
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = iconUrl,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(BottomBarIconSize)
|
||||||
|
.graphicsLayer { rotationZ = iconRotation },
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
if (showBadge) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(top = 10.dp, end = 10.dp)
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(BottomBarButtonShape)
|
||||||
|
.background(Color(0xFFE53935)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package ru.forbion.f7cloud.core.designsystem
|
||||||
|
|
||||||
|
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.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
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 coil.request.ImageRequest
|
||||||
|
import okhttp3.Credentials
|
||||||
|
|
||||||
|
private fun TextStyle.doubled(): TextStyle = copy(
|
||||||
|
fontSize = fontSize * 2,
|
||||||
|
lineHeight = lineHeight * 2,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun F7NotificationRow(
|
||||||
|
subject: String,
|
||||||
|
message: String,
|
||||||
|
relativeTime: String,
|
||||||
|
iconUrl: String?,
|
||||||
|
closeIconUrl: String,
|
||||||
|
authHeader: String?,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
showDivider: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (relativeTime.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = relativeTime,
|
||||||
|
style = MaterialTheme.typography.labelSmall.doubled(),
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = F7Colors.TextSecondary.copy(alpha = 0.55f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 8.dp)
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.clickable(onClick = onDismiss),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = closeIconUrl,
|
||||||
|
contentDescription = "Закрыть",
|
||||||
|
modifier = Modifier.size(28.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(64.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(F7Colors.SurfaceMuted),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
val model = if (!iconUrl.isNullOrBlank()) {
|
||||||
|
ImageRequest.Builder(context)
|
||||||
|
.data(iconUrl)
|
||||||
|
.apply {
|
||||||
|
authHeader?.let { addHeader("Authorization", it) }
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (model != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = model,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = subject,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.doubled(),
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (message.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodySmall.doubled(),
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = F7Colors.TextSecondary.copy(alpha = 0.7f),
|
||||||
|
maxLines = 6,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 78.dp, top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showDivider) {
|
||||||
|
HorizontalDivider(color = F7Colors.Border, thickness = 1.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package ru.forbion.f7cloud.core.designsystem
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
|
||||||
|
interface F7OverlayNavigationScope {
|
||||||
|
fun registerDismissHandler(handler: () -> Boolean): () -> Unit
|
||||||
|
|
||||||
|
fun dismissTopOverlay(): Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
private class F7OverlayNavigationScopeImpl : F7OverlayNavigationScope {
|
||||||
|
private val handlers = mutableStateListOf<() -> Boolean>()
|
||||||
|
|
||||||
|
override fun registerDismissHandler(handler: () -> Boolean): () -> Unit {
|
||||||
|
handlers.add(handler)
|
||||||
|
return { handlers.remove(handler) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dismissTopOverlay(): Boolean {
|
||||||
|
for (index in handlers.indices.reversed()) {
|
||||||
|
if (handlers[index]()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalF7OverlayNavigation = compositionLocalOf<F7OverlayNavigationScope?> { null }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun F7OverlayNavigationProvider(
|
||||||
|
onSwipeDismiss: () -> Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val scope = remember { F7OverlayNavigationScopeImpl() }
|
||||||
|
CompositionLocalProvider(LocalF7OverlayNavigation provides scope) {
|
||||||
|
androidx.compose.foundation.layout.Box(
|
||||||
|
modifier = modifier.f7SwipeFromRightToDismiss {
|
||||||
|
if (scope.dismissTopOverlay()) return@f7SwipeFromRightToDismiss
|
||||||
|
onSwipeDismiss()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun F7OverlayDismissHandler(
|
||||||
|
enabled: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val scope = LocalF7OverlayNavigation.current ?: return
|
||||||
|
DisposableEffect(enabled, scope, onDismiss) {
|
||||||
|
if (!enabled) {
|
||||||
|
return@DisposableEffect onDispose {}
|
||||||
|
}
|
||||||
|
val unregister = scope.registerDismissHandler {
|
||||||
|
onDismiss()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
onDispose(unregister)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Modifier.f7SwipeFromRightToDismiss(
|
||||||
|
enabled: Boolean = true,
|
||||||
|
edgeFraction: Float = 0.24f,
|
||||||
|
dismissDistanceFraction: Float = 0.14f,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
): Modifier {
|
||||||
|
if (!enabled) return this
|
||||||
|
return pointerInput(Unit) {
|
||||||
|
val edgeStartPx = size.width * (1f - edgeFraction)
|
||||||
|
val dismissDistancePx = size.width * dismissDistanceFraction
|
||||||
|
awaitEachGesture {
|
||||||
|
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||||
|
if (down.position.x < edgeStartPx) return@awaitEachGesture
|
||||||
|
|
||||||
|
var dragLeft = 0f
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent(pass = PointerEventPass.Initial)
|
||||||
|
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||||
|
if (!change.pressed) break
|
||||||
|
val delta = change.position.x - change.previousPosition.x
|
||||||
|
if (delta < 0f) {
|
||||||
|
dragLeft += -delta
|
||||||
|
}
|
||||||
|
if (dragLeft >= dismissDistancePx) {
|
||||||
|
onDismiss()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package ru.forbion.f7cloud.core.designsystem
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
fun formatNotificationRelativeTime(isoDatetime: String): String {
|
||||||
|
if (isoDatetime.isBlank()) return ""
|
||||||
|
val instant = runCatching { Instant.parse(isoDatetime) }.getOrNull() ?: return ""
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
val date = instant.atZone(zone).toLocalDate()
|
||||||
|
val today = LocalDate.now(zone)
|
||||||
|
val days = ChronoUnit.DAYS.between(date, today)
|
||||||
|
return when {
|
||||||
|
days == 0L -> "сегодня"
|
||||||
|
days == 1L -> "вчера"
|
||||||
|
days == 2L -> "позавчера"
|
||||||
|
days in 3..6 -> "$days ${daysLabel(days)} назад"
|
||||||
|
else -> DateTimeFormatter.ofPattern("d MMM", Locale("ru")).format(date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun daysLabel(days: Long): String {
|
||||||
|
val mod10 = (days % 10).toInt()
|
||||||
|
val mod100 = (days % 100).toInt()
|
||||||
|
return when {
|
||||||
|
mod10 == 1 && mod100 != 11 -> "день"
|
||||||
|
mod10 in 2..4 && mod100 !in 12..14 -> "дня"
|
||||||
|
else -> "дней"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package ru.forbion.f7cloud.core.designsystem
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
private val F7LightScheme = lightColorScheme(
|
||||||
|
primary = F7Colors.Primary,
|
||||||
|
onPrimary = F7Colors.TextOnPrimary,
|
||||||
|
primaryContainer = F7Colors.PrimaryLight,
|
||||||
|
onPrimaryContainer = F7Colors.TextPrimary,
|
||||||
|
secondary = F7Colors.PrimaryDark,
|
||||||
|
onSecondary = F7Colors.TextOnPrimary,
|
||||||
|
background = F7Colors.Background,
|
||||||
|
onBackground = F7Colors.TextPrimary,
|
||||||
|
surface = F7Colors.Surface,
|
||||||
|
onSurface = F7Colors.TextPrimary,
|
||||||
|
surfaceVariant = F7Colors.SurfaceMuted,
|
||||||
|
onSurfaceVariant = F7Colors.TextSecondary,
|
||||||
|
outline = F7Colors.Border,
|
||||||
|
error = F7Colors.Error,
|
||||||
|
onError = Color.White,
|
||||||
|
errorContainer = F7Colors.ErrorBg,
|
||||||
|
onErrorContainer = F7Colors.TextPrimary,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun F7Theme(
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = F7LightScheme,
|
||||||
|
typography = F7Typography,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package ru.forbion.f7cloud.core.designsystem
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.R
|
||||||
|
|
||||||
|
val RalewayFamily = FontFamily(
|
||||||
|
Font(R.font.raleway_medium, FontWeight.Medium),
|
||||||
|
Font(R.font.raleway_semibold, FontWeight.SemiBold),
|
||||||
|
)
|
||||||
|
|
||||||
|
val F7Typography = Typography(
|
||||||
|
displayLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 28.sp),
|
||||||
|
titleLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 28.sp),
|
||||||
|
titleMedium = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 20.sp),
|
||||||
|
titleSmall = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp),
|
||||||
|
bodyLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 20.sp),
|
||||||
|
bodyMedium = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp),
|
||||||
|
bodySmall = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp),
|
||||||
|
labelLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp),
|
||||||
|
labelMedium = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp),
|
||||||
|
labelSmall = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 14.sp),
|
||||||
|
)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package ru.forbion.f7cloud.core.designsystem
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.only
|
||||||
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
|
||||||
|
/** Top + horizontal safe area (status bar, display cutout on foldables / punch-hole). */
|
||||||
|
@Composable
|
||||||
|
fun Modifier.f7SafeTopInsets(): Modifier = windowInsetsPadding(
|
||||||
|
WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Bottom navigation bar / gesture area when the app bottom bar is hidden. */
|
||||||
|
@Composable
|
||||||
|
fun Modifier.f7SafeBottomInsets(): Modifier = windowInsetsPadding(
|
||||||
|
WindowInsets.navigationBars,
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.library'
|
||||||
|
id 'org.jetbrains.kotlin.android'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'ru.forbion.f7cloud.core.network'
|
||||||
|
compileSdk 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk 26
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '17'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||||
|
implementation 'org.json:json:20240303'
|
||||||
|
api 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest />
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package ru.forbion.f7cloud.core.network
|
||||||
|
|
||||||
|
import okhttp3.Credentials
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
class BasicAuthInterceptor(
|
||||||
|
private val username: String,
|
||||||
|
private val appPassword: String,
|
||||||
|
) : Interceptor {
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val req = chain.request().newBuilder()
|
||||||
|
.header("Authorization", Credentials.basic(username, appPassword))
|
||||||
|
.build()
|
||||||
|
return chain.proceed(req)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
package ru.forbion.f7cloud.core.network
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
|
data class CalendarAttendeeData(
|
||||||
|
val email: String,
|
||||||
|
val displayName: String = "",
|
||||||
|
val partStat: String = "NEEDS-ACTION",
|
||||||
|
val role: String = "REQ-PARTICIPANT",
|
||||||
|
val rsvp: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CalendarAlarmData(
|
||||||
|
val minutesBefore: Int,
|
||||||
|
val action: String = "DISPLAY",
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CalendarEventData(
|
||||||
|
val uid: String,
|
||||||
|
val summary: String,
|
||||||
|
val description: String = "",
|
||||||
|
val location: String = "",
|
||||||
|
val startEpochMilli: Long,
|
||||||
|
val endEpochMilli: Long,
|
||||||
|
val allDay: Boolean = false,
|
||||||
|
val rrule: String = "",
|
||||||
|
val categories: List<String> = emptyList(),
|
||||||
|
val status: String = "CONFIRMED",
|
||||||
|
val classification: String = "PUBLIC",
|
||||||
|
val attendees: List<CalendarAttendeeData> = emptyList(),
|
||||||
|
val organizerEmail: String = "",
|
||||||
|
val organizerName: String = "",
|
||||||
|
val alarms: List<CalendarAlarmData> = emptyList(),
|
||||||
|
val conferenceUri: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
fun parseAll(ics: String): List<CalendarEventData> {
|
||||||
|
val unfolded = unfold(ics)
|
||||||
|
val blocks = veventBlock.matcher(unfolded)
|
||||||
|
val out = mutableListOf<CalendarEventData>()
|
||||||
|
while (blocks.find()) {
|
||||||
|
parseVEventBlock(blocks.group(1) ?: continue)?.let { out += it }
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseSingle(ics: String): CalendarEventData? {
|
||||||
|
return parseAll(ics).firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun build(data: CalendarEventData): String = buildString {
|
||||||
|
appendLine("BEGIN:VCALENDAR")
|
||||||
|
appendLine("VERSION:2.0")
|
||||||
|
appendLine("PRODID:-//F7cloud Mobile//EN")
|
||||||
|
appendLine("CALSCALE:GREGORIAN")
|
||||||
|
append(serializeVEvent(data))
|
||||||
|
appendLine("END:VCALENDAR")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseVEventBlock(block: String): CalendarEventData? {
|
||||||
|
val lines = parseLines(block)
|
||||||
|
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 alarms = parseAlarms(block)
|
||||||
|
val conference = lines.entries
|
||||||
|
.firstOrNull { it.key.startsWith("CONFERENCE") }
|
||||||
|
?.value
|
||||||
|
?.trim()
|
||||||
|
.orEmpty()
|
||||||
|
val location = unescape(lines["LOCATION"].orEmpty())
|
||||||
|
val talkUrl = conference.ifBlank {
|
||||||
|
if (location.contains("/call/")) location else ""
|
||||||
|
}
|
||||||
|
return CalendarEventData(
|
||||||
|
uid = uid,
|
||||||
|
summary = unescape(lines["SUMMARY"].orEmpty()).ifBlank { "(без названия)" },
|
||||||
|
description = unescape(lines["DESCRIPTION"].orEmpty()),
|
||||||
|
location = location,
|
||||||
|
startEpochMilli = start.toEpochMilli(),
|
||||||
|
endEpochMilli = end.toEpochMilli(),
|
||||||
|
allDay = allDay,
|
||||||
|
rrule = lines["RRULE"].orEmpty(),
|
||||||
|
categories = lines["CATEGORIES"]?.split(',')?.map { unescape(it.trim()) }?.filter { it.isNotBlank() }.orEmpty(),
|
||||||
|
status = lines["STATUS"]?.trim().orEmpty().ifBlank { "CONFIRMED" },
|
||||||
|
classification = lines["CLASS"]?.trim().orEmpty().ifBlank { "PUBLIC" },
|
||||||
|
attendees = attendees,
|
||||||
|
organizerEmail = orgEmail,
|
||||||
|
organizerName = orgName,
|
||||||
|
alarms = alarms,
|
||||||
|
conferenceUri = talkUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serializeVEvent(data: CalendarEventData): String = buildString {
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
val tzId = zone.id.replace(":", "\\:")
|
||||||
|
val now = Instant.now()
|
||||||
|
appendLine("BEGIN:VEVENT")
|
||||||
|
appendLine("UID:${data.uid}")
|
||||||
|
appendLine("DTSTAMP:${formatUtc(now)}")
|
||||||
|
val startZ = Instant.ofEpochMilli(data.startEpochMilli).atZone(zone)
|
||||||
|
val endZ = Instant.ofEpochMilli(data.endEpochMilli).atZone(zone)
|
||||||
|
if (data.allDay) {
|
||||||
|
appendLine("DTSTART;VALUE=DATE:${startZ.format(DateTimeFormatter.BASIC_ISO_DATE)}")
|
||||||
|
appendLine("DTEND;VALUE=DATE:${endZ.format(DateTimeFormatter.BASIC_ISO_DATE)}")
|
||||||
|
} else {
|
||||||
|
appendLine("DTSTART;TZID=$tzId:${formatLocal(startZ)}")
|
||||||
|
appendLine("DTEND;TZID=$tzId:${formatLocal(endZ)}")
|
||||||
|
}
|
||||||
|
appendLine("SUMMARY:${escape(data.summary)}")
|
||||||
|
if (data.description.isNotBlank()) appendLine("DESCRIPTION:${escape(data.description)}")
|
||||||
|
if (data.location.isNotBlank()) appendLine("LOCATION:${escape(data.location)}")
|
||||||
|
if (data.rrule.isNotBlank()) appendLine("RRULE:${data.rrule}")
|
||||||
|
if (data.categories.isNotEmpty()) {
|
||||||
|
appendLine("CATEGORIES:${data.categories.joinToString(",") { escape(it) }}")
|
||||||
|
}
|
||||||
|
appendLine("STATUS:${data.status}")
|
||||||
|
appendLine("CLASS:${data.classification}")
|
||||||
|
val orgEmail = data.organizerEmail
|
||||||
|
if (orgEmail.isNotBlank()) {
|
||||||
|
val cn = if (data.organizerName.isNotBlank()) ";CN=${escape(data.organizerName)}" else ""
|
||||||
|
appendLine("ORGANIZER;CUTYPE=INDIVIDUAL$cn:mailto:$orgEmail")
|
||||||
|
}
|
||||||
|
data.attendees.forEach { attendee ->
|
||||||
|
val cn = if (attendee.displayName.isNotBlank()) ";CN=${escape(attendee.displayName)}" else ""
|
||||||
|
val rsvp = if (attendee.rsvp) ";RSVP=TRUE" else ""
|
||||||
|
appendLine(
|
||||||
|
"ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=${attendee.role};PARTSTAT=${attendee.partStat}$rsvp$cn:mailto:${attendee.email}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val talk = data.conferenceUri.ifBlank {
|
||||||
|
if (data.location.contains("/call/")) data.location else ""
|
||||||
|
}
|
||||||
|
if (talk.isNotBlank()) {
|
||||||
|
appendLine("CONFERENCE;FEATURE=PHONE,VIDEO;VALUE=URI:$talk")
|
||||||
|
if (data.location.isBlank()) appendLine("LOCATION:$talk")
|
||||||
|
}
|
||||||
|
data.alarms.forEach { alarm ->
|
||||||
|
appendLine("BEGIN:VALARM")
|
||||||
|
appendLine("ACTION:${alarm.action}")
|
||||||
|
appendLine("TRIGGER:-PT${alarm.minutesBefore}M")
|
||||||
|
appendLine("DESCRIPTION:${escape(data.summary)}")
|
||||||
|
appendLine("END:VALARM")
|
||||||
|
}
|
||||||
|
appendLine("END:VEVENT")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun newUid(): String = "${UUID.randomUUID()}@f7cloud.mobile"
|
||||||
|
|
||||||
|
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"))
|
||||||
|
.atZone(ZoneId.systemDefault()).toInstant()
|
||||||
|
}
|
||||||
|
value.length >= 8 -> {
|
||||||
|
LocalDate.parse(value.take(8), DateTimeFormatter.BASIC_ISO_DATE)
|
||||||
|
.atStartOfDay(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseAttendeeLine(key: String, value: String): CalendarAttendeeData? {
|
||||||
|
val email = value.substringAfter("mailto:", 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
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseAlarms(block: String): List<CalendarAlarmData> {
|
||||||
|
val alarmPattern = Pattern.compile("BEGIN:VALARM([\\s\\S]*?)END:VALARM", Pattern.CASE_INSENSITIVE)
|
||||||
|
val matcher = alarmPattern.matcher(block)
|
||||||
|
val out = mutableListOf<CalendarAlarmData>()
|
||||||
|
while (matcher.find()) {
|
||||||
|
val lines = parseLines(matcher.group(1).orEmpty())
|
||||||
|
val trigger = lines["TRIGGER"].orEmpty()
|
||||||
|
val minutes = parseTriggerMinutes(trigger)
|
||||||
|
if (minutes != null) {
|
||||||
|
out += CalendarAlarmData(minutesBefore = minutes, action = lines["ACTION"] ?: "DISPLAY")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseTriggerMinutes(trigger: String): Int? {
|
||||||
|
val t = trigger.trim()
|
||||||
|
val relative = Regex("-PT(\\d+)M", RegexOption.IGNORE_CASE).find(t)?.groupValues?.get(1)?.toIntOrNull()
|
||||||
|
if (relative != null) return relative
|
||||||
|
val hours = Regex("-PT(\\d+)H", RegexOption.IGNORE_CASE).find(t)?.groupValues?.get(1)?.toIntOrNull()
|
||||||
|
if (hours != null) return hours * 60
|
||||||
|
val days = Regex("-P(\\d+)D", RegexOption.IGNORE_CASE).find(t)?.groupValues?.get(1)?.toIntOrNull()
|
||||||
|
if (days != null) return days * 24 * 60
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unfold(raw: String): String {
|
||||||
|
val normalized = raw.replace("\r\n", "\n").replace('\r', '\n')
|
||||||
|
val lines = normalized.split('\n')
|
||||||
|
val unfolded = StringBuilder()
|
||||||
|
for (line in lines) {
|
||||||
|
if (line.startsWith(' ') || line.startsWith('\t')) {
|
||||||
|
if (unfolded.isNotEmpty()) unfolded.append(line.drop(1))
|
||||||
|
} else {
|
||||||
|
if (unfolded.isNotEmpty()) unfolded.append('\n')
|
||||||
|
unfolded.append(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unfolded.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
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("\\\\", "\\")
|
||||||
|
|
||||||
|
private fun formatUtc(instant: Instant): String =
|
||||||
|
instant.atZone(ZoneOffset.UTC).format(DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'"))
|
||||||
|
|
||||||
|
private fun formatLocal(zoned: ZonedDateTime): String =
|
||||||
|
zoned.format(DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
package ru.forbion.f7cloud.core.network
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
import org.xmlpull.v1.XmlPullParserFactory
|
||||||
|
import java.net.URLDecoder
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
|
data class DavContact(
|
||||||
|
val uid: String,
|
||||||
|
val displayName: String,
|
||||||
|
val email: String,
|
||||||
|
val phone: String,
|
||||||
|
val bookName: String,
|
||||||
|
val photoBase64: String = "",
|
||||||
|
val photoMimeType: String = "",
|
||||||
|
val organization: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val address: String = "",
|
||||||
|
val website: String = "",
|
||||||
|
val birthday: String = "",
|
||||||
|
val emails: String = "",
|
||||||
|
val phones: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
object CardDavClient {
|
||||||
|
private val fnPattern = Pattern.compile("FN[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||||
|
private val nPattern = Pattern.compile("N[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||||
|
private val emailPattern = Pattern.compile("EMAIL[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||||
|
private val telPattern = Pattern.compile("TEL[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||||
|
private val uidPattern = Pattern.compile("UID[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||||
|
private val orgPattern = Pattern.compile("ORG[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||||
|
private val titlePattern = Pattern.compile("TITLE[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||||
|
private val urlPattern = Pattern.compile("URL[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||||
|
private val adrPattern = Pattern.compile("ADR[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||||
|
private val bdayPattern = Pattern.compile("BDAY[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||||
|
|
||||||
|
private val genericNames = setOf(
|
||||||
|
"contact",
|
||||||
|
"carddav",
|
||||||
|
"card dav",
|
||||||
|
"unknown",
|
||||||
|
"vcard",
|
||||||
|
)
|
||||||
|
|
||||||
|
fun listContacts(
|
||||||
|
client: OkHttpClient,
|
||||||
|
serverUrl: String,
|
||||||
|
userId: String,
|
||||||
|
limitPerBook: Int = 500,
|
||||||
|
): List<DavContact> {
|
||||||
|
val base = davAddressBooksBaseUrl(serverUrl, userId)
|
||||||
|
val books = listAddressBooks(client, base)
|
||||||
|
val out = mutableListOf<DavContact>()
|
||||||
|
var fetchFailed = false
|
||||||
|
for (book in books) {
|
||||||
|
runCatching { fetchContactsFromBook(client, book, limitPerBook) }
|
||||||
|
.onSuccess { out += it }
|
||||||
|
.onFailure { fetchFailed = true }
|
||||||
|
}
|
||||||
|
if (out.isEmpty() && fetchFailed) {
|
||||||
|
error("Не удалось загрузить контакты")
|
||||||
|
}
|
||||||
|
return out.distinctBy { "${it.uid}|${it.email}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createContact(
|
||||||
|
client: OkHttpClient,
|
||||||
|
serverUrl: String,
|
||||||
|
userId: String,
|
||||||
|
displayName: String,
|
||||||
|
email: String,
|
||||||
|
phone: String = "",
|
||||||
|
): DavContact {
|
||||||
|
val base = davAddressBooksBaseUrl(serverUrl, userId)
|
||||||
|
val books = listAddressBooks(client, base)
|
||||||
|
val book = books.firstOrNull()
|
||||||
|
?: error("Не найдена адресная книга")
|
||||||
|
val (bookUrl, bookName) = book
|
||||||
|
val uid = UUID.randomUUID().toString()
|
||||||
|
val vcard = buildVCard(uid, displayName, email, phone)
|
||||||
|
val url = bookUrl.trimEnd('/') + "/$uid.vcf"
|
||||||
|
val req = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.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})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DavContact(
|
||||||
|
uid = uid,
|
||||||
|
displayName = displayName.trim(),
|
||||||
|
email = email.trim(),
|
||||||
|
phone = phone.trim(),
|
||||||
|
bookName = bookName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildVCard(uid: String, displayName: String, email: String, phone: String): String {
|
||||||
|
val fn = displayName.trim()
|
||||||
|
val parts = fn.split(Regex("\\s+"), limit = 2)
|
||||||
|
val given = parts.getOrNull(0).orEmpty()
|
||||||
|
val family = parts.getOrNull(1).orEmpty()
|
||||||
|
return buildString {
|
||||||
|
append("BEGIN:VCARD\n")
|
||||||
|
append("VERSION:3.0\n")
|
||||||
|
append("UID:$uid\n")
|
||||||
|
append("FN:$fn\n")
|
||||||
|
append("N:$family;$given;;;\n")
|
||||||
|
if (email.isNotBlank()) {
|
||||||
|
append("EMAIL;TYPE=INTERNET:$email\n")
|
||||||
|
}
|
||||||
|
if (phone.isNotBlank()) {
|
||||||
|
append("TEL;TYPE=CELL:$phone\n")
|
||||||
|
}
|
||||||
|
append("END:VCARD\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun listAddressBooks(client: OkHttpClient, baseUrl: String): List<Pair<String, String>> {
|
||||||
|
val body = """
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||||
|
<d:prop><d:displayname/><d:resourcetype/></d:prop>
|
||||||
|
</d:propfind>
|
||||||
|
""".trimIndent()
|
||||||
|
val xml = propfind(client, baseUrl, depth = 1, body)
|
||||||
|
val parsed = parseAddressBooks(xml, baseUrl)
|
||||||
|
return parsed.ifEmpty {
|
||||||
|
listOf(resolveHref(baseUrl, "contacts") to "Contacts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchContactsFromBook(
|
||||||
|
client: OkHttpClient,
|
||||||
|
book: Pair<String, String>,
|
||||||
|
limit: Int,
|
||||||
|
): List<DavContact> {
|
||||||
|
val (href, bookName) = book
|
||||||
|
val url = href.trimEnd('/') + "/"
|
||||||
|
val body = """
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||||
|
<d:prop><d:getetag/><card:address-data/></d:prop>
|
||||||
|
</d:propfind>
|
||||||
|
""".trimIndent()
|
||||||
|
val xml = propfind(client, url, depth = 1, body)
|
||||||
|
return parseContacts(xml, bookName).take(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun propfind(client: OkHttpClient, url: String, depth: Int, body: String): String {
|
||||||
|
val req = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.header("Depth", depth.toString())
|
||||||
|
.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")
|
||||||
|
}
|
||||||
|
val xml = response.body?.string().orEmpty()
|
||||||
|
if (xml.isBlank()) {
|
||||||
|
error("CardDAV empty response")
|
||||||
|
}
|
||||||
|
return xml
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseAddressBooks(xml: String, baseUrl: String): List<Pair<String, String>> {
|
||||||
|
val parser = newParser(xml)
|
||||||
|
val out = mutableListOf<Pair<String, String>>()
|
||||||
|
val basePath = baseUrl.toDavPath()
|
||||||
|
var inResponse = false
|
||||||
|
var href = ""
|
||||||
|
var displayName = ""
|
||||||
|
var isCollection = false
|
||||||
|
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||||
|
when (parser.eventType) {
|
||||||
|
XmlPullParser.START_TAG -> when (parser.localTag()) {
|
||||||
|
"response" -> {
|
||||||
|
inResponse = true
|
||||||
|
href = ""
|
||||||
|
displayName = ""
|
||||||
|
isCollection = false
|
||||||
|
}
|
||||||
|
"collection" -> if (inResponse) isCollection = true
|
||||||
|
"displayname" -> if (inResponse) displayName = parser.readText().trim()
|
||||||
|
"href" -> if (inResponse) href = parser.readText().trim()
|
||||||
|
}
|
||||||
|
XmlPullParser.END_TAG -> if (parser.localTag() == "response" && inResponse) {
|
||||||
|
if (isCollection && href.isNotBlank()) {
|
||||||
|
val full = resolveHref(baseUrl, href)
|
||||||
|
val fullPath = full.toDavPath()
|
||||||
|
if (fullPath != basePath &&
|
||||||
|
fullPath.startsWith(basePath) &&
|
||||||
|
!fullPath.contains("/system/")
|
||||||
|
) {
|
||||||
|
val name = displayName.ifBlank {
|
||||||
|
fullPath.removePrefix(basePath).trim('/').substringAfterLast('/')
|
||||||
|
}
|
||||||
|
out += full to name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inResponse = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parser.next()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseContacts(xml: String, bookName: String): List<DavContact> {
|
||||||
|
val parser = newParser(xml)
|
||||||
|
val out = mutableListOf<DavContact>()
|
||||||
|
var inAddressData = false
|
||||||
|
val data = StringBuilder()
|
||||||
|
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||||
|
when (parser.eventType) {
|
||||||
|
XmlPullParser.START_TAG -> if (parser.localTag() == "address-data") {
|
||||||
|
inAddressData = true
|
||||||
|
data.clear()
|
||||||
|
}
|
||||||
|
XmlPullParser.TEXT -> if (inAddressData) data.append(parser.text)
|
||||||
|
XmlPullParser.END_TAG -> if (parser.localTag() == "address-data" && inAddressData) {
|
||||||
|
val vcard = unfoldVCard(data.toString())
|
||||||
|
val uid = extract(uidPattern, vcard).ifBlank {
|
||||||
|
vcard.hashCode().toString()
|
||||||
|
}
|
||||||
|
val name = resolveContactName(vcard)
|
||||||
|
val allEmails = extractAll(emailPattern, vcard)
|
||||||
|
val allPhones = extractAll(telPattern, vcard)
|
||||||
|
val email = allEmails.firstOrNull().orEmpty()
|
||||||
|
val phone = allPhones.firstOrNull().orEmpty()
|
||||||
|
val photo = parseContactPhoto(vcard)
|
||||||
|
if (name.isNotBlank() || email.isNotBlank()) {
|
||||||
|
out += DavContact(
|
||||||
|
uid = uid,
|
||||||
|
displayName = name,
|
||||||
|
email = email,
|
||||||
|
phone = phone,
|
||||||
|
bookName = bookName,
|
||||||
|
photoBase64 = photo?.base64.orEmpty(),
|
||||||
|
photoMimeType = photo?.mimeType.orEmpty(),
|
||||||
|
organization = formatOrganization(extract(orgPattern, vcard)),
|
||||||
|
title = extract(titlePattern, vcard),
|
||||||
|
address = formatAddress(extract(adrPattern, vcard)),
|
||||||
|
website = extract(urlPattern, vcard),
|
||||||
|
birthday = formatBirthday(extract(bdayPattern, vcard)),
|
||||||
|
emails = allEmails.joinToString("\n"),
|
||||||
|
phones = allPhones.joinToString("\n"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
inAddressData = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parser.next()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unfoldVCard(raw: String): String {
|
||||||
|
val normalized = raw.replace("\r\n", "\n").replace('\r', '\n')
|
||||||
|
val lines = normalized.split('\n')
|
||||||
|
val unfolded = StringBuilder()
|
||||||
|
for (line in lines) {
|
||||||
|
if (line.startsWith(' ') || line.startsWith('\t')) {
|
||||||
|
if (unfolded.isNotEmpty()) {
|
||||||
|
unfolded.append(line.drop(1))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (unfolded.isNotEmpty()) unfolded.append('\n')
|
||||||
|
unfolded.append(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unfolded.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveHref(baseUrl: String, href: String): String {
|
||||||
|
if (href.startsWith("http")) return href
|
||||||
|
if (href.startsWith("/")) {
|
||||||
|
val server = baseUrl.substringBefore("/remote.php")
|
||||||
|
return server + href
|
||||||
|
}
|
||||||
|
return baseUrl.trimEnd('/') + "/" + href.trimStart('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extract(pattern: Pattern, text: String): String {
|
||||||
|
val m = pattern.matcher(text)
|
||||||
|
return if (m.find()) m.group(1)?.trim().orEmpty() else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractAll(pattern: Pattern, text: String): List<String> {
|
||||||
|
val m = pattern.matcher(text)
|
||||||
|
val out = mutableListOf<String>()
|
||||||
|
while (m.find()) {
|
||||||
|
m.group(1)?.trim()?.takeIf { it.isNotBlank() }?.let { out += it }
|
||||||
|
}
|
||||||
|
return out.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatOrganization(raw: String): String {
|
||||||
|
if (raw.isBlank()) return ""
|
||||||
|
return raw.split(';').firstOrNull()?.trim().orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatAddress(raw: String): String {
|
||||||
|
if (raw.isBlank()) return ""
|
||||||
|
val parts = raw.split(';')
|
||||||
|
return listOf(
|
||||||
|
parts.getOrNull(2).orEmpty(),
|
||||||
|
parts.getOrNull(3).orEmpty(),
|
||||||
|
parts.getOrNull(4).orEmpty(),
|
||||||
|
parts.getOrNull(5).orEmpty(),
|
||||||
|
parts.getOrNull(6).orEmpty(),
|
||||||
|
)
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.joinToString(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatBirthday(raw: String): String {
|
||||||
|
val value = raw.trim()
|
||||||
|
if (value.isBlank()) return ""
|
||||||
|
if (value.length == 8 && value.all { it.isDigit() }) {
|
||||||
|
return "${value.substring(6, 8)}.${value.substring(4, 6)}.${value.substring(0, 4)}"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveContactName(vcard: String): String {
|
||||||
|
val fn = extract(fnPattern, vcard)
|
||||||
|
if (fn.isNotBlank() && !isGenericName(fn)) return fn
|
||||||
|
val nRaw = extract(nPattern, vcard)
|
||||||
|
if (nRaw.isNotBlank()) {
|
||||||
|
val parts = nRaw.split(';')
|
||||||
|
val family = parts.getOrNull(0).orEmpty().trim()
|
||||||
|
val given = parts.getOrNull(1).orEmpty().trim()
|
||||||
|
val additional = parts.getOrNull(2).orEmpty().trim()
|
||||||
|
val composed = listOf(given, additional, family)
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.joinToString(" ")
|
||||||
|
if (composed.isNotBlank() && !isGenericName(composed)) return composed
|
||||||
|
}
|
||||||
|
val email = extract(emailPattern, vcard)
|
||||||
|
if (email.isNotBlank()) return email.substringBefore('@')
|
||||||
|
return fn.ifBlank { "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isGenericName(name: String): Boolean {
|
||||||
|
val normalized = name.trim().lowercase()
|
||||||
|
if (normalized.isBlank()) return true
|
||||||
|
return genericNames.contains(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ParsedContactPhoto(
|
||||||
|
val base64: String,
|
||||||
|
val mimeType: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun parseContactPhoto(vcard: String): ParsedContactPhoto? {
|
||||||
|
val line = vcard.lineSequence()
|
||||||
|
.firstOrNull { it.startsWith("PHOTO", ignoreCase = true) }
|
||||||
|
?: return null
|
||||||
|
val colon = line.indexOf(':')
|
||||||
|
if (colon < 0) return null
|
||||||
|
val header = line.substring(0, colon)
|
||||||
|
val payload = line.substring(colon + 1).trim()
|
||||||
|
if (payload.isBlank()) return null
|
||||||
|
|
||||||
|
if (payload.startsWith("data:", ignoreCase = true)) {
|
||||||
|
val mime = payload.substringAfter("data:", "")
|
||||||
|
.substringBefore(';')
|
||||||
|
.ifBlank { "image/jpeg" }
|
||||||
|
val base64 = payload.substringAfter("base64,", "")
|
||||||
|
return encodePhotoBase64(base64, mime)
|
||||||
|
}
|
||||||
|
if (header.contains("VALUE=URI", ignoreCase = true)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val mime = when {
|
||||||
|
header.contains("TYPE=PNG", ignoreCase = true) ||
|
||||||
|
header.contains("MEDIATYPE=image/png", ignoreCase = true) -> "image/png"
|
||||||
|
header.contains("TYPE=GIF", ignoreCase = true) ||
|
||||||
|
header.contains("MEDIATYPE=image/gif", ignoreCase = true) -> "image/gif"
|
||||||
|
header.contains("TYPE=WEBP", ignoreCase = true) ||
|
||||||
|
header.contains("MEDIATYPE=image/webp", ignoreCase = true) -> "image/webp"
|
||||||
|
else -> "image/jpeg"
|
||||||
|
}
|
||||||
|
return encodePhotoBase64(payload, mime)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodePhotoBase64(raw: String, mimeType: String): ParsedContactPhoto? {
|
||||||
|
val normalized = raw.replace("\\s".toRegex(), "")
|
||||||
|
if (normalized.isBlank()) return null
|
||||||
|
val bytes = runCatching {
|
||||||
|
Base64.getDecoder().decode(normalized)
|
||||||
|
}.getOrNull() ?: return null
|
||||||
|
if (bytes.isEmpty()) return null
|
||||||
|
return ParsedContactPhoto(
|
||||||
|
base64 = Base64.getEncoder().encodeToString(bytes),
|
||||||
|
mimeType = mimeType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newParser(xml: String): XmlPullParser {
|
||||||
|
val parser = XmlPullParserFactory.newInstance().newPullParser()
|
||||||
|
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
|
||||||
|
parser.setInput(xml.reader())
|
||||||
|
return parser
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun XmlPullParser.localTag(): String = name.substringAfter(':')
|
||||||
|
|
||||||
|
private fun XmlPullParser.readText(): String {
|
||||||
|
if (next() != XmlPullParser.TEXT) return ""
|
||||||
|
return text.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toDavPath(): String {
|
||||||
|
val path = if (contains("://")) {
|
||||||
|
java.net.URI(this).path.orEmpty()
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
URLDecoder.decode(path, Charsets.UTF_8.name()).trim('/')
|
||||||
|
} catch (_: Exception) {
|
||||||
|
path.trim('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
package ru.forbion.f7cloud.core.network
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
import org.xmlpull.v1.XmlPullParserFactory
|
||||||
|
import java.net.URLDecoder
|
||||||
|
|
||||||
|
object DavClient {
|
||||||
|
data class DavEntry(
|
||||||
|
val name: String,
|
||||||
|
val href: String,
|
||||||
|
val isDirectory: Boolean,
|
||||||
|
val fileId: Long? = null,
|
||||||
|
val lastModified: Long? = null,
|
||||||
|
val size: Long? = null,
|
||||||
|
val mimeType: String? = null,
|
||||||
|
val favorite: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun mkcol(client: OkHttpClient, folderUrl: String) {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(folderUrl)
|
||||||
|
.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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun put(
|
||||||
|
client: OkHttpClient,
|
||||||
|
fileUrl: String,
|
||||||
|
body: RequestBody,
|
||||||
|
) {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(fileUrl)
|
||||||
|
.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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun propfind(client: OkHttpClient, folderUrl: String, depth: Int = 1): List<DavEntry> {
|
||||||
|
val body = """
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname/>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<d:getlastmodified/>
|
||||||
|
<d:getcontentlength/>
|
||||||
|
<d:getcontenttype/>
|
||||||
|
<oc:fileid/>
|
||||||
|
<oc:favorite/>
|
||||||
|
</d:prop>
|
||||||
|
</d:propfind>
|
||||||
|
""".trimIndent()
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(folderUrl)
|
||||||
|
.header("Depth", depth.toString())
|
||||||
|
.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")
|
||||||
|
}
|
||||||
|
val xml = response.body?.string().orEmpty()
|
||||||
|
if (xml.isBlank()) {
|
||||||
|
error("DAV empty response")
|
||||||
|
}
|
||||||
|
parseMultiStatus(xml, folderUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseMultiStatus(xml: String, folderUrl: String): List<DavEntry> {
|
||||||
|
val parser = XmlPullParserFactory.newInstance().newPullParser()
|
||||||
|
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
|
||||||
|
parser.setInput(xml.reader())
|
||||||
|
|
||||||
|
val folderPath = folderUrl.toDavPath()
|
||||||
|
val result = mutableListOf<DavEntry>()
|
||||||
|
|
||||||
|
var inResponse = false
|
||||||
|
var href = ""
|
||||||
|
var displayName = ""
|
||||||
|
var isCollection = false
|
||||||
|
var fileId: Long? = null
|
||||||
|
var lastModified: Long? = null
|
||||||
|
var size: Long? = null
|
||||||
|
var mimeType: String? = null
|
||||||
|
var favorite = false
|
||||||
|
|
||||||
|
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||||
|
when (parser.eventType) {
|
||||||
|
XmlPullParser.START_TAG -> {
|
||||||
|
when (parser.localTag()) {
|
||||||
|
"response" -> {
|
||||||
|
inResponse = true
|
||||||
|
href = ""
|
||||||
|
displayName = ""
|
||||||
|
isCollection = false
|
||||||
|
fileId = null
|
||||||
|
lastModified = null
|
||||||
|
size = null
|
||||||
|
mimeType = null
|
||||||
|
favorite = false
|
||||||
|
}
|
||||||
|
"href" -> if (inResponse) href = parser.readText()
|
||||||
|
"displayname" -> if (inResponse) displayName = parser.readText()
|
||||||
|
"collection" -> if (inResponse) isCollection = true
|
||||||
|
"getlastmodified" -> if (inResponse) {
|
||||||
|
lastModified = parseHttpDate(parser.readText())
|
||||||
|
}
|
||||||
|
"getcontentlength" -> if (inResponse) {
|
||||||
|
parser.readText().toLongOrNull()?.let { size = it }
|
||||||
|
}
|
||||||
|
"getcontenttype" -> if (inResponse) {
|
||||||
|
mimeType = parser.readText().trim().ifBlank { null }
|
||||||
|
}
|
||||||
|
"fileid" -> if (inResponse) {
|
||||||
|
parser.readText().toLongOrNull()?.let { fileId = it }
|
||||||
|
}
|
||||||
|
"favorite" -> if (inResponse) {
|
||||||
|
favorite = parser.readText().trim() == "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XmlPullParser.END_TAG -> {
|
||||||
|
if (parser.localTag() == "response" && inResponse) {
|
||||||
|
inResponse = false
|
||||||
|
val entryPath = href.decodeHrefPath()
|
||||||
|
if (entryPath.isNotBlank() && entryPath != folderPath && entryPath.startsWith(folderPath)) {
|
||||||
|
val relative = entryPath.removePrefix(folderPath).trim('/')
|
||||||
|
if (relative.isNotBlank() && !relative.contains('/')) {
|
||||||
|
val name = displayName.trim().ifBlank {
|
||||||
|
relative.substringAfterLast('/')
|
||||||
|
}
|
||||||
|
if (name.isNotBlank() && name != "." && name != "..") {
|
||||||
|
result += DavEntry(
|
||||||
|
name = name,
|
||||||
|
href = href,
|
||||||
|
isDirectory = isCollection,
|
||||||
|
fileId = fileId,
|
||||||
|
lastModified = lastModified,
|
||||||
|
size = size,
|
||||||
|
mimeType = mimeType,
|
||||||
|
favorite = favorite,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parser.next()
|
||||||
|
}
|
||||||
|
return result.sortedWith(compareByDescending<DavEntry> { it.isDirectory }.thenBy { it.name.lowercase() })
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delete(client: OkHttpClient, resourceUrl: String) {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(resourceUrl)
|
||||||
|
.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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun move(client: OkHttpClient, sourceUrl: String, destinationUrl: String) {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(sourceUrl)
|
||||||
|
.method("MOVE", null)
|
||||||
|
.header("Destination", destinationUrl)
|
||||||
|
.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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setFavorite(client: OkHttpClient, resourceUrl: String, favorite: Boolean) {
|
||||||
|
val value = if (favorite) "1" else "0"
|
||||||
|
val body = """
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||||
|
<d:set>
|
||||||
|
<d:prop>
|
||||||
|
<oc:favorite>$value</oc:favorite>
|
||||||
|
</d:prop>
|
||||||
|
</d:set>
|
||||||
|
</d:propertyupdate>
|
||||||
|
""".trimIndent()
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(resourceUrl)
|
||||||
|
.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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseHttpDate(raw: String): Long? {
|
||||||
|
val trimmed = raw.trim()
|
||||||
|
if (trimmed.isBlank()) return null
|
||||||
|
return try {
|
||||||
|
val format = java.text.SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", java.util.Locale.US)
|
||||||
|
format.parse(trimmed)?.time
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun XmlPullParser.localTag(): String = name.substringAfter(':')
|
||||||
|
|
||||||
|
private fun XmlPullParser.readText(): String {
|
||||||
|
if (next() != XmlPullParser.TEXT) return ""
|
||||||
|
return text.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.decodeHrefPath(): String {
|
||||||
|
val path = try {
|
||||||
|
URLDecoder.decode(this, Charsets.UTF_8.name())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
return path.substringBefore('?').trim('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toDavPath(): String {
|
||||||
|
val path = if (contains("://")) {
|
||||||
|
java.net.URI(this).path.orEmpty()
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
return path.decodeHrefPath()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun davFilesBaseUrl(serverUrl: String, userId: String): String {
|
||||||
|
val base = serverUrl.trimEnd('/')
|
||||||
|
val encodedUser = userId.split('/').joinToString("/") { segment ->
|
||||||
|
java.net.URLEncoder.encode(segment, Charsets.UTF_8.name()).replace("+", "%20")
|
||||||
|
}
|
||||||
|
return "$base/remote.php/dav/files/$encodedUser/"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun davFolderUrl(serverUrl: String, userId: String, relativePath: String): String {
|
||||||
|
val base = davFilesBaseUrl(serverUrl, userId)
|
||||||
|
if (relativePath.isBlank()) return base
|
||||||
|
val encodedPath = relativePath.trim('/').split('/').joinToString("/") { segment ->
|
||||||
|
java.net.URLEncoder.encode(segment, Charsets.UTF_8.name()).replace("+", "%20")
|
||||||
|
}
|
||||||
|
return "$base$encodedPath/"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun davFileUrl(serverUrl: String, userId: String, relativePath: String): String {
|
||||||
|
val base = davFilesBaseUrl(serverUrl, userId)
|
||||||
|
if (relativePath.isBlank()) error("Путь к файлу пуст")
|
||||||
|
val encodedPath = relativePath.trim('/').split('/').joinToString("/") { segment ->
|
||||||
|
java.net.URLEncoder.encode(segment, Charsets.UTF_8.name()).replace("+", "%20")
|
||||||
|
}
|
||||||
|
return "$base$encodedPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun davAddressBooksBaseUrl(serverUrl: String, userId: String): String {
|
||||||
|
val base = serverUrl.trimEnd('/')
|
||||||
|
val encodedUser = userId.split('/').joinToString("/") { segment ->
|
||||||
|
java.net.URLEncoder.encode(segment, Charsets.UTF_8.name()).replace("+", "%20")
|
||||||
|
}
|
||||||
|
return "$base/remote.php/dav/addressbooks/users/$encodedUser/"
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package ru.forbion.f7cloud.core.network
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.Cookie
|
||||||
|
import okhttp3.CookieJar
|
||||||
|
import okhttp3.Credentials
|
||||||
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.net.URLDecoder
|
||||||
|
|
||||||
|
data class QrLoginResult(
|
||||||
|
val serverUrl: String,
|
||||||
|
val username: String,
|
||||||
|
val appPassword: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
object LoginFlowClient {
|
||||||
|
/** F7cloud mobile QR scheme. */
|
||||||
|
const val F7_LOGIN_PREFIX = "f7://login/"
|
||||||
|
const val F7_OTP_PREFIX = "f7://onetime-login/"
|
||||||
|
|
||||||
|
/** Browser login flow v2 landing URL prefix (QR on web login page). */
|
||||||
|
private val BROWSER_FLOW_REGEX = Regex("""/login/v2/flow/([A-Za-z0-9]+)""")
|
||||||
|
|
||||||
|
/** True when the scanned text looks like a complete F7 or browser login QR payload. */
|
||||||
|
fun isCompleteQrPayload(qrData: String): Boolean {
|
||||||
|
val trimmed = qrData.trim()
|
||||||
|
return when {
|
||||||
|
trimmed.startsWith(F7_LOGIN_PREFIX) || trimmed.startsWith(F7_OTP_PREFIX) ->
|
||||||
|
parseCredentialParams(extractParams(trimmed)) != null
|
||||||
|
isBrowserLoginFlowUrl(trimmed) ->
|
||||||
|
(trimmed.startsWith("http://", ignoreCase = true) ||
|
||||||
|
trimmed.startsWith("https://", ignoreCase = true)) &&
|
||||||
|
BROWSER_FLOW_REGEX.containsMatchIn(trimmed)
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun completeQrLogin(qrData: String, trustAllCerts: Boolean = false): QrLoginResult? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
when {
|
||||||
|
isBrowserLoginFlowUrl(qrData) -> null
|
||||||
|
else -> when (val payload = extractPayload(qrData)) {
|
||||||
|
null -> null
|
||||||
|
QrPayload.DirectLogin -> parseDirectLogin(extractParams(qrData))
|
||||||
|
QrPayload.OneTimeLogin -> parseOneTimeLogin(extractParams(qrData), trustAllCerts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve browser login after scanning Login Flow v2 QR (logged-in mobile user).
|
||||||
|
*/
|
||||||
|
suspend fun approveBrowserLoginFromQr(
|
||||||
|
qrData: String,
|
||||||
|
username: String,
|
||||||
|
appPassword: String,
|
||||||
|
trustAllCerts: Boolean = false,
|
||||||
|
): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val flowUrl = qrData.trim()
|
||||||
|
val landingUrl = normalizeBrowserFlowUrl(flowUrl) ?: return@withContext false
|
||||||
|
val cookies = mutableListOf<Cookie>()
|
||||||
|
val cookieJar = object : CookieJar {
|
||||||
|
override fun saveFromResponse(url: HttpUrl, list: List<Cookie>) {
|
||||||
|
cookies.removeAll { existing ->
|
||||||
|
list.any { it.name == existing.name && it.domain == existing.domain && it.path == existing.path }
|
||||||
|
}
|
||||||
|
cookies.addAll(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun loadForRequest(url: HttpUrl): List<Cookie> =
|
||||||
|
cookies.filter { it.matches(url) }
|
||||||
|
}
|
||||||
|
val client = OkHttpClient.Builder()
|
||||||
|
.cookieJar(cookieJar)
|
||||||
|
.followRedirects(true)
|
||||||
|
.applyUnsafeSslIfNeeded(trustAllCerts)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val landingHtml = client.newCall(
|
||||||
|
Request.Builder().url(landingUrl).get().build(),
|
||||||
|
).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@withContext false
|
||||||
|
response.body?.string().orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
val stateToken = STATE_TOKEN_REGEX.find(landingHtml)?.groupValues?.get(1)?.trim().orEmpty()
|
||||||
|
if (stateToken.isBlank()) return@withContext false
|
||||||
|
|
||||||
|
val requestToken = REQUEST_TOKEN_REGEX.find(landingHtml)?.groupValues?.get(1)?.trim().orEmpty()
|
||||||
|
val base = extractServerBase(landingUrl)
|
||||||
|
val apptokenUrl = "$base/login/v2/apptoken"
|
||||||
|
val form = FormBody.Builder()
|
||||||
|
.add("stateToken", stateToken)
|
||||||
|
.add("user", username)
|
||||||
|
.add("password", appPassword)
|
||||||
|
if (requestToken.isNotBlank()) {
|
||||||
|
form.add("requesttoken", requestToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
client.newCall(
|
||||||
|
Request.Builder()
|
||||||
|
.url(apptokenUrl)
|
||||||
|
.post(form.build())
|
||||||
|
.build(),
|
||||||
|
).execute().use { response ->
|
||||||
|
response.isSuccessful || response.code == 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isBrowserLoginFlowUrl(qrData: String): Boolean =
|
||||||
|
BROWSER_FLOW_REGEX.containsMatchIn(qrData)
|
||||||
|
|
||||||
|
private fun extractServerBase(url: String): String {
|
||||||
|
val trimmed = url.trim()
|
||||||
|
return when {
|
||||||
|
trimmed.contains("/index.php") -> trimmed.substringBefore("/index.php")
|
||||||
|
trimmed.contains("/login/v2") -> trimmed.substringBefore("/login/v2")
|
||||||
|
else -> {
|
||||||
|
val schemeEnd = trimmed.indexOf("://")
|
||||||
|
if (schemeEnd == -1) return trimmed.trimEnd('/')
|
||||||
|
val pathStart = trimmed.indexOf('/', schemeEnd + 3)
|
||||||
|
if (pathStart == -1) trimmed else trimmed.substring(0, pathStart)
|
||||||
|
}
|
||||||
|
}.trimEnd('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeBrowserFlowUrl(qrData: String): String? {
|
||||||
|
val trimmed = qrData.trim()
|
||||||
|
if (trimmed.startsWith("http://", ignoreCase = true) || trimmed.startsWith("https://", ignoreCase = true)) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private val STATE_TOKEN_REGEX = Regex("""name=["']stateToken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)
|
||||||
|
private val REQUEST_TOKEN_REGEX = Regex("""name=["']requesttoken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
private enum class QrPayload { DirectLogin, OneTimeLogin }
|
||||||
|
|
||||||
|
private fun extractPayload(qrData: String): QrPayload? = when {
|
||||||
|
qrData.startsWith(F7_LOGIN_PREFIX) -> QrPayload.DirectLogin
|
||||||
|
qrData.startsWith(F7_OTP_PREFIX) -> QrPayload.OneTimeLogin
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractParams(qrData: String): String {
|
||||||
|
val prefix = when {
|
||||||
|
qrData.startsWith(F7_LOGIN_PREFIX) -> F7_LOGIN_PREFIX
|
||||||
|
qrData.startsWith(F7_OTP_PREFIX) -> F7_OTP_PREFIX
|
||||||
|
else -> return qrData
|
||||||
|
}
|
||||||
|
return qrData.removePrefix(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDirectLogin(params: String): QrLoginResult? {
|
||||||
|
val parsed = parseCredentialParams(params) ?: return null
|
||||||
|
return QrLoginResult(parsed.server.trimEnd('/'), parsed.user, parsed.password)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseOneTimeLogin(params: String, trustAllCerts: Boolean): QrLoginResult? {
|
||||||
|
val parsed = parseCredentialParams(params) ?: return null
|
||||||
|
val client = OkHttpClient.Builder().applyUnsafeSslIfNeeded(trustAllCerts).build()
|
||||||
|
val credentials = Credentials.basic(parsed.user, parsed.password)
|
||||||
|
val url = "${parsed.server.trimEnd('/')}/ocs/v2.php/core/getapppassword-onetime"
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.header("Authorization", credentials)
|
||||||
|
.header("OCS-APIRequest", "true")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.build()
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return null
|
||||||
|
val body = response.body?.string().orEmpty()
|
||||||
|
val appPassword = JSONObject(body)
|
||||||
|
.optJSONObject("ocs")
|
||||||
|
?.optJSONObject("data")
|
||||||
|
?.optString("apppassword")
|
||||||
|
.orEmpty()
|
||||||
|
if (appPassword.isBlank()) return null
|
||||||
|
return QrLoginResult(parsed.server.trimEnd('/'), parsed.user, appPassword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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:"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (server.isBlank() || user.isBlank() || password.isBlank()) return null
|
||||||
|
return CredentialParams(server, user, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun pollBrowserLogin(serverUrl: String, trustAllCerts: Boolean = false): QrLoginResult? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val base = serverUrl.trimEnd('/')
|
||||||
|
val client = OkHttpClient.Builder().applyUnsafeSslIfNeeded(trustAllCerts).build()
|
||||||
|
val startRequest = Request.Builder()
|
||||||
|
.url("$base/index.php/login/v2")
|
||||||
|
.post(FormBody.Builder().build())
|
||||||
|
.header("Clear-Site-Data", "cookies")
|
||||||
|
.build()
|
||||||
|
val startBody = client.newCall(startRequest).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@withContext null
|
||||||
|
response.body?.string().orEmpty()
|
||||||
|
}
|
||||||
|
val json = JSONObject(startBody)
|
||||||
|
val poll = json.optJSONObject("poll") ?: return@withContext null
|
||||||
|
val token = poll.optString("token")
|
||||||
|
val pollUrl = poll.optString("endpoint")
|
||||||
|
if (token.isBlank() || pollUrl.isBlank()) return@withContext null
|
||||||
|
repeat(120) {
|
||||||
|
val pollRequest = Request.Builder()
|
||||||
|
.url(pollUrl)
|
||||||
|
.post(FormBody.Builder().add("token", token).build())
|
||||||
|
.build()
|
||||||
|
val result = client.newCall(pollRequest).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@use null
|
||||||
|
val body = response.body?.string().orEmpty()
|
||||||
|
if (body.isBlank()) return@use null
|
||||||
|
val obj = JSONObject(body)
|
||||||
|
QrLoginResult(
|
||||||
|
serverUrl = obj.optString("server", base).trimEnd('/'),
|
||||||
|
username = obj.optString("loginName"),
|
||||||
|
appPassword = obj.optString("appPassword"),
|
||||||
|
).takeIf { it.username.isNotBlank() && it.appPassword.isNotBlank() }
|
||||||
|
}
|
||||||
|
if (result != null) return@withContext result
|
||||||
|
delay(250)
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decode(value: String): String =
|
||||||
|
runCatching { URLDecoder.decode(value, Charsets.UTF_8.name()) }.getOrDefault(value)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package ru.forbion.f7cloud.core.network
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
object NetworkFactory {
|
||||||
|
fun newAuthedClient(
|
||||||
|
username: String,
|
||||||
|
appPassword: String,
|
||||||
|
trustAllCerts: Boolean = false,
|
||||||
|
callTimeoutSeconds: Long = 30,
|
||||||
|
readTimeoutSeconds: Long = 30,
|
||||||
|
): OkHttpClient {
|
||||||
|
return OkHttpClient.Builder()
|
||||||
|
.callTimeout(callTimeoutSeconds, TimeUnit.SECONDS)
|
||||||
|
.connectTimeout(20, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(readTimeoutSeconds, TimeUnit.SECONDS)
|
||||||
|
.applyUnsafeSslIfNeeded(trustAllCerts)
|
||||||
|
.addInterceptor(BasicAuthInterceptor(username, appPassword))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collabora / richdocuments: cold start and WOPI can be slow on mobile networks. */
|
||||||
|
fun newAuthedClientForOffice(
|
||||||
|
username: String,
|
||||||
|
appPassword: String,
|
||||||
|
trustAllCerts: Boolean = false,
|
||||||
|
): OkHttpClient = newAuthedClient(
|
||||||
|
username = username,
|
||||||
|
appPassword = appPassword,
|
||||||
|
trustAllCerts = trustAllCerts,
|
||||||
|
callTimeoutSeconds = 120,
|
||||||
|
readTimeoutSeconds = 120,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package ru.forbion.f7cloud.core.network
|
||||||
|
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
data class F7Notification(
|
||||||
|
val id: Long,
|
||||||
|
val subject: String,
|
||||||
|
val message: String,
|
||||||
|
val datetime: String,
|
||||||
|
val link: String,
|
||||||
|
val app: String,
|
||||||
|
val icon: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
class NotificationsRepository {
|
||||||
|
fun load(
|
||||||
|
serverUrl: String,
|
||||||
|
username: String,
|
||||||
|
appPassword: String,
|
||||||
|
trustAllCerts: Boolean = false,
|
||||||
|
limit: Int = 50,
|
||||||
|
): List<F7Notification> {
|
||||||
|
val client = NetworkFactory.newAuthedClient(username, appPassword, trustAllCerts)
|
||||||
|
val url = "${serverUrl.trimEnd('/')}/ocs/v2.php/apps/notifications/api/v2/notifications" +
|
||||||
|
"?format=json&limit=$limit"
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.header("OCS-APIRequest", "true")
|
||||||
|
.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}")
|
||||||
|
}
|
||||||
|
val ocs = JSONObject(response.body!!.string()).optJSONObject("ocs")
|
||||||
|
?: error("Некорректный ответ уведомлений")
|
||||||
|
val meta = ocs.optJSONObject("meta")
|
||||||
|
if (meta?.optString("status").equals("failure", ignoreCase = true)) {
|
||||||
|
error(meta?.optString("message").orEmpty().ifBlank { "Ошибка уведомлений" })
|
||||||
|
}
|
||||||
|
val data = ocs.opt("data")
|
||||||
|
val array = when (data) {
|
||||||
|
is JSONArray -> data
|
||||||
|
is JSONObject -> JSONArray().put(data)
|
||||||
|
else -> JSONArray()
|
||||||
|
}
|
||||||
|
return parseNotifications(array)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseNotifications(array: JSONArray): List<F7Notification> {
|
||||||
|
val out = mutableListOf<F7Notification>()
|
||||||
|
for (i in 0 until array.length()) {
|
||||||
|
val obj = array.optJSONObject(i) ?: continue
|
||||||
|
val id = obj.optLong("notification_id", 0L)
|
||||||
|
if (id <= 0L) continue
|
||||||
|
val subject = obj.optString("subject").ifBlank { obj.optString("app") }
|
||||||
|
out += F7Notification(
|
||||||
|
id = id,
|
||||||
|
subject = subject,
|
||||||
|
message = obj.optString("message"),
|
||||||
|
datetime = obj.optString("datetime"),
|
||||||
|
link = obj.optString("link"),
|
||||||
|
app = obj.optString("app"),
|
||||||
|
icon = obj.optString("icon"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismiss(
|
||||||
|
serverUrl: String,
|
||||||
|
username: String,
|
||||||
|
appPassword: String,
|
||||||
|
notificationId: Long,
|
||||||
|
trustAllCerts: Boolean = false,
|
||||||
|
) {
|
||||||
|
val client = NetworkFactory.newAuthedClient(username, appPassword, trustAllCerts)
|
||||||
|
val url = "${serverUrl.trimEnd('/')}/ocs/v2.php/apps/notifications/api/v2/notifications/$notificationId"
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.delete()
|
||||||
|
.header("OCS-APIRequest", "true")
|
||||||
|
.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,51 @@
|
|||||||
|
package ru.forbion.f7cloud.core.network
|
||||||
|
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
fun Request.Builder.applyOcsJson(): Request.Builder =
|
||||||
|
header("OCS-APIRequest", "true")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
|
||||||
|
fun parseJsonObject(body: String, what: String = "ответ сервера"): JSONObject {
|
||||||
|
val trimmed = body.trim()
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
error("Пустой $what")
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith("<?xml") || trimmed.startsWith("<")) {
|
||||||
|
error("Сервер вернул XML вместо JSON ($what)")
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
JSONObject(trimmed)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Некорректный JSON ($what)", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseJsonArray(body: String, what: String = "ответ сервера"): JSONArray {
|
||||||
|
val trimmed = body.trim()
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
error("Пустой $what")
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith("<?xml") || trimmed.startsWith("<")) {
|
||||||
|
error("Сервер вернул XML вместо JSON ($what)")
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
JSONArray(trimmed)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Некорректный JSON ($what)", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun JSONObject.ocsMeta(): JSONObject? = optJSONObject("ocs")?.optJSONObject("meta")
|
||||||
|
|
||||||
|
fun JSONObject.ocsData(): JSONObject? = optJSONObject("ocs")?.optJSONObject("data")
|
||||||
|
|
||||||
|
/** OCS v1 uses statuscode 100; OCS v2 uses meta.status "ok" or HTTP-style 200–299. */
|
||||||
|
fun isOcsSuccess(meta: JSONObject?): Boolean {
|
||||||
|
if (meta == null) return false
|
||||||
|
if (meta.optString("status") == "ok") return true
|
||||||
|
val code = meta.optInt("statuscode", 0)
|
||||||
|
return code == 100 || code in 200..299
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package ru.forbion.f7cloud.core.network
|
||||||
|
|
||||||
|
class UnauthorizedException(message: String = "Session expired") : RuntimeException(message)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package ru.forbion.f7cloud.core.network
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import javax.net.ssl.TrustManager
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
internal fun OkHttpClient.Builder.applyUnsafeSslIfNeeded(trustAllCerts: Boolean): OkHttpClient.Builder {
|
||||||
|
if (!trustAllCerts) return this
|
||||||
|
val trustAll = arrayOf<TrustManager>(
|
||||||
|
object : X509TrustManager {
|
||||||
|
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
|
||||||
|
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
|
||||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val sslContext = SSLContext.getInstance("TLS")
|
||||||
|
sslContext.init(null, trustAll, SecureRandom())
|
||||||
|
val trustManager = trustAll[0] as X509TrustManager
|
||||||
|
sslSocketFactory(sslContext.socketFactory, trustManager)
|
||||||
|
hostnameVerifier { _, _ -> true }
|
||||||
|
return this
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.library'
|
||||||
|
id 'org.jetbrains.kotlin.android'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'ru.forbion.f7cloud.core.push'
|
||||||
|
compileSdk 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk 26
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '17'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application>
|
||||||
|
<service
|
||||||
|
android:name=".F7FirebaseMessagingService"
|
||||||
|
android:directBootAware="true"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter android:priority="1">
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
<receiver
|
||||||
|
android:name=".F7CallActionReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package ru.forbion.f7cloud.core.push
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
|
||||||
|
/** Handles Decline 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_DECLINE = "ru.forbion.f7cloud.action.DECLINE_CALL"
|
||||||
|
const val EXTRA_NOTIFICATION_ID = "notificationId"
|
||||||
|
const val EXTRA_ROOM_TOKEN = "roomToken"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package ru.forbion.f7cloud.core.push
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.forbion.f7cloud.core.auth.AuthStore
|
||||||
|
|
||||||
|
class F7FirebaseMessagingService : FirebaseMessagingService() {
|
||||||
|
override fun onNewToken(token: String) {
|
||||||
|
Log.i(TAG, "FCM token refreshed")
|
||||||
|
val session = AuthStore(this).load()
|
||||||
|
if (session != null) {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
val code = F7PushRegistrar.registerBlocking(this@F7FirebaseMessagingService, session, token)
|
||||||
|
Log.i(TAG, "push register result: $code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.onNewToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessageReceived(message: RemoteMessage) {
|
||||||
|
val data = message.data
|
||||||
|
Log.i(TAG, "FCM data keys=${data.keys} priority=${message.priority}")
|
||||||
|
|
||||||
|
val type = data["type"]
|
||||||
|
val clickUrl = data["acceptUrl"]
|
||||||
|
?: data["url"]
|
||||||
|
?: data["clickUrl"]
|
||||||
|
?: data["link"]
|
||||||
|
val priority = data["priority"]
|
||||||
|
val highPriority = priority.equals("high", ignoreCase = true)
|
||||||
|
val title = message.notification?.title
|
||||||
|
?: data["title"]
|
||||||
|
?: "F7cloud"
|
||||||
|
val body = message.notification?.body
|
||||||
|
?: data["body"]
|
||||||
|
?: ""
|
||||||
|
|
||||||
|
// Only explicit call pushes should ring — Talk recording/chat links may also contain "/call/".
|
||||||
|
val isCall = type == "call"
|
||||||
|
|
||||||
|
val pushEvent = F7PushEventParser.parse(data, title, body)
|
||||||
|
F7PushEventHub.publish(pushEvent)
|
||||||
|
|
||||||
|
if (isCall) {
|
||||||
|
if (!canPostNotifications()) {
|
||||||
|
Log.w(TAG, "POST_NOTIFICATIONS denied — incoming call UI blocked")
|
||||||
|
}
|
||||||
|
val wakeLock = (getSystemService(POWER_SERVICE) as? PowerManager)
|
||||||
|
?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "f7cloud:incoming_call")
|
||||||
|
?.apply { acquire(30_000L) }
|
||||||
|
try {
|
||||||
|
val shown = F7IncomingCallQueue.enqueue(
|
||||||
|
context = this,
|
||||||
|
title = title,
|
||||||
|
body = body,
|
||||||
|
acceptUrl = data["acceptUrl"] ?: clickUrl,
|
||||||
|
roomToken = data["roomToken"],
|
||||||
|
roomDisplayName = data["roomDisplayName"],
|
||||||
|
)
|
||||||
|
Log.i(TAG, "Incoming call enqueued: title=$title room=${data["roomToken"]} shown=$shown")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.e(TAG, "Incoming call notification failed", t)
|
||||||
|
} finally {
|
||||||
|
wakeLock?.let { if (it.isHeld) it.release() }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!canPostNotifications()) {
|
||||||
|
Log.w(TAG, "POST_NOTIFICATIONS denied — message notification skipped")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
F7PushNotificationHelper.show(
|
||||||
|
context = this,
|
||||||
|
title = title,
|
||||||
|
body = body,
|
||||||
|
openUrl = clickUrl,
|
||||||
|
highPriority = highPriority,
|
||||||
|
type = type,
|
||||||
|
channelHint = data["channel"],
|
||||||
|
roomToken = data["roomToken"],
|
||||||
|
messageId = data["messageId"],
|
||||||
|
)
|
||||||
|
Log.i(TAG, "Message notification shown: $title")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.e(TAG, "Message notification failed", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun canPostNotifications(): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return ContextCompat.checkSelfPermission(
|
||||||
|
this,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "F7Push"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
package ru.forbion.f7cloud.core.push
|
||||||
|
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.Person
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import ru.forbion.f7cloud.core.auth.AuthStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows one incoming Talk call at a time; further calls wait in a FIFO queue.
|
||||||
|
*/
|
||||||
|
object F7IncomingCallQueue {
|
||||||
|
private const val TAG = "F7IncomingCallQueue"
|
||||||
|
private const val PREFS = "f7push_call_queue"
|
||||||
|
private const val KEY_QUEUE = "queue"
|
||||||
|
private const val KEY_ACTIVE_TOKEN = "active_token"
|
||||||
|
private const val KEY_ACTIVE_AT = "active_at"
|
||||||
|
const val ACTIVE_NOTIFICATION_ID = 5000
|
||||||
|
private const val ACTIVE_RING_TTL_MS = 3 * 60 * 1000L
|
||||||
|
|
||||||
|
private val lock = Any()
|
||||||
|
|
||||||
|
fun enqueue(
|
||||||
|
context: Context,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
acceptUrl: String?,
|
||||||
|
roomToken: String?,
|
||||||
|
roomDisplayName: String? = null,
|
||||||
|
): Boolean {
|
||||||
|
val token = normalizeToken(roomToken, acceptUrl, title)
|
||||||
|
val displayName = TalkCallPushLabels.resolveRoomDisplayName(title, body, roomDisplayName)
|
||||||
|
synchronized(lock) {
|
||||||
|
val prefs = prefs(context)
|
||||||
|
expireStaleActiveLocked(prefs)
|
||||||
|
|
||||||
|
val call = PendingCall(token, title, body, acceptUrl, displayName)
|
||||||
|
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
|
||||||
|
|
||||||
|
if (token == active) {
|
||||||
|
// Duplicate FCM for the same room — refresh UI only, do not re-ring.
|
||||||
|
val shown = showNotification(context, call, waiting = 0, alert = false)
|
||||||
|
if (shown) {
|
||||||
|
touchActive(prefs)
|
||||||
|
}
|
||||||
|
return shown
|
||||||
|
}
|
||||||
|
|
||||||
|
val queue = readQueue(prefs)
|
||||||
|
if (containsToken(queue, token)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
return shown
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.put(call.toJson())
|
||||||
|
prefs.edit().putString(KEY_QUEUE, queue.toString()).apply()
|
||||||
|
Log.d(TAG, "Call queued: $token, queue size=${queue.length()}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissAndShowNext(context: Context, roomToken: String?) {
|
||||||
|
synchronized(lock) {
|
||||||
|
val prefs = prefs(context)
|
||||||
|
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
|
||||||
|
var queue = readQueue(prefs)
|
||||||
|
|
||||||
|
if (!roomToken.isNullOrBlank()) {
|
||||||
|
val token = roomToken.trim()
|
||||||
|
if (token != active) {
|
||||||
|
queue = removeToken(queue, token)
|
||||||
|
prefs.edit().putString(KEY_QUEUE, queue.toString()).apply()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelNotification(context)
|
||||||
|
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
|
||||||
|
|
||||||
|
if (queue.length() == 0) {
|
||||||
|
prefs.edit().remove(KEY_QUEUE).apply()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
val next = PendingCall.fromJson(queue.getJSONObject(0))
|
||||||
|
val rest = JSONArray()
|
||||||
|
for (i in 1 until queue.length()) {
|
||||||
|
rest.put(queue.get(i))
|
||||||
|
}
|
||||||
|
prefs.edit().putString(KEY_QUEUE, rest.toString()).apply()
|
||||||
|
if (showNotification(context, next, rest.length())) {
|
||||||
|
setActive(prefs, 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,
|
||||||
|
call: PendingCall,
|
||||||
|
waiting: Int,
|
||||||
|
alert: Boolean = true,
|
||||||
|
): Boolean {
|
||||||
|
F7NotificationChannels.ensureAll(context)
|
||||||
|
val body = if (waiting > 0) {
|
||||||
|
call.body + "\n" + context.resources.getQuantityString(
|
||||||
|
R.plurals.call_queue_waiting,
|
||||||
|
waiting,
|
||||||
|
waiting,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
call.body
|
||||||
|
}
|
||||||
|
|
||||||
|
val joinUrl = resolveJoinUrl(context, call) ?: run {
|
||||||
|
Log.w(TAG, "Cannot resolve join URL for call ${call.roomToken}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val intents = buildPendingIntents(context, call, joinUrl)
|
||||||
|
if (alert) {
|
||||||
|
runCatching { F7IncomingCallRinger.start(context, call.roomToken) }
|
||||||
|
.onFailure { Log.w(TAG, "Ringtone start failed", it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val posted = runCatching {
|
||||||
|
postCallStyleNotification(context, call, body, intents, alert)
|
||||||
|
}.onFailure {
|
||||||
|
Log.w(TAG, "CallStyle notification failed, using fallback", it)
|
||||||
|
}.isSuccess || runCatching {
|
||||||
|
postFallbackNotification(context, call, body, intents, alert)
|
||||||
|
}.onFailure {
|
||||||
|
Log.e(TAG, "Fallback call notification failed", it)
|
||||||
|
}.isSuccess
|
||||||
|
|
||||||
|
if (!posted) {
|
||||||
|
F7IncomingCallRinger.stop()
|
||||||
|
}
|
||||||
|
return posted
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class CallPendingIntents(
|
||||||
|
val accept: PendingIntent,
|
||||||
|
val preview: PendingIntent,
|
||||||
|
val decline: PendingIntent,
|
||||||
|
val fullScreen: PendingIntent,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun buildPendingIntents(
|
||||||
|
context: Context,
|
||||||
|
call: PendingCall,
|
||||||
|
joinUrl: String,
|
||||||
|
): CallPendingIntents {
|
||||||
|
val requestCode = ACTIVE_NOTIFICATION_ID + kotlin.math.abs(call.roomToken.hashCode() % 10000)
|
||||||
|
val accept = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
requestCode,
|
||||||
|
incomingCallIntent(context, call, joinUrl, autoAccept = true),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
val preview = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
requestCode + 50000,
|
||||||
|
incomingCallIntent(context, call, joinUrl, autoAccept = false),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
val declineIntent = Intent(context, F7CallActionReceiver::class.java).apply {
|
||||||
|
action = F7CallActionReceiver.ACTION_DECLINE
|
||||||
|
putExtra(F7CallActionReceiver.EXTRA_NOTIFICATION_ID, ACTIVE_NOTIFICATION_ID)
|
||||||
|
putExtra(F7CallActionReceiver.EXTRA_ROOM_TOKEN, call.roomToken)
|
||||||
|
}
|
||||||
|
val decline = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
ACTIVE_NOTIFICATION_ID + 1,
|
||||||
|
declineIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
val fullScreen = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
requestCode + 60000,
|
||||||
|
incomingCallIntent(context, call, joinUrl, autoAccept = false),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
return CallPendingIntents(accept, preview, decline, fullScreen)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun postCallStyleNotification(
|
||||||
|
context: Context,
|
||||||
|
call: PendingCall,
|
||||||
|
body: String,
|
||||||
|
intents: CallPendingIntents,
|
||||||
|
alert: Boolean,
|
||||||
|
) {
|
||||||
|
val caller = Person.Builder()
|
||||||
|
.setName(call.displayName.ifBlank { call.title.ifBlank { context.getString(R.string.incoming_call_subtitle) } })
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val canFullScreen = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
context.getSystemService(NotificationManager::class.java)
|
||||||
|
?.canUseFullScreenIntent() != false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
val callStyle = NotificationCompat.CallStyle.forIncomingCall(
|
||||||
|
caller,
|
||||||
|
intents.decline,
|
||||||
|
intents.accept,
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(context, F7NotificationChannels.CALLS)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_phone_call)
|
||||||
|
.setContentTitle(call.displayName.ifBlank { call.title })
|
||||||
|
.setContentText(body)
|
||||||
|
.setStyle(callStyle)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setAutoCancel(false)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setSound(null)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setVibrate(null)
|
||||||
|
.setContentIntent(intents.preview)
|
||||||
|
.apply {
|
||||||
|
if (canFullScreen) {
|
||||||
|
setFullScreenIntent(intents.fullScreen, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
|
||||||
|
context.getSystemService(NotificationManager::class.java)
|
||||||
|
?.notify(ACTIVE_NOTIFICATION_ID, notification)
|
||||||
|
?: throw IllegalStateException("NotificationManager unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun postFallbackNotification(
|
||||||
|
context: Context,
|
||||||
|
call: PendingCall,
|
||||||
|
body: String,
|
||||||
|
intents: CallPendingIntents,
|
||||||
|
alert: Boolean,
|
||||||
|
) {
|
||||||
|
val canFullScreen = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
context.getSystemService(NotificationManager::class.java)
|
||||||
|
?.canUseFullScreenIntent() != false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(context, F7NotificationChannels.CALLS)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_phone_call)
|
||||||
|
.setContentTitle(call.displayName.ifBlank { call.title })
|
||||||
|
.setContentText(body)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setAutoCancel(false)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setSound(null)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setVibrate(null)
|
||||||
|
.setContentIntent(intents.preview)
|
||||||
|
.apply {
|
||||||
|
if (canFullScreen) {
|
||||||
|
setFullScreenIntent(intents.fullScreen, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.addAction(0, context.getString(R.string.call_action_accept), intents.accept)
|
||||||
|
.addAction(0, context.getString(R.string.call_action_decline), intents.decline)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
context.getSystemService(NotificationManager::class.java)
|
||||||
|
?.notify(ACTIVE_NOTIFICATION_ID, notification)
|
||||||
|
?: throw IllegalStateException("NotificationManager unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveJoinUrl(context: Context, call: PendingCall): String? {
|
||||||
|
val raw = call.acceptUrl?.takeIf { it.isNotBlank() }
|
||||||
|
?: AuthStore(context).load()?.let { session ->
|
||||||
|
buildCallUrl(session.serverUrl, call.roomToken)
|
||||||
|
}
|
||||||
|
?: return null
|
||||||
|
return stripDirectCallHash(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildCallUrl(serverBase: String, roomToken: String): String {
|
||||||
|
val base = serverBase.trimEnd('/')
|
||||||
|
return "$base/call/${roomToken.trim()}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stripDirectCallHash(url: String): String {
|
||||||
|
val hash = url.indexOf('#')
|
||||||
|
return if (hash >= 0) url.substring(0, hash) else url
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prefs(context: Context) =
|
||||||
|
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
private fun setActive(prefs: android.content.SharedPreferences, token: String) {
|
||||||
|
prefs.edit()
|
||||||
|
.putString(KEY_ACTIVE_TOKEN, token)
|
||||||
|
.putLong(KEY_ACTIVE_AT, System.currentTimeMillis())
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun touchActive(prefs: android.content.SharedPreferences) {
|
||||||
|
prefs.edit().putLong(KEY_ACTIVE_AT, System.currentTimeMillis()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun expireStaleActiveLocked(prefs: android.content.SharedPreferences) {
|
||||||
|
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
|
||||||
|
if (active.isEmpty()) return
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeToken(roomToken: String?, acceptUrl: String?, fallback: String): String {
|
||||||
|
extractTokenFromUrl(acceptUrl)?.let { return it }
|
||||||
|
roomToken?.trim()?.takeIf { it.isNotBlank() }?.let { return it }
|
||||||
|
return "call:${kotlin.math.abs(fallback.hashCode())}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractTokenFromUrl(url: String?): String? {
|
||||||
|
if (url.isNullOrBlank()) return null
|
||||||
|
val path = runCatching { android.net.Uri.parse(url).path }.getOrNull() ?: url
|
||||||
|
val marker = "/call/"
|
||||||
|
val idx = path.indexOf(marker)
|
||||||
|
if (idx < 0) return null
|
||||||
|
val rest = path.substring(idx + marker.length)
|
||||||
|
val end = rest.indexOfFirst { it == '/' || it == '?' || it == '#' }.let { if (it < 0) rest.length else it }
|
||||||
|
return rest.substring(0, end).ifBlank { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelNotification(context: Context) {
|
||||||
|
F7IncomingCallRinger.stop()
|
||||||
|
context.getSystemService(NotificationManager::class.java)
|
||||||
|
?.cancel(ACTIVE_NOTIFICATION_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readQueue(prefs: android.content.SharedPreferences): JSONArray {
|
||||||
|
val raw = prefs.getString(KEY_QUEUE, "[]").orEmpty()
|
||||||
|
return runCatching { JSONArray(raw) }.getOrDefault(JSONArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun containsToken(queue: JSONArray, token: String): Boolean {
|
||||||
|
for (i in 0 until queue.length()) {
|
||||||
|
runCatching {
|
||||||
|
if (token == queue.getJSONObject(i).optString("roomToken")) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeToken(queue: JSONArray, token: String): JSONArray {
|
||||||
|
val next = JSONArray()
|
||||||
|
for (i in 0 until queue.length()) {
|
||||||
|
runCatching {
|
||||||
|
val item = queue.getJSONObject(i)
|
||||||
|
if (token != item.optString("roomToken")) {
|
||||||
|
next.put(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class PendingCall(
|
||||||
|
val roomToken: String,
|
||||||
|
val title: String,
|
||||||
|
val body: String,
|
||||||
|
val acceptUrl: String?,
|
||||||
|
val displayName: String,
|
||||||
|
) {
|
||||||
|
fun toJson(): JSONObject = JSONObject().apply {
|
||||||
|
put("roomToken", roomToken)
|
||||||
|
put("title", title)
|
||||||
|
put("body", body)
|
||||||
|
put("displayName", displayName)
|
||||||
|
if (acceptUrl != null) put("acceptUrl", acceptUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromJson(o: JSONObject): PendingCall = PendingCall(
|
||||||
|
roomToken = o.getString("roomToken"),
|
||||||
|
title = o.optString("title", ""),
|
||||||
|
body = o.optString("body", ""),
|
||||||
|
acceptUrl = if (o.has("acceptUrl")) o.optString("acceptUrl") else null,
|
||||||
|
displayName = o.optString("displayName", ""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val INCOMING_CALL_ACTIVITY = "ru.forbion.f7cloud.mobile.CallIncomingActivity"
|
||||||
|
|
||||||
|
private fun incomingCallIntent(
|
||||||
|
context: Context,
|
||||||
|
call: PendingCall,
|
||||||
|
joinUrl: String,
|
||||||
|
autoAccept: Boolean,
|
||||||
|
): Intent = Intent().apply {
|
||||||
|
setClassName(context, INCOMING_CALL_ACTIVITY)
|
||||||
|
action = PushIntents.ACTION_OPEN_CALL
|
||||||
|
addFlags(
|
||||||
|
Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||||
|
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||||
|
Intent.FLAG_ACTIVITY_SINGLE_TOP,
|
||||||
|
)
|
||||||
|
putExtra(PushIntents.EXTRA_ACCEPT_URL, joinUrl)
|
||||||
|
putExtra(PushIntents.EXTRA_ROOM_TOKEN, call.roomToken)
|
||||||
|
putExtra(PushIntents.EXTRA_CALL_TITLE, call.title)
|
||||||
|
putExtra(PushIntents.EXTRA_CALL_BODY, call.body)
|
||||||
|
putExtra(PushIntents.EXTRA_ROOM_DISPLAY_NAME, call.displayName)
|
||||||
|
putExtra(PushIntents.EXTRA_AUTO_ACCEPT, autoAccept)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package ru.forbion.f7cloud.core.push
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.MediaPlayer
|
||||||
|
import android.media.RingtoneManager
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loops the default ringtone while an incoming call waits for Accept/Decline.
|
||||||
|
* Debounced per call token so duplicate FCM / notification updates do not restart audio.
|
||||||
|
*/
|
||||||
|
object F7IncomingCallRinger {
|
||||||
|
private const val TAG = "F7IncomingCallRinger"
|
||||||
|
private const val RING_PREFS = "f7_call_ring_guard"
|
||||||
|
private const val KEY_LAST_TOKEN = "last_token"
|
||||||
|
private const val KEY_LAST_AT = "last_at"
|
||||||
|
private const val RING_DEBOUNCE_MS = 90_000L
|
||||||
|
|
||||||
|
private val lock = Any()
|
||||||
|
private var player: MediaPlayer? = null
|
||||||
|
private var ringing = false
|
||||||
|
private var activeToken: String? = null
|
||||||
|
|
||||||
|
fun isPlaying(): Boolean = synchronized(lock) {
|
||||||
|
runCatching { player?.isPlaying == true }.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shouldAlert(context: Context, callToken: String): Boolean {
|
||||||
|
if (callToken.isBlank()) return false
|
||||||
|
synchronized(lock) {
|
||||||
|
if (runCatching { player?.isPlaying == true }.getOrDefault(false)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val prefs = context.applicationContext.getSharedPreferences(RING_PREFS, Context.MODE_PRIVATE)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val lastToken = prefs.getString(KEY_LAST_TOKEN, "").orEmpty()
|
||||||
|
val lastAt = prefs.getLong(KEY_LAST_AT, 0L)
|
||||||
|
if (callToken == lastToken && now - lastAt < RING_DEBOUNCE_MS) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
prefs.edit()
|
||||||
|
.putString(KEY_LAST_TOKEN, callToken)
|
||||||
|
.putLong(KEY_LAST_AT, now)
|
||||||
|
.apply()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start(context: Context, callToken: String) {
|
||||||
|
if (!shouldAlert(context, callToken)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
synchronized(lock) {
|
||||||
|
ringing = true
|
||||||
|
activeToken = callToken
|
||||||
|
stopLocked(keepFlag = true)
|
||||||
|
val uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
|
||||||
|
if (uri == null) {
|
||||||
|
Log.w(TAG, "No default ringtone URI")
|
||||||
|
stopLocked()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
runCatching {
|
||||||
|
player = MediaPlayer().apply {
|
||||||
|
setDataSource(appContext, uri)
|
||||||
|
isLooping = true
|
||||||
|
setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
setOnPreparedListener { prepared ->
|
||||||
|
synchronized(lock) {
|
||||||
|
if (!ringing) {
|
||||||
|
runCatching { prepared.release() }
|
||||||
|
return@setOnPreparedListener
|
||||||
|
}
|
||||||
|
runCatching { prepared.start() }
|
||||||
|
.onFailure {
|
||||||
|
Log.w(TAG, "Ringtone start failed", it)
|
||||||
|
stopLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOnErrorListener { _, what, extra ->
|
||||||
|
Log.w(TAG, "Ringtone error what=$what extra=$extra")
|
||||||
|
synchronized(lock) { stopLocked() }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
prepareAsync()
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
Log.w(TAG, "Ringtone prepare failed", it)
|
||||||
|
stopLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
synchronized(lock) {
|
||||||
|
stopLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopLocked(keepFlag: Boolean = false) {
|
||||||
|
if (!keepFlag) {
|
||||||
|
ringing = false
|
||||||
|
activeToken = null
|
||||||
|
}
|
||||||
|
player?.runCatching {
|
||||||
|
if (isPlaying) stop()
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
player = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package ru.forbion.f7cloud.core.push
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.RingtoneManager
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
object F7NotificationChannels {
|
||||||
|
const val MESSAGES = "f7_mobile_messages"
|
||||||
|
/** Silent channel: ringtone is played only by [F7IncomingCallRinger]. */
|
||||||
|
const val CALLS = "f7_mobile_calls_v3"
|
||||||
|
|
||||||
|
/** Channel IDs referenced in FCM payloads from f7push server (background tray). */
|
||||||
|
private const val SERVER_MESSAGES = "f7cloud_messages_v2"
|
||||||
|
private const val SERVER_CALLS = "f7cloud_calls_v2"
|
||||||
|
|
||||||
|
fun ensureAll(context: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
val audio = AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
|
.build()
|
||||||
|
val notificationSound = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
|
||||||
|
val ringtoneAudio = AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
|
.build()
|
||||||
|
val ringtone = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
|
||||||
|
|
||||||
|
fun create(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
importance: Int,
|
||||||
|
vibration: LongArray,
|
||||||
|
sound: android.net.Uri?,
|
||||||
|
soundAttrs: AudioAttributes,
|
||||||
|
) {
|
||||||
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(id, name, importance).apply {
|
||||||
|
description = name
|
||||||
|
enableLights(true)
|
||||||
|
enableVibration(true)
|
||||||
|
vibrationPattern = vibration
|
||||||
|
if (sound != null) {
|
||||||
|
setSound(sound, soundAttrs)
|
||||||
|
}
|
||||||
|
setShowBadge(true)
|
||||||
|
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val messagesName = context.getString(R.string.notification_channel_messages)
|
||||||
|
val callsName = context.getString(R.string.notification_channel_calls)
|
||||||
|
val msgVibration = longArrayOf(0, 250, 150, 250)
|
||||||
|
val callVibration = longArrayOf(0, 500, 200, 500)
|
||||||
|
|
||||||
|
create(MESSAGES, messagesName, NotificationManager.IMPORTANCE_HIGH, msgVibration, notificationSound, audio)
|
||||||
|
create(SERVER_MESSAGES, messagesName, NotificationManager.IMPORTANCE_HIGH, msgVibration, notificationSound, audio)
|
||||||
|
create(CALLS, callsName, NotificationManager.IMPORTANCE_HIGH, longArrayOf(0), null, audio)
|
||||||
|
create(SERVER_CALLS, callsName, NotificationManager.IMPORTANCE_HIGH, longArrayOf(0), null, audio)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resolveChannel(channelHint: String?, highPriority: Boolean): String {
|
||||||
|
if (channelHint == CALLS || channelHint == SERVER_CALLS) return CALLS
|
||||||
|
if (channelHint == MESSAGES || channelHint == SERVER_MESSAGES) return MESSAGES
|
||||||
|
return if (highPriority) CALLS else MESSAGES
|
||||||
|
}
|
||||||
|
}
|
||||||