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:
@@ -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()
|
||||
+40
@@ -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 = "Имя папки",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+105
@@ -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
|
||||
}
|
||||
}
|
||||
+79
@@ -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>
|
||||
Reference in New Issue
Block a user