Обновление времени при записи, StorageMetaInfo
This commit is contained in:
@@ -0,0 +1,92 @@
|
|||||||
|
package com.github.nullptroma.wallenc.data.utils
|
||||||
|
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
|
||||||
|
private class CloseHandledOutputStream(
|
||||||
|
private val stream: OutputStream,
|
||||||
|
private val onClose: () -> Unit
|
||||||
|
) : OutputStream() {
|
||||||
|
|
||||||
|
override fun write(b: Int) {
|
||||||
|
stream.write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun write(b: ByteArray) {
|
||||||
|
stream.write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun write(b: ByteArray, off: Int, len: Int) {
|
||||||
|
stream.write(b, off, len)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun flush() {
|
||||||
|
stream.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
try {
|
||||||
|
stream.close()
|
||||||
|
} finally {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CloseHandledInputStream(
|
||||||
|
private val stream: InputStream,
|
||||||
|
private val onClose: () -> Unit
|
||||||
|
) : InputStream() {
|
||||||
|
|
||||||
|
override fun read(): Int {
|
||||||
|
return stream.read()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun read(b: ByteArray): Int {
|
||||||
|
return stream.read(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun read(b: ByteArray, off: Int, len: Int): Int {
|
||||||
|
return stream.read(b, off, len)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun skip(n: Long): Long {
|
||||||
|
return stream.skip(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun available(): Int {
|
||||||
|
return stream.available()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
try {
|
||||||
|
stream.close()
|
||||||
|
} finally {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mark(readlimit: Int) {
|
||||||
|
stream.mark(readlimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun reset() {
|
||||||
|
stream.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun markSupported(): Boolean {
|
||||||
|
return stream.markSupported()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CloseHandledStreamExtension {
|
||||||
|
companion object {
|
||||||
|
fun OutputStream.onClose(callback: ()->Unit): OutputStream {
|
||||||
|
return CloseHandledOutputStream(this, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun InputStream.onClose(callback: ()->Unit): InputStream {
|
||||||
|
return CloseHandledInputStream(this, callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ class UnlockManager(
|
|||||||
private val _openedStorages = MutableStateFlow<Map<UUID, EncryptedStorage>?>(null)
|
private val _openedStorages = MutableStateFlow<Map<UUID, EncryptedStorage>?>(null)
|
||||||
override val openedStorages: StateFlow<Map<UUID, IStorage>?>
|
override val openedStorages: StateFlow<Map<UUID, IStorage>?>
|
||||||
get() = _openedStorages
|
get() = _openedStorages
|
||||||
val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
CoroutineScope(ioDispatcher).launch {
|
CoroutineScope(ioDispatcher).launch {
|
||||||
@@ -51,7 +51,7 @@ class UnlockManager(
|
|||||||
|
|
||||||
private fun createEncryptedStorage(storage: IStorage, key: EncryptKey, uuid: UUID): EncryptedStorage {
|
private fun createEncryptedStorage(storage: IStorage, key: EncryptKey, uuid: UUID): EncryptedStorage {
|
||||||
return EncryptedStorage(
|
return EncryptedStorage(
|
||||||
source = storage,
|
_source = storage,
|
||||||
key = key,
|
key = key,
|
||||||
ioDispatcher = ioDispatcher,
|
ioDispatcher = ioDispatcher,
|
||||||
uuid = uuid
|
uuid = uuid
|
||||||
@@ -63,13 +63,10 @@ class UnlockManager(
|
|||||||
key: EncryptKey
|
key: EncryptKey
|
||||||
) = withContext(ioDispatcher) {
|
) = withContext(ioDispatcher) {
|
||||||
mutex.lock()
|
mutex.lock()
|
||||||
val encInfo = storage.encInfo.value ?: throw Exception("EncInfo is null") // TODO
|
val encInfo = storage.metaInfo.value.encInfo ?: throw Exception("EncInfo is null") // TODO
|
||||||
if (!Encryptor.checkKey(key, encInfo))
|
if (!Encryptor.checkKey(key, encInfo))
|
||||||
throw Exception("Incorrect Key")
|
throw Exception("Incorrect Key")
|
||||||
|
|
||||||
if (_openedStorages.value == null) {
|
|
||||||
val childScope = CoroutineScope(ioDispatcher)
|
|
||||||
}
|
|
||||||
val opened = _openedStorages.first { it != null }!!.toMutableMap()
|
val opened = _openedStorages.first { it != null }!!.toMutableMap()
|
||||||
val cur = opened[storage.uuid]
|
val cur = opened[storage.uuid]
|
||||||
if (cur != null)
|
if (cur != null)
|
||||||
|
|||||||
@@ -1,35 +1,46 @@
|
|||||||
package com.github.nullptroma.wallenc.data.vaults.local
|
package com.github.nullptroma.wallenc.data.vaults.local
|
||||||
|
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.github.nullptroma.wallenc.domain.common.impl.CommonStorageMetaInfo
|
||||||
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
|
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
|
||||||
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.InputStream
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
|
||||||
class LocalStorage(
|
class LocalStorage(
|
||||||
override val uuid: UUID,
|
override val uuid: UUID,
|
||||||
absolutePath: String,
|
absolutePath: String,
|
||||||
ioDispatcher: CoroutineDispatcher,
|
private val ioDispatcher: CoroutineDispatcher,
|
||||||
) : IStorage {
|
) : IStorage {
|
||||||
override val size: StateFlow<Long?>
|
override val size: StateFlow<Long?>
|
||||||
get() = accessor.size
|
get() = accessor.size
|
||||||
override val numberOfFiles: StateFlow<Int?>
|
override val numberOfFiles: StateFlow<Int?>
|
||||||
get() = accessor.numberOfFiles
|
get() = accessor.numberOfFiles
|
||||||
|
|
||||||
|
private val _metaInfo = MutableStateFlow(
|
||||||
|
CommonStorageMetaInfo(
|
||||||
|
encInfo = StorageEncryptionInfo(
|
||||||
|
isEncrypted = false,
|
||||||
|
encryptedTestData = null
|
||||||
|
),
|
||||||
|
name = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
override val metaInfo: StateFlow<IStorageMetaInfo>
|
||||||
|
get() = _metaInfo
|
||||||
|
|
||||||
override val isAvailable: StateFlow<Boolean>
|
override val isAvailable: StateFlow<Boolean>
|
||||||
get() = accessor.isAvailable
|
get() = accessor.isAvailable
|
||||||
private val _accessor = LocalStorageAccessor(absolutePath, ioDispatcher)
|
private val _accessor = LocalStorageAccessor(absolutePath, ioDispatcher)
|
||||||
override val accessor: IStorageAccessor = _accessor
|
override val accessor: IStorageAccessor = _accessor
|
||||||
|
|
||||||
private val _encInfo = MutableStateFlow<StorageEncryptionInfo?>(null)
|
|
||||||
override val encInfo: StateFlow<StorageEncryptionInfo?>
|
|
||||||
get() = _encInfo
|
|
||||||
override val name: StateFlow<String>
|
|
||||||
get() = TODO("Добавить класс в Domain, который с помощью accessor будет читать и сохранять имя в скрытую папку")
|
|
||||||
|
|
||||||
private val encInfoFileName: String = "$uuid$ENC_INFO_FILE_POSTFIX"
|
private val encInfoFileName: String = "$uuid$ENC_INFO_FILE_POSTFIX"
|
||||||
|
|
||||||
suspend fun init() {
|
suspend fun init() {
|
||||||
@@ -37,15 +48,14 @@ class LocalStorage(
|
|||||||
readEncInfo()
|
readEncInfo()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun readEncInfo() {
|
private suspend fun readEncInfo() = withContext(ioDispatcher) {
|
||||||
val reader = _accessor.openReadSystemFile(encInfoFileName)
|
|
||||||
var enc: StorageEncryptionInfo? = null
|
var enc: StorageEncryptionInfo? = null
|
||||||
|
var reader: InputStream? = null
|
||||||
try {
|
try {
|
||||||
enc = _jackson.readValue(reader, StorageEncryptionInfo::class.java)
|
reader = _accessor.openReadSystemFile(encInfoFileName)
|
||||||
reader.close()
|
enc = jackson.readValue(reader, StorageEncryptionInfo::class.java)
|
||||||
}
|
}
|
||||||
catch(e: Exception) {
|
catch(e: Exception) {
|
||||||
reader.close()
|
|
||||||
// чтение не удалось, значит нужно записать файл
|
// чтение не удалось, значит нужно записать файл
|
||||||
enc = StorageEncryptionInfo(
|
enc = StorageEncryptionInfo(
|
||||||
isEncrypted = false,
|
isEncrypted = false,
|
||||||
@@ -53,19 +63,22 @@ class LocalStorage(
|
|||||||
)
|
)
|
||||||
setEncInfo(enc)
|
setEncInfo(enc)
|
||||||
}
|
}
|
||||||
_encInfo.value = enc
|
finally {
|
||||||
|
reader?.close()
|
||||||
|
}
|
||||||
|
_metaInfo.value = _metaInfo.value.copy(encInfo = enc)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setEncInfo(enc: StorageEncryptionInfo) {
|
suspend fun setEncInfo(enc: StorageEncryptionInfo) = withContext(ioDispatcher) {
|
||||||
val writer = _accessor.openWriteSystemFile(encInfoFileName)
|
val writer = _accessor.openWriteSystemFile(encInfoFileName)
|
||||||
try {
|
try {
|
||||||
_jackson.writeValue(writer, enc)
|
jackson.writeValue(writer, enc)
|
||||||
}
|
}
|
||||||
catch (e: Exception) {
|
catch (e: Exception) {
|
||||||
TODO("Это никогда не должно произойти")
|
TODO("Это никогда не должно произойти")
|
||||||
}
|
}
|
||||||
writer.close()
|
writer.close()
|
||||||
_encInfo.value = enc
|
_metaInfo.value = _metaInfo.value.copy(encInfo = enc)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun rename(newName: String) {
|
override suspend fun rename(newName: String) {
|
||||||
@@ -74,6 +87,6 @@ class LocalStorage(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val ENC_INFO_FILE_POSTFIX = ".enc-info"
|
const val ENC_INFO_FILE_POSTFIX = ".enc-info"
|
||||||
private val _jackson = jacksonObjectMapper().apply { findAndRegisterModules() }
|
private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ package com.github.nullptroma.wallenc.data.vaults.local
|
|||||||
import com.fasterxml.jackson.core.JacksonException
|
import com.fasterxml.jackson.core.JacksonException
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
|
import com.github.nullptroma.wallenc.data.utils.CloseHandledStreamExtension.Companion.onClose
|
||||||
import com.github.nullptroma.wallenc.domain.common.impl.CommonDirectory
|
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.CommonFile
|
||||||
import com.github.nullptroma.wallenc.domain.common.impl.CommonMetaInfo
|
import com.github.nullptroma.wallenc.domain.common.impl.CommonMetaInfo
|
||||||
@@ -13,6 +14,7 @@ import com.github.nullptroma.wallenc.domain.interfaces.IFile
|
|||||||
import com.github.nullptroma.wallenc.domain.interfaces.IMetaInfo
|
import com.github.nullptroma.wallenc.domain.interfaces.IMetaInfo
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -20,6 +22,7 @@ import kotlinx.coroutines.flow.SharedFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
@@ -115,7 +118,7 @@ class LocalStorageAccessor(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val _jackson = jacksonObjectMapper().apply { findAndRegisterModules() }
|
private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() }
|
||||||
|
|
||||||
fun fromFile(filesystemBasePath: Path, file: File): LocalStorageFilePair? {
|
fun fromFile(filesystemBasePath: Path, file: File): LocalStorageFilePair? {
|
||||||
if (!file.exists())
|
if (!file.exists())
|
||||||
@@ -140,19 +143,19 @@ class LocalStorageAccessor(
|
|||||||
size = filePath.fileSize(),
|
size = filePath.fileSize(),
|
||||||
path = storageFilePath
|
path = storageFilePath
|
||||||
)
|
)
|
||||||
_jackson.writeValue(metaFile, metaInfo)
|
jackson.writeValue(metaFile, metaInfo)
|
||||||
} else {
|
} else {
|
||||||
var readMeta: CommonMetaInfo
|
var readMeta: CommonMetaInfo
|
||||||
try {
|
try {
|
||||||
val reader = metaFile.bufferedReader()
|
val reader = metaFile.bufferedReader()
|
||||||
readMeta = _jackson.readValue(reader)
|
readMeta = jackson.readValue(reader)
|
||||||
} catch (e: JacksonException) {
|
} catch (e: JacksonException) {
|
||||||
// если файл повреждён - пересоздать
|
// если файл повреждён - пересоздать
|
||||||
readMeta = CommonMetaInfo(
|
readMeta = CommonMetaInfo(
|
||||||
size = filePath.fileSize(),
|
size = filePath.fileSize(),
|
||||||
path = storageFilePath
|
path = storageFilePath
|
||||||
)
|
)
|
||||||
_jackson.writeValue(metaFile, readMeta)
|
jackson.writeValue(metaFile, readMeta)
|
||||||
}
|
}
|
||||||
metaInfo = readMeta
|
metaInfo = readMeta
|
||||||
}
|
}
|
||||||
@@ -171,7 +174,7 @@ class LocalStorageAccessor(
|
|||||||
var pair: LocalStorageFilePair? = null
|
var pair: LocalStorageFilePair? = null
|
||||||
try {
|
try {
|
||||||
val reader = metaFile.bufferedReader()
|
val reader = metaFile.bufferedReader()
|
||||||
val metaInfo: CommonMetaInfo = _jackson.readValue(reader)
|
val metaInfo: CommonMetaInfo = jackson.readValue(reader)
|
||||||
val pathString = Path(filesystemBasePath.pathString, metaInfo.path).pathString
|
val pathString = Path(filesystemBasePath.pathString, metaInfo.path).pathString
|
||||||
val file = File(pathString)
|
val file = File(pathString)
|
||||||
if (!file.exists()) {
|
if (!file.exists()) {
|
||||||
@@ -258,8 +261,8 @@ class LocalStorageAccessor(
|
|||||||
var size = 0L
|
var size = 0L
|
||||||
var numOfFiles = 0
|
var numOfFiles = 0
|
||||||
|
|
||||||
scanStorage(baseStoragePath = "/", maxDepth = -1, fileCallback = { _, CommonFile ->
|
scanStorage(baseStoragePath = "/", maxDepth = -1, fileCallback = { _, commonFile ->
|
||||||
size += CommonFile.metaInfo.size
|
size += commonFile.metaInfo.size
|
||||||
numOfFiles++
|
numOfFiles++
|
||||||
|
|
||||||
if (numOfFiles % DATA_PAGE_LENGTH == 0) {
|
if (numOfFiles % DATA_PAGE_LENGTH == 0) {
|
||||||
@@ -408,9 +411,11 @@ class LocalStorageAccessor(
|
|||||||
writeMeta(pair.metaFile, newMeta)
|
writeMeta(pair.metaFile, newMeta)
|
||||||
_filesUpdates.emit(
|
_filesUpdates.emit(
|
||||||
DataPage(
|
DataPage(
|
||||||
list = listOf(CommonFile(
|
list = listOf(
|
||||||
|
CommonFile(
|
||||||
metaInfo = newMeta
|
metaInfo = newMeta
|
||||||
)),
|
)
|
||||||
|
),
|
||||||
pageLength = 1,
|
pageLength = 1,
|
||||||
pageIndex = 0
|
pageIndex = 0
|
||||||
)
|
)
|
||||||
@@ -419,31 +424,41 @@ class LocalStorageAccessor(
|
|||||||
|
|
||||||
|
|
||||||
private fun writeMeta(metaFile: File, meta: IMetaInfo) {
|
private fun writeMeta(metaFile: File, meta: IMetaInfo) {
|
||||||
_jackson.writeValue(metaFile, meta)
|
jackson.writeValue(metaFile, meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createFile(storagePath: String): CommonFile {
|
private suspend fun createFile(storagePath: String): CommonFile = withContext(ioDispatcher) {
|
||||||
val path = Path(_filesystemBasePath.pathString, storagePath)
|
val path = Path(_filesystemBasePath.pathString, storagePath)
|
||||||
val file = path.toFile()
|
val file = path.toFile()
|
||||||
if (file.exists() && file.isDirectory) {
|
if (file.exists() && file.isDirectory) {
|
||||||
throw Exception("Что то пошло не так") // TODO
|
throw Exception("Что то пошло не так") // TODO
|
||||||
} else {
|
} else if(!file.exists()) {
|
||||||
file.createNewFile()
|
file.createNewFile()
|
||||||
|
|
||||||
|
val cur = _numberOfFiles.value
|
||||||
|
_numberOfFiles.value = if (cur == null) null else cur + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
val pair = LocalStorageFilePair.from(_filesystemBasePath, file)
|
val pair = LocalStorageFilePair.from(_filesystemBasePath, file)
|
||||||
?: throw Exception("Что то пошло не так") // TODO
|
?: throw Exception("Что то пошло не так") // TODO
|
||||||
val newMeta = pair.meta.copy(lastModified = Clock.systemUTC().instant())
|
val newMeta = pair.meta.copy(lastModified = Clock.systemUTC().instant())
|
||||||
writeMeta(pair.metaFile, newMeta)
|
writeMeta(pair.metaFile, newMeta)
|
||||||
return CommonFile(newMeta)
|
_filesUpdates.emit(
|
||||||
|
DataPage(
|
||||||
|
list = listOf(CommonFile(pair.meta)),
|
||||||
|
pageLength = 1,
|
||||||
|
pageIndex = 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return@withContext CommonFile(newMeta)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createDir(storagePath: String): CommonDirectory {
|
private suspend fun createDir(storagePath: String): CommonDirectory = withContext(ioDispatcher) {
|
||||||
val path = Path(_filesystemBasePath.pathString, storagePath)
|
val path = Path(_filesystemBasePath.pathString, storagePath)
|
||||||
val file = path.toFile()
|
val file = path.toFile()
|
||||||
if (file.exists() && !file.isDirectory) {
|
if (file.exists() && !file.isDirectory) {
|
||||||
throw Exception("Что то пошло не так") // TODO
|
throw Exception("Что то пошло не так") // TODO
|
||||||
} else {
|
} else if(!file.exists()) {
|
||||||
Files.createDirectories(path)
|
Files.createDirectories(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,11 +466,25 @@ class LocalStorageAccessor(
|
|||||||
?: throw Exception("Что то пошло не так") // TODO
|
?: throw Exception("Что то пошло не так") // TODO
|
||||||
val newMeta = pair.meta.copy(lastModified = Clock.systemUTC().instant())
|
val newMeta = pair.meta.copy(lastModified = Clock.systemUTC().instant())
|
||||||
writeMeta(pair.metaFile, newMeta)
|
writeMeta(pair.metaFile, newMeta)
|
||||||
return CommonDirectory(newMeta, 0)
|
_dirsUpdates.emit(
|
||||||
|
DataPage(
|
||||||
|
list = listOf(CommonDirectory(pair.meta, null)),
|
||||||
|
pageLength = 1,
|
||||||
|
pageIndex = 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return@withContext CommonDirectory(newMeta, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun touchFile(path: String): Unit = withContext(ioDispatcher) {
|
override suspend fun touchFile(path: String): Unit = withContext(ioDispatcher) {
|
||||||
createFile(path)
|
createFile(path)
|
||||||
|
|
||||||
|
// перебор все каталогов и обновление их времени модификации
|
||||||
|
var parent = Path(path).parent
|
||||||
|
while(parent != null) {
|
||||||
|
touchDir(parent.pathString)
|
||||||
|
parent = parent.parent
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun touchDir(path: String): Unit = withContext(ioDispatcher) {
|
override suspend fun touchDir(path: String): Unit = withContext(ioDispatcher) {
|
||||||
@@ -474,7 +503,11 @@ class LocalStorageAccessor(
|
|||||||
touchFile(path)
|
touchFile(path)
|
||||||
val pair = LocalStorageFilePair.from(_filesystemBasePath, path)
|
val pair = LocalStorageFilePair.from(_filesystemBasePath, path)
|
||||||
?: throw Exception("Файла нет") // TODO
|
?: throw Exception("Файла нет") // TODO
|
||||||
return@withContext pair.file.outputStream()
|
return@withContext pair.file.outputStream().onClose {
|
||||||
|
CoroutineScope(ioDispatcher).launch {
|
||||||
|
touchFile(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun openRead(path: String): InputStream = withContext(ioDispatcher) {
|
override suspend fun openRead(path: String): InputStream = withContext(ioDispatcher) {
|
||||||
@@ -519,6 +552,6 @@ class LocalStorageAccessor(
|
|||||||
private const val SYSTEM_HIDDEN_DIRNAME = "wallenc-local-storage-meta-dir"
|
private const val SYSTEM_HIDDEN_DIRNAME = "wallenc-local-storage-meta-dir"
|
||||||
private const val META_INFO_POSTFIX = ".wallenc-meta"
|
private const val META_INFO_POSTFIX = ".wallenc-meta"
|
||||||
private const val DATA_PAGE_LENGTH = 10
|
private const val DATA_PAGE_LENGTH = 10
|
||||||
private val _jackson = jacksonObjectMapper().apply { findAndRegisterModules() }
|
private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,18 +34,18 @@ class LocalVault(private val ioDispatcher: CoroutineDispatcher, context: Context
|
|||||||
private val _availableSpace = MutableStateFlow(null)
|
private val _availableSpace = MutableStateFlow(null)
|
||||||
override val availableSpace: StateFlow<Int?> = _availableSpace
|
override val availableSpace: StateFlow<Int?> = _availableSpace
|
||||||
|
|
||||||
private val _path = MutableStateFlow<File?>(null)
|
private val path = MutableStateFlow<File?>(null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
CoroutineScope(ioDispatcher).launch {
|
CoroutineScope(ioDispatcher).launch {
|
||||||
_path.value = context.getExternalFilesDir("LocalVault")
|
path.value = context.getExternalFilesDir("LocalVault")
|
||||||
_isAvailable.value = _path.value != null
|
_isAvailable.value = path.value != null
|
||||||
readStorages()
|
readStorages()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun readStorages() {
|
private suspend fun readStorages() {
|
||||||
val path = _path.value
|
val path = path.value
|
||||||
if (path == null || !_isAvailable.value)
|
if (path == null || !_isAvailable.value)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ class LocalVault(private val ioDispatcher: CoroutineDispatcher, context: Context
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createStorage(): LocalStorage = withContext(ioDispatcher) {
|
override suspend fun createStorage(): LocalStorage = withContext(ioDispatcher) {
|
||||||
val path = _path.value
|
val path = path.value
|
||||||
if (path == null || !_isAvailable.value)
|
if (path == null || !_isAvailable.value)
|
||||||
throw Exception("Not available")
|
throw Exception("Not available")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.github.nullptroma.wallenc.domain.common.impl
|
||||||
|
|
||||||
|
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
|
||||||
|
import com.github.nullptroma.wallenc.domain.interfaces.IMetaInfo
|
||||||
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
|
||||||
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
|
||||||
|
import java.time.Clock
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
|
||||||
|
data class CommonStorageMetaInfo(
|
||||||
|
override val encInfo: StorageEncryptionInfo?,
|
||||||
|
override val name: String?,
|
||||||
|
override val lastModified: Instant = Clock.systemUTC().instant()
|
||||||
|
) : IStorageMetaInfo
|
||||||
@@ -2,9 +2,9 @@ package com.github.nullptroma.wallenc.domain.datatypes
|
|||||||
|
|
||||||
class DataPage<T>(
|
class DataPage<T>(
|
||||||
list: List<T>,
|
list: List<T>,
|
||||||
isLoading: Boolean? = false,
|
isLoading: Boolean? = null,
|
||||||
isError: Boolean? = false,
|
isError: Boolean? = null,
|
||||||
val hasNext: Boolean? = false,
|
val hasNext: Boolean? = null,
|
||||||
val pageLength: Int,
|
val pageLength: Int,
|
||||||
val pageIndex: Int
|
val pageIndex: Int
|
||||||
) : DataPackage<List<T>>(data = list, isLoading = isLoading, isError = isError)
|
) : DataPackage<List<T>>(data = list, isLoading = isLoading, isError = isError)
|
||||||
@@ -1,38 +1,46 @@
|
|||||||
package com.github.nullptroma.wallenc.domain.encrypt
|
package com.github.nullptroma.wallenc.domain.encrypt
|
||||||
|
|
||||||
|
import com.github.nullptroma.wallenc.domain.common.impl.CommonStorageMetaInfo
|
||||||
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
|
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
|
||||||
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
|
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.ILogger
|
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
|
||||||
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.DisposableHandle
|
import kotlinx.coroutines.DisposableHandle
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
class EncryptedStorage(
|
class EncryptedStorage(
|
||||||
private val source: IStorage,
|
private val _source: IStorage,
|
||||||
key: EncryptKey,
|
key: EncryptKey,
|
||||||
ioDispatcher: CoroutineDispatcher,
|
private val ioDispatcher: CoroutineDispatcher,
|
||||||
override val uuid: UUID = UUID.randomUUID()
|
override val uuid: UUID = UUID.randomUUID()
|
||||||
) : IStorage, DisposableHandle {
|
) : IStorage, DisposableHandle {
|
||||||
override val size: StateFlow<Long?>
|
override val size: StateFlow<Long?>
|
||||||
get() = source.size
|
get() = _source.size
|
||||||
override val numberOfFiles: StateFlow<Int?>
|
override val numberOfFiles: StateFlow<Int?>
|
||||||
get() = source.numberOfFiles
|
get() = _source.numberOfFiles
|
||||||
override val name: StateFlow<String>
|
|
||||||
get() = TODO("Not yet implemented")
|
private val _metaInfo = MutableStateFlow<IStorageMetaInfo>(
|
||||||
override val isAvailable: StateFlow<Boolean>
|
CommonStorageMetaInfo(
|
||||||
get() = source.isAvailable
|
encInfo = StorageEncryptionInfo(
|
||||||
override val encInfo: StateFlow<StorageEncryptionInfo?>
|
|
||||||
get() = MutableStateFlow(
|
|
||||||
StorageEncryptionInfo(
|
|
||||||
isEncrypted = false,
|
isEncrypted = false,
|
||||||
encryptedTestData = null
|
encryptedTestData = null
|
||||||
|
),
|
||||||
|
name = null
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
override val metaInfo: StateFlow<IStorageMetaInfo>
|
||||||
|
get() = _metaInfo
|
||||||
|
|
||||||
|
override val isAvailable: StateFlow<Boolean>
|
||||||
|
get() = _source.isAvailable
|
||||||
override val accessor: EncryptedStorageAccessor =
|
override val accessor: EncryptedStorageAccessor =
|
||||||
EncryptedStorageAccessor(source.accessor, key, ioDispatcher)
|
EncryptedStorageAccessor(_source.accessor, key, ioDispatcher)
|
||||||
|
|
||||||
override suspend fun rename(newName: String) {
|
override suspend fun rename(newName: String) {
|
||||||
TODO("Not yet implemented")
|
TODO("Not yet implemented")
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import com.github.nullptroma.wallenc.domain.datatypes.DataPackage
|
|||||||
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
|
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IDirectory
|
import com.github.nullptroma.wallenc.domain.interfaces.IDirectory
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IFile
|
import com.github.nullptroma.wallenc.domain.interfaces.IFile
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.ILogger
|
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IMetaInfo
|
import com.github.nullptroma.wallenc.domain.interfaces.IMetaInfo
|
||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -30,8 +29,8 @@ class EncryptedStorageAccessor(
|
|||||||
key: EncryptKey,
|
key: EncryptKey,
|
||||||
ioDispatcher: CoroutineDispatcher
|
ioDispatcher: CoroutineDispatcher
|
||||||
) : IStorageAccessor, DisposableHandle {
|
) : IStorageAccessor, DisposableHandle {
|
||||||
private val _job = Job()
|
private val job = Job()
|
||||||
private val _scope = CoroutineScope(ioDispatcher + _job)
|
private val scope = CoroutineScope(ioDispatcher + job)
|
||||||
|
|
||||||
override val size: StateFlow<Long?> = source.size
|
override val size: StateFlow<Long?> = source.size
|
||||||
override val numberOfFiles: StateFlow<Int?> = source.numberOfFiles
|
override val numberOfFiles: StateFlow<Int?> = source.numberOfFiles
|
||||||
@@ -43,14 +42,14 @@ class EncryptedStorageAccessor(
|
|||||||
private val _dirsUpdates = MutableSharedFlow<DataPackage<List<IDirectory>>>()
|
private val _dirsUpdates = MutableSharedFlow<DataPackage<List<IDirectory>>>()
|
||||||
override val dirsUpdates: SharedFlow<DataPackage<List<IDirectory>>> = _dirsUpdates
|
override val dirsUpdates: SharedFlow<DataPackage<List<IDirectory>>> = _dirsUpdates
|
||||||
|
|
||||||
private val _encryptor = Encryptor(key.toAesKey())
|
private val encryptor = Encryptor(key.toAesKey())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
collectSourceState()
|
collectSourceState()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun collectSourceState() {
|
private fun collectSourceState() {
|
||||||
_scope.launch {
|
scope.launch {
|
||||||
launch {
|
launch {
|
||||||
source.filesUpdates.collect {
|
source.filesUpdates.collect {
|
||||||
val files = it.data.map(::decryptEntity)
|
val files = it.data.map(::decryptEntity)
|
||||||
@@ -116,7 +115,7 @@ class EncryptedStorageAccessor(
|
|||||||
val path = Path(pathStr)
|
val path = Path(pathStr)
|
||||||
val segments = mutableListOf<String>()
|
val segments = mutableListOf<String>()
|
||||||
for (segment in path)
|
for (segment in path)
|
||||||
segments.add(_encryptor.encryptString(segment.pathString))
|
segments.add(encryptor.encryptString(segment.pathString))
|
||||||
val res = Path("/",*(segments.toTypedArray()))
|
val res = Path("/",*(segments.toTypedArray()))
|
||||||
return res.pathString
|
return res.pathString
|
||||||
}
|
}
|
||||||
@@ -125,7 +124,7 @@ class EncryptedStorageAccessor(
|
|||||||
val path = Path(pathStr)
|
val path = Path(pathStr)
|
||||||
val segments = mutableListOf<String>()
|
val segments = mutableListOf<String>()
|
||||||
for (segment in path)
|
for (segment in path)
|
||||||
segments.add(_encryptor.decryptString(segment.pathString))
|
segments.add(encryptor.decryptString(segment.pathString))
|
||||||
val res = Path("/",*(segments.toTypedArray()))
|
val res = Path("/",*(segments.toTypedArray()))
|
||||||
return res.pathString
|
return res.pathString
|
||||||
}
|
}
|
||||||
@@ -198,12 +197,12 @@ class EncryptedStorageAccessor(
|
|||||||
|
|
||||||
override suspend fun openWrite(path: String): OutputStream {
|
override suspend fun openWrite(path: String): OutputStream {
|
||||||
val stream = source.openWrite(encryptPath(path))
|
val stream = source.openWrite(encryptPath(path))
|
||||||
return _encryptor.encryptStream(stream)
|
return encryptor.encryptStream(stream)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun openRead(path: String): InputStream {
|
override suspend fun openRead(path: String): InputStream {
|
||||||
val stream = source.openRead(encryptPath(path))
|
val stream = source.openRead(encryptPath(path))
|
||||||
return _encryptor.decryptStream(stream)
|
return encryptor.decryptStream(stream)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun moveToTrash(path: String) {
|
override suspend fun moveToTrash(path: String) {
|
||||||
@@ -211,7 +210,8 @@ class EncryptedStorageAccessor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun dispose() {
|
override fun dispose() {
|
||||||
_job.cancel()
|
job.cancel()
|
||||||
_encryptor.dispose()
|
encryptor.dispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.github.nullptroma.wallenc.domain.interfaces
|
package com.github.nullptroma.wallenc.domain.interfaces
|
||||||
|
|
||||||
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -9,6 +8,5 @@ sealed interface IStorageInfo {
|
|||||||
val isAvailable: StateFlow<Boolean>
|
val isAvailable: StateFlow<Boolean>
|
||||||
val size: StateFlow<Long?>
|
val size: StateFlow<Long?>
|
||||||
val numberOfFiles: StateFlow<Int?>
|
val numberOfFiles: StateFlow<Int?>
|
||||||
val encInfo: StateFlow<StorageEncryptionInfo?>
|
val metaInfo: StateFlow<IStorageMetaInfo>
|
||||||
val name: StateFlow<String?>
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.github.nullptroma.wallenc.domain.interfaces
|
||||||
|
|
||||||
|
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import java.time.Clock
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
interface IStorageMetaInfo {
|
||||||
|
val encInfo: StorageEncryptionInfo?
|
||||||
|
val name: String?
|
||||||
|
val lastModified: Instant
|
||||||
|
|
||||||
|
}
|
||||||
@@ -3,5 +3,5 @@ package com.github.nullptroma.wallenc.presentation.extensions
|
|||||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
|
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
|
||||||
|
|
||||||
fun IStorageInfo.toPrintable(): String {
|
fun IStorageInfo.toPrintable(): String {
|
||||||
return "{ uuid: $uuid, enc: ${encInfo.value} }"
|
return "{ uuid: $uuid, enc: ${metaInfo.value.encInfo} }"
|
||||||
}
|
}
|
||||||
@@ -48,7 +48,9 @@ fun LocalVaultScreen(modifier: Modifier = Modifier,
|
|||||||
val available by it.isAvailable.collectAsStateWithLifecycle()
|
val available by it.isAvailable.collectAsStateWithLifecycle()
|
||||||
val numOfFiles by it.numberOfFiles.collectAsStateWithLifecycle()
|
val numOfFiles by it.numberOfFiles.collectAsStateWithLifecycle()
|
||||||
val size by it.size.collectAsStateWithLifecycle()
|
val size by it.size.collectAsStateWithLifecycle()
|
||||||
val enc by it.encInfo.collectAsStateWithLifecycle()
|
val metaInfo by it.metaInfo.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val enc = metaInfo.encInfo
|
||||||
Column {
|
Column {
|
||||||
Text(it.uuid.toString())
|
Text(it.uuid.toString())
|
||||||
Text("IsAvailable: $available")
|
Text("IsAvailable: $available")
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import com.github.nullptroma.wallenc.domain.usecases.StorageFileManagementUseCas
|
|||||||
import com.github.nullptroma.wallenc.presentation.extensions.toPrintable
|
import com.github.nullptroma.wallenc.presentation.extensions.toPrintable
|
||||||
import com.github.nullptroma.wallenc.presentation.viewmodel.ViewModelBase
|
import com.github.nullptroma.wallenc.presentation.viewmodel.ViewModelBase
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.collect
|
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -21,15 +20,15 @@ import kotlin.system.measureTimeMillis
|
|||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class LocalVaultViewModel @Inject constructor(
|
class LocalVaultViewModel @Inject constructor(
|
||||||
private val _manageLocalVaultUseCase: ManageLocalVaultUseCase,
|
private val manageLocalVaultUseCase: ManageLocalVaultUseCase,
|
||||||
private val _getOpenedStoragesUseCase: GetOpenedStoragesUseCase,
|
private val getOpenedStoragesUseCase: GetOpenedStoragesUseCase,
|
||||||
private val _storageFileManagementUseCase: StorageFileManagementUseCase,
|
private val storageFileManagementUseCase: StorageFileManagementUseCase,
|
||||||
private val logger: ILogger
|
private val logger: ILogger
|
||||||
) :
|
) :
|
||||||
ViewModelBase<LocalVaultScreenState>(LocalVaultScreenState(listOf())) {
|
ViewModelBase<LocalVaultScreenState>(LocalVaultScreenState(listOf())) {
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_manageLocalVaultUseCase.localStorages.combine(_getOpenedStoragesUseCase.openedStorages) { local, opened ->
|
manageLocalVaultUseCase.localStorages.combine(getOpenedStoragesUseCase.openedStorages) { local, opened ->
|
||||||
local + (opened?.map { it.value } ?: listOf())
|
local + (opened?.map { it.value } ?: listOf())
|
||||||
}.collectLatest {
|
}.collectLatest {
|
||||||
val newState = state.value.copy(
|
val newState = state.value.copy(
|
||||||
@@ -41,13 +40,13 @@ class LocalVaultViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun printStorageInfoToLog(storage: IStorageInfo) {
|
fun printStorageInfoToLog(storage: IStorageInfo) {
|
||||||
_storageFileManagementUseCase.setStorage(storage)
|
storageFileManagementUseCase.setStorage(storage)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val files: List<IFile>
|
val files: List<IFile>
|
||||||
val dirs: List<IDirectory>
|
val dirs: List<IDirectory>
|
||||||
val time = measureTimeMillis {
|
val time = measureTimeMillis {
|
||||||
files = _storageFileManagementUseCase.getAllFiles()
|
files = storageFileManagementUseCase.getAllFiles()
|
||||||
dirs = _storageFileManagementUseCase.getAllDirs()
|
dirs = storageFileManagementUseCase.getAllDirs()
|
||||||
}
|
}
|
||||||
for (file in files) {
|
for (file in files) {
|
||||||
logger.debug("Files", file.metaInfo.toString())
|
logger.debug("Files", file.metaInfo.toString())
|
||||||
@@ -62,7 +61,7 @@ class LocalVaultViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun createStorage() {
|
fun createStorage() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_manageLocalVaultUseCase.createStorage(EncryptKey("hello"))
|
manageLocalVaultUseCase.createStorage(EncryptKey("hello"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user