Улучшение UI/UX
This commit is contained in:
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 "••••••••••••"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user