feat(storage): добавлены маршруты и экраны для управления текстовыми секретами и 2FA токенами
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -16,5 +16,6 @@ kotlin {
|
||||
dependencies {
|
||||
implementation(project(":domain"))
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user