diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/SingletonModule.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/SingletonModule.kt index fe93efb..2148738 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/SingletonModule.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/SingletonModule.kt @@ -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, ) } diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 956896c..8576134 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -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) diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/dao/YandexAccountDao.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/dao/YandexAccountDao.kt index 28b9693..4c6c3e7 100644 --- a/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/dao/YandexAccountDao.kt +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/dao/YandexAccountDao.kt @@ -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) diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/repository/YandexAccountRepository.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/repository/YandexAccountRepository.kt index 06f9b59..bef0607 100644 --- a/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/repository/YandexAccountRepository.kt +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/repository/YandexAccountRepository.kt @@ -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) } diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/network/yandexdisk/YandexDiskApi.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/network/yandexdisk/YandexDiskApi.kt new file mode 100644 index 0000000..0bbac2d --- /dev/null +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/network/yandexdisk/YandexDiskApi.kt @@ -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 + + @DELETE("v1/disk/resources") + suspend fun deleteResource( + @Query("path") path: String, + @Query("permanently") permanently: Boolean, + ): Response + + @POST("v1/disk/resources/move") + suspend fun moveResource( + @Query("from") from: String, + @Query("path") toPath: String, + @Query("overwrite") overwrite: Boolean = false, + ): Response + + @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 + + @GET + suspend fun getOperationByUrl(@Url url: String): OperationStatusDto + + @GET("v1/disk/operations/{id}") + suspend fun getOperation(@Path("id") id: String): OperationStatusDto +} diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/network/yandexdisk/YandexDiskApiFactory.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/network/yandexdisk/YandexDiskApiFactory.kt new file mode 100644 index 0000000..4734d0b --- /dev/null +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/network/yandexdisk/YandexDiskApiFactory.kt @@ -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/" + } +} diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/network/yandexdisk/dto/YandexDiskDto.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/network/yandexdisk/dto/YandexDiskDto.kt new file mode 100644 index 0000000..53c5c5e --- /dev/null +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/network/yandexdisk/dto/YandexDiskDto.kt @@ -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? = 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? = 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, +) diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/network/yandexdisk/repository/YandexDiskRepository.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/network/yandexdisk/repository/YandexDiskRepository.kt new file mode 100644 index 0000000..b247400 --- /dev/null +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/network/yandexdisk/repository/YandexDiskRepository.kt @@ -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): 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 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): 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 + } + } +} diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/network/yandexdisk/repository/YandexDiskRepositoryFactory.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/network/yandexdisk/repository/YandexDiskRepositoryFactory.kt new file mode 100644 index 0000000..b22e6c6 --- /dev/null +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/network/yandexdisk/repository/YandexDiskRepositoryFactory.kt @@ -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, + ) +} diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/storages/yandex/YandexStorage.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/storages/yandex/YandexStorage.kt new file mode 100644 index 0000000..3779378 --- /dev/null +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/storages/yandex/YandexStorage.kt @@ -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, + 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() + } +} diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/storages/yandex/YandexStorageAccessor.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/storages/yandex/YandexStorageAccessor.kt new file mode 100644 index 0000000..fe3648c --- /dev/null +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/storages/yandex/YandexStorageAccessor.kt @@ -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://…` на Яндекс.Диске. + * + * [isAvailable] = доступность vault'а ([vaultAvailability]) **и** успешная локальная + * инициализация этого storage ([storageReady]). + */ +class YandexStorageAccessor( + private val storageUuid: UUID, + private val repo: YandexDiskRepository, + private val ioDispatcher: CoroutineDispatcher, + vaultAvailability: StateFlow, + accessorScope: CoroutineScope, + private val reportAuthFailure: () -> Unit, +) : IStorageAccessor { + + private val diskRoot = "app:/$storageUuid" + + private val _size = MutableStateFlow(null) + override val size: StateFlow = _size + + private val _numberOfFiles = MutableStateFlow(null) + override val numberOfFiles: StateFlow = _numberOfFiles + + private val _storageReady = MutableStateFlow(false) + + override val isAvailable: StateFlow = + combine(vaultAvailability, _storageReady) { vaultOk, ready -> vaultOk && ready } + .stateIn(accessorScope, SharingStarted.Eagerly, false) + + private val _filesUpdates = MutableSharedFlow>() + override val filesUpdates: SharedFlow> = _filesUpdates + + private val _dirsUpdates = MutableSharedFlow>() + override val dirsUpdates: SharedFlow> = _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 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() + 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> { + val diskPath = toDiskPath(relDir) + val files = mutableListOf() + val dirs = mutableListOf() + 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?, 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 = withContext(ioDispatcher) { + val out = mutableListOf() + val queue = ArrayDeque() + 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 = withContext(ioDispatcher) { + listImmediateChildren(path).first + } + + override fun getFilesFlow(path: String): Flow> = 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 = withContext(ioDispatcher) { + val out = mutableListOf() + val queue = ArrayDeque() + 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 = withContext(ioDispatcher) { + listImmediateChildren(path).second + } + + override fun getDirsFlow(path: String): Flow> = 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 = + path.trim('/').split('/').filter { it.isNotBlank() } + + private suspend fun patchCustom(path: String, mutator: (MutableMap) -> 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" + } +} diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/VaultsManager.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/VaultsManager.kt index dcfe0d1..39195d9 100644 --- a/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/VaultsManager.kt +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/VaultsManager.kt @@ -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> = 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, ) } } diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/yandex/YandexVault.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/yandex/YandexVault.kt index 9e11992..e42b935 100644 --- a/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/yandex/YandexVault.kt +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/yandex/YandexVault.kt @@ -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 = _vaultReachable + private val _storages = MutableStateFlow>(emptyList()) override val storages: StateFlow> = _storages - private val _isAvailable = MutableStateFlow(true) - override val isAvailable: StateFlow = _isAvailable - private val _totalSpace = MutableStateFlow(null) override val totalSpace: StateFlow = _totalSpace private val _availableSpace = MutableStateFlow(null) override val availableSpace: StateFlow = _availableSpace - override suspend fun createStorage(): IStorage = - throw UnsupportedOperationException("Yandex.Disk REST integration is not connected yet") + init { + parentScope.launch { + runCatching { refreshFromDisk() } + } + } - override suspend fun createStorage(enc: StorageEncryptionInfo): IStorage = - throw UnsupportedOperationException("Yandex.Disk REST integration is not connected yet") + private suspend fun refreshFromDisk() { + _vaultReachable.value = false + try { + val info = repo.diskInfo() + _totalSpace.value = info.totalSpace + val used = info.usedSpace ?: 0L + val total = info.totalSpace ?: 0L + _availableSpace.value = (total - used).coerceAtLeast(0L) + _vaultReachable.value = true + _storages.value = loadStoragesList() + } catch (_: YandexDiskAuthException) { + _vaultReachable.value = false + _storages.value = emptyList() + } catch (_: Exception) { + _vaultReachable.value = false + _storages.value = emptyList() + } + } - override suspend fun remove(storage: IStorage) { - // No-op до интеграции API Диска. + private suspend fun loadStoragesList(): List { + val out = mutableListOf() + 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 } }