From 555448d9989a71f0fa62be117f4ffc5f5d33f2cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=8B=D1=82=D0=BA=D0=BE=D0=B2=20=D0=A0=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD?= Date: Sun, 17 May 2026 11:54:02 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20UI/UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../encrypt/EncryptedStorageAccessor.kt | 10 +- .../storages/local/LocalStorageAccessor.kt | 10 +- .../storages/yandex/YandexStorageAccessor.kt | 10 +- .../wallenc/ui/screens/main/MainScreen.kt | 15 ++- .../main/screens/storage/StorageHomeScreen.kt | 105 +++++++++++------- .../screens/storage/StorageHomeViewModel.kt | 28 +++-- .../secrets/TextSecretDetailsScreen.kt | 30 ++++- .../secrets/TextSecretDetailsViewModel.kt | 23 ++-- .../storage/secrets/TextSecretsScreen.kt | 60 +++++++--- .../storage/secrets/TextSecretsViewModel.kt | 20 ++-- .../storage/twofa/TwoFaTokensScreen.kt | 79 ++++++++++--- .../storage/twofa/TwoFaTokensViewModel.kt | 24 ++-- ui/src/main/res/values/strings.xml | 2 + .../usecases/ManageTextSecretsUseCase.kt | 16 ++- .../usecases/ManageTwoFaTokensUseCase.kt | 16 ++- .../wallenc/usecases/StorageSyncEngine.kt | 6 +- .../usecases/StorageDomainUseCasesTest.kt | 15 ++- 17 files changed, 330 insertions(+), 139 deletions(-) diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/encrypt/EncryptedStorageAccessor.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/encrypt/EncryptedStorageAccessor.kt index 979c6cb..a721eea 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/encrypt/EncryptedStorageAccessor.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/encrypt/EncryptedStorageAccessor.kt @@ -351,13 +351,18 @@ class EncryptedStorageAccessor( override suspend fun tryAcquireSyncLock(holderId: String, leaseUntil: Instant): Boolean { return syncLockMutex.withLock { val current = readSyncLock() - if (current != null && current.holderId != holderId) { + val now = Instant.now() + val foreignLockActive = current != null && + current.holderId != holderId && + current.leaseUntil.isAfter(now) && + current.updatedAt.plusSeconds(SYNC_LOCK_STALE_TIMEOUT_SECONDS).isAfter(now) + if (foreignLockActive) { return@withLock false } val next = StorageSyncLock( holderId = holderId, leaseUntil = leaseUntil, - updatedAt = Instant.now(), + updatedAt = now, ) openWriteSystemFile(SYNC_LOCK_FILENAME).use { out -> jackson.writeValue(out, next) @@ -424,6 +429,7 @@ class EncryptedStorageAccessor( private companion object { private const val SYNC_JOURNAL_FILENAME = "sync-journal.json" private const val SYNC_LOCK_FILENAME = "sync-lock.json" + private const val SYNC_LOCK_STALE_TIMEOUT_SECONDS: Long = 60 * 60 private val jackson = com.fasterxml.jackson.module.kotlin.jacksonObjectMapper() .apply { findAndRegisterModules() } } diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/local/LocalStorageAccessor.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/local/LocalStorageAccessor.kt index e7ca253..c545c62 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/local/LocalStorageAccessor.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/local/LocalStorageAccessor.kt @@ -627,7 +627,12 @@ class LocalStorageAccessor( val fileLock = channel.lock() try { val current = readSyncLockFromChannel(channel) - if (current != null && current.holderId != holderId) { + val now = Instant.now() + val foreignLockActive = current != null && + current.holderId != holderId && + current.leaseUntil.isAfter(now) && + current.updatedAt.plusSeconds(SYNC_LOCK_STALE_TIMEOUT_SECONDS).isAfter(now) + if (foreignLockActive) { return@withContext false } @@ -636,7 +641,7 @@ class LocalStorageAccessor( lock = StorageSyncLock( holderId = holderId, leaseUntil = leaseUntil, - updatedAt = Instant.now(), + updatedAt = now, ), ) return@withContext true @@ -742,6 +747,7 @@ class LocalStorageAccessor( private const val DATA_PAGE_LENGTH = 10 private const val SYNC_JOURNAL_FILENAME = "sync-journal.json" private const val SYNC_LOCK_FILENAME = "sync-lock.json" + private const val SYNC_LOCK_STALE_TIMEOUT_SECONDS: Long = 60 * 60 private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() } } } \ No newline at end of file diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/yandex/YandexStorageAccessor.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/yandex/YandexStorageAccessor.kt index 92249db..40d7c20 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/yandex/YandexStorageAccessor.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/yandex/YandexStorageAccessor.kt @@ -640,13 +640,18 @@ class YandexStorageAccessor( override suspend fun tryAcquireSyncLock(holderId: String, leaseUntil: Instant): Boolean = withContext(ioDispatcher) { return@withContext syncLockMutex.withLock { val current = readSyncLock() - if (current != null && current.holderId != holderId) { + val now = Instant.now() + val foreignLockActive = current != null && + current.holderId != holderId && + current.leaseUntil.isAfter(now) && + current.updatedAt.plusSeconds(SYNC_LOCK_STALE_TIMEOUT_SECONDS).isAfter(now) + if (foreignLockActive) { return@withLock false } val next = StorageSyncLock( holderId = holderId, leaseUntil = leaseUntil, - updatedAt = Instant.now(), + updatedAt = now, ) openWriteSystemFile(SYNC_LOCK_FILENAME).use { out -> statsMapper.writeValue(out, next) @@ -716,6 +721,7 @@ class YandexStorageAccessor( private const val STATS_FILENAME = "yandex-vault-stats.json" private const val SYNC_JOURNAL_FILENAME = "sync-journal.json" private const val SYNC_LOCK_FILENAME = "sync-lock.json" + private const val SYNC_LOCK_STALE_TIMEOUT_SECONDS: Long = 60 * 60 private const val STATS_DEBOUNCE_MS = 450L private const val DATA_PAGE_LENGTH = 10 private const val API_LIST_LIMIT = 1000 diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainScreen.kt index a181cc4..f503f0d 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainScreen.kt @@ -254,13 +254,16 @@ fun MainScreen( val route: TextSecretEditRoute = entry.toRoute() TextSecretEditScreen( onSaved = { savedSecretId -> + val editingExisting = route.secretId != null navState.navHostController.popBackStack() - navState.push( - TextSecretDetailsRoute( - storageUuid = route.storageUuid, - secretId = savedSecretId, - ), - ) + if (!editingExisting) { + navState.push( + TextSecretDetailsRoute( + storageUuid = route.storageUuid, + secretId = savedSecretId, + ), + ) + } }, ) } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt index dab33ef..737f1ff 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt @@ -2,20 +2,26 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.storage import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Notes import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -83,45 +89,21 @@ fun StorageHomeScreen( ) } - Card(colors = CardDefaults.cardColors()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(R.string.storage_home_two_fa_title, uiState.twoFaCount), - style = MaterialTheme.typography.titleMedium, - ) - Button( - onClick = { onOpenTwoFa(uiState.storageUuid) }, - enabled = uiState.isAvailable, - ) { - Text(stringResource(R.string.storage_home_open_two_fa)) - } - } - } + StorageSectionCard( + title = stringResource(R.string.storage_home_two_fa_title, uiState.twoFaCount), + description = stringResource(R.string.storage_home_two_fa_subtitle), + icon = Icons.Outlined.Lock, + enabled = uiState.isAvailable, + onClick = { onOpenTwoFa(uiState.storageUuid) }, + ) - Card(colors = CardDefaults.cardColors()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(R.string.storage_home_text_secrets_title, uiState.textSecretsCount), - style = MaterialTheme.typography.titleMedium, - ) - Button( - onClick = { onOpenTextSecrets(uiState.storageUuid) }, - enabled = uiState.isAvailable, - ) { - Text(stringResource(R.string.storage_home_open_text_secrets)) - } - } - } + StorageSectionCard( + title = stringResource(R.string.storage_home_text_secrets_title, uiState.textSecretsCount), + description = stringResource(R.string.storage_home_text_secrets_subtitle), + icon = Icons.Outlined.Notes, + enabled = uiState.isAvailable, + onClick = { onOpenTextSecrets(uiState.storageUuid) }, + ) Text( text = stringResource(R.string.storage_home_future_sections), @@ -131,3 +113,48 @@ fun StorageHomeScreen( } } } + +@Composable +private fun StorageSectionCard( + title: String, + description: String, + icon: ImageVector, + enabled: Boolean, + onClick: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + onClick = onClick, + enabled = enabled, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeViewModel.kt index 2892f64..51def79 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeViewModel.kt @@ -7,7 +7,7 @@ import com.github.nullptroma.wallenc.usecases.FindStorageUseCase import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase import com.github.nullptroma.wallenc.usecases.ManageTwoFaTokensUseCase import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import javax.inject.Inject @@ -22,10 +22,10 @@ class StorageHomeViewModel @Inject constructor( private val storageUuid = savedStateHandle.requireStorageUuid() init { - refresh() + observeStorageState() } - fun refresh() { + private fun observeStorageState() { viewModelScope.launch { val storage = findStorageUseCase.find(storageUuid) if (storage == null) { @@ -38,21 +38,25 @@ class StorageHomeViewModel @Inject constructor( ) return@launch } - - val twoFa = manageTwoFaTokensUseCase.list(storage) - val secrets = manageTextSecretsUseCase.list(storage) - updateState( + combine( + storage.isAvailable, + storage.metaInfo, + manageTwoFaTokensUseCase.observe(storage), + manageTextSecretsUseCase.observe(storage), + ) { available, meta, twoFa, secrets -> state.value.copy( isLoading = false, storageUuid = storage.uuid.toString(), - storageName = storage.metaInfo.value.name.orEmpty(), - isAvailable = storage.isAvailable.first(), - isEncrypted = storage.metaInfo.value.encInfo != null, + storageName = meta.name.orEmpty(), + isAvailable = available, + isEncrypted = meta.encInfo != null, twoFaCount = twoFa.size, textSecretsCount = secrets.size, errorMessage = null, - ), - ) + ) + }.collect { ui -> + updateState(ui) + } } } } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreen.kt index 21fc0dd..0ae9927 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreen.kt @@ -10,6 +10,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -64,12 +66,28 @@ fun TextSecretDetailsScreen( LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { items(secret.items) { item -> - Column { - Text( - text = item.label ?: stringResource(R.string.text_secret_item_without_label), - style = MaterialTheme.typography.labelMedium, - ) - Text(text = item.value) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = item.label ?: stringResource(R.string.text_secret_item_without_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = item.value, + style = MaterialTheme.typography.bodyLarge, + ) + } } } } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsViewModel.kt index 6be885c..3ae96eb 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsViewModel.kt @@ -7,7 +7,8 @@ import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStor import com.github.nullptroma.wallenc.usecases.FindStorageUseCase import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import javax.inject.Inject @@ -23,10 +24,10 @@ class TextSecretDetailsViewModel @Inject constructor( ?: error("Missing secret id") init { - refresh() + observeSecret() } - fun refresh() { + private fun observeSecret() { viewModelScope.launch { val storage = findStorageUseCase.find(storageUuid) if (storage == null) { @@ -38,15 +39,21 @@ class TextSecretDetailsViewModel @Inject constructor( ) return@launch } - val secret = manageTextSecretsUseCase.get(storage, secretId) - updateState( + combine( + storage.isAvailable, + manageTextSecretsUseCase.observe(storage).map { list -> + list.firstOrNull { it.id == secretId } + }, + ) { available, secret -> state.value.copy( isLoading = false, - isAvailable = storage.isAvailable.first(), + isAvailable = available, secret = secret, errorMessage = if (secret == null) "Secret not found" else null, - ), - ) + ) + }.collect { ui -> + updateState(ui) + } } } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreen.kt index 49974d8..7e5897d 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreen.kt @@ -7,10 +7,15 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.outlined.Notes +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -68,28 +73,51 @@ fun TextSecretsScreen( } else { LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { items(uiState.items) { secret -> - Row( + Card( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + onClick = { onOpenSecret(secret) }, + enabled = uiState.isAvailable, + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), ) { - Column( + Row( modifier = Modifier - .weight(1f) - .padding(end = 12.dp), + .fillMaxWidth() + .padding(14.dp), + horizontalArrangement = Arrangement.SpaceBetween, ) { - Text(text = secret.title) - Text( - text = stringResource(R.string.text_secret_items_count, secret.items.size), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Outlined.Notes, + contentDescription = null, + modifier = Modifier + .size(22.dp) + .padding(top = 2.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Column( + modifier = Modifier.padding(end = 12.dp), + ) { + Text( + text = secret.title, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = stringResource(R.string.text_secret_items_count, secret.items.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Icon( + imageVector = Icons.Default.KeyboardArrowRight, + contentDescription = null, ) } - androidx.compose.material3.TextButton( - onClick = { onOpenSecret(secret) }, - enabled = uiState.isAvailable, - ) { - Text(stringResource(R.string.open)) - } } } } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsViewModel.kt index a3f9f13..2a09f42 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsViewModel.kt @@ -7,7 +7,7 @@ import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStor import com.github.nullptroma.wallenc.usecases.FindStorageUseCase import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import javax.inject.Inject @@ -21,10 +21,10 @@ class TextSecretsViewModel @Inject constructor( private val storageUuid = savedStateHandle.requireStorageUuid() init { - refresh() + observeSecrets() } - fun refresh() { + private fun observeSecrets() { viewModelScope.launch { val storage = findStorageUseCase.find(storageUuid) if (storage == null) { @@ -36,15 +36,19 @@ class TextSecretsViewModel @Inject constructor( ) return@launch } - val items = manageTextSecretsUseCase.list(storage) - updateState( + combine( + storage.isAvailable, + manageTextSecretsUseCase.observe(storage), + ) { available, items -> state.value.copy( isLoading = false, - isAvailable = storage.isAvailable.first(), + isAvailable = available, items = items, errorMessage = null, - ), - ) + ) + }.collect { ui -> + updateState(ui) + } } } } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt index 8d3ab19..12e1e85 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt @@ -3,21 +3,27 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.outlined.Lock import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -66,6 +72,7 @@ fun TwoFaTokensScreen( .fillMaxSize() .padding(innerPadding) .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { if (uiState.isLoading) { CircularProgressIndicator() @@ -81,23 +88,62 @@ fun TwoFaTokensScreen( } else { LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { items(uiState.items) { item -> - Row( + Card( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), ) { - Column(modifier = Modifier.weight(1f)) { - Text(text = item.issuer) - Text(text = item.account) - Text(text = item.secret) - } - IconButton(onClick = { editingToken = item }, enabled = uiState.isAvailable) { - Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.edit)) - } - IconButton( - onClick = { viewModel.deleteToken(item.id) }, - enabled = uiState.isAvailable, + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + horizontalArrangement = Arrangement.SpaceBetween, ) { - Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.remove)) + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Outlined.Lock, + contentDescription = null, + modifier = Modifier + .size(22.dp) + .padding(top = 2.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Column { + Text( + text = item.issuer, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = item.account, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.size(4.dp)) + Text( + text = maskedSecret(item.secret), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Row { + IconButton( + onClick = { editingToken = item }, + enabled = uiState.isAvailable, + ) { + Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.edit)) + } + IconButton( + onClick = { viewModel.deleteToken(item.id) }, + enabled = uiState.isAvailable, + ) { + Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.remove)) + } + } } } } @@ -202,3 +248,8 @@ private fun TwoFaTokenEditDialog( }, ) } + +private fun maskedSecret(secret: String): String { + if (secret.isBlank()) return "—" + return "••••••••••••" +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt index 4b2d2be..1d09e1f 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt @@ -8,7 +8,7 @@ import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStor import com.github.nullptroma.wallenc.usecases.FindStorageUseCase import com.github.nullptroma.wallenc.usecases.ManageTwoFaTokensUseCase import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import javax.inject.Inject @@ -22,10 +22,10 @@ class TwoFaTokensViewModel @Inject constructor( private val storageUuid = savedStateHandle.requireStorageUuid() init { - refresh() + observeStorageTokens() } - fun refresh() { + private fun observeStorageTokens() { viewModelScope.launch { val storage = findStorageUseCase.find(storageUuid) if (storage == null) { @@ -37,15 +37,19 @@ class TwoFaTokensViewModel @Inject constructor( ) return@launch } - val tokens = manageTwoFaTokensUseCase.list(storage) - updateState( + combine( + storage.isAvailable, + manageTwoFaTokensUseCase.observe(storage), + ) { available, items -> state.value.copy( isLoading = false, - isAvailable = storage.isAvailable.first(), - items = tokens, + isAvailable = available, + items = items, errorMessage = null, - ), - ) + ) + }.collect { ui -> + updateState(ui) + } } } @@ -78,7 +82,6 @@ class TwoFaTokensViewModel @Inject constructor( ), ) } - refresh() } } @@ -86,7 +89,6 @@ class TwoFaTokensViewModel @Inject constructor( viewModelScope.launch { val storage = findStorageUseCase.find(storageUuid) ?: return@launch manageTwoFaTokensUseCase.delete(storage, id) - refresh() } } } diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index ca17f82..ef70891 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -183,8 +183,10 @@ не зашифровано 2FA токены (%1$d) Открыть 2FA + Коды и секреты двухфакторной аутентификации Текстовые секреты (%1$d) Открыть текстовые секреты + Заметки, токены и произвольные пары ключ-значение Скоро здесь появятся Files, Media и другие типы данных. Добавить токен diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTextSecretsUseCase.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTextSecretsUseCase.kt index 6ebbe24..8fd0880 100644 --- a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTextSecretsUseCase.kt +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTextSecretsUseCase.kt @@ -4,6 +4,11 @@ import com.github.nullptroma.wallenc.domain.datatypes.TextSecretEntryRecord import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord import com.github.nullptroma.wallenc.domain.interfaces.IStorage import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.JsonArray @@ -20,9 +25,14 @@ import java.util.UUID class ManageTextSecretsUseCase { private val mutex = Mutex() - suspend fun list(storageInfo: IStorageInfo): List = mutex.withLock { - val storage = storageInfo as? IStorage ?: return@withLock emptyList() - readAll(storage) + fun observe(storageInfo: IStorageInfo): Flow> { + val storage = storageInfo as? IStorage ?: return flowOf(emptyList()) + return merge( + flowOf(Unit), + storage.accessor.filesUpdates.map { Unit }, + ).map { + mutex.withLock { readAll(storage) } + }.distinctUntilChanged() } suspend fun get(storageInfo: IStorageInfo, id: String): TextSecretRecord? = mutex.withLock { diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt index bceec26..4e7468b 100644 --- a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt @@ -3,6 +3,11 @@ package com.github.nullptroma.wallenc.usecases import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord import com.github.nullptroma.wallenc.domain.interfaces.IStorage import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.JsonElement @@ -16,9 +21,14 @@ import java.util.UUID class ManageTwoFaTokensUseCase { private val mutex = Mutex() - suspend fun list(storageInfo: IStorageInfo): List = mutex.withLock { - val storage = storageInfo as? IStorage ?: return@withLock emptyList() - readAll(storage) + fun observe(storageInfo: IStorageInfo): Flow> { + val storage = storageInfo as? IStorage ?: return flowOf(emptyList()) + return merge( + flowOf(Unit), + storage.accessor.filesUpdates.map { Unit }, + ).map { + mutex.withLock { readAll(storage) } + }.distinctUntilChanged() } suspend fun get(storageInfo: IStorageInfo, id: String): TwoFaTokenRecord? = mutex.withLock { diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageSyncEngine.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageSyncEngine.kt index 81f1032..e5ff47a 100644 --- a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageSyncEngine.kt +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageSyncEngine.kt @@ -73,7 +73,7 @@ class StorageSyncEngine( return } - val leaseUntil = Instant.MAX + val leaseUntil = Instant.now().plusSeconds(SYNC_LOCK_LEASE_SECONDS) val lockedAccessors = mutableListOf() try { reportProgress(null, "Storage sync: group \"$groupId\" acquiring locks") @@ -223,4 +223,8 @@ class StorageSyncEngine( } return a.revision.createdAt.compareTo(b.revision.createdAt) } + + private companion object { + private const val SYNC_LOCK_LEASE_SECONDS: Long = 30 * 60 + } } diff --git a/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageDomainUseCasesTest.kt b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageDomainUseCasesTest.kt index 612d61e..c701f2f 100644 --- a/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageDomainUseCasesTest.kt +++ b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageDomainUseCasesTest.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals @@ -46,7 +47,7 @@ class StorageDomainUseCasesTest { notes = "primary", ) assertNotNull(created.id) - assertEquals(1, useCase.list(storage).size) + assertEquals(1, useCase.observe(storage).first().size) val updated = useCase.update( storageInfo = storage, @@ -57,7 +58,7 @@ class StorageDomainUseCasesTest { val removed = useCase.delete(storage, created.id) assertTrue(removed) - assertTrue(useCase.list(storage).isEmpty()) + assertTrue(useCase.observe(storage).first().isEmpty()) } @Test @@ -66,7 +67,7 @@ class StorageDomainUseCasesTest { setDomainFile(StorageDomainDataFiles.TWO_FA_TOKENS_FILE, "not-json") } val useCase = ManageTwoFaTokensUseCase() - assertTrue(useCase.list(storage).isEmpty()) + assertTrue(useCase.observe(storage).first().isEmpty()) } @Test @@ -82,7 +83,7 @@ class StorageDomainUseCasesTest { TextSecretEntryRecord(label = null, value = "password"), ), ) - assertEquals(1, useCase.list(storage).size) + assertEquals(1, useCase.observe(storage).first().size) val updated = useCase.update( storageInfo = storage, @@ -107,7 +108,7 @@ class StorageDomainUseCasesTest { setDomainFile(StorageDomainDataFiles.TEXT_SECRETS_FILE, "{broken") } val useCase = ManageTextSecretsUseCase() - assertTrue(useCase.list(storage).isEmpty()) + assertTrue(useCase.observe(storage).first().isEmpty()) } } @@ -143,11 +144,12 @@ private class FakeMetaInfo : IStorageMetaInfo { private class FakeStorageAccessor : IStorageAccessor { val dataFiles: MutableMap = mutableMapOf() private val systemFiles: MutableMap = mutableMapOf() + private val _filesUpdates = MutableSharedFlow>(extraBufferCapacity = 16) override val size: StateFlow = MutableStateFlow(0L) override val numberOfFiles: StateFlow = MutableStateFlow(0) override val isAvailable: StateFlow = MutableStateFlow(true) - override val filesUpdates: SharedFlow> = MutableSharedFlow() + override val filesUpdates: SharedFlow> = _filesUpdates override val dirsUpdates: SharedFlow> = MutableSharedFlow() override suspend fun getAllFiles(): List = emptyList() @@ -182,6 +184,7 @@ private class FakeStorageAccessor : IStorageAccessor { return object : ByteArrayOutputStream() { override fun close() { dataFiles[path] = toByteArray() + _filesUpdates.tryEmit(DataPage(list = emptyList(), pageLength = 1, pageIndex = 0)) } } }