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.StorageKeyMapRepository
|
||||||
import com.github.nullptroma.wallenc.data.db.app.repository.StorageMetaInfoRepository
|
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.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.YandexUserInfoApi
|
||||||
import com.github.nullptroma.wallenc.data.network.yandexuserinfo.YandexUserInfoApiFactory
|
import com.github.nullptroma.wallenc.data.network.yandexuserinfo.YandexUserInfoApiFactory
|
||||||
import com.github.nullptroma.wallenc.data.network.yandexuserinfo.repository.YandexUserInfoRepository
|
import com.github.nullptroma.wallenc.data.network.yandexuserinfo.repository.YandexUserInfoRepository
|
||||||
@@ -38,6 +40,26 @@ class SingletonModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideYandexUserInfoApi(): YandexUserInfoApi = YandexUserInfoApiFactory.create()
|
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
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideVaultsManager(
|
fun provideVaultsManager(
|
||||||
@@ -46,6 +68,7 @@ class SingletonModule {
|
|||||||
keyRepo: StorageKeyMapRepository,
|
keyRepo: StorageKeyMapRepository,
|
||||||
yandexAccountRepository: YandexAccountRepository,
|
yandexAccountRepository: YandexAccountRepository,
|
||||||
yandexUserInfoRepository: YandexUserInfoRepository,
|
yandexUserInfoRepository: YandexUserInfoRepository,
|
||||||
|
yandexDiskRepositoryFactory: YandexDiskRepositoryFactory,
|
||||||
): VaultsManager {
|
): VaultsManager {
|
||||||
return VaultsManager(
|
return VaultsManager(
|
||||||
ioDispatcher = ioDispatcher,
|
ioDispatcher = ioDispatcher,
|
||||||
@@ -53,6 +76,7 @@ class SingletonModule {
|
|||||||
keyRepo = keyRepo,
|
keyRepo = keyRepo,
|
||||||
yandexAccountRepository = yandexAccountRepository,
|
yandexAccountRepository = yandexAccountRepository,
|
||||||
yandexUserInfoRepository = yandexUserInfoRepository,
|
yandexUserInfoRepository = yandexUserInfoRepository,
|
||||||
|
yandexDiskRepositoryFactory = yandexDiskRepositoryFactory,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ dependencies {
|
|||||||
implementation(libs.retrofit.converter.scalars)
|
implementation(libs.retrofit.converter.scalars)
|
||||||
implementation(libs.retrofit.converter.jackson)
|
implementation(libs.retrofit.converter.jackson)
|
||||||
|
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ interface YandexAccountDao {
|
|||||||
@Query("SELECT * FROM yandex_accounts WHERE yandexUserId = :id LIMIT 1")
|
@Query("SELECT * FROM yandex_accounts WHERE yandexUserId = :id LIMIT 1")
|
||||||
suspend fun getByYandexUserId(id: String): DbYandexAccount?
|
suspend fun getByYandexUserId(id: String): DbYandexAccount?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM yandex_accounts WHERE vaultUuid = :vaultUuid LIMIT 1")
|
||||||
|
suspend fun getByVaultUuid(vaultUuid: String): DbYandexAccount?
|
||||||
|
|
||||||
@Insert
|
@Insert
|
||||||
suspend fun insert(account: DbYandexAccount)
|
suspend fun insert(account: DbYandexAccount)
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ class YandexAccountRepository(
|
|||||||
dao.getByYandexUserId(id)
|
dao.getByYandexUserId(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getByVaultUuid(vaultUuid: String): DbYandexAccount? = withContext(ioDispatcher) {
|
||||||
|
dao.getByVaultUuid(vaultUuid)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun insert(account: DbYandexAccount) = withContext(ioDispatcher) {
|
suspend fun insert(account: DbYandexAccount) = withContext(ioDispatcher) {
|
||||||
dao.insert(account)
|
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.model.DbYandexAccount
|
||||||
import com.github.nullptroma.wallenc.data.db.app.repository.StorageKeyMapRepository
|
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.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.network.yandexuserinfo.repository.YandexUserInfoRepository
|
||||||
import com.github.nullptroma.wallenc.data.storages.UnlockManager
|
import com.github.nullptroma.wallenc.data.storages.UnlockManager
|
||||||
import com.github.nullptroma.wallenc.data.vaults.local.LocalVault
|
import com.github.nullptroma.wallenc.data.vaults.local.LocalVault
|
||||||
@@ -37,6 +38,7 @@ class VaultsManager(
|
|||||||
keyRepo: StorageKeyMapRepository,
|
keyRepo: StorageKeyMapRepository,
|
||||||
private val yandexAccountRepository: YandexAccountRepository,
|
private val yandexAccountRepository: YandexAccountRepository,
|
||||||
private val yandexUserInfoRepository: YandexUserInfoRepository,
|
private val yandexUserInfoRepository: YandexUserInfoRepository,
|
||||||
|
private val yandexDiskRepositoryFactory: YandexDiskRepositoryFactory,
|
||||||
) : IVaultsManager, VaultRegistrar {
|
) : IVaultsManager, VaultRegistrar {
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)
|
private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)
|
||||||
@@ -50,10 +52,13 @@ class VaultsManager(
|
|||||||
private val yandexVaults: StateFlow<List<IVault>> = yandexAccountRepository.observeAll()
|
private val yandexVaults: StateFlow<List<IVault>> = yandexAccountRepository.observeAll()
|
||||||
.map { rows ->
|
.map { rows ->
|
||||||
rows.map { row ->
|
rows.map { row ->
|
||||||
|
val vaultUuid = UUID.fromString(row.vaultUuid)
|
||||||
YandexVault(
|
YandexVault(
|
||||||
uuid = UUID.fromString(row.vaultUuid),
|
uuid = vaultUuid,
|
||||||
accountEmail = row.email,
|
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
|
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.datatypes.StorageEncryptionInfo
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
|
||||||
import com.github.nullptroma.wallenc.vaultapi.CloudBrand
|
import com.github.nullptroma.wallenc.vaultapi.CloudBrand
|
||||||
import com.github.nullptroma.wallenc.vaultapi.DescribedVault
|
import com.github.nullptroma.wallenc.vaultapi.DescribedVault
|
||||||
import com.github.nullptroma.wallenc.vaultapi.VaultDescriptor
|
import com.github.nullptroma.wallenc.vaultapi.VaultDescriptor
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Удалённый vault Яндекс.Диска. Сейчас — заглушка для уровня storages
|
* Удалённый vault Яндекс.Диска: папка приложения `app:/`, внутри — подпапки-UUID как [YandexStorage].
|
||||||
* (Phase 1 — только OAuth + перечисление аккаунтов).
|
*
|
||||||
|
* [isAvailable] — успешный контакт с Disk API (метаданные диска и загрузка списка storages).
|
||||||
|
* Пока false, дочерние storages тоже считаются недоступными (см. YandexStorageAccessor).
|
||||||
*/
|
*/
|
||||||
class YandexVault(
|
class YandexVault(
|
||||||
override val uuid: UUID,
|
override val uuid: UUID,
|
||||||
accountEmail: String,
|
accountEmail: String,
|
||||||
val oauthToken: String,
|
private val repo: YandexDiskRepository,
|
||||||
|
private val ioDispatcher: CoroutineDispatcher,
|
||||||
|
private val parentScope: CoroutineScope,
|
||||||
) : DescribedVault {
|
) : DescribedVault {
|
||||||
|
|
||||||
override val descriptor: VaultDescriptor = VaultDescriptor.LinkedRemote(
|
override val descriptor: VaultDescriptor = VaultDescriptor.LinkedRemote(
|
||||||
@@ -25,25 +36,107 @@ class YandexVault(
|
|||||||
accountDisplayName = accountEmail,
|
accountDisplayName = accountEmail,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val _vaultReachable = MutableStateFlow(false)
|
||||||
|
override val isAvailable: StateFlow<Boolean> = _vaultReachable
|
||||||
|
|
||||||
private val _storages = MutableStateFlow<List<IStorage>>(emptyList())
|
private val _storages = MutableStateFlow<List<IStorage>>(emptyList())
|
||||||
override val storages: StateFlow<List<IStorage>> = _storages
|
override val storages: StateFlow<List<IStorage>> = _storages
|
||||||
|
|
||||||
private val _isAvailable = MutableStateFlow(true)
|
|
||||||
override val isAvailable: StateFlow<Boolean> = _isAvailable
|
|
||||||
|
|
||||||
private val _totalSpace = MutableStateFlow<Long?>(null)
|
private val _totalSpace = MutableStateFlow<Long?>(null)
|
||||||
override val totalSpace: StateFlow<Long?> = _totalSpace
|
override val totalSpace: StateFlow<Long?> = _totalSpace
|
||||||
|
|
||||||
private val _availableSpace = MutableStateFlow<Long?>(null)
|
private val _availableSpace = MutableStateFlow<Long?>(null)
|
||||||
override val availableSpace: StateFlow<Long?> = _availableSpace
|
override val availableSpace: StateFlow<Long?> = _availableSpace
|
||||||
|
|
||||||
override suspend fun createStorage(): IStorage =
|
init {
|
||||||
throw UnsupportedOperationException("Yandex.Disk REST integration is not connected yet")
|
parentScope.launch {
|
||||||
|
runCatching { refreshFromDisk() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun createStorage(enc: StorageEncryptionInfo): IStorage =
|
private suspend fun refreshFromDisk() {
|
||||||
throw UnsupportedOperationException("Yandex.Disk REST integration is not connected yet")
|
_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) {
|
private suspend fun loadStoragesList(): List<IStorage> {
|
||||||
// No-op до интеграции API Диска.
|
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