From d6bfdff0773f63eccfe53502e32ef10e20a6a1c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=8B=D1=82=D0=BA=D0=BE=D0=B2=20=D0=A0=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD?= Date: Mon, 11 May 2026 23:24:54 +0300 Subject: [PATCH] =?UTF-8?q?perf(storage):=20=D0=BE=D0=BF=D1=82=D0=B8=D0=BC?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=AF=D0=BD=D0=B4=D0=B5?= =?UTF-8?q?=D0=BA=D1=81.=D0=94=D0=B8=D1=81=D0=BA=D0=B0=20=D0=B8=20=D0=B8?= =?UTF-8?q?=D0=BD=D0=B4=D0=B8=D0=BA=D0=B0=D1=82=D0=BE=D1=80=D0=B0=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Яндекс: параметр fields, getOrNull, инкрементальная статистика с манифестом yandex-vault-stats.json, PATCH custom_properties без предварительного GET, touchDir с проверкой существования, дебаунс записи статистики. UI: CircularProgressIndicator на экране хранилищ — Modifier.size вместо width, чтобы вращение было по центру кольца. Co-authored-by: Cursor --- .../network/yandexdisk/YandexDiskApi.kt | 6 +- .../dto/YandexVaultPersistedStats.kt | 9 + .../repository/YandexDiskRepository.kt | 24 ++- .../storages/yandex/YandexStorageAccessor.kt | 179 ++++++++++++++++-- .../main/screens/vault/VaultBrowserScreen.kt | 4 +- 5 files changed, 193 insertions(+), 29 deletions(-) create mode 100644 domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/dto/YandexVaultPersistedStats.kt diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/YandexDiskApi.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/YandexDiskApi.kt index facdf25..afddce8 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/YandexDiskApi.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/YandexDiskApi.kt @@ -14,7 +14,6 @@ import retrofit2.http.Headers import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.PUT -import retrofit2.http.Path import retrofit2.http.Query import retrofit2.http.Url @@ -29,11 +28,13 @@ interface YandexDiskApi { @Query("limit") limit: Int, @Query("offset") offset: Int, @Query("sort") sort: String? = null, + @Query("fields") fields: String, ): ResourceDto @GET("v1/disk/resources") suspend fun getResource( @Query("path") path: String, + @Query("fields") fields: String, ): ResourceDto @PUT("v1/disk/resources") @@ -70,7 +71,4 @@ interface YandexDiskApi { @GET suspend fun getOperationByUrl(@Url url: String): OperationStatusDto - - @GET("v1/disk/operations/{id}") - suspend fun getOperation(@Path("id") id: String): OperationStatusDto } diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/dto/YandexVaultPersistedStats.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/dto/YandexVaultPersistedStats.kt new file mode 100644 index 0000000..eb46dee --- /dev/null +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/dto/YandexVaultPersistedStats.kt @@ -0,0 +1,9 @@ +package com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.dto + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +data class YandexVaultPersistedStats( + val totalBytes: Long = 0L, + val fileCount: Int = 0, +) diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/repository/YandexDiskRepository.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/repository/YandexDiskRepository.kt index aabbf9f..c725824 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/repository/YandexDiskRepository.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/repository/YandexDiskRepository.kt @@ -37,7 +37,7 @@ class YandexDiskRepository( suspend fun list(path: String, limit: Int, offset: Int, sort: String? = null): ResourceDto = withContext(ioDispatcher) { try { - wrapAuth { api.listResources(path, limit, offset, sort) } + wrapAuth { api.listResources(path, limit, offset, sort, FIELDS_LIST) } } catch (e: HttpException) { if (e.code() == 404) { ResourceDto(embedded = EmbeddedResourceListDto(items = emptyList())) @@ -48,7 +48,15 @@ class YandexDiskRepository( } suspend fun get(path: String): ResourceDto = withContext(ioDispatcher) { - wrapAuth { api.getResource(path) } + wrapAuth { api.getResource(path, FIELDS_RESOURCE) } + } + + suspend fun getOrNull(path: String): ResourceDto? = withContext(ioDispatcher) { + try { + wrapAuth { api.getResource(path, FIELDS_RESOURCE) } + } catch (e: HttpException) { + if (e.code() == 404) null else throw e + } } suspend fun createFolder(path: String): Unit = withContext(ioDispatcher) { @@ -183,5 +191,17 @@ class YandexDiskRepository( private val OCTET_STREAM = "application/octet-stream".toMediaType() private const val OPERATION_POLL_DELAY_MS = 300L private const val OPERATION_POLL_MAX = 200 + + /** + * Урезанный набор полей для листинга каталога (см. параметр `fields` в Disk API). + */ + private const val FIELDS_LIST = + "path,type,name,size,modified,created,custom_properties," + + "_embedded.items.path,_embedded.items.type,_embedded.items.name," + + "_embedded.items.size,_embedded.items.modified,_embedded.items.created," + + "_embedded.items.custom_properties" + + private const val FIELDS_RESOURCE = + "path,type,name,size,modified,created,custom_properties" } } diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/yandex/YandexStorageAccessor.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/yandex/YandexStorageAccessor.kt index 2092f8e..bac871d 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/yandex/YandexStorageAccessor.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/yandex/YandexStorageAccessor.kt @@ -1,7 +1,9 @@ package com.github.nullptroma.wallenc.infrastructure.storages.yandex +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.YandexDiskAuthException import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.dto.ResourceDto +import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.dto.YandexVaultPersistedStats import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.repository.YandexDiskRepository import com.github.nullptroma.wallenc.infrastructure.utils.CloseHandledStreamExtension.Companion.onClosed import com.github.nullptroma.wallenc.domain.common.impl.CommonDirectory @@ -11,8 +13,11 @@ import com.github.nullptroma.wallenc.domain.datatypes.DataPage import com.github.nullptroma.wallenc.domain.interfaces.IDirectory import com.github.nullptroma.wallenc.domain.interfaces.IFile import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -23,6 +28,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import java.io.ByteArrayOutputStream @@ -44,7 +50,7 @@ class YandexStorageAccessor( private val repo: YandexDiskRepository, private val ioDispatcher: CoroutineDispatcher, vaultAvailability: StateFlow, - accessorScope: CoroutineScope, + private val accessorScope: CoroutineScope, private val reportAuthFailure: () -> Unit, ) : IStorageAccessor { @@ -68,9 +74,18 @@ class YandexStorageAccessor( private val _dirsUpdates = MutableSharedFlow>() override val dirsUpdates: SharedFlow> = _dirsUpdates + private var statsPersistJob: Job? = null + suspend fun init() = withContext(ioDispatcher) { try { - scanSizeAndNumOfFiles() + val persisted = readPersistedStats() + if (persisted != null) { + _size.value = persisted.totalBytes + _numberOfFiles.value = persisted.fileCount + } else { + scanSizeAndNumOfFiles() + writePersistedStatsInternal() + } _storageReady.value = true } catch (e: YandexDiskAuthException) { reportAuthFailure() @@ -125,7 +140,84 @@ class YandexStorageAccessor( rel == "/$SYSTEM_HIDDEN_DIRNAME" || rel.startsWith("/$SYSTEM_HIDDEN_DIRNAME/") private suspend fun ensureSystemDirExists() { - guard { repo.createFolder(toDiskPath("/$SYSTEM_HIDDEN_DIRNAME")) } + val p = toDiskPath("/$SYSTEM_HIDDEN_DIRNAME") + if (guard { repo.getOrNull(p) }?.type == "dir") return + guard { repo.createFolder(p) } + } + + private fun statsFileRel(): String = "/$SYSTEM_HIDDEN_DIRNAME/$STATS_FILENAME" + + private fun statsDiskPath(): String = toDiskPath(statsFileRel()) + + private suspend fun readPersistedStats(): YandexVaultPersistedStats? { + val meta = guard { repo.getOrNull(statsDiskPath()) } ?: return null + if (meta.type != "file") return null + return try { + guard { + repo.openDownloadStream(statsDiskPath()).use { + statsMapper.readValue(it, YandexVaultPersistedStats::class.java) + } + } + } catch (_: Exception) { + null + } + } + + private suspend fun writePersistedStatsInternal() { + ensureSystemDirExists() + val bytes = statsMapper.writeValueAsBytes( + YandexVaultPersistedStats( + totalBytes = _size.value ?: 0L, + fileCount = _numberOfFiles.value ?: 0, + ), + ) + guard { repo.uploadBytes(statsDiskPath(), bytes, overwrite = true) } + } + + private suspend fun persistStatsImmediate() { + statsPersistJob?.cancel() + statsPersistJob = null + writePersistedStatsInternal() + } + + private fun scheduleStatsPersist() { + statsPersistJob?.cancel() + statsPersistJob = accessorScope.launch(ioDispatcher) { + delay(STATS_DEBOUNCE_MS) + try { + writePersistedStatsInternal() + } catch (e: CancellationException) { + throw e + } catch (e: YandexDiskAuthException) { + reportAuthFailure() + throw e + } + } + } + + /** + * Сумма размеров и число пользовательских файлов в поддереве [relDir] (сама папка не считается файлом). + */ + private suspend fun sumSubtreeStats(relDir: String): Pair { + var fileCount = 0 + var totalBytes = 0L + val queue = ArrayDeque() + queue.add(relDir) + while (queue.isNotEmpty()) { + val rel = queue.removeFirst() + if (isSystemRel(rel)) continue + val (files, dirs) = listImmediateChildren(rel) + for (d in dirs) { + if (!isSystemRel(d.metaInfo.path)) queue.add(d.metaInfo.path) + } + for (f in files) { + if (!isSystemRel(f.metaInfo.path)) { + fileCount++ + totalBytes += f.metaInfo.size + } + } + } + return fileCount to totalBytes } private suspend fun scanSizeAndNumOfFiles() { @@ -325,7 +417,10 @@ class YandexStorageAccessor( } override suspend fun setHidden(path: String, hidden: Boolean) = withContext(ioDispatcher) { - patchCustom(path) { it[PROP_HIDDEN] = if (hidden) "true" else "false" } + patchCustomProps( + path, + mapOf(PROP_HIDDEN to if (hidden) "true" else "false"), + ) val f = getFileInfo(path) _filesUpdates.emit( DataPage(listOf(f), pageLength = 1, pageIndex = 0), @@ -334,12 +429,17 @@ class YandexStorageAccessor( override suspend fun touchFile(path: String): Unit = withContext(ioDispatcher) { touchParentDirs(path) + var created = false try { guard { repo.uploadBytes(toDiskPath(path), ByteArray(0), overwrite = false) } + created = true } catch (_: Exception) { // файл уже есть — ок } - scanSizeAndNumOfFiles() + if (created) { + _numberOfFiles.value = (_numberOfFiles.value ?: 0) + 1 + persistStatsImmediate() + } } override suspend fun touchDir(path: String): Unit = withContext(ioDispatcher) { @@ -347,7 +447,12 @@ class YandexStorageAccessor( var acc = "" for (seg in segments) { acc += "/$seg" - guard { repo.createFolder(toDiskPath(acc)) } + val diskPath = toDiskPath(acc) + when (guard { repo.getOrNull(diskPath) }?.type) { + "dir" -> continue + "file" -> throw IllegalStateException("Path segment is a file: $acc") + else -> guard { repo.createFolder(diskPath) } + } } } @@ -355,8 +460,24 @@ class YandexStorageAccessor( if (path == "/" || path.isBlank()) { throw IllegalArgumentException("Deleting root path is forbidden") } - guard { repo.delete(toDiskPath(path), permanently = true) } - scanSizeAndNumOfFiles() + val diskPath = toDiskPath(path) + val prior = guard { repo.getOrNull(diskPath) } + if (prior != null) { + when (prior.type) { + "file" -> { + val sz = prior.size ?: 0L + _size.value = ((_size.value ?: 0L) - sz).coerceAtLeast(0L) + _numberOfFiles.value = ((_numberOfFiles.value ?: 0) - 1).coerceAtLeast(0) + } + "dir" -> { + val (fc, total) = sumSubtreeStats(path) + _size.value = ((_size.value ?: 0L) - total).coerceAtLeast(0L) + _numberOfFiles.value = ((_numberOfFiles.value ?: 0) - fc).coerceAtLeast(0) + } + } + } + guard { repo.delete(diskPath, permanently = true) } + scheduleStatsPersist() } override suspend fun openWrite(path: String): OutputStream = withContext(ioDispatcher) { @@ -366,11 +487,27 @@ class YandexStorageAccessor( fos.onClosed { runBlocking(ioDispatcher) { try { - guard { repo.uploadFile(toDiskPath(path), tmp, overwrite = true) } - scanSizeAndNumOfFiles() - val info = runCatching { getFileInfo(path) }.getOrNull() - if (info != null) { - _filesUpdates.emit(DataPage(listOf(info), pageLength = 1, pageIndex = 0)) + val diskPath = toDiskPath(path) + val prior = guard { repo.getOrNull(diskPath) } + if (prior?.type == "dir") { + throw IllegalStateException("Cannot openWrite over directory: $path") + } + val hadFile = prior?.type == "file" + val priorSize = if (prior?.type == "file") prior.size ?: 0L else 0L + guard { repo.uploadFile(diskPath, tmp, overwrite = true) } + val after = guard { repo.get(diskPath) } + if (after.type != "file") { + throw IllegalStateException("Expected file after upload: $path") + } + val newSize = after.size ?: 0L + _size.value = ((_size.value ?: 0L) + newSize - priorSize).coerceAtLeast(0L) + if (!hadFile) { + _numberOfFiles.value = (_numberOfFiles.value ?: 0) + 1 + } + persistStatsImmediate() + val info = runCatching { after.toCommonFile(path) }.getOrNull() + info?.let { + _filesUpdates.emit(DataPage(listOf(it), pageLength = 1, pageIndex = 0)) } } finally { tmp.delete() @@ -384,7 +521,7 @@ class YandexStorageAccessor( } override suspend fun moveToTrash(path: String) = withContext(ioDispatcher) { - patchCustom(path) { it[PROP_DELETED] = "true" } + patchCustomProps(path, mapOf(PROP_DELETED to "true")) } override suspend fun openReadSystemFile(name: String): InputStream = withContext(ioDispatcher) { @@ -424,17 +561,17 @@ class YandexStorageAccessor( private fun pathSegments(path: String): List = path.trim('/').split('/').filter { it.isNotBlank() } - private suspend fun patchCustom(path: String, mutator: (MutableMap) -> Unit) { - val cur = guard { repo.get(toDiskPath(path)) } - val merged = (cur.customProperties ?: emptyMap()) - .mapValues { (_, v) -> v?.toString() ?: "" } - .toMutableMap() - mutator(merged) - guard { repo.setCustomProperties(toDiskPath(path), merged) } + /** PATCH только переданных ключей — API Диска дополняет [custom_properties], без предварительного GET. */ + private suspend fun patchCustomProps(path: String, props: Map) { + guard { repo.setCustomProperties(toDiskPath(path), props) } } companion object { + private val statsMapper = jacksonObjectMapper().apply { findAndRegisterModules() } + private const val SYSTEM_HIDDEN_DIRNAME = "wallenc-yandex-system" + private const val STATS_FILENAME = "yandex-vault-stats.json" + private const val STATS_DEBOUNCE_MS = 450L private const val DATA_PAGE_LENGTH = 10 private const val API_LIST_LIMIT = 200 private const val PROP_HIDDEN = "wallenc.hidden" diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreen.kt index 7f13323..fe6aa93 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreen.kt @@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons @@ -100,7 +100,7 @@ fun VaultBrowserScreen( Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize().alpha(0.6f).background(Color.Black)) CircularProgressIndicator( - modifier = Modifier.width(64.dp), + modifier = Modifier.size(64.dp), color = MaterialTheme.colorScheme.secondary, trackColor = MaterialTheme.colorScheme.surfaceVariant, )