Этап 0 п.5 (gradle-гигиена): parallel=true + удалены мёртвые модули

- org.gradle.parallel=true (быстрее сборка 20 модулей)
- удалены core:data, core:ui (пустые заготовки) и feature:widgets (не в
  settings.gradle, не компилировался) + чистка settings.gradle
- nonTransitiveRClass=true и убирание enableJetifier ОТКАЧЕНЫ: vendor-форк
  talk-android их не переживает (R транзитивно; legacy android.support.* в
  зависимостях типа FlexibleAdapter). Причины — в комментах gradle.properties.

Проверено сборкой: assembleRelease BUILD SUCCESSFUL. Этап 0 закрыт.
This commit is contained in:
b-dev-mobile
2026-07-07 21:47:49 +00:00
parent 5eb6efb36b
commit cef526e778
12 changed files with 7 additions and 615 deletions
+1
View File
@@ -2,6 +2,7 @@
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск` Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.5 (gradle-гигиена): org.gradle.parallel=true (ускорение сборки 20 модулей); удалены мёртвые модули core:data, core:ui (пустые заготовки) и feature:widgets (не был в settings.gradle, не компилировался) + чистка settings.gradle. nonTransitiveRClass и убирание jetifier — ПРОБОВАЛ, откатил: vendor-форк talk-android ломается (R транзитивно + legacy android.support.* в его depS); причины в комментах gradle.properties. Проверено сборкой: assembleRelease BUILD SUCCESSFUL. **Этап 0 завершён** (кроме п.4c — публикация исходников, к CI этапа 1) | контракты не менялись | низкий: удалён неиспользуемый код, ускорена сборка
- 2026-07-07 | v0.5.114 (122) | Требование владельца: звонки/push ДОЛЖНЫ работать при заблокированном телефоне. (1) Биометрическая привязка app password ОТКЛОНЕНА (вариант А) — она неустранимо блокировала приём звонка без разблокировки; откачена из main, сохранена на ветке wip/biometric-binding. App password остаётся защищён шифрованием в покое (Keystore AES-256-GCM из п.3, работает в фоне → звонки/push/приём при блокировке ОК). (2) Приватность локскрина: уведомления сообщений и звонков → VISIBILITY_PRIVATE + generic public-версия («Новое сообщение»/«Входящий звонок»), канал lockscreenVisibility=PRIVATE. Полный контент (отправитель/текст/имя звонящего) — только после разблокировки. Push у приложения plaintext, пароль для показа/звонка НЕ нужен. Проверено сборкой: assembleRelease 53 МБ | контракты не менялись | низкий: только UX уведомлений; звонки/push в фоне работают как раньше - 2026-07-07 | v0.5.114 (122) | Требование владельца: звонки/push ДОЛЖНЫ работать при заблокированном телефоне. (1) Биометрическая привязка app password ОТКЛОНЕНА (вариант А) — она неустранимо блокировала приём звонка без разблокировки; откачена из main, сохранена на ветке wip/biometric-binding. App password остаётся защищён шифрованием в покое (Keystore AES-256-GCM из п.3, работает в фоне → звонки/push/приём при блокировке ОК). (2) Приватность локскрина: уведомления сообщений и звонков → VISIBILITY_PRIVATE + generic public-версия («Новое сообщение»/«Входящий звонок»), канал lockscreenVisibility=PRIVATE. Полный контент (отправитель/текст/имя звонящего) — только после разблокировки. Push у приложения plaintext, пароль для показа/звонка НЕ нужен. Проверено сборкой: assembleRelease 53 МБ | контракты не менялись | низкий: только UX уведомлений; звонки/push в фоне работают как раньше
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.4b (GPL): восстановлены затёртые копирайты Nextcloud в vendor/talk-android — точной сверкой с апстримом v23.0.0 (скачан с GitHub). 102 файла: искажённые email реальных разработчиков NC (@f7cloud.com → настоящие), холдер «F7cloud»/«F7cloud and F7cloud contributors» → «Nextcloud GmbH and Nextcloud contributors». Название продукта в заголовках оставлено «F7cloud Talk» (ребренд форка, требование trademark-политики NC). Менялись ТОЛЬКО строки копирайта в комментах, код не тронут (103+/103−). Проверено сборкой: assembleRelease 53 МБ OK | лицензирование/атрибуция (не код) | нет: функционально нейтрально, снимает нарушение GPLv3 §4 (атрибуция) - 2026-07-07 | v0.5.114 (122) | Этап 0 п.4b (GPL): восстановлены затёртые копирайты Nextcloud в vendor/talk-android — точной сверкой с апстримом v23.0.0 (скачан с GitHub). 102 файла: искажённые email реальных разработчиков NC (@f7cloud.com → настоящие), холдер «F7cloud»/«F7cloud and F7cloud contributors» → «Nextcloud GmbH and Nextcloud contributors». Название продукта в заголовках оставлено «F7cloud Talk» (ребренд форка, требование trademark-политики NC). Менялись ТОЛЬКО строки копирайта в комментах, код не тронут (103+/103−). Проверено сборкой: assembleRelease 53 МБ OK | лицензирование/атрибуция (не код) | нет: функционально нейтрально, снимает нарушение GPLv3 §4 (атрибуция)
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.4a (GPL): добавлен LICENSE (GPL-3.0-or-later) в корень репо — приложение производно от vendored Nextcloud Talk (GPL), значит весь APK под GPL; раздел «Лицензия» в README (обязательство corresponding source по тегу релиза). Восстановление затёртых копирайтов Nextcloud в vendor (п.4b) — отдельно, сверкой с апстримом v23.0.0 | лицензирование (не код) | нет - 2026-07-07 | v0.5.114 (122) | Этап 0 п.4a (GPL): добавлен LICENSE (GPL-3.0-or-later) в корень репо — приложение производно от vendored Nextcloud Talk (GPL), значит весь APK под GPL; раздел «Лицензия» в README (обязательство corresponding source по тегу релиза). Восстановление затёртых копирайтов Nextcloud в vendor (п.4b) — отдельно, сверкой с апстримом v23.0.0 | лицензирование (не код) | нет
-26
View File
@@ -1,26 +0,0 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'ru.forbion.f7cloud.core.data'
compileSdk 36
defaultConfig {
minSdk 26
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation project(':core:auth')
implementation project(':core:database')
implementation project(':core:network')
}
-2
View File
@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
-3
View File
@@ -1,3 +0,0 @@
plugins {
id 'java-library'
}
-41
View File
@@ -1,41 +0,0 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose'
}
android {
namespace 'ru.forbion.f7cloud.feature.widgets'
compileSdk 36
defaultConfig {
minSdk 26
}
buildFeatures {
compose true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation project(':core:auth')
implementation project(':core:network')
implementation project(':core:designsystem')
implementation project(':feature:files')
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation 'org.json:json:20240303'
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
implementation composeBom
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.foundation:foundation'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
implementation 'io.coil-kt:coil-compose:2.6.0'
implementation 'io.coil-kt:coil-svg:2.6.0'
}
@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
@@ -1,271 +0,0 @@
package ru.forbion.f7cloud.feature.widgets
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.UnauthorizedException
class WidgetsRepository {
suspend fun loadDashboard(session: AuthSession): List<DashboardWidgetGroup> = coroutineScope {
val client = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
val base = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/dashboard"
val widgetsDeferred = async(Dispatchers.IO) {
parseWidgets(getOcsData(client, "$base/api/v1/widgets"))
}
val layoutDeferred = async(Dispatchers.IO) {
fetchLayoutWithTimeout(client, base)
}
val itemsV2Deferred = async(Dispatchers.IO) {
parseWidgetItemsV2(getOcsData(client, "$base/api/v2/widget-items?limit=7"))
}
// recommendations есть только в API v1
val itemsV1Deferred = async(Dispatchers.IO) {
parseWidgetItemsV1(
getOcsData(
client,
buildWidgetItemsUrl(base, apiVersion = 1, widgetIds = listOf("recommendations")),
),
)
}
val allWidgets = widgetsDeferred.await().associateBy { it.id }
val layout = layoutDeferred.await()
val itemsByWidget = mergeWidgetItems(itemsV1Deferred.await(), itemsV2Deferred.await())
val widgetOrder = resolveWidgetOrder(layout, allWidgets, itemsByWidget.keys)
widgetOrder.map { widget ->
val bucket = itemsByWidget[widget.id]
DashboardWidgetGroup(
id = widget.id,
title = widget.title,
iconUrl = widget.iconUrl,
items = bucket?.items.orEmpty(),
emptyMessage = bucket?.emptyMessage,
)
}
}
private suspend fun fetchLayoutWithTimeout(
client: okhttp3.OkHttpClient,
base: String,
): List<String> = withContext(Dispatchers.IO) {
withTimeoutOrNull(LAYOUT_TIMEOUT_MS) {
runCatching {
parseLayout(getOcsData(client, "$base/api/v3/layout"))
}.getOrNull()
}?.takeIf { it.isNotEmpty() } ?: DEFAULT_LAYOUT
}
private fun buildWidgetItemsUrl(
base: String,
apiVersion: Int,
widgetIds: List<String> = emptyList(),
): String {
val builder = "$base/api/v$apiVersion/widget-items?limit=7"
.toHttpUrlOrNull()
?.newBuilder()
?: return "$base/api/v$apiVersion/widget-items?limit=7"
widgetIds.forEach { builder.addQueryParameter("widgets[]", it) }
return builder.build().toString()
}
private fun resolveWidgetOrder(
layout: List<String>,
allWidgets: Map<String, WidgetMeta>,
itemWidgetIds: Set<String>,
): List<WidgetMeta> {
val order = layout.ifEmpty { DEFAULT_LAYOUT }
val seen = mutableSetOf<String>()
val ordered = mutableListOf<WidgetMeta>()
for (id in order) {
if (!seen.add(id)) continue
val meta = allWidgets[id] ?: continue
if (id in itemWidgetIds || meta.id in allWidgets) {
ordered += meta
}
}
for (id in itemWidgetIds) {
if (!seen.add(id)) continue
allWidgets[id]?.let { ordered += it }
}
return ordered
}
private fun mergeWidgetItems(
v1: Map<String, WidgetItemsBucket>,
v2: Map<String, WidgetItemsBucket>,
): Map<String, WidgetItemsBucket> {
val ids = (v1.keys + v2.keys).toSet()
return ids.associateWith { id ->
val fromV2 = v2[id]
val fromV1 = v1[id]
when {
fromV2 != null && fromV2.items.isNotEmpty() -> fromV2
fromV1 != null && fromV1.items.isNotEmpty() -> fromV1
fromV2 != null -> fromV2
else -> fromV1 ?: WidgetItemsBucket()
}
}
}
private fun getOcsData(client: okhttp3.OkHttpClient, url: String): Any {
val jsonUrl = if (url.contains("format=")) url else {
if (url.contains("?")) "$url&format=json" else "$url?format=json"
}
val request = Request.Builder()
.url(jsonUrl)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Dashboard API HTTP ${response.code}")
}
val body = response.body!!.string()
val ocs = JSONObject(body).optJSONObject("ocs")
?: error("Некорректный ответ Dashboard API")
val meta = ocs.optJSONObject("meta")
val status = meta?.optString("status").orEmpty()
if (status.equals("failure", ignoreCase = true)) {
error(meta?.optString("message").orEmpty().ifBlank { "Dashboard API error" })
}
return ocs.opt("data") ?: JSONObject()
}
}
private fun parseLayout(data: Any): List<String> {
if (data !is JSONObject) return emptyList()
val layout = data.optJSONArray("layout") ?: return emptyList()
return buildList {
for (i in 0 until layout.length()) {
val id = layout.optString(i)
if (id.isNotBlank()) add(id)
}
}
}
private fun parseWidgets(data: Any): List<WidgetMeta> {
val out = mutableListOf<WidgetMeta>()
when (data) {
is JSONObject -> {
val keys = data.keys()
while (keys.hasNext()) {
val key = keys.next()
val obj = data.optJSONObject(key) ?: continue
out += widgetMetaFromJson(obj, key)
}
}
is JSONArray -> for (i in 0 until data.length()) {
val obj = data.optJSONObject(i) ?: continue
out += widgetMetaFromJson(obj, obj.optString("id"))
}
}
return out
}
private fun widgetMetaFromJson(obj: JSONObject, fallbackId: String): WidgetMeta {
return WidgetMeta(
id = obj.optString("id", fallbackId),
title = obj.optString("title", fallbackId),
order = obj.optInt("order", 0),
iconUrl = obj.optString("icon_url"),
)
}
private fun parseWidgetItemsV1(data: Any): Map<String, WidgetItemsBucket> {
val out = mutableMapOf<String, WidgetItemsBucket>()
if (data !is JSONObject) return out
val keys = data.keys()
while (keys.hasNext()) {
val widgetId = keys.next()
val value = data.opt(widgetId) ?: continue
val items = when (value) {
is JSONArray -> parseItemArray(value)
is JSONObject -> parseItemArray(value.optJSONArray("items") ?: JSONArray())
else -> emptyList()
}
if (items.isNotEmpty()) {
out[widgetId] = WidgetItemsBucket(items = items)
}
}
return out
}
private fun parseWidgetItemsV2(data: Any): Map<String, WidgetItemsBucket> {
val out = mutableMapOf<String, WidgetItemsBucket>()
if (data !is JSONObject) return out
val keys = data.keys()
while (keys.hasNext()) {
val widgetId = keys.next()
val widgetObj = data.optJSONObject(widgetId) ?: continue
val items = parseItemArray(widgetObj.optJSONArray("items") ?: JSONArray())
val emptyMessage = widgetObj.optString("emptyContentMessage")
.ifBlank { widgetObj.optString("halfEmptyContentMessage") }
.ifBlank { null }
if (items.isNotEmpty() || !emptyMessage.isNullOrBlank()) {
out[widgetId] = WidgetItemsBucket(items = items, emptyMessage = emptyMessage)
}
}
return out
}
private fun parseItemArray(itemsArray: JSONArray): List<WidgetItem> {
val items = mutableListOf<WidgetItem>()
for (i in 0 until itemsArray.length()) {
val item = itemsArray.optJSONObject(i) ?: continue
items += WidgetItem(
title = item.optString("title"),
subtitle = item.optString("subtitle"),
link = item.optString("link"),
iconUrl = item.optString("iconUrl"),
)
}
return items
}
companion object {
private const val LAYOUT_TIMEOUT_MS = 2_000L
private val DEFAULT_LAYOUT = listOf("recommendations", "spreed", "mail", "calendar")
}
}
private data class WidgetMeta(
val id: String,
val title: String,
val order: Int,
val iconUrl: String,
)
data class WidgetItem(
val title: String,
val subtitle: String,
val link: String,
val iconUrl: String = "",
)
data class DashboardWidgetGroup(
val id: String,
val title: String,
val iconUrl: String = "",
val items: List<WidgetItem>,
val emptyMessage: String? = null,
)
private data class WidgetItemsBucket(
val items: List<WidgetItem> = emptyList(),
val emptyMessage: String? = null,
)
@@ -1,176 +0,0 @@
package ru.forbion.f7cloud.feature.widgets
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7ListCard
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
import ru.forbion.f7cloud.feature.files.OfficeEditorLaunch
import ru.forbion.f7cloud.feature.files.OfficeFileLinks
import ru.forbion.f7cloud.feature.files.OfficeFiles
import ru.forbion.f7cloud.feature.files.OfficeWarmup
@Composable
fun WidgetsScreen(
session: AuthSession,
modifier: Modifier = Modifier,
onUnauthorized: () -> Unit = {},
onOpenOfficeEditor: (OfficeEditorLaunch) -> Unit = {},
onOpenLink: (String) -> Unit = {},
) {
val vm: WidgetsViewModel = viewModel()
val state by vm.state.collectAsState()
LaunchedEffect(session.serverUrl, session.username) {
OfficeWarmup.warm(session)
vm.load(session)
}
LaunchedEffect(state.unauthorized) {
if (state.unauthorized) onUnauthorized()
}
LaunchedEffect(state.editorLaunch) {
val launch = state.editorLaunch ?: return@LaunchedEffect
onOpenOfficeEditor(launch)
vm.clearEditorLaunch()
}
F7ModuleScreen(
modifier = modifier,
loading = state.loading && state.groups.isEmpty(),
error = state.error,
onRefresh = { vm.load(session) },
) {
if (!state.loading && state.groups.isEmpty() && state.error.isNullOrBlank()) {
Text(
text = "Нет виджетов на главной. Настройте их в веб-интерфейсе.",
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
)
}
if (state.openingFile != null) {
CircularProgressIndicator(color = F7Colors.Primary)
Text(
text = "Открываем «${state.openingFile}»…",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(state.groups, key = { it.id }) { group ->
F7ListCard {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (group.iconUrl.isNotBlank()) {
AsyncImage(
model = group.iconUrl,
contentDescription = group.title,
modifier = Modifier.size(24.dp),
contentScale = ContentScale.Fit,
)
}
Text(
group.title.ifBlank { group.id },
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
}
group.items.forEach { item ->
val canOpenOffice = item.link.isNotBlank() &&
OfficeFileLinks.parseFileId(item.link, session.serverUrl) != null &&
(OfficeFiles.isOfficeFile(item.title) ||
OfficeFiles.isOfficeFile(item.link.substringAfterLast('/')))
val canOpenLink = item.link.isNotBlank() && !canOpenOffice
Row(
modifier = Modifier
.fillMaxWidth()
.then(
when {
canOpenOffice -> Modifier.clickable { vm.openWidgetItem(session, item) }
canOpenLink -> Modifier.clickable { onOpenLink(item.link) }
else -> Modifier
},
)
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (item.iconUrl.isNotBlank()) {
AsyncImage(
model = item.iconUrl,
contentDescription = null,
modifier = Modifier.size(32.dp),
contentScale = ContentScale.Fit,
)
}
androidx.compose.foundation.layout.Column(modifier = Modifier.weight(1f)) {
Text(
item.title.ifBlank { "" },
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (item.subtitle.isNotBlank()) {
Text(
item.subtitle,
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
if (canOpenOffice) {
Text(
text = "Открыть в Office",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
} else if (canOpenLink) {
Text(
text = "Открыть",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
}
}
}
}
if (group.items.isEmpty()) {
Text(
text = group.emptyMessage ?: "Нет элементов",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
}
}
}
}
}
}
@@ -1,12 +0,0 @@
package ru.forbion.f7cloud.feature.widgets
import ru.forbion.f7cloud.feature.files.OfficeEditorLaunch
data class WidgetsUiState(
val loading: Boolean = false,
val groups: List<DashboardWidgetGroup> = emptyList(),
val error: String? = null,
val unauthorized: Boolean = false,
val openingFile: String? = null,
val editorLaunch: OfficeEditorLaunch? = null,
)
@@ -1,79 +0,0 @@
package ru.forbion.f7cloud.feature.widgets
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
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.AuthSession
import ru.forbion.f7cloud.core.network.UnauthorizedException
import ru.forbion.f7cloud.feature.files.OfficeFileLinks
import ru.forbion.f7cloud.feature.files.OfficeFileOpener
import ru.forbion.f7cloud.feature.files.OfficeFiles
class WidgetsViewModel(
private val repository: WidgetsRepository = WidgetsRepository(),
private val officeFileOpener: OfficeFileOpener = OfficeFileOpener(),
) : ViewModel() {
private val _state = MutableStateFlow(WidgetsUiState())
val state: StateFlow<WidgetsUiState> = _state.asStateFlow()
fun load(session: AuthSession) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, error = null)
runCatching { repository.loadDashboard(session) }
.onSuccess { groups ->
_state.value = _state.value.copy(loading = false, groups = groups)
}
.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun openWidgetItem(session: AuthSession, item: WidgetItem) {
if (item.link.isBlank()) return
val fileId = OfficeFileLinks.parseFileId(item.link, session.serverUrl)
val isOffice = OfficeFiles.isOfficeFile(item.title) ||
OfficeFiles.isOfficeFile(item.link.substringAfterLast('/'))
if (fileId == null || !isOffice) {
_state.value = _state.value.copy(
error = "Открытие через Office доступно только для документов Word/Excel",
)
return
}
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(openingFile = item.title, error = null)
runCatching {
officeFileOpener.prepareLaunch(
session,
fileId,
OfficeFileLinks.titleFromLink(item.link, item.title),
)
}
.onSuccess { launch ->
_state.value = _state.value.copy(
openingFile = null,
editorLaunch = launch,
)
}
.onFailure { t ->
_state.value = _state.value.copy(
openingFile = null,
error = t.message ?: "Не удалось открыть документ",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun clearEditorLaunch() {
_state.value = _state.value.copy(editorLaunch = null)
}
}
+6 -1
View File
@@ -1,8 +1,13 @@
org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC -Dfile.encoding=UTF-8
org.gradle.parallel=false org.gradle.parallel=true
org.gradle.caching=true org.gradle.caching=true
android.useAndroidX=true android.useAndroidX=true
# enableJetifier ОБЯЗАТЕЛЕН: vendor-форк talk-android тянет legacy-зависимости с
# android.support.* (напр. FlexibleAdapter → android.support.v7.widget.RecyclerView) —
# без jetifier сборка падает (проверено). Убрать можно только после чистки vendor-депов.
android.enableJetifier=true android.enableJetifier=true
# nonTransitiveRClass ОСТАВЛЕН false: vendor ссылается на R транзитивно (R.color.transparent,
# R.id.container из зависимостей) — true ломает ~сотни ссылок в форке (проверено).
android.nonTransitiveRClass=false android.nonTransitiveRClass=false
kotlin.code.style=official kotlin.code.style=official
f7cloudAsLibrary=true f7cloudAsLibrary=true
-2
View File
@@ -19,12 +19,10 @@ rootProject.name = "F7CloudMobile"
include(":app") include(":app")
include(":core:auth") include(":core:auth")
include(":core:data")
include(":core:database") include(":core:database")
include(":core:designsystem") include(":core:designsystem")
include(":core:network") include(":core:network")
include(":core:push") include(":core:push")
include(":core:ui")
include(":feature:files") include(":feature:files")
include(":feature:talk") include(":feature:talk")
include(":feature:talk-native") include(":feature:talk-native")