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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user