Большой рефакторинг

Из domain выкинуты типы vault, теперь он ничего не знает о Yandex. Объявления провайдеров вынесены в vault-api, а реализации в data
This commit is contained in:
2026-04-27 02:47:02 +03:00
parent 2b1be58a8e
commit 1034e134c2
37 changed files with 527 additions and 219 deletions

View File

@@ -54,4 +54,5 @@ dependencies {
androidTestImplementation(libs.androidx.espresso.core)
implementation(project(":domain"))
implementation(project(":vault-api"))
}

View File

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

View File

@@ -6,10 +6,16 @@ import com.github.nullptroma.wallenc.data.db.app.repository.StorageKeyMapReposit
import com.github.nullptroma.wallenc.data.db.app.repository.YandexAccountRepository
import com.github.nullptroma.wallenc.data.network.yandexuserinfo.repository.YandexUserInfoRepository
import com.github.nullptroma.wallenc.data.storages.UnlockManager
import com.github.nullptroma.wallenc.data.vaults.local.LocalVault
import com.github.nullptroma.wallenc.data.vaults.local.LocalVaultIdStore
import com.github.nullptroma.wallenc.data.vaults.yandex.YandexRegistration
import com.github.nullptroma.wallenc.data.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.vaultapi.VaultRegistrar
import com.github.nullptroma.wallenc.vaultapi.VaultRegistration
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
@@ -26,21 +32,17 @@ class VaultsManager(
keyRepo: StorageKeyMapRepository,
private val yandexAccountRepository: YandexAccountRepository,
private val yandexUserInfoRepository: YandexUserInfoRepository,
) : IVaultsManager {
) : IVaultsManager, VaultRegistrar {
private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)
override val localVault = LocalVault(ioDispatcher, context)
// До unlockManager: UnlockManager в init подписывается на allStorages.
override val allStorages: StateFlow<List<IStorage>> = localVault.storages
override val unlockManager: IUnlockManager = UnlockManager(
keymapRepository = keyRepo,
private val localVault: IVault = LocalVault(
ioDispatcher = ioDispatcher,
vaultsManager = this
context = context,
idStore = LocalVaultIdStore(context),
)
private val _remoteVaults = yandexAccountRepository.observeAll()
private val yandexVaults: StateFlow<List<IVault>> = yandexAccountRepository.observeAll()
.map { rows ->
rows.map { row ->
YandexVault(
@@ -52,33 +54,51 @@ class VaultsManager(
}
.stateIn(scope, SharingStarted.Eagerly, emptyList())
override val remoteVaults: StateFlow<List<IVault>> = _remoteVaults
override val allVaults: StateFlow<List<IVault>> = _remoteVaults
.map { listOf(localVault) + it }
override val vaults: StateFlow<List<IVault>> = yandexVaults
.map { remote -> listOf(localVault) + remote }
.stateIn(scope, SharingStarted.Eagerly, listOf(localVault))
override suspend fun addYandexVault(accessToken: String) = withContext(ioDispatcher) {
val info = yandexUserInfoRepository.userInfo(accessToken)
// Поведение Phase 1 — UnlockManager работает только с локальными storages.
// Расширение до combine(local + remote) пойдёт во Phase 2.
override val allStorages: StateFlow<List<IStorage>> = localVault.storages
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) {
yandexAccountRepository.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 = yandexAccountRepository.getByYandexUserId(info.id)
val vaultUuid = existing?.vaultUuid ?: UUID.randomUUID().toString()
if (existing != null) {
yandexAccountRepository.updateCredentials(vaultUuid, email, accessToken)
yandexAccountRepository.updateCredentials(vaultUuid, email, token)
} else {
yandexAccountRepository.insert(
DbYandexAccount(
vaultUuid = vaultUuid,
yandexUserId = info.id,
email = email,
oauthToken = accessToken,
)
oauthToken = token,
),
)
}
}
override suspend fun removeRemoteVault(vaultUuid: UUID) = withContext(ioDispatcher) {
yandexAccountRepository.deleteByVaultUuid(vaultUuid.toString())
}
}

View File

@@ -1,44 +0,0 @@
package com.github.nullptroma.wallenc.data.vaults
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.enums.VaultType
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IYandexVault
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.util.UUID
/**
* Удалённый vault Яндекс: OAuth и профиль есть; Yandex.Disk и хранилища — заглушки.
*/
class YandexVault(
override val uuid: UUID,
override val accountEmail: String,
val oauthToken: String,
) : IYandexVault {
override val type: VaultType = VaultType.YANDEX
private val _storages = MutableStateFlow<List<IStorage>>(emptyList())
override val storages: StateFlow<List<IStorage>?> = _storages
private val _isAvailable = MutableStateFlow(true)
override val isAvailable: StateFlow<Boolean> = _isAvailable
private val _totalSpace = MutableStateFlow<Int?>(null)
override val totalSpace: StateFlow<Int?> = _totalSpace
private val _availableSpace = MutableStateFlow<Int?>(null)
override val availableSpace: StateFlow<Int?> = _availableSpace
override suspend fun createStorage(): IStorage {
throw UnsupportedOperationException("Yandex.Disk ещё не подключён")
}
override suspend fun createStorage(enc: StorageEncryptionInfo): IStorage {
throw UnsupportedOperationException("Yandex.Disk ещё не подключён")
}
override suspend fun remove(storage: IStorage) {
// заглушка до интеграции API Диска
}
}

View File

@@ -1,11 +1,11 @@
package com.github.nullptroma.wallenc.data.vaults
package com.github.nullptroma.wallenc.data.vaults.local
import android.content.Context
import com.github.nullptroma.wallenc.data.storages.local.LocalStorage
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.enums.VaultType
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IVault
import com.github.nullptroma.wallenc.vaultapi.DescribedVault
import com.github.nullptroma.wallenc.vaultapi.VaultDescriptor
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
@@ -18,21 +18,26 @@ import kotlin.io.path.Path
import kotlin.io.path.createDirectory
import kotlin.io.path.pathString
class LocalVault(private val ioDispatcher: CoroutineDispatcher, context: Context) : IVault {
override val type: VaultType = VaultType.LOCAL
override val uuid: UUID
get() = TODO("Not yet implemented")
class LocalVault(
private val ioDispatcher: CoroutineDispatcher,
context: Context,
idStore: LocalVaultIdStore,
) : DescribedVault {
private val _storages = MutableStateFlow(listOf<LocalStorage>())
override val uuid: UUID = idStore.getOrCreate()
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(null)
private val _totalSpace = MutableStateFlow<Int?>(null)
override val totalSpace: StateFlow<Int?> = _totalSpace
private val _availableSpace = MutableStateFlow(null)
private val _availableSpace = MutableStateFlow<Int?>(null)
override val availableSpace: StateFlow<Int?> = _availableSpace
private val path = MutableStateFlow<File?>(null)
@@ -54,7 +59,7 @@ class LocalVault(private val ioDispatcher: CoroutineDispatcher, context: Context
if (dirs != null) {
_storages.value = dirs.map {
val uuid = UUID.fromString(it.name)
return@map LocalStorage(uuid, it.path, ioDispatcher).apply { init() }
LocalStorage(uuid, it.path, ioDispatcher).apply { init() }
}
}
}
@@ -76,7 +81,7 @@ class LocalVault(private val ioDispatcher: CoroutineDispatcher, context: Context
}
override suspend fun createStorage(
enc: StorageEncryptionInfo
enc: StorageEncryptionInfo,
): LocalStorage = withContext(ioDispatcher) {
val storage = createStorage()
storage.setEncInfo(enc)
@@ -90,11 +95,11 @@ class LocalVault(private val ioDispatcher: CoroutineDispatcher, context: Context
val curStorages = _storages.value.toMutableList()
val index = curStorages.indexOfFirst { it.uuid == storage.uuid }
if(index != -1) {
val localStorage = curStorages[index]
if (index != -1) {
val localStorage = curStorages[index] as LocalStorage
curStorages.removeAt(index)
_storages.value = curStorages
File(localStorage.absolutePath).deleteRecursively()
}
}
}
}

View File

@@ -0,0 +1,30 @@
package com.github.nullptroma.wallenc.data.vaults.local
import android.content.Context
import java.util.UUID
/**
* Хранит/восстанавливает идентификатор единственного локального vault'а
* в [android.content.SharedPreferences]. При первом обращении генерирует новый UUID
* и записывает его синхронно (`commit`), чтобы к моменту, когда другие подсистемы
* (DB, шифр-ключи) начнут связывать с ним записи, значение уже было персистентно.
*/
class LocalVaultIdStore(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
/** Возвращает существующий идентификатор или, если его нет, генерирует и сохраняет новый. */
fun getOrCreate(): UUID {
prefs.getString(KEY_LOCAL_VAULT_UUID, null)?.let { stored ->
runCatching { UUID.fromString(stored) }.getOrNull()?.let { return it }
}
val generated = UUID.randomUUID()
prefs.edit().putString(KEY_LOCAL_VAULT_UUID, generated.toString()).commit()
return generated
}
private companion object {
const val PREFS_NAME = "wallenc.vaults"
const val KEY_LOCAL_VAULT_UUID = "local_vault_uuid"
}
}

View File

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

View File

@@ -0,0 +1,49 @@
package com.github.nullptroma.wallenc.data.vaults.yandex
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.vaultapi.CloudBrand
import com.github.nullptroma.wallenc.vaultapi.DescribedVault
import com.github.nullptroma.wallenc.vaultapi.VaultDescriptor
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.util.UUID
/**
* Удалённый vault Яндекс.Диска. Сейчас — заглушка для уровня storages
* (Phase 1 — только OAuth + перечисление аккаунтов).
*/
class YandexVault(
override val uuid: UUID,
accountEmail: String,
val oauthToken: String,
) : DescribedVault {
override val descriptor: VaultDescriptor = VaultDescriptor.LinkedRemote(
uuid = uuid,
brand = CloudBrand.YANDEX,
accountDisplayName = accountEmail,
)
private val _storages = MutableStateFlow<List<IStorage>>(emptyList())
override val storages: StateFlow<List<IStorage>> = _storages
private val _isAvailable = MutableStateFlow(true)
override val isAvailable: StateFlow<Boolean> = _isAvailable
private val _totalSpace = MutableStateFlow<Int?>(null)
override val totalSpace: StateFlow<Int?> = _totalSpace
private val _availableSpace = MutableStateFlow<Int?>(null)
override val availableSpace: StateFlow<Int?> = _availableSpace
override suspend fun createStorage(): IStorage =
throw UnsupportedOperationException("Yandex.Disk REST integration is not connected yet")
override suspend fun createStorage(enc: StorageEncryptionInfo): IStorage =
throw UnsupportedOperationException("Yandex.Disk REST integration is not connected yet")
override suspend fun remove(storage: IStorage) {
// No-op до интеграции API Диска.
}
}