Исправлено множество предупреждений

This commit is contained in:
2026-05-19 01:42:22 +03:00
parent eecaf44b72
commit ffdab4563d
64 changed files with 241 additions and 567 deletions

View File

@@ -35,7 +35,7 @@ class YandexDiskLiveIntegrationTest {
} }
@After @After
fun tearDown() = runBlocking { fun tearDown(): Unit = runBlocking {
runCatching { repository.delete(probePath, permanently = true) } runCatching { repository.delete(probePath, permanently = true) }
} }

View File

@@ -7,12 +7,6 @@ object YandexTestCredentials {
fun oauthToken(): String? = fun oauthToken(): String? =
InstrumentationRegistry.getArguments().getString("yandex.oauth.token")?.takeIf { it.isNotBlank() } InstrumentationRegistry.getArguments().getString("yandex.oauth.token")?.takeIf { it.isNotBlank() }
fun userId(): String? =
InstrumentationRegistry.getArguments().getString("yandex.user.id")?.takeIf { it.isNotBlank() }
fun vaultUuid(): String? =
InstrumentationRegistry.getArguments().getString("yandex.vault.uuid")?.takeIf { it.isNotBlank() }
fun assumePresent(message: String = "Добавьте yandex.test.oauth.token в local.properties") { fun assumePresent(message: String = "Добавьте yandex.test.oauth.token в local.properties") {
assumeFalse(message, oauthToken().isNullOrBlank()) assumeFalse(message, oauthToken().isNullOrBlank())
} }

View File

@@ -70,7 +70,8 @@
<service <service
android:name=".tasks.TaskPipelineForegroundService" android:name=".tasks.TaskPipelineForegroundService"
android:exported="false" android:exported="false"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync"
android:description="@string/fgs_task_pipeline_description" />
</application> </application>
</manifest> </manifest>

View File

@@ -16,11 +16,19 @@ object UiStringModule {
@Provides @Provides
@Singleton @Singleton
fun provideUiStringResolver(@ApplicationContext context: Context): UiStringResolver = fun provideUiStringResolver(@ApplicationContext context: Context): UiStringResolver =
UiStringResolver { id, args -> object : UiStringResolver {
if (args.isEmpty()) { override fun invoke(id: Int, vararg formatArgs: Any): String =
context.getString(id) if (formatArgs.isEmpty()) {
} else { context.getString(id)
context.getString(id, *args) } else {
} context.getString(id, *formatArgs)
}
override fun plurals(id: Int, quantity: Int, vararg formatArgs: Any): String =
if (formatArgs.isEmpty()) {
context.resources.getQuantityString(id, quantity)
} else {
context.resources.getQuantityString(id, quantity, *formatArgs)
}
} }
} }

View File

@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import androidx.core.content.edit
/** /**
* Хранение выбранного языка. SharedPreferences — для синхронного чтения в * Хранение выбранного языка. SharedPreferences — для синхронного чтения в
@@ -50,7 +51,7 @@ internal object AppLocaleStorage {
} }
fun persistLanguage(context: Context, language: AppLanguage) { fun persistLanguage(context: Context, language: AppLanguage) {
prefs(context).edit().putString(PREFS_KEY_LANGUAGE, language.storageValue).apply() prefs(context).edit { putString(PREFS_KEY_LANGUAGE, language.storageValue) }
applyLanguage(language) applyLanguage(language)
} }
@@ -83,7 +84,7 @@ internal object AppLocaleStorage {
runCatching { runCatching {
val legacy = storage.legacyLocaleDataStore.data.first()[legacyLanguageKey] val legacy = storage.legacyLocaleDataStore.data.first()[legacyLanguageKey]
if (legacy != null) { if (legacy != null) {
prefs(storage).edit().putString(PREFS_KEY_LANGUAGE, legacy).apply() prefs(storage).edit { putString(PREFS_KEY_LANGUAGE, legacy) }
} }
} }
} }

View File

@@ -6,4 +6,5 @@
<string name="task_notification_preparing">Подготовка…</string> <string name="task_notification_preparing">Подготовка…</string>
<string name="task_notification_indeterminate">Выполняется…</string> <string name="task_notification_indeterminate">Выполняется…</string>
<string name="task_notification_cancel">Отмена</string> <string name="task_notification_cancel">Отмена</string>
<string name="fgs_task_pipeline_description">Показывает прогресс фоновых задач хранилищ и vault.</string>
</resources> </resources>

View File

@@ -6,4 +6,5 @@
<string name="task_notification_preparing">Preparing…</string> <string name="task_notification_preparing">Preparing…</string>
<string name="task_notification_indeterminate">Running…</string> <string name="task_notification_indeterminate">Running…</string>
<string name="task_notification_cancel">Cancel</string> <string name="task_notification_cancel">Cancel</string>
<string name="fgs_task_pipeline_description">Shows progress while storage and vault tasks run in the background.</string>
</resources> </resources>

View File

@@ -1,15 +0,0 @@
package com.github.nullptroma.wallenc.domain.vault.auth
/**
* Scope-ы Яндекс.OAuth, которые нам нужны: только app_folder + disk.info.
*
* Используется как ссылка для синхронизации с консолью Yandex OAuth.
* Сам Yandex Auth SDK для WEBVIEW-логина запрашивает scope-ы, выставленные
* у приложения в OAuth-консоли; мы держим список здесь, чтобы было одно место правды.
*/
object YandexOAuthScopes {
const val DISK_APP_FOLDER = "cloud_api:disk.app_folder"
const val DISK_INFO = "cloud_api:disk.info"
val ALL: Set<String> = setOf(DISK_APP_FOLDER, DISK_INFO)
}

View File

@@ -9,13 +9,13 @@ import java.io.IOException
fun Throwable.toVaultWallencException(): WallencException = when (this) { fun Throwable.toVaultWallencException(): WallencException = when (this) {
is WallencException -> this is WallencException -> this
is YandexDiskAuthException -> WallencException.Auth.Failed is YandexDiskAuthException -> WallencException.Auth.Failed()
is HttpException -> WallencException.Network.HttpFailed( is HttpException -> WallencException.Network.HttpFailed(
operation = "http", operation = "http",
statusCode = code(), statusCode = code(),
cause = this, cause = this,
) )
is FileNotFoundException -> WallencException.Storage.FileNotFound is FileNotFoundException -> WallencException.Storage.FileNotFound()
is IOException -> mapVaultIo(this) is IOException -> mapVaultIo(this)
is IllegalStateException -> mapIllegalState(this) is IllegalStateException -> mapIllegalState(this)
is IllegalArgumentException -> mapIllegalArgument(this) is IllegalArgumentException -> mapIllegalArgument(this)
@@ -26,29 +26,29 @@ private fun mapVaultIo(e: IOException): WallencException {
val msg = e.message.orEmpty() val msg = e.message.orEmpty()
return when { return when {
msg.contains("OAuth token is missing", ignoreCase = true) -> msg.contains("OAuth token is missing", ignoreCase = true) ->
WallencException.Auth.TokenMissing WallencException.Auth.TokenMissing()
msg.contains("HTTP 423", ignoreCase = true) || msg.contains("423 after retries", ignoreCase = true) -> msg.contains("HTTP 423", ignoreCase = true) || msg.contains("423 after retries", ignoreCase = true) ->
WallencException.Network.ResourceLocked WallencException.Network.ResourceLocked()
msg.contains("async operation timed out", ignoreCase = true) -> msg.contains("async operation timed out", ignoreCase = true) ->
WallencException.Network.OperationTimedOut WallencException.Network.OperationTimedOut()
msg.contains("async operation failed", ignoreCase = true) -> msg.contains("async operation failed", ignoreCase = true) ->
WallencException.Network.OperationFailed WallencException.Network.OperationFailed()
else -> WallencException.Network.IoFailed(e) else -> WallencException.Network.IoFailed(e)
} }
} }
private fun mapIllegalState(e: IllegalStateException): WallencException = when { private fun mapIllegalState(e: IllegalStateException): WallencException = when {
e.message == "Not a file" -> WallencException.Storage.NotAFile e.message == "Not a file" -> WallencException.Storage.NotAFile()
e.message == "Not a directory" -> WallencException.Storage.NotADirectory e.message == "Not a directory" -> WallencException.Storage.NotADirectory()
e.message?.startsWith("Path segment is a file:") == true -> WallencException.Storage.PathIsFile e.message?.startsWith("Path segment is a file:") == true -> WallencException.Storage.PathIsFile()
e.message?.startsWith("Cannot openWrite over directory:") == true -> e.message?.startsWith("Cannot openWrite over directory:") == true ->
WallencException.Storage.CannotWriteOverDirectory WallencException.Storage.CannotWriteOverDirectory()
e.message?.startsWith("Expected file after upload:") == true -> e.message?.startsWith("Expected file after upload:") == true ->
WallencException.Storage.UnexpectedState WallencException.Storage.UnexpectedState()
else -> WallencException.Storage.UnexpectedState else -> WallencException.Storage.UnexpectedState()
} }
private fun mapIllegalArgument(e: IllegalArgumentException): WallencException = when { private fun mapIllegalArgument(e: IllegalArgumentException): WallencException = when {
e.message == "Deleting root path is forbidden" -> WallencException.Storage.DeleteRootForbidden e.message == "Deleting root path is forbidden" -> WallencException.Storage.DeleteRootForbidden()
else -> WallencException.Unknown(e) else -> WallencException.Unknown(e)
} }

View File

@@ -12,7 +12,6 @@ import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Headers import retrofit2.http.Headers
import retrofit2.http.PATCH import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.PUT import retrofit2.http.PUT
import retrofit2.http.Query import retrofit2.http.Query
import retrofit2.http.Url import retrofit2.http.Url
@@ -46,13 +45,6 @@ interface YandexDiskApi {
@Query("permanently") permanently: Boolean, @Query("permanently") permanently: Boolean,
): Response<ResponseBody> ): Response<ResponseBody>
@POST("v1/disk/resources/move")
suspend fun moveResource(
@Query("from") from: String,
@Query("path") toPath: String,
@Query("overwrite") overwrite: Boolean = false,
): Response<ResponseBody>
@GET("v1/disk/resources/upload") @GET("v1/disk/resources/upload")
suspend fun getUploadLink( suspend fun getUploadLink(
@Query("path") path: String, @Query("path") path: String,

View File

@@ -33,7 +33,7 @@ class YandexDiskApiFactory(
/** /**
* [tokenProvider] вызывается на каждый HTTP-запрос к cloud-api (свежий токен из БД). * [tokenProvider] вызывается на каждый HTTP-запрос к cloud-api (свежий токен из БД).
*/ */
fun createAuthenticatedApi(tokenProvider: () -> String?): com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.YandexDiskApi { fun createAuthenticatedApi(tokenProvider: () -> String?): YandexDiskApi {
val client = OkHttpClient.Builder() val client = OkHttpClient.Builder()
.addInterceptor { chain -> .addInterceptor { chain ->
val token = tokenProvider() val token = tokenProvider()

View File

@@ -47,13 +47,6 @@ data class OperationStatusDto(
val status: String? = null, val status: String? = null,
) )
@JsonIgnoreProperties(ignoreUnknown = true)
data class ApiErrorDto(
val message: String? = null,
val description: String? = null,
val error: String? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class CustomPropertiesPatchDto( data class CustomPropertiesPatchDto(
@param:JsonProperty("custom_properties") val customProperties: Map<String, String>, @param:JsonProperty("custom_properties") val customProperties: Map<String, String>,

View File

@@ -110,9 +110,9 @@ class UnlockManager(
rememberPassword: Boolean rememberPassword: Boolean
): EncryptedStorage = withContext(ioDispatcher) { ): EncryptedStorage = withContext(ioDispatcher) {
return@withContext mutex.withLock { return@withContext mutex.withLock {
val encInfo = storage.metaInfo.value.encInfo ?: throw WallencException.Storage.EncInfoMissing val encInfo = storage.metaInfo.value.encInfo ?: throw WallencException.Storage.EncInfoMissing()
if (!Encryptor.checkKey(key, encInfo)) if (!Encryptor.checkKey(key, encInfo))
throw WallencException.Storage.IncorrectKey throw WallencException.Storage.IncorrectKey()
val opened = _openedStorages.value.toMutableMap() val opened = _openedStorages.value.toMutableMap()
val cur = opened[storage.uuid] val cur = opened[storage.uuid]

View File

@@ -29,7 +29,7 @@ class EncryptedStorage private constructor(
private val scope = CoroutineScope(ioDispatcher + job) private val scope = CoroutineScope(ioDispatcher + job)
private val encInfo = private val encInfo =
source.metaInfo.value.encInfo ?: throw WallencException.Storage.NotEncrypted source.metaInfo.value.encInfo ?: throw WallencException.Storage.NotEncrypted()
override val isVirtualStorage: Boolean = true override val isVirtualStorage: Boolean = true
@@ -52,7 +52,7 @@ class EncryptedStorage private constructor(
private fun checkKey() { private fun checkKey() {
if (!Encryptor.checkKey(key, encInfo)) if (!Encryptor.checkKey(key, encInfo))
throw WallencException.Storage.IncorrectKey throw WallencException.Storage.IncorrectKey()
} }
fun getKey(): EncryptKey = EncryptKey(key.bytes) fun getKey(): EncryptKey = EncryptKey(key.bytes)

View File

@@ -128,18 +128,10 @@ class EncryptedStorageAccessor(
return source.getFiles(encryptPath(systemHiddenDirName)) return source.getFiles(encryptPath(systemHiddenDirName))
} }
private fun encryptEntity(file: IFile): IFile {
return CommonFile(encryptMeta(file.metaInfo))
}
private fun decryptEntity(file: IFile): IFile { private fun decryptEntity(file: IFile): IFile {
return CommonFile(decryptMeta(file.metaInfo)) return CommonFile(decryptMeta(file.metaInfo))
} }
private fun encryptEntity(dir: IDirectory): IDirectory {
return CommonDirectory(encryptMeta(dir.metaInfo), dir.elementsCount)
}
private fun decryptEntity(dir: IDirectory): IDirectory { private fun decryptEntity(dir: IDirectory): IDirectory {
return CommonDirectory(decryptMeta(dir.metaInfo), dir.elementsCount) return CommonDirectory(decryptMeta(dir.metaInfo), dir.elementsCount)
} }
@@ -303,12 +295,11 @@ class EncryptedStorageAccessor(
return emptyList() return emptyList()
} }
return runCatching { return runCatching {
val javaType = jackson.typeFactory.constructCollectionType( val journalType = jackson.typeFactory.constructCollectionType(
List::class.java, List::class.java,
StorageSyncJournalEntry::class.java, StorageSyncJournalEntry::class.java,
) )
@Suppress("UNCHECKED_CAST") jackson.readValue<List<StorageSyncJournalEntry>>(bytes, journalType)
(jackson.readValue(bytes, javaType) as List<StorageSyncJournalEntry>)
}.getOrElse { }.getOrElse {
emptyList() emptyList()
} }

View File

@@ -214,10 +214,6 @@ class LocalStorageAccessor(
val filePath = Path(filesystemBasePath.pathString, storagePath) val filePath = Path(filesystemBasePath.pathString, storagePath)
return from(filesystemBasePath, filePath.toFile()) return from(filesystemBasePath, filePath.toFile())
} }
fun from(filesystemBasePath: Path, meta: IMetaInfo): LocalStorageFilePair? {
return from(filesystemBasePath, meta.path)
}
} }
} }
@@ -232,7 +228,7 @@ class LocalStorageAccessor(
dirCallback: (suspend (File, CommonDirectory) -> Unit)? = null dirCallback: (suspend (File, CommonDirectory) -> Unit)? = null
) { ) {
if (!checkAvailable()) if (!checkAvailable())
throw WallencException.Storage.NotAvailable throw WallencException.Storage.NotAvailable()
val basePath = Path(_filesystemBasePath.pathString, baseStoragePath) val basePath = Path(_filesystemBasePath.pathString, baseStoragePath)
val workedFiles = mutableSetOf<String>() val workedFiles = mutableSetOf<String>()
val workedMetaFiles = mutableSetOf<String>() val workedMetaFiles = mutableSetOf<String>()
@@ -398,7 +394,7 @@ class LocalStorageAccessor(
override suspend fun getFileInfo(path: String): IFile { override suspend fun getFileInfo(path: String): IFile {
val pair = LocalStorageFilePair.from(_filesystemBasePath, path) val pair = LocalStorageFilePair.from(_filesystemBasePath, path)
?: throw WallencException.Storage.UnexpectedState ?: throw WallencException.Storage.UnexpectedState()
return CommonFile( return CommonFile(
metaInfo = pair.meta, metaInfo = pair.meta,
) )
@@ -406,7 +402,7 @@ class LocalStorageAccessor(
override suspend fun getDirInfo(path: String): IDirectory { override suspend fun getDirInfo(path: String): IDirectory {
val pair = LocalStorageFilePair.from(_filesystemBasePath, path) val pair = LocalStorageFilePair.from(_filesystemBasePath, path)
?: throw WallencException.Storage.UnexpectedState ?: throw WallencException.Storage.UnexpectedState()
return CommonDirectory( return CommonDirectory(
metaInfo = pair.meta, metaInfo = pair.meta,
elementsCount = null elementsCount = null
@@ -415,7 +411,7 @@ class LocalStorageAccessor(
override suspend fun setHidden(path: String, hidden: Boolean) { override suspend fun setHidden(path: String, hidden: Boolean) {
val pair = LocalStorageFilePair.from(_filesystemBasePath, path) val pair = LocalStorageFilePair.from(_filesystemBasePath, path)
?: throw WallencException.Storage.UnexpectedState ?: throw WallencException.Storage.UnexpectedState()
if (pair.meta.isHidden == hidden) if (pair.meta.isHidden == hidden)
return return
val newMeta = pair.meta.copy(isHidden = hidden) val newMeta = pair.meta.copy(isHidden = hidden)
@@ -442,7 +438,7 @@ class LocalStorageAccessor(
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 WallencException.Storage.UnexpectedState throw WallencException.Storage.UnexpectedState()
} else if(!file.exists()) { } else if(!file.exists()) {
val parent = Path(storagePath).parent val parent = Path(storagePath).parent
createDir(parent.pathString) createDir(parent.pathString)
@@ -453,7 +449,7 @@ class LocalStorageAccessor(
} }
val pair = LocalStorageFilePair.from(_filesystemBasePath, file) val pair = LocalStorageFilePair.from(_filesystemBasePath, file)
?: throw WallencException.Storage.UnexpectedState ?: throw WallencException.Storage.UnexpectedState()
val newMeta = pair.meta.copy(lastModified = Clock.systemUTC().instant(), size = Files.size(pair.file.toPath())) val newMeta = pair.meta.copy(lastModified = Clock.systemUTC().instant(), size = Files.size(pair.file.toPath()))
writeMeta(pair.metaFile, newMeta) writeMeta(pair.metaFile, newMeta)
_filesUpdates.emit( _filesUpdates.emit(
@@ -470,13 +466,13 @@ class LocalStorageAccessor(
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 WallencException.Storage.UnexpectedState throw WallencException.Storage.UnexpectedState()
} else if(!file.exists()) { } else if(!file.exists()) {
Files.createDirectories(path) Files.createDirectories(path)
} }
val pair = LocalStorageFilePair.from(_filesystemBasePath, file) val pair = LocalStorageFilePair.from(_filesystemBasePath, file)
?: throw WallencException.Storage.UnexpectedState ?: throw WallencException.Storage.UnexpectedState()
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)
_dirsUpdates.emit( _dirsUpdates.emit(
@@ -514,7 +510,7 @@ class LocalStorageAccessor(
override suspend fun delete(path: String) = withContext(ioDispatcher) { override suspend fun delete(path: String) = withContext(ioDispatcher) {
if (path == "/" || path.isBlank()) { if (path == "/" || path.isBlank()) {
throw WallencException.Storage.DeleteRootForbidden throw WallencException.Storage.DeleteRootForbidden()
} }
val pair = LocalStorageFilePair.from(_filesystemBasePath, path) val pair = LocalStorageFilePair.from(_filesystemBasePath, path)
if (pair != null) { if (pair != null) {
@@ -529,7 +525,7 @@ class LocalStorageAccessor(
override suspend fun openWrite(path: String): OutputStream = withContext(ioDispatcher) { override suspend fun openWrite(path: String): OutputStream = withContext(ioDispatcher) {
touchFileInternal(path, recordJournal = false) touchFileInternal(path, recordJournal = false)
val pair = LocalStorageFilePair.from(_filesystemBasePath, path) val pair = LocalStorageFilePair.from(_filesystemBasePath, path)
?: throw WallencException.Storage.FileNotFound ?: throw WallencException.Storage.FileNotFound()
return@withContext pair.file.outputStream().onClosed { return@withContext pair.file.outputStream().onClosed {
CoroutineScope(ioDispatcher).launch { CoroutineScope(ioDispatcher).launch {
touchFileInternal(path, recordJournal = false) touchFileInternal(path, recordJournal = false)
@@ -541,13 +537,13 @@ class LocalStorageAccessor(
override suspend fun openRead(path: String): InputStream = withContext(ioDispatcher) { override suspend fun openRead(path: String): InputStream = withContext(ioDispatcher) {
val pair = LocalStorageFilePair.from(_filesystemBasePath, path) val pair = LocalStorageFilePair.from(_filesystemBasePath, path)
?: throw WallencException.Storage.FileNotFound ?: throw WallencException.Storage.FileNotFound()
return@withContext pair.file.inputStream() return@withContext pair.file.inputStream()
} }
override suspend fun moveToTrash(path: String) = withContext(ioDispatcher) { override suspend fun moveToTrash(path: String) = withContext(ioDispatcher) {
val pair = LocalStorageFilePair.from(_filesystemBasePath, path) val pair = LocalStorageFilePair.from(_filesystemBasePath, path)
?: throw WallencException.Storage.FileNotFound ?: throw WallencException.Storage.FileNotFound()
val newMeta = pair.meta.copy(isDeleted = true) val newMeta = pair.meta.copy(isDeleted = true)
writeMeta(pair.metaFile, newMeta) writeMeta(pair.metaFile, newMeta)
appendSyncEntry(path = path, operation = StorageSyncOperation.DELETE) appendSyncEntry(path = path, operation = StorageSyncOperation.DELETE)

View File

@@ -107,7 +107,7 @@ class YandexStorageAccessor(
} catch (e: YandexDiskAuthException) { } catch (e: YandexDiskAuthException) {
reportAuthFailure() reportAuthFailure()
_storageReady.value = false _storageReady.value = false
throw WallencException.Auth.Failed throw WallencException.Auth.Failed()
} catch (e: Exception) { } catch (e: Exception) {
_storageReady.value = false _storageReady.value = false
throw e.toVaultWallencException() throw e.toVaultWallencException()
@@ -121,7 +121,7 @@ class YandexStorageAccessor(
throw e throw e
} catch (e: YandexDiskAuthException) { } catch (e: YandexDiskAuthException) {
reportAuthFailure() reportAuthFailure()
throw WallencException.Auth.Failed throw WallencException.Auth.Failed()
} catch (e: Throwable) { } catch (e: Throwable) {
throw e.toVaultWallencException() throw e.toVaultWallencException()
} }
@@ -176,9 +176,7 @@ class YandexStorageAccessor(
systemDirEnsured = true systemDirEnsured = true
} }
private fun statsFileRel(): String = "/$SYSTEM_HIDDEN_DIRNAME/$STATS_FILENAME" private fun statsDiskPath(): String = toDiskPath("/$SYSTEM_HIDDEN_DIRNAME/$STATS_FILENAME")
private fun statsDiskPath(): String = toDiskPath(statsFileRel())
private suspend fun readPersistedStats(): YandexVaultPersistedStats? { private suspend fun readPersistedStats(): YandexVaultPersistedStats? {
val meta = guard { repo.getOrNull(statsDiskPath()) } ?: return null val meta = guard { repo.getOrNull(statsDiskPath()) } ?: return null
@@ -452,13 +450,13 @@ class YandexStorageAccessor(
override suspend fun getFileInfo(path: String): IFile = withContext(ioDispatcher) { override suspend fun getFileInfo(path: String): IFile = withContext(ioDispatcher) {
val r = guard { repo.get(toDiskPath(path)) } val r = guard { repo.get(toDiskPath(path)) }
if (r.type != "file") throw WallencException.Storage.NotAFile if (r.type != "file") throw WallencException.Storage.NotAFile()
r.toCommonFile(path) r.toCommonFile(path)
} }
override suspend fun getDirInfo(path: String): IDirectory = withContext(ioDispatcher) { override suspend fun getDirInfo(path: String): IDirectory = withContext(ioDispatcher) {
val r = guard { repo.get(toDiskPath(path)) } val r = guard { repo.get(toDiskPath(path)) }
if (r.type != "dir") throw WallencException.Storage.NotADirectory if (r.type != "dir") throw WallencException.Storage.NotADirectory()
r.toCommonDir(path) r.toCommonDir(path)
} }
@@ -497,7 +495,7 @@ class YandexStorageAccessor(
val diskPath = toDiskPath(acc) val diskPath = toDiskPath(acc)
when (guard { repo.getOrNull(diskPath) }?.type) { when (guard { repo.getOrNull(diskPath) }?.type) {
"dir" -> continue "dir" -> continue
"file" -> throw WallencException.Storage.PathIsFile "file" -> throw WallencException.Storage.PathIsFile()
else -> guard { repo.createFolder(diskPath) } else -> guard { repo.createFolder(diskPath) }
} }
} }
@@ -505,7 +503,7 @@ class YandexStorageAccessor(
override suspend fun delete(path: String) = withContext(ioDispatcher) { override suspend fun delete(path: String) = withContext(ioDispatcher) {
if (path == "/" || path.isBlank()) { if (path == "/" || path.isBlank()) {
throw WallencException.Storage.DeleteRootForbidden throw WallencException.Storage.DeleteRootForbidden()
} }
val diskPath = toDiskPath(path) val diskPath = toDiskPath(path)
val prior = guard { repo.getOrNull(diskPath) } val prior = guard { repo.getOrNull(diskPath) }
@@ -538,14 +536,14 @@ class YandexStorageAccessor(
val diskPath = toDiskPath(path) val diskPath = toDiskPath(path)
val prior = guard { repo.getOrNull(diskPath) } val prior = guard { repo.getOrNull(diskPath) }
if (prior?.type == "dir") { if (prior?.type == "dir") {
throw WallencException.Storage.CannotWriteOverDirectory throw WallencException.Storage.CannotWriteOverDirectory()
} }
val hadFile = prior?.type == "file" val hadFile = prior?.type == "file"
val priorSize = if (prior?.type == "file") prior.size ?: 0L else 0L val priorSize = if (prior?.type == "file") prior.size ?: 0L else 0L
guard { repo.uploadFile(diskPath, tmp, overwrite = true) } guard { repo.uploadFile(diskPath, tmp, overwrite = true) }
val after = guard { getMetadataAfterWrite(diskPath) } val after = guard { getMetadataAfterWrite(diskPath) }
if (after.type != "file") { if (after.type != "file") {
throw WallencException.Storage.UnexpectedState throw WallencException.Storage.UnexpectedState()
} }
val newSize = after.size ?: 0L val newSize = after.size ?: 0L
_size.value = ((_size.value ?: 0L) + newSize - priorSize).coerceAtLeast(0L) _size.value = ((_size.value ?: 0L) + newSize - priorSize).coerceAtLeast(0L)
@@ -605,12 +603,11 @@ class YandexStorageAccessor(
return@withContext emptyList() return@withContext emptyList()
} }
return@withContext runCatching { return@withContext runCatching {
val javaType = statsMapper.typeFactory.constructCollectionType( val journalType = statsMapper.typeFactory.constructCollectionType(
List::class.java, List::class.java,
StorageSyncJournalEntry::class.java, StorageSyncJournalEntry::class.java,
) )
@Suppress("UNCHECKED_CAST") statsMapper.readValue<List<StorageSyncJournalEntry>>(bytes, journalType)
(statsMapper.readValue(bytes, javaType) as List<StorageSyncJournalEntry>)
}.getOrElse { }.getOrElse {
emptyList() emptyList()
} }

View File

@@ -89,16 +89,8 @@ class CloseHandledStreamExtension {
return CloseHandledOutputStream(this, {}, callback) return CloseHandledOutputStream(this, {}, callback)
} }
fun InputStream.onClosed(callback: ()->Unit): InputStream {
return CloseHandledInputStream(this, {}, callback)
}
fun OutputStream.onClosing(callback: ()->Unit): OutputStream { fun OutputStream.onClosing(callback: ()->Unit): OutputStream {
return CloseHandledOutputStream(this, callback) {} return CloseHandledOutputStream(this, callback) {}
} }
fun InputStream.onClosing(callback: ()->Unit): InputStream {
return CloseHandledInputStream(this, callback) {}
}
} }
} }

View File

@@ -66,7 +66,7 @@ class LocalVault(
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) {
throw WallencException.Storage.NotAvailable throw WallencException.Storage.NotAvailable()
} }
val dirs = path.listFiles()?.filter { it.isDirectory } val dirs = path.listFiles()?.filter { it.isDirectory }
@@ -81,7 +81,7 @@ class LocalVault(
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 WallencException.Storage.NotAvailable throw WallencException.Storage.NotAvailable()
} }
val storageUuid = UUID.randomUUID() val storageUuid = UUID.randomUUID()
@@ -106,7 +106,7 @@ class LocalVault(
override suspend fun remove(storage: IStorage) = withContext(ioDispatcher) { override suspend fun remove(storage: IStorage) = withContext(ioDispatcher) {
val path = path.value val path = path.value
if (path == null || !_isAvailable.value) { if (path == null || !_isAvailable.value) {
throw WallencException.Storage.NotAvailable throw WallencException.Storage.NotAvailable()
} }
val curStorages = _storages.value.toMutableList() val curStorages = _storages.value.toMutableList()

View File

@@ -16,7 +16,7 @@ class VaultThrowableMappingTest {
@Test @Test
fun mapsYandexDiskAuthToAuthFailed() { fun mapsYandexDiskAuthToAuthFailed() {
val mapped = YandexDiskAuthException("unauthorized").toVaultWallencException() val mapped = YandexDiskAuthException("unauthorized").toVaultWallencException()
assertEquals(WallencException.Auth.Failed, mapped) assertTrue(mapped is WallencException.Auth.Failed)
} }
@Test @Test
@@ -30,18 +30,18 @@ class VaultThrowableMappingTest {
@Test @Test
fun mapsMissingOAuthTokenIoToTokenMissing() { fun mapsMissingOAuthTokenIoToTokenMissing() {
val mapped = IOException("Yandex OAuth token is missing").toVaultWallencException() val mapped = IOException("Yandex OAuth token is missing").toVaultWallencException()
assertEquals(WallencException.Auth.TokenMissing, mapped) assertTrue(mapped is WallencException.Auth.TokenMissing)
} }
@Test @Test
fun mapsFileNotFoundToStorageFileNotFound() { fun mapsFileNotFoundToStorageFileNotFound() {
val mapped = FileNotFoundException("x").toVaultWallencException() val mapped = FileNotFoundException("x").toVaultWallencException()
assertEquals(WallencException.Storage.FileNotFound, mapped) assertTrue(mapped is WallencException.Storage.FileNotFound)
} }
@Test @Test
fun mapsIllegalStateNotAFile() { fun mapsIllegalStateNotAFile() {
val mapped = IllegalStateException("Not a file").toVaultWallencException() val mapped = IllegalStateException("Not a file").toVaultWallencException()
assertEquals(WallencException.Storage.NotAFile, mapped) assertTrue(mapped is WallencException.Storage.NotAFile)
} }
} }

View File

@@ -1,9 +1,6 @@
package com.github.nullptroma.wallenc.domain.vault.network.yandexdisk package com.github.nullptroma.wallenc.domain.vault.network.yandexdisk
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.repository.YandexDiskRepository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.jackson.JacksonConverterFactory import retrofit2.converter.jackson.JacksonConverterFactory
@@ -13,16 +10,6 @@ object YandexDiskRepositoryTestFactory {
private val jackson = jacksonObjectMapper().findAndRegisterModules() private val jackson = jacksonObjectMapper().findAndRegisterModules()
fun create(
baseUrl: String,
oauthToken: String,
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
rawHttp: OkHttpClient = OkHttpClient(),
): YandexDiskRepository {
val api = createApi(baseUrl) { oauthToken }
return YandexDiskRepository(api, rawHttp, ioDispatcher)
}
fun createApi( fun createApi(
baseUrl: String, baseUrl: String,
tokenProvider: () -> String?, tokenProvider: () -> String?,

View File

@@ -2,11 +2,7 @@ package com.github.nullptroma.wallenc.domain.encrypt
import com.github.nullptroma.wallenc.domain.encrypt.Encryptor.Companion.AES_SETTINGS import com.github.nullptroma.wallenc.domain.encrypt.Encryptor.Companion.AES_SETTINGS
import kotlinx.coroutines.DisposableHandle import kotlinx.coroutines.DisposableHandle
import java.io.InputStream
import java.io.OutputStream
import javax.crypto.Cipher import javax.crypto.Cipher
import javax.crypto.CipherInputStream
import javax.crypto.CipherOutputStream
import javax.crypto.SecretKey import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.IvParameterSpec
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
@@ -47,22 +43,6 @@ class EncryptorWithStaticIv(private var secretKey: SecretKey, iv: ByteArray) : D
return decryptedBytes 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() { override fun dispose() {
secretKey.destroy() secretKey.destroy()
} }

View File

@@ -10,45 +10,44 @@ sealed class WallencException(
) : Exception(message, cause) { ) : Exception(message, cause) {
sealed class Feature : WallencException() { sealed class Feature : WallencException() {
data object StorageNotFound : Feature() class StorageNotFound : Feature()
data object NeedsDecryptedView : Feature() class NeedsDecryptedView : Feature()
data object SecretNotFound : Feature() class SecretNotFound : Feature()
data object StorageNotWritable : Feature() class StorageNotWritable : Feature()
} }
sealed class Storage(cause: Throwable? = null) : WallencException(cause = cause) { sealed class Storage(cause: Throwable? = null) : WallencException(cause = cause) {
data object NotAvailable : Storage() class NotAvailable : Storage()
data object FileNotFound : Storage() class FileNotFound : Storage()
data object IncorrectKey : Storage() class IncorrectKey : Storage()
class EncInfoMissing : Storage()
class NotEncrypted : Storage()
class NotWritable : Storage()
class NotAFile : Storage()
class NotADirectory : Storage()
class PathIsFile : Storage()
class CannotWriteOverDirectory : Storage()
class DeleteRootForbidden : Storage()
class UnexpectedState : Storage()
data class IoFailed(override val cause: Throwable) : Storage(cause) data class IoFailed(override val cause: Throwable) : Storage(cause)
data object EncInfoMissing : Storage()
data object NotEncrypted : Storage()
data object NotWritable : Storage()
data object NotAFile : Storage()
data object NotADirectory : Storage()
data object PathIsFile : Storage()
data object CannotWriteOverDirectory : Storage()
data object DeleteRootForbidden : Storage()
data object UnexpectedState : Storage()
} }
/** Ошибки аутентификации (OAuth, токен), без привязки к провайдеру. */
sealed class Auth : WallencException() { sealed class Auth : WallencException() {
data object Failed : Auth() class Failed : Auth()
data object TokenMissing : Auth() class TokenMissing : Auth()
} }
/** Сетевые и удалённые операции (HTTP, блокировки, таймауты). */
sealed class Network(cause: Throwable? = null) : WallencException(cause = cause) { sealed class Network(cause: Throwable? = null) : WallencException(cause = cause) {
data class HttpFailed( data class HttpFailed(
val operation: String, val operation: String,
val statusCode: Int, val statusCode: Int,
override val cause: Throwable? = null, override val cause: Throwable? = null,
) : Network(cause) ) : Network(cause)
data class IoFailed(override val cause: Throwable) : Network(cause) data class IoFailed(override val cause: Throwable) : Network(cause)
data object ResourceLocked : Network() class ResourceLocked : Network()
data object OperationFailed : Network() class OperationFailed : Network()
data object OperationTimedOut : Network() class OperationTimedOut : Network()
} }
data class Unknown(override val cause: Throwable?) : WallencException(cause = cause) data class Unknown(override val cause: Throwable?) : WallencException(cause = cause)

View File

@@ -5,9 +5,7 @@ import java.io.IOException
fun Throwable.toWallencException(): WallencException = when (this) { fun Throwable.toWallencException(): WallencException = when (this) {
is WallencException -> this is WallencException -> this
is FileNotFoundException -> WallencException.Storage.FileNotFound is FileNotFoundException -> WallencException.Storage.FileNotFound()
is IOException -> WallencException.Network.IoFailed(this) is IOException -> WallencException.Network.IoFailed(this)
else -> WallencException.Unknown(this) else -> WallencException.Unknown(this)
} }
fun Throwable.rethrowAsWallencException(): Nothing = throw toWallencException()

View File

@@ -1,10 +0,0 @@
package com.github.nullptroma.wallenc.domain.interfaces
import kotlinx.coroutines.flow.StateFlow
interface IStorageExplorer {
val currentPath: StateFlow<String>
// TODO
// пока бесполезный интерфейс
}

View File

@@ -1,6 +1,5 @@
package com.github.nullptroma.wallenc.domain.errors package com.github.nullptroma.wallenc.domain.errors
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@@ -11,14 +10,14 @@ class WallencExceptionMappingTest {
@Test @Test
fun preservesWallencException() { fun preservesWallencException() {
val original = WallencException.Feature.StorageNotFound val original = WallencException.Feature.StorageNotFound()
assertSame(original, original.toWallencException()) assertSame(original, original.toWallencException())
} }
@Test @Test
fun mapsFileNotFoundException() { fun mapsFileNotFoundException() {
val mapped = FileNotFoundException("missing").toWallencException() val mapped = FileNotFoundException("missing").toWallencException()
assertEquals(WallencException.Storage.FileNotFound, mapped) assertTrue(mapped is WallencException.Storage.FileNotFound)
} }
@Test @Test

View File

@@ -1,6 +1,7 @@
[versions] [versions]
agp = "9.1.1" agp = "9.2.1"
jacksonModuleKotlin = "2.21.3" jacksonModuleKotlin = "2.21.3"
javaxInject = "1"
kotlin = "2.3.21" kotlin = "2.3.21"
coreKtx = "1.18.0" coreKtx = "1.18.0"
junit = "4.13.2" junit = "4.13.2"
@@ -32,11 +33,11 @@ datastore = "1.2.1"
mockk = "1.14.9" mockk = "1.14.9"
robolectric = "4.16.1" robolectric = "4.16.1"
androidxArchCore = "2.2.0" androidxArchCore = "2.2.0"
turbine = "1.2.1"
[libraries] [libraries]
jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" } jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" }
jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jacksonModuleKotlin" }
javax-inject = { module = "javax.inject:javax.inject", version.ref = "javaxInject" }
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlinReflect" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlinReflect" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesCore" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesCore" }
@@ -45,7 +46,6 @@ mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "
room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
androidx-arch-core-testing = { group = "androidx.arch.core", name = "core-testing", version.ref = "androidxArchCore" } androidx-arch-core-testing = { group = "androidx.arch.core", name = "core-testing", version.ref = "androidxArchCore" }
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigation" } androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigation" }

View File

@@ -1,59 +0,0 @@
package com.github.nullptroma.wallenc.infrastructure.android.db.app.repository
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageMetaInfoDao
import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbStorageMetaInfo
import com.github.nullptroma.wallenc.domain.vault.utils.IProvider
import com.github.nullptroma.wallenc.domain.common.impl.CommonStorageMetaInfo
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.util.UUID
class StorageMetaInfoRepository(
private val dao: StorageMetaInfoDao,
private val ioDispatcher: CoroutineDispatcher
) {
fun getAllFlow() = dao.getAllFlow()
suspend fun getAll() = withContext(ioDispatcher) { dao.getAll() }
suspend fun getMeta(uuid: UUID): CommonStorageMetaInfo? = withContext(ioDispatcher) {
val json = dao.getMetaInfo(uuid)?.metaInfoJson ?: return@withContext null
return@withContext jackson.readValue(
json,
CommonStorageMetaInfo::class.java
)
}
fun observeMeta(uuid: UUID): Flow<CommonStorageMetaInfo> {
return dao.getMetaInfoFlow(uuid)
.map { jackson.readValue(it.metaInfoJson, CommonStorageMetaInfo::class.java) }
}
suspend fun setMeta(uuid: UUID, metaInfo: CommonStorageMetaInfo) = withContext(ioDispatcher) {
val json = jackson.writeValueAsString(metaInfo)
dao.add(DbStorageMetaInfo(uuid, json))
}
suspend fun delete(uuid: UUID) = withContext(ioDispatcher) {
dao.delete(uuid)
}
fun createSingleStorageProvider(uuid: UUID): SingleStorageMetaInfoProvider {
return SingleStorageMetaInfoProvider(this, uuid)
}
class SingleStorageMetaInfoProvider (
private val repo: StorageMetaInfoRepository,
val uuid: UUID
) : IProvider<CommonStorageMetaInfo> {
override suspend fun get(): CommonStorageMetaInfo? = repo.getMeta(uuid)
override suspend fun clear() = repo.delete(uuid)
override suspend fun set(value: CommonStorageMetaInfo) = repo.setMeta(uuid, value)
}
companion object {
private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() }
}
}

View File

@@ -2,7 +2,6 @@ package com.github.nullptroma.wallenc.task.runtime
import com.github.nullptroma.wallenc.domain.errors.WallencException import com.github.nullptroma.wallenc.domain.errors.WallencException
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -10,7 +9,6 @@ import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@@ -59,13 +57,13 @@ class TaskOrchestratorTest {
title = "Fail", title = "Fail",
dispatcher = dispatcher, dispatcher = dispatcher,
work = { ctx -> work = { ctx ->
ctx.fail(WallencException.Storage.FileNotFound) ctx.fail(WallencException.Storage.FileNotFound())
}, },
) )
advanceUntilIdle() advanceUntilIdle()
val task = orchestrator.pipelineState.value.tasks.first { it.id == id } val task = orchestrator.pipelineState.value.tasks.first { it.id == id }
val failed = task.state as TaskRunState.Failed val failed = task.state as TaskRunState.Failed
assertEquals(WallencException.Storage.FileNotFound, failed.error) assertTrue(failed.error is WallencException.Storage.FileNotFound)
} }
@Test @Test

View File

@@ -4,13 +4,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable import androidx.lifecycle.viewmodel.compose.saveable
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
import com.github.nullptroma.wallenc.ui.screens.main.MainRoute import com.github.nullptroma.wallenc.ui.screens.main.MainRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.tasks.TaskPipelineRoute import com.github.nullptroma.wallenc.ui.screens.main.screens.tasks.TaskPipelineRoute
import com.github.nullptroma.wallenc.ui.screens.settings.SettingsRoute import com.github.nullptroma.wallenc.ui.screens.settings.SettingsRoute
import com.github.nullptroma.wallenc.ui.screens.sync.StorageSyncRoute import com.github.nullptroma.wallenc.ui.screens.sync.StorageSyncRoute
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlin.collections.set
@HiltViewModel @HiltViewModel
class WallencViewModel @javax.inject.Inject constructor(savedStateHandle: SavedStateHandle) : class WallencViewModel @javax.inject.Inject constructor(savedStateHandle: SavedStateHandle) :
@@ -27,10 +25,4 @@ class WallencViewModel @javax.inject.Inject constructor(savedStateHandle: SavedS
) )
} }
private set private set
fun updateRoute(qualifiedName: String, route: ScreenRoute) {
routes = routes.toMutableMap().apply {
this[qualifiedName] = route
}
}
} }

View File

@@ -1,8 +1,8 @@
package com.github.nullptroma.wallenc.ui.elements package com.github.nullptroma.wallenc.ui.elements
import android.annotation.SuppressLint
import android.view.ViewGroup import android.view.ViewGroup
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
@@ -28,11 +28,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.BarcodeScanning
@@ -41,8 +39,8 @@ import com.google.mlkit.vision.common.InputImage
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@OptIn(ExperimentalMaterial3Api::class) @ExperimentalGetImage
@SuppressLint("UnsafeOptInUsageError") @OptIn(ExperimentalMaterial3Api::class, ExperimentalGetImage::class)
@Composable @Composable
fun QrScannerDialog( fun QrScannerDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,

View File

@@ -1,61 +0,0 @@
package com.github.nullptroma.wallenc.ui.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,44 +0,0 @@
package com.github.nullptroma.wallenc.ui.extensions
import androidx.compose.ui.Modifier
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.unit.Dp
fun Modifier.ignoreHorizontalParentPadding(horizontal: Dp): Modifier {
return this.layout { measurable, constraints ->
val overrideWidth = constraints.maxWidth + 2 * horizontal.roundToPx()
val placeable = measurable.measure(constraints.copy(maxWidth = overrideWidth))
layout(placeable.width, placeable.height) {
placeable.place(0, 0)
}
}
}
fun Modifier.ignoreVerticalParentPadding(vertical: Dp): Modifier {
return this.layout { measurable, constraints ->
val overrideHeight = constraints.maxHeight + 2 * vertical.roundToPx()
val placeable = measurable.measure(constraints.copy(maxHeight = overrideHeight))
layout(placeable.width, placeable.height) {
placeable.place(0, 0)
}
}
}
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

@@ -6,50 +6,50 @@ import com.github.nullptroma.wallenc.usecases.AddStorageToSyncGroupResult
import com.github.nullptroma.wallenc.vault.contract.VaultLinkFailure import com.github.nullptroma.wallenc.vault.contract.VaultLinkFailure
fun WallencException.toUserNotification(): UserNotification.TextRes = when (this) { fun WallencException.toUserNotification(): UserNotification.TextRes = when (this) {
WallencException.Feature.StorageNotFound -> is WallencException.Feature.StorageNotFound ->
UserNotification.TextRes(R.string.error_storage_not_found) UserNotification.TextRes(R.string.error_storage_not_found)
WallencException.Feature.NeedsDecryptedView -> is WallencException.Feature.NeedsDecryptedView ->
UserNotification.TextRes(R.string.error_storage_locked_view) UserNotification.TextRes(R.string.error_storage_locked_view)
WallencException.Feature.SecretNotFound -> is WallencException.Feature.SecretNotFound ->
UserNotification.TextRes(R.string.error_secret_not_found) UserNotification.TextRes(R.string.error_secret_not_found)
WallencException.Feature.StorageNotWritable -> is WallencException.Feature.StorageNotWritable ->
UserNotification.TextRes(R.string.error_storage_not_writable) UserNotification.TextRes(R.string.error_storage_not_writable)
WallencException.Storage.NotAvailable, is WallencException.Storage.NotAvailable,
WallencException.Storage.NotWritable, is WallencException.Storage.NotWritable,
-> ->
UserNotification.TextRes(R.string.error_storage_not_writable) UserNotification.TextRes(R.string.error_storage_not_writable)
WallencException.Storage.FileNotFound -> is WallencException.Storage.FileNotFound ->
UserNotification.TextRes(R.string.error_file_not_found) UserNotification.TextRes(R.string.error_file_not_found)
WallencException.Storage.IncorrectKey -> is WallencException.Storage.IncorrectKey ->
UserNotification.TextRes(R.string.error_incorrect_password) UserNotification.TextRes(R.string.error_incorrect_password)
WallencException.Storage.NotEncrypted -> is WallencException.Storage.NotEncrypted ->
UserNotification.TextRes(R.string.error_storage_not_encrypted) UserNotification.TextRes(R.string.error_storage_not_encrypted)
WallencException.Storage.EncInfoMissing -> is WallencException.Storage.EncInfoMissing ->
UserNotification.TextRes(R.string.error_enc_info_missing) UserNotification.TextRes(R.string.error_enc_info_missing)
WallencException.Storage.DeleteRootForbidden -> is WallencException.Storage.DeleteRootForbidden ->
UserNotification.TextRes(R.string.error_delete_root_forbidden) UserNotification.TextRes(R.string.error_delete_root_forbidden)
WallencException.Storage.NotAFile -> is WallencException.Storage.NotAFile ->
UserNotification.TextRes(R.string.error_not_a_file) UserNotification.TextRes(R.string.error_not_a_file)
WallencException.Storage.NotADirectory -> is WallencException.Storage.NotADirectory ->
UserNotification.TextRes(R.string.error_not_a_directory) UserNotification.TextRes(R.string.error_not_a_directory)
WallencException.Storage.PathIsFile -> is WallencException.Storage.PathIsFile ->
UserNotification.TextRes(R.string.error_path_is_file) UserNotification.TextRes(R.string.error_path_is_file)
WallencException.Storage.CannotWriteOverDirectory -> is WallencException.Storage.CannotWriteOverDirectory ->
UserNotification.TextRes(R.string.error_cannot_write_over_directory) UserNotification.TextRes(R.string.error_cannot_write_over_directory)
WallencException.Storage.UnexpectedState -> is WallencException.Storage.UnexpectedState ->
UserNotification.TextRes(R.string.error_unexpected_state) UserNotification.TextRes(R.string.error_unexpected_state)
is WallencException.Storage.IoFailed -> is WallencException.Storage.IoFailed ->
UserNotification.TextRes(R.string.error_network) UserNotification.TextRes(R.string.error_network)
WallencException.Auth.Failed, is WallencException.Auth.Failed,
WallencException.Auth.TokenMissing, is WallencException.Auth.TokenMissing,
-> ->
UserNotification.TextRes(R.string.vault_link_error_auth) UserNotification.TextRes(R.string.vault_link_error_auth)
WallencException.Network.ResourceLocked -> is WallencException.Network.ResourceLocked ->
UserNotification.TextRes(R.string.error_disk_resource_locked) UserNotification.TextRes(R.string.error_disk_resource_locked)
WallencException.Network.OperationFailed, is WallencException.Network.OperationFailed,
WallencException.Network.OperationTimedOut, is WallencException.Network.OperationTimedOut,
is WallencException.Network.HttpFailed, is WallencException.Network.HttpFailed,
is WallencException.Network.IoFailed, is WallencException.Network.IoFailed,
-> ->

View File

@@ -1,22 +1,13 @@
package com.github.nullptroma.wallenc.ui.resources package com.github.nullptroma.wallenc.ui.resources
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLine import com.github.nullptroma.wallenc.domain.tasks.TaskLogLine
@Composable @Composable
fun TaskLogLine.displayText(): String { fun TaskLogLine.displayText(): String {
val key = logKey val key = logKey
if (key != null) { if (key != null) {
val context = LocalContext.current return key.resolve(composableUiStringResolver())
val resolver = UiStringResolver { id, args ->
if (args.isEmpty()) {
context.getString(id)
} else {
context.getString(id, *args)
}
}
return key.resolve(resolver)
} }
return message return message
} }

View File

@@ -8,7 +8,8 @@ fun TaskProgressLabel.resolve(resolver: UiStringResolver): String = when (this)
TaskProgressLabel.SyncNoGroups -> resolver(R.string.sync_progress_no_groups) TaskProgressLabel.SyncNoGroups -> resolver(R.string.sync_progress_no_groups)
TaskProgressLabel.SyncStarted -> resolver(R.string.sync_progress_started) TaskProgressLabel.SyncStarted -> resolver(R.string.sync_progress_started)
TaskProgressLabel.SyncCompleted -> resolver(R.string.sync_progress_completed) TaskProgressLabel.SyncCompleted -> resolver(R.string.sync_progress_completed)
is TaskProgressLabel.SyncPreparing -> resolver(R.string.sync_progress_preparing, groupCount) is TaskProgressLabel.SyncPreparing ->
resolver.plurals(R.plurals.sync_progress_preparing, groupCount, groupCount)
is TaskProgressLabel.SyncGroupPreparing -> resolver(R.string.sync_progress_group_preparing, groupId) is TaskProgressLabel.SyncGroupPreparing -> resolver(R.string.sync_progress_group_preparing, groupId)
is TaskProgressLabel.SyncGroupNotFound -> resolver(R.string.sync_progress_group_not_found, groupId) is TaskProgressLabel.SyncGroupNotFound -> resolver(R.string.sync_progress_group_not_found, groupId)
@@ -31,7 +32,7 @@ fun TaskProgressLabel.resolve(resolver: UiStringResolver): String = when (this)
is TaskProgressLabel.SyncGroupNoJournalEntries -> is TaskProgressLabel.SyncGroupNoJournalEntries ->
resolver(R.string.sync_progress_group_no_entries, groupId) resolver(R.string.sync_progress_group_no_entries, groupId)
is TaskProgressLabel.SyncGroupProcessingEntries -> is TaskProgressLabel.SyncGroupProcessingEntries ->
resolver(R.string.sync_progress_group_processing, groupId, count) resolver.plurals(R.plurals.sync_progress_group_processing, count, groupId, count)
is TaskProgressLabel.SyncGroupEntryProgress -> is TaskProgressLabel.SyncGroupEntryProgress ->
resolver(R.string.sync_progress_group_entry, groupId, current, total) resolver(R.string.sync_progress_group_entry, groupId, current, total)
is TaskProgressLabel.SyncGroupCompleted -> is TaskProgressLabel.SyncGroupCompleted ->

View File

@@ -1,19 +1,7 @@
package com.github.nullptroma.wallenc.ui.resources package com.github.nullptroma.wallenc.ui.resources
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
@Composable @Composable
fun TaskProgressLabel.resolveText(): String { fun TaskProgressLabel.resolveText(): String = resolve(composableUiStringResolver())
val context = LocalContext.current
return resolve(
UiStringResolver { id, args ->
if (args.isEmpty()) {
context.getString(id)
} else {
context.getString(id, *args)
}
},
)
}

View File

@@ -1,8 +1,11 @@
package com.github.nullptroma.wallenc.ui.resources package com.github.nullptroma.wallenc.ui.resources
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
/** Разрешение Android-строк для код-домена (ViewModel, без Compose). */ /** Разрешение Android-строк для код-домена (ViewModel, без Compose). */
fun interface UiStringResolver { interface UiStringResolver {
operator fun invoke(@StringRes id: Int, vararg formatArgs: Any): String operator fun invoke(@StringRes id: Int, vararg formatArgs: Any): String
fun plurals(@PluralsRes id: Int, quantity: Int, vararg formatArgs: Any): String
} }

View File

@@ -0,0 +1,37 @@
package com.github.nullptroma.wallenc.ui.resources
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
@Composable
fun composableUiStringResolver(): UiStringResolver {
val resources = LocalResources.current
return remember(resources) {
object : UiStringResolver {
override fun invoke(id: Int, vararg formatArgs: Any): String =
if (formatArgs.isEmpty()) {
resources.getString(id)
} else {
resources.getString(id, *formatArgs)
}
override fun plurals(id: Int, quantity: Int, vararg formatArgs: Any): String =
if (formatArgs.isEmpty()) {
resources.getQuantityString(id, quantity)
} else {
resources.getQuantityString(id, quantity, *formatArgs)
}
}
}
}
@Composable
fun resolveString(@StringRes id: Int, vararg formatArgs: Any): String =
if (formatArgs.isEmpty()) {
stringResource(id)
} else {
stringResource(id, *formatArgs)
}

View File

@@ -71,12 +71,6 @@ class MainViewModel @Inject constructor(
} }
} }
fun updateRoute(qualifiedName: String, route: ScreenRoute) {
routes = routes.toMutableMap().apply {
this[qualifiedName] = route
}
}
private fun mapWorkStatus( private fun mapWorkStatus(
fg: TaskForegroundUiState, fg: TaskForegroundUiState,
pipe: PipelineState, pipe: PipelineState,

View File

@@ -40,6 +40,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
@@ -58,6 +59,7 @@ fun RemoteVaultsScreen(
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
val resources = LocalResources.current
Box { Box {
Scaffold( Scaffold(
@@ -215,9 +217,9 @@ fun RemoteVaultsScreen(
is VaultLinkOutcome.Failed -> { is VaultLinkOutcome.Failed -> {
val notification = outcome.reason.toUserNotification() val notification = outcome.reason.toUserNotification()
val text = if (notification.formatArgs.isEmpty()) { val text = if (notification.formatArgs.isEmpty()) {
context.getString(notification.id) resources.getString(notification.id)
} else { } else {
context.getString( resources.getString(
notification.id, notification.id,
*notification.formatArgs.toTypedArray(), *notification.formatArgs.toTypedArray(),
) )

View File

@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Notes import androidx.compose.material.icons.automirrored.outlined.Notes
import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Notes
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator

View File

@@ -35,7 +35,7 @@ class StorageHomeViewModel @Inject constructor(
state.value.copy( state.value.copy(
isLoading = false, isLoading = false,
storageUuid = storageUuid.toString(), storageUuid = storageUuid.toString(),
errorNotification = WallencException.Feature.StorageNotFound.toUserNotification(), errorNotification = WallencException.Feature.StorageNotFound().toUserNotification(),
), ),
) )
return@launch return@launch
@@ -59,7 +59,7 @@ class StorageHomeViewModel @Inject constructor(
textSecretsCount = secrets.size, textSecretsCount = secrets.size,
canManageDomainData = canManageDomainData, canManageDomainData = canManageDomainData,
errorNotification = if (isRawEncrypted) { errorNotification = if (isRawEncrypted) {
WallencException.Feature.NeedsDecryptedView.toUserNotification() WallencException.Feature.NeedsDecryptedView().toUserNotification()
} else { } else {
null null
}, },

View File

@@ -48,7 +48,7 @@ class TextSecretDetailsViewModel @Inject constructor(
updateState( updateState(
state.value.copy( state.value.copy(
isLoading = false, isLoading = false,
errorNotification = WallencException.Feature.StorageNotFound.toUserNotification(), errorNotification = WallencException.Feature.StorageNotFound().toUserNotification(),
), ),
) )
return@launch return@launch
@@ -58,7 +58,7 @@ class TextSecretDetailsViewModel @Inject constructor(
state.value.copy( state.value.copy(
isLoading = false, isLoading = false,
isAvailable = false, isAvailable = false,
errorNotification = WallencException.Feature.NeedsDecryptedView.toUserNotification(), errorNotification = WallencException.Feature.NeedsDecryptedView().toUserNotification(),
), ),
) )
return@launch return@launch
@@ -80,7 +80,7 @@ class TextSecretDetailsViewModel @Inject constructor(
isMutating = isMutating, isMutating = isMutating,
secret = secret, secret = secret,
errorNotification = if (secret == null) { errorNotification = if (secret == null) {
WallencException.Feature.SecretNotFound.toUserNotification() WallencException.Feature.SecretNotFound().toUserNotification()
} else { } else {
null null
}, },
@@ -97,7 +97,7 @@ class TextSecretDetailsViewModel @Inject constructor(
if (storage.metaInfo.value.encInfo != null && !storage.isVirtualStorage) { if (storage.metaInfo.value.encInfo != null && !storage.isVirtualStorage) {
updateState( updateState(
state.value.copy( state.value.copy(
errorNotification = WallencException.Feature.NeedsDecryptedView.toUserNotification(), errorNotification = WallencException.Feature.NeedsDecryptedView().toUserNotification(),
), ),
) )
return@launch return@launch

View File

@@ -52,7 +52,7 @@ class TextSecretEditViewModel @Inject constructor(
updateState( updateState(
state.value.copy( state.value.copy(
isLoading = false, isLoading = false,
errorNotification = WallencException.Feature.StorageNotFound.toUserNotification(), errorNotification = WallencException.Feature.StorageNotFound().toUserNotification(),
), ),
) )
return@launch return@launch
@@ -62,7 +62,7 @@ class TextSecretEditViewModel @Inject constructor(
state.value.copy( state.value.copy(
isLoading = false, isLoading = false,
isAvailable = false, isAvailable = false,
errorNotification = WallencException.Feature.NeedsDecryptedView.toUserNotification(), errorNotification = WallencException.Feature.NeedsDecryptedView().toUserNotification(),
), ),
) )
return@launch return@launch
@@ -100,7 +100,7 @@ class TextSecretEditViewModel @Inject constructor(
if (storage.metaInfo.value.encInfo != null && !storage.isVirtualStorage) { if (storage.metaInfo.value.encInfo != null && !storage.isVirtualStorage) {
updateState( updateState(
state.value.copy( state.value.copy(
errorNotification = WallencException.Feature.NeedsDecryptedView.toUserNotification(), errorNotification = WallencException.Feature.NeedsDecryptedView().toUserNotification(),
), ),
) )
return@launch return@launch

View File

@@ -33,7 +33,7 @@ class TextSecretsViewModel @Inject constructor(
updateState( updateState(
state.value.copy( state.value.copy(
isLoading = false, isLoading = false,
errorNotification = WallencException.Feature.StorageNotFound.toUserNotification(), errorNotification = WallencException.Feature.StorageNotFound().toUserNotification(),
), ),
) )
return@launch return@launch
@@ -43,7 +43,7 @@ class TextSecretsViewModel @Inject constructor(
state.value.copy( state.value.copy(
isLoading = false, isLoading = false,
isAvailable = false, isAvailable = false,
errorNotification = WallencException.Feature.NeedsDecryptedView.toUserNotification(), errorNotification = WallencException.Feature.NeedsDecryptedView().toUserNotification(),
), ),
) )
return@launch return@launch

View File

@@ -28,11 +28,9 @@ import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -80,7 +78,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.resources.resolveText
import com.github.nullptroma.wallenc.ui.elements.QrScannerDialog import com.github.nullptroma.wallenc.ui.elements.QrScannerDialog
import com.github.nullptroma.wallenc.usecases.TwoFaCodeState import com.github.nullptroma.wallenc.usecases.TwoFaCodeState
import com.github.nullptroma.wallenc.usecases.buildTwoFaCodeState import com.github.nullptroma.wallenc.usecases.buildTwoFaCodeState
@@ -256,7 +253,6 @@ fun TwoFaTokensScreen(
isBusy = uiState.isMutating, isBusy = uiState.isMutating,
onDismiss = { creating = false }, onDismiss = { creating = false },
onSave = { issuer, account, secret, notes, digits, periodSeconds, algorithm -> onSave = { issuer, account, secret, notes, digits, periodSeconds, algorithm ->
creating = false
viewModel.saveToken( viewModel.saveToken(
existingId = null, existingId = null,
issuer = issuer, issuer = issuer,
@@ -267,6 +263,7 @@ fun TwoFaTokensScreen(
periodSeconds = periodSeconds, periodSeconds = periodSeconds,
algorithm = algorithm, algorithm = algorithm,
) )
creating = false
}, },
) )
} }
@@ -277,7 +274,6 @@ fun TwoFaTokensScreen(
isBusy = uiState.isMutating, isBusy = uiState.isMutating,
onDismiss = { editingToken = null }, onDismiss = { editingToken = null },
onSave = { issuer, account, secret, notes, digits, periodSeconds, algorithm -> onSave = { issuer, account, secret, notes, digits, periodSeconds, algorithm ->
editingToken = null
viewModel.saveToken( viewModel.saveToken(
existingId = token.id, existingId = token.id,
issuer = issuer, issuer = issuer,
@@ -288,6 +284,7 @@ fun TwoFaTokensScreen(
periodSeconds = periodSeconds, periodSeconds = periodSeconds,
algorithm = algorithm, algorithm = algorithm,
) )
editingToken = null
}, },
) )
} }

View File

@@ -44,7 +44,7 @@ class TwoFaTokensViewModel @Inject constructor(
updateState( updateState(
state.value.copy( state.value.copy(
isLoading = false, isLoading = false,
errorNotification = WallencException.Feature.StorageNotFound.toUserNotification(), errorNotification = WallencException.Feature.StorageNotFound().toUserNotification(),
), ),
) )
return@launch return@launch
@@ -54,7 +54,7 @@ class TwoFaTokensViewModel @Inject constructor(
state.value.copy( state.value.copy(
isLoading = false, isLoading = false,
isAvailable = false, isAvailable = false,
errorNotification = WallencException.Feature.NeedsDecryptedView.toUserNotification(), errorNotification = WallencException.Feature.NeedsDecryptedView().toUserNotification(),
), ),
) )
return@launch return@launch
@@ -96,7 +96,7 @@ class TwoFaTokensViewModel @Inject constructor(
if (storage.metaInfo.value.encInfo != null && !storage.isVirtualStorage) { if (storage.metaInfo.value.encInfo != null && !storage.isVirtualStorage) {
updateState( updateState(
state.value.copy( state.value.copy(
errorNotification = WallencException.Feature.NeedsDecryptedView.toUserNotification(), errorNotification = WallencException.Feature.NeedsDecryptedView().toUserNotification(),
), ),
) )
return@launch return@launch
@@ -144,7 +144,7 @@ class TwoFaTokensViewModel @Inject constructor(
if (storage.metaInfo.value.encInfo != null && !storage.isVirtualStorage) { if (storage.metaInfo.value.encInfo != null && !storage.isVirtualStorage) {
updateState( updateState(
state.value.copy( state.value.copy(
errorNotification = WallencException.Feature.NeedsDecryptedView.toUserNotification(), errorNotification = WallencException.Feature.NeedsDecryptedView().toUserNotification(),
), ),
) )
return@launch return@launch

View File

@@ -4,8 +4,7 @@ import androidx.annotation.StringRes
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
import com.github.nullptroma.wallenc.domain.datatypes.Tree import com.github.nullptroma.wallenc.domain.datatypes.Tree
import com.github.nullptroma.wallenc.domain.interfaces.IDirectory import com.github.nullptroma.wallenc.domain.errors.toWallencException
import com.github.nullptroma.wallenc.domain.interfaces.IFile
import com.github.nullptroma.wallenc.domain.interfaces.ILogger import com.github.nullptroma.wallenc.domain.interfaces.ILogger
import com.github.nullptroma.wallenc.domain.interfaces.IStorage import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
@@ -14,19 +13,17 @@ import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
import com.github.nullptroma.wallenc.domain.tasks.VaultTaskStep import com.github.nullptroma.wallenc.domain.tasks.VaultTaskStep
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import com.github.nullptroma.wallenc.ui.resources.UserNotification
import com.github.nullptroma.wallenc.ui.resources.toUserNotification
import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase
import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase
import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase
import com.github.nullptroma.wallenc.usecases.RemoveStorageUseCase import com.github.nullptroma.wallenc.usecases.RemoveStorageUseCase
import com.github.nullptroma.wallenc.usecases.RenameStorageUseCase import com.github.nullptroma.wallenc.usecases.RenameStorageUseCase
import com.github.nullptroma.wallenc.usecases.StorageFileManagementUseCase import com.github.nullptroma.wallenc.usecases.StorageFileManagementUseCase
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.ui.extensions.toPrintable
import com.github.nullptroma.wallenc.domain.errors.toWallencException
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import com.github.nullptroma.wallenc.ui.resources.UserNotification
import com.github.nullptroma.wallenc.ui.resources.toUserNotification
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@@ -36,7 +33,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID import java.util.UUID
import kotlin.system.measureTimeMillis
/** /**
* Общая логика дерева storages для локального и удалённого vault (presentation). * Общая логика дерева storages для локального и удалённого vault (presentation).
@@ -153,42 +149,6 @@ abstract class AbstractVaultBrowserViewModel(
} }
} }
fun printStorageInfoToLog(storage: IStorageInfo) {
val id = storage.uuid
if (isStorageTaskActive(id)) {
notifyUser(R.string.vault_msg_storage_pipeline_busy)
return
}
taskOrchestrator.enqueue(
title = uiStrings(R.string.task_title_dump_storage_log),
dispatcher = Dispatchers.IO,
busyStorageUuid = id,
work = { ctx ->
ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.DumpStorageLog))
storageFileManagementUseCase.setStorage(storage)
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_enumerating))
val files: List<IFile>
val dirs: List<IDirectory>
val time = measureTimeMillis {
files = storageFileManagementUseCase.getAllFiles()
dirs = storageFileManagementUseCase.getAllDirs()
}
for (file in files) {
logger.debug("Files", file.metaInfo.toString())
}
for (dir in dirs) {
logger.debug("Dirs", dir.metaInfo.toString())
}
logger.debug("Time", "Time: $time ms")
logger.debug("Storage", storage.toPrintable())
ctx.log(
TaskLogLevel.Info,
uiStrings(R.string.task_log_enumerate_done, files.size, dirs.size, time),
)
},
)
}
fun createStorage() { fun createStorage() {
if (!state.value.addStorageFabEnabled) { if (!state.value.addStorageFabEnabled) {
logger.debug(TAG, "createStorage ignored (vault unavailable or FAB disabled)") logger.debug(TAG, "createStorage ignored (vault unavailable or FAB disabled)")

View File

@@ -69,10 +69,9 @@ fun VaultBrowserScreen(
null -> null null -> null
} }
LaunchedEffect(notificationText) { LaunchedEffect(notificationText) {
if (notificationText != null) { val text = notificationText ?: return@LaunchedEffect
Toast.makeText(context, notificationText, Toast.LENGTH_SHORT).show() Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
pendingNotification = null pendingNotification = null
}
} }
val fabEnabled = uiState.addStorageFabEnabled val fabEnabled = uiState.addStorageFabEnabled

View File

@@ -366,11 +366,9 @@ fun StorageSyncScreen(
confirmButton = { confirmButton = {
Button( Button(
onClick = { onClick = {
val groupId = pendingRemoveGroupId val groupId = pendingRemoveGroupId ?: return@Button
viewModel.removeGroup(groupId)
pendingRemoveGroupId = null pendingRemoveGroupId = null
if (groupId != null) {
viewModel.removeGroup(groupId)
}
}, },
) { ) {
Text(text = stringResource(id = R.string.sync_confirm_delete)) Text(text = stringResource(id = R.string.sync_confirm_delete))
@@ -399,11 +397,9 @@ fun StorageSyncScreen(
confirmButton = { confirmButton = {
Button( Button(
onClick = { onClick = {
val payload = pendingRemoveStorage val payload = pendingRemoveStorage ?: return@Button
viewModel.removeStorageFromGroup(payload.first, payload.second)
pendingRemoveStorage = null pendingRemoveStorage = null
if (payload != null) {
viewModel.removeStorageFromGroup(payload.first, payload.second)
}
}, },
) { ) {
Text(text = stringResource(id = R.string.sync_confirm_delete)) Text(text = stringResource(id = R.string.sync_confirm_delete))

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="sync_progress_preparing">
<item quantity="one">Синхронизация: подготовка %d группы</item>
<item quantity="few">Синхронизация: подготовка %d групп</item>
<item quantity="many">Синхронизация: подготовка %d групп</item>
<item quantity="other">Синхронизация: подготовка %d групп</item>
</plurals>
<plurals name="sync_progress_group_processing">
<item quantity="one">Синхронизация: группа «%1$s» — обработка %2$d записи</item>
<item quantity="few">Синхронизация: группа «%1$s» — обработка %2$d записей</item>
<item quantity="many">Синхронизация: группа «%1$s» — обработка %2$d записей</item>
<item quantity="other">Синхронизация: группа «%1$s» — обработка %2$d записей</item>
</plurals>
</resources>

View File

@@ -15,13 +15,10 @@
<string name="sync_progress_section_title">Синхронизация хранилищ</string> <string name="sync_progress_section_title">Синхронизация хранилищ</string>
<string name="sync_groups_busy_section_title">Сохранение групп синхронизации</string> <string name="sync_groups_busy_section_title">Сохранение групп синхронизации</string>
<string name="sync_run_now">Запустить синхронизацию</string> <string name="sync_run_now">Запустить синхронизацию</string>
<string name="sync_cd_run_now">Запустить синхронизацию сейчас</string>
<string name="sync_refresh">Обновить</string>
<string name="sync_add_storage">Добавить хранилище в группу</string> <string name="sync_add_storage">Добавить хранилище в группу</string>
<string name="sync_remove_group">Удалить группу</string> <string name="sync_remove_group">Удалить группу</string>
<string name="sync_group_empty">В группе нет хранилищ</string> <string name="sync_group_empty">В группе нет хранилищ</string>
<string name="sync_remove_storage">Убрать хранилище из группы</string> <string name="sync_remove_storage">Убрать хранилище из группы</string>
<string name="sync_picker_back">Назад</string>
<string name="sync_cd_picker_back">Закрыть выбор хранилища</string> <string name="sync_cd_picker_back">Закрыть выбор хранилища</string>
<string name="sync_picker_title">Выбор хранилища для %1$s</string> <string name="sync_picker_title">Выбор хранилища для %1$s</string>
<string name="sync_picker_add">Добавить</string> <string name="sync_picker_add">Добавить</string>
@@ -31,7 +28,6 @@
<string name="sync_picker_expand">Развернуть</string> <string name="sync_picker_expand">Развернуть</string>
<string name="sync_picker_collapse">Свернуть</string> <string name="sync_picker_collapse">Свернуть</string>
<string name="sync_fab_create_group_cd">Создать группу синхронизации</string> <string name="sync_fab_create_group_cd">Создать группу синхронизации</string>
<string name="sync_group_mixed_encryption_warning">В группе разное шифрование: задайте единый режим</string>
<string name="sync_group_incompatible_warning">Несовместимые хранилища в группе: %1$d</string> <string name="sync_group_incompatible_warning">Несовместимые хранилища в группе: %1$d</string>
<string name="sync_group_policy_line">Политика шифрования группы: %1$s</string> <string name="sync_group_policy_line">Политика шифрования группы: %1$s</string>
<string name="sync_group_policy_unset">Не определена (группа пуста)</string> <string name="sync_group_policy_unset">Не определена (группа пуста)</string>
@@ -51,8 +47,6 @@
<string name="sync_msg_only_plain_storage_allowed">В группы синхронизации можно добавлять только незашифрованные хранилища</string> <string name="sync_msg_only_plain_storage_allowed">В группы синхронизации можно добавлять только незашифрованные хранилища</string>
<string name="sync_msg_storage_encryption_key_required">Для зашифрованного хранилища нужно знать пароль (откройте его перед добавлением)</string> <string name="sync_msg_storage_encryption_key_required">Для зашифрованного хранилища нужно знать пароль (откройте его перед добавлением)</string>
<string name="sync_msg_storage_incompatible_encryption">Хранилище не совместимо с политикой шифрования группы</string> <string name="sync_msg_storage_incompatible_encryption">Хранилище не совместимо с политикой шифрования группы</string>
<string name="sync_msg_virtual_storage_not_supported">Нельзя добавлять открытое виртуальное хранилище: синхронизация работает с исходными raw storage</string>
<string name="sync_msg_task_enqueued">Задача синхронизации поставлена в очередь</string>
<string name="sync_msg_sync_already_running">Синхронизация уже выполняется</string> <string name="sync_msg_sync_already_running">Синхронизация уже выполняется</string>
<string name="sync_msg_blocked_during_sync">Дождитесь окончания синхронизации</string> <string name="sync_msg_blocked_during_sync">Дождитесь окончания синхронизации</string>
<string name="sync_encryption_unknown">Неизвестно</string> <string name="sync_encryption_unknown">Неизвестно</string>
@@ -208,7 +202,6 @@
<string name="enc_status_not_encrypted">Не зашифровано</string> <string name="enc_status_not_encrypted">Не зашифровано</string>
<string name="enc_status_encrypted_open">Зашифровано (открыто)</string> <string name="enc_status_encrypted_open">Зашифровано (открыто)</string>
<string name="enc_status_encrypted">Зашифровано</string> <string name="enc_status_encrypted">Зашифровано</string>
<string name="text_edit_screen_title">Текст</string>
<string name="text_edit_screen_placeholder">Содержимое: %1$s</string> <string name="text_edit_screen_placeholder">Содержимое: %1$s</string>
<string name="storage_home_unnamed_storage">Storage</string> <string name="storage_home_unnamed_storage">Storage</string>
<string name="storage_home_status_line">Статус: %1$s, %2$s</string> <string name="storage_home_status_line">Статус: %1$s, %2$s</string>
@@ -217,10 +210,8 @@
<string name="storage_home_status_encrypted">зашифровано</string> <string name="storage_home_status_encrypted">зашифровано</string>
<string name="storage_home_status_not_encrypted">не зашифровано</string> <string name="storage_home_status_not_encrypted">не зашифровано</string>
<string name="storage_home_two_fa_title">2FA токены (%1$d)</string> <string name="storage_home_two_fa_title">2FA токены (%1$d)</string>
<string name="storage_home_open_two_fa">Открыть 2FA</string>
<string name="storage_home_two_fa_subtitle">Коды и секреты двухфакторной аутентификации</string> <string name="storage_home_two_fa_subtitle">Коды и секреты двухфакторной аутентификации</string>
<string name="storage_home_text_secrets_title">Текстовые секреты (%1$d)</string> <string name="storage_home_text_secrets_title">Текстовые секреты (%1$d)</string>
<string name="storage_home_open_text_secrets">Открыть текстовые секреты</string>
<string name="storage_home_text_secrets_subtitle">Заметки, токены и произвольные пары ключ-значение</string> <string name="storage_home_text_secrets_subtitle">Заметки, токены и произвольные пары ключ-значение</string>
<string name="storage_home_future_sections">Скоро здесь появятся Files, Media и другие типы данных.</string> <string name="storage_home_future_sections">Скоро здесь появятся Files, Media и другие типы данных.</string>
<string name="two_fa_add_token">Добавить токен</string> <string name="two_fa_add_token">Добавить токен</string>
@@ -231,17 +222,13 @@
<string name="two_fa_field_account">Аккаунт</string> <string name="two_fa_field_account">Аккаунт</string>
<string name="two_fa_field_secret">Секрет</string> <string name="two_fa_field_secret">Секрет</string>
<string name="two_fa_field_notes_optional">Заметка (опционально)</string> <string name="two_fa_field_notes_optional">Заметка (опционально)</string>
<string name="two_fa_field_digits">Количество цифр кода (обычно 6 или 8)</string>
<string name="two_fa_field_period_seconds">Период обновления в секундах (обычно 30)</string>
<string name="two_fa_field_algorithm">Алгоритм (SHA1, SHA256, SHA512)</string> <string name="two_fa_field_algorithm">Алгоритм (SHA1, SHA256, SHA512)</string>
<string name="two_fa_field_digits_value">Количество цифр: %1$d</string> <string name="two_fa_field_digits_value">Количество цифр: %1$d</string>
<string name="two_fa_field_period_seconds_value">Период обновления: %1$d с</string> <string name="two_fa_field_period_seconds_value">Период обновления: %1$d с</string>
<string name="two_fa_code_unavailable">------</string> <string name="two_fa_code_unavailable">------</string>
<string name="two_fa_code_refresh_in">Обновление через %1$d с</string>
<string name="two_fa_code_refresh_label">Обновление через</string> <string name="two_fa_code_refresh_label">Обновление через</string>
<string name="two_fa_code_refresh_seconds">%1$d с</string> <string name="two_fa_code_refresh_seconds">%1$d с</string>
<string name="two_fa_code_invalid_secret">Неверный секрет или формат</string> <string name="two_fa_code_invalid_secret">Неверный секрет или формат</string>
<string name="two_fa_copy_code_hint">Нажмите, чтобы скопировать код</string>
<string name="two_fa_scan_qr_action">Сканировать QR</string> <string name="two_fa_scan_qr_action">Сканировать QR</string>
<string name="two_fa_scan_qr_title">Сканирование QR-кода TOTP</string> <string name="two_fa_scan_qr_title">Сканирование QR-кода TOTP</string>
<string name="two_fa_scan_qr_invalid">QR-код не содержит валидный otpauth://totp URI</string> <string name="two_fa_scan_qr_invalid">QR-код не содержит валидный otpauth://totp URI</string>
@@ -259,9 +246,7 @@
<string name="text_secret_copy_value">Скопировать значение</string> <string name="text_secret_copy_value">Скопировать значение</string>
<string name="save">Сохранить</string> <string name="save">Сохранить</string>
<string name="cancel">Отмена</string> <string name="cancel">Отмена</string>
<string name="open">Открыть</string>
<string name="edit">Редактировать</string> <string name="edit">Редактировать</string>
<string name="common_unknown">Неизвестно</string>
<string name="settings_language_section">Язык</string> <string name="settings_language_section">Язык</string>
<string name="settings_language_system">Как в системе</string> <string name="settings_language_system">Как в системе</string>
<string name="settings_language_english">English</string> <string name="settings_language_english">English</string>
@@ -269,7 +254,6 @@
<string name="task_pipeline_test_elapsed">Прошло: %1$d с / %2$d с</string> <string name="task_pipeline_test_elapsed">Прошло: %1$d с / %2$d с</string>
<string name="text_secret_clipboard_fallback_label">значение</string> <string name="text_secret_clipboard_fallback_label">значение</string>
<string name="sync_progress_no_groups">Синхронизация: группы не настроены</string> <string name="sync_progress_no_groups">Синхронизация: группы не настроены</string>
<string name="sync_progress_preparing">Синхронизация: подготовка %1$d групп</string>
<string name="sync_progress_started">Синхронизация: запущена</string> <string name="sync_progress_started">Синхронизация: запущена</string>
<string name="sync_progress_completed">Синхронизация: завершена</string> <string name="sync_progress_completed">Синхронизация: завершена</string>
<string name="sync_progress_group_preparing">Синхронизация: группа «%1$s» — подготовка</string> <string name="sync_progress_group_preparing">Синхронизация: группа «%1$s» — подготовка</string>
@@ -283,7 +267,6 @@
<string name="sync_progress_group_cancelled">Синхронизация: группа «%1$s» отменена новым запуском</string> <string name="sync_progress_group_cancelled">Синхронизация: группа «%1$s» отменена новым запуском</string>
<string name="sync_progress_group_journal">Синхронизация: группа «%1$s» — журнал %2$d/%3$d</string> <string name="sync_progress_group_journal">Синхронизация: группа «%1$s» — журнал %2$d/%3$d</string>
<string name="sync_progress_group_no_entries">Синхронизация: группа «%1$s» — нет записей в журнале</string> <string name="sync_progress_group_no_entries">Синхронизация: группа «%1$s» — нет записей в журнале</string>
<string name="sync_progress_group_processing">Синхронизация: группа «%1$s» — обработка %2$d записей</string>
<string name="sync_progress_group_entry">Синхронизация: группа «%1$s» — запись %2$d/%3$d</string> <string name="sync_progress_group_entry">Синхронизация: группа «%1$s» — запись %2$d/%3$d</string>
<string name="sync_progress_group_completed">Синхронизация: группа «%1$s» завершена</string> <string name="sync_progress_group_completed">Синхронизация: группа «%1$s» завершена</string>
<string name="sync_progress_group_renewing_locks">Синхронизация: группа «%1$s» — продление блокировок</string> <string name="sync_progress_group_renewing_locks">Синхронизация: группа «%1$s» — продление блокировок</string>
@@ -293,7 +276,6 @@
<string name="task_log_sync_finished">Синхронизация хранилищ завершена</string> <string name="task_log_sync_finished">Синхронизация хранилищ завершена</string>
<string name="task_log_sync_failed">Синхронизация не удалась: %1$s</string> <string name="task_log_sync_failed">Синхронизация не удалась: %1$s</string>
<string name="task_log_enumerating">Перечисление файлов и папок…</string> <string name="task_log_enumerating">Перечисление файлов и папок…</string>
<string name="task_log_enumerate_done">Готово: %1$d файлов, %2$d папок за %3$d мс (подробности в журнале приложения)</string>
<string name="task_log_creating_storage">Создание хранилища…</string> <string name="task_log_creating_storage">Создание хранилища…</string>
<string name="task_log_storage_created">Хранилище создано</string> <string name="task_log_storage_created">Хранилище создано</string>
<string name="task_log_checking_storage">Проверка хранилища…</string> <string name="task_log_checking_storage">Проверка хранилища…</string>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="sync_progress_preparing">
<item quantity="one">Storage sync: preparing %d group</item>
<item quantity="other">Storage sync: preparing %d groups</item>
</plurals>
<plurals name="sync_progress_group_processing">
<item quantity="one">Storage sync: group "%1$s" processing %2$d entry</item>
<item quantity="other">Storage sync: group "%1$s" processing %2$d entries</item>
</plurals>
</resources>

View File

@@ -15,13 +15,10 @@
<string name="sync_progress_section_title">Storage sync</string> <string name="sync_progress_section_title">Storage sync</string>
<string name="sync_groups_busy_section_title">Saving sync groups</string> <string name="sync_groups_busy_section_title">Saving sync groups</string>
<string name="sync_run_now">Run sync now</string> <string name="sync_run_now">Run sync now</string>
<string name="sync_cd_run_now">Run sync now</string>
<string name="sync_refresh">Refresh</string>
<string name="sync_add_storage">Add storage to group</string> <string name="sync_add_storage">Add storage to group</string>
<string name="sync_remove_group">Remove group</string> <string name="sync_remove_group">Remove group</string>
<string name="sync_group_empty">No storages in this group</string> <string name="sync_group_empty">No storages in this group</string>
<string name="sync_remove_storage">Remove storage from group</string> <string name="sync_remove_storage">Remove storage from group</string>
<string name="sync_picker_back">Back</string>
<string name="sync_cd_picker_back">Close storage picker</string> <string name="sync_cd_picker_back">Close storage picker</string>
<string name="sync_picker_title">Pick storage for %1$s</string> <string name="sync_picker_title">Pick storage for %1$s</string>
<string name="sync_picker_add">Add</string> <string name="sync_picker_add">Add</string>
@@ -31,7 +28,6 @@
<string name="sync_picker_expand">Expand</string> <string name="sync_picker_expand">Expand</string>
<string name="sync_picker_collapse">Collapse</string> <string name="sync_picker_collapse">Collapse</string>
<string name="sync_fab_create_group_cd">Create sync group</string> <string name="sync_fab_create_group_cd">Create sync group</string>
<string name="sync_group_mixed_encryption_warning">Mixed encryption in group: set a single mode</string>
<string name="sync_group_incompatible_warning">Incompatible storages in group: %1$d</string> <string name="sync_group_incompatible_warning">Incompatible storages in group: %1$d</string>
<string name="sync_group_policy_line">Group encryption policy: %1$s</string> <string name="sync_group_policy_line">Group encryption policy: %1$s</string>
<string name="sync_group_policy_unset">Not set (group is empty)</string> <string name="sync_group_policy_unset">Not set (group is empty)</string>
@@ -51,8 +47,6 @@
<string name="sync_msg_only_plain_storage_allowed">Only unencrypted storages can be added to sync groups</string> <string name="sync_msg_only_plain_storage_allowed">Only unencrypted storages can be added to sync groups</string>
<string name="sync_msg_storage_encryption_key_required">Encrypted storage requires the password (open it before adding)</string> <string name="sync_msg_storage_encryption_key_required">Encrypted storage requires the password (open it before adding)</string>
<string name="sync_msg_storage_incompatible_encryption">Storage is not compatible with the group encryption policy</string> <string name="sync_msg_storage_incompatible_encryption">Storage is not compatible with the group encryption policy</string>
<string name="sync_msg_virtual_storage_not_supported">Cannot add an open virtual storage: sync works with raw storages</string>
<string name="sync_msg_task_enqueued">Sync task queued</string>
<string name="sync_msg_sync_already_running">Sync is already running</string> <string name="sync_msg_sync_already_running">Sync is already running</string>
<string name="sync_msg_blocked_during_sync">Wait for sync to finish</string> <string name="sync_msg_blocked_during_sync">Wait for sync to finish</string>
<string name="sync_encryption_unknown">Unknown</string> <string name="sync_encryption_unknown">Unknown</string>
@@ -208,7 +202,6 @@
<string name="enc_status_not_encrypted">Not encrypted</string> <string name="enc_status_not_encrypted">Not encrypted</string>
<string name="enc_status_encrypted_open">Encrypted (open)</string> <string name="enc_status_encrypted_open">Encrypted (open)</string>
<string name="enc_status_encrypted">Encrypted</string> <string name="enc_status_encrypted">Encrypted</string>
<string name="text_edit_screen_title">Text</string>
<string name="text_edit_screen_placeholder">Content: %1$s</string> <string name="text_edit_screen_placeholder">Content: %1$s</string>
<string name="storage_home_unnamed_storage">Storage</string> <string name="storage_home_unnamed_storage">Storage</string>
<string name="storage_home_status_line">Status: %1$s, %2$s</string> <string name="storage_home_status_line">Status: %1$s, %2$s</string>
@@ -217,10 +210,8 @@
<string name="storage_home_status_encrypted">encrypted</string> <string name="storage_home_status_encrypted">encrypted</string>
<string name="storage_home_status_not_encrypted">not encrypted</string> <string name="storage_home_status_not_encrypted">not encrypted</string>
<string name="storage_home_two_fa_title">2FA tokens (%1$d)</string> <string name="storage_home_two_fa_title">2FA tokens (%1$d)</string>
<string name="storage_home_open_two_fa">Open 2FA</string>
<string name="storage_home_two_fa_subtitle">Two-factor authentication codes and secrets</string> <string name="storage_home_two_fa_subtitle">Two-factor authentication codes and secrets</string>
<string name="storage_home_text_secrets_title">Text secrets (%1$d)</string> <string name="storage_home_text_secrets_title">Text secrets (%1$d)</string>
<string name="storage_home_open_text_secrets">Open text secrets</string>
<string name="storage_home_text_secrets_subtitle">Notes, tokens, and arbitrary key-value pairs</string> <string name="storage_home_text_secrets_subtitle">Notes, tokens, and arbitrary key-value pairs</string>
<string name="storage_home_future_sections">Files, Media, and more will appear here soon.</string> <string name="storage_home_future_sections">Files, Media, and more will appear here soon.</string>
<string name="two_fa_add_token">Add token</string> <string name="two_fa_add_token">Add token</string>
@@ -231,17 +222,13 @@
<string name="two_fa_field_account">Account</string> <string name="two_fa_field_account">Account</string>
<string name="two_fa_field_secret">Secret</string> <string name="two_fa_field_secret">Secret</string>
<string name="two_fa_field_notes_optional">Note (optional)</string> <string name="two_fa_field_notes_optional">Note (optional)</string>
<string name="two_fa_field_digits">Code digits (usually 6 or 8)</string>
<string name="two_fa_field_period_seconds">Refresh period in seconds (usually 30)</string>
<string name="two_fa_field_algorithm">Algorithm (SHA1, SHA256, SHA512)</string> <string name="two_fa_field_algorithm">Algorithm (SHA1, SHA256, SHA512)</string>
<string name="two_fa_field_digits_value">Digits: %1$d</string> <string name="two_fa_field_digits_value">Digits: %1$d</string>
<string name="two_fa_field_period_seconds_value">Refresh period: %1$d s</string> <string name="two_fa_field_period_seconds_value">Refresh period: %1$d s</string>
<string name="two_fa_code_unavailable">------</string> <string name="two_fa_code_unavailable">------</string>
<string name="two_fa_code_refresh_in">Refresh in %1$d s</string>
<string name="two_fa_code_refresh_label">Refresh in</string> <string name="two_fa_code_refresh_label">Refresh in</string>
<string name="two_fa_code_refresh_seconds">%1$d s</string> <string name="two_fa_code_refresh_seconds">%1$d s</string>
<string name="two_fa_code_invalid_secret">Invalid secret or format</string> <string name="two_fa_code_invalid_secret">Invalid secret or format</string>
<string name="two_fa_copy_code_hint">Tap to copy code</string>
<string name="two_fa_scan_qr_action">Scan QR</string> <string name="two_fa_scan_qr_action">Scan QR</string>
<string name="two_fa_scan_qr_title">Scan TOTP QR code</string> <string name="two_fa_scan_qr_title">Scan TOTP QR code</string>
<string name="two_fa_scan_qr_invalid">QR code does not contain a valid otpauth://totp URI</string> <string name="two_fa_scan_qr_invalid">QR code does not contain a valid otpauth://totp URI</string>
@@ -259,9 +246,7 @@
<string name="text_secret_copy_value">Copy value</string> <string name="text_secret_copy_value">Copy value</string>
<string name="save">Save</string> <string name="save">Save</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<string name="open">Open</string>
<string name="edit">Edit</string> <string name="edit">Edit</string>
<string name="common_unknown">Unknown</string>
<string name="settings_language_section">Language</string> <string name="settings_language_section">Language</string>
<string name="settings_language_system">System default</string> <string name="settings_language_system">System default</string>
<string name="settings_language_english">English</string> <string name="settings_language_english">English</string>
@@ -269,7 +254,6 @@
<string name="task_pipeline_test_elapsed">Elapsed: %1$d s / %2$d s</string> <string name="task_pipeline_test_elapsed">Elapsed: %1$d s / %2$d s</string>
<string name="text_secret_clipboard_fallback_label">value</string> <string name="text_secret_clipboard_fallback_label">value</string>
<string name="sync_progress_no_groups">Storage sync: no groups configured</string> <string name="sync_progress_no_groups">Storage sync: no groups configured</string>
<string name="sync_progress_preparing">Storage sync: preparing %1$d groups</string>
<string name="sync_progress_started">Storage sync: started</string> <string name="sync_progress_started">Storage sync: started</string>
<string name="sync_progress_completed">Storage sync: completed</string> <string name="sync_progress_completed">Storage sync: completed</string>
<string name="sync_progress_group_preparing">Storage sync: group "%1$s" preparing</string> <string name="sync_progress_group_preparing">Storage sync: group "%1$s" preparing</string>
@@ -283,7 +267,6 @@
<string name="sync_progress_group_cancelled">Storage sync: group "%1$s" cancelled by newer run</string> <string name="sync_progress_group_cancelled">Storage sync: group "%1$s" cancelled by newer run</string>
<string name="sync_progress_group_journal">Storage sync: group "%1$s" journal %2$d/%3$d</string> <string name="sync_progress_group_journal">Storage sync: group "%1$s" journal %2$d/%3$d</string>
<string name="sync_progress_group_no_entries">Storage sync: group "%1$s" no journal entries</string> <string name="sync_progress_group_no_entries">Storage sync: group "%1$s" no journal entries</string>
<string name="sync_progress_group_processing">Storage sync: group "%1$s" processing %2$d entries</string>
<string name="sync_progress_group_entry">Storage sync: group "%1$s" entry %2$d/%3$d</string> <string name="sync_progress_group_entry">Storage sync: group "%1$s" entry %2$d/%3$d</string>
<string name="sync_progress_group_completed">Storage sync: group "%1$s" completed</string> <string name="sync_progress_group_completed">Storage sync: group "%1$s" completed</string>
<string name="sync_progress_group_renewing_locks">Storage sync: group "%1$s" renewing locks</string> <string name="sync_progress_group_renewing_locks">Storage sync: group "%1$s" renewing locks</string>
@@ -293,7 +276,6 @@
<string name="task_log_sync_finished">Storage sync finished</string> <string name="task_log_sync_finished">Storage sync finished</string>
<string name="task_log_sync_failed">Storage sync failed: %1$s</string> <string name="task_log_sync_failed">Storage sync failed: %1$s</string>
<string name="task_log_enumerating">Enumerating files and directories…</string> <string name="task_log_enumerating">Enumerating files and directories…</string>
<string name="task_log_enumerate_done">Done: %1$d files, %2$d dirs in %3$d ms (see app log for lines)</string>
<string name="task_log_creating_storage">Creating storage…</string> <string name="task_log_creating_storage">Creating storage…</string>
<string name="task_log_storage_created">Storage created</string> <string name="task_log_storage_created">Storage created</string>
<string name="task_log_checking_storage">Checking storage…</string> <string name="task_log_checking_storage">Checking storage…</string>

View File

@@ -8,7 +8,11 @@ import org.junit.Test
class TaskProgressLabelsTest { class TaskProgressLabelsTest {
private val resolver = UiStringResolver { id, _ -> "res:$id" } private val resolver = object : UiStringResolver {
override fun invoke(id: Int, vararg formatArgs: Any): String = "res:$id"
override fun plurals(id: Int, quantity: Int, vararg formatArgs: Any): String = "plural:$id"
}
@Test @Test
fun syncNoGroups_mapsToStringRes() { fun syncNoGroups_mapsToStringRes() {

View File

@@ -9,13 +9,13 @@ class WallencUserNotificationMappingTest {
@Test @Test
fun mapsFeatureStorageNotFound() { fun mapsFeatureStorageNotFound() {
val notification = WallencException.Feature.StorageNotFound.toUserNotification() val notification = WallencException.Feature.StorageNotFound().toUserNotification()
assertEquals(R.string.error_storage_not_found, notification.id) assertEquals(R.string.error_storage_not_found, notification.id)
} }
@Test @Test
fun mapsStorageIncorrectKey() { fun mapsStorageIncorrectKey() {
val notification = WallencException.Storage.IncorrectKey.toUserNotification() val notification = WallencException.Storage.IncorrectKey().toUserNotification()
assertEquals(R.string.error_incorrect_password, notification.id) assertEquals(R.string.error_incorrect_password, notification.id)
} }

View File

@@ -25,6 +25,7 @@ class TaskPipelineViewModelTest {
val uiStrings = mockk<UiStringResolver>() val uiStrings = mockk<UiStringResolver>()
every { uiStrings.invoke(any<Int>(), any()) } returns "Test task" every { uiStrings.invoke(any<Int>(), any()) } returns "Test task"
every { uiStrings.invoke(any<Int>()) } returns "Test" every { uiStrings.invoke(any<Int>()) } returns "Test"
every { uiStrings.plurals(any(), any(), any()) } returns "Plural"
val viewModel = TaskPipelineViewModel(orchestrator, uiStrings) val viewModel = TaskPipelineViewModel(orchestrator, uiStrings)
viewModel.startTestTask(durationSec = 0, infinityIndeterminateProgress = false) viewModel.startTestTask(durationSec = 0, infinityIndeterminateProgress = false)

View File

@@ -14,7 +14,7 @@ kotlin {
} }
dependencies { dependencies {
compileOnly("javax.inject:javax.inject:1") compileOnly(libs.javax.inject)
implementation(project(":domain")) implementation(project(":domain"))
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)

View File

@@ -67,11 +67,4 @@ class ManageStoragesEncryptionUseCase @Inject constructor(
unlockManager.close(storage) unlockManager.close(storage)
} }
suspend fun changePassword(storage: IStorageInfo, newKey: EncryptKey, encryptPath: Boolean) {
if (storage !is IStorage) return
if (storage.metaInfo.value.encInfo == null) {
throw IllegalStateException("Storage is not encrypted")
}
storage.setEncInfo(Encryptor.generateEncryptionInfo(newKey, encryptPath))
}
} }

View File

@@ -34,7 +34,6 @@ class RunStorageSyncUseCase @Inject constructor(
* @param logReason техническая метка для логов (не для UI) * @param logReason техническая метка для логов (не для UI)
* @return false, если синхронизация уже в очереди или выполняется — новая задача не создана * @return false, если синхронизация уже в очереди или выполняется — новая задача не создана
*/ */
@Suppress("UNUSED_PARAMETER")
fun enqueue(displayTitle: String, logReason: String): Boolean { fun enqueue(displayTitle: String, logReason: String): Boolean {
if (!running.compareAndSet(false, true)) { if (!running.compareAndSet(false, true)) {
return false return false

View File

@@ -4,12 +4,8 @@ import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
import com.github.nullptroma.wallenc.domain.interfaces.IStorage import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroup import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroup
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind
import java.util.Base64
import java.util.UUID import java.util.UUID
fun storageEncryptionSecret(key: EncryptKey): String =
Base64.getEncoder().encodeToString(key.bytes)
fun isStorageCompatibleWithGroup( fun isStorageCompatibleWithGroup(
storage: IStorage, storage: IStorage,
group: StorageSyncGroup, group: StorageSyncGroup,

View File

@@ -3,7 +3,6 @@ package com.github.nullptroma.wallenc.usecases
import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton

View File

@@ -16,7 +16,3 @@ val List<DescribedVault>.locals: List<DescribedVault>
val List<DescribedVault>.remotes: List<DescribedVault> val List<DescribedVault>.remotes: List<DescribedVault>
get() = filter { it.descriptor is VaultDescriptor.LinkedRemote } get() = filter { it.descriptor is VaultDescriptor.LinkedRemote }
fun List<DescribedVault>.byBrand(brand: CloudBrand): List<DescribedVault> = filter {
(it.descriptor as? VaultDescriptor.LinkedRemote)?.brand == brand
}