20 Commits

Author SHA1 Message Date
b-dev-mobile c6732546f1 chore: версия 0.5.124 (132)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 17:53:20 +00:00
b-dev-mobile dd5bdbc177 push: 30-с таймаут входящего звонка + уведомление «Пропущенный звонок»
Не отвеченный звонок звонит 30 секунд, после чего снимается сам:
рингтон/уведомление гасятся, показывается «Пропущенный звонок»
(на локскрине — обезличенно, после разблокировки — с названием
комнаты; тап открывает комнату). Полноэкранный входящий закрывается
по тому же событию. Страховочный alarm поднимает процесс и снимает
звонок, даже если система убила приложение во время звонка.
Принятые/отклонённые звонки «Пропущенный» не показывают.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 17:47:53 +00:00
b-dev-mobile 47534ceb59 refactor: session/auth в MainViewModel (Этап 2 п.10, ч.2)
Верхнеуровневый session-жизненный цикл вынесен из AppScaffold в MainViewModel
(AndroidViewModel): держит session (StateFlow), login()=save+set,
logout()=ревокация app password + OfficeWarmup.clear + WebViewPool.dispose +
AuthStore.clear. AppScaffold читает vm.session.collectAsState(), forceLogout=
{vm.logout()}. Бонус: сессия переживает пересоздание Activity (поворот) —
раньше жила в remember и перечитывалась из AuthStore. UI-тумблеры (menuOpen,
sidebar/settings-флаги, pending* deep-link) осознанно оставлены в composable —
это чистое UI-состояние, не бизнес-логика.
2026-07-09 15:02:21 +00:00
b-dev-mobile 8c90e83cef refactor: LoginScreen вынесен из AppScaffold в свой файл (Этап 2 п.10, ч.1)
LoginScreen (~234 строки) был вложен в файл-«бог» AppScaffold. Вынесен в
LoginScreen.kt (internal, тот же пакет) — чистый перенос, поведение не
менялось. AppScaffold: 925→691 строк, убрано 22 импорта, использовавшихся
только в LoginScreen. Первый безопасный слой разбора AppScaffold.
2026-07-09 14:42:55 +00:00
b-dev-mobile a674721353 ux: единый экран ошибки F7ErrorState + retry (Этап 2 п.12)
Новый F7ErrorState: центрированная иконка + сообщение + «Повторить»
вместо россыпи красного текста. F7ModuleScreen при ошибке без данных
показывает его (+ новый onErrorRetry — retry без хедер-кнопки ↻).
Подключено: Контакты/Конференции/Карточки/Поддержка (через F7ModuleScreen),
Файлы (onErrorRetry=refreshCurrent), Почта (пустой список + ошибка →
F7ErrorState, убран верхний баннер-текст). Retry везде дергает load/refresh.
2026-07-09 14:08:36 +00:00
b-dev-mobile 8f7eaff611 ux: SplashScreen на холодном старте (Этап 2 п.12)
androidx core-splashscreen 1.0.1: тема Theme.F7.Splash (фон #FBFBFB как
F7Colors.Background + иконка ic_launcher), postSplashScreenTheme — прежняя
тема приложения. installSplashScreen() в MainActivity.onCreate до super.
Вместо голого белого экрана при запуске.
2026-07-09 13:13:01 +00:00
b-dev-mobile 83e071673c talk-native: убран runBlocking со старта и пути звонка (аудит C4/п.7)
TalkVendorUserSync.ensureUser → suspend(IO). Прогрев на старте приложения —
в фоновом scope (раньше блокировал main в Application.onCreate, вкл. чтение
AuthStore/Keystore). Путь звонка: подготовка vendor-юзера в корутине (IO),
запуск CallActivity на main — убран ANR-риск на входящем звонке.
Поведение то же: при неготовности — тот же тост, фолбэк WebView сохранён.
2026-07-09 12:58:55 +00:00
b-dev-mobile d1d61d0cf8 ux: pull-to-refresh на всех списках (Этап 2 п.12)
Раньше свайп-обновление было только в Файлах. Добавлено (Material3
PullToRefreshBox): Почта (входящие/исходящие, force-обновление), Конференции
(список бесед — заменяет убранную кнопку ↻), Контакты (force + CTag — дёшево),
Задачи (список), Карточки (доски и открытая доска). Контакты: refresh()
получил параметр force (pull-to-refresh форсит sync мимо TTL; с CTag-проверкой
это 1 лёгкий PROPFIND).
2026-07-09 12:48:08 +00:00
b-dev-mobile 286ab9c8bf gitignore: dist/ (локальные архивы исходников для релизов) 2026-07-09 09:41:13 +00:00
b-dev-mobile b03cb09a6c changelog: календарь — кэш событий по CTag 2026-07-09 09:40:28 +00:00
b-dev-mobile fb57156ee9 calendar: кэш событий по CTag (Этап 2, DAV ч.2)
listCalendars теперь забирает и cs:getctag (тот же PROPFIND — ноль лишних
запросов; DavCalendar.ctag). Новый CalendarEventsCache — дисковый кэш
СЫРЫХ REPORT-ответов per (аккаунт, календарь, диапазон), ключ — CTag:
не менялся → события парсятся с диска (боевым парсером CalDavClient),
REPORT-запросы в сеть не идут. Изменился → сеть + обновление кэша.
Хранение сырого XML вместо сериализации моделей — без хрупкого
JSON-маппинга ~20 полей события; чистка записей старше 30 дней.
CalDavClient.queryEventsInRange разделён на queryEventsRawXml +
parseEventsXml (поведение прежнее). Эффект: переключение месяцев,
периодический рефреш и возврат в календарь при неизменных календарях —
1 PROPFIND вместо 1+N REPORT. +5 unit-тестов кэша (JVM, TemporaryFolder).
2026-07-09 09:39:07 +00:00
b-dev-mobile 4c403e5ec2 changelog: контакты — инкрементальная синхронизация по CTag 2026-07-09 06:48:19 +00:00
b-dev-mobile 00daa980a8 contacts: инкрементальная синхронизация по CTag (Этап 2, DAV)
CardDavClient.collectionSignature() — дешёвый PROPFIND depth:1 за CTag книг
(расширение CalendarServer, Nextcloud; фолбэк sync-token), без address-data.
ContactsRepository.syncContacts: перед полной закачкой всех vCard сверяет
подпись коллекций; не менялось → отдаём кэш (Room), не качаем. Ускоряет
pull-to-refresh (раньше — полный PROPFIND ~500 vCard каждый раз).
+4 unit-теста на parseCollectionSignature (regex-парсер, без Android XmlPull).
2026-07-09 06:44:49 +00:00
b-dev-mobile 2709dba03f ci: паркуем CI до Docker-режима (host-исполнение act_runner 0.2.13 сломано)
Диагноз: диспетчеризация Gitea работает, но host-исполнение act_runner
роняет шаги (No such file / MODULE_NOT_FOUND, задвоение hostexecutor/.cache).
Workflow → workflow_dispatch (не auto-fail на push), раннер остановлен.
Рабочий путь на будущее — Docker-образ с JDK+SDK + gradle-кэш (docs/CI.md).
Сборки/релизы пока вручную (v0.5.123 опубликован).
2026-07-09 04:56:43 +00:00
b-dev-mobile c53885b230 ci: обход бага host-режима act_runner — checkout обычным git (без JS-экшенов)
CI / verify (push) Failing after 1s
2026-07-09 04:50:55 +00:00
b-dev-mobile a696d45f2e ci: тест после чистой регистрации раннера
CI / verify (push) Failing after 3s
CI / release (push) Has been skipped
2026-07-09 04:19:35 +00:00
b-dev-mobile 2ae03264ff ci: восстановлен runs-on [self-hosted, f7-android] (метка не была причиной)
CI / verify (push) Failing after 4s
CI / release (push) Has been skipped
2026-07-09 04:12:34 +00:00
b-dev-mobile 47c274e441 ci: тест — runs-on одна метка f7-android
CI / verify (push) Failing after 3s
CI / release (push) Has been skipped
2026-07-09 04:10:19 +00:00
b-dev-mobile 34bb16c59d ci: тест триггера после апгрейда раннера 0.2.13
CI / verify (push) Failing after 4s
CI / release (push) Has been skipped
2026-07-09 04:06:54 +00:00
b-dev-mobile 0fcc575e76 ci: пустой коммит для проверки триггера Actions
CI / verify (push) Failing after 3s
CI / release (push) Has been skipped
2026-07-09 03:55:57 +00:00
36 changed files with 1106 additions and 401 deletions
+17 -9
View File
@@ -1,14 +1,22 @@
name: CI name: CI
# Активируется, когда на инфре B зарегистрирован self-hosted act_runner с меткой `f7-android` # ⏸️ ПАРКОВКА (2026-07-09): CI отложен. Раннер на инфре B развёрнут, но
# (JDK 21 + Android SDK 36 — как на билд-машине). Пока раннера нет — джобы ждут исполнителя. # host-исполнение act_runner 0.2.13 в нашем окружении сломано (криво резолвит
# Регистрация раннера — инфра-шаг ячейки B (см. docs/CI.md). # пути к шагам/JS-экшенам: MODULE_NOT_FOUND / "No such file", задвоение
# hostexecutor/.cache/act). Рабочий путь — Docker-исполнение с кастомным образом
# (JDK 21 + Android SDK 36) и volume для ~/.gradle. До этого — сборки/релизы вручную
# на билд-машине (см. docs/CI.md).
#
# Триггер временно ручной (workflow_dispatch), чтобы push'и не создавали падающие
# прогоны. Когда настроим Docker-раннер — вернуть блок on: push/pull_request ниже.
#
# on:
# push:
# branches: [main]
# tags: ['v*']
# pull_request:
on: on:
push: workflow_dispatch:
branches: [main]
tags: ['v*']
pull_request:
env: env:
JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64 JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64
@@ -17,7 +25,7 @@ env:
jobs: jobs:
verify: verify:
# lint + unit-тесты + debug-сборка на каждый push в main и на PR # detekt + unit-тесты + Android Lint + debug-сборка на каждый push в main и на PR
runs-on: [self-hosted, f7-android] runs-on: [self-hosted, f7-android]
if: github.ref_type != 'tag' if: github.ref_type != 'tag'
steps: steps:
+1
View File
@@ -24,3 +24,4 @@
*.log *.log
*.tmp *.tmp
.cxx/ .cxx/
dist/
+4
View File
@@ -2,6 +2,10 @@
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск` Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
- 2026-07-09 | (в разработке) | Этап 2 (DAV, ч.2 — календарь): кэш событий по CTag. `listCalendars` дополнительно забирает `cs:getctag` (тот же PROPFIND — лишних запросов нет). Новый `CalendarEventsCache` — дисковый кэш сырых REPORT-ответов per (аккаунт, календарь, диапазон) с ключом-CTag: календарь не менялся → события парсятся с диска, REPORT в сеть не идёт; менялся → сеть + обновление кэша (+чистка записей >30 дней). Сырой XML вместо сериализации моделей — переиспользуется боевой парсер. Эффект: переключение месяцев/периодический рефреш/возврат в календарь при неизменных данных — 1 PROPFIND вместо 1+N REPORT. +5 unit-тестов | контракты не менялись | низкий: при любом изменении CTag меняется — данные всегда свежие
- 2026-07-09 | (в разработке) | Этап 2 (DAV, ч.1 — контакты): инкрементальная синхронизация по CTag. `CardDavClient.collectionSignature()` — дешёвый PROPFIND depth:1 за CTag адресных книг (расширение CalendarServer, поддерживается Nextcloud; фолбэк на sync-token), без address-data. `ContactsRepository.syncContacts` перед полной закачкой всех vCard сверяет подпись коллекций: не менялось → отдаём кэш (Room), сеть не грузим. Ускоряет pull-to-refresh (было — полный PROPFIND ~500 vCard каждый раз). +4 unit-теста (regex-парсер подписи, без Android XmlPull). Дальше по Этапу 2: кэш событий календаря + CTag (сейчас экран календаря тянет события живьём по диапазону — под него сначала нужен кэш) | контракты не менялись | низкий: только оптимизация, поведение то же при изменениях
- 2026-07-09 | v0.5.123 (131) | Первый тегированный релиз в Gitea (тег `v0.5.123` + Release с APK и архивом исходников GPL). Включает хвосты этапов 0/1: FLAG_SECURE на экранах входа/PIN (аудит п.3, коммит cfa9fe2) + активированный CI-раннер на инфре B (docs/CI). Функциональных изменений в приложении относительно v0.5.122 нет, кроме FLAG_SECURE | контракты не менялись | низкий - 2026-07-09 | v0.5.123 (131) | Первый тегированный релиз в Gitea (тег `v0.5.123` + Release с APK и архивом исходников GPL). Включает хвосты этапов 0/1: FLAG_SECURE на экранах входа/PIN (аудит п.3, коммит cfa9fe2) + активированный CI-раннер на инфре B (docs/CI). Функциональных изменений в приложении относительно v0.5.122 нет, кроме FLAG_SECURE | контракты не менялись | низкий
- 2026-07-08 | v0.5.122 (130) | Убрана кнопка «чаты» из нижней панели (слева теперь всегда «назад», единый layout во всех разделах, включая Конференции) + убрана кнопка обновления ↻ сверху справа в Конференциях (onRefresh у F7ModuleScreen; список грузится при открытии/по push) | контракты не менялись | низкий: только UI - 2026-07-08 | v0.5.122 (130) | Убрана кнопка «чаты» из нижней панели (слева теперь всегда «назад», единый layout во всех разделах, включая Конференции) + убрана кнопка обновления ↻ сверху справа в Конференциях (onRefresh у F7ModuleScreen; список грузится при открытии/по push) | контракты не менялись | низкий: только UI
+3 -2
View File
@@ -39,8 +39,8 @@ android {
applicationId 'ru.forbion.f7cloud.mobile' applicationId 'ru.forbion.f7cloud.mobile'
minSdk 26 minSdk 26
targetSdk 36 targetSdk 36
versionCode 131 versionCode 132
versionName '0.5.123' versionName '0.5.124'
missingDimensionStrategy 'default', 'f7' missingDimensionStrategy 'default', 'f7'
multiDexEnabled true multiDexEnabled true
@@ -128,6 +128,7 @@ dependencies {
implementation libs.firebase.messaging implementation libs.firebase.messaging
implementation libs.coroutines.play.services implementation libs.coroutines.play.services
implementation libs.multidex implementation libs.multidex
implementation libs.core.splashscreen
implementation libs.biometric implementation libs.biometric
implementation libs.fragment.ktx implementation libs.fragment.ktx
implementation libs.camera.camera2 implementation libs.camera.camera2
+1
View File
@@ -37,6 +37,7 @@
android:resource="@mipmap/ic_launcher" /> android:resource="@mipmap/ic_launcher" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:theme="@style/Theme.F7.Splash"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -25,6 +25,8 @@ import ru.forbion.f7cloud.feature.talknative.TalkNativeCallLauncher
*/ */
class CallIncomingActivity : ComponentActivity() { class CallIncomingActivity : ComponentActivity() {
private var callEndedListener: ((String) -> Unit)? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
@@ -55,6 +57,20 @@ class CallIncomingActivity : ComponentActivity() {
finish() finish()
} }
// Звонок сняли извне (30-с таймаут «не берут трубку», принят/отклонён из
// уведомления) — закрываем полноэкранный входящий.
launch.roomToken?.takeIf { it.isNotBlank() }?.let { token ->
val listener: (String) -> Unit = { ended ->
if (ended == token) {
runOnUiThread {
if (!isFinishing) finish()
}
}
}
callEndedListener = listener
F7IncomingCallQueue.addCallEndedListener(listener)
}
setContent { setContent {
F7Theme { F7Theme {
IncomingCallScreen( IncomingCallScreen(
@@ -74,6 +90,12 @@ class CallIncomingActivity : ComponentActivity() {
} }
} }
override fun onDestroy() {
callEndedListener?.let(F7IncomingCallQueue::removeCallEndedListener)
callEndedListener = null
super.onDestroy()
}
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
@@ -6,6 +6,7 @@ import android.util.Log
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -43,6 +44,8 @@ class MainActivity : FragmentActivity() {
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
// Системный сплэш (фон + иконка) на холодном старте вместо белого экрана
installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
if (!handleIncomingIntent(intent)) { if (!handleIncomingIntent(intent)) {
@@ -18,27 +18,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text 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.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -53,12 +38,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -68,34 +50,26 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await import kotlinx.coroutines.tasks.await
import ru.forbion.f7cloud.mobile.BuildConfig
import ru.forbion.f7cloud.core.auth.AppLockStore import ru.forbion.f7cloud.core.auth.AppLockStore
import ru.forbion.f7cloud.core.auth.AppPasswordRevoker
import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.AuthStore import androidx.compose.runtime.collectAsState
import ru.forbion.f7cloud.core.auth.AuthVerifier import androidx.lifecycle.viewmodel.compose.viewModel
import ru.forbion.f7cloud.core.auth.normalizeServerUrl
import ru.forbion.f7cloud.core.network.LoginFlowClient import ru.forbion.f7cloud.core.network.LoginFlowClient
import ru.forbion.f7cloud.core.push.F7PushEvent import ru.forbion.f7cloud.core.push.F7PushEvent
import ru.forbion.f7cloud.core.push.F7PushEventHub import ru.forbion.f7cloud.core.push.F7PushEventHub
import ru.forbion.f7cloud.core.push.F7PushRegistrar import ru.forbion.f7cloud.core.push.F7PushRegistrar
import ru.forbion.f7cloud.core.designsystem.F7OverlayNavigationProvider import ru.forbion.f7cloud.core.designsystem.F7OverlayNavigationProvider
import ru.forbion.f7cloud.core.designsystem.F7AppMenuSheet import ru.forbion.f7cloud.core.designsystem.F7AppMenuSheet
import ru.forbion.f7cloud.core.designsystem.F7SecureScreen
import ru.forbion.f7cloud.core.designsystem.F7AppScaffold import ru.forbion.f7cloud.core.designsystem.F7AppScaffold
import ru.forbion.f7cloud.core.designsystem.F7AutoHideBottomBar import ru.forbion.f7cloud.core.designsystem.F7AutoHideBottomBar
import ru.forbion.f7cloud.core.designsystem.F7BottomBarActions import ru.forbion.f7cloud.core.designsystem.F7BottomBarActions
import ru.forbion.f7cloud.core.designsystem.F7BottomBarConfig import ru.forbion.f7cloud.core.designsystem.F7BottomBarConfig
import ru.forbion.f7cloud.core.designsystem.F7MobileBottomBar import ru.forbion.f7cloud.core.designsystem.F7MobileBottomBar
import ru.forbion.f7cloud.core.designsystem.F7Colors 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.F7Theme
import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets
import ru.forbion.f7cloud.mobile.OfficeEditorActivity import ru.forbion.f7cloud.mobile.OfficeEditorActivity
import ru.forbion.f7cloud.mobile.qr.F7QrScannerActivity import ru.forbion.f7cloud.mobile.qr.F7QrScannerActivity
import ru.forbion.f7cloud.mobile.OfficeWebViewPool
import ru.forbion.f7cloud.feature.files.OfficeWarmup import ru.forbion.f7cloud.feature.files.OfficeWarmup
import ru.forbion.f7cloud.mobile.permissions.F7AppPermissions import ru.forbion.f7cloud.mobile.permissions.F7AppPermissions
import ru.forbion.f7cloud.mobile.permissions.F7PermissionRationaleDialog import ru.forbion.f7cloud.mobile.permissions.F7PermissionRationaleDialog
@@ -117,8 +91,8 @@ fun AppScaffold(
onRequestRuntimePermissions: (onFinished: (() -> Unit)?) -> Unit = {}, onRequestRuntimePermissions: (onFinished: (() -> Unit)?) -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
val authStore = remember { AuthStore(context) } val mainVm: MainViewModel = viewModel()
var session by remember { mutableStateOf(authStore.load()) } val session by mainVm.session.collectAsState()
var activeTab by rememberSaveable( var activeTab by rememberSaveable(
saver = Saver( saver = Saver(
save = { state -> state.value.name }, save = { state -> state.value.name },
@@ -173,17 +147,7 @@ fun AppScaffold(
context.applicationContext.getSharedPreferences("f7_permissions", android.content.Context.MODE_PRIVATE) context.applicationContext.getSharedPreferences("f7_permissions", android.content.Context.MODE_PRIVATE)
} }
val logoutScope = rememberCoroutineScope() val forceLogout = { mainVm.logout() }
val forceLogout = {
// Best-effort ревокация app password на сервере ДО очистки локальной сессии.
session?.let { current ->
logoutScope.launch { AppPasswordRevoker.revoke(current) }
}
OfficeWarmup.clear()
OfficeWebViewPool.dispose()
authStore.clear()
session = null
}
LaunchedEffect(session?.serverUrl, session?.username) { LaunchedEffect(session?.serverUrl, session?.username) {
session?.let { OfficeWarmup.warm(it) } session?.let { OfficeWarmup.warm(it) }
} }
@@ -196,10 +160,7 @@ fun AppScaffold(
F7Theme { F7Theme {
if (session == null) { if (session == null) {
LoginScreen(onLogin = { LoginScreen(onLogin = { mainVm.login(it) })
authStore.save(it)
session = it
})
return@F7Theme return@F7Theme
} }
@@ -688,237 +649,3 @@ fun AppScaffold(
} }
private const val PERMISSIONS_PROMPTED_KEY = "runtime_permissions_prompted_v2" private const val PERMISSIONS_PROMPTED_KEY = "runtime_permissions_prompted_v2"
@Composable
private fun LoginScreen(onLogin: (AuthSession) -> Unit) {
F7SecureScreen() // ввод пароля — не в скриншотах/recents
val context = LocalContext.current
val scope = rememberCoroutineScope()
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val serverFocus = remember { FocusRequester() }
val usernameFocus = remember { FocusRequester() }
val passwordFocus = remember { FocusRequester() }
var serverUrl by rememberSaveable { mutableStateOf(BuildConfig.DEFAULT_SERVER_URL) }
var username by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var trustAllCerts by rememberSaveable { mutableStateOf(false) }
var loading by rememberSaveable { mutableStateOf(false) }
var error by rememberSaveable { mutableStateOf<String?>(null) }
val qrLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val qrData = result.data?.getStringExtra(F7QrScannerActivity.RESULT_EXTRA)
?: return@rememberLauncherForActivityResult
scope.launch {
loading = true
error = null
val loginResult = LoginFlowClient.completeQrLogin(qrData, trustAllCerts)
if (loginResult == null) {
error = if (qrData.contains("/login/v2/flow/")) {
"Для входа в браузер откройте профиль в приложении и выберите «Сканировать QR браузера»"
} else {
"Не удалось распознать QR-код"
}
loading = false
return@launch
}
val newSession = AuthSession(
serverUrl = normalizeServerUrl(loginResult.serverUrl),
username = loginResult.username,
appPassword = loginResult.appPassword,
trustAllCerts = trustAllCerts,
)
AuthVerifier.verify(newSession)
.onSuccess { verified -> onLogin(verified) }
.onFailure { error = it.message ?: "Ошибка входа по QR" }
loading = false
}
}
val cameraPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
qrLauncher.launch(F7QrScannerActivity.intent(context))
}
}
fun launchQrScan() {
if (androidx.core.content.ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.CAMERA,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
) {
qrLauncher.launch(F7QrScannerActivity.intent(context))
} else {
cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA)
}
}
fun submitLogin() {
if (loading || serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
return
}
val newSession = AuthSession(
serverUrl = normalizeServerUrl(serverUrl),
username = username.trim(),
appPassword = password,
trustAllCerts = trustAllCerts,
)
scope.launch {
loading = true
error = null
AuthVerifier.verify(newSession)
.onSuccess { verified -> onLogin(verified) }
.onFailure { error = it.message ?: "Ошибка входа" }
loading = false
}
}
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()
},
)
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,295 @@
package ru.forbion.f7cloud.mobile.ui
import android.app.Activity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.AuthVerifier
import ru.forbion.f7cloud.core.auth.normalizeServerUrl
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
import ru.forbion.f7cloud.core.designsystem.F7SecureScreen
import ru.forbion.f7cloud.core.designsystem.F7TextButton
import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets
import ru.forbion.f7cloud.core.network.LoginFlowClient
import ru.forbion.f7cloud.mobile.BuildConfig
import ru.forbion.f7cloud.mobile.qr.F7QrScannerActivity
@Composable
internal fun LoginScreen(onLogin: (AuthSession) -> Unit) {
F7SecureScreen() // ввод пароля — не в скриншотах/recents
val context = LocalContext.current
val scope = rememberCoroutineScope()
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val serverFocus = remember { FocusRequester() }
val usernameFocus = remember { FocusRequester() }
val passwordFocus = remember { FocusRequester() }
var serverUrl by rememberSaveable { mutableStateOf(BuildConfig.DEFAULT_SERVER_URL) }
var username by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var trustAllCerts by rememberSaveable { mutableStateOf(false) }
var loading by rememberSaveable { mutableStateOf(false) }
var error by rememberSaveable { mutableStateOf<String?>(null) }
val qrLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val qrData = result.data?.getStringExtra(F7QrScannerActivity.RESULT_EXTRA)
?: return@rememberLauncherForActivityResult
scope.launch {
loading = true
error = null
val loginResult = LoginFlowClient.completeQrLogin(qrData, trustAllCerts)
if (loginResult == null) {
error = if (qrData.contains("/login/v2/flow/")) {
"Для входа в браузер откройте профиль в приложении и выберите «Сканировать QR браузера»"
} else {
"Не удалось распознать QR-код"
}
loading = false
return@launch
}
val newSession = AuthSession(
serverUrl = normalizeServerUrl(loginResult.serverUrl),
username = loginResult.username,
appPassword = loginResult.appPassword,
trustAllCerts = trustAllCerts,
)
AuthVerifier.verify(newSession)
.onSuccess { verified -> onLogin(verified) }
.onFailure { error = it.message ?: "Ошибка входа по QR" }
loading = false
}
}
val cameraPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
qrLauncher.launch(F7QrScannerActivity.intent(context))
}
}
fun launchQrScan() {
if (androidx.core.content.ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.CAMERA,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
) {
qrLauncher.launch(F7QrScannerActivity.intent(context))
} else {
cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA)
}
}
fun submitLogin() {
if (loading || serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
return
}
val newSession = AuthSession(
serverUrl = normalizeServerUrl(serverUrl),
username = username.trim(),
appPassword = password,
trustAllCerts = trustAllCerts,
)
scope.launch {
loading = true
error = null
AuthVerifier.verify(newSession)
.onSuccess { verified -> onLogin(verified) }
.onFailure { error = it.message ?: "Ошибка входа" }
loading = false
}
}
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()
},
)
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,41 @@
package ru.forbion.f7cloud.mobile.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AppPasswordRevoker
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.AuthStore
import ru.forbion.f7cloud.feature.files.OfficeWarmup
import ru.forbion.f7cloud.mobile.OfficeWebViewPool
/**
* Держит session/auth-жизненный цикл верхнего уровня (вынесено из AppScaffold).
* В ViewModel сессия переживает пересоздание Activity (поворот и т.п.) — раньше
* жила в remember и перечитывалась из AuthStore.
*/
class MainViewModel(app: Application) : AndroidViewModel(app) {
private val authStore = AuthStore(app)
private val _session = MutableStateFlow(authStore.load())
val session: StateFlow<AuthSession?> = _session.asStateFlow()
fun login(newSession: AuthSession) {
authStore.save(newSession)
_session.value = newSession
}
fun logout() {
// Best-effort ревокация app password на сервере ДО очистки локальной сессии.
_session.value?.let { current ->
viewModelScope.launch { runCatching { AppPasswordRevoker.revoke(current) } }
}
OfficeWarmup.clear()
OfficeWebViewPool.dispose()
authStore.clear()
_session.value = null
}
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Сплэш при холодном старте (androidx core-splashscreen): фон в цвет
F7Colors.Background + иконка приложения; после — прежняя тема. -->
<style name="Theme.F7.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">#FBFBFB</item>
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher</item>
<item name="postSplashScreenTheme">@android:style/Theme.Material.Light.NoActionBar</item>
</style>
</resources>
@@ -16,8 +16,11 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding 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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -43,11 +46,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow 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 androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
/** /**
@@ -69,6 +75,46 @@ fun F7ScreenBackground(modifier: Modifier = Modifier, content: @Composable () ->
} }
} }
/**
* Единый экран ошибки (по центру): иконка + сообщение + кнопка «Повторить».
* Используется везде, где загрузка упала и данных нет — вместо россыпи красного текста.
*/
@Composable
fun F7ErrorState(
message: String,
modifier: Modifier = Modifier,
onRetry: (() -> Unit)? = null,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(F7Colors.Error.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
Text("!", color = F7Colors.Error, fontSize = 30.sp, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.height(12.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
textAlign = TextAlign.Center,
)
if (onRetry != null) {
Spacer(Modifier.height(16.dp))
F7SecondaryButton(text = "Повторить", onClick = onRetry)
}
}
}
@Composable @Composable
fun F7ModuleScreen( fun F7ModuleScreen(
title: String? = null, title: String? = null,
@@ -76,6 +122,9 @@ fun F7ModuleScreen(
loading: Boolean = false, loading: Boolean = false,
error: String? = null, error: String? = null,
onRefresh: (() -> Unit)? = null, onRefresh: (() -> Unit)? = null,
// Действие «Повторить» в экране ошибки; по умолчанию — как onRefresh.
// Позволяет дать retry, не показывая хедер-кнопку ↻ (когда onRefresh не задан).
onErrorRetry: (() -> Unit)? = onRefresh,
headerActions: @Composable RowScope.() -> Unit = {}, headerActions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
@@ -126,8 +175,15 @@ fun F7ModuleScreen(
CircularProgressIndicator(color = F7Colors.Primary) CircularProgressIndicator(color = F7Colors.Primary)
} }
if (!error.isNullOrBlank()) { if (!error.isNullOrBlank()) {
Text(text = error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium) // Ошибка без данных → центрированный экран с «Повторить»
} F7ErrorState(
message = error,
onRetry = onErrorRetry,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
)
} else {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -136,6 +192,7 @@ fun F7ModuleScreen(
) )
} }
} }
}
@Composable @Composable
fun F7PrimaryButton( fun F7PrimaryButton(
@@ -22,6 +22,9 @@ data class DavCalendar(
val href: String, val href: String,
val displayName: String, val displayName: String,
val color: String? = null, val color: String? = null,
// CTag коллекции (CalendarServer-расширение, Nextcloud поддерживает):
// меняется при любом изменении в календаре → ключ инкрементального кэша событий.
val ctag: String = "",
) )
data class DavEvent( data class DavEvent(
@@ -103,7 +106,7 @@ object CalDavClient {
val body = """ val body = """
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/"> <d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/">
<d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/></d:prop> <d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/><cs:getctag/></d:prop>
</d:propfind> </d:propfind>
""".trimIndent() """.trimIndent()
val xml = propfind(client, baseUrl, depth = 1, body) val xml = propfind(client, baseUrl, depth = 1, body)
@@ -129,7 +132,15 @@ object CalDavClient {
calendar: DavCalendar, calendar: DavCalendar,
rangeStart: Instant, rangeStart: Instant,
rangeEnd: Instant, rangeEnd: Instant,
): List<DavEvent> { ): List<DavEvent> = parseEventsXml(queryEventsRawXml(client, calendar, rangeStart, rangeEnd), calendar)
/** Сырой REPORT-ответ (для кэширования на диске с ключом по CTag). */
fun queryEventsRawXml(
client: OkHttpClient,
calendar: DavCalendar,
rangeStart: Instant,
rangeEnd: Instant,
): String {
val startStr = formatCalDavTime(rangeStart) val startStr = formatCalDavTime(rangeStart)
val endStr = formatCalDavTime(rangeEnd) val endStr = formatCalDavTime(rangeEnd)
val body = """ val body = """
@@ -146,10 +157,13 @@ object CalDavClient {
</c:calendar-query> </c:calendar-query>
""".trimIndent() """.trimIndent()
val href = calendar.href.trimEnd('/') + "/" val href = calendar.href.trimEnd('/') + "/"
val xml = report(client, href, body) return report(client, href, body)
return parseCalendarQueryResponses(xml, calendar)
} }
/** Парсинг REPORT-ответа (в т.ч. взятого из кэша). */
fun parseEventsXml(xml: String, calendar: DavCalendar): List<DavEvent> =
parseCalendarQueryResponses(xml, calendar)
fun createEvent( fun createEvent(
client: OkHttpClient, client: OkHttpClient,
calendar: DavCalendar, calendar: DavCalendar,
@@ -782,6 +796,7 @@ object CalDavClient {
var href = "" var href = ""
var displayName = "" var displayName = ""
var calendarColor: String? = null var calendarColor: String? = null
var ctag = ""
var isCollection = false var isCollection = false
while (parser.eventType != XmlPullParser.END_DOCUMENT) { while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) { when (parser.eventType) {
@@ -791,11 +806,13 @@ object CalDavClient {
href = "" href = ""
displayName = "" displayName = ""
calendarColor = null calendarColor = null
ctag = ""
isCollection = false isCollection = false
} }
"collection" -> if (inResponse) isCollection = true "collection" -> if (inResponse) isCollection = true
"displayname" -> if (inResponse) displayName = parser.readText().trim() "displayname" -> if (inResponse) displayName = parser.readText().trim()
"calendar-color" -> if (inResponse) calendarColor = parser.readText().trim() "calendar-color" -> if (inResponse) calendarColor = parser.readText().trim()
"getctag" -> if (inResponse) ctag = parser.readText().trim()
"href" -> if (inResponse) href = parser.readText().trim() "href" -> if (inResponse) href = parser.readText().trim()
} }
XmlPullParser.END_TAG -> if (parser.localTag() == "response" && inResponse) { XmlPullParser.END_TAG -> if (parser.localTag() == "response" && inResponse) {
@@ -809,7 +826,7 @@ object CalDavClient {
val name = displayName.ifBlank { val name = displayName.ifBlank {
fullPath.removePrefix(basePath).trim('/').substringAfterLast('/') fullPath.removePrefix(basePath).trim('/').substringAfterLast('/')
} }
out += DavCalendar(href = full, displayName = name, color = calendarColor) out += DavCalendar(href = full, displayName = name, color = calendarColor, ctag = ctag)
} }
} }
inResponse = false inResponse = false
@@ -69,6 +69,53 @@ object CardDavClient {
return out.distinctBy { "${it.uid}|${it.email}" } return out.distinctBy { "${it.uid}|${it.email}" }
} }
/**
* Дешёвая «подпись» состояния адресных книг CTag коллекций (расширение
* CalendarServer, поддерживается Nextcloud), с фолбэком на sync-token.
* Один PROPFIND depth:1 без address-data. Если подпись не изменилась с прошлой
* синхронизации контакты качать не нужно. null при ошибке/неподдержке полный sync.
*/
fun collectionSignature(
client: OkHttpClient,
serverUrl: String,
userId: String,
): String? = runCatching {
val base = davAddressBooksBaseUrl(serverUrl, userId)
val body = """
<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">
<d:prop><cs:getctag/><d:sync-token/></d:prop>
</d:propfind>
""".trimIndent()
val xml = propfind(client, base, depth = 1, body)
parseCollectionSignature(xml).ifBlank { null }
}.getOrNull()
/**
* Парсит PROPFIND-ответ в стабильную подпись «href=ctag» (по книгам, отсортировано).
* Regex-парсинг (как ICS в проекте) чистая функция, тестируется без Android XmlPull.
*/
internal fun parseCollectionSignature(xml: String): String {
val entries = sortedSetOf<String>()
for (m in responseBlockPattern.findAll(xml)) {
val block = m.value
val href = hrefTagPattern.find(block)?.groupValues?.get(1)?.trim().orEmpty()
val tag = (
getctagPattern.find(block)?.groupValues?.get(1)
?: syncTokenPattern.find(block)?.groupValues?.get(1)
)?.trim().orEmpty()
if (href.isNotBlank() && tag.isNotBlank()) {
entries += "$href=$tag"
}
}
return entries.joinToString("\n")
}
private val responseBlockPattern = Regex("(?is)<(?:\\w+:)?response\\b.*?</(?:\\w+:)?response>")
private val hrefTagPattern = Regex("(?is)<(?:\\w+:)?href>(.*?)</(?:\\w+:)?href>")
private val getctagPattern = Regex("(?is)<(?:\\w+:)?getctag>(.*?)</(?:\\w+:)?getctag>")
private val syncTokenPattern = Regex("(?is)<(?:\\w+:)?sync-token>(.*?)</(?:\\w+:)?sync-token>")
fun createContact( fun createContact(
client: OkHttpClient, client: OkHttpClient,
serverUrl: String, serverUrl: String,
@@ -0,0 +1,56 @@
package ru.forbion.f7cloud.core.network
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Тесты подписи коллекций CardDAV (CTag/sync-token) основа инкрементальной
* синхронизации: если подпись не изменилась, контакты не перекачиваются.
*/
class CardDavSignatureTest {
private val ns = """xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/""""
private fun body(vararg responses: String) =
"""<?xml version="1.0"?><d:multistatus $ns>${responses.joinToString("")}</d:multistatus>"""
private fun resp(href: String, ctag: String? = null, token: String? = null) = """
<d:response>
<d:href>$href</d:href>
<d:propstat><d:prop>
${ctag?.let { "<cs:getctag>$it</cs:getctag>" } ?: ""}
${token?.let { "<d:sync-token>$it</d:sync-token>" } ?: ""}
</d:prop></d:propstat>
</d:response>
""".trimIndent()
@Test fun ctag_signature_is_stable_and_sorted() {
val a = CardDavClient.parseCollectionSignature(
body(resp("/dav/addressbooks/u/b1/", ctag = "111"), resp("/dav/addressbooks/u/b2/", ctag = "222")),
)
// порядок ответов не влияет на подпись
val b = CardDavClient.parseCollectionSignature(
body(resp("/dav/addressbooks/u/b2/", ctag = "222"), resp("/dav/addressbooks/u/b1/", ctag = "111")),
)
assertEquals(a, b)
assertTrue(a.contains("/dav/addressbooks/u/b1/=111"))
assertTrue(a.contains("/dav/addressbooks/u/b2/=222"))
}
@Test fun ctag_change_changes_signature() {
val before = CardDavClient.parseCollectionSignature(body(resp("/b1/", ctag = "111")))
val after = CardDavClient.parseCollectionSignature(body(resp("/b1/", ctag = "999")))
assertNotEquals(before, after)
}
@Test fun sync_token_fallback_when_no_ctag() {
val sig = CardDavClient.parseCollectionSignature(body(resp("/b1/", token = "http://sabre/sync/42")))
assertEquals("/b1/=http://sabre/sync/42", sig)
}
@Test fun empty_when_no_tags() {
assertEquals("", CardDavClient.parseCollectionSignature(body(resp("/b1/"))))
}
}
@@ -4,16 +4,19 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
/** Handles Decline on incoming Talk call notifications. */ /** Handles Decline and ring-timeout on incoming Talk call notifications. */
class F7CallActionReceiver : BroadcastReceiver() { class F7CallActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION_DECLINE) return
val roomToken = intent.getStringExtra(EXTRA_ROOM_TOKEN) val roomToken = intent.getStringExtra(EXTRA_ROOM_TOKEN)
F7IncomingCallQueue.dismissAndShowNext(context, roomToken) when (intent.action) {
ACTION_DECLINE -> F7IncomingCallQueue.dismissAndShowNext(context, roomToken)
ACTION_CALL_TIMEOUT -> F7IncomingCallQueue.timeoutActive(context, roomToken)
}
} }
companion object { companion object {
const val ACTION_DECLINE = "ru.forbion.f7cloud.action.DECLINE_CALL" const val ACTION_DECLINE = "ru.forbion.f7cloud.action.DECLINE_CALL"
const val ACTION_CALL_TIMEOUT = "ru.forbion.f7cloud.action.CALL_TIMEOUT"
const val EXTRA_NOTIFICATION_ID = "notificationId" const val EXTRA_NOTIFICATION_ID = "notificationId"
const val EXTRA_ROOM_TOKEN = "roomToken" const val EXTRA_ROOM_TOKEN = "roomToken"
} }
@@ -1,10 +1,13 @@
package ru.forbion.f7cloud.core.push package ru.forbion.f7cloud.core.push
import android.app.AlarmManager
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.Person import androidx.core.app.Person
@@ -21,10 +24,36 @@ object F7IncomingCallQueue {
private const val KEY_QUEUE = "queue" private const val KEY_QUEUE = "queue"
private const val KEY_ACTIVE_TOKEN = "active_token" private const val KEY_ACTIVE_TOKEN = "active_token"
private const val KEY_ACTIVE_AT = "active_at" private const val KEY_ACTIVE_AT = "active_at"
private const val KEY_ACTIVE_CALL = "active_call"
const val ACTIVE_NOTIFICATION_ID = 5000 const val ACTIVE_NOTIFICATION_ID = 5000
private const val MISSED_NOTIFICATION_BASE = 5100
private const val ACTIVE_RING_TTL_MS = 3 * 60 * 1000L private const val ACTIVE_RING_TTL_MS = 3 * 60 * 1000L
/** Входящий звонит 30 с; дальше — авто-сброс и уведомление «Пропущенный звонок». */
private const val RING_TIMEOUT_MS = 30_000L
/** Запас страховочного alarm поверх основного in-process таймера. */
private const val TIMEOUT_ALARM_SLACK_MS = 5_000L
private val lock = Any() private val lock = Any()
private val handler = Handler(Looper.getMainLooper())
private var timeoutRunnable: Runnable? = null
private val callEndedListeners = java.util.concurrent.CopyOnWriteArraySet<(String) -> Unit>()
/** Уведомляет UI (полноэкранный входящий), что активный звонок снят: принят/отклонён/протух. */
fun addCallEndedListener(listener: (String) -> Unit) {
callEndedListeners.add(listener)
}
fun removeCallEndedListener(listener: (String) -> Unit) {
callEndedListeners.remove(listener)
}
private fun notifyCallEnded(token: String) {
callEndedListeners.forEach { listener ->
runCatching { listener(token) }
}
}
fun enqueue( fun enqueue(
context: Context, context: Context,
@@ -58,10 +87,10 @@ object F7IncomingCallQueue {
} }
if (active.isEmpty()) { if (active.isEmpty()) {
setActive(prefs, token)
val shown = showNotification(context, call, waiting = 0) val shown = showNotification(context, call, waiting = 0)
if (!shown) { if (shown) {
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply() setActive(prefs, call)
scheduleTimeout(context, token)
} }
return shown return shown
} }
@@ -74,6 +103,7 @@ object F7IncomingCallQueue {
} }
fun dismissAndShowNext(context: Context, roomToken: String?) { fun dismissAndShowNext(context: Context, roomToken: String?) {
var ended: String? = null
synchronized(lock) { synchronized(lock) {
val prefs = prefs(context) val prefs = prefs(context)
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty() val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
@@ -88,14 +118,60 @@ object F7IncomingCallQueue {
} }
} }
cancelTimeout(context)
cancelNotification(context) cancelNotification(context)
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply() clearActive(prefs)
ended = active.takeIf { it.isNotEmpty() }
advanceQueueLocked(context, prefs, queue)
}
ended?.let { notifyCallEnded(it) }
}
/**
* Авто-сброс не отвеченного звонка (истекли [RING_TIMEOUT_MS]): снять входящий,
* показать «Пропущенный звонок» и следующий звонок из очереди, если есть.
* Принятые/отклонённые звонки сюда не попадают их снимает [dismissAndShowNext].
*/
fun timeoutActive(context: Context, roomToken: String?) {
var ended: String? = null
synchronized(lock) {
val prefs = prefs(context)
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
if (active.isEmpty()) return
if (!roomToken.isNullOrBlank() && roomToken.trim() != active) return
val call = readActiveCall(prefs)
cancelTimeout(context)
cancelNotification(context)
clearActive(prefs)
ended = active
Log.i(TAG, "Call timed out (unanswered): $active")
if (call != null) {
showMissedNotification(context, call)
}
advanceQueueLocked(context, prefs, readQueue(prefs))
}
ended?.let { notifyCallEnded(it) }
}
fun clearAll(context: Context) {
synchronized(lock) {
val prefs = prefs(context)
cancelTimeout(context)
cancelNotification(context)
prefs.edit().remove(KEY_QUEUE).remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).remove(KEY_ACTIVE_CALL).apply()
}
}
private fun advanceQueueLocked(
context: Context,
prefs: android.content.SharedPreferences,
queue: JSONArray,
) {
if (queue.length() == 0) { if (queue.length() == 0) {
prefs.edit().remove(KEY_QUEUE).apply() prefs.edit().remove(KEY_QUEUE).apply()
return return
} }
runCatching { runCatching {
val next = PendingCall.fromJson(queue.getJSONObject(0)) val next = PendingCall.fromJson(queue.getJSONObject(0))
val rest = JSONArray() val rest = JSONArray()
@@ -104,22 +180,14 @@ object F7IncomingCallQueue {
} }
prefs.edit().putString(KEY_QUEUE, rest.toString()).apply() prefs.edit().putString(KEY_QUEUE, rest.toString()).apply()
if (showNotification(context, next, rest.length())) { if (showNotification(context, next, rest.length())) {
setActive(prefs, next.roomToken) setActive(prefs, next)
scheduleTimeout(context, next.roomToken)
} }
}.onFailure { }.onFailure {
Log.w(TAG, "Failed to parse queued call", it) Log.w(TAG, "Failed to parse queued call", it)
prefs.edit().remove(KEY_QUEUE).apply() 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( private fun showNotification(
context: Context, context: Context,
@@ -343,13 +411,106 @@ object F7IncomingCallQueue {
private fun prefs(context: Context) = private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun setActive(prefs: android.content.SharedPreferences, token: String) { private fun setActive(prefs: android.content.SharedPreferences, call: PendingCall) {
prefs.edit() prefs.edit()
.putString(KEY_ACTIVE_TOKEN, token) .putString(KEY_ACTIVE_TOKEN, call.roomToken)
.putLong(KEY_ACTIVE_AT, System.currentTimeMillis()) .putLong(KEY_ACTIVE_AT, System.currentTimeMillis())
// Персистим весь звонок: страховочный alarm после смерти процесса должен уметь
// показать «Пропущенный» с названием комнаты.
.putString(KEY_ACTIVE_CALL, call.toJson().toString())
.apply() .apply()
} }
private fun clearActive(prefs: android.content.SharedPreferences) {
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).remove(KEY_ACTIVE_CALL).apply()
}
private fun readActiveCall(prefs: android.content.SharedPreferences): PendingCall? {
val raw = prefs.getString(KEY_ACTIVE_CALL, null) ?: return null
return runCatching { PendingCall.fromJson(JSONObject(raw)) }.getOrNull()
}
private fun scheduleTimeout(context: Context, token: String) {
val app = context.applicationContext
timeoutRunnable?.let(handler::removeCallbacks)
val runnable = Runnable { timeoutActive(app, token) }
timeoutRunnable = runnable
handler.postDelayed(runnable, RING_TIMEOUT_MS)
// Страховка: если процесс умрёт до срабатывания таймера, alarm поднимет его и
// заменит зависшее уведомление звонка на «Пропущенный». timeoutActive идемпотентен.
runCatching {
app.getSystemService(AlarmManager::class.java)?.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
System.currentTimeMillis() + RING_TIMEOUT_MS + TIMEOUT_ALARM_SLACK_MS,
timeoutAlarmIntent(app, token),
)
}.onFailure { Log.w(TAG, "Timeout alarm schedule failed", it) }
}
private fun cancelTimeout(context: Context) {
timeoutRunnable?.let(handler::removeCallbacks)
timeoutRunnable = null
runCatching {
context.applicationContext.getSystemService(AlarmManager::class.java)
?.cancel(timeoutAlarmIntent(context.applicationContext, null))
}
}
private fun timeoutAlarmIntent(context: Context, token: String?): PendingIntent {
val intent = Intent(context, F7CallActionReceiver::class.java).apply {
action = F7CallActionReceiver.ACTION_CALL_TIMEOUT
if (token != null) {
putExtra(F7CallActionReceiver.EXTRA_ROOM_TOKEN, token)
}
}
return PendingIntent.getBroadcast(
context,
ACTIVE_NOTIFICATION_ID + 2,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
private fun showMissedNotification(context: Context, call: PendingCall) {
F7NotificationChannels.ensureAll(context)
val manager = context.getSystemService(NotificationManager::class.java) ?: return
val roomName = call.displayName.ifBlank { call.title }
val notificationId = MISSED_NOTIFICATION_BASE + kotlin.math.abs(call.roomToken.hashCode() % 1000)
val pending = context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { launch ->
PendingIntent.getActivity(
context,
notificationId,
Intent(launch).putExtra(PushIntents.EXTRA_ROOM_TOKEN, call.roomToken),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
val iconRes = context.applicationInfo.icon.takeIf { it != 0 }
?: android.R.drawable.sym_call_missed
// Локскрин — обезличенно (минимум информации, решение владельца); название комнаты
// видно после разблокировки.
val publicVersion = NotificationCompat.Builder(context, F7NotificationChannels.MESSAGES)
.setSmallIcon(iconRes)
.setContentTitle("F7cloud")
.setContentText(context.getString(R.string.call_missed_public))
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.apply { if (pending != null) setContentIntent(pending) }
.build()
val notification = NotificationCompat.Builder(context, F7NotificationChannels.MESSAGES)
.setSmallIcon(iconRes)
.setContentTitle(context.getString(R.string.call_missed_title))
.setContentText(roomName)
.setCategory(NotificationCompat.CATEGORY_MISSED_CALL)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setPublicVersion(publicVersion)
.apply { if (pending != null) setContentIntent(pending) }
.build()
runCatching { manager.notify(notificationId, notification) }
.onFailure { Log.w(TAG, "Missed-call notification failed", it) }
}
private fun touchActive(prefs: android.content.SharedPreferences) { private fun touchActive(prefs: android.content.SharedPreferences) {
prefs.edit().putLong(KEY_ACTIVE_AT, System.currentTimeMillis()).apply() prefs.edit().putLong(KEY_ACTIVE_AT, System.currentTimeMillis()).apply()
} }
@@ -360,7 +521,7 @@ object F7IncomingCallQueue {
val activeAt = prefs.getLong(KEY_ACTIVE_AT, 0L) val activeAt = prefs.getLong(KEY_ACTIVE_AT, 0L)
if (activeAt <= 0L || System.currentTimeMillis() - activeAt > ACTIVE_RING_TTL_MS) { if (activeAt <= 0L || System.currentTimeMillis() - activeAt > ACTIVE_RING_TTL_MS) {
Log.w(TAG, "Clearing stale active call: $active") Log.w(TAG, "Clearing stale active call: $active")
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply() clearActive(prefs)
} }
} }
@@ -5,6 +5,8 @@
<string name="call_action_accept">Принять</string> <string name="call_action_accept">Принять</string>
<string name="call_action_decline">Отклонить</string> <string name="call_action_decline">Отклонить</string>
<string name="incoming_call_subtitle">F7cloud звонок</string> <string name="incoming_call_subtitle">F7cloud звонок</string>
<string name="call_missed_title">Пропущенный звонок</string>
<string name="call_missed_public">Вы пропустили звонок</string>
<plurals name="call_queue_waiting"> <plurals name="call_queue_waiting">
<item quantity="one">Ещё %d звонок в очереди</item> <item quantity="one">Ещё %d звонок в очереди</item>
<item quantity="few">Ещё %d звонка в очереди</item> <item quantity="few">Ещё %d звонка в очереди</item>
+20 -5
View File
@@ -48,9 +48,24 @@ scripts/package-source.sh dist/ # → dist/f7cloud-mobile-v<ver>-<rev>-sourc
В CI прикладывается к каждому Gitea Release. До появления раннера — публиковать исходник В CI прикладывается к каждому Gitea Release. До появления раннера — публиковать исходник
вручную этим скриптом к каждой распространяемой сборке. вручную этим скриптом к каждой распространяемой сборке.
## Статус ## Статус — ⏸️ ОТЛОЖЕНО (2026-07-09)
**Активно (2026-07-09):** self-hosted раннер `f7-b-android` зарегистрирован и запущен на инфре B Раннер развёрнут на инфре B (act_runner 0.2.13 + Node 20 + Docker + systemd), диспетчеризация
(scope — репозиторий, host-исполнение). Workflow срабатывает на push/PR (lint+тесты+assembleDebug). Gitea работает (джобы доходят до раннера). **НО host-исполнение act_runner 0.2.13 в нашем
Осталось для джобы `release` по тегу: завести Secrets репозитория (keystore base64 + пароли) окружении сломано**: криво резолвит путь к шагам и JS-экшенам
пока release-сборки делаются вручную на билд-машине тем же ключом. `No such file or directory` на `.cache/act/<id>/act/workflow/0.sh` и `MODULE_NOT_FOUND` на
`actions/checkout` (задвоение `hostexecutor/.cache/act`). Обойти на уровне workflow нельзя
(ломается сам запуск шага). Workflow **запаркован** (триггер `workflow_dispatch`), сервис
раннера остановлен и отключён от автозапуска — чтобы push'и не плодили падающие прогоны.
**Рабочий путь (когда вернёмся):** Docker-исполнение вместо host —
- кастомный образ с JDK 21 + Android SDK 36 (в дефолтном ubuntu их нет);
- volume для тёплого `~/.gradle` (vendor/WebRTC);
- раннер с меткой на docker-образ (не `:host`); в docker-режиме пути корректны, JS-экшены работают;
- регистрировать repo-токеном именно из **`f7cloud_mobile`** (id 24) — при отладке «repo-токены»
ошибочно регистрировали к чужому `f7cloud_client` (id 4) или в user-scope; в Gitea осталось
~5 дублей раннера `f7-b-android` (удалить в админке).
**Пока:** сборки и релизы — **вручную** на билд-машине (боевой ключ владельца из
`/root/.f7cloud-keys/`), исходники — `scripts/package-source.sh`, Release в Gitea — через API.
Что и делаем: релиз `v0.5.123` опубликован (APK + source) вручную.
+1
View File
@@ -38,4 +38,5 @@ dependencies {
implementation libs.core.ktx implementation libs.core.ktx
implementation libs.coil.compose implementation libs.coil.compose
implementation libs.coil.svg implementation libs.coil.svg
testImplementation libs.junit
} }
@@ -0,0 +1,58 @@
package ru.forbion.f7cloud.feature.calendar
import android.content.Context
import java.io.File
import java.security.MessageDigest
/**
* Дисковый кэш «сырых» REPORT-ответов CalDAV (события по диапазону), ключуемый
* CTag'ом календаря: CTag не изменился события читаются с диска, сеть не трогаем.
*
* Храним именно сырой XML (а не сериализованные модели) переиспользуем боевой
* парсер CalDavClient и не заводим хрупкий JSON-маппинг ~20 полей события.
* Формат файла: первая строка CTag на момент загрузки, дальше XML.
*/
class CalendarEventsCache(private val dir: File) {
constructor(context: Context) : this(File(context.cacheDir, "caldav_events"))
/** XML событий, если кэш есть и его CTag совпадает с текущим; иначе null. */
fun get(accountKey: String, calendarHref: String, rangeKey: String, ctag: String): String? {
if (ctag.isBlank()) return null
val f = entryFile(accountKey, calendarHref, rangeKey)
if (!f.isFile) return null
return runCatching {
val text = f.readText()
val nl = text.indexOf('\n')
if (nl > 0 && text.substring(0, nl) == ctag) text.substring(nl + 1) else null
}.getOrNull()
}
fun put(accountKey: String, calendarHref: String, rangeKey: String, ctag: String, xml: String) {
if (ctag.isBlank()) return
runCatching {
dir.mkdirs()
entryFile(accountKey, calendarHref, rangeKey).writeText("$ctag\n$xml")
prune()
}
}
/** Чистка устаревших записей (диапазоны уезжают со временем — файлы копятся). */
private fun prune(maxAgeMillis: Long = MAX_AGE_MILLIS) {
val now = System.currentTimeMillis()
dir.listFiles()?.forEach { f ->
if (now - f.lastModified() > maxAgeMillis) f.delete()
}
}
private fun entryFile(accountKey: String, calendarHref: String, rangeKey: String): File =
File(dir, md5("$accountKey|$calendarHref|$rangeKey") + ".xml")
private fun md5(s: String): String =
MessageDigest.getInstance("MD5").digest(s.toByteArray())
.joinToString("") { "%02x".format(it) }
private companion object {
const val MAX_AGE_MILLIS = 30L * 24 * 3600 * 1000 // 30 дней
}
}
@@ -22,6 +22,8 @@ import java.time.ZoneOffset
class CalendarRepository( class CalendarRepository(
private val apiClient: CalendarApiClient = CalendarApiClient(), private val apiClient: CalendarApiClient = CalendarApiClient(),
// Кэш событий по CTag (null — без кэша, поведение как раньше)
private val eventsCache: CalendarEventsCache? = null,
) { ) {
fun listCalendars(session: AuthSession): List<DavCalendar> = openDavContext(session).calendars fun listCalendars(session: AuthSession): List<DavCalendar> = openDavContext(session).calendars
@@ -38,7 +40,7 @@ class CalendarRepository(
val ctx = openDavContext(session) val ctx = openDavContext(session)
val startInstant = rangeStart.atStartOfDay(ZoneOffset.UTC).toInstant() val startInstant = rangeStart.atStartOfDay(ZoneOffset.UTC).toInstant()
val endInstant = rangeEnd.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant() val endInstant = rangeEnd.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()
return fetchEvents(ctx, startInstant, endInstant, visibleHrefs) return fetchEvents(ctx, startInstant, endInstant, visibleHrefs, accountKey(session))
} }
fun loadUnscheduledTasks(session: AuthSession): List<CalendarTaskItem> { fun loadUnscheduledTasks(session: AuthSession): List<CalendarTaskItem> {
@@ -210,13 +212,25 @@ class CalendarRepository(
rangeStart: Instant, rangeStart: Instant,
rangeEnd: Instant, rangeEnd: Instant,
visibleHrefs: Set<String>, visibleHrefs: Set<String>,
accountKey: String,
): List<CalendarEventItem> { ): List<CalendarEventItem> {
val calendars = if (visibleHrefs.isEmpty()) ctx.calendars else ctx.calendars.filter { it.href in visibleHrefs } val calendars = if (visibleHrefs.isEmpty()) ctx.calendars else ctx.calendars.filter { it.href in visibleHrefs }
val rangeKey = "${rangeStart.epochSecond}-${rangeEnd.epochSecond}"
val events = mutableListOf<DavEvent>() val events = mutableListOf<DavEvent>()
var successCount = 0 var successCount = 0
var lastError: String? = null var lastError: String? = null
for (cal in calendars.take(12)) { for (cal in calendars.take(12)) {
runCatching { CalDavClient.queryEventsInRange(ctx.client, cal, rangeStart, rangeEnd) } runCatching {
// CTag календаря не менялся → сырой REPORT-ответ берём с диска, сеть не трогаем
val cachedXml = eventsCache?.get(accountKey, cal.href, rangeKey, cal.ctag)
if (cachedXml != null) {
CalDavClient.parseEventsXml(cachedXml, cal)
} else {
val xml = CalDavClient.queryEventsRawXml(ctx.client, cal, rangeStart, rangeEnd)
eventsCache?.put(accountKey, cal.href, rangeKey, cal.ctag, xml)
CalDavClient.parseEventsXml(xml, cal)
}
}
.onSuccess { successCount++; events += it } .onSuccess { successCount++; events += it }
.onFailure { lastError = it.message } .onFailure { lastError = it.message }
} }
@@ -226,6 +240,9 @@ class CalendarRepository(
return events.distinctBy { it.uid }.sortedBy { it.startEpochMilli }.map { it.toItem() } return events.distinctBy { it.uid }.sortedBy { it.startEpochMilli }.map { it.toItem() }
} }
private fun accountKey(session: AuthSession): String =
"${session.serverUrl}|${session.username}"
private fun DavEvent.toItem() = CalendarEventItem( private fun DavEvent.toItem() = CalendarEventItem(
uid = uid, uid = uid,
href = href, href = href,
@@ -62,7 +62,9 @@ data class CalendarUiState(
class CalendarViewModel( class CalendarViewModel(
context: Context, context: Context,
private val repository: CalendarRepository = CalendarRepository(), private val repository: CalendarRepository = CalendarRepository(
eventsCache = CalendarEventsCache(context.applicationContext),
),
) : ViewModel() { ) : ViewModel() {
private val appContext = context.applicationContext private val appContext = context.applicationContext
private val deviceClient = DeviceCalendarClient(appContext) private val deviceClient = DeviceCalendarClient(appContext)
@@ -0,0 +1,54 @@
package ru.forbion.f7cloud.feature.calendar
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
/**
* Кэш «сырых» REPORT-ответов CalDAV: CTag совпал XML с диска; изменился/пусто null.
*/
class CalendarEventsCacheTest {
@get:Rule
val tmp = TemporaryFolder()
private fun cache() = CalendarEventsCache(tmp.newFolder("caldav"))
@Test fun hit_when_ctag_matches() {
val c = cache()
c.put("acc", "/cal/personal/", "r1", "ctag-1", "<xml>events</xml>")
assertEquals("<xml>events</xml>", c.get("acc", "/cal/personal/", "r1", "ctag-1"))
}
@Test fun miss_when_ctag_changed() {
val c = cache()
c.put("acc", "/cal/personal/", "r1", "ctag-1", "<xml/>")
assertNull(c.get("acc", "/cal/personal/", "r1", "ctag-2"))
}
@Test fun miss_on_blank_ctag_and_unknown_range() {
val c = cache()
c.put("acc", "/cal/personal/", "r1", "", "<xml/>") // пустой ctag не кэшируется
assertNull(c.get("acc", "/cal/personal/", "r1", ""))
assertNull(c.get("acc", "/cal/personal/", "other-range", "ctag-1"))
}
@Test fun entries_are_isolated_by_account_calendar_range() {
val c = cache()
c.put("acc1", "/cal/a/", "r1", "t", "<a1/>")
c.put("acc2", "/cal/a/", "r1", "t", "<a2/>")
c.put("acc1", "/cal/b/", "r1", "t", "<b1/>")
assertEquals("<a1/>", c.get("acc1", "/cal/a/", "r1", "t"))
assertEquals("<a2/>", c.get("acc2", "/cal/a/", "r1", "t"))
assertEquals("<b1/>", c.get("acc1", "/cal/b/", "r1", "t"))
}
@Test fun multiline_xml_preserved() {
val c = cache()
val xml = "<multistatus>\n <response>\n <href>/e1.ics</href>\n </response>\n</multistatus>"
c.put("acc", "/cal/", "r", "ct", xml)
assertEquals(xml, c.get("acc", "/cal/", "r", "ct"))
}
}
@@ -74,6 +74,18 @@ class ContactsRepository(context: Context) {
) { ) {
return dao.getAll(key).map { it.toItem() } return dao.getAll(key).map { it.toItem() }
} }
// Дешёвая проверка CTag: если адресные книги не менялись (и бэкфиллы сделаны) —
// полный PROPFIND всех vCard не нужен, отдаём кэш. Это ускоряет pull-to-refresh.
val needsBackfill = needsPhotoBackfill || needsDetailsBackfill
val client = authedClient(session)
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
val currentSig = CardDavClient.collectionSignature(client, session.serverUrl, userId)
if (!needsBackfill && currentSig != null &&
currentSig == prefs.getString(ctagKey(key), null)
) {
prefs.edit().putLong(lastSyncKey(key), System.currentTimeMillis()).apply()
return dao.getAll(key).map { it.toItem() }
}
val remote = fetchRemoteContacts(session) val remote = fetchRemoteContacts(session)
val entities = remote.map { contact -> val entities = remote.map { contact ->
ContactEntity( ContactEntity(
@@ -95,11 +107,12 @@ class ContactsRepository(context: Context) {
) )
} }
dao.replaceAll(key, entities) dao.replaceAll(key, entities)
prefs.edit() val editor = prefs.edit()
.putLong(lastSyncKey(key), System.currentTimeMillis()) .putLong(lastSyncKey(key), System.currentTimeMillis())
.putBoolean(photoSyncDoneKey(key), true) .putBoolean(photoSyncDoneKey(key), true)
.putBoolean(detailsSyncDoneKey(key), true) .putBoolean(detailsSyncDoneKey(key), true)
.apply() if (currentSig != null) editor.putString(ctagKey(key), currentSig) else editor.remove(ctagKey(key))
editor.apply()
return entities.map { it.toItem() } return entities.map { it.toItem() }
} }
@@ -190,6 +203,8 @@ class ContactsRepository(context: Context) {
private fun lastSyncKey(accountKey: String) = "last_sync_$accountKey" private fun lastSyncKey(accountKey: String) = "last_sync_$accountKey"
private fun ctagKey(accountKey: String) = "ctag_$accountKey"
private fun photoSyncDoneKey(accountKey: String) = "photo_sync_done_$accountKey" private fun photoSyncDoneKey(accountKey: String) = "photo_sync_done_$accountKey"
private fun detailsSyncDoneKey(accountKey: String) = "details_sync_done_$accountKey" private fun detailsSyncDoneKey(accountKey: String) = "details_sync_done_$accountKey"
@@ -21,6 +21,8 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -51,6 +53,7 @@ import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7CreateButton import ru.forbion.f7cloud.core.designsystem.F7CreateButton
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ContactsScreen( fun ContactsScreen(
session: AuthSession, session: AuthSession,
@@ -76,6 +79,7 @@ fun ContactsScreen(
modifier = modifier, modifier = modifier,
loading = state.loading && state.contacts.isEmpty(), loading = state.loading && state.contacts.isEmpty(),
error = state.error, error = state.error,
onErrorRetry = { vm.refresh(session, force = true) },
) { ) {
// Поиск + кнопка создания контакта (создание раньше вызывалось из нижней // Поиск + кнопка создания контакта (создание раньше вызывалось из нижней
// панели, теперь — из шапки, панель стала навигационной) // панели, теперь — из шапки, панель стала навигационной)
@@ -94,14 +98,13 @@ fun ContactsScreen(
) )
F7CreateButton(onClick = vm::openAddSheet, size = 40.dp) F7CreateButton(onClick = vm::openAddSheet, size = 40.dp)
} }
if (state.syncing && state.contacts.isNotEmpty()) { PullToRefreshBox(
Text( isRefreshing = state.syncing && state.contacts.isNotEmpty(),
"Обновление…", onRefresh = { vm.refresh(session, force = true) },
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), modifier = Modifier
style = MaterialTheme.typography.labelSmall, .weight(1f)
color = F7Colors.TextSecondary, .fillMaxWidth(),
) ) {
}
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.filteredContacts, key = { "${it.uid}|${it.email}" }) { contact -> items(state.filteredContacts, key = { "${it.uid}|${it.email}" }) { contact ->
ContactListRow( ContactListRow(
@@ -112,6 +115,7 @@ fun ContactsScreen(
} }
} }
} }
}
state.selectedContact?.let { contact -> state.selectedContact?.let { contact ->
ContactDetailSheet( ContactDetailSheet(
@@ -99,14 +99,16 @@ class ContactsViewModel(
} }
} }
fun refresh(session: AuthSession, showLoading: Boolean = false) { fun refresh(session: AuthSession, showLoading: Boolean = false, force: Boolean = false) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
if (showLoading && _state.value.contacts.isEmpty()) { if (showLoading && _state.value.contacts.isEmpty()) {
_state.update { it.copy(loading = true, error = null) } _state.update { it.copy(loading = true, error = null) }
} else { } else {
_state.update { it.copy(syncing = true, error = null) } _state.update { it.copy(syncing = true, error = null) }
} }
runCatching { repository.syncContacts(session) } // force (pull-to-refresh) с CTag-проверкой дешёвый: 1 лёгкий PROPFIND,
// полная закачка vCard — только если адресные книги реально менялись
runCatching { repository.syncContacts(session, force = force) }
.onFailure { t -> .onFailure { t ->
_state.update { _state.update {
it.copy( it.copy(
@@ -1,10 +1,13 @@
package ru.forbion.f7cloud.feature.deck package ru.forbion.f7cloud.feature.deck
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -17,6 +20,7 @@ import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DeckScreen( fun DeckScreen(
session: AuthSession, session: AuthSession,
@@ -57,6 +61,15 @@ fun DeckScreen(
}, },
) { ) {
val detail = state.boardDetail val detail = state.boardDetail
PullToRefreshBox(
isRefreshing = state.loading && (state.boards.isNotEmpty() || detail != null),
onRefresh = {
if (detail != null) vm.openBoard(session, detail.boardId) else vm.load(session)
},
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
if (detail != null) { if (detail != null) {
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(modifier = Modifier.fillMaxSize()) {
items(detail.stacks, key = { it.id }) { stack -> items(detail.stacks, key = { it.id }) { stack ->
@@ -80,3 +93,4 @@ fun DeckScreen(
} }
} }
} }
}
@@ -172,6 +172,7 @@ fun FilesScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
loading = state.loading && displayItems.isEmpty(), loading = state.loading && displayItems.isEmpty(),
error = state.error, error = state.error,
onErrorRetry = { vm.refreshCurrent(session) },
headerActions = {}, headerActions = {},
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
@@ -24,6 +24,8 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -46,6 +48,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.auth.AuthSession
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7ErrorState
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
import ru.forbion.f7cloud.core.designsystem.f7SwipeFromRightToDismiss import ru.forbion.f7cloud.core.designsystem.f7SwipeFromRightToDismiss
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -485,6 +488,8 @@ fun MailScreen(
onOutboxRetry = { vm.retryOutboxMessage(session, it) }, onOutboxRetry = { vm.retryOutboxMessage(session, it) },
onOutboxDelete = { vm.deleteOutboxMessage(session, it) }, onOutboxDelete = { vm.deleteOutboxMessage(session, it) },
onLoadMore = { vm.loadMore(session) }, onLoadMore = { vm.loadMore(session) },
refreshing = state.loading,
onRefresh = { vm.load(session, forceRefresh = true) },
) )
} }
MailNavigationSidebar( MailNavigationSidebar(
@@ -520,6 +525,7 @@ fun MailScreen(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun MailInboxScreen( private fun MailInboxScreen(
session: AuthSession, session: AuthSession,
@@ -542,6 +548,8 @@ private fun MailInboxScreen(
onOutboxRetry: (Int) -> Unit, onOutboxRetry: (Int) -> Unit,
onOutboxDelete: (Int) -> Unit, onOutboxDelete: (Int) -> Unit,
onLoadMore: () -> Unit, onLoadMore: () -> Unit,
refreshing: Boolean = false,
onRefresh: () -> Unit = {},
) { ) {
val listItems = remember(messages) { buildMailInboxListItems(messages) } val listItems = remember(messages) { buildMailInboxListItems(messages) }
val folderTitle = mailFolderListTitle(selectedFolder, searchQuery, searchParams) val folderTitle = mailFolderListTitle(selectedFolder, searchQuery, searchParams)
@@ -592,14 +600,14 @@ private fun MailInboxScreen(
color = F7Colors.TextPrimary, color = F7Colors.TextPrimary,
) )
} }
if (!error.isNullOrBlank()) { PullToRefreshBox(
Text(text = error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium) isRefreshing = refreshing && !loading,
} onRefresh = onRefresh,
Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
Box(modifier = Modifier.fillMaxSize()) {
if (loading) { if (loading) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -633,7 +641,13 @@ private fun MailInboxScreen(
} }
return@Box return@Box
} }
if (messages.isEmpty()) { if (messages.isEmpty() && !error.isNullOrBlank()) {
F7ErrorState(
message = error,
onRetry = onRefresh,
modifier = Modifier.fillMaxSize(),
)
} else if (messages.isEmpty()) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@@ -707,6 +721,7 @@ private fun MailInboxScreen(
} }
} }
} }
}
@Composable @Composable
private fun MailMessageDetailHeader( private fun MailMessageDetailHeader(
@@ -5,6 +5,10 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import ru.f7cloud.talk.activities.CallActivity import ru.f7cloud.talk.activities.CallActivity
import ru.f7cloud.talk.services.CallForegroundService import ru.f7cloud.talk.services.CallForegroundService
import ru.f7cloud.talk.utils.bundle.BundleKeys import ru.f7cloud.talk.utils.bundle.BundleKeys
@@ -22,6 +26,9 @@ object TalkNativeCallLauncher {
private const val TAG = "TalkNativeCallLauncher" private const val TAG = "TalkNativeCallLauncher"
private const val WEBVIEW_CALL_ACTIVITY = "ru.forbion.f7cloud.feature.talk.TalkCallActivity" private const val WEBVIEW_CALL_ACTIVITY = "ru.forbion.f7cloud.feature.talk.TalkCallActivity"
// Main-scope для запуска звонка после suspend-подготовки vendor-юзера (IO)
private val launchScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
fun launchIncomingCall( fun launchIncomingCall(
context: Context, context: Context,
session: AuthSession, session: AuthSession,
@@ -83,15 +90,18 @@ object TalkNativeCallLauncher {
} }
return return
} }
if (!TalkVendorBootstrap.ensureSessionReady(context, session)) {
showError(context, "Не удалось подготовить Talk для звонка")
return
}
val token = callContext.roomToken.trim() val token = callContext.roomToken.trim()
if (token.isBlank()) { if (token.isBlank()) {
showError(context, "Некорректная комната звонка") showError(context, "Некорректная комната звонка")
return return
} }
// Подготовка vendor-юзера — suspend (IO): раньше runBlocking держал main
// на пути звонка (ANR-риск на входящем звонке, аудит C4). Запуск activity — на main.
launchScope.launch {
if (!TalkVendorBootstrap.ensureSessionReady(context, session)) {
showError(context, "Не удалось подготовить Talk для звонка")
return@launch
}
runCatching { runCatching {
startCallActivity( startCallActivity(
context, context,
@@ -106,6 +116,7 @@ object TalkNativeCallLauncher {
} }
} }
} }
}
private fun buildCallIntent( private fun buildCallIntent(
context: Context, context: Context,
@@ -2,6 +2,10 @@ package ru.forbion.f7cloud.feature.talknative
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.AuthStore import ru.forbion.f7cloud.core.auth.AuthStore
@@ -10,15 +14,20 @@ import ru.forbion.f7cloud.core.auth.AuthStore
*/ */
object TalkVendorBootstrap { object TalkVendorBootstrap {
private const val TAG = "TalkVendorBootstrap" private const val TAG = "TalkVendorBootstrap"
private val warmupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** Прогрев vendor-юзера на старте — в фоне (раньше блокировал main в Application.onCreate). */
fun onApplicationCreate(context: Context) { fun onApplicationCreate(context: Context) {
if (!TalkNativeConfig.bootstrapVendorRuntime) return if (!TalkNativeConfig.bootstrapVendorRuntime) return
val session = AuthStore(context).load() ?: return val appContext = context.applicationContext
runCatching { TalkVendorUserSync.ensureUser(context, session) } warmupScope.launch {
val session = AuthStore(appContext).load() ?: return@launch
runCatching { TalkVendorUserSync.ensureUser(appContext, session) }
.onFailure { Log.w(TAG, "Talk user sync failed: ${it.message}") } .onFailure { Log.w(TAG, "Talk user sync failed: ${it.message}") }
} }
}
fun ensureSessionReady(context: Context, session: AuthSession): Boolean { suspend fun ensureSessionReady(context: Context, session: AuthSession): Boolean {
if (!TalkNativeConfig.bootstrapVendorRuntime) return true if (!TalkNativeConfig.bootstrapVendorRuntime) return true
return runCatching { TalkVendorUserSync.ensureUser(context, session) } return runCatching { TalkVendorUserSync.ensureUser(context, session) }
.onFailure { Log.w(TAG, "Talk user sync failed: ${it.message}") } .onFailure { Log.w(TAG, "Talk user sync failed: ${it.message}") }
@@ -5,22 +5,25 @@ import android.util.Log
import ru.f7cloud.talk.application.F7cloudTalkApplication import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.f7cloud.F7TalkUserSync import ru.f7cloud.talk.f7cloud.F7TalkUserSync
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext
import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.auth.AuthSession
/** /**
* Ensures talk-android [ru.f7cloud.talk.data.user.model.User] exists for F7cloud sessions. * Ensures talk-android [ru.f7cloud.talk.data.user.model.User] exists for F7cloud sessions.
* Required before native [ru.f7cloud.talk.activities.CallActivity] can run. * Required before native [ru.f7cloud.talk.activities.CallActivity] can run.
*
* suspend (IO) раньше был runBlocking на main (старт приложения и путь звонка),
* что давало ANR-риск из аудита (C4/п.7).
*/ */
object TalkVendorUserSync { object TalkVendorUserSync {
private const val TAG = "TalkVendorUserSync" private const val TAG = "TalkVendorUserSync"
fun ensureUser(context: Context, session: AuthSession): Boolean { suspend fun ensureUser(context: Context, session: AuthSession): Boolean {
if (context.applicationContext !is F7cloudTalkApplication) { if (context.applicationContext !is F7cloudTalkApplication) {
Log.w(TAG, "Application is not F7cloudTalkApplication, skip sync") Log.w(TAG, "Application is not F7cloudTalkApplication, skip sync")
return false return false
} }
return runBlocking(Dispatchers.IO) { return withContext(Dispatchers.IO) {
runCatching { runCatching {
F7TalkUserSync.get().ensureUser( F7TalkUserSync.get().ensureUser(
serverUrl = session.serverUrl, serverUrl = session.serverUrl,
@@ -29,6 +29,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -304,6 +306,7 @@ fun TalkScreen(
modifier = modifier, modifier = modifier,
loading = state.loading && state.rooms.isEmpty(), loading = state.loading && state.rooms.isEmpty(),
error = state.error, error = state.error,
onErrorRetry = { vm.load(session) },
) { ) {
RoomListContent( RoomListContent(
session = session, session = session,
@@ -318,11 +321,14 @@ fun TalkScreen(
onCreateRoom = vm::openCreateRoom, onCreateRoom = vm::openCreateRoom,
onToggleFavorite = { vm.toggleFavorite(session, it.token) }, onToggleFavorite = { vm.toggleFavorite(session, it.token) },
onMarkUnread = { vm.markRoomUnread(session, it.token) }, onMarkUnread = { vm.markRoomUnread(session, it.token) },
refreshing = state.loading && state.rooms.isNotEmpty(),
onRefresh = { vm.load(session) },
) )
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun RoomListContent( private fun RoomListContent(
session: AuthSession, session: AuthSession,
@@ -337,6 +343,8 @@ private fun RoomListContent(
onCreateRoom: () -> Unit, onCreateRoom: () -> Unit,
onToggleFavorite: (TalkRoom) -> Unit, onToggleFavorite: (TalkRoom) -> Unit,
onMarkUnread: (TalkRoom) -> Unit, onMarkUnread: (TalkRoom) -> Unit,
refreshing: Boolean = false,
onRefresh: () -> Unit = {},
) { ) {
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -351,6 +359,13 @@ private fun RoomListContent(
onCreateClick = onCreateRoom, onCreateClick = onCreateRoom,
) )
TalkFilterChips(selected = roomFilter, onClear = onClearFilter) TalkFilterChips(selected = roomFilter, onClear = onClearFilter)
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = onRefresh,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
if (rooms.isEmpty()) { if (rooms.isEmpty()) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -393,6 +408,7 @@ private fun RoomListContent(
} }
} }
} }
}
@Composable @Composable
private fun ChatContent( private fun ChatContent(
@@ -11,6 +11,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -143,6 +145,7 @@ fun TasksScreen(
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun TasksMainContent( private fun TasksMainContent(
session: AuthSession, session: AuthSession,
@@ -190,6 +193,13 @@ private fun TasksMainContent(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} else { } else {
PullToRefreshBox(
isRefreshing = state.loading,
onRefresh = onReload,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -232,3 +242,4 @@ private fun TasksMainContent(
} }
} }
} }
}
+1
View File
@@ -60,3 +60,4 @@ multidex = { group = "androidx.multidex", name = "multidex", version.ref = "mult
fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment" } fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment" }
documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" } documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version = "1.0.1" }