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.tasks.ITaskOrchestrator
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
|
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
|
||||||
import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase
|
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.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.ManageVaultUseCase
|
||||||
import com.github.nullptroma.wallenc.usecases.RemoveStorageUseCase
|
import com.github.nullptroma.wallenc.usecases.RemoveStorageUseCase
|
||||||
import com.github.nullptroma.wallenc.usecases.RenameStorageUseCase
|
import com.github.nullptroma.wallenc.usecases.RenameStorageUseCase
|
||||||
@@ -35,6 +38,12 @@ class UseCasesModule {
|
|||||||
return ManageVaultUseCase(vaultsManager)
|
return ManageVaultUseCase(vaultsManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideFindStorageUseCase(vaultsManager: IVaultsManager): FindStorageUseCase {
|
||||||
|
return FindStorageUseCase(vaultsManager)
|
||||||
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideStorageFileManagementUseCase(): StorageFileManagementUseCase {
|
fun provideStorageFileManagementUseCase(): StorageFileManagementUseCase {
|
||||||
@@ -65,6 +74,18 @@ class UseCasesModule {
|
|||||||
return RemoveStorageUseCase(vaultsManager, unlockManager, manageStoragesEncryptionUseCase)
|
return RemoveStorageUseCase(vaultsManager, unlockManager, manageStoragesEncryptionUseCase)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideManageTwoFaTokensUseCase(): ManageTwoFaTokensUseCase {
|
||||||
|
return ManageTwoFaTokensUseCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideManageTextSecretsUseCase(): ManageTextSecretsUseCase {
|
||||||
|
return ManageTextSecretsUseCase()
|
||||||
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideStorageSyncEngine(
|
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.RemoteVaultsRoute
|
||||||
import com.github.nullptroma.wallenc.ui.screens.main.screens.remotes.RemoteVaultsScreen
|
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.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.LocalVaultRoute
|
||||||
import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.LocalVaultScreen
|
import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.LocalVaultScreen
|
||||||
import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.LocalVaultViewModel
|
import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.LocalVaultViewModel
|
||||||
@@ -141,8 +151,8 @@ fun MainScreen(
|
|||||||
) {
|
) {
|
||||||
LocalVaultScreen(
|
LocalVaultScreen(
|
||||||
viewModel = localVaultViewModel,
|
viewModel = localVaultViewModel,
|
||||||
openTextEdit = { text ->
|
onOpenStorageHome = { storageUuid ->
|
||||||
navState.push(TextEditRoute(text))
|
navState.push(StorageHomeRoute(storageUuid = storageUuid))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -162,10 +172,95 @@ fun MainScreen(
|
|||||||
exitTransition = { fadeOut(tween(200)) },
|
exitTransition = { fadeOut(tween(200)) },
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val remoteVaultViewModel: RemoteVaultViewModel = hiltViewModel(entry)
|
val remoteVaultViewModel: RemoteVaultViewModel = hiltViewModel(entry)
|
||||||
|
val route: VaultBrowserRoute = entry.toRoute()
|
||||||
VaultBrowserScreen(
|
VaultBrowserScreen(
|
||||||
viewModel = remoteVaultViewModel,
|
viewModel = remoteVaultViewModel,
|
||||||
openTextEdit = { text ->
|
onOpenStorageHome = { storageUuid ->
|
||||||
navState.push(TextEditRoute(text))
|
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(
|
fun LocalVaultScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: LocalVaultViewModel = hiltViewModel(),
|
viewModel: LocalVaultViewModel = hiltViewModel(),
|
||||||
openTextEdit: (String) -> Unit,
|
onOpenStorageHome: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
VaultBrowserScreen(
|
VaultBrowserScreen(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
openTextEdit = openTextEdit,
|
onOpenStorageHome = onOpenStorageHome,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import java.util.UUID
|
|||||||
fun VaultBrowserScreen(
|
fun VaultBrowserScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: AbstractVaultBrowserViewModel,
|
viewModel: AbstractVaultBrowserViewModel,
|
||||||
openTextEdit: (String) -> Unit,
|
onOpenStorageHome: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.state.collectAsStateWithLifecycle()
|
val uiState by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -139,7 +139,7 @@ fun VaultBrowserScreen(
|
|||||||
modifier = Modifier.padding(8.dp, 8.dp, 8.dp, 0.dp),
|
modifier = Modifier.padding(8.dp, 8.dp, 8.dp, 0.dp),
|
||||||
tree = listItem,
|
tree = listItem,
|
||||||
isUuidBusy = isUuidBusy,
|
isUuidBusy = isUuidBusy,
|
||||||
onClick = { openTextEdit(it.value.uuid.toString()) },
|
onClick = { onOpenStorageHome(it.value.uuid.toString()) },
|
||||||
onRename = { tree, newName -> viewModel.rename(tree.value, newName) },
|
onRename = { tree, newName -> viewModel.rename(tree.value, newName) },
|
||||||
onRemove = { tree -> viewModel.remove(tree.value) },
|
onRemove = { tree -> viewModel.remove(tree.value) },
|
||||||
onEncrypt = { tree, password, encryptPath ->
|
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.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -97,6 +98,7 @@ fun StorageSyncScreen(
|
|||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
contentWindowInsets = WindowInsets(0.dp),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
@@ -427,7 +429,10 @@ private fun StoragePickerScreen(
|
|||||||
) {
|
) {
|
||||||
val selected = state.groups.firstOrNull { it.id == groupId }?.storageUuids ?: emptySet()
|
val selected = state.groups.firstOrNull { it.id == groupId }?.storageUuids ?: emptySet()
|
||||||
val groupEditLocked = state.isBusy || state.isStorageSyncRunning
|
val groupEditLocked = state.isBusy || state.isStorageSyncRunning
|
||||||
Scaffold(modifier = modifier) { inner ->
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
contentWindowInsets = WindowInsets(0.dp),
|
||||||
|
) { inner ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(inner)
|
.padding(inner)
|
||||||
|
|||||||
@@ -175,5 +175,41 @@
|
|||||||
<string name="text_edit_screen_title">Текст</string>
|
<string name="text_edit_screen_title">Текст</string>
|
||||||
<string name="text_edit_screen_placeholder">Содержимое: %1$s</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>
|
<string name="common_unknown">Неизвестно</string>
|
||||||
</resources>
|
</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 {
|
dependencies {
|
||||||
implementation(project(":domain"))
|
implementation(project(":domain"))
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
testImplementation(libs.junit)
|
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