Полное управление шифрованием и ключами

This commit is contained in:
2026-04-18 17:36:29 +03:00
parent 3455b91bca
commit db9463c2c6
18 changed files with 484 additions and 128 deletions

View File

@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Checkbox
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
@@ -21,6 +22,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@@ -92,4 +94,99 @@ fun ConfirmationCancelOkDialog(onDismiss: () -> Unit, onConfirmation: () -> Unit
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EncryptionSetupDialog(
onDismiss: () -> Unit,
onConfirmation: (password: String, encryptPath: Boolean) -> Unit,
) {
var password by remember { mutableStateOf("") }
var encryptPath by remember { mutableStateOf(false) }
BasicAlertDialog(onDismissRequest = onDismiss) {
Card {
Column(modifier = Modifier.padding(12.dp)) {
Text("Enable encryption", style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(12.dp))
TextField(value = password, onValueChange = { password = it }, label = { Text("Password") })
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = encryptPath, onCheckedChange = { encryptPath = it })
Text("Encrypt paths")
}
Spacer(modifier = Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
Button(modifier = Modifier.weight(1f), onClick = onDismiss) { Text("Cancel") }
Spacer(modifier = Modifier.width(12.dp))
Button(
modifier = Modifier.weight(1f),
onClick = { onConfirmation(password, encryptPath) },
enabled = password.isNotEmpty()
) { Text("Apply") }
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OpenEncryptedStorageDialog(
onDismiss: () -> Unit,
onConfirmation: (password: String, rememberPassword: Boolean) -> Unit,
) {
var password by remember { mutableStateOf("") }
var rememberPassword by remember { mutableStateOf(false) }
BasicAlertDialog(onDismissRequest = onDismiss) {
Card {
Column(modifier = Modifier.padding(12.dp)) {
Text("Open encrypted storage", style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(12.dp))
TextField(value = password, onValueChange = { password = it }, label = { Text("Password") })
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = rememberPassword, onCheckedChange = { rememberPassword = it })
Text("Remember password")
}
Spacer(modifier = Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
Button(modifier = Modifier.weight(1f), onClick = onDismiss) { Text("Cancel") }
Spacer(modifier = Modifier.width(12.dp))
Button(
modifier = Modifier.weight(1f),
onClick = { onConfirmation(password, rememberPassword) },
enabled = password.isNotEmpty()
) { Text("Open") }
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StorageEncryptionActionsDialog(
onDismiss: () -> Unit,
title: String,
isOpened: Boolean,
onOpen: () -> Unit,
onClose: () -> Unit,
onDisable: () -> Unit,
) {
BasicAlertDialog(onDismissRequest = onDismiss) {
Card {
Column(modifier = Modifier.padding(12.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(12.dp))
if (isOpened) {
Button(onClick = onClose, modifier = Modifier.fillMaxWidth()) { Text("Close") }
} else {
Button(onClick = onOpen, modifier = Modifier.fillMaxWidth()) { Text("Open") }
}
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onDisable, modifier = Modifier.fillMaxWidth()) { Text("Disable encryption") }
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { Text("Done") }
}
}
}
}

View File

@@ -18,8 +18,9 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
@@ -60,7 +61,12 @@ fun StorageTree(
onClick: (Tree<IStorageInfo>) -> Unit,
onRename: (Tree<IStorageInfo>, String) -> Unit,
onRemove: (Tree<IStorageInfo>) -> Unit,
onEncrypt: (Tree<IStorageInfo>) -> Unit,
onEncrypt: (Tree<IStorageInfo>, String, Boolean) -> Unit,
onOpenEncrypted: (Tree<IStorageInfo>, String, Boolean) -> Unit,
onCloseEncrypted: (Tree<IStorageInfo>) -> Unit,
onDisableEncryption: (Tree<IStorageInfo>) -> Unit,
getStatusText: (Tree<IStorageInfo>) -> String,
isEncryptionOpened: (Tree<IStorageInfo>) -> Boolean,
) {
val cur = tree.value
val available by cur.isAvailable.collectAsStateWithLifecycle()
@@ -68,6 +74,8 @@ fun StorageTree(
val size by cur.size.collectAsStateWithLifecycle()
val metaInfo by cur.metaInfo.collectAsStateWithLifecycle()
val isAvailable by cur.isAvailable.collectAsStateWithLifecycle()
val isEncrypted = metaInfo.encInfo != null
val isOpened = isEncryptionOpened(tree)
val borderColor =
if (cur.isVirtualStorage) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.primary
Column(modifier) {
@@ -120,10 +128,13 @@ fun StorageTree(
modifier = Modifier,
horizontalAlignment = Alignment.End
) {
var expanded by remember { mutableStateOf(false) }
var showRenameDialog by remember { mutableStateOf(false) }
var showRemoveConfirmDialog by remember { mutableStateOf(false) }
var showLockDialog by remember { mutableStateOf(false) }
var showSetupEncryptionDialog by remember { mutableStateOf(false) }
var showOpenEncryptionDialog by remember { mutableStateOf(false) }
Box(modifier = Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp)) {
var expanded by remember { mutableStateOf(false) }
var showRenameDialog by remember { mutableStateOf(false) }
var showRemoveConfirmationDiaglog by remember { mutableStateOf(false) }
IconButton(onClick = { expanded = !expanded }) {
Icon(
Icons.Default.MoreVert,
@@ -145,10 +156,20 @@ fun StorageTree(
DropdownMenuItem(
onClick = {
expanded = false
showRemoveConfirmationDiaglog = true;
showRemoveConfirmDialog = true
},
text = { Text(stringResource(R.string.remove)) }
)
if (!isEncrypted) {
HorizontalDivider()
DropdownMenuItem(
onClick = {
expanded = false
showSetupEncryptionDialog = true
},
text = { Text(stringResource(R.string.encrypt)) }
)
}
}
if (showRenameDialog) {
@@ -163,26 +184,78 @@ fun StorageTree(
)
}
if (showRemoveConfirmationDiaglog) {
if (showRemoveConfirmDialog) {
ConfirmationCancelOkDialog(
onDismiss = {
showRemoveConfirmationDiaglog = false
},
onConfirmation = {
showRemoveConfirmationDiaglog = false
onRemove(tree)
},
onDismiss = { showRemoveConfirmDialog = false },
title = stringResource(
R.string.remove_confirmation_dialog,
metaInfo.name ?: "<noname>"
)
),
onConfirmation = {
showRemoveConfirmDialog = false
onRemove(tree)
}
)
}
if (showLockDialog) {
StorageEncryptionActionsDialog(
onDismiss = { showLockDialog = false },
title = metaInfo.name ?: stringResource(R.string.no_name),
isOpened = isOpened,
onOpen = {
showLockDialog = false
showOpenEncryptionDialog = true
},
onClose = {
showLockDialog = false
onCloseEncrypted(tree)
},
onDisable = {
showLockDialog = false
onDisableEncryption(tree)
}
)
}
if (showSetupEncryptionDialog) {
EncryptionSetupDialog(
onDismiss = { showSetupEncryptionDialog = false },
onConfirmation = { password, encryptPath ->
showSetupEncryptionDialog = false
onEncrypt(tree, password, encryptPath)
}
)
}
if (showOpenEncryptionDialog) {
OpenEncryptedStorageDialog(
onDismiss = { showOpenEncryptionDialog = false },
onConfirmation = { password, rememberPassword ->
showOpenEncryptionDialog = false
onOpenEncrypted(tree, password, rememberPassword)
}
)
}
}
Spacer(modifier = Modifier.weight(1f))
Button(onClick = { onEncrypt(tree) }, enabled = metaInfo.encInfo == null) {
Text("Encrypt")
if (isEncrypted) {
IconButton(onClick = { showLockDialog = true }) {
Icon(
if (isOpened) Icons.Default.LockOpen else Icons.Default.Lock,
contentDescription = stringResource(R.string.storage_lock_actions)
)
}
}
Text(
modifier = Modifier
.fillMaxWidth()
.padding(0.dp, 0.dp, 12.dp, 0.dp)
.align(Alignment.End),
text = getStatusText(tree),
textAlign = TextAlign.End,
fontSize = 11.sp,
)
Text(
modifier = Modifier
.fillMaxWidth()
@@ -221,7 +294,12 @@ fun StorageTree(
onClick,
onRename,
onRemove,
onEncrypt
onEncrypt,
onOpenEncrypted,
onCloseEncrypted,
onDisableEncryption,
getStatusText,
isEncryptionOpened
)
}
}

View File

@@ -1,9 +1,7 @@
package com.github.nullptroma.wallenc.presentation.screens.main.screens.local.vault
import android.widget.ProgressBar
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
@@ -23,16 +21,18 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.presentation.elements.StorageTree
import com.github.nullptroma.wallenc.presentation.extensions.gesturesDisabled
import kotlinx.coroutines.flow.collect
@Composable
fun LocalVaultScreen(
@@ -42,6 +42,13 @@ fun LocalVaultScreen(
) {
val uiState by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.messages.collect { message ->
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
Box {
Scaffold(modifier = modifier, contentWindowInsets = WindowInsets(0.dp), floatingActionButton = {
FloatingActionButton(
@@ -66,9 +73,24 @@ fun LocalVaultScreen(
onRemove = { tree ->
viewModel.remove(tree.value)
},
onEncrypt = { tree ->
viewModel.enableEncryptionAndOpenStorage(tree.value)
}
onEncrypt = { tree, password, encryptPath ->
viewModel.enableEncryption(tree.value, password, encryptPath)
},
onOpenEncrypted = { tree, password, remember ->
viewModel.openEncryptedStorage(tree.value, password, remember)
},
onCloseEncrypted = { tree ->
viewModel.closeEncryptedStorage(tree.value)
},
onDisableEncryption = { tree ->
viewModel.disableEncryption(tree.value)
},
getStatusText = { tree ->
viewModel.getStorageStatus(tree.value)
},
isEncryptionOpened = { tree ->
viewModel.isEncryptionSessionOpen(tree.value)
},
)
}
item {

View File

@@ -7,7 +7,6 @@ import com.github.nullptroma.wallenc.domain.interfaces.IDirectory
import com.github.nullptroma.wallenc.domain.interfaces.IFile
import com.github.nullptroma.wallenc.domain.interfaces.ILogger
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
import com.github.nullptroma.wallenc.domain.enums.StorageDeletionPolicy
import com.github.nullptroma.wallenc.domain.usecases.GetOpenedStoragesUseCase
import com.github.nullptroma.wallenc.domain.usecases.ManageLocalVaultUseCase
import com.github.nullptroma.wallenc.domain.usecases.ManageStoragesEncryptionUseCase
@@ -19,6 +18,8 @@ import com.github.nullptroma.wallenc.presentation.extensions.toPrintable
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.system.measureTimeMillis
@@ -33,6 +34,9 @@ class LocalVaultViewModel @Inject constructor(
private val renameStorageUseCase: RenameStorageUseCase,
private val logger: ILogger
) : ViewModelBase<LocalVaultScreenState>(LocalVaultScreenState(listOf(), true)) {
private val _messages = MutableSharedFlow<String>()
val messages: SharedFlow<String> = _messages
private var _taskCount: Int = 0
private var tasksCount
get() = _taskCount
@@ -114,37 +118,105 @@ class LocalVaultViewModel @Inject constructor(
}
}
private val runningStorages = mutableSetOf<IStorageInfo>()
fun enableEncryptionAndOpenStorage(storage: IStorageInfo) {
if(runningStorages.contains(storage))
private val runningStorages = mutableSetOf<java.util.UUID>()
fun enableEncryption(storage: IStorageInfo, password: String, encryptPath: Boolean) {
val id = storage.uuid
if (runningStorages.contains(id))
return
tasksCount++
runningStorages.add(storage)
val key = EncryptKey("Hello")
runningStorages.add(id)
val key = EncryptKey(password)
viewModelScope.launch {
try {
manageStoragesEncryptionUseCase.enableEncryption(storage, key, false)
manageStoragesEncryptionUseCase.openStorage(storage, key)
when (manageStoragesEncryptionUseCase.canEncrypt(storage)) {
ManageStoragesEncryptionUseCase.CanEncryptResult.Allowed -> {
manageStoragesEncryptionUseCase.enableEncryption(storage, key, encryptPath)
manageStoragesEncryptionUseCase.openStorage(storage, key, true)
_messages.emit("Encryption enabled")
}
ManageStoragesEncryptionUseCase.CanEncryptResult.AlreadyEncrypted -> {
_messages.emit("Storage is already encrypted")
}
ManageStoragesEncryptionUseCase.CanEncryptResult.StorageIsNotEmpty -> {
_messages.emit("Storage is not empty")
}
ManageStoragesEncryptionUseCase.CanEncryptResult.StorageStateUnknown -> {
_messages.emit("Cannot determine whether storage is empty")
}
ManageStoragesEncryptionUseCase.CanEncryptResult.UnsupportedStorageType -> {
_messages.emit("Unsupported storage type")
}
}
} catch (e: Exception) {
_messages.emit(e.message ?: "Failed to enable encryption")
}
finally {
runningStorages.remove(storage)
runningStorages.remove(id)
tasksCount--
}
}
}
fun openEncryptedStorage(storage: IStorageInfo, password: String, rememberPassword: Boolean) {
val id = storage.uuid
if (runningStorages.contains(id)) return
tasksCount++
runningStorages.add(id)
val key = EncryptKey(password)
viewModelScope.launch {
try {
manageStoragesEncryptionUseCase.openStorage(storage, key, rememberPassword)
} catch (e: Exception) {
_messages.emit(e.message ?: "Failed to open encrypted storage")
} finally {
runningStorages.remove(id)
tasksCount--
}
}
}
fun closeEncryptedStorage(storage: IStorageInfo) {
viewModelScope.launch {
try {
manageStoragesEncryptionUseCase.closeStorage(storage)
} catch (e: Exception) {
_messages.emit(e.message ?: "Failed to close encrypted storage")
}
}
}
fun disableEncryption(storage: IStorageInfo) {
viewModelScope.launch {
try {
manageStoragesEncryptionUseCase.disableEncryption(storage)
_messages.emit("Encryption disabled")
} catch (e: Exception) {
_messages.emit(e.message ?: "Failed to disable encryption")
}
}
}
fun rename(storage: IStorageInfo, newName: String) {
viewModelScope.launch {
renameStorageUseCase.rename(storage, newName)
}
}
fun remove(
storage: IStorageInfo,
policy: StorageDeletionPolicy = StorageDeletionPolicy.REMOVE_PHYSICAL,
) {
fun remove(storage: IStorageInfo) {
viewModelScope.launch {
removeStorageUseCase.remove(storage, policy)
removeStorageUseCase.remove(storage)
}
}
fun getStorageStatus(storage: IStorageInfo): String {
val encrypted = storage.metaInfo.value.encInfo != null
if (!encrypted) return "Not encrypted"
val opened = isEncryptionSessionOpen(storage)
return if (opened) "Encrypted (opened)" else "Encrypted (closed)"
}
fun isEncryptionSessionOpen(storage: IStorageInfo): Boolean {
val openedMap = getOpenedStoragesUseCase.openedStorages.value
return openedMap.containsKey(storage.uuid)
}
}

View File

@@ -10,7 +10,9 @@
<string name="show_storage_item_menu">Show storage item menu</string>
<string name="rename">Rename</string>
<string name="remove">Remove</string>
<string name="encrypt">Encrypt</string>
<string name="new_name_title">New name</string>
<string name="remove_confirmation_dialog">Delete storage "%1$s"?</string>
<string name="storage_lock_actions">Storage encryption actions</string>
</resources>