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
|
||||
}
|
||||
}
|
||||