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
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".ImageViewerActivity"
android:exported="false"
android:theme="@android:style/Theme.Material.Light.NoActionBar" />
<provider
android:name=".F7FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/f7_file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,33 @@
package ru.forbion.f7cloud.feature.files
import android.webkit.MimeTypeMap
import java.util.Locale
object ArchiveFiles {
private val EXT = setOf(
"zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "tbz2", "tbz", "xz", "txz",
"zst", "tzst", "lzma", "cab", "lz", "lzo",
)
fun isArchive(name: String): Boolean {
val lower = name.lowercase(Locale.ROOT)
val dot = lower.lastIndexOf('.')
if (dot == -1) return false
return lower.substring(dot + 1) in EXT
}
fun mimeType(name: String): String {
val ext = name.lowercase(Locale.ROOT).substringAfterLast('.', missingDelimiterValue = "")
MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)?.let { return it }
return when (ext) {
"rar" -> "application/x-rar-compressed"
"7z" -> "application/x-7z-compressed"
"tar" -> "application/x-tar"
"gz", "tgz" -> "application/gzip"
"bz2", "tbz", "tbz2" -> "application/x-bzip2"
"xz", "txz" -> "application/x-xz"
"zip" -> "application/zip"
else -> "application/octet-stream"
}
}
}
@@ -0,0 +1,6 @@
package ru.forbion.f7cloud.feature.files
import androidx.core.content.FileProvider
/** Distinct FileProvider so manifest merger keeps F7 files separate from talk-android. */
class F7FileProvider : FileProvider()
@@ -0,0 +1,40 @@
package ru.forbion.f7cloud.feature.files
import android.content.Context
import okhttp3.Request
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.OcsUserResolver
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.UnauthorizedException
import ru.forbion.f7cloud.core.network.davFileUrl
import java.io.File
class FileDownloadRepository(private val context: Context) {
fun download(session: AuthSession, relativePath: String, fileName: String): File {
val client = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
val url = davFileUrl(session.serverUrl, userId, relativePath)
val request = Request.Builder()
.url(url)
.header("Accept", "*/*")
.get()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Не удалось скачать файл (HTTP ${response.code})")
}
val dir = File(context.cacheDir, "f7_files").apply { mkdirs() }
val safeName = fileName.replace(Regex("[\\\\/:*?\"<>|]"), "_")
val out = File(dir, safeName)
response.body!!.byteStream().use { input ->
out.outputStream().use { output -> input.copyTo(output) }
}
return out
}
}
}
@@ -0,0 +1,27 @@
package ru.forbion.f7cloud.feature.files
object FileIcons {
/** Те же SVG, что отдаёт F7cloud в `iconUrl` виджетов (mimeTypeIcon). */
fun iconUrl(serverUrl: String, name: String, isDirectory: Boolean): String {
val base = "${serverUrl.trimEnd('/')}/core/img/filetypes"
val icon = if (isDirectory) "folder" else iconFileName(name)
return "$base/$icon.svg"
}
private fun iconFileName(name: String): String {
val ext = name.substringAfterLast('.', "").lowercase()
return when (ext) {
"doc", "docx", "dot", "dotx", "odt", "rtf" -> "x-office-document"
"xls", "xlsx", "xlsm", "xlt", "xltx", "ods", "csv" -> "x-office-spreadsheet"
"ppt", "pptx", "pot", "potx", "odp" -> "x-office-presentation"
"odg" -> "x-office-drawing"
"pdf" -> "application-pdf"
"jpg", "jpeg", "png", "gif", "webp", "bmp", "svg", "heic" -> "image"
"mp3", "wav", "ogg", "flac", "aac" -> "audio"
"mp4", "mkv", "avi", "mov", "webm" -> "video"
"zip", "rar", "7z", "tar", "gz" -> "package-x-generic"
"txt", "md" -> "text"
else -> "file"
}
}
}
@@ -0,0 +1,12 @@
package ru.forbion.f7cloud.feature.files
data class FileItem(
val name: String,
val isDirectory: Boolean,
val relativePath: String = name,
val fileId: Long? = null,
val lastModified: Long? = null,
val size: Long? = null,
val mimeType: String? = null,
val favorite: Boolean = false,
)
@@ -0,0 +1,6 @@
package ru.forbion.f7cloud.feature.files
sealed class FileOpenAction {
data class Image(val path: String, val title: String) : FileOpenAction()
data class External(val path: String, val mimeType: String, val title: String) : FileOpenAction()
}
@@ -0,0 +1,303 @@
package ru.forbion.f7cloud.feature.files
import okhttp3.FormBody
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
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
import ru.forbion.f7cloud.core.network.applyOcsJson
import ru.forbion.f7cloud.core.network.isOcsSuccess
import ru.forbion.f7cloud.core.network.ocsData
import ru.forbion.f7cloud.core.network.ocsMeta
import ru.forbion.f7cloud.core.network.parseJsonObject
class FilesApiRepository {
fun fetchUserConfig(session: AuthSession): FilesUserConfig {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/files/api/v1/configs"
val request = Request.Builder().url(url).get().build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Files config HTTP ${response.code}")
}
val data = JSONObject(response.body!!.string()).optJSONObject("data") ?: JSONObject()
return FilesUserConfig(
sortFavoritesFirst = data.optBoolean("sort_favorites_first", true),
sortFoldersFirst = data.optBoolean("sort_folders_first", true),
folderTree = data.optBoolean("folder_tree", true),
defaultView = data.optString("default_view", "files"),
showHidden = data.optBoolean("show_hidden", false),
showMimeColumn = data.optBoolean("show_mime_column", false),
showExtensions = data.optBoolean("show_files_extensions", true),
cropImagePreviews = data.optBoolean("crop_image_previews", true),
)
}
}
fun saveUserConfig(session: AuthSession, key: String, value: String) {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/files/api/v1/config/$key"
val request = Request.Builder()
.url(url)
.put(value.toRequestBody("text/plain".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Files config save HTTP ${response.code}")
}
}
fun fetchStorageStats(session: AuthSession): FilesStorageStats {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/files/api/v1/stats"
val request = Request.Builder().url(url).get().build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
return FilesStorageStats()
}
val data = JSONObject(response.body!!.string()).optJSONObject("data") ?: JSONObject()
return FilesStorageStats(
usedBytes = data.optLong("used", 0L),
totalBytes = data.optLong("total", 0L),
usedLabel = data.optString("usage", ""),
)
}
}
fun fetchFolderTree(session: AuthSession, path: String = "/", depth: Int = 2): List<FilesFolderTreeNode> {
val client = authedClient(session)
val encodedPath = java.net.URLEncoder.encode(path, Charsets.UTF_8.name())
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/files/api/v1/folder-tree" +
"?path=$encodedPath&depth=$depth&format=json"
val request = Request.Builder().url(url).applyOcsJson().get().build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
return emptyList()
}
val root = parseJsonObject(response.body!!.string(), "дерево папок")
val data = root.optJSONObject("ocs")?.opt("data")
val nodes = when (data) {
is JSONArray -> data
else -> JSONArray()
}
return (0 until nodes.length()).mapNotNull { index ->
parseTreeNode(nodes.optJSONObject(index), "")
}
}
}
fun fetchRecentFiles(session: AuthSession): List<FileItem> {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/files/api/v1/recent/"
val request = Request.Builder().url(url).get().build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Recent files HTTP ${response.code}")
}
val files = JSONObject(response.body!!.string()).optJSONArray("files") ?: JSONArray()
return (0 until files.length()).mapNotNull { index ->
val file = files.optJSONObject(index) ?: return@mapNotNull null
val name = file.optString("name").ifBlank { file.optString("basename") }
val path = file.optString("path", "/").trim('/')
val relative = if (path.isBlank()) name else "$path/$name".trim('/')
FileItem(
name = name,
isDirectory = file.optInt("type") == 2 || file.optString("type") == "dir",
relativePath = relative,
fileId = file.optLong("id").takeIf { it > 0L },
lastModified = file.optLong("mtime").takeIf { it > 0L }?.times(1000L),
size = file.optLong("size").takeIf { it >= 0L },
mimeType = file.optString("mimetype").ifBlank { null },
favorite = file.optBoolean("favorite"),
)
}
}
}
fun searchFiles(session: AuthSession, query: String): List<FilesSearchHit> {
if (query.isBlank()) return emptyList()
val client = authedClient(session)
val encoded = java.net.URLEncoder.encode(query, Charsets.UTF_8.name())
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/search/providers/files/search" +
"?term=$encoded&format=json"
val request = Request.Builder().url(url).applyOcsJson().get().build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
return emptyList()
}
val data = parseJsonObject(response.body!!.string(), "поиск файлов").ocsData() ?: JSONObject()
val entries = data.optJSONArray("entries") ?: JSONArray()
return (0 until entries.length()).mapNotNull { index ->
val entry = entries.optJSONObject(index) ?: return@mapNotNull null
val title = entry.optString("title").ifBlank { entry.optString("name") }
val path = entry.optJSONObject("attributes")
?.optString("path")
?.trim('/')
.orEmpty()
val relative = when {
path.isBlank() -> title
path.endsWith(title) -> path
else -> "$path/$title".trim('/')
}
FilesSearchHit(
name = title,
path = relative,
isDirectory = entry.optString("type") == "folder",
fileId = entry.optLong("fileId").takeIf { it > 0L },
)
}
}
}
private fun parseTreeNode(json: JSONObject?, parentPath: String): FilesFolderTreeNode? {
if (json == null) return null
val basename = json.optString("basename").ifBlank { json.optString("displayName") }
if (basename.isBlank()) return null
val path = buildRelativePath(parentPath.trim('/'), basename)
val childrenJson = json.optJSONArray("children") ?: JSONArray()
val children = (0 until childrenJson.length()).mapNotNull { index ->
parseTreeNode(childrenJson.optJSONObject(index), path)
}
return FilesFolderTreeNode(
id = json.optLong("id"),
basename = basename,
displayName = json.optString("displayName", basename),
path = path,
children = children,
)
}
fun createPublicShareLink(session: AuthSession, path: String, password: String = ""): String {
val client = authedClient(session)
val normalizedPath = if (path.startsWith("/")) path else "/$path"
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/files_sharing/api/v1/shares"
val body = FormBody.Builder()
.add("shareType", "3")
.add("path", normalizedPath)
.add("shareWith", password)
.build()
val request = Request.Builder().url(url).applyOcsJson().post(body).build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Не удалось создать ссылку (HTTP ${response.code})")
}
val root = parseJsonObject(response.body!!.string(), "создание ссылки")
val meta = root.ocsMeta()
if (!isOcsSuccess(meta)) {
error(meta?.optString("message") ?: "Не удалось создать ссылку")
}
val shareUrl = root.ocsData()?.optString("url").orEmpty()
if (shareUrl.isBlank()) error("Не удалось создать ссылку")
return shareUrl
}
}
fun fetchShares(session: AuthSession, relativePath: String): List<FileShareEntry> {
val client = authedClient(session)
val path = "/" + relativePath.trim('/')
val encodedPath = java.net.URLEncoder.encode(path, Charsets.UTF_8.name())
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/files_sharing/api/v1/shares" +
"?path=$encodedPath&reshares=true&format=json"
val request = Request.Builder().url(url).applyOcsJson().get().build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) return emptyList()
val root = parseJsonObject(response.body!!.string(), "общий доступ")
val data = root.optJSONObject("ocs")?.opt("data")
val array = when (data) {
is JSONArray -> data
else -> JSONArray()
}
return (0 until array.length()).mapNotNull { index ->
val share = array.optJSONObject(index) ?: return@mapNotNull null
val shareType = share.optInt("share_type", -1)
val label = when (shareType) {
3 -> share.optString("note").ifBlank { "Ссылка" }
0 -> share.optString("share_with_displayname")
.ifBlank { share.optString("share_with") }
else -> share.optString("share_with_displayname")
.ifBlank { share.optString("uid_owner") }
.ifBlank { share.optString("share_with") }
}.ifBlank { "Общий доступ" }
FileShareEntry(
id = share.optLong("id"),
shareType = shareType,
label = label,
permissionsLabel = formatSharePermissions(share.optInt("permissions", 0)),
shareWith = share.optString("share_with"),
)
}
}
}
fun fetchFileActivity(session: AuthSession, fileId: Long): List<FileActivityEntry> {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/activity/api/v2/activity" +
"?format=json&object_type=files&object_id=$fileId&limit=50&sort=desc"
val request = Request.Builder().url(url).applyOcsJson().get().build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code == 204 || !response.isSuccessful || response.body == null) return emptyList()
val root = parseJsonObject(response.body!!.string(), "события")
val data = root.optJSONObject("ocs")?.opt("data")
val array = when (data) {
is JSONArray -> data
else -> JSONArray()
}
return (0 until array.length()).mapNotNull { index ->
val activity = array.optJSONObject(index) ?: return@mapNotNull null
val message = activity.optString("message").ifBlank { activity.optString("subject") }
if (message.isBlank()) return@mapNotNull null
FileActivityEntry(
id = activity.optLong("activity_id"),
author = activity.optString("user").ifBlank { "Пользователь" },
message = message,
timestamp = parseActivityTimestamp(activity),
)
}
}
}
private fun parseActivityTimestamp(activity: JSONObject): Long {
val datetime = activity.optString("datetime")
if (datetime.isNotBlank()) {
runCatching {
java.time.Instant.parse(datetime).toEpochMilli()
}.getOrNull()?.let { return it }
}
return activity.optLong("timestamp", 0L).takeIf { it > 0L }?.times(1000L) ?: 0L
}
private fun formatSharePermissions(permissions: Int): String {
val read = permissions and 1 != 0
val update = permissions and 2 != 0
val create = permissions and 4 != 0
val delete = permissions and 8 != 0
val share = permissions and 16 != 0
return when {
update || create || delete -> "Для редактирования"
share && read -> "Для просмотра и обмена"
read -> "Для просмотра"
else -> "Ограниченный доступ"
}
}
private fun buildRelativePath(parent: String, name: String): String {
val base = parent.trim('/')
return if (base.isEmpty()) name else "$base/$name"
}
private fun authedClient(session: AuthSession): OkHttpClient =
NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,224 @@
package ru.forbion.f7cloud.feature.files
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import ru.forbion.f7cloud.core.designsystem.F7Colors
enum class FilesCreateAction {
UploadFiles,
UploadFolders,
NewFolder,
FileRequest,
NewDiagram,
NewBoard,
NewPresentation,
NewSpreadsheet,
NewDocument,
NewTextFile,
TemplateFolder,
FolderDescription,
}
@Composable
fun FilesCreateMenu(
serverUrl: String,
visible: Boolean,
onDismiss: () -> Unit,
onAction: (FilesCreateAction) -> Unit,
modifier: Modifier = Modifier,
bottomOffset: androidx.compose.ui.unit.Dp = 88.dp,
) {
if (!visible) return
val base = serverUrl.trimEnd('/')
Box(modifier = modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = bottomOffset)
.background(Color.Black.copy(alpha = 0.18f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onDismiss,
),
)
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(start = 16.dp, end = 16.dp, bottom = bottomOffset)
.navigationBarsPadding()
.widthIn(max = 360.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.shadow(
elevation = 12.dp,
shape = RoundedCornerShape(16.dp),
spotColor = Color.Black.copy(alpha = 0.12f),
)
.clip(RoundedCornerShape(16.dp))
.background(F7Colors.SecondaryButtonBg)
.border(1.dp, F7Colors.Border, RoundedCornerShape(16.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {},
)
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
FilesCreateSectionHeader("Загрузить с устройства")
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/upload-black.svg",
label = "Загрузить файлы",
onClick = { onDismiss(); onAction(FilesCreateAction.UploadFiles) },
)
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/folder-black.svg",
label = "Загрузить папки",
onClick = { onDismiss(); onAction(FilesCreateAction.UploadFolders) },
)
FilesCreateDivider()
FilesCreateSectionHeader("Создать новое")
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/add-folder-black.svg",
label = "Новая папка",
onClick = { onDismiss(); onAction(FilesCreateAction.NewFolder) },
)
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/plus-black.svg",
label = "Запрос на создание файла",
onClick = { onDismiss(); onAction(FilesCreateAction.FileRequest) },
)
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/file-presentation.svg",
label = "Новая диаграмма",
onClick = { onDismiss(); onAction(FilesCreateAction.NewDiagram) },
)
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/local-edit-black.svg",
label = "Новая доска",
onClick = { onDismiss(); onAction(FilesCreateAction.NewBoard) },
)
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/file-presentation.svg",
label = "Новая презентация",
onClick = { onDismiss(); onAction(FilesCreateAction.NewPresentation) },
)
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/file-sheet.svg",
label = "Новая таблица",
onClick = { onDismiss(); onAction(FilesCreateAction.NewSpreadsheet) },
)
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/file-doc-docx.svg",
label = "Новый документ",
onClick = { onDismiss(); onAction(FilesCreateAction.NewDocument) },
)
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/add-text-file-black.svg",
label = "Новый текстовый файл",
onClick = { onDismiss(); onAction(FilesCreateAction.NewTextFile) },
)
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/plus-black.svg",
label = "Создать папку шаблонов",
onClick = { onDismiss(); onAction(FilesCreateAction.TemplateFolder) },
)
FilesCreateDivider()
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/edit-pencil-black.svg",
label = "Добавить описание папки",
onClick = { onDismiss(); onAction(FilesCreateAction.FolderDescription) },
)
}
Box(
modifier = Modifier
.padding(top = 2.dp)
.size(width = 14.dp, height = 8.dp)
.clip(RoundedCornerShape(bottomStart = 2.dp, bottomEnd = 2.dp))
.background(F7Colors.SecondaryButtonBg)
.border(1.dp, F7Colors.Border, RoundedCornerShape(2.dp)),
)
}
}
}
@Composable
private fun FilesCreateSectionHeader(text: String) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium,
color = F7Colors.TextSecondary,
)
}
@Composable
private fun FilesCreateDivider() {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
color = F7Colors.Border,
)
}
@Composable
private fun FilesCreateMenuItem(
iconUrl: String,
label: String,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
AsyncImage(
model = iconUrl,
contentDescription = null,
modifier = Modifier.size(20.dp),
contentScale = ContentScale.Fit,
)
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextPrimary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -0,0 +1,148 @@
package ru.forbion.f7cloud.feature.files
enum class FilesBrowseMode {
AllFiles,
Personal,
Recent,
Favorites,
}
enum class FilesSortColumn {
Name,
Type,
Modified,
}
enum class FilesSortDirection {
Asc,
Desc,
}
data class FilesUserConfig(
val sortFavoritesFirst: Boolean = true,
val sortFoldersFirst: Boolean = true,
val folderTree: Boolean = true,
val defaultView: String = "files",
val showHidden: Boolean = false,
val showMimeColumn: Boolean = false,
val showExtensions: Boolean = true,
val cropImagePreviews: Boolean = true,
)
data class FilesStorageStats(
val usedBytes: Long = 0L,
val totalBytes: Long = 0L,
val usedLabel: String = "",
)
data class FilesFolderTreeNode(
val id: Long,
val basename: String,
val displayName: String,
val path: String,
val children: List<FilesFolderTreeNode> = emptyList(),
)
data class FilesSearchHit(
val name: String,
val path: String,
val isDirectory: Boolean,
val fileId: Long? = null,
)
enum class FileContextAction {
Favorite,
Details,
Sharing,
Tags,
Rename,
MoveCopy,
Reminder,
OpenLocally,
Download,
Archive,
Delete,
}
enum class FileDetailsTab {
Sharing,
Events,
}
data class FileContextMenuEntry(
val action: FileContextAction,
val label: String,
val iconPath: String,
val showChevron: Boolean = false,
val destructive: Boolean = false,
)
data class FileShareEntry(
val id: Long,
val shareType: Int,
val label: String,
val permissionsLabel: String,
val shareWith: String = "",
)
data class FileActivityEntry(
val id: Long,
val author: String,
val message: String,
val timestamp: Long,
)
data class FileDetailsData(
val shares: List<FileShareEntry> = emptyList(),
val activities: List<FileActivityEntry> = emptyList(),
val error: String? = null,
)
fun buildFileContextMenu(item: FileItem): List<FileContextMenuEntry> = buildList {
add(
FileContextMenuEntry(
action = FileContextAction.Favorite,
label = if (item.favorite) "Удалить из избранного" else "Добавить в избранное",
iconPath = if (item.favorite) "files/star-green-full.svg" else "files/star-gray.svg",
),
)
add(FileContextMenuEntry(FileContextAction.Details, "Подробно", "files/info-icon-black.svg"))
add(FileContextMenuEntry(FileContextAction.Sharing, "Варианты обмена", "files/button-shared-for-files.svg"))
add(FileContextMenuEntry(FileContextAction.Tags, "Управление метками", "files/tags-gray.svg"))
add(FileContextMenuEntry(FileContextAction.Rename, "Переименовать", "files/rename-pencil-gray.svg"))
add(FileContextMenuEntry(FileContextAction.MoveCopy, "Переместить или копировать", "files/copy-move-gray.svg"))
add(
FileContextMenuEntry(
action = FileContextAction.Reminder,
label = "Установить напоминание",
iconPath = "files/grid-files-gray.svg",
showChevron = true,
),
)
if (!item.isDirectory) {
add(FileContextMenuEntry(FileContextAction.OpenLocally, "Открыть локально", "files/local-edit-black.svg"))
add(FileContextMenuEntry(FileContextAction.Download, "Скачать", "files/download-icon-gray.svg"))
}
add(
FileContextMenuEntry(
action = FileContextAction.Archive,
label = "Архивировать в...",
iconPath = "files/folder-gray.svg",
showChevron = true,
),
)
add(
FileContextMenuEntry(
action = FileContextAction.Delete,
label = if (item.isDirectory) "Удалить папку" else "Удалить файл",
iconPath = "files/trash-gray.svg",
destructive = true,
),
)
}
enum class FilesBulkAction {
Favorite,
Download,
Delete,
}
@@ -0,0 +1,170 @@
package ru.forbion.f7cloud.feature.files
import android.content.Context
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.database.F7Database
import ru.forbion.f7cloud.core.database.FileEntity
import ru.forbion.f7cloud.core.network.DavClient
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.auth.OcsUserResolver
import ru.forbion.f7cloud.core.network.UnauthorizedException
import ru.forbion.f7cloud.core.network.davFileUrl
import ru.forbion.f7cloud.core.network.davFolderUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
class FilesRepository(context: Context) {
private val db = F7Database.get(context)
suspend fun listFolder(session: AuthSession, relativePath: String = ""): List<FileItem> {
val client = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
val folderUrl = davFolderUrl(session.serverUrl, userId, relativePath)
val entries = DavClient.propfind(client, folderUrl)
val items = entries.map {
FileItem(
name = it.name,
isDirectory = it.isDirectory,
relativePath = buildRelativePath(relativePath, it.name),
fileId = it.fileId,
lastModified = it.lastModified,
size = it.size,
mimeType = it.mimeType,
favorite = it.favorite,
)
}
if (relativePath.isBlank()) {
val dao = db.filesDao()
dao.clear(session.serverUrl, session.username)
dao.insertAll(
items.map {
FileEntity(
serverUrl = session.serverUrl,
username = session.username,
name = it.name,
isDirectory = it.isDirectory,
)
},
)
}
return items
}
suspend fun createFolder(session: AuthSession, relativeFolderPath: String, folderName: String) {
ensureFolder(session, buildRelativePath(relativeFolderPath, folderName))
}
suspend fun ensureFolder(session: AuthSession, relativePath: String) {
val client = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
val folderUrl = davFolderUrl(session.serverUrl, userId, relativePath)
runCatching { DavClient.mkcol(client, folderUrl) }
}
suspend fun uploadFile(
session: AuthSession,
relativeFolderPath: String,
fileName: String,
bytes: ByteArray,
mimeType: String?,
) {
val client = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
val path = buildRelativePath(relativeFolderPath, fileName)
val fileUrl = davFileUrl(session.serverUrl, userId, path)
val mediaType = (mimeType?.takeIf { it.isNotBlank() } ?: "application/octet-stream")
.toMediaType()
DavClient.put(client, fileUrl, bytes.toRequestBody(mediaType))
}
suspend fun listCached(session: AuthSession): List<FileItem> {
return db.filesDao()
.list(session.serverUrl, session.username)
.map {
FileItem(
name = it.name,
isDirectory = it.isDirectory,
relativePath = it.name,
)
}
}
fun buildRelativePath(parent: String, name: String): String {
val base = parent.trim('/')
return if (base.isEmpty()) name else "$base/$name"
}
suspend fun deleteItem(session: AuthSession, relativePath: String, isDirectory: Boolean) {
val client = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
val url = if (isDirectory) {
davFolderUrl(session.serverUrl, userId, relativePath)
} else {
davFileUrl(session.serverUrl, userId, relativePath)
}
DavClient.delete(client, url)
}
suspend fun renameItem(session: AuthSession, relativePath: String, newName: String, isDirectory: Boolean) {
val client = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
val parent = relativePath.substringBeforeLast('/', missingDelimiterValue = "")
val destinationPath = buildRelativePath(parent, newName)
val sourceUrl = if (isDirectory) {
davFolderUrl(session.serverUrl, userId, relativePath)
} else {
davFileUrl(session.serverUrl, userId, relativePath)
}
val destinationUrl = if (isDirectory) {
davFolderUrl(session.serverUrl, userId, destinationPath)
} else {
davFileUrl(session.serverUrl, userId, destinationPath)
}
DavClient.move(client, sourceUrl, destinationUrl)
}
suspend fun setFavorite(session: AuthSession, relativePath: String, favorite: Boolean, isDirectory: Boolean) {
val client = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
val url = if (isDirectory) {
davFolderUrl(session.serverUrl, userId, relativePath)
} else {
davFileUrl(session.serverUrl, userId, relativePath)
}
DavClient.setFavorite(client, url, favorite)
}
fun uniqueName(existingNames: Collection<String>, baseName: String, extension: String): String {
val ext = extension.takeIf { it.startsWith('.') } ?: ".$extension"
val stem = baseName.removeSuffix(ext).ifBlank { baseName }
val first = "$stem$ext"
if (first !in existingNames) return first
var index = 1
while ("$stem ($index)$ext" in existingNames) index++
return "$stem ($index)$ext"
}
}
@@ -0,0 +1,432 @@
package ru.forbion.f7cloud.feature.files
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
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.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7AlertDialog
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FilesScreen(
session: AuthSession,
modifier: Modifier = Modifier,
uploadRequest: Int = 0,
pushRefreshRequest: Int = 0,
openFileId: Long? = null,
sidebarOpen: Boolean = false,
onSidebarOpenChange: (Boolean) -> Unit = {},
settingsOpen: Boolean = false,
onSettingsOpenChange: (Boolean) -> Unit = {},
onOpenFileConsumed: () -> Unit = {},
onUnauthorized: () -> Unit = {},
onOpenOfficeEditor: (OfficeEditorLaunch) -> Unit = {},
) {
val context = LocalContext.current
val vm: FilesViewModel = viewModel(factory = FilesViewModel.Factory(context))
val state by vm.state.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
var showCreateMenu by remember { mutableStateOf(false) }
var showNewFolderDialog by remember { mutableStateOf(false) }
var newFolderName by remember { mutableStateOf("") }
var contextMenuItem by remember { mutableStateOf<FileItem?>(null) }
var contextMenuAnchor by remember { mutableStateOf<Rect?>(null) }
var bulkMenuAnchor by remember { mutableStateOf<Rect?>(null) }
var bulkMenuOpen by remember { mutableStateOf(false) }
var deleteConfirmItem by remember { mutableStateOf<FileItem?>(null) }
var deleteConfirmBulk by remember { mutableStateOf(false) }
F7OverlayDismissHandler(
enabled = state.detailsItem != null,
onDismiss = vm::hideDetails,
)
F7OverlayDismissHandler(
enabled = showCreateMenu,
onDismiss = { showCreateMenu = false },
)
F7OverlayDismissHandler(
enabled = contextMenuItem != null,
onDismiss = { contextMenuItem = null },
)
val uploadFilesLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments(),
) { uris ->
if (uris.isNotEmpty()) {
vm.uploadFromUris(context, session, uris)
}
}
val uploadFolderLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { uri ->
if (uri != null) {
context.contentResolver.takePersistableUriPermission(
uri,
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
vm.uploadFolderTree(context, session, uri)
}
}
LaunchedEffect(session.serverUrl, session.username, session.davUserId) {
OfficeWarmup.warm(session)
vm.load(session)
vm.loadSidebarData(session)
}
LaunchedEffect(uploadRequest) {
if (uploadRequest > 0) showCreateMenu = true
}
LaunchedEffect(pushRefreshRequest) {
if (pushRefreshRequest > 0) vm.load(session)
}
LaunchedEffect(openFileId) {
val fileId = openFileId ?: return@LaunchedEffect
vm.openFileById(session, fileId)
onOpenFileConsumed()
}
LaunchedEffect(state.unauthorized) {
if (state.unauthorized) onUnauthorized()
}
LaunchedEffect(state.editorLaunch) {
val launch = state.editorLaunch ?: return@LaunchedEffect
onOpenOfficeEditor(launch)
vm.clearEditorLaunch()
}
LaunchedEffect(state.openAction) {
when (val action = state.openAction) {
null -> Unit
is FileOpenAction.Image -> {
context.startActivity(ImageViewerActivity.intent(context, action.path, action.title))
vm.clearOpenAction()
}
is FileOpenAction.External -> {
LocalFileOpener.openExternal(context, File(action.path), action.mimeType)
vm.clearOpenAction()
}
}
}
LaunchedEffect(state.snackbar) {
val msg = state.snackbar ?: return@LaunchedEffect
snackbarHostState.showSnackbar(msg)
vm.clearSnackbar()
}
val displayItems = vm.displayItems(state)
val pathLabel = when (state.browseMode) {
FilesBrowseMode.AllFiles -> if (state.currentPath.isBlank()) "Все файлы" else state.currentPath.replace("/", " ")
FilesBrowseMode.Personal -> "Личные файлы"
FilesBrowseMode.Recent -> "Недавно изменённые"
FilesBrowseMode.Favorites -> "Избранные"
}
val allSelected = displayItems.isNotEmpty() && displayItems.all { it.relativePath in state.selectedPaths }
val selectionMode = state.selectedPaths.isNotEmpty()
val isRefreshing = state.loading && displayItems.isNotEmpty()
val listState = rememberLazyListState()
Box(modifier = modifier.fillMaxSize()) {
F7ModuleScreen(
modifier = Modifier.fillMaxSize(),
loading = state.loading && displayItems.isEmpty(),
error = state.error,
headerActions = {},
) {
Column(modifier = Modifier.fillMaxSize()) {
if (state.currentPath.isNotBlank() && state.browseMode == FilesBrowseMode.AllFiles) {
F7SecondaryButton(
text = "Назад",
onClick = { vm.goUp(session) },
modifier = Modifier.padding(bottom = 8.dp),
)
}
Text(
text = pathLabel,
style = MaterialTheme.typography.titleSmall,
color = F7Colors.TextPrimary,
modifier = Modifier.padding(bottom = 8.dp),
)
FilesSearchBar(
serverUrl = session.serverUrl,
query = state.searchQuery,
onQueryChange = { vm.setSearchQuery(session, it) },
)
if (selectionMode) {
FilesBulkActionsBar(
serverUrl = session.serverUrl,
selectedCount = state.selectedPaths.size,
onActionsClick = { rect ->
bulkMenuAnchor = rect
bulkMenuOpen = true
},
)
} else {
FilesListHeader(
serverUrl = session.serverUrl,
showMimeColumn = state.userConfig.showMimeColumn,
sortColumn = state.sortColumn,
sortDirection = state.sortDirection,
selectionMode = false,
allSelected = allSelected,
onSelectAllToggle = {
if (allSelected) vm.clearSelection() else vm.selectAllVisible()
},
onSortColumnClick = vm::setSortColumn,
)
}
if (state.openingFile != null) {
CircularProgressIndicator(color = F7Colors.Primary)
Text(
text = "Открываем «${state.openingFile}»…",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
}
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = { vm.refreshCurrent(session) },
modifier = Modifier
.fillMaxWidth()
.weight(1f),
) {
if (displayItems.isEmpty() && !state.loading && state.error.isNullOrBlank()) {
Text(
text = if (state.searchQuery.isNotBlank()) "Ничего не найдено" else "Папка пуста",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp),
)
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
) {
itemsIndexed(displayItems, key = { _, item -> item.relativePath }) { index, item ->
val openable = !item.isDirectory &&
(OfficeFiles.isOfficeFile(item.name) || OpenableFiles.isOpenable(item.name))
FilesListRow(
serverUrl = session.serverUrl,
item = item,
selected = item.relativePath in state.selectedPaths,
showExtensions = state.userConfig.showExtensions,
onSelectedChange = { vm.toggleSelection(item.relativePath) },
onOpenClick = {
when {
item.isDirectory -> vm.openFolder(session, item)
openable -> vm.openItem(session, item)
}
},
onMenuClick = { rect ->
contextMenuItem = item
contextMenuAnchor = rect
},
)
if (index < displayItems.lastIndex) {
HorizontalDivider(
modifier = Modifier.fillMaxWidth(),
thickness = 1.dp,
color = F7Colors.Border.copy(alpha = 0.55f),
)
}
}
}
}
}
}
FilesNavigationSidebar(
serverUrl = session.serverUrl,
visible = sidebarOpen,
browseMode = state.browseMode,
storageStats = state.storageStats,
folderTree = state.folderTree,
expandedTreePaths = state.expandedTreePaths,
expandedSections = state.expandedSidebarSections,
onDismiss = { onSidebarOpenChange(false) },
onBrowseModeClick = { mode ->
vm.navigateBrowseMode(session, mode)
onSidebarOpenChange(false)
},
onFolderPathClick = { path ->
vm.navigateToPath(session, path)
onSidebarOpenChange(false)
},
onToggleTreePath = vm::toggleTreePath,
onToggleSection = vm::toggleSidebarSection,
onWebOnlyClick = { title ->
vm.showWebOnlyMessage(title)
onSidebarOpenChange(false)
},
)
FilesCreateMenu(
serverUrl = session.serverUrl,
visible = showCreateMenu,
onDismiss = { showCreateMenu = false },
onAction = { action ->
when (action) {
FilesCreateAction.UploadFiles -> uploadFilesLauncher.launch(arrayOf("*/*"))
FilesCreateAction.UploadFolders -> uploadFolderLauncher.launch(null)
FilesCreateAction.NewFolder -> {
newFolderName = ""
showNewFolderDialog = true
}
else -> vm.handleCreateAction(session, action)
}
},
)
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
FilesSettingsSheet(
visible = settingsOpen,
config = state.userConfig,
onDismiss = { onSettingsOpenChange(false) },
onToggle = { key, value -> vm.updateUserConfig(session, key, value) },
onDefaultViewChange = { vm.updateUserConfig(session, "default_view", it) },
)
FilesDetailsSheet(
serverUrl = session.serverUrl,
ownerLabel = session.username,
item = state.detailsItem,
tab = state.detailsTab,
details = state.fileDetails,
loading = state.detailsLoading,
onDismiss = vm::hideDetails,
onTabChange = vm::setDetailsTab,
onWebOnlyAction = vm::showWebOnlyMessage,
)
FilesRenameDialog(
item = state.renameItem,
onDismiss = vm::hideRename,
onConfirm = { newName ->
state.renameItem?.let { vm.renameItem(session, it, newName) }
},
)
val contextItem = contextMenuItem
if (contextItem != null) {
FileActionMenuPopup(
expanded = true,
serverUrl = session.serverUrl,
anchorBounds = contextMenuAnchor,
entries = buildFileContextMenu(contextItem),
onDismiss = { contextMenuItem = null },
onAction = { action ->
when (action) {
FileContextAction.Favorite -> vm.toggleFavorite(session, contextItem)
FileContextAction.Details -> vm.showDetails(session, contextItem)
FileContextAction.Sharing -> vm.showDetails(session, contextItem, FileDetailsTab.Sharing)
FileContextAction.Tags -> vm.showWebOnlyMessage("Управление метками")
FileContextAction.Rename -> vm.showRename(contextItem)
FileContextAction.MoveCopy -> vm.showWebOnlyMessage("Перемещение и копирование")
FileContextAction.Reminder -> vm.showWebOnlyMessage("Напоминания")
FileContextAction.OpenLocally -> vm.showWebOnlyMessage("Открытие локально")
FileContextAction.Download -> vm.downloadItem(session, contextItem, context)
FileContextAction.Archive -> vm.showWebOnlyMessage("Архивирование")
FileContextAction.Delete -> deleteConfirmItem = contextItem
}
},
)
}
FilesBulkActionMenuPopup(
expanded = bulkMenuOpen,
serverUrl = session.serverUrl,
anchorBounds = bulkMenuAnchor,
onDismiss = { bulkMenuOpen = false },
onAction = { action ->
when (action) {
FilesBulkAction.Favorite -> vm.favoriteSelected(session)
FilesBulkAction.Download -> vm.showWebOnlyMessage("Массовое скачивание")
FilesBulkAction.Delete -> deleteConfirmBulk = true
}
},
)
deleteConfirmItem?.let { item ->
F7AlertDialog(
title = if (item.isDirectory) "Удалить папку?" else "Удалить файл?",
onDismiss = { deleteConfirmItem = null },
confirmText = "Удалить",
onConfirm = {
deleteConfirmItem = null
vm.deleteItem(session, item)
},
) {
Text("«${item.name}» будет удалён.")
}
}
if (deleteConfirmBulk) {
F7AlertDialog(
title = "Удалить выбранные элементы?",
onDismiss = { deleteConfirmBulk = false },
confirmText = "Удалить",
onConfirm = {
deleteConfirmBulk = false
vm.deleteSelected(session)
},
) {
Text("Будет удалено элементов: ${state.selectedPaths.size}")
}
}
if (showNewFolderDialog) {
F7AlertDialog(
title = "Новая папка",
onDismiss = { showNewFolderDialog = false },
confirmText = "Создать",
onConfirm = {
showNewFolderDialog = false
vm.createFolder(session, newFolderName)
},
confirmEnabled = newFolderName.trim().isNotBlank(),
) {
F7OutlinedField(
value = newFolderName,
onValueChange = { newFolderName = it },
label = "Имя папки",
)
}
}
}
@@ -0,0 +1,105 @@
package ru.forbion.f7cloud.feature.files
import okhttp3.FormBody
import okhttp3.Request
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 FilesTemplatesRepository {
data class CreatedFile(
val fileId: Long,
val name: String,
val relativePath: String,
)
fun createFromTemplate(session: AuthSession, relativeFilePath: String): CreatedFile {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/files/api/v1/templates/create?format=json"
val body = FormBody.Builder()
.add("filePath", relativeFilePath)
.add("templatePath", "")
.add("templateType", "user")
.build()
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post(body)
.build()
return client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
parseOcsData(response) { data ->
CreatedFile(
fileId = data.optLong("fileid"),
name = data.optString("basename"),
relativePath = data.optString("filename"),
)
}
}
}
fun initializeTemplateDirectory(session: AuthSession): String {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/files/api/v1/templates/path?format=json"
val body = FormBody.Builder()
.add("templatePath", "")
.add("copySystemTemplates", "true")
.build()
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post(body)
.build()
return client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
parseOcsData(response) { data ->
data.optString("template_path")
}
}
}
fun openFolderDescription(session: AuthSession, relativeFolderPath: String): String {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/text/api/v1/workspace/direct?format=json"
val body = FormBody.Builder()
.add("path", relativeFolderPath.ifBlank { "/" })
.build()
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post(body)
.build()
return client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
parseOcsData(response) { data ->
data.optString("url").ifBlank {
error("Не удалось открыть описание папки")
}
}
}
}
private fun <T> parseOcsData(response: okhttp3.Response, block: (JSONObject) -> T): T {
if (!response.isSuccessful || response.body == null) {
error("HTTP ${response.code}")
}
val ocs = JSONObject(response.body!!.string()).optJSONObject("ocs")
?: error("Некорректный ответ сервера")
val meta = ocs.optJSONObject("meta")
if (meta?.optString("status").equals("failure", ignoreCase = true)) {
error(meta?.optString("message").orEmpty().ifBlank { "Ошибка сервера" })
}
val data = ocs.optJSONObject("data") ?: JSONObject()
return block(data)
}
private fun authedClient(session: AuthSession) = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
}
@@ -0,0 +1,40 @@
package ru.forbion.f7cloud.feature.files
data class OfficeEditorLaunch(
val url: String,
val title: String,
val username: String,
val password: String,
val trustAllCerts: Boolean,
val serverUrl: String = "",
val collaboraBaseUrl: String = "",
)
data class FilesUiState(
val loading: Boolean = false,
val items: List<FileItem> = emptyList(),
val currentPath: String = "",
val browseMode: FilesBrowseMode = FilesBrowseMode.AllFiles,
val error: String? = null,
val unauthorized: Boolean = false,
val openingFile: String? = null,
val editorLaunch: OfficeEditorLaunch? = null,
val openAction: FileOpenAction? = null,
val searchQuery: String = "",
val searchResults: List<FilesSearchHit> = emptyList(),
val searchActive: Boolean = false,
val selectedPaths: Set<String> = emptySet(),
val sortColumn: FilesSortColumn = FilesSortColumn.Name,
val sortDirection: FilesSortDirection = FilesSortDirection.Asc,
val userConfig: FilesUserConfig = FilesUserConfig(),
val storageStats: FilesStorageStats = FilesStorageStats(),
val folderTree: List<FilesFolderTreeNode> = emptyList(),
val expandedTreePaths: Set<String> = emptySet(),
val expandedSidebarSections: Set<String> = setOf("sharing"),
val detailsItem: FileItem? = null,
val detailsTab: FileDetailsTab = FileDetailsTab.Sharing,
val fileDetails: FileDetailsData? = null,
val detailsLoading: Boolean = false,
val renameItem: FileItem? = null,
val snackbar: String? = null,
)
@@ -0,0 +1,814 @@
package ru.forbion.f7cloud.feature.files
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
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
class FilesViewModel(
private val repository: FilesRepository,
private val downloadRepository: FileDownloadRepository,
private val apiRepository: FilesApiRepository = FilesApiRepository(),
private val templatesRepository: FilesTemplatesRepository = FilesTemplatesRepository(),
private val officeFileOpener: OfficeFileOpener = OfficeFileOpener(),
) : ViewModel() {
private val _state = MutableStateFlow(FilesUiState())
val state: StateFlow<FilesUiState> = _state.asStateFlow()
private var searchJob: Job? = null
fun load(session: AuthSession, path: String = _state.value.currentPath) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(
loading = true,
error = null,
currentPath = path,
browseMode = FilesBrowseMode.AllFiles,
selectedPaths = emptySet(),
)
runCatching { repository.listFolder(session, path) }
.onSuccess { items ->
_state.value = _state.value.copy(
loading = false,
items = applySorting(items, _state.value),
currentPath = path,
error = null,
)
}
.onFailure { t ->
val cached = if (path.isBlank()) {
runCatching { repository.listCached(session) }.getOrDefault(emptyList())
} else {
emptyList()
}
_state.value = _state.value.copy(
loading = false,
items = applySorting(cached, _state.value),
currentPath = path,
error = t.message ?: "Не удалось загрузить файлы",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun loadSidebarData(session: AuthSession) {
viewModelScope.launch(Dispatchers.IO) {
runCatching {
val config = apiRepository.fetchUserConfig(session)
val stats = apiRepository.fetchStorageStats(session)
val tree = if (config.folderTree) {
apiRepository.fetchFolderTree(session, depth = 2)
} else {
emptyList()
}
_state.value = _state.value.copy(
userConfig = config,
storageStats = stats,
folderTree = tree,
items = applySorting(_state.value.items, _state.value.copy(userConfig = config)),
)
}
}
}
fun setSearchActive(active: Boolean) {
_state.value = _state.value.copy(searchActive = active)
if (!active) {
_state.value = _state.value.copy(searchQuery = "", searchResults = emptyList())
}
}
fun setSearchQuery(session: AuthSession, query: String) {
_state.value = _state.value.copy(searchQuery = query)
searchJob?.cancel()
if (query.isBlank()) {
_state.value = _state.value.copy(searchResults = emptyList())
return
}
searchJob = viewModelScope.launch(Dispatchers.IO) {
delay(300)
runCatching { apiRepository.searchFiles(session, query) }
.onSuccess { hits ->
if (_state.value.searchQuery == query) {
_state.value = _state.value.copy(searchResults = hits)
}
}
.onFailure { t ->
_state.value = _state.value.copy(
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun navigateBrowseMode(session: AuthSession, mode: FilesBrowseMode) {
when (mode) {
FilesBrowseMode.AllFiles -> load(session, "")
FilesBrowseMode.Personal -> load(session, "")
FilesBrowseMode.Recent -> loadRecent(session)
FilesBrowseMode.Favorites -> loadFavorites(session)
}
}
private fun loadRecent(session: AuthSession) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, error = null, selectedPaths = emptySet())
runCatching { apiRepository.fetchRecentFiles(session) }
.onSuccess { items ->
_state.value = _state.value.copy(
loading = false,
items = applySorting(items, _state.value),
currentPath = "",
browseMode = FilesBrowseMode.Recent,
)
}
.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message ?: "Не удалось загрузить недавние файлы",
unauthorized = t is UnauthorizedException,
)
}
}
}
private fun loadFavorites(session: AuthSession) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, error = null, selectedPaths = emptySet())
runCatching {
repository.listFolder(session, "").filter { it.favorite }
}.onSuccess { items ->
_state.value = _state.value.copy(
loading = false,
items = applySorting(items, _state.value),
currentPath = "",
browseMode = FilesBrowseMode.Favorites,
)
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message ?: "Не удалось загрузить избранное",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun navigateToPath(session: AuthSession, path: String) {
load(session, path)
}
fun toggleSelection(path: String) {
val selected = _state.value.selectedPaths.toMutableSet()
if (path in selected) selected.remove(path) else selected.add(path)
_state.value = _state.value.copy(selectedPaths = selected)
}
fun selectAllVisible() {
val paths = displayItems(_state.value).map { it.relativePath }.toSet()
_state.value = _state.value.copy(selectedPaths = paths)
}
fun clearSelection() {
_state.value = _state.value.copy(selectedPaths = emptySet())
}
fun setSortColumn(column: FilesSortColumn) {
val current = _state.value
val direction = if (current.sortColumn == column && current.sortDirection == FilesSortDirection.Asc) {
FilesSortDirection.Desc
} else {
FilesSortDirection.Asc
}
_state.value = current.copy(
sortColumn = column,
sortDirection = direction,
items = applySorting(current.items, current.copy(sortColumn = column, sortDirection = direction)),
)
}
fun toggleTreePath(path: String) {
val expanded = _state.value.expandedTreePaths.toMutableSet()
if (path in expanded) expanded.remove(path) else expanded.add(path)
_state.value = _state.value.copy(expandedTreePaths = expanded)
}
fun toggleSidebarSection(sectionId: String) {
val expanded = _state.value.expandedSidebarSections.toMutableSet()
if (sectionId in expanded) expanded.remove(sectionId) else expanded.add(sectionId)
_state.value = _state.value.copy(expandedSidebarSections = expanded)
}
fun refreshCurrent(session: AuthSession) {
viewModelScope.launch(Dispatchers.IO) {
loadSidebarData(session)
reloadCurrent(session)
}
}
fun showDetails(session: AuthSession, item: FileItem, tab: FileDetailsTab = FileDetailsTab.Sharing) {
_state.value = _state.value.copy(
detailsItem = item,
detailsTab = tab,
fileDetails = null,
detailsLoading = true,
)
loadFileDetails(session, item)
}
fun setDetailsTab(tab: FileDetailsTab) {
_state.value = _state.value.copy(detailsTab = tab)
}
fun hideDetails() {
_state.value = _state.value.copy(
detailsItem = null,
fileDetails = null,
detailsLoading = false,
)
}
private fun loadFileDetails(session: AuthSession, item: FileItem) {
viewModelScope.launch(Dispatchers.IO) {
val details = runCatching {
FileDetailsData(
shares = apiRepository.fetchShares(session, item.relativePath),
activities = item.fileId?.let { apiRepository.fetchFileActivity(session, it) }.orEmpty(),
)
}.getOrElse { FileDetailsData(error = it.message) }
if (_state.value.detailsItem?.relativePath == item.relativePath) {
_state.value = _state.value.copy(
fileDetails = details,
detailsLoading = false,
)
}
}
}
fun showRename(item: FileItem) {
_state.value = _state.value.copy(renameItem = item)
}
fun hideRename() {
_state.value = _state.value.copy(renameItem = null)
}
fun renameItem(session: AuthSession, item: FileItem, newName: String) {
val trimmed = newName.trim()
if (trimmed.isBlank() || trimmed == item.name) {
hideRename()
return
}
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, renameItem = null)
runCatching {
repository.renameItem(session, item.relativePath, trimmed, item.isDirectory)
}.onSuccess {
reloadCurrent(session)
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message ?: "Не удалось переименовать",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun deleteItem(session: AuthSession, item: FileItem) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true)
runCatching {
repository.deleteItem(session, item.relativePath, item.isDirectory)
}.onSuccess {
reloadCurrent(session)
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message ?: "Не удалось удалить",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun deleteSelected(session: AuthSession) {
val items = displayItems(_state.value).filter { it.relativePath in _state.value.selectedPaths }
if (items.isEmpty()) return
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true)
runCatching {
items.forEach { item ->
repository.deleteItem(session, item.relativePath, item.isDirectory)
}
}.onSuccess {
reloadCurrent(session)
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message ?: "Не удалось удалить выбранные элементы",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun toggleFavorite(session: AuthSession, item: FileItem) {
viewModelScope.launch(Dispatchers.IO) {
runCatching {
repository.setFavorite(session, item.relativePath, !item.favorite, item.isDirectory)
}.onSuccess {
reloadCurrent(session)
}.onFailure { t ->
_state.value = _state.value.copy(
snackbar = t.message ?: "Не удалось изменить избранное",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun favoriteSelected(session: AuthSession) {
val items = displayItems(_state.value).filter { it.relativePath in _state.value.selectedPaths }
viewModelScope.launch(Dispatchers.IO) {
runCatching {
items.filter { !it.favorite }.forEach { item ->
repository.setFavorite(session, item.relativePath, true, item.isDirectory)
}
}.onSuccess {
reloadCurrent(session)
}.onFailure { t ->
_state.value = _state.value.copy(
snackbar = t.message ?: "Не удалось добавить в избранное",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun downloadItem(session: AuthSession, item: FileItem, context: Context) {
if (item.isDirectory) {
_state.value = _state.value.copy(snackbar = "Скачивание папок пока доступно в веб-версии")
return
}
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(openingFile = item.name)
runCatching { downloadRepository.download(session, item.relativePath, item.name) }
.onSuccess { file ->
_state.value = _state.value.copy(
openingFile = null,
openAction = FileOpenAction.External(
path = file.absolutePath,
mimeType = item.mimeType ?: "application/octet-stream",
title = item.name,
),
)
}
.onFailure { t ->
_state.value = _state.value.copy(
openingFile = null,
error = t.message ?: "Не удалось скачать файл",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun updateUserConfig(session: AuthSession, key: String, value: String) {
viewModelScope.launch(Dispatchers.IO) {
runCatching { apiRepository.saveUserConfig(session, key, value) }
.onSuccess { loadSidebarData(session) }
.onFailure { t ->
_state.value = _state.value.copy(
snackbar = t.message ?: "Не удалось сохранить настройку",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun showWebOnlyMessage(message: String) {
_state.value = _state.value.copy(snackbar = "$message — доступно в веб-версии")
}
fun clearSnackbar() {
_state.value = _state.value.copy(snackbar = null)
}
fun displayItems(state: FilesUiState = _state.value): List<FileItem> {
if (state.searchQuery.isNotBlank()) {
return state.searchResults.map { hit ->
FileItem(
name = hit.name,
isDirectory = hit.isDirectory,
relativePath = hit.path,
fileId = hit.fileId,
)
}
}
return state.items
}
private fun reloadCurrent(session: AuthSession) {
when (_state.value.browseMode) {
FilesBrowseMode.Recent -> loadRecent(session)
FilesBrowseMode.Favorites -> loadFavorites(session)
else -> load(session, _state.value.currentPath)
}
}
private fun applySorting(items: List<FileItem>, state: FilesUiState): List<FileItem> {
var filtered = items
if (!state.userConfig.showHidden) {
filtered = filtered.filter { !it.name.startsWith('.') }
}
val direction = state.sortDirection
val sorted = when (state.sortColumn) {
FilesSortColumn.Name -> filtered.sortedBy { it.name.lowercase() }
FilesSortColumn.Type -> filtered.sortedBy { typeKey(it) }
FilesSortColumn.Modified -> filtered.sortedBy { it.lastModified ?: 0L }
}
val withFolders = if (state.userConfig.sortFoldersFirst) {
sorted.sortedByDescending { it.isDirectory }
} else {
sorted
}
val withFavorites = if (state.userConfig.sortFavoritesFirst) {
withFolders.sortedByDescending { it.favorite }
} else {
withFolders
}
return if (direction == FilesSortDirection.Desc) withFavorites.reversed() else withFavorites
}
private fun typeKey(item: FileItem): String = when {
item.isDirectory -> "0_folder"
else -> item.mimeType ?: item.name.substringAfterLast('.', "file")
}
// --- existing methods below ---
fun openFileById(session: AuthSession, fileId: Long, title: String = "Файл") {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(openingFile = title, error = null)
runCatching { officeFileOpener.prepareLaunch(session, fileId, 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 openFolder(session: AuthSession, item: FileItem) {
if (!item.isDirectory) return
load(session, item.relativePath)
}
fun openItem(session: AuthSession, item: FileItem) {
if (item.isDirectory) return
when {
OfficeFiles.isOfficeFile(item.name) -> openOfficeFile(session, item)
ArchiveFiles.isArchive(item.name) -> openArchiveFile(session, item)
OpenableFiles.isImage(item.name) -> openImageFile(session, item)
else -> _state.value = _state.value.copy(
error = "Этот тип файла пока не поддерживается",
)
}
}
private fun openOfficeFile(session: AuthSession, item: FileItem) {
val fileId = item.fileId ?: run {
_state.value = _state.value.copy(error = "Не удалось определить ID файла")
return
}
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(openingFile = item.name, error = null)
runCatching { officeFileOpener.prepareLaunch(session, fileId, item.name) }
.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,
)
}
}
}
private fun openArchiveFile(session: AuthSession, item: FileItem) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(openingFile = item.name, error = null)
runCatching { downloadRepository.download(session, item.relativePath, item.name) }
.onSuccess { file ->
_state.value = _state.value.copy(
openingFile = null,
openAction = FileOpenAction.External(
path = file.absolutePath,
mimeType = ArchiveFiles.mimeType(item.name),
title = item.name,
),
)
}
.onFailure { t ->
_state.value = _state.value.copy(
openingFile = null,
error = t.message ?: "Не удалось скачать архив",
unauthorized = t is UnauthorizedException,
)
}
}
}
private fun openImageFile(session: AuthSession, item: FileItem) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(openingFile = item.name, error = null)
runCatching { downloadRepository.download(session, item.relativePath, item.name) }
.onSuccess { file ->
_state.value = _state.value.copy(
openingFile = null,
openAction = FileOpenAction.Image(file.absolutePath, item.name),
)
}
.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)
}
fun clearOpenAction() {
_state.value = _state.value.copy(openAction = null)
}
fun uploadFromUris(context: Context, session: AuthSession, uris: List<Uri>) {
if (uris.isEmpty()) return
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, error = null)
val path = _state.value.currentPath
runCatching {
uris.forEach { uri ->
val (name, bytes, mime) = readUriPayload(context, uri)
repository.uploadFile(session, path, name, bytes, mime)
}
}.onSuccess {
load(session, path)
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message ?: "Не удалось загрузить файлы",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun uploadFolderTree(context: Context, session: AuthSession, treeUri: Uri) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, error = null)
val basePath = _state.value.currentPath
runCatching {
val root = DocumentFile.fromTreeUri(context, treeUri)
?: error("Не удалось открыть папку")
uploadDocumentNode(context, session, root, basePath, preserveRoot = true)
}.onSuccess {
load(session, basePath)
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message ?: "Не удалось загрузить папку",
unauthorized = t is UnauthorizedException,
)
}
}
}
private suspend fun uploadDocumentNode(
context: Context,
session: AuthSession,
node: DocumentFile,
parentPath: String,
preserveRoot: Boolean = false,
) {
if (node.isDirectory) {
val folderName = node.name?.takeIf { it.isNotBlank() } ?: return
val nextPath = if (preserveRoot) {
parentPath
} else {
val built = repository.buildRelativePath(parentPath, folderName)
repository.ensureFolder(session, built)
built
}
node.listFiles().forEach { child ->
uploadDocumentNode(context, session, child, nextPath)
}
return
}
val name = node.name?.takeIf { it.isNotBlank() } ?: return
val uri = node.uri
val (resolvedName, bytes, mime) = readUriPayload(context, uri, fallbackName = name)
repository.uploadFile(session, parentPath, resolvedName, bytes, mime)
}
fun createFolder(session: AuthSession, folderName: String) {
val trimmed = folderName.trim()
if (trimmed.isBlank()) {
_state.value = _state.value.copy(error = "Введите имя папки")
return
}
viewModelScope.launch(Dispatchers.IO) {
val path = _state.value.currentPath
_state.value = _state.value.copy(loading = true, error = null)
runCatching {
repository.createFolder(session, path, trimmed)
}.onSuccess {
load(session, path)
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message ?: "Не удалось создать папку",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun handleCreateAction(session: AuthSession, action: FilesCreateAction) {
when (action) {
FilesCreateAction.UploadFiles,
FilesCreateAction.UploadFolders,
FilesCreateAction.NewFolder,
-> Unit
FilesCreateAction.FileRequest -> {
_state.value = _state.value.copy(
error = "Запрос на создание файла пока доступен в веб-версии",
)
}
FilesCreateAction.NewDiagram -> createTemplateFile(session, "Новая диаграмма", ".odg", openAfterCreate = true)
FilesCreateAction.NewBoard -> createTemplateFile(session, "Новая доска", ".whiteboard", openAfterCreate = false)
FilesCreateAction.NewPresentation -> createTemplateFile(session, "Новая презентация", ".pptx", openAfterCreate = true)
FilesCreateAction.NewSpreadsheet -> createTemplateFile(session, "Новая таблица", ".xlsx", openAfterCreate = true)
FilesCreateAction.NewDocument -> createTemplateFile(session, "Новый документ", ".docx", openAfterCreate = true)
FilesCreateAction.NewTextFile -> createTemplateFile(session, "Новый текстовый файл", ".txt", openAfterCreate = false)
FilesCreateAction.TemplateFolder -> initializeTemplateFolder(session)
FilesCreateAction.FolderDescription -> openFolderDescription(session)
}
}
private fun createTemplateFile(
session: AuthSession,
baseName: String,
extension: String,
openAfterCreate: Boolean,
) {
viewModelScope.launch(Dispatchers.IO) {
val folderPath = _state.value.currentPath
val existing = _state.value.items.map { it.name }.toSet()
val fileName = repository.uniqueName(existing, baseName, extension)
val relativeFilePath = repository.buildRelativePath(folderPath, fileName)
_state.value = _state.value.copy(loading = true, error = null, openingFile = fileName)
runCatching {
templatesRepository.createFromTemplate(session, relativeFilePath)
}.onSuccess { created ->
load(session, folderPath)
if (openAfterCreate && created.fileId > 0L) {
runCatching { officeFileOpener.prepareLaunch(session, created.fileId, created.name) }
.onSuccess { launch ->
_state.value = _state.value.copy(openingFile = null, editorLaunch = launch)
}
.onFailure { t ->
_state.value = _state.value.copy(
openingFile = null,
error = t.message ?: "Файл создан, но не удалось открыть редактор",
)
}
} else {
_state.value = _state.value.copy(openingFile = null)
}
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
openingFile = null,
error = t.message ?: "Не удалось создать файл",
unauthorized = t is UnauthorizedException,
)
}
}
}
private fun initializeTemplateFolder(session: AuthSession) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, error = null)
runCatching {
templatesRepository.initializeTemplateDirectory(session)
}.onSuccess {
load(session, _state.value.currentPath)
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message ?: "Не удалось создать папку шаблонов",
unauthorized = t is UnauthorizedException,
)
}
}
}
private fun openFolderDescription(session: AuthSession) {
val folderPath = _state.value.currentPath
if (folderPath.isBlank()) {
_state.value = _state.value.copy(error = "Откройте папку, чтобы добавить описание")
return
}
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(openingFile = "Описание папки", error = null)
runCatching {
val url = templatesRepository.openFolderDescription(session, folderPath)
val collaboraBaseUrl = OfficeWarmup.getCachedCollaboraUrl(session.serverUrl)
.ifBlank { RichdocumentsRepository().fetchCollaboraPublicUrl(session) }
OfficeEditorLaunch(
url = url,
title = "Описание папки",
username = session.username,
password = session.appPassword,
trustAllCerts = session.trustAllCerts,
serverUrl = session.serverUrl,
collaboraBaseUrl = collaboraBaseUrl,
)
}.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,
)
}
}
}
private fun readUriPayload(
context: Context,
uri: Uri,
fallbackName: String = "upload.bin",
): Triple<String, ByteArray, String?> {
val resolver = context.contentResolver
var name = fallbackName
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (idx >= 0) {
name = cursor.getString(idx)?.takeIf { it.isNotBlank() } ?: name
}
}
}
val mime = resolver.getType(uri)
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() }
?: error("Не удалось прочитать файл")
return Triple(name, bytes, mime)
}
fun goUp(session: AuthSession) {
val path = _state.value.currentPath.trim('/')
if (path.isEmpty()) return
val parent = path.substringBeforeLast('/', missingDelimiterValue = "")
load(session, parent)
}
class Factory(private val context: Context) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val appContext = context.applicationContext
return FilesViewModel(
FilesRepository(appContext),
FileDownloadRepository(appContext),
) as T
}
}
}
@@ -0,0 +1,98 @@
package ru.forbion.f7cloud.feature.files
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import ru.forbion.f7cloud.core.designsystem.F7Theme
import java.io.File
class ImageViewerActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val path = intent.getStringExtra(EXTRA_PATH).orEmpty()
val title = intent.getStringExtra(EXTRA_TITLE).orEmpty()
if (path.isBlank()) {
finish()
return
}
setContent {
F7Theme {
ImageViewerScreen(
path = path,
title = title,
onClose = { finish() },
)
}
}
}
companion object {
private const val EXTRA_PATH = "path"
private const val EXTRA_TITLE = "title"
fun intent(context: Context, path: String, title: String): Intent =
Intent(context, ImageViewerActivity::class.java).apply {
putExtra(EXTRA_PATH, path)
putExtra(EXTRA_TITLE, title)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ImageViewerScreen(
path: String,
title: String,
onClose: () -> Unit,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
) {
AsyncImage(
model = File(path),
contentDescription = title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
alignment = Alignment.Center,
)
TopAppBar(
title = {
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.White,
style = MaterialTheme.typography.titleMedium,
)
},
navigationIcon = {
IconButton(onClick = onClose) {
Text("", color = Color.White, modifier = Modifier.padding(8.dp))
}
},
modifier = Modifier.align(Alignment.TopCenter),
)
}
}
@@ -0,0 +1,27 @@
package ru.forbion.f7cloud.feature.files
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.core.content.FileProvider
import java.io.File
object LocalFileOpener {
fun openExternal(context: Context, file: File, mimeType: String) {
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file,
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
context.startActivity(Intent.createChooser(intent, "Открыть с помощью"))
} catch (_: ActivityNotFoundException) {
Toast.makeText(context, "Нет приложения для открытия этого файла", Toast.LENGTH_LONG).show()
}
}
}
@@ -0,0 +1,31 @@
package ru.forbion.f7cloud.feature.files
import java.net.URI
object OfficeFileLinks {
fun parseFileId(link: String, serverUrl: String): Long? {
if (link.isBlank()) return null
val base = serverUrl.trimEnd('/')
val candidates = listOf(
Regex("""${Regex.escape(base)}/index\.php/f/(\d+)""", RegexOption.IGNORE_CASE),
Regex("""${Regex.escape(base)}/f/(\d+)""", RegexOption.IGNORE_CASE),
Regex("""/index\.php/f/(\d+)""", RegexOption.IGNORE_CASE),
Regex("""/f/(\d+)""", RegexOption.IGNORE_CASE),
)
for (pattern in candidates) {
val match = pattern.find(link) ?: continue
return match.groupValues[1].toLongOrNull()
}
return runCatching {
val uri = URI(link)
val path = uri.path.orEmpty()
Regex("""/f/(\d+)""").find(path)?.groupValues?.get(1)?.toLongOrNull()
}.getOrNull()
}
fun titleFromLink(link: String, fallback: String): String {
return fallback.ifBlank {
link.substringAfterLast('/').substringBefore('?').ifBlank { "Документ" }
}
}
}
@@ -0,0 +1,28 @@
package ru.forbion.f7cloud.feature.files
import ru.forbion.f7cloud.core.auth.AuthSession
class OfficeFileOpener(
private val richdocumentsRepository: RichdocumentsRepository = RichdocumentsRepository(),
) {
fun prepareLaunch(session: AuthSession, fileId: Long, title: String): OfficeEditorLaunch {
val collaboraBaseUrl = OfficeWarmup.getCachedCollaboraUrl(session.serverUrl)
.ifBlank { richdocumentsRepository.fetchCollaboraPublicUrl(session) }
val url = richdocumentsRepository.createDirectUrl(session, fileId)
return OfficeEditorLaunch(
url = url,
title = title,
username = session.username,
password = session.appPassword,
trustAllCerts = session.trustAllCerts,
serverUrl = session.serverUrl,
collaboraBaseUrl = collaboraBaseUrl,
)
}
fun prepareLaunchFromLink(session: AuthSession, link: String, title: String): OfficeEditorLaunch {
val fileId = OfficeFileLinks.parseFileId(link, session.serverUrl)
?: error("Не удалось определить файл по ссылке")
return prepareLaunch(session, fileId, OfficeFileLinks.titleFromLink(link, title))
}
}
@@ -0,0 +1,16 @@
package ru.forbion.f7cloud.feature.files
object OfficeFiles {
private val EXTENSIONS = setOf(
"doc", "docx", "dot", "dotx",
"xls", "xlsx", "xlsm", "xlt", "xltx",
"ppt", "pptx", "pot", "potx",
"odt", "ods", "odp", "odg",
"csv", "rtf",
)
fun isOfficeFile(name: String): Boolean {
val ext = name.substringAfterLast('.', "").lowercase()
return ext in EXTENSIONS
}
}
@@ -0,0 +1,92 @@
package ru.forbion.f7cloud.feature.files
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.Request
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.NetworkFactory
/**
* Прогрев Collabora / Richdocuments: кэш public WOPI URL и «холодный» TCP/HTTP к серверу офиса.
* Сам документ каждый раз открывается по новой direct-ссылке (одноразовый токен F7cloud).
*/
object OfficeWarmup {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val mutex = Mutex()
@Volatile
private var cachedServerUrl: String? = null
@Volatile
private var cachedCollaboraUrl: String? = null
@Volatile
private var lastWarmupAtMs: Long = 0L
private const val WARMUP_INTERVAL_MS = 10 * 60 * 1000L
fun getCachedCollaboraUrl(serverUrl: String): String {
if (cachedServerUrl == serverUrl.trimEnd('/')) {
return cachedCollaboraUrl.orEmpty()
}
return ""
}
/**
* Вызывать при входе в «Файлы» или после логина — не блокирует UI.
*/
fun warm(session: AuthSession, repository: RichdocumentsRepository = RichdocumentsRepository()) {
val serverKey = session.serverUrl.trimEnd('/')
val now = System.currentTimeMillis()
if (cachedServerUrl == serverKey &&
cachedCollaboraUrl?.isNotBlank() == true &&
now - lastWarmupAtMs < WARMUP_INTERVAL_MS
) {
return
}
scope.launch {
mutex.withLock {
runCatching {
val collabora = repository.fetchCollaboraPublicUrl(session).trim().trimEnd('/')
if (collabora.isNotBlank()) {
cachedServerUrl = serverKey
cachedCollaboraUrl = collabora
lastWarmupAtMs = System.currentTimeMillis()
pingCollabora(session, collabora)
}
}
}
}
}
fun clear() {
cachedServerUrl = null
cachedCollaboraUrl = null
lastWarmupAtMs = 0L
}
private fun pingCollabora(session: AuthSession, collaboraBase: String) {
val client = NetworkFactory.newAuthedClientForOffice(
session.username,
session.appPassword,
session.trustAllCerts,
)
val discoveryUrl = "$collaboraBase/hosting/discovery"
runCatching {
client.newCall(
Request.Builder()
.url(discoveryUrl)
.header("User-Agent", OFFICE_WARMUP_UA)
.get()
.build(),
).execute().use { /* прогрев соединения */ }
}
}
private const val OFFICE_WARMUP_UA =
"F7cloud-Mobile/1.0 (OfficeWarmup)"
}
@@ -0,0 +1,26 @@
package ru.forbion.f7cloud.feature.files
import java.util.Locale
enum class OpenableKind {
IMAGE,
}
object OpenableFiles {
private val IMAGE_EXT = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "heic", "heif")
fun kind(name: String): OpenableKind? {
val ext = name.lowercase(Locale.ROOT).substringAfterLast('.', missingDelimiterValue = "")
return if (ext in IMAGE_EXT) OpenableKind.IMAGE else null
}
fun isOpenable(name: String): Boolean = kind(name) != null || ArchiveFiles.isArchive(name)
fun isImage(name: String): Boolean = kind(name) == OpenableKind.IMAGE
fun hint(name: String): String? = when {
isImage(name) -> "Просмотр"
ArchiveFiles.isArchive(name) -> "Открыть с помощью…"
else -> null
}
}
@@ -0,0 +1,79 @@
package ru.forbion.f7cloud.feature.files
import okhttp3.FormBody
import okhttp3.Request
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 RichdocumentsRepository {
/**
* Создаёт одноразовую ссылку Direct Editing (Collabora / Richdocuments).
* @see OCA\Richdocuments\Controller\OCSController::createDirect
*/
fun createDirectUrl(session: AuthSession, fileId: Long): String {
val client = officeClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/richdocuments/api/v1/document?format=json"
val body = FormBody.Builder()
.add("fileId", fileId.toString())
.build()
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post(body)
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Richdocuments HTTP ${response.code}")
}
val ocs = JSONObject(response.body!!.string()).optJSONObject("ocs")
?: error("Некорректный ответ Richdocuments")
val meta = ocs.optJSONObject("meta")
if (meta?.optString("status").equals("failure", ignoreCase = true)) {
error(meta?.optString("message").orEmpty().ifBlank { "Richdocuments error" })
}
val editorUrl = ocs.optJSONObject("data")?.optString("url").orEmpty()
if (editorUrl.isBlank()) error("Richdocuments не вернул URL редактора")
return editorUrl
}
}
/**
* Public Collabora URL from F7cloud capabilities (used for WebView intercept + auth hosts).
*/
fun fetchCollaboraPublicUrl(session: AuthSession): String {
val client = officeClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/capabilities?format=json"
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.get()
.build()
return runCatching {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful || response.body == null) return ""
val data = JSONObject(response.body!!.string())
.optJSONObject("ocs")
?.optJSONObject("data")
?: return ""
data.optJSONObject("capabilities")
?.optJSONObject("richdocuments")
?.optJSONObject("config")
?.optString("public_wopi_url")
.orEmpty()
.trim()
}
}.getOrDefault("")
}
private fun officeClient(session: AuthSession) =
NetworkFactory.newAuthedClientForOffice(
session.username,
session.appPassword,
session.trustAllCerts,
)
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="f7_files" path="f7_files/" />
</paths>