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