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

Из 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

@@ -77,4 +77,5 @@ dependencies {
implementation(project(":domain"))
implementation(project(":data"))
implementation(project(":presentation"))
implementation(project(":vault-api"))
}

View File

@@ -3,8 +3,10 @@ package com.github.nullptroma.wallenc.app.auth
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import com.github.nullptroma.wallenc.domain.auth.RemoteYandexAuthResult
import com.github.nullptroma.wallenc.domain.auth.RemoteYandexSignInLauncher
import com.github.nullptroma.wallenc.data.vaults.yandex.YandexRegistration
import com.github.nullptroma.wallenc.vaultapi.CloudBrand
import com.github.nullptroma.wallenc.vaultapi.RemoteVaultAuthenticator
import com.github.nullptroma.wallenc.vaultapi.VaultLinkOutcome
import com.yandex.authsdk.YandexAuthLoginOptions
import com.yandex.authsdk.YandexAuthOptions
import com.yandex.authsdk.YandexAuthResult
@@ -21,11 +23,15 @@ import javax.inject.Singleton
*
* При смене конфигурации новая Activity может вызвать [registerWith] до [unregister] старой —
* тогда владелец и launcher обновляются; [unregister] для уже неактуального экземпляра — no-op.
*
* Реализует [RemoteVaultAuthenticator] из `:vault-api`: presentation про Yandex SDK
* ничего не знает, а получает обобщённый [VaultLinkOutcome] с готовой
* `VaultRegistration` внутри.
*/
@Singleton
class YandexSignInService @Inject constructor(
@ApplicationContext appContext: Context,
) : RemoteYandexSignInLauncher {
) : RemoteVaultAuthenticator {
private val sdk = YandexAuthSdk.create(YandexAuthOptions(appContext, true))
@@ -34,7 +40,7 @@ class YandexSignInService @Inject constructor(
private var registrationOwner: ComponentActivity? = null
@Volatile
private var pending: ((RemoteYandexAuthResult) -> Unit)? = null
private var pending: ((VaultLinkOutcome) -> Unit)? = null
fun registerWith(activity: ComponentActivity) {
if (registrationOwner === activity && launcher != null) return
@@ -59,25 +65,34 @@ class YandexSignInService @Inject constructor(
pending = null
p
}
cb?.invoke(RemoteYandexAuthResult.Cancelled)
cb?.invoke(VaultLinkOutcome.Cancelled)
}
override fun launch(onResult: (RemoteYandexAuthResult) -> Unit) {
val l = launcher
?: error("YandexSignInService: call registerWith(activity) from MainActivity.onCreate first")
synchronized(this) {
pending = onResult
override fun beginLink(brand: CloudBrand, onResult: (VaultLinkOutcome) -> Unit) {
if (brand != CloudBrand.YANDEX) {
onResult(VaultLinkOutcome.Failed("Brand $brand is not supported by YandexSignInService"))
return
}
val l = launcher
if (l == null) {
onResult(
VaultLinkOutcome.Failed(
"YandexSignInService: call registerWith(activity) from MainActivity.onCreate first",
),
)
return
}
synchronized(this) { pending = onResult }
l.launch(YandexAuthLoginOptions(LoginType.WEBVIEW))
}
private fun mapYandexResult(result: YandexAuthResult): RemoteYandexAuthResult = when (result) {
private fun mapYandexResult(result: YandexAuthResult): VaultLinkOutcome = when (result) {
is YandexAuthResult.Success ->
RemoteYandexAuthResult.Success(result.token.value)
VaultLinkOutcome.Success(YandexRegistration(result.token.value))
is YandexAuthResult.Failure ->
RemoteYandexAuthResult.Failure(
VaultLinkOutcome.Failed(
result.exception.message ?: result.exception.toString(),
)
YandexAuthResult.Cancelled -> RemoteYandexAuthResult.Cancelled
YandexAuthResult.Cancelled -> VaultLinkOutcome.Cancelled
}
}

View File

@@ -1,7 +1,7 @@
package com.github.nullptroma.wallenc.app.di.modules.auth
import com.github.nullptroma.wallenc.app.auth.YandexSignInService
import com.github.nullptroma.wallenc.domain.auth.RemoteYandexSignInLauncher
import com.github.nullptroma.wallenc.vaultapi.RemoteVaultAuthenticator
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@@ -14,7 +14,7 @@ abstract class YandexAuthModule {
@Binds
@Singleton
abstract fun bindRemoteYandexSignInLauncher(
abstract fun bindRemoteVaultAuthenticator(
impl: YandexSignInService,
): RemoteYandexSignInLauncher
): RemoteVaultAuthenticator
}

View File

@@ -16,6 +16,7 @@ import com.github.nullptroma.wallenc.data.vaults.VaultsManager
import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.vaultapi.VaultRegistrar
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -45,7 +46,7 @@ class SingletonModule {
keyRepo: StorageKeyMapRepository,
yandexAccountRepository: YandexAccountRepository,
yandexUserInfoRepository: YandexUserInfoRepository,
): IVaultsManager {
): VaultsManager {
return VaultsManager(
ioDispatcher = ioDispatcher,
context = context,
@@ -55,6 +56,14 @@ class SingletonModule {
)
}
@Provides
@Singleton
fun provideIVaultsManager(impl: VaultsManager): IVaultsManager = impl
@Provides
@Singleton
fun provideVaultRegistrar(impl: VaultsManager): VaultRegistrar = impl
@Provides
fun provideUnlockManager(
vaultsManager: IVaultsManager

View File

@@ -3,8 +3,8 @@ package com.github.nullptroma.wallenc.app.di.modules.domain
import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.domain.usecases.GetOpenedStoragesUseCase
import com.github.nullptroma.wallenc.domain.usecases.ManageLocalVaultUseCase
import com.github.nullptroma.wallenc.domain.usecases.ManageStoragesEncryptionUseCase
import com.github.nullptroma.wallenc.domain.usecases.ManageVaultUseCase
import com.github.nullptroma.wallenc.domain.usecases.RemoveStorageUseCase
import com.github.nullptroma.wallenc.domain.usecases.RenameStorageUseCase
import com.github.nullptroma.wallenc.domain.usecases.StorageFileManagementUseCase
@@ -25,8 +25,8 @@ class UseCasesModule {
@Provides
@Singleton
fun provideManageLocalVaultUseCase(vaultsManager: IVaultsManager): ManageLocalVaultUseCase {
return ManageLocalVaultUseCase(vaultsManager)
fun provideManageVaultUseCase(vaultsManager: IVaultsManager): ManageVaultUseCase {
return ManageVaultUseCase(vaultsManager)
}
@Provides

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 Диска.
}
}

View File

@@ -1,17 +0,0 @@
package com.github.nullptroma.wallenc.domain.auth
/**
* Результат входа через Яндекс (без зависимости от Yandex SDK).
*/
sealed interface RemoteYandexAuthResult {
data class Success(val accessToken: String) : RemoteYandexAuthResult
data class Failure(val message: String) : RemoteYandexAuthResult
data object Cancelled : RemoteYandexAuthResult
}
/**
* Запуск OAuth Яндекса. Реализация только в модуле app (Yandex Auth SDK).
*/
fun interface RemoteYandexSignInLauncher {
fun launch(onResult: (RemoteYandexAuthResult) -> Unit)
}

View File

@@ -1,7 +0,0 @@
package com.github.nullptroma.wallenc.domain.enums
enum class VaultType {
LOCAL,
DECRYPTED,
YANDEX
}

View File

@@ -3,10 +3,18 @@ package com.github.nullptroma.wallenc.domain.interfaces
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import kotlinx.coroutines.flow.StateFlow
/**
* Контракт vault'а: коллекция [IStorage] с реактивным состоянием.
*
* domain не различает локальные/удалённые/Yandex/etc. — это общий порт.
*/
interface IVault : IVaultInfo {
override val storages: StateFlow<List<IStorage>?>
val storages: StateFlow<List<IStorage>>
val isAvailable: StateFlow<Boolean>
val totalSpace: StateFlow<Int?>
val availableSpace: StateFlow<Int?>
suspend fun createStorage(): IStorage
suspend fun createStorage(enc: StorageEncryptionInfo): IStorage
suspend fun remove(storage: IStorage)
}
}

View File

@@ -1,14 +1,13 @@
package com.github.nullptroma.wallenc.domain.interfaces
import com.github.nullptroma.wallenc.domain.enums.VaultType
import kotlinx.coroutines.flow.StateFlow
import java.util.UUID
sealed interface IVaultInfo {
val type: VaultType
/**
* Минимальная идентификация vault'а.
*
* Намеренно «голая»: domain ничего не знает о брендах, локальности или статусе —
* вся категоризация лежит во внешнем кольце (`:vault-api: VaultDescriptor`).
*/
interface IVaultInfo {
val uuid: UUID
val storages: StateFlow<List<IStorageInfo>?>
val isAvailable: StateFlow<Boolean>
val totalSpace: StateFlow<Int?>
val availableSpace: StateFlow<Int?>
}
}

View File

@@ -1,15 +1,15 @@
package com.github.nullptroma.wallenc.domain.interfaces
import kotlinx.coroutines.flow.StateFlow
import java.util.UUID
/**
* Единая точка доступа ко всем vault'ам приложения.
*
* domain не различает категории vault'ов — потребители (presentation) фильтруют
* [vaults] через `:vault-api` (`VaultDescriptor`/`DescribedVault`).
*/
interface IVaultsManager {
val localVault: IVault
val unlockManager: IUnlockManager
val remoteVaults: StateFlow<List<IVault>>
val vaults: StateFlow<List<IVault>>
val allStorages: StateFlow<List<IStorage>>
val allVaults: StateFlow<List<IVault>>
suspend fun addYandexVault(accessToken: String)
suspend fun removeRemoteVault(vaultUuid: UUID)
}
val unlockManager: IUnlockManager
}

View File

@@ -1,8 +0,0 @@
package com.github.nullptroma.wallenc.domain.interfaces
/**
* Удалённый vault Яндекс с привязанным аккаунтом (почта для UI).
*/
interface IYandexVault : IVault {
val accountEmail: String
}

View File

@@ -1,14 +0,0 @@
package com.github.nullptroma.wallenc.domain.usecases
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import kotlinx.coroutines.flow.StateFlow
class ManageLocalVaultUseCase(private val manager: IVaultsManager) {
val localStorages: StateFlow<List<IStorageInfo>?>
get() = manager.localVault.storages
suspend fun createStorage() {
manager.localVault.createStorage()
}
}

View File

@@ -0,0 +1,34 @@
package com.github.nullptroma.wallenc.domain.usecases
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IVault
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import java.util.UUID
@OptIn(ExperimentalCoroutinesApi::class)
class ManageVaultUseCase(private val manager: IVaultsManager) {
/** Найти vault по идентификатору в текущем состоянии. */
fun find(vaultUuid: UUID): IVault? =
manager.vaults.value.firstOrNull { it.uuid == vaultUuid }
/** Реактивно отдаёт сам vault, либо null если он отсутствует. */
fun observe(vaultUuid: UUID): Flow<IVault?> =
manager.vaults.map { list -> list.firstOrNull { it.uuid == vaultUuid } }
/** Реактивно отдаёт storages указанного vault'а; пустой список, если vault не найден. */
fun storagesOf(vaultUuid: UUID): Flow<List<IStorage>> =
observe(vaultUuid).flatMapLatest { vault -> vault?.storages ?: flowOf(emptyList()) }
/** Создать новое хранилище в указанном vault'е. */
suspend fun createStorage(vaultUuid: UUID): IStorage {
val vault = find(vaultUuid)
?: throw IllegalStateException("Vault $vaultUuid is not registered")
return vault.createStorage()
}
}

View File

@@ -3,7 +3,9 @@ package com.github.nullptroma.wallenc.domain.usecases
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
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 java.util.UUID
class RemoveStorageUseCase(
private val vaultsManager: IVaultsManager,
@@ -12,12 +14,11 @@ class RemoveStorageUseCase(
) {
suspend fun remove(storage: IStorageInfo) {
if (storage !is IStorage) return
if (!storage.isVirtualStorage) {
unlockManager.close(storage)
vaultsManager.localVault.remove(storage)
findOwningVault(storage.uuid)?.remove(storage)
return
}
@@ -25,13 +26,18 @@ class RemoveStorageUseCase(
manageStoragesEncryptionUseCase.clearAndDisableEncryption(parent)
}
private fun findOwningVault(storageUuid: UUID): IVault? =
vaultsManager.vaults.value.firstOrNull { v ->
v.storages.value.any { it.uuid == storageUuid }
}
private fun findParentStorage(storage: IStorage): IStorage? {
val opened = unlockManager.openedStorages.value
val parentUuid = opened.entries.firstOrNull { it.value.uuid == storage.uuid }?.key ?: return null
val locals = vaultsManager.localVault.storages.value.orEmpty()
return locals.firstOrNull { it.uuid == parentUuid }
?: opened[parentUuid]
val parentUuid = opened.entries.firstOrNull { it.value.uuid == storage.uuid }?.key
?: return null
val realParent = vaultsManager.vaults.value
.flatMap { it.storages.value }
.firstOrNull { it.uuid == parentUuid }
return realParent ?: opened[parentUuid]
}
}

View File

@@ -79,4 +79,5 @@ dependencies {
androidTestImplementation(libs.androidx.ui.test.junit4)
implementation(project(":domain"))
implementation(project(":vault-api"))
}

View File

@@ -7,29 +7,38 @@ import com.github.nullptroma.wallenc.domain.interfaces.IDirectory
import com.github.nullptroma.wallenc.domain.interfaces.IFile
import com.github.nullptroma.wallenc.domain.interfaces.ILogger
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import com.github.nullptroma.wallenc.domain.usecases.GetOpenedStoragesUseCase
import com.github.nullptroma.wallenc.domain.usecases.ManageLocalVaultUseCase
import com.github.nullptroma.wallenc.domain.usecases.ManageStoragesEncryptionUseCase
import com.github.nullptroma.wallenc.domain.usecases.ManageVaultUseCase
import com.github.nullptroma.wallenc.domain.usecases.RemoveStorageUseCase
import com.github.nullptroma.wallenc.domain.usecases.RenameStorageUseCase
import com.github.nullptroma.wallenc.domain.usecases.StorageFileManagementUseCase
import com.github.nullptroma.wallenc.presentation.ViewModelBase
import com.github.nullptroma.wallenc.presentation.extensions.toPrintable
import com.github.nullptroma.wallenc.vaultapi.described
import com.github.nullptroma.wallenc.vaultapi.locals
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.util.UUID
import javax.inject.Inject
import kotlin.system.measureTimeMillis
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class LocalVaultViewModel @Inject constructor(
private val manageLocalVaultUseCase: ManageLocalVaultUseCase,
private val vaultsManager: IVaultsManager,
private val manageVaultUseCase: ManageVaultUseCase,
private val removeStorageUseCase: RemoveStorageUseCase,
private val getOpenedStoragesUseCase: GetOpenedStoragesUseCase,
private val storageFileManagementUseCase: StorageFileManagementUseCase,
@@ -38,6 +47,13 @@ class LocalVaultViewModel @Inject constructor(
private val taskOrchestrator: ITaskOrchestrator,
private val logger: ILogger
) : ViewModelBase<LocalVaultScreenState>(LocalVaultScreenState(listOf(), true)) {
private val localVaultUuid: UUID?
get() = vaultsManager.vaults.value.described().locals.firstOrNull()?.uuid
private val localStoragesFlow = vaultsManager.vaults
.map { vaults -> vaults.described().locals.firstOrNull() }
.flatMapLatest { v -> v?.storages ?: flowOf(emptyList()) }
private val _messages = MutableSharedFlow<String>()
val messages: SharedFlow<String> = _messages
@@ -69,24 +85,21 @@ class LocalVaultViewModel @Inject constructor(
private fun collectFlows() {
viewModelScope.launch {
manageLocalVaultUseCase.localStorages.combine(getOpenedStoragesUseCase.openedStorages) { local, opened ->
if (local == null) {
return@combine Pair(true, emptyList<Tree<IStorageInfo>>())
}
localStoragesFlow.combine(getOpenedStoragesUseCase.openedStorages) { local, opened ->
val list = mutableListOf<Tree<IStorageInfo>>()
for (storage in local) {
var tree = Tree(storage)
var tree = Tree<IStorageInfo>(storage)
list.add(tree)
while (opened.containsKey(tree.value.uuid)) {
val child = opened.getValue(tree.value.uuid)
val nextTree = Tree(child)
val nextTree = Tree<IStorageInfo>(child)
tree.children = listOf(nextTree)
tree = nextTree
}
}
return@combine Pair(false, list)
}.collect { (loading, trees) ->
isLoading = loading
list
}.collect { trees ->
isLoading = false
updateState(state.value.copy(storagesList = trees))
}
}
@@ -127,7 +140,9 @@ class LocalVaultViewModel @Inject constructor(
dispatcher = Dispatchers.IO,
work = { ctx ->
ctx.log(TaskLogLevel.Info, "Creating storage…")
manageLocalVaultUseCase.createStorage()
val uuid = localVaultUuid
?: throw IllegalStateException("Local vault is not registered")
manageVaultUseCase.createStorage(uuid)
ctx.log(TaskLogLevel.Info, "Storage created")
},
)

View File

@@ -44,9 +44,9 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.runtime.getValue
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.domain.auth.RemoteYandexAuthResult
import com.github.nullptroma.wallenc.domain.enums.VaultType
import com.github.nullptroma.wallenc.presentation.R
import com.github.nullptroma.wallenc.vaultapi.CloudBrand
import com.github.nullptroma.wallenc.vaultapi.VaultLinkOutcome
@Composable
fun RemoteVaultsScreen(
@@ -114,10 +114,9 @@ fun RemoteVaultsScreen(
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = when (item.type) {
VaultType.YANDEX ->
text = when (item.brand) {
CloudBrand.YANDEX ->
stringResource(R.string.remote_vault_type_yandex)
else -> item.type.name
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -172,14 +171,14 @@ fun RemoteVaultsScreen(
FilledTonalButton(
onClick = {
viewModel.setAddChoiceVisible(false)
viewModel.yandexSignIn.launch { outcome ->
viewModel.remoteAuthenticator.beginLink(CloudBrand.YANDEX) { outcome ->
when (outcome) {
is RemoteYandexAuthResult.Success ->
viewModel.onYandexAuthSuccess(outcome.accessToken)
is RemoteYandexAuthResult.Failure ->
is VaultLinkOutcome.Success ->
viewModel.onLinkSucceeded(outcome.registration)
is VaultLinkOutcome.Failed ->
Toast.makeText(context, outcome.message, Toast.LENGTH_LONG)
.show()
RemoteYandexAuthResult.Cancelled -> { }
VaultLinkOutcome.Cancelled -> { }
}
}
},

View File

@@ -1,11 +1,11 @@
package com.github.nullptroma.wallenc.presentation.screens.main.screens.remotes
import com.github.nullptroma.wallenc.domain.enums.VaultType
import com.github.nullptroma.wallenc.vaultapi.CloudBrand
import java.util.UUID
data class RemoteVaultListItem(
val uuid: UUID,
val type: VaultType,
val brand: CloudBrand,
val label: String,
)
@@ -13,6 +13,5 @@ data class RemoteVaultsScreenState(
val vaults: List<RemoteVaultListItem> = emptyList(),
val isBusy: Boolean = false,
val addChoiceVisible: Boolean = false,
/** Карточка, для которой показан диалог удаления */
val vaultPendingDelete: RemoteVaultListItem? = null,
)
)

View File

@@ -1,12 +1,16 @@
package com.github.nullptroma.wallenc.presentation.screens.main.screens.remotes
import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.auth.RemoteYandexSignInLauncher
import com.github.nullptroma.wallenc.domain.interfaces.IYandexVault
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import com.github.nullptroma.wallenc.presentation.ViewModelBase
import com.github.nullptroma.wallenc.vaultapi.RemoteVaultAuthenticator
import com.github.nullptroma.wallenc.vaultapi.VaultDescriptor
import com.github.nullptroma.wallenc.vaultapi.VaultRegistrar
import com.github.nullptroma.wallenc.vaultapi.VaultRegistration
import com.github.nullptroma.wallenc.vaultapi.described
import com.github.nullptroma.wallenc.vaultapi.remotes
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
@@ -18,24 +22,22 @@ import javax.inject.Inject
@HiltViewModel
class RemoteVaultsViewModel @Inject constructor(
private val vaultsManager: IVaultsManager,
val yandexSignIn: RemoteYandexSignInLauncher,
private val vaultRegistrar: VaultRegistrar,
val remoteAuthenticator: RemoteVaultAuthenticator,
private val taskOrchestrator: ITaskOrchestrator,
) : ViewModelBase<RemoteVaultsScreenState>(RemoteVaultsScreenState()) {
val uiState = combine(
vaultsManager.remoteVaults,
vaultsManager.vaults,
state,
) { remotes, base ->
) { all, base ->
base.copy(
vaults = remotes.map { v ->
val label = when (v) {
is IYandexVault -> v.accountEmail
else -> v.uuid.toString()
}
vaults = all.described().remotes.mapNotNull { v ->
val descriptor = v.descriptor as? VaultDescriptor.LinkedRemote ?: return@mapNotNull null
RemoteVaultListItem(
uuid = v.uuid,
type = v.type,
label = label,
uuid = descriptor.uuid,
brand = descriptor.brand,
label = descriptor.accountDisplayName,
)
},
)
@@ -53,15 +55,15 @@ class RemoteVaultsViewModel @Inject constructor(
updateState(state.value.copy(isBusy = busy))
}
fun onYandexAuthSuccess(accessToken: String) {
fun onLinkSucceeded(registration: VaultRegistration) {
setBusy(true)
taskOrchestrator.enqueue(
title = "Add Yandex vault",
title = "Add remote vault",
dispatcher = Dispatchers.IO,
work = { ctx ->
try {
ctx.log(TaskLogLevel.Info, "Adding vault…")
vaultsManager.addYandexVault(accessToken)
vaultRegistrar.register(registration)
ctx.log(TaskLogLevel.Info, "Vault added")
} catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Failed to add vault")
@@ -93,7 +95,7 @@ class RemoteVaultsViewModel @Inject constructor(
work = { ctx ->
try {
ctx.log(TaskLogLevel.Info, "Removing remote vault…")
vaultsManager.removeRemoteVault(uuid)
vaultRegistrar.unregister(uuid)
ctx.log(TaskLogLevel.Info, "Remote vault removed")
} catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Failed to remove vault")

View File

@@ -27,3 +27,4 @@ include(":app")
include(":data")
include(":domain")
include(":presentation")
include(":vault-api")

View File

@@ -0,0 +1,23 @@
import org.gradle.api.file.DuplicatesStrategy
import org.gradle.api.tasks.bundling.Jar
plugins {
id("java-library")
alias(libs.plugins.jetbrains.kotlin.jvm)
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
tasks.withType<Jar>().configureEach {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
dependencies {
implementation(project(":domain"))
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.junit)
}

View File

@@ -0,0 +1,11 @@
package com.github.nullptroma.wallenc.vaultapi
/**
* Поддерживаемые облачные провайдеры удалённых vault'ов.
*
* Бренд «локального» устройства тут не указывается — для дискриминации UI
* используется [VaultDescriptor.LocalDevice] vs [VaultDescriptor.LinkedRemote].
*/
enum class CloudBrand {
YANDEX,
}

View File

@@ -0,0 +1,14 @@
package com.github.nullptroma.wallenc.vaultapi
import com.github.nullptroma.wallenc.domain.interfaces.IVault
/**
* Vault, дополнительно знающий свою категорию для UI/маршрутизации.
*
* Все реализации vault'ов в `:data` обязаны быть [DescribedVault] —
* это единственный способ для presentation/app узнать, что это за vault,
* не имея зависимости от `:data`.
*/
interface DescribedVault : IVault {
val descriptor: VaultDescriptor
}

View File

@@ -0,0 +1,20 @@
package com.github.nullptroma.wallenc.vaultapi
/**
* Запуск OAuth-сценария привязки удалённого vault'а для конкретного [CloudBrand].
*
* Реализация в `:app` (привязана к Activity). presentation вызывает [beginLink]
* из контекста Compose-экрана и получает [VaultLinkOutcome] асинхронно.
*
* Намеренно императивный API через колбэк, чтобы корректно встроиться
* в `ActivityResultLauncher` Yandex Auth SDK без удержания Activity синглтоном.
*/
fun interface RemoteVaultAuthenticator {
/**
* Начать сценарий линка для бренда [brand].
*
* Если бренд не поддерживается — реализация должна синхронно вызвать
* `onResult(VaultLinkOutcome.Failed(...))`, не бросая исключение.
*/
fun beginLink(brand: CloudBrand, onResult: (VaultLinkOutcome) -> Unit)
}

View File

@@ -0,0 +1,26 @@
package com.github.nullptroma.wallenc.vaultapi
import com.github.nullptroma.wallenc.domain.interfaces.IVaultInfo
import java.util.UUID
/**
* Категория vault'а для UI и ветвления — sealed-иерархия в одном модуле,
* чтобы `when` был exhaustive.
*
* `domain` про эти подтипы ничего не знает; внешнее кольцо (`:vault-api`)
* расширяет [IVaultInfo] (только `uuid`) до структуры с дополнительной
* категоризацией.
*/
sealed interface VaultDescriptor : IVaultInfo {
/** Vault, физически живущий на устройстве; без привязки к облачному аккаунту. */
data class LocalDevice(
override val uuid: UUID,
) : VaultDescriptor
/** Vault, привязанный к облачному аккаунту через OAuth. */
data class LinkedRemote(
override val uuid: UUID,
val brand: CloudBrand,
val accountDisplayName: String,
) : VaultDescriptor
}

View File

@@ -0,0 +1,18 @@
package com.github.nullptroma.wallenc.vaultapi
/**
* Результат сценария OAuth-линка нового удалённого vault'а.
*
* presentation сводит это к: успех → отдать `registration` в [VaultRegistrar.register];
* cancel → ничего не делать; failure → показать сообщение.
*/
sealed interface VaultLinkOutcome {
/** Пользователь успешно вошёл; в [registration] лежат данные для регистрации. */
data class Success(val registration: VaultRegistration) : VaultLinkOutcome
/** Пользователь отменил вход. */
data object Cancelled : VaultLinkOutcome
/** Ошибка SDK/сети/сервера; [message] годится для показа пользователю. */
data class Failed(val message: String) : VaultLinkOutcome
}

View File

@@ -0,0 +1,19 @@
package com.github.nullptroma.wallenc.vaultapi
import java.util.UUID
/**
* Контракт регистрации/удаления удалённого vault'а.
* Реализуется тем же объектом, что и `domain.IVaultsManager`.
*/
interface VaultRegistrar {
/**
* Зарегистрировать новый удалённый vault по «полезной нагрузке» из
* [VaultLinkOutcome.Success]. Если тип [registration] неизвестен реализации,
* бросает [IllegalArgumentException].
*/
suspend fun register(registration: VaultRegistration)
/** Удалить ранее зарегистрированный vault по идентификатору. */
suspend fun unregister(vaultUuid: UUID)
}

View File

@@ -0,0 +1,14 @@
package com.github.nullptroma.wallenc.vaultapi
/**
* Маркер «полезной нагрузки» для регистрации удалённого vault'а через [VaultRegistrar].
*
* Намеренно НЕ sealed: конкретные реализации (`YandexRegistration`, …) живут в `:data`
* рядом с соответствующими реализациями vault'а, чтобы `:data` не разнесёшь по
* нескольким модулям без необходимости. Цена — отсутствие exhaustive-when через
* границу модуля, лечится fail-fast веткой `else` в `VaultsManager.register(...)`.
*
* presentation/app никогда не «открывают» этот тип — они только перепасовывают
* объект из [VaultLinkOutcome.Success] в [VaultRegistrar.register].
*/
interface VaultRegistration

View File

@@ -0,0 +1,22 @@
package com.github.nullptroma.wallenc.vaultapi
import com.github.nullptroma.wallenc.domain.interfaces.IVault
/**
* Хелперы фильтрации списка vault'ов по [VaultDescriptor].
*
* Все реализации vault'а обязаны быть [DescribedVault];
* [described] отбрасывает те, что не соответствуют контракту (на случай
* вспомогательных тест-двойников и т.п.).
*/
fun List<IVault>.described(): List<DescribedVault> = filterIsInstance<DescribedVault>()
val List<DescribedVault>.locals: List<DescribedVault>
get() = filter { it.descriptor is VaultDescriptor.LocalDevice }
val List<DescribedVault>.remotes: List<DescribedVault>
get() = filter { it.descriptor is VaultDescriptor.LinkedRemote }
fun List<DescribedVault>.byBrand(brand: CloudBrand): List<DescribedVault> = filter {
(it.descriptor as? VaultDescriptor.LinkedRemote)?.brand == brand
}