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).
This commit is contained in:
F7cloud Mobile
2026-07-07 12:05:18 +03:00
commit fd17df80a8
1789 changed files with 246889 additions and 0 deletions
+26
View File
@@ -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/
+22
View File
@@ -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).
+317
View File
@@ -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.890.5.90 | 9798 | Техподдержка, карточка контакта, повторный звонок |
| 0.5.910.5.92 | 99100 | Расширенный календарь, composer поддержки, фикс краша календаря |
| 0.5.100 | 108 | Меню «⋯» в почте с иконками |
| 0.5.1010.5.102 | 109110 | Вёрстка просмотра письма, swipe-to-dismiss |
| 0.5.1030.5.105 | 111113 | Фиксы крашей HTML-писем |
| 0.5.104 | 112 | Предзагрузка тел писем |
| 0.5.106 | 114 | Свайп при открытом sidebar |
| 0.5.110 | 118 | Параметры эл. почты |
| 0.5.1110.5.112 | 119120 | Запланированная отправка, меню приложений |
| **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`.
+113
View File
@@ -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 → ~24 с |
### Сервер
Перед релизом или при жалобах на медленную «Главную» проверьте сервер:
**[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-уведомления
+96
View File
@@ -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'
}
+28
View File
@@ -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"
}
+1
View File
@@ -0,0 +1 @@
# Project specific ProGuard rules.
+71
View File
@@ -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,
)
}
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 776 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFFFF</color>
</resources>
+15
View File
@@ -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>
+15
View File
@@ -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>
+13
View File
@@ -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'
+25
View File
@@ -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'
}
+2
View File
@@ -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 }
}
}
}
+26
View File
@@ -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')
}
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
+27
View File
@@ -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>)
}
+35
View File
@@ -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,
)
+26
View File
@@ -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)
}
}
File diff suppressed because it is too large Load Diff
@@ -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 200299. */
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
}
+28
View File
@@ -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'
}
+16
View File
@@ -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
}
}

Some files were not shown because too many files have changed in this diff Show More