feat(storage): добавлены маршруты и экраны для управления текстовыми секретами и 2FA токенами

This commit is contained in:
2026-05-13 20:39:55 +03:00
parent c6df089668
commit 5777f8e459
36 changed files with 1894 additions and 9 deletions

View File

@@ -6,7 +6,10 @@ import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase
import com.github.nullptroma.wallenc.usecases.FindStorageUseCase
import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase
import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase
import com.github.nullptroma.wallenc.usecases.ManageTwoFaTokensUseCase
import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase
import com.github.nullptroma.wallenc.usecases.RemoveStorageUseCase
import com.github.nullptroma.wallenc.usecases.RenameStorageUseCase
@@ -35,6 +38,12 @@ class UseCasesModule {
return ManageVaultUseCase(vaultsManager)
}
@Provides
@Singleton
fun provideFindStorageUseCase(vaultsManager: IVaultsManager): FindStorageUseCase {
return FindStorageUseCase(vaultsManager)
}
@Provides
@Singleton
fun provideStorageFileManagementUseCase(): StorageFileManagementUseCase {
@@ -65,6 +74,18 @@ class UseCasesModule {
return RemoveStorageUseCase(vaultsManager, unlockManager, manageStoragesEncryptionUseCase)
}
@Provides
@Singleton
fun provideManageTwoFaTokensUseCase(): ManageTwoFaTokensUseCase {
return ManageTwoFaTokensUseCase()
}
@Provides
@Singleton
fun provideManageTextSecretsUseCase(): ManageTextSecretsUseCase {
return ManageTextSecretsUseCase()
}
@Provides
@Singleton
fun provideStorageSyncEngine(

View File

@@ -0,0 +1,20 @@
package com.github.nullptroma.wallenc.domain.datatypes
data class TwoFaTokenRecord(
val id: String,
val issuer: String,
val account: String,
val secret: String,
val notes: String? = null,
)
data class TextSecretEntryRecord(
val label: String?,
val value: String,
)
data class TextSecretRecord(
val id: String,
val title: String,
val items: List<TextSecretEntryRecord>,
)

View File

@@ -35,6 +35,16 @@ import com.github.nullptroma.wallenc.ui.navigation.rememberNavigationState
import com.github.nullptroma.wallenc.ui.screens.main.screens.remotes.RemoteVaultsRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.remotes.RemoteVaultsScreen
import com.github.nullptroma.wallenc.ui.screens.main.screens.remotes.RemoteVaultsViewModel
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.StorageHomeRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.StorageHomeScreen
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets.TextSecretDetailsRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets.TextSecretDetailsScreen
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets.TextSecretEditRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets.TextSecretEditScreen
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets.TextSecretsRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets.TextSecretsScreen
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa.TwoFaTokensRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa.TwoFaTokensScreen
import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.LocalVaultRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.LocalVaultScreen
import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.LocalVaultViewModel
@@ -141,8 +151,8 @@ fun MainScreen(
) {
LocalVaultScreen(
viewModel = localVaultViewModel,
openTextEdit = { text ->
navState.push(TextEditRoute(text))
onOpenStorageHome = { storageUuid ->
navState.push(StorageHomeRoute(storageUuid = storageUuid))
},
)
}
@@ -162,10 +172,95 @@ fun MainScreen(
exitTransition = { fadeOut(tween(200)) },
) { entry ->
val remoteVaultViewModel: RemoteVaultViewModel = hiltViewModel(entry)
val route: VaultBrowserRoute = entry.toRoute()
VaultBrowserScreen(
viewModel = remoteVaultViewModel,
openTextEdit = { text ->
navState.push(TextEditRoute(text))
onOpenStorageHome = { storageUuid ->
navState.push(
StorageHomeRoute(
vaultUuid = route.vaultUuid,
storageUuid = storageUuid,
),
)
},
)
}
composable<StorageHomeRoute>(
enterTransition = { fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(200)) },
) {
StorageHomeScreen(
onOpenTwoFa = { storageUuid ->
navState.push(TwoFaTokensRoute(storageUuid))
},
onOpenTextSecrets = { storageUuid ->
navState.push(TextSecretsRoute(storageUuid))
},
)
}
composable<TwoFaTokensRoute>(
enterTransition = { fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(200)) },
) {
TwoFaTokensScreen()
}
composable<TextSecretsRoute>(
enterTransition = { fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(200)) },
) { entry ->
val route: TextSecretsRoute = entry.toRoute()
TextSecretsScreen(
onOpenSecret = { secret ->
navState.push(
TextSecretDetailsRoute(
storageUuid = route.storageUuid,
secretId = secret.id,
),
)
},
onCreateSecret = {
navState.push(
TextSecretEditRoute(
storageUuid = route.storageUuid,
secretId = null,
),
)
},
)
}
composable<TextSecretDetailsRoute>(
enterTransition = { fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(200)) },
) { entry ->
val route: TextSecretDetailsRoute = entry.toRoute()
TextSecretDetailsScreen(
onEdit = { secretId ->
navState.push(
TextSecretEditRoute(
storageUuid = route.storageUuid,
secretId = secretId,
),
)
},
onDeleted = {
navState.navHostController.popBackStack()
},
)
}
composable<TextSecretEditRoute>(
enterTransition = { fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(200)) },
) { entry ->
val route: TextSecretEditRoute = entry.toRoute()
TextSecretEditScreen(
onSaved = { savedSecretId ->
navState.navHostController.popBackStack()
navState.push(
TextSecretDetailsRoute(
storageUuid = route.storageUuid,
secretId = savedSecretId,
),
)
},
)
}

View File

@@ -0,0 +1,12 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class StorageHomeRoute(
val vaultUuid: String? = null,
val storageUuid: String,
) : ScreenRoute()

View File

@@ -0,0 +1,133 @@
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.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.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
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.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.ui.R
@Composable
fun StorageHomeScreen(
modifier: Modifier = Modifier,
viewModel: StorageHomeViewModel = hiltViewModel(),
onOpenTwoFa: (String) -> Unit,
onOpenTextSecrets: (String) -> Unit,
) {
val uiState by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0.dp),
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (uiState.isLoading) {
CircularProgressIndicator()
return@Column
}
Text(
text = if (uiState.storageName.isBlank()) {
stringResource(R.string.storage_home_unnamed_storage)
} else {
uiState.storageName
},
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = uiState.storageUuid,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(
R.string.storage_home_status_line,
if (uiState.isAvailable) {
stringResource(R.string.storage_home_status_available)
} else {
stringResource(R.string.storage_home_status_unavailable)
},
if (uiState.isEncrypted) {
stringResource(R.string.storage_home_status_encrypted)
} else {
stringResource(R.string.storage_home_status_not_encrypted)
},
),
)
uiState.errorMessage?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.error,
)
}
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))
}
}
}
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))
}
}
}
Text(
text = stringResource(R.string.storage_home_future_sections),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View File

@@ -0,0 +1,15 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage
import androidx.compose.runtime.Immutable
@Immutable
data class StorageHomeScreenState(
val storageUuid: String = "",
val storageName: String = "",
val isLoading: Boolean = true,
val isAvailable: Boolean = false,
val isEncrypted: Boolean = false,
val twoFaCount: Int = 0,
val textSecretsCount: Int = 0,
val errorMessage: String? = null,
)

View File

@@ -0,0 +1,58 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.ui.ViewModelBase
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.launch
import javax.inject.Inject
@HiltViewModel
class StorageHomeViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val findStorageUseCase: FindStorageUseCase,
private val manageTwoFaTokensUseCase: ManageTwoFaTokensUseCase,
private val manageTextSecretsUseCase: ManageTextSecretsUseCase,
) : ViewModelBase<StorageHomeScreenState>(StorageHomeScreenState()) {
private val storageUuid = savedStateHandle.requireStorageUuid()
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid)
if (storage == null) {
updateState(
state.value.copy(
isLoading = false,
storageUuid = storageUuid.toString(),
errorMessage = "Storage not found",
),
)
return@launch
}
val twoFa = manageTwoFaTokensUseCase.list(storage)
val secrets = manageTextSecretsUseCase.list(storage)
updateState(
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,
twoFaCount = twoFa.size,
textSecretsCount = secrets.size,
errorMessage = null,
),
)
}
}
}

View File

@@ -0,0 +1,11 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage
import androidx.lifecycle.SavedStateHandle
import java.util.UUID
internal fun SavedStateHandle.requireStorageUuid(): UUID {
val raw = get<String>("storageUuid") ?: error("Missing storage UUID in navigation arguments")
return UUID.fromString(raw)
}
internal fun SavedStateHandle.optionalSecretId(): String? = get<String>("secretId")

View File

@@ -0,0 +1,12 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class TextSecretDetailsRoute(
val storageUuid: String,
val secretId: String,
) : ScreenRoute()

View File

@@ -0,0 +1,85 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.ui.R
@Composable
fun TextSecretDetailsScreen(
modifier: Modifier = Modifier,
viewModel: TextSecretDetailsViewModel = hiltViewModel(),
onEdit: (String) -> Unit,
onDeleted: () -> Unit,
) {
val uiState by viewModel.state.collectAsStateWithLifecycle()
val secret = uiState.secret
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0.dp),
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
uiState.errorMessage?.let {
Text(it, color = MaterialTheme.colorScheme.error)
}
if (secret == null) return@Column
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = secret.title,
style = MaterialTheme.typography.headlineSmall,
)
TextButton(onClick = { onEdit(secret.id) }, enabled = uiState.isAvailable) {
Text(stringResource(R.string.edit))
}
}
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)
}
}
}
Button(
onClick = { viewModel.delete(onDeleted) },
enabled = uiState.isAvailable,
) {
Text(stringResource(R.string.remove))
}
}
}
}

View File

@@ -0,0 +1,12 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import androidx.compose.runtime.Immutable
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
@Immutable
data class TextSecretDetailsScreenState(
val isLoading: Boolean = true,
val isAvailable: Boolean = false,
val secret: TextSecretRecord? = null,
val errorMessage: String? = null,
)

View File

@@ -0,0 +1,62 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStorageUuid
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.launch
import javax.inject.Inject
@HiltViewModel
class TextSecretDetailsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val findStorageUseCase: FindStorageUseCase,
private val manageTextSecretsUseCase: ManageTextSecretsUseCase,
) : ViewModelBase<TextSecretDetailsScreenState>(TextSecretDetailsScreenState()) {
private val storageUuid = savedStateHandle.requireStorageUuid()
private val secretId: String = savedStateHandle.get<String>("secretId")
?: error("Missing secret id")
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid)
if (storage == null) {
updateState(
state.value.copy(
isLoading = false,
errorMessage = "Storage not found",
),
)
return@launch
}
val secret = manageTextSecretsUseCase.get(storage, secretId)
updateState(
state.value.copy(
isLoading = false,
isAvailable = storage.isAvailable.first(),
secret = secret,
errorMessage = if (secret == null) "Secret not found" else null,
),
)
}
}
fun delete(onDeleted: () -> Unit) {
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) ?: return@launch
val removed = manageTextSecretsUseCase.delete(storage, secretId)
if (removed) {
onDeleted()
}
}
}
}

View File

@@ -0,0 +1,12 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class TextSecretEditRoute(
val storageUuid: String,
val secretId: String? = null,
) : ScreenRoute()

View File

@@ -0,0 +1,143 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretEntryRecord
import com.github.nullptroma.wallenc.ui.R
@Composable
fun TextSecretEditScreen(
modifier: Modifier = Modifier,
viewModel: TextSecretEditViewModel = hiltViewModel(),
onSaved: (String) -> Unit,
) {
val uiState by viewModel.state.collectAsStateWithLifecycle()
val currentOnSaved by rememberUpdatedState(onSaved)
var title by remember(uiState.initialSecret) {
mutableStateOf(uiState.initialSecret?.title.orEmpty())
}
val items = remember(uiState.initialSecret) {
mutableStateListOf<TextSecretEntryRecord>().apply {
addAll(
uiState.initialSecret?.items.orEmpty().ifEmpty {
listOf(TextSecretEntryRecord(label = null, value = ""))
},
)
}
}
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0.dp),
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
if (uiState.initialSecret == null) {
stringResource(R.string.text_secret_create)
} else {
stringResource(R.string.text_secret_edit)
},
)
OutlinedTextField(
value = title,
onValueChange = { title = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.text_secret_title)) },
)
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
itemsIndexed(items) { index, item ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = item.label.orEmpty(),
onValueChange = { newLabel ->
items[index] = item.copy(label = newLabel.ifBlank { null })
},
modifier = Modifier.weight(0.45f),
label = { Text(stringResource(R.string.text_secret_item_label_optional)) },
)
OutlinedTextField(
value = item.value,
onValueChange = { newValue ->
items[index] = item.copy(value = newValue)
},
modifier = Modifier.weight(0.55f),
label = { Text(stringResource(R.string.text_secret_item_value)) },
)
IconButton(
onClick = {
if (items.size > 1) {
items.removeAt(index)
} else {
items[index] = TextSecretEntryRecord(label = null, value = "")
}
},
) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.remove))
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = { items.add(TextSecretEntryRecord(label = null, value = "")) }) {
Icon(Icons.Default.Add, contentDescription = null)
Text(stringResource(R.string.text_secret_add_item))
}
TextButton(
onClick = {
viewModel.save(
title = title,
items = items.toList(),
onSaved = currentOnSaved,
)
},
enabled = title.isNotBlank(),
) {
Text(stringResource(R.string.save))
}
}
}
}
}

View File

@@ -0,0 +1,12 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import androidx.compose.runtime.Immutable
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
@Immutable
data class TextSecretEditScreenState(
val isLoading: Boolean = true,
val isAvailable: Boolean = false,
val initialSecret: TextSecretRecord? = null,
val errorMessage: String? = null,
)

View File

@@ -0,0 +1,83 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretEntryRecord
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.optionalSecretId
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStorageUuid
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.launch
import javax.inject.Inject
@HiltViewModel
class TextSecretEditViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val findStorageUseCase: FindStorageUseCase,
private val manageTextSecretsUseCase: ManageTextSecretsUseCase,
) : ViewModelBase<TextSecretEditScreenState>(TextSecretEditScreenState()) {
private val storageUuid = savedStateHandle.requireStorageUuid()
private val secretId: String? = savedStateHandle.optionalSecretId()
init {
load()
}
private fun load() {
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid)
if (storage == null) {
updateState(
state.value.copy(
isLoading = false,
errorMessage = "Storage not found",
),
)
return@launch
}
val initial = secretId?.let { manageTextSecretsUseCase.get(storage, it) }
updateState(
state.value.copy(
isLoading = false,
isAvailable = storage.isAvailable.first(),
initialSecret = initial,
errorMessage = null,
),
)
}
}
fun save(
title: String,
items: List<TextSecretEntryRecord>,
onSaved: (secretId: String) -> Unit,
) {
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) ?: return@launch
val existingId = secretId
if (existingId == null) {
val created = manageTextSecretsUseCase.create(
storageInfo = storage,
title = title,
items = items,
)
onSaved(created.id)
} else {
manageTextSecretsUseCase.update(
storageInfo = storage,
secret = TextSecretRecord(
id = existingId,
title = title,
items = items,
),
)
onSaved(existingId)
}
}
}
}

View File

@@ -0,0 +1,11 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class TextSecretsRoute(
val storageUuid: String,
) : ScreenRoute()

View File

@@ -0,0 +1,99 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.FloatingActionButton
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.draw.alpha
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
import com.github.nullptroma.wallenc.ui.R
@Composable
fun TextSecretsScreen(
modifier: Modifier = Modifier,
viewModel: TextSecretsViewModel = hiltViewModel(),
onOpenSecret: (TextSecretRecord) -> Unit,
onCreateSecret: () -> Unit,
) {
val uiState by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0.dp),
floatingActionButton = {
FloatingActionButton(
onClick = {
if (uiState.isAvailable) {
onCreateSecret()
}
},
modifier = Modifier.alpha(if (uiState.isAvailable) 1f else 0.5f),
) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.text_secret_create))
}
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
uiState.errorMessage?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
}
if (uiState.items.isEmpty()) {
Text(stringResource(R.string.text_secret_empty_state))
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.items) { secret ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 12.dp),
) {
Text(text = secret.title)
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))
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,12 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import androidx.compose.runtime.Immutable
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
@Immutable
data class TextSecretsScreenState(
val isLoading: Boolean = true,
val isAvailable: Boolean = false,
val items: List<TextSecretRecord> = emptyList(),
val errorMessage: String? = null,
)

View File

@@ -0,0 +1,50 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStorageUuid
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.launch
import javax.inject.Inject
@HiltViewModel
class TextSecretsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val findStorageUseCase: FindStorageUseCase,
private val manageTextSecretsUseCase: ManageTextSecretsUseCase,
) : ViewModelBase<TextSecretsScreenState>(TextSecretsScreenState()) {
private val storageUuid = savedStateHandle.requireStorageUuid()
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid)
if (storage == null) {
updateState(
state.value.copy(
isLoading = false,
errorMessage = "Storage not found",
),
)
return@launch
}
val items = manageTextSecretsUseCase.list(storage)
updateState(
state.value.copy(
isLoading = false,
isAvailable = storage.isAvailable.first(),
items = items,
errorMessage = null,
),
)
}
}
}

View File

@@ -0,0 +1,11 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class TwoFaTokensRoute(
val storageUuid: String,
) : ScreenRoute()

View File

@@ -0,0 +1,204 @@
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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord
import com.github.nullptroma.wallenc.ui.R
@Composable
fun TwoFaTokensScreen(
modifier: Modifier = Modifier,
viewModel: TwoFaTokensViewModel = hiltViewModel(),
) {
val uiState by viewModel.state.collectAsStateWithLifecycle()
var editingToken by remember { mutableStateOf<TwoFaTokenRecord?>(null) }
var creating by remember { mutableStateOf(false) }
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0.dp),
floatingActionButton = {
FloatingActionButton(
onClick = {
if (uiState.isAvailable) {
creating = true
}
},
modifier = Modifier.alpha(if (uiState.isAvailable) 1f else 0.5f),
) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.two_fa_add_token))
}
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
) {
if (uiState.isLoading) {
CircularProgressIndicator()
return@Column
}
uiState.errorMessage?.let {
Text(it)
}
if (uiState.items.isEmpty()) {
Text(stringResource(R.string.two_fa_empty_state))
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.items) { item ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
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,
) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.remove))
}
}
}
}
}
}
}
if (creating) {
TwoFaTokenEditDialog(
startValue = null,
onDismiss = { creating = false },
onSave = { issuer, account, secret, notes ->
creating = false
viewModel.saveToken(
existingId = null,
issuer = issuer,
account = account,
secret = secret,
notes = notes,
)
},
)
}
editingToken?.let { token ->
TwoFaTokenEditDialog(
startValue = token,
onDismiss = { editingToken = null },
onSave = { issuer, account, secret, notes ->
editingToken = null
viewModel.saveToken(
existingId = token.id,
issuer = issuer,
account = account,
secret = secret,
notes = notes,
)
},
)
}
}
@Composable
private fun TwoFaTokenEditDialog(
startValue: TwoFaTokenRecord?,
onDismiss: () -> Unit,
onSave: (String, String, String, String?) -> Unit,
) {
var issuer by remember(startValue) { mutableStateOf(startValue?.issuer.orEmpty()) }
var account by remember(startValue) { mutableStateOf(startValue?.account.orEmpty()) }
var secret by remember(startValue) { mutableStateOf(startValue?.secret.orEmpty()) }
var notes by remember(startValue) { mutableStateOf(startValue?.notes.orEmpty()) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
if (startValue == null) {
stringResource(R.string.two_fa_create_title)
} else {
stringResource(R.string.two_fa_edit_title)
},
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = issuer,
onValueChange = { issuer = it },
label = { Text(stringResource(R.string.two_fa_field_issuer)) },
)
OutlinedTextField(
value = account,
onValueChange = { account = it },
label = { Text(stringResource(R.string.two_fa_field_account)) },
)
OutlinedTextField(
value = secret,
onValueChange = { secret = it },
label = { Text(stringResource(R.string.two_fa_field_secret)) },
)
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text(stringResource(R.string.two_fa_field_notes_optional)) },
)
}
},
confirmButton = {
TextButton(
enabled = issuer.isNotBlank() && account.isNotBlank() && secret.isNotBlank(),
onClick = { onSave(issuer, account, secret, notes.ifBlank { null }) },
) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
},
)
}

View File

@@ -0,0 +1,12 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa
import androidx.compose.runtime.Immutable
import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord
@Immutable
data class TwoFaTokensScreenState(
val isLoading: Boolean = true,
val isAvailable: Boolean = false,
val items: List<TwoFaTokenRecord> = emptyList(),
val errorMessage: String? = null,
)

View File

@@ -0,0 +1,92 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord
import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStorageUuid
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.launch
import javax.inject.Inject
@HiltViewModel
class TwoFaTokensViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val findStorageUseCase: FindStorageUseCase,
private val manageTwoFaTokensUseCase: ManageTwoFaTokensUseCase,
) : ViewModelBase<TwoFaTokensScreenState>(TwoFaTokensScreenState()) {
private val storageUuid = savedStateHandle.requireStorageUuid()
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid)
if (storage == null) {
updateState(
state.value.copy(
isLoading = false,
errorMessage = "Storage not found",
),
)
return@launch
}
val tokens = manageTwoFaTokensUseCase.list(storage)
updateState(
state.value.copy(
isLoading = false,
isAvailable = storage.isAvailable.first(),
items = tokens,
errorMessage = null,
),
)
}
}
fun saveToken(
existingId: String?,
issuer: String,
account: String,
secret: String,
notes: String?,
) {
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) ?: return@launch
if (existingId == null) {
manageTwoFaTokensUseCase.create(
storageInfo = storage,
issuer = issuer,
account = account,
secret = secret,
notes = notes,
)
} else {
manageTwoFaTokensUseCase.update(
storageInfo = storage,
token = TwoFaTokenRecord(
id = existingId,
issuer = issuer,
account = account,
secret = secret,
notes = notes,
),
)
}
refresh()
}
}
fun deleteToken(id: String) {
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) ?: return@launch
manageTwoFaTokensUseCase.delete(storage, id)
refresh()
}
}
}

View File

@@ -8,11 +8,11 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
fun LocalVaultScreen(
modifier: Modifier = Modifier,
viewModel: LocalVaultViewModel = hiltViewModel(),
openTextEdit: (String) -> Unit,
onOpenStorageHome: (String) -> Unit,
) {
VaultBrowserScreen(
modifier = modifier,
viewModel = viewModel,
openTextEdit = openTextEdit,
onOpenStorageHome = onOpenStorageHome,
)
}

View File

@@ -43,7 +43,7 @@ import java.util.UUID
fun VaultBrowserScreen(
modifier: Modifier = Modifier,
viewModel: AbstractVaultBrowserViewModel,
openTextEdit: (String) -> Unit,
onOpenStorageHome: (String) -> Unit,
) {
val uiState by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
@@ -139,7 +139,7 @@ fun VaultBrowserScreen(
modifier = Modifier.padding(8.dp, 8.dp, 8.dp, 0.dp),
tree = listItem,
isUuidBusy = isUuidBusy,
onClick = { openTextEdit(it.value.uuid.toString()) },
onClick = { onOpenStorageHome(it.value.uuid.toString()) },
onRename = { tree, newName -> viewModel.rename(tree.value, newName) },
onRemove = { tree -> viewModel.remove(tree.value) },
onEncrypt = { tree, password, encryptPath ->

View File

@@ -4,6 +4,7 @@ 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
@@ -97,6 +98,7 @@ fun StorageSyncScreen(
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0.dp),
snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = {
FloatingActionButton(
@@ -427,7 +429,10 @@ private fun StoragePickerScreen(
) {
val selected = state.groups.firstOrNull { it.id == groupId }?.storageUuids ?: emptySet()
val groupEditLocked = state.isBusy || state.isStorageSyncRunning
Scaffold(modifier = modifier) { inner ->
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0.dp),
) { inner ->
Column(
modifier = Modifier
.padding(inner)

View File

@@ -175,5 +175,41 @@
<string name="text_edit_screen_title">Текст</string>
<string name="text_edit_screen_placeholder">Содержимое: %1$s</string>
<string name="storage_home_unnamed_storage">Storage</string>
<string name="storage_home_status_line">Статус: %1$s, %2$s</string>
<string name="storage_home_status_available">доступно</string>
<string name="storage_home_status_unavailable">недоступно</string>
<string name="storage_home_status_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_open_two_fa">Открыть 2FA</string>
<string name="storage_home_text_secrets_title">Текстовые секреты (%1$d)</string>
<string name="storage_home_open_text_secrets">Открыть текстовые секреты</string>
<string name="storage_home_future_sections">Скоро здесь появятся Files, Media и другие типы данных.</string>
<string name="two_fa_add_token">Добавить токен</string>
<string name="two_fa_empty_state">Пока нет 2FA токенов</string>
<string name="two_fa_create_title">Новый 2FA токен</string>
<string name="two_fa_edit_title">Редактирование 2FA токена</string>
<string name="two_fa_field_issuer">Сервис</string>
<string name="two_fa_field_account">Аккаунт</string>
<string name="two_fa_field_secret">Секрет</string>
<string name="two_fa_field_notes_optional">Заметка (опционально)</string>
<string name="text_secret_create">Создать секрет</string>
<string name="text_secret_edit">Редактировать секрет</string>
<string name="text_secret_title">Название</string>
<string name="text_secret_empty_state">Пока нет текстовых секретов</string>
<string name="text_secret_items_count">Элементов: %1$d</string>
<string name="text_secret_item_without_label">Без названия</string>
<string name="text_secret_item_label_optional">Название (опционально)</string>
<string name="text_secret_item_value">Значение</string>
<string name="text_secret_add_item">Добавить пару</string>
<string name="save">Сохранить</string>
<string name="cancel">Отмена</string>
<string name="open">Открыть</string>
<string name="edit">Редактировать</string>
<string name="common_unknown">Неизвестно</string>
</resources>

View File

@@ -0,0 +1,35 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets.TextSecretDetailsRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets.TextSecretEditRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets.TextSecretsRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa.TwoFaTokensRoute
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class StorageNavigationRoutesSmokeTest {
@Test
fun storageHomeRouteCarriesVaultAndStorageIds() {
val route = StorageHomeRoute(
vaultUuid = "vault-1",
storageUuid = "storage-1",
)
assertEquals("vault-1", route.vaultUuid)
assertEquals("storage-1", route.storageUuid)
}
@Test
fun textSecretsRoutesCarryRequiredArguments() {
val listRoute = TextSecretsRoute(storageUuid = "storage-1")
val detailsRoute = TextSecretDetailsRoute(storageUuid = "storage-1", secretId = "secret-1")
val editRoute = TextSecretEditRoute(storageUuid = "storage-1", secretId = null)
val twoFaRoute = TwoFaTokensRoute(storageUuid = "storage-1")
assertEquals("storage-1", listRoute.storageUuid)
assertEquals("secret-1", detailsRoute.secretId)
assertNull(editRoute.secretId)
assertEquals("storage-1", twoFaRoute.storageUuid)
}
}

View File

@@ -16,5 +16,6 @@ kotlin {
dependencies {
implementation(project(":domain"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
testImplementation(libs.junit)
}

View File

@@ -0,0 +1,15 @@
package com.github.nullptroma.wallenc.usecases
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import java.util.UUID
class FindStorageUseCase(
private val vaultsManager: IVaultsManager,
) {
fun find(storageUuid: UUID): IStorage? {
return vaultsManager.vaults.value
.flatMap { it.storages.value }
.firstOrNull { it.uuid == storageUuid }
}
}

View File

@@ -0,0 +1,135 @@
package com.github.nullptroma.wallenc.usecases
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.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
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)
}
suspend fun get(storageInfo: IStorageInfo, id: String): TextSecretRecord? = mutex.withLock {
val storage = storageInfo as? IStorage ?: return@withLock null
readAll(storage).firstOrNull { it.id == id }
}
suspend fun create(storageInfo: IStorageInfo, title: String, items: List<TextSecretEntryRecord>): TextSecretRecord =
mutex.withLock {
val storage = storageInfo as? IStorage ?: error("Storage is not writable")
val next = TextSecretRecord(
id = UUID.randomUUID().toString(),
title = title.trim(),
items = items.normalizeItems(),
)
val updated = readAll(storage).toMutableList().apply { add(next) }
writeAll(storage, updated)
next
}
suspend fun update(storageInfo: IStorageInfo, secret: TextSecretRecord): Boolean = mutex.withLock {
val storage = storageInfo as? IStorage ?: return@withLock false
val current = readAll(storage)
val index = current.indexOfFirst { it.id == secret.id }
if (index < 0) return@withLock false
val updatedSecret = secret.copy(
title = secret.title.trim(),
items = secret.items.normalizeItems(),
)
val updated = current.toMutableList().apply { this[index] = updatedSecret }
writeAll(storage, updated)
true
}
suspend fun delete(storageInfo: IStorageInfo, id: String): Boolean = mutex.withLock {
val storage = storageInfo as? IStorage ?: return@withLock false
val current = readAll(storage)
val updated = current.filterNot { it.id == id }
if (updated.size == current.size) return@withLock false
writeAll(storage, updated)
true
}
private suspend fun readAll(storage: IStorage): List<TextSecretRecord> {
return StorageDomainJsonIo.readArray(storage, StorageDomainDataFiles.TEXT_SECRETS_FILE)
.mapNotNull { parseSecret(it) }
}
private suspend fun writeAll(storage: IStorage, records: List<TextSecretRecord>) {
StorageDomainJsonIo.writeArray(
storage = storage,
fileName = StorageDomainDataFiles.TEXT_SECRETS_FILE,
data = records.map { encodeSecret(it) },
)
}
private fun parseSecret(element: JsonElement): TextSecretRecord? {
val obj = element as? JsonObject ?: return null
val id = obj["id"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } ?: return null
val title = obj["title"]?.jsonPrimitive?.contentOrNull ?: return null
val itemsElement = obj["items"] ?: JsonArray(emptyList())
val items = (itemsElement as? JsonArray)?.mapNotNull { parseItem(it) } ?: emptyList()
return TextSecretRecord(
id = id,
title = title,
items = items,
)
}
private fun parseItem(element: JsonElement): TextSecretEntryRecord? {
val obj = element as? JsonObject ?: return null
val value = obj["value"]?.jsonPrimitive?.contentOrNull ?: return null
val label = obj["label"]?.jsonPrimitive?.contentOrNull
return TextSecretEntryRecord(
label = label,
value = value,
)
}
private fun encodeSecret(record: TextSecretRecord): JsonElement = buildJsonObject {
put("id", JsonPrimitive(record.id))
put("title", JsonPrimitive(record.title))
put(
"items",
buildJsonArray {
record.items.forEach { item ->
add(
buildJsonObject {
item.label?.let { put("label", JsonPrimitive(it)) }
put("value", JsonPrimitive(item.value))
},
)
}
},
)
}
private fun List<TextSecretEntryRecord>.normalizeItems(): List<TextSecretEntryRecord> =
this.mapNotNull { item ->
val value = item.value.trim()
if (value.isBlank()) {
null
} else {
TextSecretEntryRecord(
label = item.label?.trim().takeUnless { it.isNullOrBlank() },
value = value,
)
}
}
}

View File

@@ -0,0 +1,110 @@
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.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive
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)
}
suspend fun get(storageInfo: IStorageInfo, id: String): TwoFaTokenRecord? = mutex.withLock {
val storage = storageInfo as? IStorage ?: return@withLock null
readAll(storage).firstOrNull { it.id == id }
}
suspend fun create(
storageInfo: IStorageInfo,
issuer: String,
account: String,
secret: String,
notes: String? = null,
): TwoFaTokenRecord = mutex.withLock {
val storage = storageInfo as? IStorage ?: error("Storage is not writable")
val next = TwoFaTokenRecord(
id = UUID.randomUUID().toString(),
issuer = issuer.trim(),
account = account.trim(),
secret = secret.trim(),
notes = notes?.trim().takeUnless { it.isNullOrBlank() },
)
val updated = readAll(storage).toMutableList().apply { add(next) }
writeAll(storage, updated)
next
}
suspend fun update(storageInfo: IStorageInfo, token: TwoFaTokenRecord): Boolean = mutex.withLock {
val storage = storageInfo as? IStorage ?: return@withLock false
val updatedToken = token.copy(
issuer = token.issuer.trim(),
account = token.account.trim(),
secret = token.secret.trim(),
notes = token.notes?.trim().takeUnless { it.isNullOrBlank() },
)
val current = readAll(storage)
val index = current.indexOfFirst { it.id == token.id }
if (index < 0) return@withLock false
val updated = current.toMutableList().apply { this[index] = updatedToken }
writeAll(storage, updated)
true
}
suspend fun delete(storageInfo: IStorageInfo, id: String): Boolean = mutex.withLock {
val storage = storageInfo as? IStorage ?: return@withLock false
val current = readAll(storage)
val updated = current.filterNot { it.id == id }
if (updated.size == current.size) return@withLock false
writeAll(storage, updated)
true
}
private suspend fun readAll(storage: IStorage): List<TwoFaTokenRecord> {
return StorageDomainJsonIo.readArray(storage, StorageDomainDataFiles.TWO_FA_TOKENS_FILE)
.mapNotNull { parseToken(it) }
}
private suspend fun writeAll(storage: IStorage, records: List<TwoFaTokenRecord>) {
StorageDomainJsonIo.writeArray(
storage = storage,
fileName = StorageDomainDataFiles.TWO_FA_TOKENS_FILE,
data = records.map { record -> encodeToken(record) },
)
}
private fun parseToken(element: JsonElement): TwoFaTokenRecord? {
val obj = element as? JsonObject ?: return null
val id = obj["id"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } ?: return null
val issuer = obj["issuer"]?.jsonPrimitive?.contentOrNull ?: return null
val account = obj["account"]?.jsonPrimitive?.contentOrNull ?: return null
val secret = obj["secret"]?.jsonPrimitive?.contentOrNull ?: return null
val notes = obj["notes"]?.jsonPrimitive?.contentOrNull
return TwoFaTokenRecord(
id = id,
issuer = issuer,
account = account,
secret = secret,
notes = notes,
)
}
private fun encodeToken(record: TwoFaTokenRecord): JsonElement = buildJsonObject {
put("id", JsonPrimitive(record.id))
put("issuer", JsonPrimitive(record.issuer))
put("account", JsonPrimitive(record.account))
put("secret", JsonPrimitive(record.secret))
record.notes?.let { put("notes", JsonPrimitive(it)) }
}
}

View File

@@ -0,0 +1,9 @@
package com.github.nullptroma.wallenc.usecases
/**
* Единая точка с путями обычных JSON-файлов пользовательских доменных данных.
*/
object StorageDomainDataFiles {
const val TWO_FA_TOKENS_FILE = "/two-fa-tokens.json"
const val TEXT_SECRETS_FILE = "/text-secrets.json"
}

View File

@@ -0,0 +1,40 @@
package com.github.nullptroma.wallenc.usecases
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.buildJsonArray
internal object StorageDomainJsonIo {
val json: Json = Json {
prettyPrint = true
ignoreUnknownKeys = true
explicitNulls = false
}
suspend fun readArray(storage: IStorage, fileName: String): JsonArray {
return try {
val text = storage.accessor.openRead(fileName).use { stream ->
stream.readBytes().decodeToString()
}
if (text.isBlank()) {
JsonArray(emptyList())
} else {
when (val parsed = json.parseToJsonElement(text)) {
is JsonArray -> parsed
else -> JsonArray(emptyList())
}
}
} catch (_: Exception) {
JsonArray(emptyList())
}
}
suspend fun writeArray(storage: IStorage, fileName: String, data: List<JsonElement>) {
val payload = buildJsonArray { data.forEach { add(it) } }
storage.accessor.openWrite(fileName).use { stream ->
stream.write(json.encodeToString(JsonArray.serializer(), payload).encodeToByteArray())
}
}
}

View File

@@ -0,0 +1,222 @@
package com.github.nullptroma.wallenc.usecases
import com.github.nullptroma.wallenc.domain.datatypes.DataPage
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretEntryRecord
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncLock
import com.github.nullptroma.wallenc.domain.interfaces.IDirectory
import com.github.nullptroma.wallenc.domain.interfaces.IFile
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.io.OutputStream
import java.time.Instant
import java.util.UUID
class StorageDomainUseCasesTest {
@Test
fun twoFaCrudWorksAndPersists() = runBlocking {
val storage = FakeStorage()
val useCase = ManageTwoFaTokensUseCase()
val created = useCase.create(
storageInfo = storage,
issuer = "GitHub",
account = "user@example.com",
secret = "SECRET",
notes = "primary",
)
assertNotNull(created.id)
assertEquals(1, useCase.list(storage).size)
val updated = useCase.update(
storageInfo = storage,
token = created.copy(issuer = "GitHubUpdated"),
)
assertTrue(updated)
assertEquals("GitHubUpdated", useCase.get(storage, created.id)?.issuer)
val removed = useCase.delete(storage, created.id)
assertTrue(removed)
assertTrue(useCase.list(storage).isEmpty())
}
@Test
fun twoFaInvalidJsonFallsBackToEmptyList() = runBlocking {
val storage = FakeStorage().apply {
setDomainFile(StorageDomainDataFiles.TWO_FA_TOKENS_FILE, "not-json")
}
val useCase = ManageTwoFaTokensUseCase()
assertTrue(useCase.list(storage).isEmpty())
}
@Test
fun textSecretsCrudWorksWithOptionalLabels() = runBlocking {
val storage = FakeStorage()
val useCase = ManageTextSecretsUseCase()
val created = useCase.create(
storageInfo = storage,
title = "Server Credentials",
items = listOf(
TextSecretEntryRecord(label = "username", value = "admin"),
TextSecretEntryRecord(label = null, value = "password"),
),
)
assertEquals(1, useCase.list(storage).size)
val updated = useCase.update(
storageInfo = storage,
secret = created.copy(
title = "Prod Credentials",
items = created.items + TextSecretEntryRecord(label = "url", value = "https://x.dev"),
),
)
assertTrue(updated)
val loaded = useCase.get(storage, created.id)
assertEquals("Prod Credentials", loaded?.title)
assertEquals(3, loaded?.items?.size)
val deleted = useCase.delete(storage, created.id)
assertTrue(deleted)
assertNull(useCase.get(storage, created.id))
}
@Test
fun textSecretsInvalidJsonFallsBackToEmptyList() = runBlocking {
val storage = FakeStorage().apply {
setDomainFile(StorageDomainDataFiles.TEXT_SECRETS_FILE, "{broken")
}
val useCase = ManageTextSecretsUseCase()
assertTrue(useCase.list(storage).isEmpty())
}
}
private class FakeStorage : IStorage {
private val accessorImpl = FakeStorageAccessor()
override val uuid: UUID = UUID.randomUUID()
override val isAvailable: StateFlow<Boolean> = MutableStateFlow(true)
override val size: StateFlow<Long?> = MutableStateFlow(0L)
override val numberOfFiles: StateFlow<Int?> = MutableStateFlow(0)
override val isEmpty: Flow<Boolean?> = flowOf(true)
override val metaInfo: StateFlow<IStorageMetaInfo> = MutableStateFlow(FakeMetaInfo())
override val isVirtualStorage: Boolean = false
override val accessor: IStorageAccessor = accessorImpl
override suspend fun rename(newName: String) = Unit
override suspend fun setEncInfo(encInfo: StorageEncryptionInfo?) = Unit
override suspend fun clearAllContent(onProgress: suspend (TaskProgress) -> Unit) = Unit
fun setDomainFile(path: String, value: String) {
accessorImpl.dataFiles[path] = value.encodeToByteArray()
}
}
private class FakeMetaInfo : IStorageMetaInfo {
override val encInfo: StorageEncryptionInfo? = null
override val name: String? = "Fake"
override val lastModified: Instant = Instant.now()
}
private class FakeStorageAccessor : IStorageAccessor {
val dataFiles: MutableMap<String, ByteArray> = mutableMapOf()
private val systemFiles: MutableMap<String, ByteArray> = mutableMapOf()
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 dirsUpdates: SharedFlow<DataPage<IDirectory>> = MutableSharedFlow()
override suspend fun getAllFiles(): List<IFile> = emptyList()
override suspend fun getFiles(path: String): List<IFile> = emptyList()
override fun getFilesFlow(path: String): Flow<DataPage<IFile>> = emptyFlow()
override suspend fun getAllDirs(): List<IDirectory> = emptyList()
override suspend fun getDirs(path: String): List<IDirectory> = emptyList()
override fun getDirsFlow(path: String): Flow<DataPage<IDirectory>> = emptyFlow()
override suspend fun getFileInfo(path: String): IFile {
error("Not implemented in tests")
}
override suspend fun getDirInfo(path: String): IDirectory {
error("Not implemented in tests")
}
override suspend fun setHidden(path: String, hidden: Boolean) = Unit
override suspend fun touchFile(path: String) = Unit
override suspend fun touchDir(path: String) = Unit
override suspend fun delete(path: String) = Unit
override suspend fun openWrite(path: String): OutputStream {
return object : ByteArrayOutputStream() {
override fun close() {
dataFiles[path] = toByteArray()
}
}
}
override suspend fun openRead(path: String): InputStream {
val bytes = dataFiles[path] ?: throw IllegalStateException("File not found: $path")
return ByteArrayInputStream(bytes)
}
override suspend fun moveToTrash(path: String) = Unit
override suspend fun openReadSystemFile(name: String): InputStream {
val bytes = systemFiles[name] ?: ByteArray(0)
return ByteArrayInputStream(bytes)
}
override suspend fun openWriteSystemFile(name: String): OutputStream {
return object : ByteArrayOutputStream() {
override fun close() {
systemFiles[name] = toByteArray()
}
}
}
override suspend fun readSyncJournal(): List<StorageSyncJournalEntry> = emptyList()
override suspend fun appendSyncJournal(entries: List<StorageSyncJournalEntry>) = Unit
override suspend fun rewriteSyncJournal(entries: List<StorageSyncJournalEntry>) = Unit
override suspend fun readSyncLock(): StorageSyncLock? = null
override suspend fun tryAcquireSyncLock(holderId: String, leaseUntil: Instant): Boolean = true
override suspend fun releaseSyncLock(holderId: String) = Unit
override suspend fun forceClearSyncLock() = Unit
}