From 5777f8e45955b51f2bc0a591758d41c21465cef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=8B=D1=82=D0=BA=D0=BE=D0=B2=20=D0=A0=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD?= Date: Wed, 13 May 2026 20:39:55 +0300 Subject: [PATCH] =?UTF-8?q?feat(storage):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B8=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=BC=D0=B8=20=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8=20=D0=B8=202FA=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/di/modules/domain/UseCasesModule.kt | 21 ++ .../domain/datatypes/StorageDomainRecords.kt | 20 ++ .../wallenc/ui/screens/main/MainScreen.kt | 103 +++++++- .../main/screens/storage/StorageHomeRoute.kt | 12 + .../main/screens/storage/StorageHomeScreen.kt | 133 +++++++++++ .../screens/storage/StorageHomeScreenState.kt | 15 ++ .../screens/storage/StorageHomeViewModel.kt | 58 +++++ .../main/screens/storage/StorageRouteArgs.kt | 11 + .../storage/secrets/TextSecretDetailsRoute.kt | 12 + .../secrets/TextSecretDetailsScreen.kt | 85 +++++++ .../secrets/TextSecretDetailsScreenState.kt | 12 + .../secrets/TextSecretDetailsViewModel.kt | 62 +++++ .../storage/secrets/TextSecretEditRoute.kt | 12 + .../storage/secrets/TextSecretEditScreen.kt | 143 +++++++++++ .../secrets/TextSecretEditScreenState.kt | 12 + .../secrets/TextSecretEditViewModel.kt | 83 +++++++ .../storage/secrets/TextSecretsRoute.kt | 11 + .../storage/secrets/TextSecretsScreen.kt | 99 ++++++++ .../storage/secrets/TextSecretsScreenState.kt | 12 + .../storage/secrets/TextSecretsViewModel.kt | 50 ++++ .../screens/storage/twofa/TwoFaTokensRoute.kt | 11 + .../storage/twofa/TwoFaTokensScreen.kt | 204 ++++++++++++++++ .../storage/twofa/TwoFaTokensScreenState.kt | 12 + .../storage/twofa/TwoFaTokensViewModel.kt | 92 ++++++++ .../main/screens/vault/LocalVaultScreen.kt | 4 +- .../main/screens/vault/VaultBrowserScreen.kt | 4 +- .../ui/screens/sync/StorageSyncScreen.kt | 7 +- ui/src/main/res/values/strings.xml | 36 +++ .../StorageNavigationRoutesSmokeTest.kt | 35 +++ usecases/build.gradle.kts | 1 + .../wallenc/usecases/FindStorageUseCase.kt | 15 ++ .../usecases/ManageTextSecretsUseCase.kt | 135 +++++++++++ .../usecases/ManageTwoFaTokensUseCase.kt | 110 +++++++++ .../usecases/StorageDomainDataFiles.kt | 9 + .../wallenc/usecases/StorageDomainJsonIo.kt | 40 ++++ .../usecases/StorageDomainUseCasesTest.kt | 222 ++++++++++++++++++ 36 files changed, 1894 insertions(+), 9 deletions(-) create mode 100644 domain/src/main/java/com/github/nullptroma/wallenc/domain/datatypes/StorageDomainRecords.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeRoute.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreenState.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeViewModel.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageRouteArgs.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsRoute.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreen.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreenState.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsViewModel.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditRoute.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreen.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreenState.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditViewModel.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsRoute.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreen.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreenState.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsViewModel.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensRoute.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreenState.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt create mode 100644 ui/src/test/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageNavigationRoutesSmokeTest.kt create mode 100644 usecases/src/main/java/com/github/nullptroma/wallenc/usecases/FindStorageUseCase.kt create mode 100644 usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTextSecretsUseCase.kt create mode 100644 usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt create mode 100644 usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageDomainDataFiles.kt create mode 100644 usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageDomainJsonIo.kt create mode 100644 usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageDomainUseCasesTest.kt diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/domain/UseCasesModule.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/domain/UseCasesModule.kt index 19d9e19..c16500c 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/domain/UseCasesModule.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/domain/UseCasesModule.kt @@ -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( diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/datatypes/StorageDomainRecords.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/datatypes/StorageDomainRecords.kt new file mode 100644 index 0000000..3d64afb --- /dev/null +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/datatypes/StorageDomainRecords.kt @@ -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, +) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainScreen.kt index 214d1d1..a181cc4 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainScreen.kt @@ -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( + enterTransition = { fadeIn(tween(200)) }, + exitTransition = { fadeOut(tween(200)) }, + ) { + StorageHomeScreen( + onOpenTwoFa = { storageUuid -> + navState.push(TwoFaTokensRoute(storageUuid)) + }, + onOpenTextSecrets = { storageUuid -> + navState.push(TextSecretsRoute(storageUuid)) + }, + ) + } + composable( + enterTransition = { fadeIn(tween(200)) }, + exitTransition = { fadeOut(tween(200)) }, + ) { + TwoFaTokensScreen() + } + composable( + 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( + 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( + 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, + ), + ) }, ) } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeRoute.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeRoute.kt new file mode 100644 index 0000000..fc21c2f --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeRoute.kt @@ -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() diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt new file mode 100644 index 0000000..dab33ef --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt @@ -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, + ) + } + } +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreenState.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreenState.kt new file mode 100644 index 0000000..5882367 --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreenState.kt @@ -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, +) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeViewModel.kt new file mode 100644 index 0000000..2892f64 --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeViewModel.kt @@ -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()) { + + 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, + ), + ) + } + } +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageRouteArgs.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageRouteArgs.kt new file mode 100644 index 0000000..9241efa --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageRouteArgs.kt @@ -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("storageUuid") ?: error("Missing storage UUID in navigation arguments") + return UUID.fromString(raw) +} + +internal fun SavedStateHandle.optionalSecretId(): String? = get("secretId") diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsRoute.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsRoute.kt new file mode 100644 index 0000000..ef51ed8 --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsRoute.kt @@ -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() diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreen.kt new file mode 100644 index 0000000..21fc0dd --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreen.kt @@ -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)) + } + } + } +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreenState.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreenState.kt new file mode 100644 index 0000000..42f938b --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreenState.kt @@ -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, +) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsViewModel.kt new file mode 100644 index 0000000..6be885c --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsViewModel.kt @@ -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()) { + + private val storageUuid = savedStateHandle.requireStorageUuid() + private val secretId: String = savedStateHandle.get("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() + } + } + } +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditRoute.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditRoute.kt new file mode 100644 index 0000000..668ee6b --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditRoute.kt @@ -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() diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreen.kt new file mode 100644 index 0000000..b25760c --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreen.kt @@ -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().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)) + } + } + } + } +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreenState.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreenState.kt new file mode 100644 index 0000000..531e05f --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreenState.kt @@ -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, +) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditViewModel.kt new file mode 100644 index 0000000..7927506 --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditViewModel.kt @@ -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()) { + + 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, + 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) + } + } + } +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsRoute.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsRoute.kt new file mode 100644 index 0000000..add9da2 --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsRoute.kt @@ -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() diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreen.kt new file mode 100644 index 0000000..49974d8 --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreen.kt @@ -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)) + } + } + } + } + } + } + } +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreenState.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreenState.kt new file mode 100644 index 0000000..50cf6e2 --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreenState.kt @@ -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 = emptyList(), + val errorMessage: String? = null, +) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsViewModel.kt new file mode 100644 index 0000000..a3f9f13 --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsViewModel.kt @@ -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()) { + + 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, + ), + ) + } + } +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensRoute.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensRoute.kt new file mode 100644 index 0000000..95abaa0 --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensRoute.kt @@ -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() diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt new file mode 100644 index 0000000..8d3ab19 --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt @@ -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(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)) + } + }, + ) +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreenState.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreenState.kt new file mode 100644 index 0000000..959bef8 --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreenState.kt @@ -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 = emptyList(), + val errorMessage: String? = null, +) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt new file mode 100644 index 0000000..4b2d2be --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt @@ -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()) { + + 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() + } + } +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/LocalVaultScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/LocalVaultScreen.kt index 8220e14..2447b16 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/LocalVaultScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/LocalVaultScreen.kt @@ -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, ) } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreen.kt index 0375ac0..1575aa6 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreen.kt @@ -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 -> diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreen.kt index fbf13fe..b768114 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreen.kt @@ -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) diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index c830c07..ca17f82 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -175,5 +175,41 @@ Текст Содержимое: %1$s + Storage + Статус: %1$s, %2$s + доступно + недоступно + зашифровано + не зашифровано + 2FA токены (%1$d) + Открыть 2FA + Текстовые секреты (%1$d) + Открыть текстовые секреты + Скоро здесь появятся Files, Media и другие типы данных. + + Добавить токен + Пока нет 2FA токенов + Новый 2FA токен + Редактирование 2FA токена + Сервис + Аккаунт + Секрет + Заметка (опционально) + + Создать секрет + Редактировать секрет + Название + Пока нет текстовых секретов + Элементов: %1$d + Без названия + Название (опционально) + Значение + Добавить пару + + Сохранить + Отмена + Открыть + Редактировать + Неизвестно diff --git a/ui/src/test/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageNavigationRoutesSmokeTest.kt b/ui/src/test/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageNavigationRoutesSmokeTest.kt new file mode 100644 index 0000000..2af4312 --- /dev/null +++ b/ui/src/test/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageNavigationRoutesSmokeTest.kt @@ -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) + } +} diff --git a/usecases/build.gradle.kts b/usecases/build.gradle.kts index 07c4e1f..625ac0f 100644 --- a/usecases/build.gradle.kts +++ b/usecases/build.gradle.kts @@ -16,5 +16,6 @@ kotlin { dependencies { implementation(project(":domain")) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) testImplementation(libs.junit) } diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/FindStorageUseCase.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/FindStorageUseCase.kt new file mode 100644 index 0000000..fd268e7 --- /dev/null +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/FindStorageUseCase.kt @@ -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 } + } +} diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTextSecretsUseCase.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTextSecretsUseCase.kt new file mode 100644 index 0000000..6ebbe24 --- /dev/null +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTextSecretsUseCase.kt @@ -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 = 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): 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 { + return StorageDomainJsonIo.readArray(storage, StorageDomainDataFiles.TEXT_SECRETS_FILE) + .mapNotNull { parseSecret(it) } + } + + private suspend fun writeAll(storage: IStorage, records: List) { + 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.normalizeItems(): List = + this.mapNotNull { item -> + val value = item.value.trim() + if (value.isBlank()) { + null + } else { + TextSecretEntryRecord( + label = item.label?.trim().takeUnless { it.isNullOrBlank() }, + value = value, + ) + } + } +} diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt new file mode 100644 index 0000000..bceec26 --- /dev/null +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt @@ -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 = 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 { + return StorageDomainJsonIo.readArray(storage, StorageDomainDataFiles.TWO_FA_TOKENS_FILE) + .mapNotNull { parseToken(it) } + } + + private suspend fun writeAll(storage: IStorage, records: List) { + 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)) } + } +} diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageDomainDataFiles.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageDomainDataFiles.kt new file mode 100644 index 0000000..6a96fc3 --- /dev/null +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageDomainDataFiles.kt @@ -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" +} diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageDomainJsonIo.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageDomainJsonIo.kt new file mode 100644 index 0000000..ce233bd --- /dev/null +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/StorageDomainJsonIo.kt @@ -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) { + val payload = buildJsonArray { data.forEach { add(it) } } + storage.accessor.openWrite(fileName).use { stream -> + stream.write(json.encodeToString(JsonArray.serializer(), payload).encodeToByteArray()) + } + } +} diff --git a/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageDomainUseCasesTest.kt b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageDomainUseCasesTest.kt new file mode 100644 index 0000000..612d61e --- /dev/null +++ b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageDomainUseCasesTest.kt @@ -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 = MutableStateFlow(true) + override val size: StateFlow = MutableStateFlow(0L) + override val numberOfFiles: StateFlow = MutableStateFlow(0) + override val isEmpty: Flow = flowOf(true) + override val metaInfo: StateFlow = 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 = mutableMapOf() + private val systemFiles: MutableMap = mutableMapOf() + + override val size: StateFlow = MutableStateFlow(0L) + override val numberOfFiles: StateFlow = MutableStateFlow(0) + override val isAvailable: StateFlow = MutableStateFlow(true) + override val filesUpdates: SharedFlow> = MutableSharedFlow() + override val dirsUpdates: SharedFlow> = MutableSharedFlow() + + override suspend fun getAllFiles(): List = emptyList() + + override suspend fun getFiles(path: String): List = emptyList() + + override fun getFilesFlow(path: String): Flow> = emptyFlow() + + override suspend fun getAllDirs(): List = emptyList() + + override suspend fun getDirs(path: String): List = emptyList() + + override fun getDirsFlow(path: String): Flow> = 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 = emptyList() + + override suspend fun appendSyncJournal(entries: List) = Unit + + override suspend fun rewriteSyncJournal(entries: List) = 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 +}