Улучшение UI/UX

This commit is contained in:
2026-05-17 11:54:02 +03:00
parent 5777f8e459
commit 555448d998
17 changed files with 330 additions and 139 deletions

View File

@@ -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() }
}

View File

@@ -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() }
}
}

View File

@@ -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

View File

@@ -254,13 +254,16 @@ fun MainScreen(
val route: TextSecretEditRoute = entry.toRoute()
TextSecretEditScreen(
onSaved = { savedSecretId ->
val editingExisting = route.secretId != null
navState.navHostController.popBackStack()
if (!editingExisting) {
navState.push(
TextSecretDetailsRoute(
storageUuid = route.storageUuid,
secretId = savedSecretId,
),
)
}
},
)
}

View File

@@ -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(
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) },
enabled = uiState.isAvailable,
) {
Text(stringResource(R.string.storage_home_open_two_fa))
}
}
}
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) },
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,
) {
Text(stringResource(R.string.storage_home_open_text_secrets))
}
}
}
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,
)
}
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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 {
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)
Text(
text = item.value,
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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,27 +73,50 @@ fun TextSecretsScreen(
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.items) { secret ->
Row(
Card(
modifier = Modifier.fillMaxWidth(),
onClick = { onOpenSecret(secret) },
enabled = uiState.isAvailable,
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 12.dp),
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(text = secret.title)
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,
)
}
androidx.compose.material3.TextButton(
onClick = { onOpenSecret(secret) },
enabled = uiState.isAvailable,
) {
Text(stringResource(R.string.open))
}
Icon(
imageVector = Icons.Default.KeyboardArrowRight,
contentDescription = null,
)
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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,16 +88,53 @@ fun TwoFaTokensScreen(
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.items) { item ->
Row(
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = item.issuer)
Text(text = item.account)
Text(text = item.secret)
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,
)
}
IconButton(onClick = { editingToken = item }, enabled = uiState.isAvailable) {
}
Row {
IconButton(
onClick = { editingToken = item },
enabled = uiState.isAvailable,
) {
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.edit))
}
IconButton(
@@ -105,6 +149,8 @@ fun TwoFaTokensScreen(
}
}
}
}
}
if (creating) {
TwoFaTokenEditDialog(
@@ -202,3 +248,8 @@ private fun TwoFaTokenEditDialog(
},
)
}
private fun maskedSecret(secret: String): String {
if (secret.isBlank()) return ""
return "••••••••••••"
}

View File

@@ -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()
}
}
}

View File

@@ -183,8 +183,10 @@
<string name="storage_home_status_not_encrypted">не зашифровано</string>
<string name="storage_home_two_fa_title">2FA токены (%1$d)</string>
<string name="storage_home_open_two_fa">Открыть 2FA</string>
<string name="storage_home_two_fa_subtitle">Коды и секреты двухфакторной аутентификации</string>
<string name="storage_home_text_secrets_title">Текстовые секреты (%1$d)</string>
<string name="storage_home_open_text_secrets">Открыть текстовые секреты</string>
<string name="storage_home_text_secrets_subtitle">Заметки, токены и произвольные пары ключ-значение</string>
<string name="storage_home_future_sections">Скоро здесь появятся Files, Media и другие типы данных.</string>
<string name="two_fa_add_token">Добавить токен</string>

View File

@@ -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<TextSecretRecord> = mutex.withLock {
val storage = storageInfo as? IStorage ?: return@withLock emptyList()
readAll(storage)
fun observe(storageInfo: IStorageInfo): Flow<List<TextSecretRecord>> {
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 {

View File

@@ -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<TwoFaTokenRecord> = mutex.withLock {
val storage = storageInfo as? IStorage ?: return@withLock emptyList()
readAll(storage)
fun observe(storageInfo: IStorageInfo): Flow<List<TwoFaTokenRecord>> {
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 {

View File

@@ -73,7 +73,7 @@ class StorageSyncEngine(
return
}
val leaseUntil = Instant.MAX
val leaseUntil = Instant.now().plusSeconds(SYNC_LOCK_LEASE_SECONDS)
val lockedAccessors = mutableListOf<IStorageAccessor>()
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
}
}

View File

@@ -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<String, ByteArray> = mutableMapOf()
private val systemFiles: MutableMap<String, ByteArray> = mutableMapOf()
private val _filesUpdates = MutableSharedFlow<DataPage<IFile>>(extraBufferCapacity = 16)
override val size: StateFlow<Long?> = MutableStateFlow(0L)
override val numberOfFiles: StateFlow<Int?> = MutableStateFlow(0)
override val isAvailable: StateFlow<Boolean> = MutableStateFlow(true)
override val filesUpdates: SharedFlow<DataPage<IFile>> = MutableSharedFlow()
override val filesUpdates: SharedFlow<DataPage<IFile>> = _filesUpdates
override val dirsUpdates: SharedFlow<DataPage<IDirectory>> = MutableSharedFlow()
override suspend fun getAllFiles(): List<IFile> = 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))
}
}
}