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

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 @Provides
@Singleton @Singleton
fun provideManageStoragesEncryptionUseCase(unlockManager: IUnlockManager): ManageStoragesEncryptionUseCase { fun provideManageStoragesEncryptionUseCase(
unlockManager: IUnlockManager,
): ManageStoragesEncryptionUseCase {
return ManageStoragesEncryptionUseCase(unlockManager) return ManageStoragesEncryptionUseCase(unlockManager)
} }
@@ -52,7 +54,8 @@ class UseCasesModule {
fun provideRemoveStorageUseCase( fun provideRemoveStorageUseCase(
vaultsManager: IVaultsManager, vaultsManager: IVaultsManager,
unlockManager: IUnlockManager, unlockManager: IUnlockManager,
manageStoragesEncryptionUseCase: ManageStoragesEncryptionUseCase,
): RemoveStorageUseCase { ): RemoveStorageUseCase {
return RemoveStorageUseCase(vaultsManager, unlockManager) return RemoveStorageUseCase(vaultsManager, unlockManager, manageStoragesEncryptionUseCase)
} }
} }

View File

@@ -12,7 +12,7 @@ interface IAppDb {
val storageMetaInfoDao: StorageMetaInfoDao 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 class AppDb : IAppDb, RoomDatabase() {
abstract override val storageKeyMapDao: StorageKeyMapDao abstract override val storageKeyMapDao: StorageKeyMapDao
abstract override val storageMetaInfoDao: StorageMetaInfoDao 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 com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
import java.util.UUID import java.util.UUID
@Entity(tableName = "storage_key_maps", primaryKeys = [ "source_uuid", "dest_uuid" ]) @Entity(tableName = "storage_key_maps")
data class DbStorageKeyMap( data class DbStorageKeyMap(
@androidx.room.PrimaryKey
@ColumnInfo(name = "source_uuid") val sourceUuid: UUID, @ColumnInfo(name = "source_uuid") val sourceUuid: UUID,
@ColumnInfo(name = "dest_uuid") val destUuid: UUID,
@ColumnInfo(name = "key") val key: ByteArray @ColumnInfo(name = "key") val key: ByteArray
) { ) {
fun toModel(): StorageKeyMap { fun toModel(): StorageKeyMap {
return StorageKeyMap( return StorageKeyMap(
sourceUuid = sourceUuid, sourceUuid = sourceUuid,
destUuid = destUuid,
key = EncryptKey(key) key = EncryptKey(key)
) )
} }
@@ -28,7 +27,6 @@ data class DbStorageKeyMap(
other as DbStorageKeyMap other as DbStorageKeyMap
if (sourceUuid != other.sourceUuid) return false if (sourceUuid != other.sourceUuid) return false
if (destUuid != other.destUuid) return false
if (!key.contentEquals(other.key)) return false if (!key.contentEquals(other.key)) return false
return true return true
@@ -36,7 +34,6 @@ data class DbStorageKeyMap(
override fun hashCode(): Int { override fun hashCode(): Int {
var result = sourceUuid.hashCode() var result = sourceUuid.hashCode()
result = 31 * result + destUuid.hashCode()
result = 31 * result + key.contentHashCode() result = 31 * result + key.contentHashCode()
return result return result
} }
@@ -45,7 +42,6 @@ data class DbStorageKeyMap(
fun fromModel(keymap: StorageKeyMap): DbStorageKeyMap { fun fromModel(keymap: StorageKeyMap): DbStorageKeyMap {
return DbStorageKeyMap( return DbStorageKeyMap(
sourceUuid = keymap.sourceUuid, sourceUuid = keymap.sourceUuid,
destUuid = keymap.destUuid,
key = keymap.key.bytes key = keymap.key.bytes
) )
} }

View File

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

View File

@@ -17,6 +17,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.nio.ByteBuffer
import java.security.MessageDigest
import java.util.UUID import java.util.UUID
class UnlockManager( class UnlockManager(
@@ -45,7 +47,7 @@ class UnlockManager(
continue continue
} }
try { try {
val encStorage = createEncryptedStorage(storage, key.key, key.destUuid) val encStorage = createEncryptedStorage(storage, key.key, getDestUuid(storage.uuid))
map[storage.uuid] = encStorage map[storage.uuid] = encStorage
allStorages.removeAt(allStorages.size - 1) allStorages.removeAt(allStorages.size - 1)
allStorages.add(encStorage) 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( override suspend fun open(
storage: IStorage, storage: IStorage,
key: EncryptKey key: EncryptKey,
rememberPassword: Boolean
): EncryptedStorage = withContext(ioDispatcher) { ): EncryptedStorage = withContext(ioDispatcher) {
return@withContext mutex.withLock { return@withContext mutex.withLock {
val encInfo = storage.metaInfo.value.encInfo ?: throw Exception("EncInfo is null") // TODO val encInfo = storage.metaInfo.value.encInfo ?: throw Exception("EncInfo is null") // TODO
@@ -84,17 +111,18 @@ class UnlockManager(
val opened = _openedStorages.value.toMutableMap() val opened = _openedStorages.value.toMutableMap()
val cur = opened[storage.uuid] val cur = opened[storage.uuid]
if (cur != null) if (cur != null)
throw Exception("Storage is already open") return@withLock cur
val keymap = StorageKeyMap( val keymap = StorageKeyMap(
sourceUuid = storage.uuid, sourceUuid = storage.uuid,
destUuid = UUID.randomUUID(),
key = key key = key
) )
val encStorage = createEncryptedStorage(storage, keymap.key, keymap.destUuid) val encStorage = createEncryptedStorage(storage, keymap.key, getDestUuid(storage.uuid))
opened[storage.uuid] = encStorage opened[storage.uuid] = encStorage
_openedStorages.value = opened _openedStorages.value = opened
keymapRepository.add(keymap) if (rememberPassword) {
keymapRepository.add(keymap)
}
encStorage encStorage
} }
} }
@@ -111,30 +139,21 @@ class UnlockManager(
} }
} }
// Закрытие отображения по экземпляру (source или decrypted). // Закрытие только по source-экземпляру.
override suspend fun close(storage: IStorage) { override suspend fun close(storage: IStorage) {
val opened = _openedStorages.value val opened = _openedStorages.value
val source = opened.entries.firstOrNull { if (opened.containsKey(storage.uuid)) {
it.key == storage.uuid || it.value.uuid == storage.uuid close(storage.uuid)
} }
if (source != null)
close(source.key)
} }
private suspend fun closeBySourceUuid(opened: MutableMap<UUID, EncryptedStorage>, sourceUuid: UUID) { private suspend fun closeBySourceUuid(opened: MutableMap<UUID, EncryptedStorage>, sourceUuid: UUID) {
val enc = opened[sourceUuid] ?: return val enc = opened[sourceUuid] ?: return
val childSourceUuid = opened.entries.firstOrNull { it.value.uuid == enc.uuid }?.key val nestedSourceUuid = enc.uuid
if (childSourceUuid != null) { if (nestedSourceUuid != sourceUuid && opened.containsKey(nestedSourceUuid)) {
closeBySourceUuid(opened, childSourceUuid) closeBySourceUuid(opened, nestedSourceUuid)
} }
opened.remove(sourceUuid) opened.remove(sourceUuid)
enc.dispose() 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.CoroutineScope
import kotlinx.coroutines.DisposableHandle import kotlinx.coroutines.DisposableHandle
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -35,6 +36,8 @@ class EncryptedStorage private constructor(
get() = accessor.size get() = accessor.size
override val numberOfFiles: StateFlow<Int?> override val numberOfFiles: StateFlow<Int?>
get() = accessor.numberOfFiles get() = accessor.numberOfFiles
override val isEmpty: Flow<Boolean?>
get() = accessor.numberOfFiles.map { n -> n?.let { it == 0 } }
private val _metaInfo = MutableStateFlow<IStorageMetaInfo>( private val _metaInfo = MutableStateFlow<IStorageMetaInfo>(
CommonStorageMetaInfo() 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 val curMeta = metaInfo.value
updateMetaInfo( updateMetaInfo(
CommonStorageMetaInfo( 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() { override fun dispose() {
accessor.dispose() accessor.dispose()
job.cancel() 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.IStorageAccessor
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.InputStream import java.io.InputStream
import java.util.UUID import java.util.UUID
@@ -23,6 +25,8 @@ class LocalStorage(
get() = accessor.size get() = accessor.size
override val numberOfFiles: StateFlow<Int?> override val numberOfFiles: StateFlow<Int?>
get() = accessor.numberOfFiles get() = accessor.numberOfFiles
override val isEmpty: Flow<Boolean?>
get() = accessor.numberOfFiles.map { n -> n?.let { it == 0 } }
private val _metaInfo = MutableStateFlow<IStorageMetaInfo>( private val _metaInfo = MutableStateFlow<IStorageMetaInfo>(
CommonStorageMetaInfo() 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 val curMeta = metaInfo.value
updateMetaInfo(CommonStorageMetaInfo( updateMetaInfo(CommonStorageMetaInfo(
encInfo = encInfo, 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 { companion object {
const val STORAGE_INFO_FILE_POSTFIX = ".storage-info" const val STORAGE_INFO_FILE_POSTFIX = ".storage-info"
private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() } private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() }

View File

@@ -494,10 +494,15 @@ class LocalStorageAccessor(
} }
override suspend fun delete(path: String) = withContext(ioDispatcher) { 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) val pair = LocalStorageFilePair.from(_filesystemBasePath, path)
if (pair != null) { if (pair != null) {
pair.file.delete() if (pair.file.isDirectory) pair.file.deleteRecursively()
else pair.file.delete()
pair.metaFile.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 package com.github.nullptroma.wallenc.domain.interfaces
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import java.time.Instant import java.time.Instant
import java.util.UUID import java.util.UUID
@@ -10,6 +11,7 @@ sealed interface IStorageInfo {
val isAvailable: StateFlow<Boolean> val isAvailable: StateFlow<Boolean>
val size: StateFlow<Long?> val size: StateFlow<Long?>
val numberOfFiles: StateFlow<Int?> val numberOfFiles: StateFlow<Int?>
val isEmpty: Flow<Boolean?>
val metaInfo: StateFlow<IStorageMetaInfo> val metaInfo: StateFlow<IStorageMetaInfo>
val isVirtualStorage: Boolean val isVirtualStorage: Boolean
} }
@@ -18,7 +20,8 @@ interface IStorage: IStorageInfo {
val accessor: IStorageAccessor val accessor: IStorageAccessor
suspend fun rename(newName: String) suspend fun rename(newName: String)
suspend fun setEncInfo(encInfo: StorageEncryptionInfo) suspend fun setEncInfo(encInfo: StorageEncryptionInfo?)
suspend fun clearAllContent()
} }
interface IStorageMetaInfo { interface IStorageMetaInfo {

View File

@@ -14,7 +14,7 @@ interface IUnlockManager {
*/ */
val openedStorages: StateFlow<Map<UUID, IStorage>> 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(storage: IStorage)
suspend fun close(uuid: UUID): Unit 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.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager
import kotlinx.coroutines.flow.first
class ManageStoragesEncryptionUseCase(private val unlockManager: IUnlockManager) { class ManageStoragesEncryptionUseCase(
suspend fun enableEncryption(storage: IStorageInfo, key: EncryptKey, encryptPath: Boolean) { private val unlockManager: IUnlockManager,
when(storage) { ) {
is IStorage -> { sealed interface CanEncryptResult {
if(storage.metaInfo.value.encInfo != null) data object Allowed : CanEncryptResult
throw Exception() // TODO data object UnsupportedStorageType : CanEncryptResult
storage.setEncInfo(Encryptor.generateEncryptionInfo(key, encryptPath)) 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 { suspend fun enableEncryption(storage: IStorageInfo, key: EncryptKey, encryptPath: Boolean) {
when(storage) { when (val result = canEncrypt(storage)) {
is IStorage -> { CanEncryptResult.Allowed -> (storage as IStorage).setEncInfo(
return unlockManager.open(storage, key) 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 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.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager
@@ -10,36 +9,30 @@ import java.util.UUID
class RemoveStorageUseCase( class RemoveStorageUseCase(
private val vaultsManager: IVaultsManager, private val vaultsManager: IVaultsManager,
private val unlockManager: IUnlockManager, 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 if (storage !is IStorage) return
when (policy) { if (!storage.isVirtualStorage) {
StorageDeletionPolicy.CLOSE_ENCRYPTED_OVERLAYS_ONLY -> { unlockManager.close(storage)
unlockManager.close(storage) vaultsManager.localVault.remove(storage)
} return
StorageDeletionPolicy.REMOVE_PHYSICAL -> {
val physical = findPhysicalRootStorage(storage) ?: return
unlockManager.close(physical.uuid)
vaultsManager.localVault.remove(physical)
}
} }
val parent = findParentStorage(storage) ?: return
manageStoragesEncryptionUseCase.clearAndDisableEncryption(parent)
} }
/**
* Поднимается по цепочке overlay (sourceUuid -> decrypted view), пока не дойдёт private fun findParentStorage(storage: IStorage): IStorage? {
* до корневого физического storage из [IVaultsManager.localVault].
*/
private fun findPhysicalRootStorage(storage: IStorage): IStorage? {
val locals = vaultsManager.localVault.storages.value ?: return null
val opened = unlockManager.openedStorages.value val opened = unlockManager.openedStorages.value
val parentUuid = opened.entries.firstOrNull { it.value.uuid == storage.uuid }?.key ?: return null
var id: UUID = storage.uuid val locals = vaultsManager.localVault.storages.value.orEmpty()
while (true) { return locals.firstOrNull { it.uuid == parentUuid }
val parent = opened.entries.firstOrNull { it.value.uuid == id }?.key ?: break ?: opened[parentUuid]
id = parent
}
return locals.firstOrNull { it.uuid == id }
} }
} }

View File

@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.Checkbox
import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -21,6 +22,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.material.icons.filled.MoreVert
import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
@@ -60,7 +61,12 @@ fun StorageTree(
onClick: (Tree<IStorageInfo>) -> Unit, onClick: (Tree<IStorageInfo>) -> Unit,
onRename: (Tree<IStorageInfo>, String) -> Unit, onRename: (Tree<IStorageInfo>, String) -> Unit,
onRemove: (Tree<IStorageInfo>) -> 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 cur = tree.value
val available by cur.isAvailable.collectAsStateWithLifecycle() val available by cur.isAvailable.collectAsStateWithLifecycle()
@@ -68,6 +74,8 @@ fun StorageTree(
val size by cur.size.collectAsStateWithLifecycle() val size by cur.size.collectAsStateWithLifecycle()
val metaInfo by cur.metaInfo.collectAsStateWithLifecycle() val metaInfo by cur.metaInfo.collectAsStateWithLifecycle()
val isAvailable by cur.isAvailable.collectAsStateWithLifecycle() val isAvailable by cur.isAvailable.collectAsStateWithLifecycle()
val isEncrypted = metaInfo.encInfo != null
val isOpened = isEncryptionOpened(tree)
val borderColor = val borderColor =
if (cur.isVirtualStorage) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.primary if (cur.isVirtualStorage) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.primary
Column(modifier) { Column(modifier) {
@@ -120,10 +128,13 @@ fun StorageTree(
modifier = Modifier, modifier = Modifier,
horizontalAlignment = Alignment.End 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)) { 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 }) { IconButton(onClick = { expanded = !expanded }) {
Icon( Icon(
Icons.Default.MoreVert, Icons.Default.MoreVert,
@@ -145,10 +156,20 @@ fun StorageTree(
DropdownMenuItem( DropdownMenuItem(
onClick = { onClick = {
expanded = false expanded = false
showRemoveConfirmationDiaglog = true; showRemoveConfirmDialog = true
}, },
text = { Text(stringResource(R.string.remove)) } text = { Text(stringResource(R.string.remove)) }
) )
if (!isEncrypted) {
HorizontalDivider()
DropdownMenuItem(
onClick = {
expanded = false
showSetupEncryptionDialog = true
},
text = { Text(stringResource(R.string.encrypt)) }
)
}
} }
if (showRenameDialog) { if (showRenameDialog) {
@@ -163,26 +184,78 @@ fun StorageTree(
) )
} }
if (showRemoveConfirmationDiaglog) { if (showRemoveConfirmDialog) {
ConfirmationCancelOkDialog( ConfirmationCancelOkDialog(
onDismiss = { onDismiss = { showRemoveConfirmDialog = false },
showRemoveConfirmationDiaglog = false
},
onConfirmation = {
showRemoveConfirmationDiaglog = false
onRemove(tree)
},
title = stringResource( title = stringResource(
R.string.remove_confirmation_dialog, R.string.remove_confirmation_dialog,
metaInfo.name ?: "<noname>" 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)) Spacer(modifier = Modifier.weight(1f))
Button(onClick = { onEncrypt(tree) }, enabled = metaInfo.encInfo == null) { if (isEncrypted) {
Text("Encrypt") 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( Text(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -221,7 +294,12 @@ fun StorageTree(
onClick, onClick,
onRename, onRename,
onRemove, 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 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.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
@@ -23,16 +21,18 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color 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.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.presentation.elements.StorageTree import com.github.nullptroma.wallenc.presentation.elements.StorageTree
import com.github.nullptroma.wallenc.presentation.extensions.gesturesDisabled import com.github.nullptroma.wallenc.presentation.extensions.gesturesDisabled
import kotlinx.coroutines.flow.collect
@Composable @Composable
fun LocalVaultScreen( fun LocalVaultScreen(
@@ -42,6 +42,13 @@ fun LocalVaultScreen(
) { ) {
val uiState by viewModel.state.collectAsStateWithLifecycle() 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 { Box {
Scaffold(modifier = modifier, contentWindowInsets = WindowInsets(0.dp), floatingActionButton = { Scaffold(modifier = modifier, contentWindowInsets = WindowInsets(0.dp), floatingActionButton = {
FloatingActionButton( FloatingActionButton(
@@ -66,9 +73,24 @@ fun LocalVaultScreen(
onRemove = { tree -> onRemove = { tree ->
viewModel.remove(tree.value) viewModel.remove(tree.value)
}, },
onEncrypt = { tree -> onEncrypt = { tree, password, encryptPath ->
viewModel.enableEncryptionAndOpenStorage(tree.value) 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 { 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.IFile
import com.github.nullptroma.wallenc.domain.interfaces.ILogger import com.github.nullptroma.wallenc.domain.interfaces.ILogger
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo 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.GetOpenedStoragesUseCase
import com.github.nullptroma.wallenc.domain.usecases.ManageLocalVaultUseCase import com.github.nullptroma.wallenc.domain.usecases.ManageLocalVaultUseCase
import com.github.nullptroma.wallenc.domain.usecases.ManageStoragesEncryptionUseCase 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
@@ -33,6 +34,9 @@ class LocalVaultViewModel @Inject constructor(
private val renameStorageUseCase: RenameStorageUseCase, private val renameStorageUseCase: RenameStorageUseCase,
private val logger: ILogger private val logger: ILogger
) : ViewModelBase<LocalVaultScreenState>(LocalVaultScreenState(listOf(), true)) { ) : ViewModelBase<LocalVaultScreenState>(LocalVaultScreenState(listOf(), true)) {
private val _messages = MutableSharedFlow<String>()
val messages: SharedFlow<String> = _messages
private var _taskCount: Int = 0 private var _taskCount: Int = 0
private var tasksCount private var tasksCount
get() = _taskCount get() = _taskCount
@@ -114,37 +118,105 @@ class LocalVaultViewModel @Inject constructor(
} }
} }
private val runningStorages = mutableSetOf<IStorageInfo>() private val runningStorages = mutableSetOf<java.util.UUID>()
fun enableEncryptionAndOpenStorage(storage: IStorageInfo) { fun enableEncryption(storage: IStorageInfo, password: String, encryptPath: Boolean) {
if(runningStorages.contains(storage)) val id = storage.uuid
if (runningStorages.contains(id))
return return
tasksCount++ tasksCount++
runningStorages.add(storage) runningStorages.add(id)
val key = EncryptKey("Hello") val key = EncryptKey(password)
viewModelScope.launch { viewModelScope.launch {
try { try {
manageStoragesEncryptionUseCase.enableEncryption(storage, key, false) when (manageStoragesEncryptionUseCase.canEncrypt(storage)) {
manageStoragesEncryptionUseCase.openStorage(storage, key) 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 { finally {
runningStorages.remove(storage) runningStorages.remove(id)
tasksCount-- 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) { fun rename(storage: IStorageInfo, newName: String) {
viewModelScope.launch { viewModelScope.launch {
renameStorageUseCase.rename(storage, newName) renameStorageUseCase.rename(storage, newName)
} }
} }
fun remove( fun remove(storage: IStorageInfo) {
storage: IStorageInfo,
policy: StorageDeletionPolicy = StorageDeletionPolicy.REMOVE_PHYSICAL,
) {
viewModelScope.launch { 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="show_storage_item_menu">Show storage item menu</string>
<string name="rename">Rename</string> <string name="rename">Rename</string>
<string name="remove">Remove</string> <string name="remove">Remove</string>
<string name="encrypt">Encrypt</string>
<string name="new_name_title">New name</string> <string name="new_name_title">New name</string>
<string name="remove_confirmation_dialog">Delete storage "%1$s"?</string> <string name="remove_confirmation_dialog">Delete storage "%1$s"?</string>
<string name="storage_lock_actions">Storage encryption actions</string>
</resources> </resources>