diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 635b2e7..c047967 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -77,4 +77,5 @@ dependencies { implementation(project(":domain")) implementation(project(":data")) implementation(project(":presentation")) + implementation(project(":vault-api")) } \ No newline at end of file diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/auth/YandexSignInService.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/auth/YandexSignInService.kt index e50c076..d4ec7f3 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/auth/YandexSignInService.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/auth/YandexSignInService.kt @@ -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 } } diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/auth/YandexAuthModule.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/auth/YandexAuthModule.kt index 7f78c45..78a94fc 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/auth/YandexAuthModule.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/auth/YandexAuthModule.kt @@ -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 } diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/SingletonModule.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/SingletonModule.kt index 629fa7c..fe93efb 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/SingletonModule.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/SingletonModule.kt @@ -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 diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/domain/UseCasesModule.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/domain/UseCasesModule.kt index e8e9470..815d3a5 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/domain/UseCasesModule.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/domain/UseCasesModule.kt @@ -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 diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 51867d5..956896c 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -54,4 +54,5 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) implementation(project(":domain")) + implementation(project(":vault-api")) } \ No newline at end of file diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/auth/YandexOAuthScopes.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/auth/YandexOAuthScopes.kt new file mode 100644 index 0000000..6be78f8 --- /dev/null +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/auth/YandexOAuthScopes.kt @@ -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 = setOf(DISK_APP_FOLDER, DISK_INFO) +} diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/VaultsManager.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/VaultsManager.kt index 275c71d..ddad8af 100644 --- a/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/VaultsManager.kt +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/VaultsManager.kt @@ -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> = 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> = yandexAccountRepository.observeAll() .map { rows -> rows.map { row -> YandexVault( @@ -52,33 +54,51 @@ class VaultsManager( } .stateIn(scope, SharingStarted.Eagerly, emptyList()) - override val remoteVaults: StateFlow> = _remoteVaults - - override val allVaults: StateFlow> = _remoteVaults - .map { listOf(localVault) + it } + override val vaults: StateFlow> = 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> = 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()) - } } diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/YandexVault.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/YandexVault.kt deleted file mode 100644 index f92ac97..0000000 --- a/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/YandexVault.kt +++ /dev/null @@ -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>(emptyList()) - override val storages: StateFlow?> = _storages - - private val _isAvailable = MutableStateFlow(true) - override val isAvailable: StateFlow = _isAvailable - - private val _totalSpace = MutableStateFlow(null) - override val totalSpace: StateFlow = _totalSpace - - private val _availableSpace = MutableStateFlow(null) - override val availableSpace: StateFlow = _availableSpace - - override suspend fun createStorage(): IStorage { - throw UnsupportedOperationException("Yandex.Disk ещё не подключён") - } - - override suspend fun createStorage(enc: StorageEncryptionInfo): IStorage { - throw UnsupportedOperationException("Yandex.Disk ещё не подключён") - } - - override suspend fun remove(storage: IStorage) { - // заглушка до интеграции API Диска - } -} diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/LocalVault.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/local/LocalVault.kt similarity index 77% rename from data/src/main/java/com/github/nullptroma/wallenc/data/vaults/LocalVault.kt rename to data/src/main/java/com/github/nullptroma/wallenc/data/vaults/local/LocalVault.kt index 55dda56..4c00ca7 100644 --- a/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/LocalVault.kt +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/local/LocalVault.kt @@ -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()) + override val uuid: UUID = idStore.getOrCreate() + + override val descriptor: VaultDescriptor = VaultDescriptor.LocalDevice(uuid) + + private val _storages = MutableStateFlow>(emptyList()) override val storages: StateFlow> = _storages private val _isAvailable = MutableStateFlow(false) override val isAvailable: StateFlow = _isAvailable - private val _totalSpace = MutableStateFlow(null) + private val _totalSpace = MutableStateFlow(null) override val totalSpace: StateFlow = _totalSpace - private val _availableSpace = MutableStateFlow(null) + private val _availableSpace = MutableStateFlow(null) override val availableSpace: StateFlow = _availableSpace private val path = MutableStateFlow(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() } } -} \ No newline at end of file +} diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/local/LocalVaultIdStore.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/local/LocalVaultIdStore.kt new file mode 100644 index 0000000..0ee1d64 --- /dev/null +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/local/LocalVaultIdStore.kt @@ -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" + } +} diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/yandex/YandexRegistration.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/yandex/YandexRegistration.kt new file mode 100644 index 0000000..09e9c1f --- /dev/null +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/yandex/YandexRegistration.kt @@ -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 +} diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/yandex/YandexVault.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/yandex/YandexVault.kt new file mode 100644 index 0000000..df9bfb5 --- /dev/null +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/vaults/yandex/YandexVault.kt @@ -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>(emptyList()) + override val storages: StateFlow> = _storages + + private val _isAvailable = MutableStateFlow(true) + override val isAvailable: StateFlow = _isAvailable + + private val _totalSpace = MutableStateFlow(null) + override val totalSpace: StateFlow = _totalSpace + + private val _availableSpace = MutableStateFlow(null) + override val availableSpace: StateFlow = _availableSpace + + override suspend fun createStorage(): IStorage = + throw UnsupportedOperationException("Yandex.Disk REST integration is not connected yet") + + 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 Диска. + } +} diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/auth/RemoteYandexAuth.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/auth/RemoteYandexAuth.kt deleted file mode 100644 index 88d533f..0000000 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/auth/RemoteYandexAuth.kt +++ /dev/null @@ -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) -} diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/enums/VaultType.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/enums/VaultType.kt deleted file mode 100644 index c31aaf4..0000000 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/enums/VaultType.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.github.nullptroma.wallenc.domain.enums - -enum class VaultType { - LOCAL, - DECRYPTED, - YANDEX -} \ No newline at end of file diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVault.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVault.kt index 9ccbd83..b398670 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVault.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVault.kt @@ -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?> + val storages: StateFlow> + val isAvailable: StateFlow + val totalSpace: StateFlow + val availableSpace: StateFlow suspend fun createStorage(): IStorage suspend fun createStorage(enc: StorageEncryptionInfo): IStorage suspend fun remove(storage: IStorage) -} \ No newline at end of file +} diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVaultInfo.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVaultInfo.kt index 03b4ee0..8674868 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVaultInfo.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVaultInfo.kt @@ -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?> - val isAvailable: StateFlow - val totalSpace: StateFlow - val availableSpace: StateFlow -} \ No newline at end of file +} diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVaultsManager.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVaultsManager.kt index 4641534..6268bec 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVaultsManager.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVaultsManager.kt @@ -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> + val vaults: StateFlow> val allStorages: StateFlow> - val allVaults: StateFlow> - suspend fun addYandexVault(accessToken: String) - - suspend fun removeRemoteVault(vaultUuid: UUID) -} \ No newline at end of file + val unlockManager: IUnlockManager +} diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IYandexVault.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IYandexVault.kt deleted file mode 100644 index 9a18f52..0000000 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IYandexVault.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.github.nullptroma.wallenc.domain.interfaces - -/** - * Удалённый vault Яндекс с привязанным аккаунтом (почта для UI). - */ -interface IYandexVault : IVault { - val accountEmail: String -} diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/usecases/ManageLocalVaultUseCase.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/usecases/ManageLocalVaultUseCase.kt deleted file mode 100644 index 9fa9b65..0000000 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/usecases/ManageLocalVaultUseCase.kt +++ /dev/null @@ -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?> - get() = manager.localVault.storages - - suspend fun createStorage() { - manager.localVault.createStorage() - } -} \ No newline at end of file diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/usecases/ManageVaultUseCase.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/usecases/ManageVaultUseCase.kt new file mode 100644 index 0000000..a386275 --- /dev/null +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/usecases/ManageVaultUseCase.kt @@ -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 = + manager.vaults.map { list -> list.firstOrNull { it.uuid == vaultUuid } } + + /** Реактивно отдаёт storages указанного vault'а; пустой список, если vault не найден. */ + fun storagesOf(vaultUuid: UUID): Flow> = + 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() + } +} diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/usecases/RemoveStorageUseCase.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/usecases/RemoveStorageUseCase.kt index 94719b4..1e15854 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/usecases/RemoveStorageUseCase.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/usecases/RemoveStorageUseCase.kt @@ -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] } - } diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index eed7ce8..cfabcc0 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -79,4 +79,5 @@ dependencies { androidTestImplementation(libs.androidx.ui.test.junit4) implementation(project(":domain")) + implementation(project(":vault-api")) } \ No newline at end of file diff --git a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultViewModel.kt b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultViewModel.kt index b1d7ff8..f0746a3 100644 --- a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultViewModel.kt +++ b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultViewModel.kt @@ -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(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() val messages: SharedFlow = _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>()) - } + localStoragesFlow.combine(getOpenedStoragesUseCase.openedStorages) { local, opened -> val list = mutableListOf>() for (storage in local) { - var tree = Tree(storage) + var tree = Tree(storage) list.add(tree) while (opened.containsKey(tree.value.uuid)) { val child = opened.getValue(tree.value.uuid) - val nextTree = Tree(child) + val nextTree = Tree(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") }, ) diff --git a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsScreen.kt b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsScreen.kt index c5a79ca..22604d2 100644 --- a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsScreen.kt +++ b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsScreen.kt @@ -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 -> { } } } }, diff --git a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsScreenState.kt b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsScreenState.kt index 250922e..9cecb41 100644 --- a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsScreenState.kt +++ b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsScreenState.kt @@ -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 = emptyList(), val isBusy: Boolean = false, val addChoiceVisible: Boolean = false, - /** Карточка, для которой показан диалог удаления */ val vaultPendingDelete: RemoteVaultListItem? = null, -) \ No newline at end of file +) diff --git a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsViewModel.kt b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsViewModel.kt index 9d02306..9158206 100644 --- a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsViewModel.kt +++ b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/remotes/RemoteVaultsViewModel.kt @@ -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()) { 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") diff --git a/settings.gradle.kts b/settings.gradle.kts index 87f3f18..5fe403e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -27,3 +27,4 @@ include(":app") include(":data") include(":domain") include(":presentation") +include(":vault-api") diff --git a/vault-api/build.gradle.kts b/vault-api/build.gradle.kts new file mode 100644 index 0000000..1d68a7c --- /dev/null +++ b/vault-api/build.gradle.kts @@ -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().configureEach { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +dependencies { + implementation(project(":domain")) + implementation(libs.kotlinx.coroutines.core) + testImplementation(libs.junit) +} diff --git a/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/CloudBrand.kt b/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/CloudBrand.kt new file mode 100644 index 0000000..a02b7f4 --- /dev/null +++ b/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/CloudBrand.kt @@ -0,0 +1,11 @@ +package com.github.nullptroma.wallenc.vaultapi + +/** + * Поддерживаемые облачные провайдеры удалённых vault'ов. + * + * Бренд «локального» устройства тут не указывается — для дискриминации UI + * используется [VaultDescriptor.LocalDevice] vs [VaultDescriptor.LinkedRemote]. + */ +enum class CloudBrand { + YANDEX, +} diff --git a/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/DescribedVault.kt b/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/DescribedVault.kt new file mode 100644 index 0000000..60a08e0 --- /dev/null +++ b/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/DescribedVault.kt @@ -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 +} diff --git a/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/RemoteVaultAuthenticator.kt b/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/RemoteVaultAuthenticator.kt new file mode 100644 index 0000000..36206ed --- /dev/null +++ b/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/RemoteVaultAuthenticator.kt @@ -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) +} diff --git a/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/VaultDescriptor.kt b/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/VaultDescriptor.kt new file mode 100644 index 0000000..2d6559c --- /dev/null +++ b/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/VaultDescriptor.kt @@ -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 +} diff --git a/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/VaultLinkOutcome.kt b/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/VaultLinkOutcome.kt new file mode 100644 index 0000000..573838b --- /dev/null +++ b/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/VaultLinkOutcome.kt @@ -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 +} diff --git a/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/VaultRegistrar.kt b/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/VaultRegistrar.kt new file mode 100644 index 0000000..cb9a2f8 --- /dev/null +++ b/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/VaultRegistrar.kt @@ -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) +} diff --git a/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/VaultRegistration.kt b/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/VaultRegistration.kt new file mode 100644 index 0000000..334525d --- /dev/null +++ b/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/VaultRegistration.kt @@ -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 diff --git a/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/Vaults.kt b/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/Vaults.kt new file mode 100644 index 0000000..e566fc1 --- /dev/null +++ b/vault-api/src/main/java/com/github/nullptroma/wallenc/vaultapi/Vaults.kt @@ -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.described(): List = filterIsInstance() + +val List.locals: List + get() = filter { it.descriptor is VaultDescriptor.LocalDevice } + +val List.remotes: List + get() = filter { it.descriptor is VaultDescriptor.LinkedRemote } + +fun List.byBrand(brand: CloudBrand): List = filter { + (it.descriptor as? VaultDescriptor.LinkedRemote)?.brand == brand +}