perf(storage): оптимизация Яндекс.Диска и индикатора загрузки

Яндекс: параметр fields, getOrNull, инкрементальная статистика с манифестом yandex-vault-stats.json, PATCH custom_properties без предварительного GET, touchDir с проверкой существования, дебаунс записи статистики.

UI: CircularProgressIndicator на экране хранилищ — Modifier.size вместо width, чтобы вращение было по центру кольца.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-11 23:24:54 +03:00
parent 60627f11d6
commit d6bfdff077
5 changed files with 193 additions and 29 deletions

View File

@@ -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
}

View File

@@ -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,
)

View File

@@ -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"
}
}

View File

@@ -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<Boolean>,
accessorScope: CoroutineScope,
private val accessorScope: CoroutineScope,
private val reportAuthFailure: () -> Unit,
) : IStorageAccessor {
@@ -68,9 +74,18 @@ class YandexStorageAccessor(
private val _dirsUpdates = MutableSharedFlow<DataPage<IDirectory>>()
override val dirsUpdates: SharedFlow<DataPage<IDirectory>> = _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<Int, Long> {
var fileCount = 0
var totalBytes = 0L
val queue = ArrayDeque<String>()
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<String> =
path.trim('/').split('/').filter { it.isNotBlank() }
private suspend fun patchCustom(path: String, mutator: (MutableMap<String, String>) -> 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<String, String>) {
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"

View File

@@ -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,
)