Переименован domain-vault
This commit is contained in:
1
domain-vault/.gitignore
vendored
Normal file
1
domain-vault/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
27
domain-vault/build.gradle.kts
Normal file
27
domain-vault/build.gradle.kts
Normal 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"))
|
||||
}
|
||||
0
domain-vault/consumer-rules.pro
Normal file
0
domain-vault/consumer-rules.pro
Normal file
21
domain-vault/proguard-rules.pro
vendored
Normal file
21
domain-vault/proguard-rules.pro
vendored
Normal 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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
4
domain-vault/src/main/AndroidManifest.xml
Normal file
4
domain-vault/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest>
|
||||
|
||||
</manifest>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.github.nullptroma.wallenc.infrastructure.network.yandexdisk
|
||||
|
||||
import java.io.IOException
|
||||
|
||||
class YandexDiskAuthException(message: String? = null) : IOException(message)
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.github.nullptroma.wallenc.infrastructure.vaults.local
|
||||
|
||||
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
|
||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
|
||||
import com.github.nullptroma.wallenc.infrastructure.storages.local.LocalStorage
|
||||
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.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.UUID
|
||||
import kotlin.io.path.Path
|
||||
import kotlin.io.path.createDirectory
|
||||
import kotlin.io.path.pathString
|
||||
|
||||
class LocalVault(
|
||||
private val ioDispatcher: CoroutineDispatcher,
|
||||
private val vaultRoot: File?,
|
||||
) : DescribedVault {
|
||||
|
||||
override val uuid: UUID = vaultRoot?.let { root ->
|
||||
root.mkdirs()
|
||||
readOrCreateVaultUuid(File(root, UUID_FILE_NAME))
|
||||
} ?: UUID.randomUUID()
|
||||
|
||||
override val descriptor: VaultDescriptor = VaultDescriptor.LocalDevice(uuid)
|
||||
|
||||
private val _storages = MutableStateFlow<List<IStorage>>(emptyList())
|
||||
override val storages: StateFlow<List<IStorage>> = _storages
|
||||
|
||||
private val _isAvailable = MutableStateFlow(false)
|
||||
override val isAvailable: StateFlow<Boolean> = _isAvailable
|
||||
|
||||
private val _totalSpace = MutableStateFlow<Long?>(null)
|
||||
override val totalSpace: StateFlow<Long?> = _totalSpace
|
||||
|
||||
private val _availableSpace = MutableStateFlow<Long?>(null)
|
||||
override val availableSpace: StateFlow<Long?> = _availableSpace
|
||||
|
||||
private val path = MutableStateFlow<File?>(vaultRoot)
|
||||
|
||||
init {
|
||||
CoroutineScope(ioDispatcher).launch {
|
||||
_isAvailable.value = path.value != null
|
||||
if (path.value != null) {
|
||||
readStorages()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readStorages() {
|
||||
val path = path.value
|
||||
if (path == null || !_isAvailable.value) {
|
||||
throw Exception("Not available")
|
||||
}
|
||||
|
||||
val dirs = path.listFiles()?.filter { it.isDirectory }
|
||||
if (dirs != null) {
|
||||
_storages.value = dirs.map {
|
||||
val storageUuid = UUID.fromString(it.name)
|
||||
LocalStorage(storageUuid, it.path, ioDispatcher).apply { init() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createStorage(): LocalStorage = withContext(ioDispatcher) {
|
||||
val path = path.value
|
||||
if (path == null || !_isAvailable.value) {
|
||||
throw Exception("Not available")
|
||||
}
|
||||
|
||||
val storageUuid = UUID.randomUUID()
|
||||
val next = Path(path.path, storageUuid.toString())
|
||||
next.createDirectory()
|
||||
val newStorage = LocalStorage(storageUuid, next.pathString, ioDispatcher)
|
||||
newStorage.init()
|
||||
_storages.value = _storages.value.toMutableList().apply {
|
||||
add(newStorage)
|
||||
}
|
||||
return@withContext newStorage
|
||||
}
|
||||
|
||||
override suspend fun createStorage(
|
||||
enc: StorageEncryptionInfo,
|
||||
): LocalStorage = withContext(ioDispatcher) {
|
||||
val storage = createStorage()
|
||||
storage.setEncInfo(enc)
|
||||
return@withContext storage
|
||||
}
|
||||
|
||||
override suspend fun remove(storage: IStorage) = withContext(ioDispatcher) {
|
||||
val path = path.value
|
||||
if (path == null || !_isAvailable.value) {
|
||||
throw Exception("Not available")
|
||||
}
|
||||
|
||||
val curStorages = _storages.value.toMutableList()
|
||||
val index = curStorages.indexOfFirst { it.uuid == storage.uuid }
|
||||
if (index != -1) {
|
||||
val localStorage = curStorages[index] as LocalStorage
|
||||
curStorages.removeAt(index)
|
||||
_storages.value = curStorages
|
||||
File(localStorage.absolutePath).deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val UUID_FILE_NAME = ".uuid"
|
||||
|
||||
private val uuidLock = Any()
|
||||
|
||||
private fun readOrCreateVaultUuid(idFile: File): UUID = synchronized(uuidLock) {
|
||||
if (idFile.exists()) {
|
||||
idFile.bufferedReader().use { reader ->
|
||||
val line = reader.readLine()?.trim()
|
||||
if (!line.isNullOrEmpty()) {
|
||||
runCatching { UUID.fromString(line) }.getOrNull()?.let { return@synchronized it }
|
||||
}
|
||||
}
|
||||
}
|
||||
val generated = UUID.randomUUID()
|
||||
val parent = idFile.parentFile ?: throw IllegalStateException("No parent for $idFile")
|
||||
parent.mkdirs()
|
||||
val tmp = File.createTempFile("vault-uuid-", ".tmp", parent)
|
||||
try {
|
||||
FileOutputStream(tmp).use { fos ->
|
||||
fos.write(generated.toString().toByteArray(Charsets.UTF_8))
|
||||
fos.fd.sync()
|
||||
}
|
||||
if (!tmp.renameTo(idFile)) {
|
||||
tmp.copyTo(idFile, overwrite = true)
|
||||
}
|
||||
} finally {
|
||||
if (tmp.exists()) {
|
||||
tmp.delete()
|
||||
}
|
||||
}
|
||||
return@synchronized generated
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user