Большая реструктуризация проекта

This commit is contained in:
2026-05-11 19:33:32 +03:00
parent ad985679ee
commit 3928ac5409
132 changed files with 574 additions and 450 deletions

1
domain-storage/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,27 @@
plugins {
id("java-library")
alias(libs.plugins.jetbrains.kotlin.jvm)
}
kotlin {
jvmToolchain(17)
}
dependencies {
// jackson
implementation(libs.jackson.module.kotlin)
implementation(libs.jackson.datatype.jsr310)
// Retrofit
implementation(libs.retrofit)
implementation(libs.retrofit.converter.scalars)
implementation(libs.retrofit.converter.jackson)
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.junit)
implementation(project(":domain"))
implementation(project(":vault-contracts"))
}

View File

21
domain-storage/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,24 @@
package com.github.nullptroma.wallenc.infrastructure
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.github.nullptroma.wallenc.infrastructure.test", appContext.packageName)
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>
</manifest>

View File

@@ -0,0 +1,15 @@
package com.github.nullptroma.wallenc.infrastructure.auth
/**
* Scope-ы Яндекс.OAuth, которые нам нужны: только app_folder + disk.info.
*
* Используется как ссылка для синхронизации с консолью Yandex OAuth.
* Сам Yandex Auth SDK для WEBVIEW-логина запрашивает scope-ы, выставленные
* у приложения в OAuth-консоли; мы держим список здесь, чтобы было одно место правды.
*/
object YandexOAuthScopes {
const val DISK_APP_FOLDER = "cloud_api:disk.app_folder"
const val DISK_INFO = "cloud_api:disk.info"
val ALL: Set<String> = setOf(DISK_APP_FOLDER, DISK_INFO)
}

View File

@@ -0,0 +1,9 @@
package com.github.nullptroma.wallenc.infrastructure.model
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
import java.util.UUID
data class StorageKeyMap(
val sourceUuid: UUID,
val key: EncryptKey
)

View File

@@ -0,0 +1,8 @@
package com.github.nullptroma.wallenc.infrastructure.model
data class YandexAccount(
val vaultUuid: String,
val yandexUserId: String,
val email: String,
val oauthToken: String,
)

View File

@@ -0,0 +1,76 @@
package com.github.nullptroma.wallenc.infrastructure.network.yandexdisk
import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.dto.CustomPropertiesPatchDto
import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.dto.DiskInfoDto
import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.dto.LinkDto
import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.dto.OperationStatusDto
import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.dto.ResourceDto
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Url
interface YandexDiskApi {
@GET("v1/disk/")
suspend fun getDisk(): DiskInfoDto
@GET("v1/disk/resources")
suspend fun listResources(
@Query("path") path: String,
@Query("limit") limit: Int,
@Query("offset") offset: Int,
@Query("sort") sort: String? = null,
): ResourceDto
@GET("v1/disk/resources")
suspend fun getResource(
@Query("path") path: String,
): ResourceDto
@PUT("v1/disk/resources")
suspend fun createFolder(@Query("path") path: String): Response<ResponseBody>
@DELETE("v1/disk/resources")
suspend fun deleteResource(
@Query("path") path: String,
@Query("permanently") permanently: Boolean,
): Response<ResponseBody>
@POST("v1/disk/resources/move")
suspend fun moveResource(
@Query("from") from: String,
@Query("path") toPath: String,
@Query("overwrite") overwrite: Boolean = false,
): Response<ResponseBody>
@GET("v1/disk/resources/upload")
suspend fun getUploadLink(
@Query("path") path: String,
@Query("overwrite") overwrite: Boolean = true,
): LinkDto
@GET("v1/disk/resources/download")
suspend fun getDownloadLink(@Query("path") path: String): LinkDto
@PATCH("v1/disk/resources")
@Headers("Content-Type: application/json")
suspend fun patchResource(
@Query("path") path: String,
@Body body: CustomPropertiesPatchDto,
): Response<ResponseBody>
@GET
suspend fun getOperationByUrl(@Url url: String): OperationStatusDto
@GET("v1/disk/operations/{id}")
suspend fun getOperation(@Path("id") id: String): OperationStatusDto
}

View File

@@ -0,0 +1,66 @@
package com.github.nullptroma.wallenc.infrastructure.network.yandexdisk
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.github.nullptroma.wallenc.infrastructure.ports.YandexAccountStore
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: YandexAccountStore,
private val ioDispatcher: CoroutineDispatcher,
) {
private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() }
/** Без авторизации — только для одноразовых ссылок upload/download. */
val rawHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder().build()
}
/**
* [tokenProvider] вызывается на каждый HTTP-запрос к cloud-api (свежий токен из БД).
*/
fun createAuthenticatedApi(tokenProvider: () -> String?): YandexDiskApi {
val client = OkHttpClient.Builder()
.addInterceptor { chain ->
val token = tokenProvider()
?: throw java.io.IOException("Yandex OAuth token is missing")
val req = chain.request().newBuilder()
.header("Authorization", "OAuth $token")
.header("Accept", "application/json")
.build()
chain.proceed(req)
}
.build()
return Retrofit.Builder()
.baseUrl(BASE_URL)
.client(client)
.addConverterFactory(JacksonConverterFactory.create(jackson))
.build()
.create(YandexDiskApi::class.java)
}
/**
* Провайдер токена читает [YandexAccountRepository] на IO-диспетчере (как и остальной data-слой).
*/
fun createApiForVault(vaultUuid: UUID): YandexDiskApi {
val id = vaultUuid.toString()
return createAuthenticatedApi {
runBlocking(ioDispatcher) {
accountRepository.getByVaultUuid(id)?.oauthToken
}
}
}
companion object {
private const val BASE_URL = "https://cloud-api.yandex.net/"
}
}

View File

@@ -0,0 +1,5 @@
package com.github.nullptroma.wallenc.infrastructure.network.yandexdisk
import java.io.IOException
class YandexDiskAuthException(message: String? = null) : IOException(message)

View File

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

View File

@@ -0,0 +1,216 @@
package com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.repository
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.YandexDiskApi
import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.YandexDiskAuthException
import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.dto.CustomPropertiesPatchDto
import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.dto.DiskInfoDto
import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.dto.EmbeddedResourceListDto
import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.dto.LinkDto
import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.dto.OperationStatusDto
import com.github.nullptroma.wallenc.infrastructure.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 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) {
try {
wrapAuth { api.listResources(path, limit, offset, sort) }
} catch (e: HttpException) {
if (e.code() == 404) {
ResourceDto(embedded = EmbeddedResourceListDto(items = emptyList()))
} else {
throw e
}
}
}
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) {
when (e.code()) {
401 -> {
throw YandexDiskAuthException(e.message())
}
404 -> throw e
else -> throw e
}
}
}
private fun failure(op: String, resp: Response<ResponseBody>): IOException {
val msg = resp.errorBody()?.string() ?: resp.message()
return IOException("$op failed: HTTP ${resp.code()} $msg")
}
private fun parseLink(body: ResponseBody): LinkDto =
jackson.readValue(body.string())
companion object {
private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() }
private val OCTET_STREAM = "application/octet-stream".toMediaType()
private const val OPERATION_POLL_DELAY_MS = 300L
private const val OPERATION_POLL_MAX = 200
fun parseOperationId(href: String): String? {
val url = href.toHttpUrlOrNull() ?: return null
url.queryParameter("id")?.let { return it }
val segments = url.pathSegments
val idx = segments.indexOf("operations")
if (idx >= 0 && idx + 1 < segments.size) {
return segments[idx + 1]
}
return null
}
}
}

View File

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

View File

@@ -0,0 +1,13 @@
package com.github.nullptroma.wallenc.infrastructure.network.yandexuserinfo
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Query
interface YandexUserInfoApi {
@GET("info")
suspend fun userInfo(
@Query("format") format: String,
@Header("Authorization") authorization: String,
): YandexUserInfoDto
}

View File

@@ -0,0 +1,17 @@
package com.github.nullptroma.wallenc.infrastructure.network.yandexuserinfo
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import retrofit2.Retrofit
import retrofit2.converter.jackson.JacksonConverterFactory
object YandexUserInfoApiFactory {
fun create(): YandexUserInfoApi {
val retrofit = Retrofit.Builder()
.baseUrl("https://login.yandex.ru/")
.addConverterFactory(
JacksonConverterFactory.create(jacksonObjectMapper().findAndRegisterModules()),
)
.build()
return retrofit.create(YandexUserInfoApi::class.java)
}
}

View File

@@ -0,0 +1,11 @@
package com.github.nullptroma.wallenc.infrastructure.network.yandexuserinfo
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
@JsonIgnoreProperties(ignoreUnknown = true)
data class YandexUserInfoDto(
val id: String,
val login: String,
@param:JsonProperty("default_email") val defaultEmail: String? = null,
)

View File

@@ -0,0 +1,15 @@
package com.github.nullptroma.wallenc.infrastructure.network.yandexuserinfo.repository
import com.github.nullptroma.wallenc.infrastructure.network.yandexuserinfo.YandexUserInfoApi
import com.github.nullptroma.wallenc.infrastructure.network.yandexuserinfo.YandexUserInfoDto
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
class YandexUserInfoRepository(
private val api: YandexUserInfoApi,
private val ioDispatcher: CoroutineDispatcher
) {
suspend fun userInfo(accessToken: String): YandexUserInfoDto = withContext(ioDispatcher) {
api.userInfo(format = "json", authorization = "OAuth $accessToken")
}
}

View File

@@ -0,0 +1,9 @@
package com.github.nullptroma.wallenc.infrastructure.ports
import com.github.nullptroma.wallenc.infrastructure.model.StorageKeyMap
interface StorageKeyMapStore {
suspend fun add(value: StorageKeyMap)
suspend fun getAll(): List<StorageKeyMap>
suspend fun delete(vararg values: StorageKeyMap)
}

View File

@@ -0,0 +1,13 @@
package com.github.nullptroma.wallenc.infrastructure.ports
import com.github.nullptroma.wallenc.infrastructure.model.YandexAccount
import kotlinx.coroutines.flow.Flow
interface YandexAccountStore {
fun observeAll(): Flow<List<YandexAccount>>
suspend fun getByYandexUserId(id: String): YandexAccount?
suspend fun getByVaultUuid(vaultUuid: String): YandexAccount?
suspend fun insert(account: YandexAccount)
suspend fun updateCredentials(vaultUuid: String, email: String, token: String)
suspend fun deleteByVaultUuid(vaultUuid: String)
}

View File

@@ -0,0 +1,158 @@
package com.github.nullptroma.wallenc.infrastructure.storages
import com.github.nullptroma.wallenc.infrastructure.model.StorageKeyMap
import com.github.nullptroma.wallenc.infrastructure.ports.StorageKeyMapStore
import com.github.nullptroma.wallenc.infrastructure.storages.encrypt.EncryptedStorage
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
import com.github.nullptroma.wallenc.domain.encrypt.Encryptor
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
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.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.nio.ByteBuffer
import java.security.MessageDigest
import java.util.UUID
class UnlockManager(
private val keymapRepository: StorageKeyMapStore,
private val ioDispatcher: CoroutineDispatcher,
vaultsManager: IVaultsManager
) : IUnlockManager {
private val _openedStorages = MutableStateFlow<Map<UUID, EncryptedStorage>>(emptyMap())
override val openedStorages: StateFlow<Map<UUID, IStorage>>
get() = _openedStorages
private val mutex = Mutex()
init {
CoroutineScope(ioDispatcher).launch {
vaultsManager.allStorages.collect {
mutex.withLock {
val allKeys = keymapRepository.getAll()
val keysToRemove = mutableListOf<StorageKeyMap>()
val allStorages = it.toMutableList()
val map = _openedStorages.value.toMutableMap()
while(allStorages.isNotEmpty()) {
val storage = allStorages[allStorages.size-1]
val key = allKeys.find { key -> key.sourceUuid == storage.uuid }
if(key == null) {
allStorages.removeAt(allStorages.size - 1)
continue
}
try {
val encStorage = createEncryptedStorage(storage, key.key, getDestUuid(storage.uuid))
map[storage.uuid] = encStorage
allStorages.removeAt(allStorages.size - 1)
allStorages.add(encStorage)
}
catch (_: Exception) {
// ключ не подошёл
keysToRemove.add(key)
allStorages.removeAt(allStorages.size - 1)
}
}
keymapRepository.delete(*keysToRemove.toTypedArray()) // удалить мёртвые ключи
_openedStorages.value = map.toMap()
}
}
}
}
private suspend fun createEncryptedStorage(storage: IStorage, key: EncryptKey, uuid: UUID): EncryptedStorage {
return EncryptedStorage.create(
source = storage,
key = key,
ioDispatcher = ioDispatcher,
uuid = uuid
)
}
private fun getDestUuid(sourceUuid: UUID): UUID {
return uuid5(
namespace = UUID.fromString("6ba7b811-9dad-11d1-80b4-00c04fd430c8"), // URL namespace
name = "$sourceUuid:open"
)
}
private fun uuid5(namespace: UUID, name: String): UUID {
val digest = MessageDigest.getInstance("SHA-1")
val nsBytes = ByteBuffer.allocate(16)
.putLong(namespace.mostSignificantBits)
.putLong(namespace.leastSignificantBits)
.array()
digest.update(nsBytes)
digest.update(name.toByteArray(Charsets.UTF_8))
val hash = digest.digest()
hash[6] = (hash[6].toInt() and 0x0f or 0x50).toByte() // version 5
hash[8] = (hash[8].toInt() and 0x3f or 0x80).toByte() // RFC 4122 variant
val bb = ByteBuffer.wrap(hash, 0, 16)
return UUID(bb.long, bb.long)
}
override suspend fun open(
storage: IStorage,
key: EncryptKey,
rememberPassword: Boolean
): EncryptedStorage = withContext(ioDispatcher) {
return@withContext mutex.withLock {
val encInfo = storage.metaInfo.value.encInfo ?: throw Exception("EncInfo is null") // TODO
if (!Encryptor.checkKey(key, encInfo))
throw Exception("Incorrect Key")
val opened = _openedStorages.value.toMutableMap()
val cur = opened[storage.uuid]
if (cur != null)
return@withLock cur
val keymap = StorageKeyMap(
sourceUuid = storage.uuid,
key = key
)
val encStorage = createEncryptedStorage(storage, keymap.key, getDestUuid(storage.uuid))
opened[storage.uuid] = encStorage
_openedStorages.value = opened
if (rememberPassword) {
keymapRepository.add(keymap)
}
encStorage
}
}
/**
* Закрыть шифрование хранилища, закрывает рекурсивно, удаляя все ключи
* @param uuid uuid исходного хранилища
*/
override suspend fun close(uuid: UUID): Unit = withContext(ioDispatcher) {
mutex.withLock {
val opened = _openedStorages.value.toMutableMap()
closeBySourceUuid(opened, uuid)
_openedStorages.value = opened
}
}
// Закрытие только по source-экземпляру.
override suspend fun close(storage: IStorage) {
val opened = _openedStorages.value
if (opened.containsKey(storage.uuid)) {
close(storage.uuid)
}
}
private fun closeBySourceUuid(opened: MutableMap<UUID, EncryptedStorage>, sourceUuid: UUID) {
val enc = opened[sourceUuid] ?: return
val nestedSourceUuid = enc.uuid
if (nestedSourceUuid != sourceUuid && opened.containsKey(nestedSourceUuid)) {
closeBySourceUuid(opened, nestedSourceUuid)
}
opened.remove(sourceUuid)
enc.dispose()
}
}

View File

@@ -0,0 +1,147 @@
package com.github.nullptroma.wallenc.infrastructure.storages.common
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.github.nullptroma.wallenc.domain.common.impl.CommonStorageMetaInfo
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.util.UUID
/**
* Общий «скелет» storage'а: единая логика meta-info, rename, setEncInfo,
* clearAllContent и делегирования размера/доступности к [accessor].
*
* Подклассы определяют только как создаётся [accessor], значение
* [isVirtualStorage] и (при необходимости) расширяют [init] своими шагами
* (например, проверкой ключа или инициализацией внешней связи).
*/
abstract class BaseStorage(
override val uuid: UUID,
private val ioDispatcher: CoroutineDispatcher,
metaInfoFilePostfix: String,
) : IStorage {
protected val metaInfoFileName: String = "${metaInfoUuidPart()}$metaInfoFilePostfix"
private val _metaInfo = MutableStateFlow<IStorageMetaInfo>(CommonStorageMetaInfo())
final override val metaInfo: StateFlow<IStorageMetaInfo>
get() = _metaInfo
final override val size: StateFlow<Long?>
get() = accessor.size
final override val numberOfFiles: StateFlow<Int?>
get() = accessor.numberOfFiles
final override val isAvailable: StateFlow<Boolean>
get() = accessor.isAvailable
final override val isEmpty: Flow<Boolean?>
get() = accessor.numberOfFiles.map { n -> n?.let { it == 0 } }
abstract override val accessor: IStorageAccessor
/**
* Базовая реализация [IStorageAccessor] передаёт UUID полностью; подклассы
* могут переопределить, чтобы сохранить совместимость с уже существующими
* именами файлов (например, [com.github.nullptroma.wallenc.infrastructure.storages.encrypt.EncryptedStorage]
* раньше использовал первые 8 символов).
*/
protected open fun metaInfoUuidPart(): String = uuid.toString()
/**
* Запускается единожды при старте storage'а. Подклассы могут переопределить,
* добавив свои шаги (init accessor'а, проверка ключа и т.п.). Обязательно
* должен в какой-то момент вызвать [readMetaInfo].
*/
open suspend fun init() {
readMetaInfo()
}
private suspend fun readMetaInfo() = withContext(ioDispatcher) {
var meta: CommonStorageMetaInfo
var reader: InputStream? = null
try {
reader = accessor.openReadSystemFile(metaInfoFileName)
meta = jackson.readValue(reader, CommonStorageMetaInfo::class.java)
} catch (_: Exception) {
// чтение не удалось — пишем дефолт, чтобы файл появился
meta = CommonStorageMetaInfo()
updateMetaInfo(meta)
} finally {
reader?.close()
}
_metaInfo.value = meta
}
private suspend fun updateMetaInfo(meta: IStorageMetaInfo) = withContext(ioDispatcher) {
val writer = accessor.openWriteSystemFile(metaInfoFileName)
writer.use { writer ->
jackson.writeValue(writer, meta)
}
_metaInfo.value = meta
}
final override suspend fun rename(newName: String) = withContext(ioDispatcher) {
val cur = metaInfo.value
updateMetaInfo(
CommonStorageMetaInfo(
encInfo = cur.encInfo,
name = newName,
),
)
}
final override suspend fun setEncInfo(encInfo: StorageEncryptionInfo?) = withContext(ioDispatcher) {
val cur = metaInfo.value
updateMetaInfo(
CommonStorageMetaInfo(
encInfo = encInfo,
name = cur.name,
),
)
}
final override suspend fun clearAllContent(onProgress: suspend (TaskProgress) -> Unit) = withContext(ioDispatcher) {
val files = accessor.getAllFiles()
val dirs = accessor.getAllDirs()
val paths = buildList {
addAll(files.map { it.metaInfo.path })
addAll(dirs.map { it.metaInfo.path })
}
.filter { it != "/" && it.isNotBlank() }
.sortedByDescending { it.length }
val total = paths.size
if (total == 0) {
onProgress(TaskProgress(1f, null))
return@withContext
}
paths.forEachIndexed { index, path ->
accessor.delete(path)
if (index % PROGRESS_REPORT_INTERVAL == 0 || index == paths.lastIndex) {
onProgress(
TaskProgress(
fraction = (index + 1).toFloat() / total,
label = null,
),
)
coroutineContext.ensureActive()
}
}
}
companion object {
private const val PROGRESS_REPORT_INTERVAL = 16
private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() }
}
}

View File

@@ -0,0 +1,86 @@
package com.github.nullptroma.wallenc.infrastructure.storages.encrypt
import com.github.nullptroma.wallenc.infrastructure.storages.common.BaseStorage
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
import com.github.nullptroma.wallenc.domain.encrypt.Encryptor
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DisposableHandle
import kotlinx.coroutines.Job
import kotlinx.coroutines.withContext
import java.util.UUID
class EncryptedStorage private constructor(
private val source: IStorage,
private val key: EncryptKey,
ioDispatcher: CoroutineDispatcher,
uuid: UUID = UUID.randomUUID()
) : BaseStorage(
uuid = uuid,
ioDispatcher = ioDispatcher,
metaInfoFilePostfix = STORAGE_INFO_FILE_POSTFIX,
), DisposableHandle {
private val job = Job()
private val scope = CoroutineScope(ioDispatcher + job)
private val encInfo =
source.metaInfo.value.encInfo ?: throw Exception("Storage is not encrypted") // TODO
override val isVirtualStorage: Boolean = true
private val _accessor: EncryptedStorageAccessor =
EncryptedStorageAccessor(
source = source.accessor,
pathIv = encInfo.pathIv,
key = key,
systemHiddenDirName = "${uuid.toString().take(8)}$SYSTEM_HIDDEN_DIRNAME_POSTFIX",
scope = scope,
)
override val accessor: IStorageAccessor = _accessor
override fun metaInfoUuidPart(): String = uuid.toString().take(8)
override suspend fun init() {
checkKey()
super.init()
}
private fun checkKey() {
if (!Encryptor.checkKey(key, encInfo))
throw Exception("Incorrect key") // TODO
}
override fun dispose() {
_accessor.dispose()
job.cancel()
}
companion object {
suspend fun create(
source: IStorage,
key: EncryptKey,
ioDispatcher: CoroutineDispatcher,
uuid: UUID = UUID.randomUUID()
): EncryptedStorage = withContext(ioDispatcher) {
val storage = EncryptedStorage(
source = source,
key = key,
ioDispatcher = ioDispatcher,
uuid = uuid
)
try {
storage.init()
} catch (e: Exception) {
storage.dispose()
throw e
}
return@withContext storage
}
private const val SYSTEM_HIDDEN_DIRNAME_POSTFIX = "-enc-dir"
const val STORAGE_INFO_FILE_POSTFIX = ".enc-meta"
}
}

View File

@@ -0,0 +1,299 @@
package com.github.nullptroma.wallenc.infrastructure.storages.encrypt
import com.github.nullptroma.wallenc.infrastructure.utils.CloseHandledStreamExtension.Companion.onClosing
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.datatypes.EncryptKey
import com.github.nullptroma.wallenc.domain.encrypt.Encryptor
import com.github.nullptroma.wallenc.domain.encrypt.EncryptorWithStaticIv
import com.github.nullptroma.wallenc.domain.interfaces.IDirectory
import com.github.nullptroma.wallenc.domain.interfaces.IFile
import com.github.nullptroma.wallenc.domain.interfaces.IMetaInfo
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DisposableHandle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.io.InputStream
import java.io.OutputStream
import kotlin.io.path.Path
import kotlin.io.path.pathString
class EncryptedStorageAccessor(
private val source: IStorageAccessor,
pathIv: ByteArray?,
key: EncryptKey,
private val systemHiddenDirName: String,
private val scope: CoroutineScope
) : IStorageAccessor, DisposableHandle {
private val _size = MutableStateFlow<Long?>(null)
override val size: StateFlow<Long?> = _size
private val _numberOfFiles = MutableStateFlow<Int?>(null)
override val numberOfFiles: StateFlow<Int?> = _numberOfFiles
override val isAvailable: StateFlow<Boolean> = source.isAvailable
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
private val dataEncryptor = Encryptor(key.toAesKey())
private val pathEncryptor: EncryptorWithStaticIv? = if(pathIv != null) EncryptorWithStaticIv(key.toAesKey(), pathIv) else null
private var systemHiddenFilesIsActual = false
init {
collectSourceState()
}
private fun collectSourceState() {
scope.launch {
launch {
source.filesUpdates.collect { page ->
val files = page.data.map(::decryptEntity).filterSystemHiddenFiles()
_filesUpdates.emit(
DataPage(
list = files,
isLoading = page.isLoading,
isError = page.isError,
hasNext = page.hasNext,
pageLength = page.pageLength,
pageIndex = page.pageIndex,
)
)
}
}
launch {
source.dirsUpdates.collect { page ->
val dirs = page.data.map(::decryptEntity).filterSystemHiddenDirs()
_dirsUpdates.emit(
DataPage(
list = dirs,
isLoading = page.isLoading,
isError = page.isError,
hasNext = page.hasNext,
pageLength = page.pageLength,
pageIndex = page.pageIndex,
)
)
}
}
launch {
source.numberOfFiles.collect {
if(it == null)
_numberOfFiles.value = null
else
{
_numberOfFiles.value = it - getSystemFiles().size
}
}
}
launch {
source.size.collect { sourceSize ->
if(sourceSize == null)
_size.value = null
else
{
_size.value = sourceSize - getSystemFiles().sumOf { it.metaInfo.size }
}
}
}
}
}
private suspend fun getSystemFiles(): List<IFile> {
return source.getFiles(encryptPath(systemHiddenDirName))
}
private fun encryptEntity(file: IFile): IFile {
return CommonFile(encryptMeta(file.metaInfo))
}
private fun decryptEntity(file: IFile): IFile {
return CommonFile(decryptMeta(file.metaInfo))
}
private fun encryptEntity(dir: IDirectory): IDirectory {
return CommonDirectory(encryptMeta(dir.metaInfo), dir.elementsCount)
}
private fun decryptEntity(dir: IDirectory): IDirectory {
return CommonDirectory(decryptMeta(dir.metaInfo), dir.elementsCount)
}
private fun encryptMeta(meta: IMetaInfo): CommonMetaInfo {
return CommonMetaInfo(
size = meta.size,
isDeleted = meta.isDeleted,
isHidden = meta.isHidden,
lastModified = meta.lastModified,
path = encryptPath(meta.path)
)
}
private fun decryptMeta(meta: IMetaInfo): CommonMetaInfo {
return CommonMetaInfo(
size = meta.size,
isDeleted = meta.isDeleted,
isHidden = meta.isHidden,
lastModified = meta.lastModified,
path = decryptPath(meta.path)
)
}
private fun encryptPath(pathStr: String): String {
if(pathEncryptor == null)
return pathStr
val path = Path(pathStr)
val segments = mutableListOf<String>()
for (segment in path)
segments.add(pathEncryptor.encryptString(segment.pathString))
val res = Path("/", *(segments.toTypedArray()))
return res.pathString
}
private fun decryptPath(pathStr: String): String {
if(pathEncryptor == null)
return pathStr
val path = Path(pathStr)
val segments = mutableListOf<String>()
for (segment in path)
segments.add(pathEncryptor.decryptString(segment.pathString))
val res = Path("/", *(segments.toTypedArray()))
return res.pathString
}
override suspend fun getAllFiles(): List<IFile> {
return source.getAllFiles().map(::decryptEntity).filterSystemHiddenFiles()
}
override suspend fun getFiles(path: String): List<IFile> {
return source.getFiles(encryptPath(path)).map(::decryptEntity).filterSystemHiddenFiles()
}
override fun getFilesFlow(path: String): Flow<DataPage<IFile>> {
val flow = source.getFilesFlow(encryptPath(path)).map { page ->
DataPage(
list = page.data.map(::decryptEntity).filterSystemHiddenFiles(),
isLoading = page.isLoading,
isError = page.isError,
hasNext = page.hasNext,
pageLength = page.pageLength,
pageIndex = page.pageIndex,
)
}
return flow
}
override suspend fun getAllDirs(): List<IDirectory> {
return source.getAllDirs().map(::decryptEntity).filterSystemHiddenDirs()
}
override suspend fun getDirs(path: String): List<IDirectory> {
return source.getDirs(encryptPath(path)).map(::decryptEntity).filterSystemHiddenDirs()
}
override fun getDirsFlow(path: String): Flow<DataPage<IDirectory>> {
val flow = source.getDirsFlow(encryptPath(path)).map { page ->
DataPage(
// включать все папки, кроме системной
list = page.data.map(::decryptEntity).filterSystemHiddenDirs(),
isLoading = page.isLoading,
isError = page.isError,
hasNext = page.hasNext,
pageLength = page.pageLength,
pageIndex = page.pageIndex,
)
}
return flow
}
override suspend fun getFileInfo(path: String): IFile {
val file = source.getFileInfo(encryptPath(path))
val meta = decryptMeta(file.metaInfo)
return CommonFile(meta)
}
override suspend fun getDirInfo(path: String): IDirectory {
val dir = source.getDirInfo(encryptPath(path))
val meta = decryptMeta(dir.metaInfo)
return CommonDirectory(meta, dir.elementsCount)
}
override suspend fun setHidden(path: String, hidden: Boolean) {
source.setHidden(encryptPath(path), hidden)
}
override suspend fun touchFile(path: String) {
source.touchFile(encryptPath(path))
}
override suspend fun touchDir(path: String) {
source.touchDir(encryptPath(path))
}
override suspend fun delete(path: String) {
source.delete(encryptPath(path))
}
override suspend fun openWrite(path: String): OutputStream {
val stream = source.openWrite(encryptPath(path))
return dataEncryptor.encryptStream(stream)
}
override suspend fun openRead(path: String): InputStream {
val stream = source.openRead(encryptPath(path))
return dataEncryptor.decryptStream(stream)
}
override suspend fun moveToTrash(path: String) {
source.moveToTrash(encryptPath(path))
}
override fun dispose() {
dataEncryptor.dispose()
}
override suspend fun openReadSystemFile(name: String): InputStream = scope.run {
val path = Path(systemHiddenDirName, name).pathString
return@run openRead(path)
}
override suspend fun openWriteSystemFile(name: String): OutputStream = scope.run {
val path = Path(systemHiddenDirName, name).pathString
systemHiddenFilesIsActual = false
return@run openWrite(path).onClosing {
systemHiddenFilesIsActual = false
}
}
private fun Iterable<IFile>.filterSystemHiddenFiles(): List<IFile> {
return this.filter { file ->
!file.metaInfo.path.contains(
systemHiddenDirName
)
}
}
private fun Iterable<IDirectory>.filterSystemHiddenDirs(): List<IDirectory> {
return this.filter { dir ->
!dir.metaInfo.path.contains(
systemHiddenDirName
)
}
}
}

View File

@@ -0,0 +1,29 @@
package com.github.nullptroma.wallenc.infrastructure.storages.local
import com.github.nullptroma.wallenc.infrastructure.storages.common.BaseStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
import kotlinx.coroutines.CoroutineDispatcher
import java.util.UUID
class LocalStorage(
uuid: UUID,
val absolutePath: String,
ioDispatcher: CoroutineDispatcher,
) : BaseStorage(
uuid = uuid,
ioDispatcher = ioDispatcher,
metaInfoFilePostfix = STORAGE_INFO_FILE_POSTFIX,
) {
private val _accessor = LocalStorageAccessor(absolutePath, ioDispatcher)
override val accessor: IStorageAccessor = _accessor
override val isVirtualStorage: Boolean = false
override suspend fun init() {
_accessor.init()
super.init()
}
companion object {
const val STORAGE_INFO_FILE_POSTFIX = ".storage-info"
}
}

View File

@@ -0,0 +1,564 @@
package com.github.nullptroma.wallenc.infrastructure.storages.local
import com.fasterxml.jackson.core.JacksonException
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.github.nullptroma.wallenc.infrastructure.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.IMetaInfo
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.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import java.nio.file.Files
import java.nio.file.Path
import java.time.Clock
import kotlin.io.path.Path
import kotlin.io.path.absolute
import kotlin.io.path.fileSize
import kotlin.io.path.pathString
import kotlin.io.path.relativeTo
class LocalStorageAccessor(
filesystemBasePath: String,
private val ioDispatcher: CoroutineDispatcher
) : IStorageAccessor {
private val _filesystemBasePath: Path = Path(filesystemBasePath).normalize().absolute()
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 _isAvailable = MutableStateFlow(false)
override val isAvailable: StateFlow<Boolean> = _isAvailable
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) {
// запускам сканирование хранилища
scanSizeAndNumOfFiles()
}
/**
* Проверяет существование корневого пути Storage в файловой системе, изменяет _isAvailable
*/
private fun checkAvailable(): Boolean {
_isAvailable.value = _filesystemBasePath.toFile().exists()
return _isAvailable.value
}
/**
* Перебирает все файлы в файловой системе
* @param dir стартовый каталог
* @param maxDepth максимальная глубина (отрицательное для бесконечной)
* @param callback метод обратного вызова для каждого файла и директории
*/
private suspend fun scanFileSystem(
dir: File,
maxDepth: Int,
callback: suspend (File) -> Unit,
useCallbackForSelf: Boolean = true
) {
if (!dir.exists())
return
val children = dir.listFiles()
if (children != null) {
// вызвать коллбек для каждого элемента директории
for (child in children) {
if(child.name != SYSTEM_HIDDEN_DIRNAME)
callback(child)
}
if (useCallbackForSelf)
callback(dir)
if (maxDepth != 0) {
val nextMaxDepth = if (maxDepth > 0) maxDepth - 1 else maxDepth
for (child in children) {
if (child.isDirectory && child.name != SYSTEM_HIDDEN_DIRNAME) {
scanFileSystem(child, nextMaxDepth, callback, false)
}
}
}
} else if (useCallbackForSelf) {
callback(dir)
}
}
private class LocalStorageFilePair private constructor(
val file: File,
val metaFile: File,
val meta: CommonMetaInfo
) {
companion object {
private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() }
fun fromFile(filesystemBasePath: Path, file: File): LocalStorageFilePair? {
if (!file.exists())
return null
if (file.name.endsWith(META_INFO_POSTFIX))
return fromMetaFile(filesystemBasePath, file)
val filePath = file.toPath()
val metaFilePath = Path(
if (file.isFile) {
file.absolutePath + META_INFO_POSTFIX
} else {
Path(file.absolutePath, META_INFO_POSTFIX).pathString
}
)
val metaFile = metaFilePath.toFile()
val metaInfo: CommonMetaInfo
val storageFilePath = "/" + filePath.relativeTo(filesystemBasePath)
if (!metaFile.exists()) {
metaInfo = CommonMetaInfo(
size = filePath.fileSize(),
path = storageFilePath
)
jackson.writeValue(metaFile, metaInfo)
} else {
var readMeta: CommonMetaInfo
try {
val reader = metaFile.bufferedReader()
readMeta = jackson.readValue(reader)
} catch (e: JacksonException) {
// если файл повреждён - пересоздать
readMeta = CommonMetaInfo(
size = filePath.fileSize(),
path = storageFilePath
)
jackson.writeValue(metaFile, readMeta)
}
metaInfo = readMeta
}
return LocalStorageFilePair(
file = file,
metaFile = metaFile,
meta = metaInfo
)
}
fun fromMetaFile(filesystemBasePath: Path, metaFile: File): LocalStorageFilePair? {
if (!metaFile.exists())
return null
if (!metaFile.name.endsWith(META_INFO_POSTFIX))
return fromFile(filesystemBasePath, metaFile)
var pair: LocalStorageFilePair? = null
try {
val reader = metaFile.bufferedReader()
val metaInfo: CommonMetaInfo = jackson.readValue(reader)
val pathString = Path(filesystemBasePath.pathString, metaInfo.path).pathString
val file = File(pathString)
if (!file.exists()) {
metaFile.delete()
} else {
pair = LocalStorageFilePair(
file = file,
metaFile = metaFile,
meta = metaInfo
)
}
} catch (e: JacksonException) {
metaFile.delete()
}
return pair
}
fun from(filesystemBasePath: Path, anyFile: File): LocalStorageFilePair? {
return if (anyFile.name.endsWith(META_INFO_POSTFIX))
fromMetaFile(filesystemBasePath, anyFile)
else
fromFile(filesystemBasePath, anyFile)
}
fun from(filesystemBasePath: Path, storagePath: String): LocalStorageFilePair? {
val filePath = Path(filesystemBasePath.pathString, storagePath)
return from(filesystemBasePath, filePath.toFile())
}
fun from(filesystemBasePath: Path, meta: IMetaInfo): LocalStorageFilePair? {
return from(filesystemBasePath, meta.path)
}
}
}
/**
* Перебирает все файлы и каталоги в relativePath и возвращает с мета-информацией
*
*/
private suspend fun scanStorage(
baseStoragePath: String,
maxDepth: Int,
fileCallback: (suspend (File, CommonFile) -> Unit)? = null,
dirCallback: (suspend (File, CommonDirectory) -> Unit)? = null
) {
if (!checkAvailable())
throw Exception("Not available")
val basePath = Path(_filesystemBasePath.pathString, baseStoragePath)
val workedFiles = mutableSetOf<String>()
val workedMetaFiles = mutableSetOf<String>()
scanFileSystem(basePath.toFile(), maxDepth, { file ->
// Если парный файл уже был обработан - скип. Это позволит не читать metaFile дважды
if (workedFiles.contains(file.absolutePath) || workedMetaFiles.contains(file.absolutePath)) {
return@scanFileSystem
}
val pair = LocalStorageFilePair.from(_filesystemBasePath, file)
if (pair != null) {
workedFiles.add(pair.file.absolutePath)
workedMetaFiles.add(pair.metaFile.absolutePath)
if (pair.file.isFile) {
fileCallback?.invoke(pair.file, CommonFile(pair.meta))
} else {
dirCallback?.invoke(pair.file, CommonDirectory(pair.meta, null))
}
}
})
}
/**
* Считает файлы и их размер. Не бросает исключения, если файлы недоступны
* @throws none Если возникла ошибка, оставляет размер и количества файлов равными null
*/
private suspend fun scanSizeAndNumOfFiles() = withContext(ioDispatcher) {
if (!checkAvailable()) {
_size.value = null
_numberOfFiles.value = null
return@withContext
}
var size = 0L
var numOfFiles = 0
scanStorage(baseStoragePath = "/", maxDepth = -1, fileCallback = { _, commonFile ->
size += commonFile.metaInfo.size
numOfFiles++
if (numOfFiles % DATA_PAGE_LENGTH == 0) {
_size.value = size
_numberOfFiles.value = numOfFiles
}
})
_size.value = size
_numberOfFiles.value = numOfFiles
}
override suspend fun getAllFiles(): List<IFile> = withContext(ioDispatcher) {
if (!checkAvailable())
return@withContext listOf()
val list = mutableListOf<IFile>()
scanStorage(baseStoragePath = "/", maxDepth = -1, fileCallback = { _, commonFile ->
list.add(commonFile)
})
return@withContext list
}
override suspend fun getFiles(path: String): List<IFile> = withContext(ioDispatcher) {
if (!checkAvailable())
return@withContext listOf()
val list = mutableListOf<IFile>()
scanStorage(baseStoragePath = path, maxDepth = 0, fileCallback = { _, commonFile ->
list.add(commonFile)
})
return@withContext list
}
override fun getFilesFlow(path: String): Flow<DataPage<IFile>> = flow {
if (!checkAvailable())
return@flow
val buf = mutableListOf<IFile>()
var pageNumber = 0
scanStorage(baseStoragePath = path, maxDepth = 0, fileCallback = { _, commonFile ->
if (buf.size == DATA_PAGE_LENGTH) {
val page = DataPage(
list = buf.toList(),
isLoading = false,
isError = false,
hasNext = true,
pageLength = DATA_PAGE_LENGTH,
pageIndex = pageNumber++
)
emit(page)
buf.clear()
}
buf.add(commonFile)
})
// отправка последней страницы
val page = DataPage(
list = buf.toList(),
isLoading = false,
isError = false,
hasNext = false,
pageLength = DATA_PAGE_LENGTH,
pageIndex = pageNumber++
)
emit(page)
}.flowOn(ioDispatcher)
override suspend fun getAllDirs(): List<IDirectory> = withContext(ioDispatcher) {
if (!checkAvailable())
return@withContext listOf()
val list = mutableListOf<IDirectory>()
scanStorage(baseStoragePath = "/", maxDepth = -1, dirCallback = { _, localDir ->
list.add(localDir)
})
return@withContext list
}
override suspend fun getDirs(path: String): List<IDirectory> = withContext(ioDispatcher) {
if (!checkAvailable())
return@withContext listOf()
val list = mutableListOf<IDirectory>()
scanStorage(baseStoragePath = path, maxDepth = 0, dirCallback = { _, localDir ->
list.add(localDir)
})
return@withContext list
}
override fun getDirsFlow(path: String): Flow<DataPage<IDirectory>> = flow {
if (!checkAvailable())
return@flow
val buf = mutableListOf<IDirectory>()
var pageNumber = 0
scanStorage(baseStoragePath = path, maxDepth = 0, dirCallback = { _, localDir ->
if (buf.size == DATA_PAGE_LENGTH) {
val page = DataPage(
list = buf.toList(),
isLoading = false,
isError = false,
hasNext = true,
pageLength = DATA_PAGE_LENGTH,
pageIndex = pageNumber++
)
emit(page)
buf.clear()
}
buf.add(localDir)
})
// отправка последней страницы
val page = DataPage(
list = buf.toList(),
isLoading = false,
isError = false,
hasNext = false,
pageLength = DATA_PAGE_LENGTH,
pageIndex = pageNumber++
)
emit(page)
}.flowOn(ioDispatcher)
override suspend fun getFileInfo(path: String): IFile {
val pair = LocalStorageFilePair.from(_filesystemBasePath, path)
?: throw Exception("Что то пошло не так") // TODO
return CommonFile(
metaInfo = pair.meta,
)
}
override suspend fun getDirInfo(path: String): IDirectory {
val pair = LocalStorageFilePair.from(_filesystemBasePath, path)
?: throw Exception("Что то пошло не так") // TODO
return CommonDirectory(
metaInfo = pair.meta,
elementsCount = null
)
}
override suspend fun setHidden(path: String, hidden: Boolean) {
val pair = LocalStorageFilePair.from(_filesystemBasePath, path)
?: throw Exception("Что то пошло не так") // TODO
if (pair.meta.isHidden == hidden)
return
val newMeta = pair.meta.copy(isHidden = hidden)
writeMeta(pair.metaFile, newMeta)
_filesUpdates.emit(
DataPage(
list = listOf(
CommonFile(
metaInfo = newMeta
)
),
pageLength = 1,
pageIndex = 0
)
)
}
private fun writeMeta(metaFile: File, meta: IMetaInfo) {
jackson.writeValue(metaFile, meta)
}
private suspend fun createFile(storagePath: String): CommonFile = withContext(ioDispatcher) {
val path = Path(_filesystemBasePath.pathString, storagePath)
val file = path.toFile()
if (file.exists() && file.isDirectory) {
throw Exception("Что то пошло не так") // TODO
} else if(!file.exists()) {
val parent = Path(storagePath).parent
createDir(parent.pathString)
file.createNewFile()
val cur = _numberOfFiles.value
_numberOfFiles.value = if (cur == null) null else cur + 1
}
val pair = LocalStorageFilePair.from(_filesystemBasePath, file)
?: throw Exception("Что то пошло не так") // TODO
val newMeta = pair.meta.copy(lastModified = Clock.systemUTC().instant(), size = Files.size(pair.file.toPath()))
writeMeta(pair.metaFile, newMeta)
_filesUpdates.emit(
DataPage(
list = listOf(CommonFile(pair.meta)),
pageLength = 1,
pageIndex = 0
)
)
return@withContext CommonFile(newMeta)
}
private suspend fun createDir(storagePath: String): CommonDirectory = withContext(ioDispatcher) {
val path = Path(_filesystemBasePath.pathString, storagePath)
val file = path.toFile()
if (file.exists() && !file.isDirectory) {
throw Exception("Что то пошло не так") // TODO
} else if(!file.exists()) {
Files.createDirectories(path)
}
val pair = LocalStorageFilePair.from(_filesystemBasePath, file)
?: throw Exception("Что то пошло не так") // TODO
val newMeta = pair.meta.copy(lastModified = Clock.systemUTC().instant())
writeMeta(pair.metaFile, newMeta)
_dirsUpdates.emit(
DataPage(
list = listOf(CommonDirectory(pair.meta, null)),
pageLength = 1,
pageIndex = 0
)
)
return@withContext CommonDirectory(newMeta, 0)
}
override suspend fun touchFile(path: String): Unit = withContext(ioDispatcher) {
createFile(path)
// перебор все каталогов и обновление их времени модификации
var parent = Path(path).parent
while(parent != null) {
touchDir(parent.pathString)
parent = parent.parent
}
}
override suspend fun touchDir(path: String): Unit = withContext(ioDispatcher) {
createDir(path)
}
override suspend fun delete(path: String) = withContext(ioDispatcher) {
if (path == "/" || path.isBlank()) {
throw IllegalArgumentException("Deleting root path is forbidden")
}
val pair = LocalStorageFilePair.from(_filesystemBasePath, path)
if (pair != null) {
if (pair.file.isDirectory) pair.file.deleteRecursively()
else pair.file.delete()
pair.metaFile.delete()
scanSizeAndNumOfFiles()
}
}
override suspend fun openWrite(path: String): OutputStream = withContext(ioDispatcher) {
touchFile(path)
val pair = LocalStorageFilePair.from(_filesystemBasePath, path)
?: throw Exception("Файла нет") // TODO
return@withContext pair.file.outputStream().onClosed {
CoroutineScope(ioDispatcher).launch {
touchFile(path)
scanSizeAndNumOfFiles()
}
}
}
override suspend fun openRead(path: String): InputStream = withContext(ioDispatcher) {
val pair = LocalStorageFilePair.from(_filesystemBasePath, path)
?: throw Exception("Файла нет") // TODO
return@withContext pair.file.inputStream()
}
override suspend fun moveToTrash(path: String) = withContext(ioDispatcher) {
val pair = LocalStorageFilePair.from(_filesystemBasePath, path)
?: throw Exception("Файла нет") // TODO
val newMeta = pair.meta.copy(isDeleted = true)
writeMeta(pair.metaFile, newMeta)
}
override suspend fun openReadSystemFile(name: String): InputStream = withContext(ioDispatcher) {
val dirPath = _filesystemBasePath.resolve(SYSTEM_HIDDEN_DIRNAME)
val path = dirPath.resolve(name)
val file = path.toFile()
if(!file.exists()) {
Files.createDirectories(dirPath)
file.createNewFile()
}
return@withContext file.inputStream()
}
override suspend fun openWriteSystemFile(name: String): OutputStream = withContext(ioDispatcher) {
val dirPath = _filesystemBasePath.resolve(SYSTEM_HIDDEN_DIRNAME)
val path = dirPath.resolve(name)
val file = path.toFile()
if(!file.exists()) {
Files.createDirectories(dirPath)
file.createNewFile()
}
return@withContext file.outputStream()
}
companion object {
// Файлы, которые можно использовать для чтения и записи, но не отображаются в хранилище
private const val SYSTEM_HIDDEN_DIRNAME = "wallenc-local-storage-meta-dir"
private const val META_INFO_POSTFIX = ".wallenc-meta"
private const val DATA_PAGE_LENGTH = 10
private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() }
}
}

View File

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

View File

@@ -0,0 +1,441 @@
package com.github.nullptroma.wallenc.infrastructure.storages.yandex
import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.YandexDiskAuthException
import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.dto.ResourceDto
import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.repository.YandexDiskRepository
import com.github.nullptroma.wallenc.infrastructure.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 (e: Exception) {
_storageReady.value = false
throw Exception("Yandex storage init failed", e)
}
}
private inline fun <T> guard(block: () -> T): T {
try {
return block()
} catch (e: YandexDiskAuthException) {
reportAuthFailure()
throw e
}
}
private fun toDiskPath(rel: String): String {
val tail = when {
rel.isBlank() || rel == "/" -> ""
else -> rel.trimStart('/')
}
return if (tail.isEmpty()) diskRoot else "$diskRoot/$tail"
}
private fun toRelPath(diskPath: String): String {
val u = storageUuid.toString()
val app = "app:/$u/"
if (diskPath.startsWith(app)) {
val tail = diskPath.removePrefix(app).removeSuffix("/")
return if (tail.isEmpty()) "/" else "/$tail"
}
val needle = "/$u/"
val i = diskPath.indexOf(needle)
if (i >= 0) {
val tail = diskPath.substring(i + needle.length).removeSuffix("/")
return if (tail.isEmpty()) "/" else "/$tail"
}
return "/"
}
private fun isSystemDiskPath(diskPath: String?): Boolean {
if (diskPath == null) return false
return diskPath.contains("/$SYSTEM_HIDDEN_DIRNAME/") ||
diskPath.endsWith("/$SYSTEM_HIDDEN_DIRNAME")
}
private fun isSystemRel(rel: String): Boolean =
rel == "/$SYSTEM_HIDDEN_DIRNAME" || rel.startsWith("/$SYSTEM_HIDDEN_DIRNAME/")
private suspend fun ensureSystemDirExists() {
guard { repo.createFolder(toDiskPath("/$SYSTEM_HIDDEN_DIRNAME")) }
}
private suspend fun scanSizeAndNumOfFiles() {
var totalSize = 0L
var count = 0
val queue = ArrayDeque<String>()
queue.add("/")
while (queue.isNotEmpty()) {
val rel = queue.removeFirst()
if (isSystemRel(rel)) continue
val (files, dirs) = listImmediateChildren(rel)
for (d in dirs) {
if (!isSystemRel(d.metaInfo.path)) queue.add(d.metaInfo.path)
}
for (f in files) {
if (!isSystemRel(f.metaInfo.path)) {
totalSize += f.metaInfo.size
count++
}
}
}
_size.value = totalSize
_numberOfFiles.value = count
}
private suspend fun listImmediateChildren(relDir: String): Pair<List<IFile>, List<IDirectory>> {
val diskPath = toDiskPath(relDir)
val files = mutableListOf<IFile>()
val dirs = mutableListOf<IDirectory>()
var offset = 0
while (true) {
val res = guard { repo.list(diskPath, API_LIST_LIMIT, offset) }
val items = res.embedded?.items.orEmpty()
for (it in items) {
if (isSystemDiskPath(it.path)) continue
val rel = toRelPath(it.path ?: continue)
if (isSystemRel(rel)) continue
when (it.type) {
"file" -> files.add(it.toCommonFile(rel))
"dir" -> dirs.add(it.toCommonDir(rel))
}
}
if (items.size < API_LIST_LIMIT) break
offset += items.size
}
return files to dirs
}
private fun ResourceDto.toCommonFile(rel: String): CommonFile =
CommonFile(
CommonMetaInfo(
size = size ?: 0L,
isDeleted = boolProp(customProperties, PROP_DELETED),
isHidden = boolProp(customProperties, PROP_HIDDEN),
lastModified = modified ?: created ?: Instant.EPOCH,
path = rel,
),
)
private fun ResourceDto.toCommonDir(rel: String): CommonDirectory =
CommonDirectory(
CommonMetaInfo(
size = 0L,
isDeleted = boolProp(customProperties, PROP_DELETED),
isHidden = boolProp(customProperties, PROP_HIDDEN),
lastModified = modified ?: created ?: Instant.EPOCH,
path = rel,
),
elementsCount = null,
)
private fun boolProp(props: Map<String, Any?>?, key: String): Boolean {
val v = props?.get(key) ?: return false
return when (v) {
is Boolean -> v
is String -> v.equals("true", ignoreCase = true)
else -> v.toString().equals("true", ignoreCase = true)
}
}
override suspend fun getAllFiles(): List<IFile> = withContext(ioDispatcher) {
val out = mutableListOf<IFile>()
val queue = ArrayDeque<String>()
queue.add("/")
while (queue.isNotEmpty()) {
val rel = queue.removeFirst()
if (isSystemRel(rel)) continue
val (files, dirs) = listImmediateChildren(rel)
out.addAll(files.filter { !isSystemRel(it.metaInfo.path) })
for (d in dirs) {
if (!isSystemRel(d.metaInfo.path)) queue.add(d.metaInfo.path)
}
}
out
}
override suspend fun getFiles(path: String): List<IFile> = withContext(ioDispatcher) {
listImmediateChildren(path).first
}
override fun getFilesFlow(path: String): Flow<DataPage<IFile>> = flow {
val all = withContext(ioDispatcher) { listImmediateChildren(path).first }
var pageIndex = 0
var i = 0
while (i < all.size) {
val chunk = all.subList(i, kotlin.math.min(i + DATA_PAGE_LENGTH, all.size)).toList()
emit(
DataPage(
list = chunk,
isLoading = false,
isError = false,
hasNext = i + DATA_PAGE_LENGTH < all.size,
pageLength = DATA_PAGE_LENGTH,
pageIndex = pageIndex++,
),
)
i += DATA_PAGE_LENGTH
}
if (all.isEmpty()) {
emit(
DataPage(
list = emptyList(),
isLoading = false,
isError = false,
hasNext = false,
pageLength = DATA_PAGE_LENGTH,
pageIndex = 0,
),
)
}
}.flowOn(ioDispatcher)
override suspend fun getAllDirs(): List<IDirectory> = withContext(ioDispatcher) {
val out = mutableListOf<IDirectory>()
val queue = ArrayDeque<String>()
queue.add("/")
while (queue.isNotEmpty()) {
val rel = queue.removeFirst()
if (isSystemRel(rel)) continue
val (files, dirs) = listImmediateChildren(rel)
for (d in dirs) {
if (!isSystemRel(d.metaInfo.path)) {
out.add(d)
queue.add(d.metaInfo.path)
}
}
}
out
}
override suspend fun getDirs(path: String): List<IDirectory> = withContext(ioDispatcher) {
listImmediateChildren(path).second
}
override fun getDirsFlow(path: String): Flow<DataPage<IDirectory>> = flow {
val all = withContext(ioDispatcher) { listImmediateChildren(path).second }
var pageIndex = 0
var i = 0
while (i < all.size) {
val chunk = all.subList(i, kotlin.math.min(i + DATA_PAGE_LENGTH, all.size)).toList()
emit(
DataPage(
list = chunk,
isLoading = false,
isError = false,
hasNext = i + DATA_PAGE_LENGTH < all.size,
pageLength = DATA_PAGE_LENGTH,
pageIndex = pageIndex++,
),
)
i += DATA_PAGE_LENGTH
}
if (all.isEmpty()) {
emit(
DataPage(
list = emptyList(),
isLoading = false,
isError = false,
hasNext = false,
pageLength = DATA_PAGE_LENGTH,
pageIndex = 0,
),
)
}
}.flowOn(ioDispatcher)
override suspend fun getFileInfo(path: String): IFile = withContext(ioDispatcher) {
val r = guard { repo.get(toDiskPath(path)) }
if (r.type != "file") throw IllegalStateException("Not a file")
r.toCommonFile(path)
}
override suspend fun getDirInfo(path: String): IDirectory = withContext(ioDispatcher) {
val r = guard { repo.get(toDiskPath(path)) }
if (r.type != "dir") throw IllegalStateException("Not a directory")
r.toCommonDir(path)
}
override suspend fun setHidden(path: String, hidden: Boolean) = withContext(ioDispatcher) {
patchCustom(path) { it[PROP_HIDDEN] = if (hidden) "true" else "false" }
val f = getFileInfo(path)
_filesUpdates.emit(
DataPage(listOf(f), pageLength = 1, pageIndex = 0),
)
}
override suspend fun touchFile(path: String): Unit = withContext(ioDispatcher) {
touchParentDirs(path)
try {
guard { repo.uploadBytes(toDiskPath(path), ByteArray(0), overwrite = false) }
} catch (_: Exception) {
// файл уже есть — ок
}
scanSizeAndNumOfFiles()
}
override suspend fun touchDir(path: String): Unit = withContext(ioDispatcher) {
val segments = pathSegments(path)
var acc = ""
for (seg in segments) {
acc += "/$seg"
guard { repo.createFolder(toDiskPath(acc)) }
}
}
override suspend fun delete(path: String) = withContext(ioDispatcher) {
if (path == "/" || path.isBlank()) {
throw IllegalArgumentException("Deleting root path is forbidden")
}
guard { repo.delete(toDiskPath(path), permanently = true) }
scanSizeAndNumOfFiles()
}
override suspend fun openWrite(path: String): OutputStream = withContext(ioDispatcher) {
touchParentDirs(path)
val tmp = File.createTempFile("wallenc-yandex-", ".upload")
val fos = FileOutputStream(tmp)
fos.onClosed {
runBlocking(ioDispatcher) {
try {
guard { repo.uploadFile(toDiskPath(path), tmp, overwrite = true) }
scanSizeAndNumOfFiles()
val info = runCatching { getFileInfo(path) }.getOrNull()
if (info != null) {
_filesUpdates.emit(DataPage(listOf(info), pageLength = 1, pageIndex = 0))
}
} finally {
tmp.delete()
}
}
}
}
override suspend fun openRead(path: String): InputStream = withContext(ioDispatcher) {
guard { repo.openDownloadStream(toDiskPath(path)) }
}
override suspend fun moveToTrash(path: String) = withContext(ioDispatcher) {
patchCustom(path) { it[PROP_DELETED] = "true" }
}
override suspend fun openReadSystemFile(name: String): InputStream = withContext(ioDispatcher) {
ensureSystemDirExists()
val rel = "/$SYSTEM_HIDDEN_DIRNAME/$name"
try {
guard { repo.openDownloadStream(toDiskPath(rel)) }
} catch (_: Exception) {
// как Local: пустой файл если нет
guard { repo.uploadBytes(toDiskPath(rel), ByteArray(0), overwrite = true) }
guard { repo.openDownloadStream(toDiskPath(rel)) }
}
}
override suspend fun openWriteSystemFile(name: String): OutputStream = withContext(ioDispatcher) {
ensureSystemDirExists()
val rel = "/$SYSTEM_HIDDEN_DIRNAME/$name"
val baos = ByteArrayOutputStream()
baos.onClosed {
runBlocking(ioDispatcher) {
guard { repo.uploadBytes(toDiskPath(rel), baos.toByteArray(), overwrite = true) }
}
}
}
private suspend fun touchParentDirs(path: String) {
val normalized = path.removeSuffix("/")
if (normalized == "/" || normalized.isBlank()) return
val lastSlash = normalized.lastIndexOf('/')
if (lastSlash <= 0) return
val parent = normalized.substring(0, lastSlash).ifEmpty { "/" }
touchDir(parent)
}
private fun pathSegments(path: String): List<String> =
path.trim('/').split('/').filter { it.isNotBlank() }
private suspend fun patchCustom(path: String, mutator: (MutableMap<String, String>) -> Unit) {
val cur = guard { repo.get(toDiskPath(path)) }
val merged = (cur.customProperties ?: emptyMap())
.mapValues { (_, v) -> v?.toString() ?: "" }
.toMutableMap()
mutator(merged)
guard { repo.setCustomProperties(toDiskPath(path), merged) }
}
companion object {
private const val SYSTEM_HIDDEN_DIRNAME = "wallenc-yandex-system"
private const val DATA_PAGE_LENGTH = 10
private const val API_LIST_LIMIT = 200
private const val PROP_HIDDEN = "wallenc.hidden"
private const val PROP_DELETED = "wallenc.deleted"
}
}

View File

@@ -0,0 +1,104 @@
package com.github.nullptroma.wallenc.infrastructure.utils
import java.io.InputStream
import java.io.OutputStream
private class CloseHandledOutputStream(
private val stream: OutputStream,
private val onClosing: () -> Unit,
private val onClose: () -> Unit
) : OutputStream() {
override fun write(b: Int) {
stream.write(b)
}
override fun write(b: ByteArray) {
stream.write(b)
}
override fun write(b: ByteArray, off: Int, len: Int) {
stream.write(b, off, len)
}
override fun flush() {
stream.flush()
}
override fun close() {
onClosing()
try {
stream.close()
} finally {
onClose()
}
}
}
private class CloseHandledInputStream(
private val stream: InputStream,
private val onClosing: () -> Unit,
private val onClose: () -> Unit
) : InputStream() {
override fun read(): Int {
return stream.read()
}
override fun read(b: ByteArray): Int {
return stream.read(b)
}
override fun read(b: ByteArray, off: Int, len: Int): Int {
return stream.read(b, off, len)
}
override fun skip(n: Long): Long {
return stream.skip(n)
}
override fun available(): Int {
return stream.available()
}
override fun close() {
onClosing()
try {
stream.close()
} finally {
onClose()
}
}
override fun mark(readlimit: Int) {
stream.mark(readlimit)
}
override fun reset() {
stream.reset()
}
override fun markSupported(): Boolean {
return stream.markSupported()
}
}
class CloseHandledStreamExtension {
companion object {
fun OutputStream.onClosed(callback: ()->Unit): OutputStream {
return CloseHandledOutputStream(this, {}, callback)
}
fun InputStream.onClosed(callback: ()->Unit): InputStream {
return CloseHandledInputStream(this, {}, callback)
}
fun OutputStream.onClosing(callback: ()->Unit): OutputStream {
return CloseHandledOutputStream(this, callback) {}
}
fun InputStream.onClosing(callback: ()->Unit): InputStream {
return CloseHandledInputStream(this, callback) {}
}
}
}

View File

@@ -0,0 +1,7 @@
package com.github.nullptroma.wallenc.infrastructure.utils
interface IProvider<T> {
suspend fun get(): T?
suspend fun set(value: T)
suspend fun clear()
}

View File

@@ -0,0 +1,110 @@
package com.github.nullptroma.wallenc.infrastructure.vaults
import com.github.nullptroma.wallenc.infrastructure.model.YandexAccount
import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.repository.YandexDiskRepositoryFactory
import com.github.nullptroma.wallenc.infrastructure.network.yandexuserinfo.repository.YandexUserInfoRepository
import com.github.nullptroma.wallenc.infrastructure.ports.StorageKeyMapStore
import com.github.nullptroma.wallenc.infrastructure.ports.YandexAccountStore
import com.github.nullptroma.wallenc.infrastructure.storages.UnlockManager
import com.github.nullptroma.wallenc.infrastructure.vaults.yandex.YandexRegistration
import com.github.nullptroma.wallenc.infrastructure.vaults.yandex.YandexVault
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager
import com.github.nullptroma.wallenc.domain.interfaces.IVault
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.vault.contract.VaultRegistrar
import com.github.nullptroma.wallenc.vault.contract.VaultRegistration
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import java.util.UUID
@OptIn(ExperimentalCoroutinesApi::class)
class VaultsManager(
private val ioDispatcher: CoroutineDispatcher,
localVault: IVault,
keyRepo: StorageKeyMapStore,
private val yandexAccountStore: YandexAccountStore,
private val yandexUserInfoRepository: YandexUserInfoRepository,
private val yandexDiskRepositoryFactory: YandexDiskRepositoryFactory,
) : IVaultsManager, VaultRegistrar {
private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)
private val localVault: IVault = localVault
private val yandexVaults: StateFlow<List<IVault>> = yandexAccountStore.observeAll()
.map { rows ->
rows.map { row ->
val vaultUuid = UUID.fromString(row.vaultUuid)
YandexVault(
uuid = vaultUuid,
accountEmail = row.email,
repo = yandexDiskRepositoryFactory.create(vaultUuid),
ioDispatcher = ioDispatcher,
parentScope = scope,
)
}
}
.stateIn(scope, SharingStarted.Eagerly, emptyList())
override val vaults: StateFlow<List<IVault>> = yandexVaults
.map { remote -> listOf(localVault) + remote }
.stateIn(scope, SharingStarted.Eagerly, listOf(localVault))
override val allStorages: StateFlow<List<IStorage>> = vaults
.flatMapLatest { vs ->
if (vs.isEmpty()) flowOf(emptyList())
else combine(vs.map { it.storages }) { arr -> arr.toList().flatten() }
}
.stateIn(scope, SharingStarted.Eagerly, emptyList())
override val unlockManager: IUnlockManager = UnlockManager(
keymapRepository = keyRepo,
ioDispatcher = ioDispatcher,
vaultsManager = this,
)
override suspend fun register(registration: VaultRegistration) = withContext(ioDispatcher) {
when (registration) {
is YandexRegistration -> registerYandex(registration)
else -> throw IllegalArgumentException(
"Unknown VaultRegistration type: ${registration::class.qualifiedName}",
)
}
}
override suspend fun unregister(vaultUuid: UUID): Unit = withContext(ioDispatcher) {
yandexAccountStore.deleteByVaultUuid(vaultUuid.toString())
}
private suspend fun registerYandex(registration: YandexRegistration) {
val token = registration.oauthToken
val info = yandexUserInfoRepository.userInfo(token)
val email = info.defaultEmail?.takeIf { it.isNotBlank() }
?: "${info.login}@yandex.ru"
val existing = yandexAccountStore.getByYandexUserId(info.id)
val vaultUuid = existing?.vaultUuid ?: UUID.randomUUID().toString()
if (existing != null) {
yandexAccountStore.updateCredentials(vaultUuid, email, token)
} else {
yandexAccountStore.insert(
YandexAccount(
vaultUuid = vaultUuid,
yandexUserId = info.id,
email = email,
oauthToken = token,
),
)
}
}
}

View File

@@ -0,0 +1,22 @@
package com.github.nullptroma.wallenc.infrastructure.vaults.yandex
import com.github.nullptroma.wallenc.vault.contract.CloudBrand
import com.github.nullptroma.wallenc.vault.contract.VaultRegistration
/**
* Регистрация удалённого vault'а Яндекс.Диска по результату OAuth.
*
* Живёт в `:data` (а не в `:vault-api`), потому что [VaultRegistration]
* намеренно не sealed — конкретные реализации лежат рядом со своим поставщиком.
* presentation никогда не открывает этот тип, только перепасовывает обратно
* в `VaultRegistrar.register(...)`.
*/
data class YandexRegistration(
val oauthToken: String,
) : VaultRegistration {
init {
require(oauthToken.isNotBlank()) { "oauthToken must not be blank" }
}
val brand: CloudBrand get() = CloudBrand.YANDEX
}

View File

@@ -0,0 +1,140 @@
package com.github.nullptroma.wallenc.infrastructure.vaults.yandex
import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.YandexDiskAuthException
import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.repository.YandexDiskRepository
import com.github.nullptroma.wallenc.infrastructure.storages.yandex.YandexStorage
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.vault.contract.CloudBrand
import com.github.nullptroma.wallenc.vault.contract.DescribedVault
import com.github.nullptroma.wallenc.vault.contract.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 Яндекс.Диска: папка приложения `app:/`, внутри — подпапки-UUID как [YandexStorage].
*
* [isAvailable] — успешный контакт с Disk API (метаданные диска и загрузка списка storages).
* Пока false, дочерние storages тоже считаются недоступными (см. YandexStorageAccessor).
*/
class YandexVault(
override val uuid: UUID,
accountEmail: String,
private val repo: YandexDiskRepository,
private val ioDispatcher: CoroutineDispatcher,
private val parentScope: CoroutineScope,
) : DescribedVault {
override val descriptor: VaultDescriptor = VaultDescriptor.LinkedRemote(
uuid = uuid,
brand = CloudBrand.YANDEX,
accountDisplayName = accountEmail,
)
private val _vaultReachable = MutableStateFlow(false)
override val isAvailable: StateFlow<Boolean> = _vaultReachable
private val _storages = MutableStateFlow<List<IStorage>>(emptyList())
override val storages: StateFlow<List<IStorage>> = _storages
private val _totalSpace = MutableStateFlow<Long?>(null)
override val totalSpace: StateFlow<Long?> = _totalSpace
private val _availableSpace = MutableStateFlow<Long?>(null)
override val availableSpace: StateFlow<Long?> = _availableSpace
init {
parentScope.launch {
runCatching { refreshFromDisk() }
}
}
private suspend fun refreshFromDisk() {
_vaultReachable.value = false
try {
val info = repo.diskInfo()
_totalSpace.value = info.totalSpace
val used = info.usedSpace ?: 0L
val total = info.totalSpace ?: 0L
_availableSpace.value = (total - used).coerceAtLeast(0L)
_vaultReachable.value = true
_storages.value = loadStoragesList()
} catch (e: YandexDiskAuthException) {
_vaultReachable.value = false
_storages.value = emptyList()
} catch (e: Exception) {
_vaultReachable.value = false
_storages.value = emptyList()
}
}
private suspend fun loadStoragesList(): List<IStorage> {
val out = mutableListOf<YandexStorage>()
var offset = 0
while (true) {
val root = repo.list("app:/", APP_LIST_LIMIT, offset)
val items = root.embedded?.items.orEmpty()
for (item in items) {
if (item.type != "dir") continue
val name = item.name ?: continue
val storageUuid = runCatching { UUID.fromString(name) }.getOrNull() ?: continue
val storage = YandexStorage(
uuid = storageUuid,
repo = repo,
vaultAvailability = _vaultReachable,
ioDispatcher = ioDispatcher,
accessorScope = parentScope,
reportAuthFailure = {
parentScope.launch { _vaultReachable.value = false }
},
)
try {
storage.init()
out.add(storage)
} catch (_: Exception) { }
}
if (items.size < APP_LIST_LIMIT) break
offset += items.size
}
return out
}
override suspend fun createStorage(): IStorage = withContext(ioDispatcher) {
val id = UUID.randomUUID()
repo.createFolder("app:/$id")
val storage = YandexStorage(
uuid = id,
repo = repo,
vaultAvailability = _vaultReachable,
ioDispatcher = ioDispatcher,
accessorScope = parentScope,
reportAuthFailure = {
parentScope.launch { _vaultReachable.value = false }
},
)
storage.init()
_storages.value = _storages.value + storage
storage
}
override suspend fun createStorage(enc: StorageEncryptionInfo): IStorage {
val storage = createStorage()
storage.setEncInfo(enc)
return storage
}
override suspend fun remove(storage: IStorage) = withContext(ioDispatcher) {
if (storage !is YandexStorage) return@withContext
repo.delete("app:/${storage.uuid}", permanently = true)
_storages.value = _storages.value.filter { it.uuid != storage.uuid }
}
private companion object {
private const val APP_LIST_LIMIT = 200
}
}

View File

@@ -0,0 +1,17 @@
package com.github.nullptroma.wallenc.infrastructure
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}