feat(yandex-disk): REST client, repository, and app-folder storages

Add Yandex Disk API (Retrofit + OkHttp), per-vault repository with DB-backed
OAuth token, YandexStorage/YandexStorageAccessor under app:/<uuid>, and a
real YandexVault bootstrap (disk quota, list storages, create/remove).

Expose vault and storage availability: vault is reachable after diskInfo;
each storage combines vault reachability with local init readiness. Map
401 to YandexDiskAuthException and mark the vault unavailable.

Add YandexAccountDao.getByVaultUuid for fresh tokens on each request.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-03 21:11:53 +03:00
parent d60cd9053a
commit be1ba29f4d
13 changed files with 1048 additions and 14 deletions

View File

@@ -48,6 +48,8 @@ dependencies {
implementation(libs.retrofit.converter.scalars)
implementation(libs.retrofit.converter.jackson)
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation(libs.androidx.core.ktx)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)

View File

@@ -11,6 +11,9 @@ interface YandexAccountDao {
@Query("SELECT * FROM yandex_accounts WHERE yandexUserId = :id LIMIT 1")
suspend fun getByYandexUserId(id: String): DbYandexAccount?
@Query("SELECT * FROM yandex_accounts WHERE vaultUuid = :vaultUuid LIMIT 1")
suspend fun getByVaultUuid(vaultUuid: String): DbYandexAccount?
@Insert
suspend fun insert(account: DbYandexAccount)

View File

@@ -16,6 +16,10 @@ class YandexAccountRepository(
dao.getByYandexUserId(id)
}
suspend fun getByVaultUuid(vaultUuid: String): DbYandexAccount? = withContext(ioDispatcher) {
dao.getByVaultUuid(vaultUuid)
}
suspend fun insert(account: DbYandexAccount) = withContext(ioDispatcher) {
dao.insert(account)
}

View File

@@ -0,0 +1,76 @@
package com.github.nullptroma.wallenc.data.network.yandexdisk
import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.CustomPropertiesPatchDto
import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.DiskInfoDto
import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.LinkDto
import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.OperationStatusDto
import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.ResourceDto
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
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
interface YandexDiskApi {
@GET("v1/disk/")
suspend fun getDisk(): DiskInfoDto
@GET("v1/disk/resources")
suspend fun listResources(
@Query("path") path: String,
@Query("limit") limit: Int,
@Query("offset") offset: Int,
@Query("sort") sort: String? = null,
): ResourceDto
@GET("v1/disk/resources")
suspend fun getResource(
@Query("path") path: String,
): ResourceDto
@PUT("v1/disk/resources")
suspend fun createFolder(@Query("path") path: String): Response<ResponseBody>
@DELETE("v1/disk/resources")
suspend fun deleteResource(
@Query("path") path: String,
@Query("permanently") permanently: Boolean,
): Response<ResponseBody>
@POST("v1/disk/resources/move")
suspend fun moveResource(
@Query("from") from: String,
@Query("path") toPath: String,
@Query("overwrite") overwrite: Boolean = false,
): Response<ResponseBody>
@GET("v1/disk/resources/upload")
suspend fun getUploadLink(
@Query("path") path: String,
@Query("overwrite") overwrite: Boolean = true,
): LinkDto
@GET("v1/disk/resources/download")
suspend fun getDownloadLink(@Query("path") path: String): LinkDto
@PATCH("v1/disk/resources")
@Headers("Content-Type: application/json")
suspend fun patchResource(
@Query("path") path: String,
@Body body: CustomPropertiesPatchDto,
): Response<ResponseBody>
@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,66 @@
package com.github.nullptroma.wallenc.data.network.yandexdisk
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.github.nullptroma.wallenc.data.db.app.repository.YandexAccountRepository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.jackson.JacksonConverterFactory
import java.util.UUID
/**
* Фабрика REST-клиента Яндекс.Диска: отдельный [OkHttpClient] с OAuth на каждый vault,
* плюс «голый» клиент для PUT/GET по одноразовым upload/download URL.
*/
class YandexDiskApiFactory(
private val accountRepository: YandexAccountRepository,
private val ioDispatcher: CoroutineDispatcher,
) {
private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() }
/** Без авторизации — только для одноразовых ссылок upload/download. */
val rawHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder().build()
}
/**
* [tokenProvider] вызывается на каждый HTTP-запрос к cloud-api (свежий токен из БД).
*/
fun createAuthenticatedApi(tokenProvider: () -> String?): YandexDiskApi {
val client = OkHttpClient.Builder()
.addInterceptor { chain ->
val token = tokenProvider()
?: throw java.io.IOException("Yandex OAuth token is missing")
val req = chain.request().newBuilder()
.header("Authorization", "OAuth $token")
.header("Accept", "application/json")
.build()
chain.proceed(req)
}
.build()
return Retrofit.Builder()
.baseUrl(BASE_URL)
.client(client)
.addConverterFactory(JacksonConverterFactory.create(jackson))
.build()
.create(YandexDiskApi::class.java)
}
/**
* Провайдер токена читает [YandexAccountRepository] на IO-диспетчере (как и остальной data-слой).
*/
fun createApiForVault(vaultUuid: UUID): YandexDiskApi {
val id = vaultUuid.toString()
return createAuthenticatedApi {
runBlocking(ioDispatcher) {
accountRepository.getByVaultUuid(id)?.oauthToken
}
}
}
companion object {
private const val BASE_URL = "https://cloud-api.yandex.net/"
}
}

View File

@@ -0,0 +1,60 @@
package com.github.nullptroma.wallenc.data.network.yandexdisk.dto
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import java.time.Instant
@JsonIgnoreProperties(ignoreUnknown = true)
data class DiskInfoDto(
@JsonProperty("trash_size") val trashSize: Long? = null,
@JsonProperty("total_space") val totalSpace: Long? = null,
@JsonProperty("used_space") val usedSpace: Long? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class LinkDto(
val href: String,
val method: String,
val templated: Boolean? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class EmbeddedResourceListDto(
val items: List<ResourceDto>? = null,
val total: Int? = null,
val path: String? = null,
val sort: String? = null,
val limit: Int? = null,
val offset: Int? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ResourceDto(
val path: String? = null,
val type: String? = null,
val name: String? = null,
val size: Long? = null,
val modified: Instant? = null,
val created: Instant? = null,
@JsonProperty("mime_type") val mimeType: String? = null,
val md5: String? = null,
@JsonProperty("custom_properties") val customProperties: Map<String, Any?>? = null,
@JsonProperty("_embedded") val embedded: EmbeddedResourceListDto? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class OperationStatusDto(
val status: String? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ApiErrorDto(
val message: String? = null,
val description: String? = null,
val error: String? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class CustomPropertiesPatchDto(
@JsonProperty("custom_properties") val customProperties: Map<String, String>,
)

View File

@@ -0,0 +1,203 @@
package com.github.nullptroma.wallenc.data.network.yandexdisk.repository
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.github.nullptroma.wallenc.data.network.yandexdisk.YandexDiskApi
import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.CustomPropertiesPatchDto
import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.DiskInfoDto
import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.LinkDto
import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.OperationStatusDto
import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.ResourceDto
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.ResponseBody
import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException
import java.io.InputStream
class YandexDiskAuthException(message: String? = null) : IOException(message)
class YandexDiskRepository(
private val api: YandexDiskApi,
private val rawHttp: okhttp3.OkHttpClient,
private val ioDispatcher: CoroutineDispatcher,
) {
suspend fun diskInfo(): DiskInfoDto = withContext(ioDispatcher) {
wrapAuth { api.getDisk() }
}
suspend fun list(path: String, limit: Int, offset: Int, sort: String? = null): ResourceDto =
withContext(ioDispatcher) {
wrapAuth { api.listResources(path, limit, offset, sort) }
}
suspend fun get(path: String): ResourceDto = withContext(ioDispatcher) {
wrapAuth { api.getResource(path) }
}
suspend fun createFolder(path: String): Unit = withContext(ioDispatcher) {
val resp = wrapAuth { api.createFolder(path) }
when (resp.code()) {
201 -> Unit
409 -> Unit // уже существует
else -> throw failure("createFolder", resp)
}
}
suspend fun delete(path: String, permanently: Boolean = true): Unit = withContext(ioDispatcher) {
val resp = wrapAuth { api.deleteResource(path, permanently) }
when (resp.code()) {
204 -> Unit
202 -> {
val link = resp.body()?.use { body -> parseLink(body) }
?: throw IOException("DELETE 202 without body")
awaitOperation(link.href)
}
404 -> Unit
else -> throw failure("delete", resp)
}
}
suspend fun move(from: String, toPath: String, overwrite: Boolean = false): Unit =
withContext(ioDispatcher) {
val resp = wrapAuth { api.moveResource(from, toPath, overwrite) }
when (resp.code()) {
201 -> Unit
202 -> {
val link = resp.body()?.use { body -> parseLink(body) }
?: throw IOException("MOVE 202 without body")
awaitOperation(link.href)
}
else -> throw failure("move", resp)
}
}
suspend fun setCustomProperties(path: String, props: Map<String, String>): Unit =
withContext(ioDispatcher) {
val resp = wrapAuth {
api.patchResource(path, CustomPropertiesPatchDto(props))
}
if (!resp.isSuccessful) {
throw failure("patch", resp)
}
resp.body()?.close()
}
suspend fun uploadBytes(path: String, bytes: ByteArray, overwrite: Boolean = true): Unit =
withContext(ioDispatcher) {
val link = wrapAuth { api.getUploadLink(path, overwrite) }
require(link.method.equals("PUT", ignoreCase = true)) {
"Unexpected upload method ${link.method}"
}
val body = bytes.toRequestBody(OCTET_STREAM)
val req = Request.Builder().url(link.href).put(body).build()
rawHttp.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) {
throw IOException("Upload failed: HTTP ${resp.code}")
}
}
}
suspend fun uploadFile(path: String, file: java.io.File, overwrite: Boolean = true): Unit =
withContext(ioDispatcher) {
val link = wrapAuth { api.getUploadLink(path, overwrite) }
require(link.method.equals("PUT", ignoreCase = true)) {
"Unexpected upload method ${link.method}"
}
val body = file.asRequestBody(OCTET_STREAM)
val req = Request.Builder().url(link.href).put(body).build()
rawHttp.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) {
throw IOException("Upload failed: HTTP ${resp.code}")
}
}
}
/** Поток должен быть закрыт вызывающим кодом — закроет HTTP-ответ. */
suspend fun openDownloadStream(path: String): InputStream = withContext(ioDispatcher) {
val link = wrapAuth { api.getDownloadLink(path) }
require(link.method.equals("GET", ignoreCase = true)) {
"Unexpected download method ${link.method}"
}
val req = Request.Builder().url(link.href).get().build()
val resp = rawHttp.newCall(req).execute()
if (!resp.isSuccessful) {
resp.close()
throw IOException("Download failed: HTTP ${resp.code}")
}
val body = resp.body ?: run {
resp.close()
throw IOException("Download: empty body")
}
body.byteStream().let { stream ->
object : InputStream() {
override fun read(): Int = stream.read()
override fun read(b: ByteArray): Int = stream.read(b)
override fun read(b: ByteArray, off: Int, len: Int): Int = stream.read(b, off, len)
override fun close() {
try {
stream.close()
} finally {
resp.close()
}
}
}
}
}
private suspend fun awaitOperation(href: String) {
repeat(OPERATION_POLL_MAX) {
delay(OPERATION_POLL_DELAY_MS)
val st: OperationStatusDto = wrapAuth { api.getOperationByUrl(href) }
when (st.status?.lowercase()) {
"success" -> return
"failure", "failed" -> throw IOException("Disk async operation failed")
else -> { }
}
}
throw IOException("Disk async operation timed out")
}
private suspend inline fun <T> wrapAuth(crossinline block: suspend () -> T): T {
try {
return block()
} catch (e: HttpException) {
if (e.code() == 401) throw YandexDiskAuthException(e.message())
throw e
}
}
private fun failure(op: String, resp: Response<ResponseBody>): IOException {
val msg = resp.errorBody()?.string() ?: resp.message()
return IOException("$op failed: HTTP ${resp.code()} $msg")
}
private fun parseLink(body: ResponseBody): LinkDto =
jackson.readValue(body.string())
companion object {
private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() }
private val OCTET_STREAM = "application/octet-stream".toMediaType()
private const val OPERATION_POLL_DELAY_MS = 300L
private const val OPERATION_POLL_MAX = 200
fun parseOperationId(href: String): String? {
val url = href.toHttpUrlOrNull() ?: return null
url.queryParameter("id")?.let { return it }
val segments = url.pathSegments
val idx = segments.indexOf("operations")
if (idx >= 0 && idx + 1 < segments.size) {
return segments[idx + 1]
}
return null
}
}
}

View File

@@ -0,0 +1,17 @@
package com.github.nullptroma.wallenc.data.network.yandexdisk.repository
import com.github.nullptroma.wallenc.data.network.yandexdisk.YandexDiskApiFactory
import kotlinx.coroutines.CoroutineDispatcher
import java.util.UUID
class YandexDiskRepositoryFactory(
private val apiFactory: YandexDiskApiFactory,
private val ioDispatcher: CoroutineDispatcher,
) {
fun create(vaultUuid: UUID): YandexDiskRepository =
YandexDiskRepository(
api = apiFactory.createApiForVault(vaultUuid),
rawHttp = apiFactory.rawHttpClient,
ioDispatcher = ioDispatcher,
)
}

View File

@@ -0,0 +1,40 @@
package com.github.nullptroma.wallenc.data.storages.yandex
import com.github.nullptroma.wallenc.data.network.yandexdisk.repository.YandexDiskRepository
import com.github.nullptroma.wallenc.data.storages.common.BaseStorage
import com.github.nullptroma.wallenc.data.storages.local.LocalStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
import java.util.UUID
class YandexStorage(
uuid: UUID,
private val repo: YandexDiskRepository,
vaultAvailability: StateFlow<Boolean>,
private val ioDispatcher: CoroutineDispatcher,
accessorScope: CoroutineScope,
private val reportAuthFailure: () -> Unit,
) : BaseStorage(
uuid = uuid,
ioDispatcher = ioDispatcher,
metaInfoFilePostfix = LocalStorage.STORAGE_INFO_FILE_POSTFIX,
) {
private val _accessor = YandexStorageAccessor(
storageUuid = uuid,
repo = repo,
ioDispatcher = ioDispatcher,
vaultAvailability = vaultAvailability,
accessorScope = accessorScope,
reportAuthFailure = reportAuthFailure,
)
override val accessor: IStorageAccessor = _accessor
override val isVirtualStorage: Boolean = false
override suspend fun init() {
_accessor.init()
super.init()
}
}

View File

@@ -0,0 +1,441 @@
package com.github.nullptroma.wallenc.data.storages.yandex
import com.github.nullptroma.wallenc.data.network.yandexdisk.repository.YandexDiskAuthException
import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.ResourceDto
import com.github.nullptroma.wallenc.data.network.yandexdisk.repository.YandexDiskRepository
import com.github.nullptroma.wallenc.data.utils.CloseHandledStreamExtension.Companion.onClosed
import com.github.nullptroma.wallenc.domain.common.impl.CommonDirectory
import com.github.nullptroma.wallenc.domain.common.impl.CommonFile
import com.github.nullptroma.wallenc.domain.common.impl.CommonMetaInfo
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.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.io.OutputStream
import java.time.Instant
import java.util.UUID
/**
* [IStorageAccessor] поверх папки приложения `app:/<storageUuid>/…` на Яндекс.Диске.
*
* [isAvailable] = доступность vault'а ([vaultAvailability]) **и** успешная локальная
* инициализация этого storage ([storageReady]).
*/
class YandexStorageAccessor(
private val storageUuid: UUID,
private val repo: YandexDiskRepository,
private val ioDispatcher: CoroutineDispatcher,
vaultAvailability: StateFlow<Boolean>,
accessorScope: CoroutineScope,
private val reportAuthFailure: () -> Unit,
) : IStorageAccessor {
private val diskRoot = "app:/$storageUuid"
private val _size = MutableStateFlow<Long?>(null)
override val size: StateFlow<Long?> = _size
private val _numberOfFiles = MutableStateFlow<Int?>(null)
override val numberOfFiles: StateFlow<Int?> = _numberOfFiles
private val _storageReady = MutableStateFlow(false)
override val isAvailable: StateFlow<Boolean> =
combine(vaultAvailability, _storageReady) { vaultOk, ready -> vaultOk && ready }
.stateIn(accessorScope, SharingStarted.Eagerly, false)
private val _filesUpdates = MutableSharedFlow<DataPage<IFile>>()
override val filesUpdates: SharedFlow<DataPage<IFile>> = _filesUpdates
private val _dirsUpdates = MutableSharedFlow<DataPage<IDirectory>>()
override val dirsUpdates: SharedFlow<DataPage<IDirectory>> = _dirsUpdates
suspend fun init() = withContext(ioDispatcher) {
try {
scanSizeAndNumOfFiles()
_storageReady.value = true
} catch (e: YandexDiskAuthException) {
reportAuthFailure()
_storageReady.value = false
throw e
} catch (_: Exception) {
_storageReady.value = false
throw Exception("Yandex storage init failed")
}
}
private inline fun <T> guard(block: () -> T): T {
try {
return block()
} catch (e: YandexDiskAuthException) {
reportAuthFailure()
throw e
}
}
private fun toDiskPath(rel: String): String {
val tail = when {
rel.isBlank() || rel == "/" -> ""
else -> rel.trimStart('/')
}
return if (tail.isEmpty()) diskRoot else "$diskRoot/$tail"
}
private fun toRelPath(diskPath: String): String {
val u = storageUuid.toString()
val app = "app:/$u/"
if (diskPath.startsWith(app)) {
val tail = diskPath.removePrefix(app).removeSuffix("/")
return if (tail.isEmpty()) "/" else "/$tail"
}
val needle = "/$u/"
val i = diskPath.indexOf(needle)
if (i >= 0) {
val tail = diskPath.substring(i + needle.length).removeSuffix("/")
return if (tail.isEmpty()) "/" else "/$tail"
}
return "/"
}
private fun isSystemDiskPath(diskPath: String?): Boolean {
if (diskPath == null) return false
return diskPath.contains("/$SYSTEM_HIDDEN_DIRNAME/") ||
diskPath.endsWith("/$SYSTEM_HIDDEN_DIRNAME")
}
private fun isSystemRel(rel: String): Boolean =
rel == "/$SYSTEM_HIDDEN_DIRNAME" || rel.startsWith("/$SYSTEM_HIDDEN_DIRNAME/")
private suspend fun ensureSystemDirExists() {
guard { repo.createFolder(toDiskPath("/$SYSTEM_HIDDEN_DIRNAME")) }
}
private suspend fun scanSizeAndNumOfFiles() {
var totalSize = 0L
var count = 0
val queue = ArrayDeque<String>()
queue.add("/")
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)) {
totalSize += f.metaInfo.size
count++
}
}
}
_size.value = totalSize
_numberOfFiles.value = count
}
private suspend fun listImmediateChildren(relDir: String): Pair<List<IFile>, List<IDirectory>> {
val diskPath = toDiskPath(relDir)
val files = mutableListOf<IFile>()
val dirs = mutableListOf<IDirectory>()
var offset = 0
while (true) {
val res = guard { repo.list(diskPath, API_LIST_LIMIT, offset) }
val items = res.embedded?.items.orEmpty()
for (it in items) {
if (isSystemDiskPath(it.path)) continue
val rel = toRelPath(it.path ?: continue)
if (isSystemRel(rel)) continue
when (it.type) {
"file" -> files.add(it.toCommonFile(rel))
"dir" -> dirs.add(it.toCommonDir(rel))
}
}
if (items.size < API_LIST_LIMIT) break
offset += items.size
}
return files to dirs
}
private fun ResourceDto.toCommonFile(rel: String): CommonFile =
CommonFile(
CommonMetaInfo(
size = size ?: 0L,
isDeleted = boolProp(customProperties, PROP_DELETED),
isHidden = boolProp(customProperties, PROP_HIDDEN),
lastModified = modified ?: created ?: Instant.EPOCH,
path = rel,
),
)
private fun ResourceDto.toCommonDir(rel: String): CommonDirectory =
CommonDirectory(
CommonMetaInfo(
size = 0L,
isDeleted = boolProp(customProperties, PROP_DELETED),
isHidden = boolProp(customProperties, PROP_HIDDEN),
lastModified = modified ?: created ?: Instant.EPOCH,
path = rel,
),
elementsCount = null,
)
private fun boolProp(props: Map<String, Any?>?, key: String): Boolean {
val v = props?.get(key) ?: return false
return when (v) {
is Boolean -> v
is String -> v.equals("true", ignoreCase = true)
else -> v.toString().equals("true", ignoreCase = true)
}
}
override suspend fun getAllFiles(): List<IFile> = withContext(ioDispatcher) {
val out = mutableListOf<IFile>()
val queue = ArrayDeque<String>()
queue.add("/")
while (queue.isNotEmpty()) {
val rel = queue.removeFirst()
if (isSystemRel(rel)) continue
val (files, dirs) = listImmediateChildren(rel)
out.addAll(files.filter { !isSystemRel(it.metaInfo.path) })
for (d in dirs) {
if (!isSystemRel(d.metaInfo.path)) queue.add(d.metaInfo.path)
}
}
out
}
override suspend fun getFiles(path: String): List<IFile> = withContext(ioDispatcher) {
listImmediateChildren(path).first
}
override fun getFilesFlow(path: String): Flow<DataPage<IFile>> = flow {
val all = withContext(ioDispatcher) { listImmediateChildren(path).first }
var pageIndex = 0
var i = 0
while (i < all.size) {
val chunk = all.subList(i, kotlin.math.min(i + DATA_PAGE_LENGTH, all.size)).toList()
emit(
DataPage(
list = chunk,
isLoading = false,
isError = false,
hasNext = i + DATA_PAGE_LENGTH < all.size,
pageLength = DATA_PAGE_LENGTH,
pageIndex = pageIndex++,
),
)
i += DATA_PAGE_LENGTH
}
if (all.isEmpty()) {
emit(
DataPage(
list = emptyList(),
isLoading = false,
isError = false,
hasNext = false,
pageLength = DATA_PAGE_LENGTH,
pageIndex = 0,
),
)
}
}.flowOn(ioDispatcher)
override suspend fun getAllDirs(): List<IDirectory> = withContext(ioDispatcher) {
val out = mutableListOf<IDirectory>()
val queue = ArrayDeque<String>()
queue.add("/")
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)) {
out.add(d)
queue.add(d.metaInfo.path)
}
}
}
out
}
override suspend fun getDirs(path: String): List<IDirectory> = withContext(ioDispatcher) {
listImmediateChildren(path).second
}
override fun getDirsFlow(path: String): Flow<DataPage<IDirectory>> = flow {
val all = withContext(ioDispatcher) { listImmediateChildren(path).second }
var pageIndex = 0
var i = 0
while (i < all.size) {
val chunk = all.subList(i, kotlin.math.min(i + DATA_PAGE_LENGTH, all.size)).toList()
emit(
DataPage(
list = chunk,
isLoading = false,
isError = false,
hasNext = i + DATA_PAGE_LENGTH < all.size,
pageLength = DATA_PAGE_LENGTH,
pageIndex = pageIndex++,
),
)
i += DATA_PAGE_LENGTH
}
if (all.isEmpty()) {
emit(
DataPage(
list = emptyList(),
isLoading = false,
isError = false,
hasNext = false,
pageLength = DATA_PAGE_LENGTH,
pageIndex = 0,
),
)
}
}.flowOn(ioDispatcher)
override suspend fun getFileInfo(path: String): IFile = withContext(ioDispatcher) {
val r = guard { repo.get(toDiskPath(path)) }
if (r.type != "file") throw IllegalStateException("Not a file")
r.toCommonFile(path)
}
override suspend fun getDirInfo(path: String): IDirectory = withContext(ioDispatcher) {
val r = guard { repo.get(toDiskPath(path)) }
if (r.type != "dir") throw IllegalStateException("Not a directory")
r.toCommonDir(path)
}
override suspend fun setHidden(path: String, hidden: Boolean) = withContext(ioDispatcher) {
patchCustom(path) { it[PROP_HIDDEN] = if (hidden) "true" else "false" }
val f = getFileInfo(path)
_filesUpdates.emit(
DataPage(listOf(f), pageLength = 1, pageIndex = 0),
)
}
override suspend fun touchFile(path: String): Unit = withContext(ioDispatcher) {
touchParentDirs(path)
try {
guard { repo.uploadBytes(toDiskPath(path), ByteArray(0), overwrite = false) }
} catch (_: Exception) {
// файл уже есть — ок
}
scanSizeAndNumOfFiles()
}
override suspend fun touchDir(path: String): Unit = withContext(ioDispatcher) {
val segments = pathSegments(path)
var acc = ""
for (seg in segments) {
acc += "/$seg"
guard { repo.createFolder(toDiskPath(acc)) }
}
}
override suspend fun delete(path: String) = withContext(ioDispatcher) {
if (path == "/" || path.isBlank()) {
throw IllegalArgumentException("Deleting root path is forbidden")
}
guard { repo.delete(toDiskPath(path), permanently = true) }
scanSizeAndNumOfFiles()
}
override suspend fun openWrite(path: String): OutputStream = withContext(ioDispatcher) {
touchParentDirs(path)
val tmp = File.createTempFile("wallenc-yandex-", ".upload")
val fos = FileOutputStream(tmp)
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))
}
} finally {
tmp.delete()
}
}
}
}
override suspend fun openRead(path: String): InputStream = withContext(ioDispatcher) {
guard { repo.openDownloadStream(toDiskPath(path)) }
}
override suspend fun moveToTrash(path: String) = withContext(ioDispatcher) {
patchCustom(path) { it[PROP_DELETED] = "true" }
}
override suspend fun openReadSystemFile(name: String): InputStream = withContext(ioDispatcher) {
ensureSystemDirExists()
val rel = "/$SYSTEM_HIDDEN_DIRNAME/$name"
try {
guard { repo.openDownloadStream(toDiskPath(rel)) }
} catch (_: Exception) {
// как Local: пустой файл если нет
guard { repo.uploadBytes(toDiskPath(rel), ByteArray(0), overwrite = true) }
guard { repo.openDownloadStream(toDiskPath(rel)) }
}
}
override suspend fun openWriteSystemFile(name: String): OutputStream = withContext(ioDispatcher) {
ensureSystemDirExists()
val rel = "/$SYSTEM_HIDDEN_DIRNAME/$name"
val baos = ByteArrayOutputStream()
baos.onClosed {
runBlocking(ioDispatcher) {
guard { repo.uploadBytes(toDiskPath(rel), baos.toByteArray(), overwrite = true) }
}
}
}
private suspend fun touchParentDirs(path: String) {
val normalized = path.removeSuffix("/")
if (normalized == "/" || normalized.isBlank()) return
val lastSlash = normalized.lastIndexOf('/')
if (lastSlash <= 0) return
val parent = normalized.substring(0, lastSlash).ifEmpty { "/" }
touchDir(parent)
}
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) }
}
companion object {
private const val SYSTEM_HIDDEN_DIRNAME = "wallenc-yandex-system"
private const val DATA_PAGE_LENGTH = 10
private const val API_LIST_LIMIT = 200
private const val PROP_HIDDEN = "wallenc.hidden"
private const val PROP_DELETED = "wallenc.deleted"
}
}

View File

@@ -4,6 +4,7 @@ import android.content.Context
import com.github.nullptroma.wallenc.data.db.app.model.DbYandexAccount
import com.github.nullptroma.wallenc.data.db.app.repository.StorageKeyMapRepository
import com.github.nullptroma.wallenc.data.db.app.repository.YandexAccountRepository
import com.github.nullptroma.wallenc.data.network.yandexdisk.repository.YandexDiskRepositoryFactory
import com.github.nullptroma.wallenc.data.network.yandexuserinfo.repository.YandexUserInfoRepository
import com.github.nullptroma.wallenc.data.storages.UnlockManager
import com.github.nullptroma.wallenc.data.vaults.local.LocalVault
@@ -37,6 +38,7 @@ class VaultsManager(
keyRepo: StorageKeyMapRepository,
private val yandexAccountRepository: YandexAccountRepository,
private val yandexUserInfoRepository: YandexUserInfoRepository,
private val yandexDiskRepositoryFactory: YandexDiskRepositoryFactory,
) : IVaultsManager, VaultRegistrar {
private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)
@@ -50,10 +52,13 @@ class VaultsManager(
private val yandexVaults: StateFlow<List<IVault>> = yandexAccountRepository.observeAll()
.map { rows ->
rows.map { row ->
val vaultUuid = UUID.fromString(row.vaultUuid)
YandexVault(
uuid = UUID.fromString(row.vaultUuid),
uuid = vaultUuid,
accountEmail = row.email,
oauthToken = row.oauthToken,
repo = yandexDiskRepositoryFactory.create(vaultUuid),
ioDispatcher = ioDispatcher,
parentScope = scope,
)
}
}

View File

@@ -1,22 +1,33 @@
package com.github.nullptroma.wallenc.data.vaults.yandex
import com.github.nullptroma.wallenc.data.network.yandexdisk.repository.YandexDiskAuthException
import com.github.nullptroma.wallenc.data.network.yandexdisk.repository.YandexDiskRepository
import com.github.nullptroma.wallenc.data.storages.yandex.YandexStorage
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.vaultapi.CloudBrand
import com.github.nullptroma.wallenc.vaultapi.DescribedVault
import com.github.nullptroma.wallenc.vaultapi.VaultDescriptor
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
/**
* Удалённый vault Яндекс.Диска. Сейчас — заглушка для уровня storages
* (Phase 1 — только OAuth + перечисление аккаунтов).
* Удалённый vault Яндекс.Диска: папка приложения `app:/`, внутри — подпапки-UUID как [YandexStorage].
*
* [isAvailable] — успешный контакт с Disk API (метаданные диска и загрузка списка storages).
* Пока false, дочерние storages тоже считаются недоступными (см. YandexStorageAccessor).
*/
class YandexVault(
override val uuid: UUID,
accountEmail: String,
val oauthToken: String,
private val repo: YandexDiskRepository,
private val ioDispatcher: CoroutineDispatcher,
private val parentScope: CoroutineScope,
) : DescribedVault {
override val descriptor: VaultDescriptor = VaultDescriptor.LinkedRemote(
@@ -25,25 +36,107 @@ class YandexVault(
accountDisplayName = accountEmail,
)
private val _vaultReachable = MutableStateFlow(false)
override val isAvailable: StateFlow<Boolean> = _vaultReachable
private val _storages = MutableStateFlow<List<IStorage>>(emptyList())
override val storages: StateFlow<List<IStorage>> = _storages
private val _isAvailable = MutableStateFlow(true)
override val isAvailable: StateFlow<Boolean> = _isAvailable
private val _totalSpace = MutableStateFlow<Long?>(null)
override val totalSpace: StateFlow<Long?> = _totalSpace
private val _availableSpace = MutableStateFlow<Long?>(null)
override val availableSpace: StateFlow<Long?> = _availableSpace
override suspend fun createStorage(): IStorage =
throw UnsupportedOperationException("Yandex.Disk REST integration is not connected yet")
init {
parentScope.launch {
runCatching { refreshFromDisk() }
}
}
override suspend fun createStorage(enc: StorageEncryptionInfo): IStorage =
throw UnsupportedOperationException("Yandex.Disk REST integration is not connected yet")
private suspend fun refreshFromDisk() {
_vaultReachable.value = false
try {
val info = repo.diskInfo()
_totalSpace.value = info.totalSpace
val used = info.usedSpace ?: 0L
val total = info.totalSpace ?: 0L
_availableSpace.value = (total - used).coerceAtLeast(0L)
_vaultReachable.value = true
_storages.value = loadStoragesList()
} catch (_: YandexDiskAuthException) {
_vaultReachable.value = false
_storages.value = emptyList()
} catch (_: Exception) {
_vaultReachable.value = false
_storages.value = emptyList()
}
}
override suspend fun remove(storage: IStorage) {
// No-op до интеграции API Диска.
private suspend fun loadStoragesList(): List<IStorage> {
val out = mutableListOf<YandexStorage>()
var offset = 0
while (true) {
val root = repo.list("app:/", APP_LIST_LIMIT, offset)
val items = root.embedded?.items.orEmpty()
for (item in items) {
if (item.type != "dir") continue
val name = item.name ?: continue
val storageUuid = runCatching { UUID.fromString(name) }.getOrNull() ?: continue
val storage = YandexStorage(
uuid = storageUuid,
repo = repo,
vaultAvailability = _vaultReachable,
ioDispatcher = ioDispatcher,
accessorScope = parentScope,
reportAuthFailure = {
parentScope.launch { _vaultReachable.value = false }
},
)
try {
storage.init()
out.add(storage)
} catch (_: Exception) {
// пропускаем битое/частично созданное хранилище
}
}
if (items.size < APP_LIST_LIMIT) break
offset += items.size
}
return out
}
override suspend fun createStorage(): IStorage = withContext(ioDispatcher) {
val id = UUID.randomUUID()
repo.createFolder("app:/$id")
val storage = YandexStorage(
uuid = id,
repo = repo,
vaultAvailability = _vaultReachable,
ioDispatcher = ioDispatcher,
accessorScope = parentScope,
reportAuthFailure = {
parentScope.launch { _vaultReachable.value = false }
},
)
storage.init()
_storages.value = _storages.value + storage
storage
}
override suspend fun createStorage(enc: StorageEncryptionInfo): IStorage {
val storage = createStorage()
storage.setEncInfo(enc)
return storage
}
override suspend fun remove(storage: IStorage) = withContext(ioDispatcher) {
if (storage !is YandexStorage) return@withContext
repo.delete("app:/${storage.uuid}", permanently = true)
_storages.value = _storages.value.filter { it.uuid != storage.uuid }
}
private companion object {
private const val APP_LIST_LIMIT = 200
}
}