Compare commits
20 Commits
v0.5.123
...
c6732546f1
| Author | SHA1 | Date | |
|---|---|---|---|
| c6732546f1 | |||
| dd5bdbc177 | |||
| 47534ceb59 | |||
| 8c90e83cef | |||
| a674721353 | |||
| 8f7eaff611 | |||
| 83e071673c | |||
| d1d61d0cf8 | |||
| 286ab9c8bf | |||
| b03cb09a6c | |||
| fb57156ee9 | |||
| 4c403e5ec2 | |||
| 00daa980a8 | |||
| 2709dba03f | |||
| c53885b230 | |||
| a696d45f2e | |||
| 2ae03264ff | |||
| 47c274e441 | |||
| 34bb16c59d | |||
| 0fcc575e76 |
+17
-9
@@ -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:
|
||||||
|
|||||||
@@ -24,3 +24,4 @@
|
|||||||
*.log
|
*.log
|
||||||
*.tmp
|
*.tmp
|
||||||
.cxx/
|
.cxx/
|
||||||
|
dist/
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
+64
-7
@@ -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,14 +175,22 @@ 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(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f),
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,36 +118,74 @@ 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() }
|
||||||
if (queue.length() == 0) {
|
advanceQueueLocked(context, prefs, queue)
|
||||||
prefs.edit().remove(KEY_QUEUE).apply()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
val next = PendingCall.fromJson(queue.getJSONObject(0))
|
|
||||||
val rest = JSONArray()
|
|
||||||
for (i in 1 until queue.length()) {
|
|
||||||
rest.put(queue.get(i))
|
|
||||||
}
|
|
||||||
prefs.edit().putString(KEY_QUEUE, rest.toString()).apply()
|
|
||||||
if (showNotification(context, next, rest.length())) {
|
|
||||||
setActive(prefs, next.roomToken)
|
|
||||||
}
|
|
||||||
}.onFailure {
|
|
||||||
Log.w(TAG, "Failed to parse queued call", it)
|
|
||||||
prefs.edit().remove(KEY_QUEUE).apply()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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) {
|
fun clearAll(context: Context) {
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
val prefs = prefs(context)
|
val prefs = prefs(context)
|
||||||
|
cancelTimeout(context)
|
||||||
cancelNotification(context)
|
cancelNotification(context)
|
||||||
prefs.edit().remove(KEY_QUEUE).remove(KEY_ACTIVE_TOKEN).apply()
|
prefs.edit().remove(KEY_QUEUE).remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).remove(KEY_ACTIVE_CALL).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun advanceQueueLocked(
|
||||||
|
context: Context,
|
||||||
|
prefs: android.content.SharedPreferences,
|
||||||
|
queue: JSONArray,
|
||||||
|
) {
|
||||||
|
if (queue.length() == 0) {
|
||||||
|
prefs.edit().remove(KEY_QUEUE).apply()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
val next = PendingCall.fromJson(queue.getJSONObject(0))
|
||||||
|
val rest = JSONArray()
|
||||||
|
for (i in 1 until queue.length()) {
|
||||||
|
rest.put(queue.get(i))
|
||||||
|
}
|
||||||
|
prefs.edit().putString(KEY_QUEUE, rest.toString()).apply()
|
||||||
|
if (showNotification(context, next, rest.length())) {
|
||||||
|
setActive(prefs, next)
|
||||||
|
scheduleTimeout(context, next.roomToken)
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
Log.w(TAG, "Failed to parse queued call", it)
|
||||||
|
prefs.edit().remove(KEY_QUEUE).apply()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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) вручную.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+58
@@ -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 дней
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-2
@@ -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,
|
||||||
|
|||||||
+3
-1
@@ -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)
|
||||||
|
|||||||
+54
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-2
@@ -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"
|
||||||
|
|||||||
+19
-15
@@ -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,21 +98,21 @@ 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(
|
contact = contact,
|
||||||
contact = contact,
|
onClick = { vm.openContact(contact) },
|
||||||
onClick = { vm.openContact(contact) },
|
)
|
||||||
)
|
HorizontalDivider(color = F7Colors.Border.copy(alpha = 0.6f))
|
||||||
HorizontalDivider(color = F7Colors.Border.copy(alpha = 0.6f))
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -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,23 +61,33 @@ fun DeckScreen(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
val detail = state.boardDetail
|
val detail = state.boardDetail
|
||||||
if (detail != null) {
|
PullToRefreshBox(
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
isRefreshing = state.loading && (state.boards.isNotEmpty() || detail != null),
|
||||||
items(detail.stacks, key = { it.id }) { stack ->
|
onRefresh = {
|
||||||
F7ListCard {
|
if (detail != null) vm.openBoard(session, detail.boardId) else vm.load(session)
|
||||||
Text(stack.title, style = MaterialTheme.typography.titleSmall)
|
},
|
||||||
stack.cards.forEach { card ->
|
modifier = Modifier
|
||||||
val prefix = if (card.done) "✓ " else "• "
|
.weight(1f)
|
||||||
Text(prefix + card.title, style = MaterialTheme.typography.bodyMedium)
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
if (detail != null) {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(detail.stacks, key = { it.id }) { stack ->
|
||||||
|
F7ListCard {
|
||||||
|
Text(stack.title, style = MaterialTheme.typography.titleSmall)
|
||||||
|
stack.cards.forEach { card ->
|
||||||
|
val prefix = if (card.done) "✓ " else "• "
|
||||||
|
Text(prefix + card.title, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
} else {
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
items(state.boards, key = { it.id }) { board ->
|
||||||
items(state.boards, key = { it.id }) { board ->
|
F7ListCard(onClick = { vm.openBoard(session, board.id) }) {
|
||||||
F7ListCard(onClick = { vm.openBoard(session, board.id) }) {
|
Text(board.title, style = MaterialTheme.typography.titleSmall)
|
||||||
Text(board.title, style = MaterialTheme.typography.titleSmall)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -705,6 +719,7 @@ private fun MailInboxScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-15
@@ -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,26 +90,30 @@ 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
|
||||||
}
|
}
|
||||||
runCatching {
|
// Подготовка vendor-юзера — suspend (IO): раньше runBlocking держал main
|
||||||
startCallActivity(
|
// на пути звонка (ANR-риск на входящем звонке, аудит C4). Запуск activity — на main.
|
||||||
context,
|
launchScope.launch {
|
||||||
buildCallIntent(context, session, callContext, incomingFromNotification, suppressIncomingRingtone),
|
if (!TalkVendorBootstrap.ensureSessionReady(context, session)) {
|
||||||
)
|
showError(context, "Не удалось подготовить Talk для звонка")
|
||||||
}.onFailure { error ->
|
return@launch
|
||||||
Log.e(TAG, "Native CallActivity launch failed", error)
|
}
|
||||||
if (TalkNativeConfig.allowWebViewCallFallback) {
|
runCatching {
|
||||||
launchWebViewFallback(context, session, token, incomingFromNotification)
|
startCallActivity(
|
||||||
} else {
|
context,
|
||||||
showError(context, "Не удалось начать звонок")
|
buildCallIntent(context, session, callContext, incomingFromNotification, suppressIncomingRingtone),
|
||||||
|
)
|
||||||
|
}.onFailure { error ->
|
||||||
|
Log.e(TAG, "Native CallActivity launch failed", error)
|
||||||
|
if (TalkNativeConfig.allowWebViewCallFallback) {
|
||||||
|
launchWebViewFallback(context, session, token, incomingFromNotification)
|
||||||
|
} else {
|
||||||
|
showError(context, "Не удалось начать звонок")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-4
@@ -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 {
|
||||||
.onFailure { Log.w(TAG, "Talk user sync failed: ${it.message}") }
|
val session = AuthStore(appContext).load() ?: return@launch
|
||||||
|
runCatching { TalkVendorUserSync.ensureUser(appContext, session) }
|
||||||
|
.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}") }
|
||||||
|
|||||||
+6
-3
@@ -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
|
||||||
@@ -391,6 +406,7 @@ private fun RoomListContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
@@ -229,6 +239,7 @@ private fun TasksMainContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user