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

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

@@ -43,7 +43,9 @@ class UseCasesModule {
@Provides
@Singleton
fun provideManageStoragesEncryptionUseCase(unlockManager: IUnlockManager): ManageStoragesEncryptionUseCase {
fun provideManageStoragesEncryptionUseCase(
unlockManager: IUnlockManager,
): ManageStoragesEncryptionUseCase {
return ManageStoragesEncryptionUseCase(unlockManager)
}
@@ -52,7 +54,8 @@ class UseCasesModule {
fun provideRemoveStorageUseCase(
vaultsManager: IVaultsManager,
unlockManager: IUnlockManager,
manageStoragesEncryptionUseCase: ManageStoragesEncryptionUseCase,
): RemoveStorageUseCase {
return RemoveStorageUseCase(vaultsManager, unlockManager)
return RemoveStorageUseCase(vaultsManager, unlockManager, manageStoragesEncryptionUseCase)
}
}

View File

@@ -12,7 +12,7 @@ interface IAppDb {
val storageMetaInfoDao: StorageMetaInfoDao
}
@Database(entities = [DbStorageKeyMap::class, DbStorageMetaInfo::class], version = 2, exportSchema = false)
@Database(entities = [DbStorageKeyMap::class, DbStorageMetaInfo::class], version = 3, exportSchema = false)
abstract class AppDb : IAppDb, RoomDatabase() {
abstract override val storageKeyMapDao: StorageKeyMapDao
abstract override val storageMetaInfoDao: StorageMetaInfoDao

View File

@@ -7,16 +7,15 @@ import com.github.nullptroma.wallenc.data.model.StorageKeyMap
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
import java.util.UUID
@Entity(tableName = "storage_key_maps", primaryKeys = [ "source_uuid", "dest_uuid" ])
@Entity(tableName = "storage_key_maps")
data class DbStorageKeyMap(
@androidx.room.PrimaryKey
@ColumnInfo(name = "source_uuid") val sourceUuid: UUID,
@ColumnInfo(name = "dest_uuid") val destUuid: UUID,
@ColumnInfo(name = "key") val key: ByteArray
) {
fun toModel(): StorageKeyMap {
return StorageKeyMap(
sourceUuid = sourceUuid,
destUuid = destUuid,
key = EncryptKey(key)
)
}
@@ -28,7 +27,6 @@ data class DbStorageKeyMap(
other as DbStorageKeyMap
if (sourceUuid != other.sourceUuid) return false
if (destUuid != other.destUuid) return false
if (!key.contentEquals(other.key)) return false
return true
@@ -36,7 +34,6 @@ data class DbStorageKeyMap(
override fun hashCode(): Int {
var result = sourceUuid.hashCode()
result = 31 * result + destUuid.hashCode()
result = 31 * result + key.contentHashCode()
return result
}
@@ -45,7 +42,6 @@ data class DbStorageKeyMap(
fun fromModel(keymap: StorageKeyMap): DbStorageKeyMap {
return DbStorageKeyMap(
sourceUuid = keymap.sourceUuid,
destUuid = keymap.destUuid,
key = keymap.key.bytes
)
}

View File

@@ -6,6 +6,5 @@ import java.util.UUID
data class StorageKeyMap(
val sourceUuid: UUID,
val destUuid: UUID,
val key: EncryptKey
)

View File

@@ -17,6 +17,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.nio.ByteBuffer
import java.security.MessageDigest
import java.util.UUID
class UnlockManager(
@@ -45,7 +47,7 @@ class UnlockManager(
continue
}
try {
val encStorage = createEncryptedStorage(storage, key.key, key.destUuid)
val encStorage = createEncryptedStorage(storage, key.key, getDestUuid(storage.uuid))
map[storage.uuid] = encStorage
allStorages.removeAt(allStorages.size - 1)
allStorages.add(encStorage)
@@ -72,9 +74,34 @@ class UnlockManager(
)
}
private fun getDestUuid(sourceUuid: UUID): UUID {
return uuid5(
namespace = UUID.fromString("6ba7b811-9dad-11d1-80b4-00c04fd430c8"), // URL namespace
name = "$sourceUuid:open"
)
}
private fun uuid5(namespace: UUID, name: String): UUID {
val digest = MessageDigest.getInstance("SHA-1")
val nsBytes = ByteBuffer.allocate(16)
.putLong(namespace.mostSignificantBits)
.putLong(namespace.leastSignificantBits)
.array()
digest.update(nsBytes)
digest.update(name.toByteArray(Charsets.UTF_8))
val hash = digest.digest()
hash[6] = (hash[6].toInt() and 0x0f or 0x50).toByte() // version 5
hash[8] = (hash[8].toInt() and 0x3f or 0x80).toByte() // RFC 4122 variant
val bb = ByteBuffer.wrap(hash, 0, 16)
return UUID(bb.long, bb.long)
}
override suspend fun open(
storage: IStorage,
key: EncryptKey
key: EncryptKey,
rememberPassword: Boolean
): EncryptedStorage = withContext(ioDispatcher) {
return@withContext mutex.withLock {
val encInfo = storage.metaInfo.value.encInfo ?: throw Exception("EncInfo is null") // TODO
@@ -84,17 +111,18 @@ class UnlockManager(
val opened = _openedStorages.value.toMutableMap()
val cur = opened[storage.uuid]
if (cur != null)
throw Exception("Storage is already open")
return@withLock cur
val keymap = StorageKeyMap(
sourceUuid = storage.uuid,
destUuid = UUID.randomUUID(),
key = key
)
val encStorage = createEncryptedStorage(storage, keymap.key, keymap.destUuid)
val encStorage = createEncryptedStorage(storage, keymap.key, getDestUuid(storage.uuid))
opened[storage.uuid] = encStorage
_openedStorages.value = opened
keymapRepository.add(keymap)
if (rememberPassword) {
keymapRepository.add(keymap)
}
encStorage
}
}
@@ -111,30 +139,21 @@ class UnlockManager(
}
}
// Закрытие отображения по экземпляру (source или decrypted).
// Закрытие только по source-экземпляру.
override suspend fun close(storage: IStorage) {
val opened = _openedStorages.value
val source = opened.entries.firstOrNull {
it.key == storage.uuid || it.value.uuid == storage.uuid
if (opened.containsKey(storage.uuid)) {
close(storage.uuid)
}
if (source != null)
close(source.key)
}
private suspend fun closeBySourceUuid(opened: MutableMap<UUID, EncryptedStorage>, sourceUuid: UUID) {
val enc = opened[sourceUuid] ?: return
val childSourceUuid = opened.entries.firstOrNull { it.value.uuid == enc.uuid }?.key
if (childSourceUuid != null) {
closeBySourceUuid(opened, childSourceUuid)
val nestedSourceUuid = enc.uuid
if (nestedSourceUuid != sourceUuid && opened.containsKey(nestedSourceUuid)) {
closeBySourceUuid(opened, nestedSourceUuid)
}
opened.remove(sourceUuid)
enc.dispose()
keymapRepository.delete(
StorageKeyMap(
sourceUuid = sourceUuid,
destUuid = enc.uuid,
key = EncryptKey("")
)
)
}
}

View File

@@ -12,6 +12,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DisposableHandle
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
@@ -35,6 +36,8 @@ class EncryptedStorage private constructor(
get() = accessor.size
override val numberOfFiles: StateFlow<Int?>
get() = accessor.numberOfFiles
override val isEmpty: Flow<Boolean?>
get() = accessor.numberOfFiles.map { n -> n?.let { it == 0 } }
private val _metaInfo = MutableStateFlow<IStorageMetaInfo>(
CommonStorageMetaInfo()
@@ -102,7 +105,7 @@ class EncryptedStorage private constructor(
)
}
override suspend fun setEncInfo(encInfo: StorageEncryptionInfo) = scope.run {
override suspend fun setEncInfo(encInfo: StorageEncryptionInfo?) = scope.run {
val curMeta = metaInfo.value
updateMetaInfo(
CommonStorageMetaInfo(
@@ -112,6 +115,20 @@ class EncryptedStorage private constructor(
)
}
override suspend fun clearAllContent() = scope.run {
val files = accessor.getAllFiles()
val dirs = accessor.getAllDirs()
val paths = buildList {
addAll(files.map { it.metaInfo.path })
addAll(dirs.map { it.metaInfo.path })
}
.filter { it != "/" && it.isNotBlank() }
.sortedByDescending { it.length }
for (path in paths) {
accessor.delete(path)
}
}
override fun dispose() {
accessor.dispose()
job.cancel()

View File

@@ -7,8 +7,10 @@ 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 kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.util.UUID
@@ -23,6 +25,8 @@ class LocalStorage(
get() = accessor.size
override val numberOfFiles: StateFlow<Int?>
get() = accessor.numberOfFiles
override val isEmpty: Flow<Boolean?>
get() = accessor.numberOfFiles.map { n -> n?.let { it == 0 } }
private val _metaInfo = MutableStateFlow<IStorageMetaInfo>(
CommonStorageMetaInfo()
@@ -82,7 +86,7 @@ class LocalStorage(
))
}
override suspend fun setEncInfo(encInfo: StorageEncryptionInfo) = withContext(ioDispatcher) {
override suspend fun setEncInfo(encInfo: StorageEncryptionInfo?) = withContext(ioDispatcher) {
val curMeta = metaInfo.value
updateMetaInfo(CommonStorageMetaInfo(
encInfo = encInfo,
@@ -90,6 +94,20 @@ class LocalStorage(
))
}
override suspend fun clearAllContent() = withContext(ioDispatcher) {
val files = accessor.getAllFiles()
val dirs = accessor.getAllDirs()
val paths = buildList {
addAll(files.map { it.metaInfo.path })
addAll(dirs.map { it.metaInfo.path })
}
.filter { it != "/" && it.isNotBlank() }
.sortedByDescending { it.length }
for (path in paths) {
accessor.delete(path)
}
}
companion object {
const val STORAGE_INFO_FILE_POSTFIX = ".storage-info"
private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() }

View File

@@ -494,10 +494,15 @@ class LocalStorageAccessor(
}
override suspend fun delete(path: String) = withContext(ioDispatcher) {
if (path == "/" || path.isBlank()) {
throw IllegalArgumentException("Deleting root path is forbidden")
}
val pair = LocalStorageFilePair.from(_filesystemBasePath, path)
if (pair != null) {
pair.file.delete()
if (pair.file.isDirectory) pair.file.deleteRecursively()
else pair.file.delete()
pair.metaFile.delete()
scanSizeAndNumOfFiles()
}
}

View File

@@ -1,15 +0,0 @@
package com.github.nullptroma.wallenc.domain.enums
/**
* Политика удаления/закрытия хранилища.
*
* [CLOSE_ENCRYPTED_OVERLAYS_ONLY] — только закрыть расшифрованные представления (overlay),
* физические данные не трогаем.
*
* [REMOVE_PHYSICAL] — удалить физическое хранилище у провайдера (сейчас local vault),
* предварительно закрыв все overlay.
*/
enum class StorageDeletionPolicy {
CLOSE_ENCRYPTED_OVERLAYS_ONLY,
REMOVE_PHYSICAL,
}

View File

@@ -1,6 +1,7 @@
package com.github.nullptroma.wallenc.domain.interfaces
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import java.time.Instant
import java.util.UUID
@@ -10,6 +11,7 @@ sealed interface IStorageInfo {
val isAvailable: StateFlow<Boolean>
val size: StateFlow<Long?>
val numberOfFiles: StateFlow<Int?>
val isEmpty: Flow<Boolean?>
val metaInfo: StateFlow<IStorageMetaInfo>
val isVirtualStorage: Boolean
}
@@ -18,7 +20,8 @@ interface IStorage: IStorageInfo {
val accessor: IStorageAccessor
suspend fun rename(newName: String)
suspend fun setEncInfo(encInfo: StorageEncryptionInfo)
suspend fun setEncInfo(encInfo: StorageEncryptionInfo?)
suspend fun clearAllContent()
}
interface IStorageMetaInfo {

View File

@@ -14,7 +14,7 @@ interface IUnlockManager {
*/
val openedStorages: StateFlow<Map<UUID, IStorage>>
suspend fun open(storage: IStorage, key: EncryptKey): IStorage
suspend fun open(storage: IStorage, key: EncryptKey, rememberPassword: Boolean = true): IStorage
suspend fun close(storage: IStorage)
suspend fun close(uuid: UUID): Unit
}

View File

@@ -5,23 +5,70 @@ import com.github.nullptroma.wallenc.domain.encrypt.Encryptor
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager
import kotlinx.coroutines.flow.first
class ManageStoragesEncryptionUseCase(private val unlockManager: IUnlockManager) {
suspend fun enableEncryption(storage: IStorageInfo, key: EncryptKey, encryptPath: Boolean) {
when(storage) {
is IStorage -> {
if(storage.metaInfo.value.encInfo != null)
throw Exception() // TODO
storage.setEncInfo(Encryptor.generateEncryptionInfo(key, encryptPath))
}
class ManageStoragesEncryptionUseCase(
private val unlockManager: IUnlockManager,
) {
sealed interface CanEncryptResult {
data object Allowed : CanEncryptResult
data object UnsupportedStorageType : CanEncryptResult
data object AlreadyEncrypted : CanEncryptResult
data object StorageIsNotEmpty : CanEncryptResult
data object StorageStateUnknown : CanEncryptResult
}
suspend fun canEncrypt(storage: IStorageInfo): CanEncryptResult {
if (storage !is IStorage) return CanEncryptResult.UnsupportedStorageType
if (storage.metaInfo.value.encInfo != null) return CanEncryptResult.AlreadyEncrypted
val isEmpty = storage.isEmpty.first()
return when (isEmpty) {
true -> CanEncryptResult.Allowed
false -> CanEncryptResult.StorageIsNotEmpty
null -> CanEncryptResult.StorageStateUnknown
}
}
suspend fun openStorage(storage: IStorageInfo, key: EncryptKey): IStorageInfo {
when(storage) {
is IStorage -> {
return unlockManager.open(storage, key)
}
suspend fun enableEncryption(storage: IStorageInfo, key: EncryptKey, encryptPath: Boolean) {
when (val result = canEncrypt(storage)) {
CanEncryptResult.Allowed -> (storage as IStorage).setEncInfo(
Encryptor.generateEncryptionInfo(key, encryptPath)
)
CanEncryptResult.AlreadyEncrypted -> throw IllegalStateException("Storage is already encrypted")
CanEncryptResult.StorageIsNotEmpty -> throw IllegalStateException("Storage is not empty")
CanEncryptResult.StorageStateUnknown -> throw IllegalStateException("Storage state is unknown")
CanEncryptResult.UnsupportedStorageType -> throw IllegalStateException("Unsupported storage type")
}
}
suspend fun openStorage(storage: IStorageInfo, key: EncryptKey, rememberPassword: Boolean): IStorageInfo {
if (storage is IStorage) return unlockManager.open(storage, key, rememberPassword)
throw IllegalStateException("Unsupported storage type")
}
suspend fun closeStorage(storage: IStorageInfo) {
if (storage is IStorage) {
unlockManager.close(storage)
}
}
suspend fun disableEncryption(storage: IStorageInfo) {
clearAndDisableEncryption(storage)
}
suspend fun clearAndDisableEncryption(storage: IStorageInfo) {
if (storage !is IStorage) return
storage.clearAllContent()
storage.setEncInfo(null)
unlockManager.close(storage)
}
suspend fun changePassword(storage: IStorageInfo, newKey: EncryptKey, encryptPath: Boolean) {
if (storage !is IStorage) return
if (storage.metaInfo.value.encInfo == null) {
throw IllegalStateException("Storage is not encrypted")
}
storage.setEncInfo(Encryptor.generateEncryptionInfo(newKey, encryptPath))
}
}

View File

@@ -1,6 +1,5 @@
package com.github.nullptroma.wallenc.domain.usecases
import com.github.nullptroma.wallenc.domain.enums.StorageDeletionPolicy
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager
@@ -10,36 +9,30 @@ import java.util.UUID
class RemoveStorageUseCase(
private val vaultsManager: IVaultsManager,
private val unlockManager: IUnlockManager,
private val manageStoragesEncryptionUseCase: ManageStoragesEncryptionUseCase,
) {
suspend fun remove(storage: IStorageInfo, policy: StorageDeletionPolicy) {
suspend fun remove(storage: IStorageInfo) {
if (storage !is IStorage) return
when (policy) {
StorageDeletionPolicy.CLOSE_ENCRYPTED_OVERLAYS_ONLY -> {
unlockManager.close(storage)
}
StorageDeletionPolicy.REMOVE_PHYSICAL -> {
val physical = findPhysicalRootStorage(storage) ?: return
unlockManager.close(physical.uuid)
vaultsManager.localVault.remove(physical)
}
if (!storage.isVirtualStorage) {
unlockManager.close(storage)
vaultsManager.localVault.remove(storage)
return
}
val parent = findParentStorage(storage) ?: return
manageStoragesEncryptionUseCase.clearAndDisableEncryption(parent)
}
/**
* Поднимается по цепочке overlay (sourceUuid -> decrypted view), пока не дойдёт
* до корневого физического storage из [IVaultsManager.localVault].
*/
private fun findPhysicalRootStorage(storage: IStorage): IStorage? {
val locals = vaultsManager.localVault.storages.value ?: return null
private fun findParentStorage(storage: IStorage): IStorage? {
val opened = unlockManager.openedStorages.value
var id: UUID = storage.uuid
while (true) {
val parent = opened.entries.firstOrNull { it.value.uuid == id }?.key ?: break
id = parent
}
return locals.firstOrNull { it.uuid == id }
val parentUuid = opened.entries.firstOrNull { it.value.uuid == storage.uuid }?.key ?: return null
val locals = vaultsManager.localVault.storages.value.orEmpty()
return locals.firstOrNull { it.uuid == parentUuid }
?: opened[parentUuid]
}
}

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
@@ -93,3 +95,98 @@ 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>