Initial import of f7cloud-mobile native Android app.

Kotlin/Compose client for F7cloud (mail, files, talk, calendar, contacts, tasks, support).
Current version: 0.5.113 (build 121).
This commit is contained in:
F7cloud Mobile
2026-07-07 12:05:18 +03:00
commit fd17df80a8
1789 changed files with 246889 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
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'
}
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
@@ -0,0 +1,271 @@
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,
)
@@ -0,0 +1,176 @@
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,
)
}
}
}
}
}
}
@@ -0,0 +1,12 @@
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,
)
@@ -0,0 +1,79 @@
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)
}
}