Большой рефакторинг
Из domain выкинуты типы vault, теперь он ничего не знает о Yandex. Объявления провайдеров вынесены в vault-api, а реализации в data
This commit is contained in:
@@ -77,4 +77,5 @@ dependencies {
|
||||
implementation(project(":domain"))
|
||||
implementation(project(":data"))
|
||||
implementation(project(":presentation"))
|
||||
implementation(project(":vault-api"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -54,4 +54,5 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
|
||||
implementation(project(":domain"))
|
||||
implementation(project(":vault-api"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 Диска
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 Диска.
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.github.nullptroma.wallenc.domain.enums
|
||||
|
||||
enum class VaultType {
|
||||
LOCAL,
|
||||
DECRYPTED,
|
||||
YANDEX
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
package com.github.nullptroma.wallenc.domain.interfaces
|
||||
|
||||
/**
|
||||
* Удалённый vault Яндекс с привязанным аккаунтом (почта для UI).
|
||||
*/
|
||||
interface IYandexVault : IVault {
|
||||
val accountEmail: String
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -79,4 +79,5 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
|
||||
implementation(project(":domain"))
|
||||
implementation(project(":vault-api"))
|
||||
}
|
||||
@@ -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")
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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 -> { }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -27,3 +27,4 @@ include(":app")
|
||||
include(":data")
|
||||
include(":domain")
|
||||
include(":presentation")
|
||||
include(":vault-api")
|
||||
|
||||
23
vault-api/build.gradle.kts
Normal file
23
vault-api/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.github.nullptroma.wallenc.vaultapi
|
||||
|
||||
/**
|
||||
* Поддерживаемые облачные провайдеры удалённых vault'ов.
|
||||
*
|
||||
* Бренд «локального» устройства тут не указывается — для дискриминации UI
|
||||
* используется [VaultDescriptor.LocalDevice] vs [VaultDescriptor.LinkedRemote].
|
||||
*/
|
||||
enum class CloudBrand {
|
||||
YANDEX,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user