diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/domain/UseCasesModule.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/domain/UseCasesModule.kt index f339c6d..e8e9470 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/domain/UseCasesModule.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/domain/UseCasesModule.kt @@ -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) } } \ No newline at end of file diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/AppDb.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/AppDb.kt index ed6cdc9..ed3e40f 100644 --- a/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/AppDb.kt +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/AppDb.kt @@ -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 diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/model/DbStorageKeyMap.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/model/DbStorageKeyMap.kt index d081ba4..ddd4fd5 100644 --- a/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/model/DbStorageKeyMap.kt +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/db/app/model/DbStorageKeyMap.kt @@ -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 ) } diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/model/StorageKeyMap.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/model/StorageKeyMap.kt index 218e15b..9a949c4 100644 --- a/data/src/main/java/com/github/nullptroma/wallenc/data/model/StorageKeyMap.kt +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/model/StorageKeyMap.kt @@ -6,6 +6,5 @@ import java.util.UUID data class StorageKeyMap( val sourceUuid: UUID, - val destUuid: UUID, val key: EncryptKey ) diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/storages/UnlockManager.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/storages/UnlockManager.kt index 3fdb62d..61715dd 100644 --- a/data/src/main/java/com/github/nullptroma/wallenc/data/storages/UnlockManager.kt +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/storages/UnlockManager.kt @@ -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, 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("") - ) - ) } } \ No newline at end of file diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/storages/encrypt/EncryptedStorage.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/storages/encrypt/EncryptedStorage.kt index 3d4f64a..166ed0b 100644 --- a/data/src/main/java/com/github/nullptroma/wallenc/data/storages/encrypt/EncryptedStorage.kt +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/storages/encrypt/EncryptedStorage.kt @@ -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 get() = accessor.numberOfFiles + override val isEmpty: Flow + get() = accessor.numberOfFiles.map { n -> n?.let { it == 0 } } private val _metaInfo = MutableStateFlow( 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() diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/storages/local/LocalStorage.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/storages/local/LocalStorage.kt index 44ffa5c..d4356b4 100644 --- a/data/src/main/java/com/github/nullptroma/wallenc/data/storages/local/LocalStorage.kt +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/storages/local/LocalStorage.kt @@ -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 get() = accessor.numberOfFiles + override val isEmpty: Flow + get() = accessor.numberOfFiles.map { n -> n?.let { it == 0 } } private val _metaInfo = MutableStateFlow( 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() } diff --git a/data/src/main/java/com/github/nullptroma/wallenc/data/storages/local/LocalStorageAccessor.kt b/data/src/main/java/com/github/nullptroma/wallenc/data/storages/local/LocalStorageAccessor.kt index 846142f..8d7008f 100644 --- a/data/src/main/java/com/github/nullptroma/wallenc/data/storages/local/LocalStorageAccessor.kt +++ b/data/src/main/java/com/github/nullptroma/wallenc/data/storages/local/LocalStorageAccessor.kt @@ -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() } } diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/enums/StorageDeletionPolicy.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/enums/StorageDeletionPolicy.kt deleted file mode 100644 index 098fc4e..0000000 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/enums/StorageDeletionPolicy.kt +++ /dev/null @@ -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, -} diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IStorage.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IStorage.kt index cc6815a..cf05179 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IStorage.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IStorage.kt @@ -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 val size: StateFlow val numberOfFiles: StateFlow + val isEmpty: Flow val metaInfo: StateFlow 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 { diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IUnlockManager.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IUnlockManager.kt index b953ab7..d5b6af2 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IUnlockManager.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IUnlockManager.kt @@ -14,7 +14,7 @@ interface IUnlockManager { */ val openedStorages: StateFlow> - 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 } \ No newline at end of file diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/usecases/ManageStoragesEncryptionUseCase.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/usecases/ManageStoragesEncryptionUseCase.kt index 3e3cbc4..f380b00 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/usecases/ManageStoragesEncryptionUseCase.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/usecases/ManageStoragesEncryptionUseCase.kt @@ -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)) + } } \ No newline at end of file diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/usecases/RemoveStorageUseCase.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/usecases/RemoveStorageUseCase.kt index 0d3090a..3dd0db3 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/usecases/RemoveStorageUseCase.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/usecases/RemoveStorageUseCase.kt @@ -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] } + } diff --git a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/elements/Dialogs.kt b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/elements/Dialogs.kt index 93bb6e6..17d2d5a 100644 --- a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/elements/Dialogs.kt +++ b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/elements/Dialogs.kt @@ -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 } } } -} \ No newline at end of file +} + +@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") } + } + } + } +} diff --git a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/elements/StorageTree.kt b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/elements/StorageTree.kt index 0122f72..5b2db0a 100644 --- a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/elements/StorageTree.kt +++ b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/elements/StorageTree.kt @@ -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) -> Unit, onRename: (Tree, String) -> Unit, onRemove: (Tree) -> Unit, - onEncrypt: (Tree) -> Unit, + onEncrypt: (Tree, String, Boolean) -> Unit, + onOpenEncrypted: (Tree, String, Boolean) -> Unit, + onCloseEncrypted: (Tree) -> Unit, + onDisableEncryption: (Tree) -> Unit, + getStatusText: (Tree) -> String, + isEncryptionOpened: (Tree) -> 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 ?: "" - ) + ), + 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 ) } } diff --git a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultScreen.kt b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultScreen.kt index 8f8d5df..9fb9dd7 100644 --- a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultScreen.kt +++ b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultScreen.kt @@ -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 { diff --git a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultViewModel.kt b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultViewModel.kt index 619577e..d3ac29b 100644 --- a/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultViewModel.kt +++ b/presentation/src/main/java/com/github/nullptroma/wallenc/presentation/screens/main/screens/local/vault/LocalVaultViewModel.kt @@ -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(listOf(), true)) { + private val _messages = MutableSharedFlow() + val messages: SharedFlow = _messages + private var _taskCount: Int = 0 private var tasksCount get() = _taskCount @@ -114,37 +118,105 @@ class LocalVaultViewModel @Inject constructor( } } - private val runningStorages = mutableSetOf() - fun enableEncryptionAndOpenStorage(storage: IStorageInfo) { - if(runningStorages.contains(storage)) + private val runningStorages = mutableSetOf() + 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) + } } \ No newline at end of file diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index 57aca8b..9789831 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -10,7 +10,9 @@ Show storage item menu Rename Remove + Encrypt New name Delete storage "%1$s"? + Storage encryption actions \ No newline at end of file