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:
@@ -8,6 +8,8 @@ import com.github.nullptroma.wallenc.data.db.app.dao.YandexAccountDao
|
||||
import com.github.nullptroma.wallenc.data.db.app.repository.StorageKeyMapRepository
|
||||
import com.github.nullptroma.wallenc.data.db.app.repository.StorageMetaInfoRepository
|
||||
import com.github.nullptroma.wallenc.data.db.app.repository.YandexAccountRepository
|
||||
import com.github.nullptroma.wallenc.data.network.yandexdisk.YandexDiskApiFactory
|
||||
import com.github.nullptroma.wallenc.data.network.yandexdisk.repository.YandexDiskRepositoryFactory
|
||||
import com.github.nullptroma.wallenc.data.network.yandexuserinfo.YandexUserInfoApi
|
||||
import com.github.nullptroma.wallenc.data.network.yandexuserinfo.YandexUserInfoApiFactory
|
||||
import com.github.nullptroma.wallenc.data.network.yandexuserinfo.repository.YandexUserInfoRepository
|
||||
@@ -38,6 +40,26 @@ class SingletonModule {
|
||||
@Singleton
|
||||
fun provideYandexUserInfoApi(): YandexUserInfoApi = YandexUserInfoApiFactory.create()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideYandexDiskApiFactory(
|
||||
yandexAccountRepository: YandexAccountRepository,
|
||||
@IoDispatcher ioDispatcher: CoroutineDispatcher,
|
||||
): YandexDiskApiFactory = YandexDiskApiFactory(
|
||||
accountRepository = yandexAccountRepository,
|
||||
ioDispatcher = ioDispatcher,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideYandexDiskRepositoryFactory(
|
||||
apiFactory: YandexDiskApiFactory,
|
||||
@IoDispatcher ioDispatcher: CoroutineDispatcher,
|
||||
): YandexDiskRepositoryFactory = YandexDiskRepositoryFactory(
|
||||
apiFactory = apiFactory,
|
||||
ioDispatcher = ioDispatcher,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideVaultsManager(
|
||||
@@ -46,6 +68,7 @@ class SingletonModule {
|
||||
keyRepo: StorageKeyMapRepository,
|
||||
yandexAccountRepository: YandexAccountRepository,
|
||||
yandexUserInfoRepository: YandexUserInfoRepository,
|
||||
yandexDiskRepositoryFactory: YandexDiskRepositoryFactory,
|
||||
): VaultsManager {
|
||||
return VaultsManager(
|
||||
ioDispatcher = ioDispatcher,
|
||||
@@ -53,6 +76,7 @@ class SingletonModule {
|
||||
keyRepo = keyRepo,
|
||||
yandexAccountRepository = yandexAccountRepository,
|
||||
yandexUserInfoRepository = yandexUserInfoRepository,
|
||||
yandexDiskRepositoryFactory = yandexDiskRepositoryFactory,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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/"
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
override suspend fun createStorage(enc: StorageEncryptionInfo): IStorage =
|
||||
throw UnsupportedOperationException("Yandex.Disk REST integration is not connected yet")
|
||||
|
||||
override suspend fun remove(storage: IStorage) {
|
||||
// No-op до интеграции API Диска.
|
||||
init {
|
||||
parentScope.launch {
|
||||
runCatching { refreshFromDisk() }
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user