Улучшение 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 { override suspend fun tryAcquireSyncLock(holderId: String, leaseUntil: Instant): Boolean {
return syncLockMutex.withLock { return syncLockMutex.withLock {
val current = readSyncLock() 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 return@withLock false
} }
val next = StorageSyncLock( val next = StorageSyncLock(
holderId = holderId, holderId = holderId,
leaseUntil = leaseUntil, leaseUntil = leaseUntil,
updatedAt = Instant.now(), updatedAt = now,
) )
openWriteSystemFile(SYNC_LOCK_FILENAME).use { out -> openWriteSystemFile(SYNC_LOCK_FILENAME).use { out ->
jackson.writeValue(out, next) jackson.writeValue(out, next)
@@ -424,6 +429,7 @@ class EncryptedStorageAccessor(
private companion object { private companion object {
private const val SYNC_JOURNAL_FILENAME = "sync-journal.json" private const val SYNC_JOURNAL_FILENAME = "sync-journal.json"
private const val SYNC_LOCK_FILENAME = "sync-lock.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() private val jackson = com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
.apply { findAndRegisterModules() } .apply { findAndRegisterModules() }
} }

View File

@@ -627,7 +627,12 @@ class LocalStorageAccessor(
val fileLock = channel.lock() val fileLock = channel.lock()
try { try {
val current = readSyncLockFromChannel(channel) 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 return@withContext false
} }
@@ -636,7 +641,7 @@ class LocalStorageAccessor(
lock = StorageSyncLock( lock = StorageSyncLock(
holderId = holderId, holderId = holderId,
leaseUntil = leaseUntil, leaseUntil = leaseUntil,
updatedAt = Instant.now(), updatedAt = now,
), ),
) )
return@withContext true return@withContext true
@@ -742,6 +747,7 @@ class LocalStorageAccessor(
private const val DATA_PAGE_LENGTH = 10 private const val DATA_PAGE_LENGTH = 10
private const val SYNC_JOURNAL_FILENAME = "sync-journal.json" private const val SYNC_JOURNAL_FILENAME = "sync-journal.json"
private const val SYNC_LOCK_FILENAME = "sync-lock.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() } 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) { override suspend fun tryAcquireSyncLock(holderId: String, leaseUntil: Instant): Boolean = withContext(ioDispatcher) {
return@withContext syncLockMutex.withLock { return@withContext syncLockMutex.withLock {
val current = readSyncLock() 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 return@withLock false
} }
val next = StorageSyncLock( val next = StorageSyncLock(
holderId = holderId, holderId = holderId,
leaseUntil = leaseUntil, leaseUntil = leaseUntil,
updatedAt = Instant.now(), updatedAt = now,
) )
openWriteSystemFile(SYNC_LOCK_FILENAME).use { out -> openWriteSystemFile(SYNC_LOCK_FILENAME).use { out ->
statsMapper.writeValue(out, next) statsMapper.writeValue(out, next)
@@ -716,6 +721,7 @@ class YandexStorageAccessor(
private const val STATS_FILENAME = "yandex-vault-stats.json" private const val STATS_FILENAME = "yandex-vault-stats.json"
private const val SYNC_JOURNAL_FILENAME = "sync-journal.json" private const val SYNC_JOURNAL_FILENAME = "sync-journal.json"
private const val SYNC_LOCK_FILENAME = "sync-lock.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 STATS_DEBOUNCE_MS = 450L
private const val DATA_PAGE_LENGTH = 10 private const val DATA_PAGE_LENGTH = 10
private const val API_LIST_LIMIT = 1000 private const val API_LIST_LIMIT = 1000

View File

@@ -254,13 +254,16 @@ fun MainScreen(
val route: TextSecretEditRoute = entry.toRoute() val route: TextSecretEditRoute = entry.toRoute()
TextSecretEditScreen( TextSecretEditScreen(
onSaved = { savedSecretId -> onSaved = { savedSecretId ->
val editingExisting = route.secretId != null
navState.navHostController.popBackStack() navState.navHostController.popBackStack()
if (!editingExisting) {
navState.push( navState.push(
TextSecretDetailsRoute( TextSecretDetailsRoute(
storageUuid = route.storageUuid, storageUuid = route.storageUuid,
secretId = savedSecretId, 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.Arrangement
import androidx.compose.foundation.layout.Column 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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -83,45 +89,21 @@ fun StorageHomeScreen(
) )
} }
Card(colors = CardDefaults.cardColors()) { StorageSectionCard(
Column( title = stringResource(R.string.storage_home_two_fa_title, uiState.twoFaCount),
modifier = Modifier description = stringResource(R.string.storage_home_two_fa_subtitle),
.fillMaxWidth() icon = Icons.Outlined.Lock,
.padding(16.dp), enabled = uiState.isAvailable,
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) }, 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, enabled = uiState.isAvailable,
) { onClick = { onOpenTextSecrets(uiState.storageUuid) },
Text(stringResource(R.string.storage_home_open_text_secrets)) )
}
}
}
Text( Text(
text = stringResource(R.string.storage_home_future_sections), 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.ManageTextSecretsUseCase
import com.github.nullptroma.wallenc.usecases.ManageTwoFaTokensUseCase import com.github.nullptroma.wallenc.usecases.ManageTwoFaTokensUseCase
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -22,10 +22,10 @@ class StorageHomeViewModel @Inject constructor(
private val storageUuid = savedStateHandle.requireStorageUuid() private val storageUuid = savedStateHandle.requireStorageUuid()
init { init {
refresh() observeStorageState()
} }
fun refresh() { private fun observeStorageState() {
viewModelScope.launch { viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) val storage = findStorageUseCase.find(storageUuid)
if (storage == null) { if (storage == null) {
@@ -38,21 +38,25 @@ class StorageHomeViewModel @Inject constructor(
) )
return@launch return@launch
} }
combine(
val twoFa = manageTwoFaTokensUseCase.list(storage) storage.isAvailable,
val secrets = manageTextSecretsUseCase.list(storage) storage.metaInfo,
updateState( manageTwoFaTokensUseCase.observe(storage),
manageTextSecretsUseCase.observe(storage),
) { available, meta, twoFa, secrets ->
state.value.copy( state.value.copy(
isLoading = false, isLoading = false,
storageUuid = storage.uuid.toString(), storageUuid = storage.uuid.toString(),
storageName = storage.metaInfo.value.name.orEmpty(), storageName = meta.name.orEmpty(),
isAvailable = storage.isAvailable.first(), isAvailable = available,
isEncrypted = storage.metaInfo.value.encInfo != null, isEncrypted = meta.encInfo != null,
twoFaCount = twoFa.size, twoFaCount = twoFa.size,
textSecretsCount = secrets.size, textSecretsCount = secrets.size,
errorMessage = null, 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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -64,12 +66,28 @@ fun TextSecretDetailsScreen(
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(secret.items) { item -> 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(
text = item.label ?: stringResource(R.string.text_secret_item_without_label), text = item.label ?: stringResource(R.string.text_secret_item_without_label),
style = MaterialTheme.typography.labelMedium, 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.FindStorageUseCase
import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel 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 kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -23,10 +24,10 @@ class TextSecretDetailsViewModel @Inject constructor(
?: error("Missing secret id") ?: error("Missing secret id")
init { init {
refresh() observeSecret()
} }
fun refresh() { private fun observeSecret() {
viewModelScope.launch { viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) val storage = findStorageUseCase.find(storageUuid)
if (storage == null) { if (storage == null) {
@@ -38,15 +39,21 @@ class TextSecretDetailsViewModel @Inject constructor(
) )
return@launch return@launch
} }
val secret = manageTextSecretsUseCase.get(storage, secretId) combine(
updateState( storage.isAvailable,
manageTextSecretsUseCase.observe(storage).map { list ->
list.firstOrNull { it.id == secretId }
},
) { available, secret ->
state.value.copy( state.value.copy(
isLoading = false, isLoading = false,
isAvailable = storage.isAvailable.first(), isAvailable = available,
secret = secret, secret = secret,
errorMessage = if (secret == null) "Secret not found" else null, 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add 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.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -68,27 +73,50 @@ fun TextSecretsScreen(
} else { } else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.items) { secret -> items(uiState.items) { secret ->
Row( Card(
modifier = Modifier.fillMaxWidth(), 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, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Column( Row(
modifier = Modifier modifier = Modifier.weight(1f),
.weight(1f) horizontalArrangement = Arrangement.spacedBy(12.dp),
.padding(end = 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(
text = stringResource(R.string.text_secret_items_count, secret.items.size), text = stringResource(R.string.text_secret_items_count, secret.items.size),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
androidx.compose.material3.TextButton( }
onClick = { onOpenSecret(secret) }, Icon(
enabled = uiState.isAvailable, imageVector = Icons.Default.KeyboardArrowRight,
) { contentDescription = null,
Text(stringResource(R.string.open)) )
} }
} }
} }

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.FindStorageUseCase
import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -21,10 +21,10 @@ class TextSecretsViewModel @Inject constructor(
private val storageUuid = savedStateHandle.requireStorageUuid() private val storageUuid = savedStateHandle.requireStorageUuid()
init { init {
refresh() observeSecrets()
} }
fun refresh() { private fun observeSecrets() {
viewModelScope.launch { viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) val storage = findStorageUseCase.find(storageUuid)
if (storage == null) { if (storage == null) {
@@ -36,15 +36,19 @@ class TextSecretsViewModel @Inject constructor(
) )
return@launch return@launch
} }
val items = manageTextSecretsUseCase.list(storage) combine(
updateState( storage.isAvailable,
manageTextSecretsUseCase.observe(storage),
) { available, items ->
state.value.copy( state.value.copy(
isLoading = false, isLoading = false,
isAvailable = storage.isAvailable.first(), isAvailable = available,
items = items, items = items,
errorMessage = null, 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -66,6 +72,7 @@ fun TwoFaTokensScreen(
.fillMaxSize() .fillMaxSize()
.padding(innerPadding) .padding(innerPadding)
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
if (uiState.isLoading) { if (uiState.isLoading) {
CircularProgressIndicator() CircularProgressIndicator()
@@ -81,16 +88,53 @@ fun TwoFaTokensScreen(
} else { } else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.items) { item -> items(uiState.items) { item ->
Row( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Column(modifier = Modifier.weight(1f)) { Row(
Text(text = item.issuer) modifier = Modifier.weight(1f),
Text(text = item.account) horizontalArrangement = Arrangement.spacedBy(12.dp),
Text(text = item.secret) ) {
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)) Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.edit))
} }
IconButton( IconButton(
@@ -105,6 +149,8 @@ fun TwoFaTokensScreen(
} }
} }
} }
}
}
if (creating) { if (creating) {
TwoFaTokenEditDialog( 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.FindStorageUseCase
import com.github.nullptroma.wallenc.usecases.ManageTwoFaTokensUseCase import com.github.nullptroma.wallenc.usecases.ManageTwoFaTokensUseCase
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -22,10 +22,10 @@ class TwoFaTokensViewModel @Inject constructor(
private val storageUuid = savedStateHandle.requireStorageUuid() private val storageUuid = savedStateHandle.requireStorageUuid()
init { init {
refresh() observeStorageTokens()
} }
fun refresh() { private fun observeStorageTokens() {
viewModelScope.launch { viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) val storage = findStorageUseCase.find(storageUuid)
if (storage == null) { if (storage == null) {
@@ -37,15 +37,19 @@ class TwoFaTokensViewModel @Inject constructor(
) )
return@launch return@launch
} }
val tokens = manageTwoFaTokensUseCase.list(storage) combine(
updateState( storage.isAvailable,
manageTwoFaTokensUseCase.observe(storage),
) { available, items ->
state.value.copy( state.value.copy(
isLoading = false, isLoading = false,
isAvailable = storage.isAvailable.first(), isAvailable = available,
items = tokens, items = items,
errorMessage = null, 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 { viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) ?: return@launch val storage = findStorageUseCase.find(storageUuid) ?: return@launch
manageTwoFaTokensUseCase.delete(storage, id) manageTwoFaTokensUseCase.delete(storage, id)
refresh()
} }
} }
} }

View File

@@ -183,8 +183,10 @@
<string name="storage_home_status_not_encrypted">не зашифровано</string> <string name="storage_home_status_not_encrypted">не зашифровано</string>
<string name="storage_home_two_fa_title">2FA токены (%1$d)</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_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_text_secrets_title">Текстовые секреты (%1$d)</string>
<string name="storage_home_open_text_secrets">Открыть текстовые секреты</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="storage_home_future_sections">Скоро здесь появятся Files, Media и другие типы данных.</string>
<string name="two_fa_add_token">Добавить токен</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.datatypes.TextSecretRecord
import com.github.nullptroma.wallenc.domain.interfaces.IStorage import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo 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.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
@@ -20,9 +25,14 @@ import java.util.UUID
class ManageTextSecretsUseCase { class ManageTextSecretsUseCase {
private val mutex = Mutex() private val mutex = Mutex()
suspend fun list(storageInfo: IStorageInfo): List<TextSecretRecord> = mutex.withLock { fun observe(storageInfo: IStorageInfo): Flow<List<TextSecretRecord>> {
val storage = storageInfo as? IStorage ?: return@withLock emptyList() val storage = storageInfo as? IStorage ?: return flowOf(emptyList())
readAll(storage) 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 { 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.datatypes.TwoFaTokenRecord
import com.github.nullptroma.wallenc.domain.interfaces.IStorage import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo 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.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
@@ -16,9 +21,14 @@ import java.util.UUID
class ManageTwoFaTokensUseCase { class ManageTwoFaTokensUseCase {
private val mutex = Mutex() private val mutex = Mutex()
suspend fun list(storageInfo: IStorageInfo): List<TwoFaTokenRecord> = mutex.withLock { fun observe(storageInfo: IStorageInfo): Flow<List<TwoFaTokenRecord>> {
val storage = storageInfo as? IStorage ?: return@withLock emptyList() val storage = storageInfo as? IStorage ?: return flowOf(emptyList())
readAll(storage) 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 { suspend fun get(storageInfo: IStorageInfo, id: String): TwoFaTokenRecord? = mutex.withLock {

View File

@@ -73,7 +73,7 @@ class StorageSyncEngine(
return return
} }
val leaseUntil = Instant.MAX val leaseUntil = Instant.now().plusSeconds(SYNC_LOCK_LEASE_SECONDS)
val lockedAccessors = mutableListOf<IStorageAccessor>() val lockedAccessors = mutableListOf<IStorageAccessor>()
try { try {
reportProgress(null, "Storage sync: group \"$groupId\" acquiring locks") reportProgress(null, "Storage sync: group \"$groupId\" acquiring locks")
@@ -223,4 +223,8 @@ class StorageSyncEngine(
} }
return a.revision.createdAt.compareTo(b.revision.createdAt) 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.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -46,7 +47,7 @@ class StorageDomainUseCasesTest {
notes = "primary", notes = "primary",
) )
assertNotNull(created.id) assertNotNull(created.id)
assertEquals(1, useCase.list(storage).size) assertEquals(1, useCase.observe(storage).first().size)
val updated = useCase.update( val updated = useCase.update(
storageInfo = storage, storageInfo = storage,
@@ -57,7 +58,7 @@ class StorageDomainUseCasesTest {
val removed = useCase.delete(storage, created.id) val removed = useCase.delete(storage, created.id)
assertTrue(removed) assertTrue(removed)
assertTrue(useCase.list(storage).isEmpty()) assertTrue(useCase.observe(storage).first().isEmpty())
} }
@Test @Test
@@ -66,7 +67,7 @@ class StorageDomainUseCasesTest {
setDomainFile(StorageDomainDataFiles.TWO_FA_TOKENS_FILE, "not-json") setDomainFile(StorageDomainDataFiles.TWO_FA_TOKENS_FILE, "not-json")
} }
val useCase = ManageTwoFaTokensUseCase() val useCase = ManageTwoFaTokensUseCase()
assertTrue(useCase.list(storage).isEmpty()) assertTrue(useCase.observe(storage).first().isEmpty())
} }
@Test @Test
@@ -82,7 +83,7 @@ class StorageDomainUseCasesTest {
TextSecretEntryRecord(label = null, value = "password"), TextSecretEntryRecord(label = null, value = "password"),
), ),
) )
assertEquals(1, useCase.list(storage).size) assertEquals(1, useCase.observe(storage).first().size)
val updated = useCase.update( val updated = useCase.update(
storageInfo = storage, storageInfo = storage,
@@ -107,7 +108,7 @@ class StorageDomainUseCasesTest {
setDomainFile(StorageDomainDataFiles.TEXT_SECRETS_FILE, "{broken") setDomainFile(StorageDomainDataFiles.TEXT_SECRETS_FILE, "{broken")
} }
val useCase = ManageTextSecretsUseCase() 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 { private class FakeStorageAccessor : IStorageAccessor {
val dataFiles: MutableMap<String, ByteArray> = mutableMapOf() val dataFiles: MutableMap<String, ByteArray> = mutableMapOf()
private val systemFiles: 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 size: StateFlow<Long?> = MutableStateFlow(0L)
override val numberOfFiles: StateFlow<Int?> = MutableStateFlow(0) override val numberOfFiles: StateFlow<Int?> = MutableStateFlow(0)
override val isAvailable: StateFlow<Boolean> = MutableStateFlow(true) 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 val dirsUpdates: SharedFlow<DataPage<IDirectory>> = MutableSharedFlow()
override suspend fun getAllFiles(): List<IFile> = emptyList() override suspend fun getAllFiles(): List<IFile> = emptyList()
@@ -182,6 +184,7 @@ private class FakeStorageAccessor : IStorageAccessor {
return object : ByteArrayOutputStream() { return object : ByteArrayOutputStream() {
override fun close() { override fun close() {
dataFiles[path] = toByteArray() dataFiles[path] = toByteArray()
_filesUpdates.tryEmit(DataPage(list = emptyList(), pageLength = 1, pageIndex = 0))
} }
} }
} }