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