Compare commits

..

10 Commits

Author SHA1 Message Date
Пытков Роман
ce99888c6e Убран лишний отступ на NavBar на основном экране 2025-03-27 18:48:05 +03:00
Пытков Роман
824306d8bc storages в UnlockManager 2025-02-11 18:17:19 +03:00
Пытков Роман
85b8517a76 IUnlockManager теперь IVault 2025-02-11 17:55:54 +03:00
Пытков Роман
e1646611c2 Поправлен клик сквозь экран загрузки 2025-02-09 22:03:14 +03:00
Пытков Роман
4404ef2ff4 Исправление для юнит теста 2025-02-08 20:57:00 +03:00
Пытков Роман
86b5c6cae2 Опциональное шифрование имён файлов 2025-02-08 20:51:28 +03:00
Пытков Роман
da8808a4b9 Статичный IV для имён файлов 2025-02-08 17:45:13 +03:00
Пытков Роман
c95c374852 Дерево хранилищ 2025-02-05 21:29:16 +03:00
Пытков Роман
2cb2dabe3f Добавлен clickableDebounced 2025-02-05 13:27:20 +03:00
Пытков Роман
f7071382a7 Delete app/release directory 2025-01-28 22:53:14 +03:00
40 changed files with 1083 additions and 376 deletions

View File

@@ -1,37 +0,0 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.github.nullptroma.wallenc.app",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 26
}

View File

@@ -25,9 +25,17 @@ class SingletonModule {
@Singleton
fun provideVaultsManager(
@IoDispatcher ioDispatcher: CoroutineDispatcher,
@ApplicationContext context: Context
@ApplicationContext context: Context,
keyRepo: StorageKeyMapRepository,
): IVaultsManager {
return VaultsManager(ioDispatcher, context)
return VaultsManager(ioDispatcher, context, keyRepo)
}
@Provides
fun provideUnlockManager(
vaultsManager: IVaultsManager
): IUnlockManager {
return vaultsManager.unlockManager
}
@Provides
@@ -47,20 +55,4 @@ class SingletonModule {
): StorageMetaInfoRepository {
return StorageMetaInfoRepository(dao, ioDispatcher)
}
@Provides
@Singleton
fun provideUnlockManager(
@IoDispatcher ioDispatcher: CoroutineDispatcher,
keyRepo: StorageKeyMapRepository,
metaRepo: StorageMetaInfoRepository,
vaultsManager: IVaultsManager
): IUnlockManager {
return UnlockManager(
keymapRepository = keyRepo,
metaInfoRepository = metaRepo,
ioDispatcher = ioDispatcher,
vaultsManager = vaultsManager
)
}
}

View File

@@ -4,6 +4,7 @@ import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.domain.usecases.GetOpenedStoragesUseCase
import com.github.nullptroma.wallenc.domain.usecases.ManageLocalVaultUseCase
import com.github.nullptroma.wallenc.domain.usecases.ManageStoragesEncryptionUseCase
import com.github.nullptroma.wallenc.domain.usecases.RenameStorageUseCase
import com.github.nullptroma.wallenc.domain.usecases.StorageFileManagementUseCase
import dagger.Module
@@ -38,4 +39,10 @@ class UseCasesModule {
fun provideRenameStorageUseCase(): RenameStorageUseCase {
return RenameStorageUseCase()
}
@Provides
@Singleton
fun provideManageStoragesEncryptionUseCase(unlockManager: IUnlockManager): ManageStoragesEncryptionUseCase {
return ManageStoragesEncryptionUseCase(unlockManager)
}
}

View File

@@ -10,11 +10,11 @@ import com.github.nullptroma.wallenc.data.db.app.model.DbStorageKeyMap
@Dao
interface StorageKeyMapDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun add(keymap: DbStorageKeyMap)
suspend fun add(vararg keymaps: DbStorageKeyMap)
@Query("SELECT * FROM storage_key_maps")
suspend fun getAll(): List<DbStorageKeyMap>
@Delete
suspend fun delete(keymap: DbStorageKeyMap)
suspend fun delete(vararg keymaps: DbStorageKeyMap)
}

View File

@@ -11,13 +11,13 @@ class StorageKeyMapRepository(
private val ioDispatcher: CoroutineDispatcher
) {
suspend fun getAll() = withContext(ioDispatcher) { dao.getAll().map { it.toModel() } }
suspend fun add(keymap: StorageKeyMap) = withContext(ioDispatcher) {
val dbModel = DbStorageKeyMap.fromModel(keymap)
dao.add(dbModel)
suspend fun add(vararg keymaps: StorageKeyMap) = withContext(ioDispatcher) {
val dbModels = keymaps.map { DbStorageKeyMap.fromModel(it) }
dao.add(*dbModels.toTypedArray())
}
suspend fun delete(keymap: StorageKeyMap) = withContext(ioDispatcher) {
val dbModel = DbStorageKeyMap.fromModel(keymap)
dao.delete(dbModel)
suspend fun delete(vararg keymaps: StorageKeyMap) = withContext(ioDispatcher) {
val dbModels = keymaps.map { DbStorageKeyMap.fromModel(it) }
dao.delete(*dbModels.toTypedArray())
}
}

View File

@@ -5,16 +5,22 @@ import com.github.nullptroma.wallenc.data.db.app.repository.StorageMetaInfoRepos
import com.github.nullptroma.wallenc.data.model.StorageKeyMap
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
import com.github.nullptroma.wallenc.data.storages.encrypt.EncryptedStorage
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.encrypt.Encryptor
import com.github.nullptroma.wallenc.domain.enums.VaultType
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext
@@ -22,7 +28,6 @@ import java.util.UUID
class UnlockManager(
private val keymapRepository: StorageKeyMapRepository,
private val metaInfoRepository: StorageMetaInfoRepository,
private val ioDispatcher: CoroutineDispatcher,
vaultsManager: IVaultsManager
) : IUnlockManager {
@@ -30,22 +35,55 @@ class UnlockManager(
override val openedStorages: StateFlow<Map<UUID, IStorage>?>
get() = _openedStorages
private val mutex = Mutex()
override val type: VaultType
get() = VaultType.DECRYPTED
override val uuid: UUID
get() = TODO("Not yet implemented")
override val isAvailable: StateFlow<Boolean>
get() = MutableStateFlow(true)
override val totalSpace: StateFlow<Int?>
get() = MutableStateFlow(null)
override val availableSpace: StateFlow<Int?>
get() = MutableStateFlow(null)
override val storages: StateFlow<List<IStorage>?>
get() = openedStorages.map { it?.values?.toList() }.stateIn(
scope = CoroutineScope(ioDispatcher),
started = SharingStarted.WhileSubscribed(5000L),
initialValue = null
)
init {
CoroutineScope(ioDispatcher).launch {
vaultsManager.allStorages.collectLatest {
mutex.lock()
val allKeys = keymapRepository.getAll()
val allStorages = it.associateBy({ it.uuid }, { it })
val usedKeys = mutableListOf<StorageKeyMap>()
val keysToRemove = mutableListOf<StorageKeyMap>()
val allStorages = it.toMutableList()
val map = _openedStorages.value?.toMutableMap() ?: mutableMapOf()
for(keymap in allKeys) {
if(map.contains(keymap.sourceUuid))
while(allStorages.size > 0) {
val storage = allStorages[allStorages.size-1]
val key = allKeys.find { key -> key.sourceUuid == storage.uuid }
if(key == null) {
allStorages.removeAt(allStorages.size - 1)
continue
val storage = allStorages[keymap.sourceUuid] ?: continue
val encStorage = createEncryptedStorage(storage, keymap.key, keymap.destUuid)
map[storage.uuid] = encStorage
}
_openedStorages.value = map
try {
val encStorage = createEncryptedStorage(storage, key.key, key.destUuid)
map[storage.uuid] = encStorage
usedKeys.add(key)
allStorages.removeAt(allStorages.size - 1)
allStorages.add(encStorage)
}
catch (_: Exception) {
// ключ не подошёл
keysToRemove.add(key)
allStorages.removeAt(allStorages.size - 1)
}
}
keymapRepository.delete(*keysToRemove.toTypedArray()) // удалить мёртвые ключи
_openedStorages.value = map.toMap()
mutex.unlock()
}
}
@@ -56,7 +94,6 @@ class UnlockManager(
source = storage,
key = key,
ioDispatcher = ioDispatcher,
metaInfoProvider = metaInfoRepository.createSingleStorageProvider(uuid),
uuid = uuid
)
}
@@ -64,7 +101,7 @@ class UnlockManager(
override suspend fun open(
storage: IStorage,
key: EncryptKey
) = withContext(ioDispatcher) {
): EncryptedStorage = withContext(ioDispatcher) {
mutex.lock()
val encInfo = storage.metaInfo.value.encInfo ?: throw Exception("EncInfo is null") // TODO
if (!Encryptor.checkKey(key, encInfo))
@@ -85,22 +122,57 @@ class UnlockManager(
_openedStorages.value = opened
keymapRepository.add(keymap)
mutex.unlock()
return@withContext encStorage
}
override suspend fun close(storage: IStorage) = withContext(ioDispatcher) {
/**
* Закрыть шифрование хранилища, закрывает рекурсивно, удаляя все ключи
* @param storage исходное хранилище, а не расшифрованное отображение
*/
override suspend fun close(storage: IStorage) {
close(storage.uuid)
}
/**
* Закрыть шифрование хранилища, закрывает рекурсивно, удаляя все ключи
* @param uuid uuid исходного хранилища
*/
override suspend fun close(uuid: UUID): Unit = withContext(ioDispatcher) {
mutex.lock()
val opened = _openedStorages.first { it != null }!!
val enc = opened[storage.uuid] ?: return@withContext
val enc = opened[uuid] ?: return@withContext
close(enc)
val model = StorageKeyMap(
sourceUuid = storage.uuid,
sourceUuid = uuid,
destUuid = enc.uuid,
key = EncryptKey("")
)
_openedStorages.value = opened.toMutableMap().apply {
remove(storage.uuid)
remove(uuid)
}
enc.dispose()
keymapRepository.delete(model)
mutex.unlock()
}
override suspend fun createStorage(): IStorage {
throw UnsupportedOperationException("Нельзя создать кошелёк на UnlockManager") // TODO
}
override suspend fun createStorage(enc: StorageEncryptionInfo): IStorage {
throw UnsupportedOperationException("Нельзя создать кошелёк на UnlockManager") // TODO
}
/**
* Закрыть отображение
* @param storage исходное или расшифрованное хранилище
*/
override suspend fun remove(storage: IStorage) {
val opened = _openedStorages.first { it != null }!!
val source = opened.entries.firstOrNull {
it.key == storage.uuid || it.value.uuid == storage.uuid
}
if(source != null)
close(source.key)
}
}

View File

@@ -1,29 +1,40 @@
package com.github.nullptroma.wallenc.data.storages.encrypt
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.github.nullptroma.wallenc.data.db.app.repository.StorageMetaInfoRepository
import com.github.nullptroma.wallenc.domain.common.impl.CommonStorageMetaInfo
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.encrypt.Encryptor
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DisposableHandle
import kotlinx.coroutines.Job
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
class EncryptedStorage private constructor(
private val source: IStorage,
key: EncryptKey,
private val ioDispatcher: CoroutineDispatcher,
private val metaInfoProvider: StorageMetaInfoRepository.SingleStorageMetaInfoProvider,
private val key: EncryptKey,
ioDispatcher: CoroutineDispatcher,
override val uuid: UUID = UUID.randomUUID()
) : IStorage, DisposableHandle {
private val job = Job()
private val scope = CoroutineScope(ioDispatcher + job)
private val encInfo =
source.metaInfo.value.encInfo ?: throw Exception("Storage is not encrypted") // TODO
private val metaInfoFileName: String = "${uuid.toString().take(8)}$STORAGE_INFO_FILE_POSTFIX"
override val size: StateFlow<Long?>
get() = source.size
get() = accessor.size
override val numberOfFiles: StateFlow<Int?>
get() = source.numberOfFiles
get() = accessor.numberOfFiles
private val _metaInfo = MutableStateFlow<IStorageMetaInfo>(
CommonStorageMetaInfo()
@@ -35,43 +46,75 @@ class EncryptedStorage private constructor(
override val isAvailable: StateFlow<Boolean>
get() = source.isAvailable
override val accessor: EncryptedStorageAccessor =
EncryptedStorageAccessor(source.accessor, key, ioDispatcher)
EncryptedStorageAccessor(
source = source.accessor,
pathIv = encInfo.pathIv,
key = key,
systemHiddenDirName = "${uuid.toString().take(8)}$SYSTEM_HIDDEN_DIRNAME_POSTFIX",
scope = scope
)
private suspend fun init() {
readMeta()
checkKey()
readMetaInfo()
}
private suspend fun readMeta() = withContext(ioDispatcher) {
var meta = metaInfoProvider.get()
if(meta == null) {
private fun checkKey() {
if (!Encryptor.checkKey(key, encInfo))
throw Exception("Incorrect key") // TODO
}
private suspend fun readMetaInfo() = scope.run {
var meta: CommonStorageMetaInfo
var reader: InputStream? = null
try {
reader = accessor.openReadSystemFile(metaInfoFileName)
meta = jackson.readValue(reader, CommonStorageMetaInfo::class.java)
} catch (e: Exception) {
// чтение не удалось, значит нужно записать файл
meta = CommonStorageMetaInfo()
metaInfoProvider.set(meta)
updateMetaInfo(meta)
} finally {
reader?.close()
}
_metaInfo.value = meta
}
override suspend fun rename(newName: String) = withContext(ioDispatcher) {
val cur = _metaInfo.value
val newMeta = CommonStorageMetaInfo(
encInfo = cur.encInfo,
name = newName
)
_metaInfo.value = newMeta
metaInfoProvider.set(newMeta)
private suspend fun updateMetaInfo(meta: IStorageMetaInfo) = scope.run {
val writer = accessor.openWriteSystemFile(metaInfoFileName)
try {
jackson.writeValue(writer, meta)
} catch (e: Exception) {
throw e
} finally {
writer.close()
}
_metaInfo.value = meta
}
override suspend fun setEncInfo(encInfo: StorageEncryptionInfo) = withContext(ioDispatcher) {
val cur = _metaInfo.value
val newMeta = CommonStorageMetaInfo(
encInfo = encInfo,
name = cur.name
override suspend fun rename(newName: String) = scope.run {
val curMeta = metaInfo.value
updateMetaInfo(
CommonStorageMetaInfo(
encInfo = curMeta.encInfo,
name = newName
)
)
}
override suspend fun setEncInfo(encInfo: StorageEncryptionInfo) = scope.run {
val curMeta = metaInfo.value
updateMetaInfo(
CommonStorageMetaInfo(
encInfo = encInfo,
name = curMeta.name
)
)
_metaInfo.value = newMeta
metaInfoProvider.set(newMeta)
}
override fun dispose() {
accessor.dispose()
job.cancel()
}
companion object {
@@ -79,18 +122,25 @@ class EncryptedStorage private constructor(
source: IStorage,
key: EncryptKey,
ioDispatcher: CoroutineDispatcher,
metaInfoProvider: StorageMetaInfoRepository.SingleStorageMetaInfoProvider,
uuid: UUID = UUID.randomUUID()
): EncryptedStorage = withContext(ioDispatcher) {
val storage = EncryptedStorage(
source = source,
key = key,
ioDispatcher = ioDispatcher,
metaInfoProvider = metaInfoProvider,
uuid = uuid
)
try {
storage.init()
} catch (e: Exception) {
storage.dispose()
throw e
}
return@withContext storage
}
private const val SYSTEM_HIDDEN_DIRNAME_POSTFIX = "-enc-dir"
const val STORAGE_INFO_FILE_POSTFIX = ".enc-meta"
private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() }
}
}

View File

@@ -1,21 +1,23 @@
package com.github.nullptroma.wallenc.data.storages.encrypt
import com.github.nullptroma.wallenc.data.utils.CloseHandledStreamExtension.Companion.onClosed
import com.github.nullptroma.wallenc.data.utils.CloseHandledStreamExtension.Companion.onClosing
import com.github.nullptroma.wallenc.domain.common.impl.CommonDirectory
import com.github.nullptroma.wallenc.domain.common.impl.CommonFile
import com.github.nullptroma.wallenc.domain.common.impl.CommonMetaInfo
import com.github.nullptroma.wallenc.domain.datatypes.DataPackage
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
import com.github.nullptroma.wallenc.domain.encrypt.Encryptor
import com.github.nullptroma.wallenc.domain.encrypt.EncryptorWithStaticIv
import com.github.nullptroma.wallenc.domain.interfaces.IDirectory
import com.github.nullptroma.wallenc.domain.interfaces.IFile
import com.github.nullptroma.wallenc.domain.interfaces.IMetaInfo
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
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.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
@@ -27,14 +29,17 @@ import kotlin.io.path.pathString
class EncryptedStorageAccessor(
private val source: IStorageAccessor,
pathIv: ByteArray?,
key: EncryptKey,
ioDispatcher: CoroutineDispatcher
private val systemHiddenDirName: String,
private val scope: CoroutineScope
) : IStorageAccessor, DisposableHandle {
private val job = Job()
private val scope = CoroutineScope(ioDispatcher + job)
private val _size = MutableStateFlow<Long?>(null)
override val size: StateFlow<Long?> = _size
private val _numberOfFiles = MutableStateFlow<Int?>(null)
override val numberOfFiles: StateFlow<Int?> = _numberOfFiles
override val size: StateFlow<Long?> = source.size
override val numberOfFiles: StateFlow<Int?> = source.numberOfFiles
override val isAvailable: StateFlow<Boolean> = source.isAvailable
private val _filesUpdates = MutableSharedFlow<DataPackage<List<IFile>>>()
@@ -43,37 +48,68 @@ class EncryptedStorageAccessor(
private val _dirsUpdates = MutableSharedFlow<DataPackage<List<IDirectory>>>()
override val dirsUpdates: SharedFlow<DataPackage<List<IDirectory>>> = _dirsUpdates
private val encryptor = Encryptor(key.toAesKey())
private val dataEncryptor = Encryptor(key.toAesKey())
private val pathEncryptor: EncryptorWithStaticIv? = if(pathIv != null) EncryptorWithStaticIv(key.toAesKey(), pathIv) else null
private var systemHiddenFilesIsActual = false
init {
collectSourceState()
}
private fun collectSourceState() {
scope.launch {
launch {
source.filesUpdates.collect {
val files = it.data.map(::decryptEntity)
_filesUpdates.emit(DataPackage(
val files = it.data.map(::decryptEntity).filterSystemHiddenFiles()
_filesUpdates.emit(
DataPackage(
data = files,
isLoading = it.isLoading,
isError = it.isError
))
)
)
}
}
launch {
source.dirsUpdates.collect {
val dirs = it.data.map(::decryptEntity)
_dirsUpdates.emit(DataPackage(
val dirs = it.data.map(::decryptEntity).filterSystemHiddenDirs()
_dirsUpdates.emit(
DataPackage(
data = dirs,
isLoading = it.isLoading,
isError = it.isError
))
)
)
}
}
launch {
source.numberOfFiles.collect {
if(it == null)
_numberOfFiles.value = null
else
{
_numberOfFiles.value = it - getSystemFiles().size
}
}
}
launch {
source.size.collect { sourceSize ->
if(sourceSize == null)
_size.value = null
else
{
_size.value = sourceSize - getSystemFiles().sumOf { it.metaInfo.size }
}
}
}
}
}
private suspend fun getSystemFiles(): List<IFile> {
return source.getFiles(encryptPath(systemHiddenDirName))
}
private fun encryptEntity(file: IFile): IFile {
@@ -113,35 +149,40 @@ class EncryptedStorageAccessor(
}
private fun encryptPath(pathStr: String): String {
if(pathEncryptor == null)
return pathStr
val path = Path(pathStr)
val segments = mutableListOf<String>()
for (segment in path)
segments.add(encryptor.encryptString(segment.pathString))
val res = Path("/",*(segments.toTypedArray()))
segments.add(pathEncryptor.encryptString(segment.pathString))
val res = Path("/", *(segments.toTypedArray()))
return res.pathString
}
private fun decryptPath(pathStr: String): String {
if(pathEncryptor == null)
return pathStr
val path = Path(pathStr)
val segments = mutableListOf<String>()
for (segment in path)
segments.add(encryptor.decryptString(segment.pathString))
val res = Path("/",*(segments.toTypedArray()))
segments.add(pathEncryptor.decryptString(segment.pathString))
val res = Path("/", *(segments.toTypedArray()))
return res.pathString
}
override suspend fun getAllFiles(): List<IFile> {
return source.getAllFiles().map(::decryptEntity)
return source.getAllFiles().map(::decryptEntity).filterSystemHiddenFiles()
}
override suspend fun getFiles(path: String): List<IFile> {
return source.getFiles(encryptPath(path)).map(::decryptEntity)
return source.getFiles(encryptPath(path)).map(::decryptEntity).filterSystemHiddenFiles()
}
override fun getFilesFlow(path: String): Flow<DataPackage<List<IFile>>> {
val flow = source.getFilesFlow(encryptPath(path)).map {
DataPackage(
data = it.data.map(::decryptEntity),
data = it.data.map(::decryptEntity).filterSystemHiddenFiles(),
isLoading = it.isLoading,
isError = it.isError
)
@@ -150,17 +191,18 @@ class EncryptedStorageAccessor(
}
override suspend fun getAllDirs(): List<IDirectory> {
return source.getAllDirs().map(::decryptEntity)
return source.getAllDirs().map(::decryptEntity).filterSystemHiddenDirs()
}
override suspend fun getDirs(path: String): List<IDirectory> {
return source.getDirs(encryptPath(path)).map(::decryptEntity)
return source.getDirs(encryptPath(path)).map(::decryptEntity).filterSystemHiddenDirs()
}
override fun getDirsFlow(path: String): Flow<DataPackage<List<IDirectory>>> {
val flow = source.getDirsFlow(encryptPath(path)).map {
DataPackage(
data = it.data.map(::decryptEntity),
// включать все папки, кроме системной
data = it.data.map(::decryptEntity).filterSystemHiddenDirs(),
isLoading = it.isLoading,
isError = it.isError
)
@@ -198,12 +240,12 @@ class EncryptedStorageAccessor(
override suspend fun openWrite(path: String): OutputStream {
val stream = source.openWrite(encryptPath(path))
return encryptor.encryptStream(stream)
return dataEncryptor.encryptStream(stream)
}
override suspend fun openRead(path: String): InputStream {
val stream = source.openRead(encryptPath(path))
return encryptor.decryptStream(stream)
return dataEncryptor.decryptStream(stream)
}
override suspend fun moveToTrash(path: String) {
@@ -211,8 +253,36 @@ class EncryptedStorageAccessor(
}
override fun dispose() {
job.cancel()
encryptor.dispose()
dataEncryptor.dispose()
}
suspend fun openReadSystemFile(name: String): InputStream = scope.run {
val path = Path(systemHiddenDirName, name).pathString
return@run openRead(path)
}
suspend fun openWriteSystemFile(name: String): OutputStream = scope.run {
val path = Path(systemHiddenDirName, name).pathString
systemHiddenFilesIsActual = false
return@run openWrite(path).onClosing {
systemHiddenFilesIsActual = false
}
}
private fun Iterable<IFile>.filterSystemHiddenFiles(): List<IFile> {
return this.filter { file ->
!file.metaInfo.path.contains(
systemHiddenDirName
)
}
}
private fun Iterable<IDirectory>.filterSystemHiddenDirs(): List<IDirectory> {
return this.filter { dir ->
!dir.metaInfo.path.contains(
systemHiddenDirName
)
}
}
}

View File

@@ -3,7 +3,7 @@ package com.github.nullptroma.wallenc.data.storages.local
import com.fasterxml.jackson.core.JacksonException
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.github.nullptroma.wallenc.data.utils.CloseHandledStreamExtension.Companion.onClose
import com.github.nullptroma.wallenc.data.utils.CloseHandledStreamExtension.Companion.onClosed
import com.github.nullptroma.wallenc.domain.common.impl.CommonDirectory
import com.github.nullptroma.wallenc.domain.common.impl.CommonFile
import com.github.nullptroma.wallenc.domain.common.impl.CommonMetaInfo
@@ -433,6 +433,8 @@ class LocalStorageAccessor(
if (file.exists() && file.isDirectory) {
throw Exception("Что то пошло не так") // TODO
} else if(!file.exists()) {
val parent = Path(storagePath).parent
createDir(parent.pathString)
file.createNewFile()
val cur = _numberOfFiles.value
@@ -441,7 +443,7 @@ class LocalStorageAccessor(
val pair = LocalStorageFilePair.from(_filesystemBasePath, file)
?: throw Exception("Что то пошло не так") // TODO
val newMeta = pair.meta.copy(lastModified = Clock.systemUTC().instant())
val newMeta = pair.meta.copy(lastModified = Clock.systemUTC().instant(), size = Files.size(pair.file.toPath()))
writeMeta(pair.metaFile, newMeta)
_filesUpdates.emit(
DataPage(
@@ -503,9 +505,10 @@ class LocalStorageAccessor(
touchFile(path)
val pair = LocalStorageFilePair.from(_filesystemBasePath, path)
?: throw Exception("Файла нет") // TODO
return@withContext pair.file.outputStream().onClose {
return@withContext pair.file.outputStream().onClosed {
CoroutineScope(ioDispatcher).launch {
touchFile(path)
scanSizeAndNumOfFiles()
}
}
}

View File

@@ -5,6 +5,7 @@ import java.io.OutputStream
private class CloseHandledOutputStream(
private val stream: OutputStream,
private val onClosing: () -> Unit,
private val onClose: () -> Unit
) : OutputStream() {
@@ -25,6 +26,7 @@ private class CloseHandledOutputStream(
}
override fun close() {
onClosing()
try {
stream.close()
} finally {
@@ -35,6 +37,7 @@ private class CloseHandledOutputStream(
private class CloseHandledInputStream(
private val stream: InputStream,
private val onClosing: () -> Unit,
private val onClose: () -> Unit
) : InputStream() {
@@ -59,6 +62,7 @@ private class CloseHandledInputStream(
}
override fun close() {
onClosing()
try {
stream.close()
} finally {
@@ -81,12 +85,20 @@ private class CloseHandledInputStream(
class CloseHandledStreamExtension {
companion object {
fun OutputStream.onClose(callback: ()->Unit): OutputStream {
return CloseHandledOutputStream(this, callback)
fun OutputStream.onClosed(callback: ()->Unit): OutputStream {
return CloseHandledOutputStream(this, {}, callback)
}
fun InputStream.onClose(callback: ()->Unit): InputStream {
return CloseHandledInputStream(this, callback)
fun InputStream.onClosed(callback: ()->Unit): InputStream {
return CloseHandledInputStream(this, {}, callback)
}
fun OutputStream.onClosing(callback: ()->Unit): OutputStream {
return CloseHandledOutputStream(this, callback, {})
}
fun InputStream.onClosing(callback: ()->Unit): InputStream {
return CloseHandledInputStream(this, callback, {})
}
}
}

View File

@@ -0,0 +1,176 @@
package com.github.nullptroma.wallenc.data.vaults
import com.github.nullptroma.wallenc.data.db.app.repository.StorageKeyMapRepository
import com.github.nullptroma.wallenc.data.model.StorageKeyMap
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
import com.github.nullptroma.wallenc.data.storages.encrypt.EncryptedStorage
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.encrypt.Encryptor
import com.github.nullptroma.wallenc.domain.enums.VaultType
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext
import java.util.UUID
class UnlockManager(
private val keymapRepository: StorageKeyMapRepository,
private val ioDispatcher: CoroutineDispatcher,
vaultsManager: IVaultsManager
) : IUnlockManager {
private val _openedStorages = MutableStateFlow<Map<UUID, EncryptedStorage>?>(null)
override val openedStorages: StateFlow<Map<UUID, IStorage>?>
get() = _openedStorages
private val mutex = Mutex()
override val type: VaultType
get() = VaultType.DECRYPTED
override val uuid: UUID
get() = TODO("Not yet implemented")
override val isAvailable: StateFlow<Boolean>
get() = MutableStateFlow(true)
override val totalSpace: StateFlow<Int?>
get() = MutableStateFlow(null)
override val availableSpace: StateFlow<Int?>
get() = MutableStateFlow(null)
override val storages: StateFlow<List<IStorage>?>
get() = openedStorages.map { it?.values?.toList() }.stateIn(
scope = CoroutineScope(ioDispatcher),
started = SharingStarted.WhileSubscribed(5000L),
initialValue = null
)
init {
CoroutineScope(ioDispatcher).launch {
vaultsManager.allStorages.collectLatest {
mutex.lock()
val allKeys = keymapRepository.getAll()
val usedKeys = mutableListOf<StorageKeyMap>()
val keysToRemove = mutableListOf<StorageKeyMap>()
val allStorages = it.toMutableList()
val map = _openedStorages.value?.toMutableMap() ?: mutableMapOf()
while(allStorages.size > 0) {
val storage = allStorages[allStorages.size-1]
val key = allKeys.find { key -> key.sourceUuid == storage.uuid }
if(key == null) {
allStorages.removeAt(allStorages.size - 1)
continue
}
try {
val encStorage = createEncryptedStorage(storage, key.key, key.destUuid)
map[storage.uuid] = encStorage
usedKeys.add(key)
allStorages.removeAt(allStorages.size - 1)
allStorages.add(encStorage)
}
catch (_: Exception) {
// ключ не подошёл
keysToRemove.add(key)
allStorages.removeAt(allStorages.size - 1)
}
}
keymapRepository.delete(*keysToRemove.toTypedArray()) // удалить мёртвые ключи
_openedStorages.value = map.toMap()
mutex.unlock()
}
}
}
private suspend fun createEncryptedStorage(storage: IStorage, key: EncryptKey, uuid: UUID): EncryptedStorage {
return EncryptedStorage.create(
source = storage,
key = key,
ioDispatcher = ioDispatcher,
uuid = uuid
)
}
override suspend fun open(
storage: IStorage,
key: EncryptKey
): EncryptedStorage = withContext(ioDispatcher) {
mutex.lock()
val encInfo = storage.metaInfo.value.encInfo ?: throw Exception("EncInfo is null") // TODO
if (!Encryptor.checkKey(key, encInfo))
throw Exception("Incorrect Key")
val opened = _openedStorages.first { it != null }!!.toMutableMap()
val cur = opened[storage.uuid]
if (cur != null)
throw Exception("Storage is already open")
val keymap = StorageKeyMap(
sourceUuid = storage.uuid,
destUuid = UUID.randomUUID(),
key = key
)
val encStorage = createEncryptedStorage(storage, keymap.key, keymap.destUuid)
opened[storage.uuid] = encStorage
_openedStorages.value = opened
keymapRepository.add(keymap)
mutex.unlock()
return@withContext encStorage
}
/**
* Закрыть шифрование хранилища, закрывает рекурсивно, удаляя все ключи
* @param storage исходное хранилище, а не расшифрованное отображение
*/
override suspend fun close(storage: IStorage) {
close(storage.uuid)
}
/**
* Закрыть шифрование хранилища, закрывает рекурсивно, удаляя все ключи
* @param uuid uuid исходного хранилища
*/
override suspend fun close(uuid: UUID): Unit = withContext(ioDispatcher) {
mutex.lock()
val opened = _openedStorages.first { it != null }!!
val enc = opened[uuid] ?: return@withContext
close(enc)
val model = StorageKeyMap(
sourceUuid = uuid,
destUuid = enc.uuid,
key = EncryptKey("")
)
_openedStorages.value = opened.toMutableMap().apply {
remove(uuid)
}
enc.dispose()
keymapRepository.delete(model)
mutex.unlock()
}
override suspend fun createStorage(): IStorage {
throw UnsupportedOperationException("Нельзя создать кошелёк на UnlockManager") // TODO
}
override suspend fun createStorage(enc: StorageEncryptionInfo): IStorage {
throw UnsupportedOperationException("Нельзя создать кошелёк на UnlockManager") // TODO
}
/**
* Закрыть отображение
* @param storage исходное или расшифрованное хранилище
*/
override suspend fun remove(storage: IStorage) {
val opened = _openedStorages.first { it != null }!!
val source = opened.entries.firstOrNull {
it.key == storage.uuid || it.value.uuid == storage.uuid
}
if(source != null)
close(source.key)
}
}

View File

@@ -1,19 +1,30 @@
package com.github.nullptroma.wallenc.data.vaults
import android.content.Context
import com.github.nullptroma.wallenc.data.db.app.repository.StorageKeyMapRepository
import com.github.nullptroma.wallenc.data.storages.UnlockManager
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager
import com.github.nullptroma.wallenc.domain.interfaces.IVault
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class VaultsManager(ioDispatcher: CoroutineDispatcher, context: Context) : IVaultsManager {
class VaultsManager(ioDispatcher: CoroutineDispatcher, context: Context, keyRepo: StorageKeyMapRepository) : IVaultsManager {
override val localVault = LocalVault(ioDispatcher, context)
override val unlockManager: IUnlockManager = UnlockManager(
keymapRepository = keyRepo,
ioDispatcher = ioDispatcher,
vaultsManager = this
)
override val remoteVaults: StateFlow<List<IVault>>
get() = TODO("Not yet implemented")
override val allStorages: StateFlow<List<IStorage>>
get() = localVault.storages
override val allVaults: StateFlow<List<IVault>>
get() = MutableStateFlow(listOf(localVault, unlockManager))
override fun addYandexVault(email: String, token: String) {
TODO("Not yet implemented")

View File

@@ -9,10 +9,7 @@ import java.time.Instant
data class CommonStorageMetaInfo(
override val encInfo: StorageEncryptionInfo = StorageEncryptionInfo(
isEncrypted = false,
encryptedTestData = null
),
override val encInfo: StorageEncryptionInfo? = null,
override val name: String? = null,
override val lastModified: Instant = Clock.systemUTC().instant()
) : IStorageMetaInfo

View File

@@ -1,6 +1,24 @@
package com.github.nullptroma.wallenc.domain.datatypes
data class StorageEncryptionInfo(
val isEncrypted: Boolean,
val encryptedTestData: String?
)
val encryptedTestData: String,
val pathIv: ByteArray?
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as StorageEncryptionInfo
if (encryptedTestData != other.encryptedTestData) return false
if (!pathIv.contentEquals(other.pathIv)) return false
return true
}
override fun hashCode(): Int {
var result = encryptedTestData.hashCode()
result = 31 * result + pathIv.contentHashCode()
return result
}
}

View File

@@ -14,7 +14,7 @@ import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.random.Random
class Encryptor(private var _secretKey: SecretKey?) : DisposableHandle {
class Encryptor(private var secretKey: SecretKey) : DisposableHandle {
@OptIn(ExperimentalEncodingApi::class)
fun encryptString(str: String): String {
val bytesToEncrypt = str.toByteArray(Charsets.UTF_8)
@@ -30,70 +30,71 @@ class Encryptor(private var _secretKey: SecretKey?) : DisposableHandle {
}
fun encryptBytes(bytes: ByteArray): ByteArray {
val secretKey = _secretKey ?: throw Exception("Object was disposed")
if(secretKey.isDestroyed)
throw Exception("Object was destroyed")
val cipher = Cipher.getInstance(AES_SETTINGS)
val iv = IvParameterSpec(Random.nextBytes(IV_LEN))
cipher.init(Cipher.ENCRYPT_MODE, secretKey, iv)
val encryptedBytes = iv.iv + cipher.doFinal(bytes) // iv + зашифрованные байты
val ivSpec = IvParameterSpec(Random.nextBytes(IV_LEN))
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec)
val encryptedBytes = ivSpec.iv + cipher.doFinal(bytes) // iv + зашифрованные байты
return encryptedBytes
}
fun decryptBytes(bytes: ByteArray): ByteArray {
val secretKey = _secretKey ?: throw Exception("Object was disposed")
if(secretKey.isDestroyed)
throw Exception("Object was destroyed")
val cipher = Cipher.getInstance(AES_SETTINGS)
val iv = IvParameterSpec(bytes.take(IV_LEN).toByteArray())
cipher.init(Cipher.DECRYPT_MODE, secretKey, iv)
val ivSpec = IvParameterSpec(bytes.take(IV_LEN).toByteArray())
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec)
val decryptedBytes = cipher.doFinal(bytes.drop(IV_LEN).toByteArray())
return decryptedBytes
}
fun encryptStream(stream: OutputStream): OutputStream {
val secretKey = _secretKey ?: throw Exception("Object was disposed")
val iv = IvParameterSpec(Random.nextBytes(IV_LEN))
stream.write(iv.iv) // Запись инициализационного вектора сырой файл
if(secretKey.isDestroyed)
throw Exception("Object was destroyed")
val ivSpec = IvParameterSpec(Random.nextBytes(IV_LEN))
stream.write(ivSpec.iv) // Запись инициализационного вектора сырой файл
val cipher = Cipher.getInstance(AES_SETTINGS)
cipher.init(Cipher.ENCRYPT_MODE, secretKey, iv) // инициализация шифратора
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec) // инициализация шифратора
return CipherOutputStream(stream, cipher)
}
fun decryptStream(stream: InputStream): InputStream {
val secretKey = _secretKey ?: throw Exception("Object was disposed")
if(secretKey.isDestroyed)
throw Exception("Object was destroyed")
val ivBytes = ByteArray(IV_LEN) // Буфер для 16 байт IV
val bytesRead = stream.read(ivBytes) // Чтение IV вектора
if(bytesRead != IV_LEN)
throw Exception("TODO iv не прочитан")
val iv = IvParameterSpec(ivBytes)
val ivSpec = IvParameterSpec(ivBytes)
val cipher = Cipher.getInstance(AES_SETTINGS)
cipher.init(Cipher.DECRYPT_MODE, secretKey, iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec)
return CipherInputStream(stream, cipher)
}
override fun dispose() {
_secretKey?.destroy()
_secretKey = null
//secretKey.destroy()
}
companion object {
private const val IV_LEN = 16
public const val IV_LEN = 16
public const val AES_SETTINGS = "AES/CBC/PKCS5Padding"
private const val TEST_DATA_LEN = 512
private const val AES_SETTINGS = "AES/CBC/PKCS5Padding"
@OptIn(ExperimentalEncodingApi::class)
fun generateEncryptionInfo(key: EncryptKey) : StorageEncryptionInfo {
fun generateEncryptionInfo(key: EncryptKey, encryptPath: Boolean = true) : StorageEncryptionInfo {
val encryptor = Encryptor(key.toAesKey())
val testData = ByteArray(TEST_DATA_LEN)
val encryptedData = encryptor.encryptBytes(testData)
return StorageEncryptionInfo(
isEncrypted = true,
encryptedTestData = Base64.Default.encode(encryptedData)
encryptedTestData = Base64.Default.encode(encryptedData),
pathIv = if(encryptPath) Random.nextBytes(IV_LEN) else null
)
}
@OptIn(ExperimentalEncodingApi::class)
fun checkKey(key: EncryptKey, encInfo: StorageEncryptionInfo): Boolean {
if(encInfo.encryptedTestData == null)
return false
val encryptor = Encryptor(key.toAesKey())
try {
val encData = Base64.Default.decode(encInfo.encryptedTestData)

View File

@@ -0,0 +1,73 @@
package com.github.nullptroma.wallenc.domain.encrypt
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.encrypt.Encryptor.Companion.AES_SETTINGS
import com.github.nullptroma.wallenc.domain.encrypt.Encryptor.Companion.IV_LEN
import kotlinx.coroutines.DisposableHandle
import java.io.InputStream
import java.io.OutputStream
import javax.crypto.Cipher
import javax.crypto.CipherInputStream
import javax.crypto.CipherOutputStream
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.random.Random
class EncryptorWithStaticIv(private var secretKey: SecretKey, iv: ByteArray) : DisposableHandle {
private val ivSpec = IvParameterSpec(iv)
@OptIn(ExperimentalEncodingApi::class)
fun encryptString(str: String): String {
val bytesToEncrypt = str.toByteArray(Charsets.UTF_8)
val encryptedBytes = encryptBytes(bytesToEncrypt)
return Base64.Default.encode(encryptedBytes).replace("/", ".")
}
@OptIn(ExperimentalEncodingApi::class)
fun decryptString(str: String): String {
val bytesToDecrypt = Base64.Default.decode(str.replace(".", "/"))
val decryptedBytes = decryptBytes(bytesToDecrypt)
return String(decryptedBytes, Charsets.UTF_8)
}
fun encryptBytes(bytes: ByteArray): ByteArray {
if(secretKey.isDestroyed)
throw Exception("Object was destroyed")
val cipher = Cipher.getInstance(AES_SETTINGS)
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec)
val encryptedBytes = cipher.doFinal(bytes) // зашифрованные байты
return encryptedBytes
}
fun decryptBytes(bytes: ByteArray): ByteArray {
if(secretKey.isDestroyed)
throw Exception("Object was destroyed")
val cipher = Cipher.getInstance(AES_SETTINGS)
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec)
val decryptedBytes = cipher.doFinal(bytes)
return decryptedBytes
}
fun encryptStream(stream: OutputStream): OutputStream {
if(secretKey.isDestroyed)
throw Exception("Object was destroyed")
val cipher = Cipher.getInstance(AES_SETTINGS)
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec) // инициализация шифратора
return CipherOutputStream(stream, cipher)
}
fun decryptStream(stream: InputStream): InputStream {
if(secretKey.isDestroyed)
throw Exception("Object was destroyed")
val cipher = Cipher.getInstance(AES_SETTINGS)
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec)
return CipherInputStream(stream, cipher)
}
override fun dispose() {
secretKey.destroy()
}
}

View File

@@ -2,5 +2,6 @@ package com.github.nullptroma.wallenc.domain.enums
enum class VaultType {
LOCAL,
DECRYPTED,
YANDEX
}

View File

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

View File

@@ -1,13 +0,0 @@
package com.github.nullptroma.wallenc.domain.interfaces
import kotlinx.coroutines.flow.StateFlow
import java.util.UUID
sealed interface IStorageInfo {
val uuid: UUID
val isAvailable: StateFlow<Boolean>
val size: StateFlow<Long?>
val numberOfFiles: StateFlow<Int?>
val metaInfo: StateFlow<IStorageMetaInfo>
val isVirtualStorage: Boolean
}

View File

@@ -1,11 +0,0 @@
package com.github.nullptroma.wallenc.domain.interfaces
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import java.time.Instant
interface IStorageMetaInfo {
val encInfo: StorageEncryptionInfo
val name: String?
val lastModified: Instant
}

View File

@@ -4,12 +4,13 @@ import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
import kotlinx.coroutines.flow.StateFlow
import java.util.UUID
interface IUnlockManager {
interface IUnlockManager: IVault {
/**
* Хранилища, для которых есть ключ шифрования
*/
val openedStorages: StateFlow<Map<UUID, IStorage>?>
suspend fun open(storage: IStorage, key: EncryptKey)
suspend fun open(storage: IStorage, key: EncryptKey): IStorage
suspend fun close(storage: IStorage)
suspend fun close(uuid: UUID): Unit
}

View File

@@ -4,7 +4,7 @@ import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import kotlinx.coroutines.flow.StateFlow
interface IVault : IVaultInfo {
override val storages: StateFlow<List<IStorage>>
override val storages: StateFlow<List<IStorage>?>
suspend fun createStorage(): IStorage
suspend fun createStorage(enc: StorageEncryptionInfo): IStorage

View File

@@ -7,7 +7,7 @@ import java.util.UUID
sealed interface IVaultInfo {
val type: VaultType
val uuid: UUID
val storages: StateFlow<List<IStorageInfo>>
val storages: StateFlow<List<IStorageInfo>?>
val isAvailable: StateFlow<Boolean>
val totalSpace: StateFlow<Int?>
val availableSpace: StateFlow<Int?>

View File

@@ -4,8 +4,9 @@ import kotlinx.coroutines.flow.StateFlow
interface IVaultsManager {
val localVault: IVault
val unlockManager: IUnlockManager
val remoteVaults: StateFlow<List<IVault>>
val allStorages: StateFlow<List<IStorage>>
val allVaults: StateFlow<List<IVault>>
fun addYandexVault(email: String, token: String)
}

View File

@@ -10,19 +10,13 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
class ManageLocalVaultUseCase(private val manager: IVaultsManager, private val unlockManager: IUnlockManager) {
val localStorages: StateFlow<List<IStorageInfo>>
val localStorages: StateFlow<List<IStorageInfo>?>
get() = manager.localVault.storages
suspend fun createStorage() {
manager.localVault.createStorage()
}
suspend fun createStorage(key: EncryptKey) {
val encInfo = Encryptor.generateEncryptionInfo(key)
val storage = manager.localVault.createStorage(encInfo)
unlockManager.open(storage, key)
}
suspend fun remove(storage: IStorageInfo) {
when(storage) {
is IStorage -> manager.localVault.remove(storage)

View File

@@ -0,0 +1,27 @@
package com.github.nullptroma.wallenc.domain.usecases
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
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
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))
}
}
}
suspend fun openStorage(storage: IStorageInfo, key: EncryptKey): IStorageInfo {
when(storage) {
is IStorage -> {
return unlockManager.open(storage, key)
}
}
}
}

View File

@@ -0,0 +1,12 @@
package com.github.nullptroma.wallenc.domain.usecases
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
class RemoveStorageUseCase {
suspend fun rename(storage: IStorageInfo, newName: String) {
when (storage) {
is IStorage -> storage.rename(newName)
}
}
}

View File

@@ -1,5 +1,5 @@
[versions]
agp = "8.8.0"
agp = "8.9.1"
jacksonModuleKotlin = "2.18.2"
kotlin = "2.0.20"
coreKtx = "1.15.0"

View File

@@ -1,6 +1,6 @@
#Sat Sep 07 01:04:14 MSK 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -3,8 +3,11 @@ package com.github.nullptroma.wallenc.presentation
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material.icons.rounded.Settings
@@ -19,6 +22,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -71,13 +75,15 @@ fun WallencNavRoot(viewModel: WallencViewModel = hiltViewModel()) {
Scaffold(bottomBar = {
NavigationBar(modifier = Modifier.height(64.dp)) {
NavigationBar(modifier = Modifier.wrapContentHeight()) {
val navBackStackEntry by navState.navHostController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
topLevelNavBarItems.forEach {
val routeClassName = it.key
val navBarItemData = it.value
NavigationBarItem(icon = {
NavigationBarItem(
modifier = Modifier.wrapContentHeight(),
icon = {
if (navBarItemData.icon != null) Icon(
navBarItemData.icon,
contentDescription = stringResource(navBarItemData.nameStringResourceId)
@@ -92,7 +98,8 @@ fun WallencNavRoot(viewModel: WallencViewModel = hiltViewModel()) {
if (currentRoute?.startsWith(routeClassName) != true) navState.changeTop(
route
)
})
}
)
}
}
}) { innerPaddings ->

View File

@@ -1,17 +1,25 @@
package com.github.nullptroma.wallenc.presentation.elements
import android.widget.FrameLayout
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.MoreVert
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
@@ -20,7 +28,9 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -28,16 +38,20 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.domain.datatypes.Tree
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
import com.github.nullptroma.wallenc.presentation.R
import com.github.nullptroma.wallenc.presentation.utils.debouncedLambda
@Composable
fun StorageTree(
@@ -46,27 +60,51 @@ fun StorageTree(
onClick: (Tree<IStorageInfo>) -> Unit,
onRename: (Tree<IStorageInfo>, String) -> Unit,
onRemove: (Tree<IStorageInfo>) -> Unit,
onEncrypt: (Tree<IStorageInfo>) -> Unit,
) {
val cur = tree.value
val cardShape = RoundedCornerShape(30.dp)
Column(modifier) {
Card(
modifier = Modifier
.fillMaxWidth()
.clip(cardShape)
.clickable {
onClick(tree)
//viewModel.printStorageInfoToLog(cur)
},
shape = cardShape,
elevation = CardDefaults.cardElevation(
defaultElevation = 4.dp
),
) {
val available by cur.isAvailable.collectAsStateWithLifecycle()
val numOfFiles by cur.numberOfFiles.collectAsStateWithLifecycle()
val size by cur.size.collectAsStateWithLifecycle()
val metaInfo by cur.metaInfo.collectAsStateWithLifecycle()
val isAvailable by cur.isAvailable.collectAsStateWithLifecycle()
val borderColor =
if (cur.isVirtualStorage) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.primary
Column(modifier) {
Box(
modifier = Modifier
.height(IntrinsicSize.Min)
.zIndex(100f)
) {
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = Modifier
.clip(
CardDefaults.shape
)
.padding(0.dp, 0.dp, 16.dp, 0.dp)
.fillMaxSize()
.background(borderColor)
.clickable(
interactionSource = interactionSource,
indication = ripple(),
enabled = false,
onClick = { }
)
)
Card(
interactionSource = interactionSource,
modifier = Modifier
.padding(8.dp, 0.dp, 0.dp, 0.dp)
.fillMaxWidth(),
elevation = CardDefaults.cardElevation(
defaultElevation = 4.dp
),
onClick = debouncedLambda(debounceMs = 500) {
onClick(tree)
}
) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
Column(modifier = Modifier.padding(8.dp)) {
@@ -82,7 +120,7 @@ fun StorageTree(
modifier = Modifier,
horizontalAlignment = Alignment.End
) {
Box(modifier = Modifier.padding(0.dp, 8.dp, 8.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) }
@@ -134,11 +172,17 @@ fun StorageTree(
showRemoveConfirmationDiaglog = false
onRemove(tree)
},
title = stringResource(R.string.remove_confirmation_dialog, metaInfo.name ?: "<noname>")
title = stringResource(
R.string.remove_confirmation_dialog,
metaInfo.name ?: "<noname>"
)
)
}
}
Spacer(modifier = Modifier.weight(1f))
Button(onClick = { onEncrypt(tree) }, enabled = metaInfo.encInfo == null) {
Text("Encrypt")
}
Text(
modifier = Modifier
.fillMaxWidth()
@@ -156,8 +200,29 @@ fun StorageTree(
}
}
}
if(!isAvailable) {
Box(
modifier = Modifier
.clip(
CardDefaults.shape
)
.fillMaxSize()
.alpha(0.5f)
.background(Color.Black)
)
}
}
for (i in tree.children ?: listOf()) {
StorageTree(Modifier.padding(16.dp, 0.dp, 0.dp, 0.dp), i, onClick, onRename, onRemove)
StorageTree(
Modifier
.padding(16.dp, 0.dp, 0.dp, 0.dp)
.offset(y = (-4).dp),
i,
onClick,
onRename,
onRemove,
onEncrypt
)
}
}
}

View File

@@ -0,0 +1,61 @@
package com.github.nullptroma.wallenc.presentation.elements.indication
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.foundation.IndicationNodeFactory
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.node.DelegatableNode
import androidx.compose.ui.node.DrawModifierNode
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
private class ScaleNode(private val interactionSource: InteractionSource) :
Modifier.Node(), DrawModifierNode {
var currentPressPosition: Offset = Offset.Zero
val animatedScalePercent = Animatable(1f)
private suspend fun animateToPressed(pressPosition: Offset) {
currentPressPosition = pressPosition
animatedScalePercent.animateTo(0.9f, spring())
}
private suspend fun animateToResting() {
animatedScalePercent.animateTo(1f, spring())
}
override fun onAttach() {
coroutineScope.launch {
interactionSource.interactions.collectLatest { interaction ->
when (interaction) {
is PressInteraction.Press -> animateToPressed(interaction.pressPosition)
is PressInteraction.Release -> animateToResting()
is PressInteraction.Cancel -> animateToResting()
}
}
}
}
override fun ContentDrawScope.draw() {
scale(
scale = animatedScalePercent.value,
pivot = currentPressPosition
) {
this@draw.drawContent()
}
}
}
object ScaleIndication : IndicationNodeFactory {
override fun create(interactionSource: InteractionSource): DelegatableNode {
return ScaleNode(interactionSource)
}
override fun equals(other: Any?): Boolean = other === ScaleIndication
override fun hashCode() = 100
}

View File

@@ -1,7 +1,19 @@
package com.github.nullptroma.wallenc.presentation.extensions
import androidx.compose.foundation.Indication
import androidx.compose.foundation.IndicationNodeFactory
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
fun Modifier.ignoreHorizontalParentPadding(horizontal: Dp): Modifier {
@@ -23,3 +35,19 @@ fun Modifier.ignoreVerticalParentPadding(vertical: Dp): Modifier {
}
}
}
fun Modifier.gesturesDisabled(disabled: Boolean = true) =
if (disabled) {
pointerInput(Unit) {
awaitPointerEventScope {
// we should wait for all new pointer events
while (true) {
awaitPointerEvent(pass = PointerEventPass.Initial)
.changes
.forEach(PointerInputChange::consume)
}
}
}
} else {
this
}

View File

@@ -3,6 +3,7 @@ package com.github.nullptroma.wallenc.presentation.screens.main
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
@@ -17,6 +18,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -62,15 +64,14 @@ fun MainScreen(
Scaffold(modifier = modifier, contentWindowInsets = WindowInsets(0.dp), bottomBar = {
Column {
NavigationBar(modifier = Modifier.height(48.dp)) {
NavigationBar(windowInsets = WindowInsets(0), modifier = Modifier.height(48.dp)) {
val navBackStackEntry by navState.navHostController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
topLevelNavBarItems.forEach {
val routeClassName = it.key
val navBarItemData = it.value
NavigationBarItem(modifier = Modifier
.weight(1f)
.fillMaxHeight(),
.weight(1f),
icon = { Text(stringResource(navBarItemData.nameStringResourceId)) },
selected = currentRoute?.startsWith(routeClassName) == true,
onClick = {
@@ -80,7 +81,9 @@ fun MainScreen(
navState.changeTop(
route
)
})
},
label = null
)
}
}
HorizontalDivider()

View File

@@ -1,56 +1,39 @@
package com.github.nullptroma.wallenc.presentation.screens.main.screens.local.vault
import android.widget.ProgressBar
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CardElevation
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.domain.datatypes.Tree
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
import com.github.nullptroma.wallenc.presentation.R
import com.github.nullptroma.wallenc.presentation.elements.StorageTree
import kotlin.random.Random
import com.github.nullptroma.wallenc.presentation.extensions.gesturesDisabled
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LocalVaultScreen(
modifier: Modifier = Modifier,
@@ -59,6 +42,7 @@ fun LocalVaultScreen(
) {
val uiState by viewModel.state.collectAsStateWithLifecycle()
Box {
Scaffold(modifier = modifier, contentWindowInsets = WindowInsets(0.dp), floatingActionButton = {
FloatingActionButton(
onClick = {
@@ -68,10 +52,10 @@ fun LocalVaultScreen(
Icon(Icons.Filled.Add, "Floating action button.")
}
}) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
LazyColumn(modifier = Modifier.padding(innerPadding).gesturesDisabled(uiState.isLoading)) {
items(uiState.storagesList) { listItem ->
StorageTree(
modifier = Modifier.padding(8.dp),
modifier = Modifier.padding(8.dp, 8.dp, 8.dp, 0.dp),
tree = listItem,
onClick = {
openTextEdit(it.value.uuid.toString())
@@ -81,9 +65,27 @@ fun LocalVaultScreen(
},
onRemove = { tree ->
viewModel.remove(tree.value)
},
onEncrypt = { tree ->
viewModel.enableEncryptionAndOpenStorage(tree.value)
}
)
}
item {
Spacer(modifier = Modifier.height(8.dp))
}
}
}
if(uiState.isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.fillMaxSize().alpha(0.6f).background(Color.Black))
CircularProgressIndicator(
modifier = Modifier.width(64.dp),
color = MaterialTheme.colorScheme.secondary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
}
}

View File

@@ -3,4 +3,4 @@ package com.github.nullptroma.wallenc.presentation.screens.main.screens.local.va
import com.github.nullptroma.wallenc.domain.datatypes.Tree
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
data class LocalVaultScreenState(val storagesList: List<Tree<IStorageInfo>>)
data class LocalVaultScreenState(val storagesList: List<Tree<IStorageInfo>>, val isLoading: Boolean)

View File

@@ -1,6 +1,7 @@
package com.github.nullptroma.wallenc.presentation.screens.main.screens.local.vault
import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
import com.github.nullptroma.wallenc.domain.datatypes.Tree
import com.github.nullptroma.wallenc.domain.interfaces.IDirectory
import com.github.nullptroma.wallenc.domain.interfaces.IFile
@@ -8,10 +9,11 @@ import com.github.nullptroma.wallenc.domain.interfaces.ILogger
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
import com.github.nullptroma.wallenc.domain.usecases.GetOpenedStoragesUseCase
import com.github.nullptroma.wallenc.domain.usecases.ManageLocalVaultUseCase
import com.github.nullptroma.wallenc.domain.usecases.ManageStoragesEncryptionUseCase
import com.github.nullptroma.wallenc.domain.usecases.RenameStorageUseCase
import com.github.nullptroma.wallenc.domain.usecases.StorageFileManagementUseCase
import com.github.nullptroma.wallenc.presentation.extensions.toPrintable
import com.github.nullptroma.wallenc.presentation.ViewModelBase
import com.github.nullptroma.wallenc.presentation.extensions.toPrintable
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
@@ -24,17 +26,46 @@ class LocalVaultViewModel @Inject constructor(
private val manageLocalVaultUseCase: ManageLocalVaultUseCase,
private val getOpenedStoragesUseCase: GetOpenedStoragesUseCase,
private val storageFileManagementUseCase: StorageFileManagementUseCase,
private val manageStoragesEncryptionUseCase: ManageStoragesEncryptionUseCase,
private val renameStorageUseCase: RenameStorageUseCase,
private val logger: ILogger
) : ViewModelBase<LocalVaultScreenState>(LocalVaultScreenState(listOf())) {
) : ViewModelBase<LocalVaultScreenState>(LocalVaultScreenState(listOf(), true)) {
private var _taskCount: Int = 0
private var tasksCount
get() = _taskCount
set(value) {
_taskCount = value
updateStateLoading()
}
private var _isLoading: Boolean = false
private var isLoading
get() = _isLoading
set(value) {
_isLoading = value
updateStateLoading()
}
init {
collectFlows()
}
private fun updateStateLoading() {
updateState(state.value.copy(
isLoading = this.isLoading || this.tasksCount > 0
))
}
private fun collectFlows() {
viewModelScope.launch {
manageLocalVaultUseCase.localStorages.combine(getOpenedStoragesUseCase.openedStorages) { local, opened ->
if(local == null || opened == null)
return@combine null
val list = mutableListOf<Tree<IStorageInfo>>()
for (storage in local) {
var tree = Tree(storage)
list.add(tree)
while(opened != null && opened.containsKey(tree.value.uuid)) {
while(opened.containsKey(tree.value.uuid)) {
val child = opened.getValue(tree.value.uuid)
val nextTree = Tree(child)
tree.children = listOf(nextTree)
@@ -43,8 +74,9 @@ class LocalVaultViewModel @Inject constructor(
}
return@combine list
}.collectLatest {
isLoading = it == null
val newState = state.value.copy(
storagesList = it
storagesList = it ?: listOf()
)
updateState(newState)
}
@@ -72,8 +104,29 @@ class LocalVaultViewModel @Inject constructor(
}
fun createStorage() {
tasksCount++
viewModelScope.launch {
manageLocalVaultUseCase.createStorage()
tasksCount--
}
}
private val runningStorages = mutableSetOf<IStorageInfo>()
fun enableEncryptionAndOpenStorage(storage: IStorageInfo) {
if(runningStorages.contains(storage))
return
tasksCount++
runningStorages.add(storage)
val key = EncryptKey("Hello")
viewModelScope.launch {
try {
manageStoragesEncryptionUseCase.enableEncryption(storage, key, false)
manageStoragesEncryptionUseCase.openStorage(storage, key)
}
finally {
runningStorages.remove(storage)
tasksCount--
}
}
}

View File

@@ -0,0 +1,13 @@
package com.github.nullptroma.wallenc.presentation.utils
fun debouncedLambda(debounceMs: Long = 300, action: ()->Unit) : ()->Unit {
var latest: Long = 0
return {
val now = System.currentTimeMillis()
if (now - latest >= debounceMs) {
latest = now
action()
}
}
}