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
|
||||
|
||||
# Активируется, когда на инфре B зарегистрирован self-hosted act_runner с меткой `f7-android`
|
||||
# (JDK 21 + Android SDK 36 — как на билд-машине). Пока раннера нет — джобы ждут исполнителя.
|
||||
# Регистрация раннера — инфра-шаг ячейки B (см. docs/CI.md).
|
||||
|
||||
# ⏸️ ПАРКОВКА (2026-07-09): CI отложен. Раннер на инфре B развёрнут, но
|
||||
# host-исполнение act_runner 0.2.13 в нашем окружении сломано (криво резолвит
|
||||
# пути к шагам/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:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64
|
||||
@@ -17,7 +25,7 @@ env:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
# lint + unit-тесты + debug-сборка на каждый push в main и на PR
|
||||
# detekt + unit-тесты + Android Lint + debug-сборка на каждый push в main и на PR
|
||||
runs-on: [self-hosted, f7-android]
|
||||
if: github.ref_type != 'tag'
|
||||
steps:
|
||||
|
||||
@@ -24,3 +24,4 @@
|
||||
*.log
|
||||
*.tmp
|
||||
.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-08 | v0.5.122 (130) | Убрана кнопка «чаты» из нижней панели (слева теперь всегда «назад», единый layout во всех разделах, включая Конференции) + убрана кнопка обновления ↻ сверху справа в Конференциях (onRefresh у F7ModuleScreen; список грузится при открытии/по push) | контракты не менялись | низкий: только UI
|
||||
|
||||
+3
-2
@@ -39,8 +39,8 @@ android {
|
||||
applicationId 'ru.forbion.f7cloud.mobile'
|
||||
minSdk 26
|
||||
targetSdk 36
|
||||
versionCode 131
|
||||
versionName '0.5.123'
|
||||
versionCode 132
|
||||
versionName '0.5.124'
|
||||
missingDimensionStrategy 'default', 'f7'
|
||||
multiDexEnabled true
|
||||
|
||||
@@ -128,6 +128,7 @@ dependencies {
|
||||
implementation libs.firebase.messaging
|
||||
implementation libs.coroutines.play.services
|
||||
implementation libs.multidex
|
||||
implementation libs.core.splashscreen
|
||||
implementation libs.biometric
|
||||
implementation libs.fragment.ktx
|
||||
implementation libs.camera.camera2
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
android:resource="@mipmap/ic_launcher" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:theme="@style/Theme.F7.Splash"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -25,6 +25,8 @@ import ru.forbion.f7cloud.feature.talknative.TalkNativeCallLauncher
|
||||
*/
|
||||
class CallIncomingActivity : ComponentActivity() {
|
||||
|
||||
private var callEndedListener: ((String) -> Unit)? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
@@ -55,6 +57,20 @@ class CallIncomingActivity : ComponentActivity() {
|
||||
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 {
|
||||
F7Theme {
|
||||
IncomingCallScreen(
|
||||
@@ -74,6 +90,12 @@ class CallIncomingActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
callEndedListener?.let(F7IncomingCallQueue::removeCallEndedListener)
|
||||
callEndedListener = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.util.Log
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -43,6 +44,8 @@ class MainActivity : FragmentActivity() {
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Системный сплэш (фон + иконка) на холодном старте вместо белого экрана
|
||||
installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
if (!handleIncomingIntent(intent)) {
|
||||
|
||||
@@ -18,27 +18,12 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -53,12 +38,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
@@ -68,34 +50,26 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import ru.forbion.f7cloud.mobile.BuildConfig
|
||||
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.AuthStore
|
||||
import ru.forbion.f7cloud.core.auth.AuthVerifier
|
||||
import ru.forbion.f7cloud.core.auth.normalizeServerUrl
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import ru.forbion.f7cloud.core.network.LoginFlowClient
|
||||
import ru.forbion.f7cloud.core.push.F7PushEvent
|
||||
import ru.forbion.f7cloud.core.push.F7PushEventHub
|
||||
import ru.forbion.f7cloud.core.push.F7PushRegistrar
|
||||
import ru.forbion.f7cloud.core.designsystem.F7OverlayNavigationProvider
|
||||
import ru.forbion.f7cloud.core.designsystem.F7AppMenuSheet
|
||||
import ru.forbion.f7cloud.core.designsystem.F7SecureScreen
|
||||
import ru.forbion.f7cloud.core.designsystem.F7AppScaffold
|
||||
import ru.forbion.f7cloud.core.designsystem.F7AutoHideBottomBar
|
||||
import ru.forbion.f7cloud.core.designsystem.F7BottomBarActions
|
||||
import ru.forbion.f7cloud.core.designsystem.F7BottomBarConfig
|
||||
import ru.forbion.f7cloud.core.designsystem.F7MobileBottomBar
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
|
||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7TextButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Theme
|
||||
import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets
|
||||
import ru.forbion.f7cloud.mobile.OfficeEditorActivity
|
||||
import ru.forbion.f7cloud.mobile.qr.F7QrScannerActivity
|
||||
import ru.forbion.f7cloud.mobile.OfficeWebViewPool
|
||||
import ru.forbion.f7cloud.feature.files.OfficeWarmup
|
||||
import ru.forbion.f7cloud.mobile.permissions.F7AppPermissions
|
||||
import ru.forbion.f7cloud.mobile.permissions.F7PermissionRationaleDialog
|
||||
@@ -117,8 +91,8 @@ fun AppScaffold(
|
||||
onRequestRuntimePermissions: (onFinished: (() -> Unit)?) -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val authStore = remember { AuthStore(context) }
|
||||
var session by remember { mutableStateOf(authStore.load()) }
|
||||
val mainVm: MainViewModel = viewModel()
|
||||
val session by mainVm.session.collectAsState()
|
||||
var activeTab by rememberSaveable(
|
||||
saver = Saver(
|
||||
save = { state -> state.value.name },
|
||||
@@ -173,17 +147,7 @@ fun AppScaffold(
|
||||
context.applicationContext.getSharedPreferences("f7_permissions", android.content.Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
val logoutScope = rememberCoroutineScope()
|
||||
val forceLogout = {
|
||||
// Best-effort ревокация app password на сервере ДО очистки локальной сессии.
|
||||
session?.let { current ->
|
||||
logoutScope.launch { AppPasswordRevoker.revoke(current) }
|
||||
}
|
||||
OfficeWarmup.clear()
|
||||
OfficeWebViewPool.dispose()
|
||||
authStore.clear()
|
||||
session = null
|
||||
}
|
||||
val forceLogout = { mainVm.logout() }
|
||||
LaunchedEffect(session?.serverUrl, session?.username) {
|
||||
session?.let { OfficeWarmup.warm(it) }
|
||||
}
|
||||
@@ -196,10 +160,7 @@ fun AppScaffold(
|
||||
|
||||
F7Theme {
|
||||
if (session == null) {
|
||||
LoginScreen(onLogin = {
|
||||
authStore.save(it)
|
||||
session = it
|
||||
})
|
||||
LoginScreen(onLogin = { mainVm.login(it) })
|
||||
return@F7Theme
|
||||
}
|
||||
|
||||
@@ -688,237 +649,3 @@ fun AppScaffold(
|
||||
}
|
||||
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
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.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@@ -43,11 +46,14 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
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
|
||||
fun F7ModuleScreen(
|
||||
title: String? = null,
|
||||
@@ -76,6 +122,9 @@ fun F7ModuleScreen(
|
||||
loading: Boolean = false,
|
||||
error: String? = null,
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
// Действие «Повторить» в экране ошибки; по умолчанию — как onRefresh.
|
||||
// Позволяет дать retry, не показывая хедер-кнопку ↻ (когда onRefresh не задан).
|
||||
onErrorRetry: (() -> Unit)? = onRefresh,
|
||||
headerActions: @Composable RowScope.() -> Unit = {},
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
@@ -126,14 +175,22 @@ fun F7ModuleScreen(
|
||||
CircularProgressIndicator(color = F7Colors.Primary)
|
||||
}
|
||||
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 displayName: String,
|
||||
val color: String? = null,
|
||||
// CTag коллекции (CalendarServer-расширение, Nextcloud поддерживает):
|
||||
// меняется при любом изменении в календаре → ключ инкрементального кэша событий.
|
||||
val ctag: String = "",
|
||||
)
|
||||
|
||||
data class DavEvent(
|
||||
@@ -103,7 +106,7 @@ object CalDavClient {
|
||||
val body = """
|
||||
<?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: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>
|
||||
""".trimIndent()
|
||||
val xml = propfind(client, baseUrl, depth = 1, body)
|
||||
@@ -129,7 +132,15 @@ object CalDavClient {
|
||||
calendar: DavCalendar,
|
||||
rangeStart: 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 endStr = formatCalDavTime(rangeEnd)
|
||||
val body = """
|
||||
@@ -146,10 +157,13 @@ object CalDavClient {
|
||||
</c:calendar-query>
|
||||
""".trimIndent()
|
||||
val href = calendar.href.trimEnd('/') + "/"
|
||||
val xml = report(client, href, body)
|
||||
return parseCalendarQueryResponses(xml, calendar)
|
||||
return report(client, href, body)
|
||||
}
|
||||
|
||||
/** Парсинг REPORT-ответа (в т.ч. взятого из кэша). */
|
||||
fun parseEventsXml(xml: String, calendar: DavCalendar): List<DavEvent> =
|
||||
parseCalendarQueryResponses(xml, calendar)
|
||||
|
||||
fun createEvent(
|
||||
client: OkHttpClient,
|
||||
calendar: DavCalendar,
|
||||
@@ -782,6 +796,7 @@ object CalDavClient {
|
||||
var href = ""
|
||||
var displayName = ""
|
||||
var calendarColor: String? = null
|
||||
var ctag = ""
|
||||
var isCollection = false
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (parser.eventType) {
|
||||
@@ -791,11 +806,13 @@ object CalDavClient {
|
||||
href = ""
|
||||
displayName = ""
|
||||
calendarColor = null
|
||||
ctag = ""
|
||||
isCollection = false
|
||||
}
|
||||
"collection" -> if (inResponse) isCollection = true
|
||||
"displayname" -> if (inResponse) displayName = 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()
|
||||
}
|
||||
XmlPullParser.END_TAG -> if (parser.localTag() == "response" && inResponse) {
|
||||
@@ -809,7 +826,7 @@ object CalDavClient {
|
||||
val name = displayName.ifBlank {
|
||||
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
|
||||
|
||||
@@ -69,6 +69,53 @@ object CardDavClient {
|
||||
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(
|
||||
client: OkHttpClient,
|
||||
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.Intent
|
||||
|
||||
/** Handles Decline on incoming Talk call notifications. */
|
||||
/** Handles Decline and ring-timeout on incoming Talk call notifications. */
|
||||
class F7CallActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != ACTION_DECLINE) return
|
||||
val roomToken = intent.getStringExtra(EXTRA_ROOM_TOKEN)
|
||||
F7IncomingCallQueue.dismissAndShowNext(context, roomToken)
|
||||
when (intent.action) {
|
||||
ACTION_DECLINE -> F7IncomingCallQueue.dismissAndShowNext(context, roomToken)
|
||||
ACTION_CALL_TIMEOUT -> F7IncomingCallQueue.timeoutActive(context, roomToken)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
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_ROOM_TOKEN = "roomToken"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package ru.forbion.f7cloud.core.push
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.Person
|
||||
@@ -21,10 +24,36 @@ object F7IncomingCallQueue {
|
||||
private const val KEY_QUEUE = "queue"
|
||||
private const val KEY_ACTIVE_TOKEN = "active_token"
|
||||
private const val KEY_ACTIVE_AT = "active_at"
|
||||
private const val KEY_ACTIVE_CALL = "active_call"
|
||||
const val ACTIVE_NOTIFICATION_ID = 5000
|
||||
private const val MISSED_NOTIFICATION_BASE = 5100
|
||||
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 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(
|
||||
context: Context,
|
||||
@@ -58,10 +87,10 @@ object F7IncomingCallQueue {
|
||||
}
|
||||
|
||||
if (active.isEmpty()) {
|
||||
setActive(prefs, token)
|
||||
val shown = showNotification(context, call, waiting = 0)
|
||||
if (!shown) {
|
||||
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
|
||||
if (shown) {
|
||||
setActive(prefs, call)
|
||||
scheduleTimeout(context, token)
|
||||
}
|
||||
return shown
|
||||
}
|
||||
@@ -74,6 +103,7 @@ object F7IncomingCallQueue {
|
||||
}
|
||||
|
||||
fun dismissAndShowNext(context: Context, roomToken: String?) {
|
||||
var ended: String? = null
|
||||
synchronized(lock) {
|
||||
val prefs = prefs(context)
|
||||
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
|
||||
@@ -88,36 +118,74 @@ object F7IncomingCallQueue {
|
||||
}
|
||||
}
|
||||
|
||||
cancelTimeout(context)
|
||||
cancelNotification(context)
|
||||
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
|
||||
|
||||
if (queue.length() == 0) {
|
||||
prefs.edit().remove(KEY_QUEUE).apply()
|
||||
return
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val next = PendingCall.fromJson(queue.getJSONObject(0))
|
||||
val rest = JSONArray()
|
||||
for (i in 1 until queue.length()) {
|
||||
rest.put(queue.get(i))
|
||||
}
|
||||
prefs.edit().putString(KEY_QUEUE, rest.toString()).apply()
|
||||
if (showNotification(context, next, rest.length())) {
|
||||
setActive(prefs, next.roomToken)
|
||||
}
|
||||
}.onFailure {
|
||||
Log.w(TAG, "Failed to parse queued call", it)
|
||||
prefs.edit().remove(KEY_QUEUE).apply()
|
||||
}
|
||||
clearActive(prefs)
|
||||
ended = active.takeIf { it.isNotEmpty() }
|
||||
advanceQueueLocked(context, prefs, queue)
|
||||
}
|
||||
ended?.let { notifyCallEnded(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Авто-сброс не отвеченного звонка (истекли [RING_TIMEOUT_MS]): снять входящий,
|
||||
* показать «Пропущенный звонок» и следующий звонок из очереди, если есть.
|
||||
* Принятые/отклонённые звонки сюда не попадают — их снимает [dismissAndShowNext].
|
||||
*/
|
||||
fun timeoutActive(context: Context, roomToken: String?) {
|
||||
var ended: String? = null
|
||||
synchronized(lock) {
|
||||
val prefs = prefs(context)
|
||||
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
|
||||
if (active.isEmpty()) return
|
||||
if (!roomToken.isNullOrBlank() && roomToken.trim() != active) return
|
||||
|
||||
val call = readActiveCall(prefs)
|
||||
cancelTimeout(context)
|
||||
cancelNotification(context)
|
||||
clearActive(prefs)
|
||||
ended = active
|
||||
Log.i(TAG, "Call timed out (unanswered): $active")
|
||||
if (call != null) {
|
||||
showMissedNotification(context, call)
|
||||
}
|
||||
advanceQueueLocked(context, prefs, readQueue(prefs))
|
||||
}
|
||||
ended?.let { notifyCallEnded(it) }
|
||||
}
|
||||
|
||||
fun clearAll(context: Context) {
|
||||
synchronized(lock) {
|
||||
val prefs = prefs(context)
|
||||
cancelTimeout(context)
|
||||
cancelNotification(context)
|
||||
prefs.edit().remove(KEY_QUEUE).remove(KEY_ACTIVE_TOKEN).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) =
|
||||
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()
|
||||
.putString(KEY_ACTIVE_TOKEN, token)
|
||||
.putString(KEY_ACTIVE_TOKEN, call.roomToken)
|
||||
.putLong(KEY_ACTIVE_AT, System.currentTimeMillis())
|
||||
// Персистим весь звонок: страховочный alarm после смерти процесса должен уметь
|
||||
// показать «Пропущенный» с названием комнаты.
|
||||
.putString(KEY_ACTIVE_CALL, call.toJson().toString())
|
||||
.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) {
|
||||
prefs.edit().putLong(KEY_ACTIVE_AT, System.currentTimeMillis()).apply()
|
||||
}
|
||||
@@ -360,7 +521,7 @@ object F7IncomingCallQueue {
|
||||
val activeAt = prefs.getLong(KEY_ACTIVE_AT, 0L)
|
||||
if (activeAt <= 0L || System.currentTimeMillis() - activeAt > ACTIVE_RING_TTL_MS) {
|
||||
Log.w(TAG, "Clearing stale active call: $active")
|
||||
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
|
||||
clearActive(prefs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<string name="call_action_accept">Принять</string>
|
||||
<string name="call_action_decline">Отклонить</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">
|
||||
<item quantity="one">Ещё %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. До появления раннера — публиковать исходник
|
||||
вручную этим скриптом к каждой распространяемой сборке.
|
||||
|
||||
## Статус
|
||||
## Статус — ⏸️ ОТЛОЖЕНО (2026-07-09)
|
||||
|
||||
**Активно (2026-07-09):** self-hosted раннер `f7-b-android` зарегистрирован и запущен на инфре B
|
||||
(scope — репозиторий, host-исполнение). Workflow срабатывает на push/PR (lint+тесты+assembleDebug).
|
||||
Осталось для джобы `release` по тегу: завести Secrets репозитория (keystore base64 + пароли) —
|
||||
пока release-сборки делаются вручную на билд-машине тем же ключом.
|
||||
Раннер развёрнут на инфре B (act_runner 0.2.13 + Node 20 + Docker + systemd), диспетчеризация
|
||||
Gitea работает (джобы доходят до раннера). **НО host-исполнение act_runner 0.2.13 в нашем
|
||||
окружении сломано**: криво резолвит путь к шагам и JS-экшенам —
|
||||
`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.coil.compose
|
||||
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(
|
||||
private val apiClient: CalendarApiClient = CalendarApiClient(),
|
||||
// Кэш событий по CTag (null — без кэша, поведение как раньше)
|
||||
private val eventsCache: CalendarEventsCache? = null,
|
||||
) {
|
||||
fun listCalendars(session: AuthSession): List<DavCalendar> = openDavContext(session).calendars
|
||||
|
||||
@@ -38,7 +40,7 @@ class CalendarRepository(
|
||||
val ctx = openDavContext(session)
|
||||
val startInstant = rangeStart.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> {
|
||||
@@ -210,13 +212,25 @@ class CalendarRepository(
|
||||
rangeStart: Instant,
|
||||
rangeEnd: Instant,
|
||||
visibleHrefs: Set<String>,
|
||||
accountKey: String,
|
||||
): List<CalendarEventItem> {
|
||||
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>()
|
||||
var successCount = 0
|
||||
var lastError: String? = null
|
||||
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 }
|
||||
.onFailure { lastError = it.message }
|
||||
}
|
||||
@@ -226,6 +240,9 @@ class CalendarRepository(
|
||||
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(
|
||||
uid = uid,
|
||||
href = href,
|
||||
|
||||
+3
-1
@@ -62,7 +62,9 @@ data class CalendarUiState(
|
||||
|
||||
class CalendarViewModel(
|
||||
context: Context,
|
||||
private val repository: CalendarRepository = CalendarRepository(),
|
||||
private val repository: CalendarRepository = CalendarRepository(
|
||||
eventsCache = CalendarEventsCache(context.applicationContext),
|
||||
),
|
||||
) : ViewModel() {
|
||||
private val appContext = context.applicationContext
|
||||
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() }
|
||||
}
|
||||
// Дешёвая проверка 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 entities = remote.map { contact ->
|
||||
ContactEntity(
|
||||
@@ -95,11 +107,12 @@ class ContactsRepository(context: Context) {
|
||||
)
|
||||
}
|
||||
dao.replaceAll(key, entities)
|
||||
prefs.edit()
|
||||
val editor = prefs.edit()
|
||||
.putLong(lastSyncKey(key), System.currentTimeMillis())
|
||||
.putBoolean(photoSyncDoneKey(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() }
|
||||
}
|
||||
|
||||
@@ -190,6 +203,8 @@ class ContactsRepository(context: Context) {
|
||||
|
||||
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 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.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.F7ModuleScreen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ContactsScreen(
|
||||
session: AuthSession,
|
||||
@@ -76,6 +79,7 @@ fun ContactsScreen(
|
||||
modifier = modifier,
|
||||
loading = state.loading && state.contacts.isEmpty(),
|
||||
error = state.error,
|
||||
onErrorRetry = { vm.refresh(session, force = true) },
|
||||
) {
|
||||
// Поиск + кнопка создания контакта (создание раньше вызывалось из нижней
|
||||
// панели, теперь — из шапки, панель стала навигационной)
|
||||
@@ -94,21 +98,21 @@ fun ContactsScreen(
|
||||
)
|
||||
F7CreateButton(onClick = vm::openAddSheet, size = 40.dp)
|
||||
}
|
||||
if (state.syncing && state.contacts.isNotEmpty()) {
|
||||
Text(
|
||||
"Обновление…",
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(state.filteredContacts, key = { "${it.uid}|${it.email}" }) { contact ->
|
||||
ContactListRow(
|
||||
contact = contact,
|
||||
onClick = { vm.openContact(contact) },
|
||||
)
|
||||
HorizontalDivider(color = F7Colors.Border.copy(alpha = 0.6f))
|
||||
PullToRefreshBox(
|
||||
isRefreshing = state.syncing && state.contacts.isNotEmpty(),
|
||||
onRefresh = { vm.refresh(session, force = true) },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(state.filteredContacts, key = { "${it.uid}|${it.email}" }) { contact ->
|
||||
ContactListRow(
|
||||
contact = contact,
|
||||
onClick = { vm.openContact(contact) },
|
||||
)
|
||||
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) {
|
||||
if (showLoading && _state.value.contacts.isEmpty()) {
|
||||
_state.update { it.copy(loading = true, error = null) }
|
||||
} else {
|
||||
_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 ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package ru.forbion.f7cloud.feature.deck
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.LaunchedEffect
|
||||
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.F7SecondaryButton
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DeckScreen(
|
||||
session: AuthSession,
|
||||
@@ -57,23 +61,33 @@ fun DeckScreen(
|
||||
},
|
||||
) {
|
||||
val detail = state.boardDetail
|
||||
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)
|
||||
PullToRefreshBox(
|
||||
isRefreshing = state.loading && (state.boards.isNotEmpty() || detail != null),
|
||||
onRefresh = {
|
||||
if (detail != null) vm.openBoard(session, detail.boardId) else vm.load(session)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
if (detail != null) {
|
||||
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 {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(state.boards, key = { it.id }) { board ->
|
||||
F7ListCard(onClick = { vm.openBoard(session, board.id) }) {
|
||||
Text(board.title, style = MaterialTheme.typography.titleSmall)
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(state.boards, key = { it.id }) { board ->
|
||||
F7ListCard(onClick = { vm.openBoard(session, board.id) }) {
|
||||
Text(board.title, style = MaterialTheme.typography.titleSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +172,7 @@ fun FilesScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
loading = state.loading && displayItems.isEmpty(),
|
||||
error = state.error,
|
||||
onErrorRetry = { vm.refreshCurrent(session) },
|
||||
headerActions = {},
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
|
||||
@@ -24,6 +24,8 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -46,6 +48,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import androidx.activity.compose.BackHandler
|
||||
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.f7SwipeFromRightToDismiss
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -485,6 +488,8 @@ fun MailScreen(
|
||||
onOutboxRetry = { vm.retryOutboxMessage(session, it) },
|
||||
onOutboxDelete = { vm.deleteOutboxMessage(session, it) },
|
||||
onLoadMore = { vm.loadMore(session) },
|
||||
refreshing = state.loading,
|
||||
onRefresh = { vm.load(session, forceRefresh = true) },
|
||||
)
|
||||
}
|
||||
MailNavigationSidebar(
|
||||
@@ -520,6 +525,7 @@ fun MailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun MailInboxScreen(
|
||||
session: AuthSession,
|
||||
@@ -542,6 +548,8 @@ private fun MailInboxScreen(
|
||||
onOutboxRetry: (Int) -> Unit,
|
||||
onOutboxDelete: (Int) -> Unit,
|
||||
onLoadMore: () -> Unit,
|
||||
refreshing: Boolean = false,
|
||||
onRefresh: () -> Unit = {},
|
||||
) {
|
||||
val listItems = remember(messages) { buildMailInboxListItems(messages) }
|
||||
val folderTitle = mailFolderListTitle(selectedFolder, searchQuery, searchParams)
|
||||
@@ -592,14 +600,14 @@ private fun MailInboxScreen(
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
}
|
||||
if (!error.isNullOrBlank()) {
|
||||
Text(text = error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Box(
|
||||
PullToRefreshBox(
|
||||
isRefreshing = refreshing && !loading,
|
||||
onRefresh = onRefresh,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (loading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -633,7 +641,13 @@ private fun MailInboxScreen(
|
||||
}
|
||||
return@Box
|
||||
}
|
||||
if (messages.isEmpty()) {
|
||||
if (messages.isEmpty() && !error.isNullOrBlank()) {
|
||||
F7ErrorState(
|
||||
message = error,
|
||||
onRetry = onRefresh,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else if (messages.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
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.util.Log
|
||||
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.services.CallForegroundService
|
||||
import ru.f7cloud.talk.utils.bundle.BundleKeys
|
||||
@@ -22,6 +26,9 @@ object TalkNativeCallLauncher {
|
||||
private const val TAG = "TalkNativeCallLauncher"
|
||||
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(
|
||||
context: Context,
|
||||
session: AuthSession,
|
||||
@@ -83,26 +90,30 @@ object TalkNativeCallLauncher {
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!TalkVendorBootstrap.ensureSessionReady(context, session)) {
|
||||
showError(context, "Не удалось подготовить Talk для звонка")
|
||||
return
|
||||
}
|
||||
val token = callContext.roomToken.trim()
|
||||
if (token.isBlank()) {
|
||||
showError(context, "Некорректная комната звонка")
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
startCallActivity(
|
||||
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, "Не удалось начать звонок")
|
||||
// Подготовка vendor-юзера — suspend (IO): раньше runBlocking держал main
|
||||
// на пути звонка (ANR-риск на входящем звонке, аудит C4). Запуск activity — на main.
|
||||
launchScope.launch {
|
||||
if (!TalkVendorBootstrap.ensureSessionReady(context, session)) {
|
||||
showError(context, "Не удалось подготовить Talk для звонка")
|
||||
return@launch
|
||||
}
|
||||
runCatching {
|
||||
startCallActivity(
|
||||
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.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.AuthStore
|
||||
|
||||
@@ -10,15 +14,20 @@ import ru.forbion.f7cloud.core.auth.AuthStore
|
||||
*/
|
||||
object TalkVendorBootstrap {
|
||||
private const val TAG = "TalkVendorBootstrap"
|
||||
private val warmupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/** Прогрев vendor-юзера на старте — в фоне (раньше блокировал main в Application.onCreate). */
|
||||
fun onApplicationCreate(context: Context) {
|
||||
if (!TalkNativeConfig.bootstrapVendorRuntime) return
|
||||
val session = AuthStore(context).load() ?: return
|
||||
runCatching { TalkVendorUserSync.ensureUser(context, session) }
|
||||
.onFailure { Log.w(TAG, "Talk user sync failed: ${it.message}") }
|
||||
val appContext = context.applicationContext
|
||||
warmupScope.launch {
|
||||
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
|
||||
return runCatching { TalkVendorUserSync.ensureUser(context, session) }
|
||||
.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.f7cloud.F7TalkUserSync
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
|
||||
/**
|
||||
* Ensures talk-android [ru.f7cloud.talk.data.user.model.User] exists for F7cloud sessions.
|
||||
* Required before native [ru.f7cloud.talk.activities.CallActivity] can run.
|
||||
*
|
||||
* suspend (IO) — раньше был runBlocking на main (старт приложения и путь звонка),
|
||||
* что давало ANR-риск из аудита (C4/п.7).
|
||||
*/
|
||||
object 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) {
|
||||
Log.w(TAG, "Application is not F7cloudTalkApplication, skip sync")
|
||||
return false
|
||||
}
|
||||
return runBlocking(Dispatchers.IO) {
|
||||
return withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
F7TalkUserSync.get().ensureUser(
|
||||
serverUrl = session.serverUrl,
|
||||
|
||||
@@ -29,6 +29,8 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -304,6 +306,7 @@ fun TalkScreen(
|
||||
modifier = modifier,
|
||||
loading = state.loading && state.rooms.isEmpty(),
|
||||
error = state.error,
|
||||
onErrorRetry = { vm.load(session) },
|
||||
) {
|
||||
RoomListContent(
|
||||
session = session,
|
||||
@@ -318,11 +321,14 @@ fun TalkScreen(
|
||||
onCreateRoom = vm::openCreateRoom,
|
||||
onToggleFavorite = { vm.toggleFavorite(session, it.token) },
|
||||
onMarkUnread = { vm.markRoomUnread(session, it.token) },
|
||||
refreshing = state.loading && state.rooms.isNotEmpty(),
|
||||
onRefresh = { vm.load(session) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun RoomListContent(
|
||||
session: AuthSession,
|
||||
@@ -337,6 +343,8 @@ private fun RoomListContent(
|
||||
onCreateRoom: () -> Unit,
|
||||
onToggleFavorite: (TalkRoom) -> Unit,
|
||||
onMarkUnread: (TalkRoom) -> Unit,
|
||||
refreshing: Boolean = false,
|
||||
onRefresh: () -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -351,6 +359,13 @@ private fun RoomListContent(
|
||||
onCreateClick = onCreateRoom,
|
||||
)
|
||||
TalkFilterChips(selected = roomFilter, onClear = onClearFilter)
|
||||
PullToRefreshBox(
|
||||
isRefreshing = refreshing,
|
||||
onRefresh = onRefresh,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
if (rooms.isEmpty()) {
|
||||
Column(
|
||||
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.material3.CircularProgressIndicator
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -143,6 +145,7 @@ fun TasksScreen(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TasksMainContent(
|
||||
session: AuthSession,
|
||||
@@ -190,6 +193,13 @@ private fun TasksMainContent(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
PullToRefreshBox(
|
||||
isRefreshing = state.loading,
|
||||
onRefresh = onReload,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
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" }
|
||||
documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
|
||||
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