diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/auth/YandexSignInService.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/auth/YandexSignInService.kt index 78920ff..191471d 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/auth/YandexSignInService.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/auth/YandexSignInService.kt @@ -6,6 +6,7 @@ import androidx.activity.result.ActivityResultLauncher import com.github.nullptroma.wallenc.domain.vault.vaults.yandex.YandexRegistration import com.github.nullptroma.wallenc.vault.contract.CloudBrand import com.github.nullptroma.wallenc.vault.contract.RemoteVaultAuthenticator +import com.github.nullptroma.wallenc.vault.contract.VaultLinkFailure import com.github.nullptroma.wallenc.vault.contract.VaultLinkOutcome import com.yandex.authsdk.YandexAuthLoginOptions import com.yandex.authsdk.YandexAuthOptions @@ -70,16 +71,12 @@ class YandexSignInService @Inject constructor( override fun beginLink(brand: CloudBrand, onResult: (VaultLinkOutcome) -> Unit) { if (brand != CloudBrand.YANDEX) { - onResult(VaultLinkOutcome.Failed("Brand $brand is not supported by YandexSignInService")) + onResult(VaultLinkOutcome.Failed(VaultLinkFailure.UnsupportedBrand)) return } val l = launcher if (l == null) { - onResult( - VaultLinkOutcome.Failed( - "YandexSignInService: call registerWith(activity) from MainActivity.onCreate first", - ), - ) + onResult(VaultLinkOutcome.Failed(VaultLinkFailure.NotRegistered)) return } synchronized(this) { pending = onResult } @@ -90,9 +87,7 @@ class YandexSignInService @Inject constructor( is YandexAuthResult.Success -> VaultLinkOutcome.Success(YandexRegistration(result.token.value)) is YandexAuthResult.Failure -> - VaultLinkOutcome.Failed( - result.exception.message ?: result.exception.toString(), - ) + VaultLinkOutcome.Failed(VaultLinkFailure.AuthError) YandexAuthResult.Cancelled -> VaultLinkOutcome.Cancelled } } diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/errors/VaultThrowableMapping.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/errors/VaultThrowableMapping.kt new file mode 100644 index 0000000..ab7ca0c --- /dev/null +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/errors/VaultThrowableMapping.kt @@ -0,0 +1,54 @@ +package com.github.nullptroma.wallenc.domain.vault.errors + +import com.github.nullptroma.wallenc.domain.errors.WallencException +import com.github.nullptroma.wallenc.domain.errors.toWallencException +import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.YandexDiskAuthException +import retrofit2.HttpException +import java.io.FileNotFoundException +import java.io.IOException + +fun Throwable.toVaultWallencException(): WallencException = when (this) { + is WallencException -> this + is YandexDiskAuthException -> WallencException.Auth.Failed + is HttpException -> WallencException.Network.HttpFailed( + operation = "http", + statusCode = code(), + cause = this, + ) + is FileNotFoundException -> WallencException.Storage.FileNotFound + is IOException -> mapVaultIo(this) + is IllegalStateException -> mapIllegalState(this) + is IllegalArgumentException -> mapIllegalArgument(this) + else -> toWallencException() +} + +private fun mapVaultIo(e: IOException): WallencException { + val msg = e.message.orEmpty() + return when { + msg.contains("OAuth token is missing", ignoreCase = true) -> + WallencException.Auth.TokenMissing + msg.contains("HTTP 423", ignoreCase = true) || msg.contains("423 after retries", ignoreCase = true) -> + WallencException.Network.ResourceLocked + msg.contains("async operation timed out", ignoreCase = true) -> + WallencException.Network.OperationTimedOut + msg.contains("async operation failed", ignoreCase = true) -> + WallencException.Network.OperationFailed + else -> WallencException.Network.IoFailed(e) + } +} + +private fun mapIllegalState(e: IllegalStateException): WallencException = when { + e.message == "Not a file" -> WallencException.Storage.NotAFile + e.message == "Not a directory" -> WallencException.Storage.NotADirectory + e.message?.startsWith("Path segment is a file:") == true -> WallencException.Storage.PathIsFile + e.message?.startsWith("Cannot openWrite over directory:") == true -> + WallencException.Storage.CannotWriteOverDirectory + e.message?.startsWith("Expected file after upload:") == true -> + WallencException.Storage.UnexpectedState + else -> WallencException.Storage.UnexpectedState +} + +private fun mapIllegalArgument(e: IllegalArgumentException): WallencException = when { + e.message == "Deleting root path is forbidden" -> WallencException.Storage.DeleteRootForbidden + else -> WallencException.Unknown(e) +} diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/UnlockManager.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/UnlockManager.kt index 3a83419..452f146 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/UnlockManager.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/UnlockManager.kt @@ -1,5 +1,6 @@ package com.github.nullptroma.wallenc.domain.vault.storages +import com.github.nullptroma.wallenc.domain.errors.WallencException import com.github.nullptroma.wallenc.domain.vault.model.StorageKeyMap import com.github.nullptroma.wallenc.domain.vault.ports.StorageKeyMapStore import com.github.nullptroma.wallenc.domain.vault.storages.encrypt.EncryptedStorage @@ -109,9 +110,9 @@ class UnlockManager( rememberPassword: Boolean ): EncryptedStorage = withContext(ioDispatcher) { return@withContext mutex.withLock { - val encInfo = storage.metaInfo.value.encInfo ?: throw Exception("EncInfo is null") // TODO + val encInfo = storage.metaInfo.value.encInfo ?: throw WallencException.Storage.EncInfoMissing if (!Encryptor.checkKey(key, encInfo)) - throw Exception("Incorrect Key") + throw WallencException.Storage.IncorrectKey val opened = _openedStorages.value.toMutableMap() val cur = opened[storage.uuid] diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/encrypt/EncryptedStorage.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/encrypt/EncryptedStorage.kt index af86156..1912174 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/encrypt/EncryptedStorage.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/encrypt/EncryptedStorage.kt @@ -1,5 +1,7 @@ package com.github.nullptroma.wallenc.domain.vault.storages.encrypt +import com.github.nullptroma.wallenc.domain.errors.WallencException + import com.github.nullptroma.wallenc.domain.vault.storages.common.BaseStorage import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey import com.github.nullptroma.wallenc.domain.encrypt.Encryptor @@ -27,7 +29,7 @@ class EncryptedStorage private constructor( private val scope = CoroutineScope(ioDispatcher + job) private val encInfo = - source.metaInfo.value.encInfo ?: throw Exception("Storage is not encrypted") // TODO + source.metaInfo.value.encInfo ?: throw WallencException.Storage.NotEncrypted override val isVirtualStorage: Boolean = true @@ -50,7 +52,7 @@ class EncryptedStorage private constructor( private fun checkKey() { if (!Encryptor.checkKey(key, encInfo)) - throw Exception("Incorrect key") // TODO + throw WallencException.Storage.IncorrectKey } fun getKey(): EncryptKey = EncryptKey(key.bytes) diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/local/LocalStorageAccessor.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/local/LocalStorageAccessor.kt index bb63146..a5f7b6b 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/local/LocalStorageAccessor.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/local/LocalStorageAccessor.kt @@ -1,5 +1,7 @@ package com.github.nullptroma.wallenc.domain.vault.storages.local +import com.github.nullptroma.wallenc.domain.errors.WallencException + import com.fasterxml.jackson.core.JacksonException import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue @@ -230,7 +232,7 @@ class LocalStorageAccessor( dirCallback: (suspend (File, CommonDirectory) -> Unit)? = null ) { if (!checkAvailable()) - throw Exception("Not available") + throw WallencException.Storage.NotAvailable val basePath = Path(_filesystemBasePath.pathString, baseStoragePath) val workedFiles = mutableSetOf() val workedMetaFiles = mutableSetOf() @@ -396,7 +398,7 @@ class LocalStorageAccessor( override suspend fun getFileInfo(path: String): IFile { val pair = LocalStorageFilePair.from(_filesystemBasePath, path) - ?: throw Exception("Что то пошло не так") // TODO + ?: throw WallencException.Storage.UnexpectedState return CommonFile( metaInfo = pair.meta, ) @@ -404,7 +406,7 @@ class LocalStorageAccessor( override suspend fun getDirInfo(path: String): IDirectory { val pair = LocalStorageFilePair.from(_filesystemBasePath, path) - ?: throw Exception("Что то пошло не так") // TODO + ?: throw WallencException.Storage.UnexpectedState return CommonDirectory( metaInfo = pair.meta, elementsCount = null @@ -413,7 +415,7 @@ class LocalStorageAccessor( override suspend fun setHidden(path: String, hidden: Boolean) { val pair = LocalStorageFilePair.from(_filesystemBasePath, path) - ?: throw Exception("Что то пошло не так") // TODO + ?: throw WallencException.Storage.UnexpectedState if (pair.meta.isHidden == hidden) return val newMeta = pair.meta.copy(isHidden = hidden) @@ -440,7 +442,7 @@ class LocalStorageAccessor( val path = Path(_filesystemBasePath.pathString, storagePath) val file = path.toFile() if (file.exists() && file.isDirectory) { - throw Exception("Что то пошло не так") // TODO + throw WallencException.Storage.UnexpectedState } else if(!file.exists()) { val parent = Path(storagePath).parent createDir(parent.pathString) @@ -451,7 +453,7 @@ class LocalStorageAccessor( } val pair = LocalStorageFilePair.from(_filesystemBasePath, file) - ?: throw Exception("Что то пошло не так") // TODO + ?: throw WallencException.Storage.UnexpectedState val newMeta = pair.meta.copy(lastModified = Clock.systemUTC().instant(), size = Files.size(pair.file.toPath())) writeMeta(pair.metaFile, newMeta) _filesUpdates.emit( @@ -468,13 +470,13 @@ class LocalStorageAccessor( val path = Path(_filesystemBasePath.pathString, storagePath) val file = path.toFile() if (file.exists() && !file.isDirectory) { - throw Exception("Что то пошло не так") // TODO + throw WallencException.Storage.UnexpectedState } else if(!file.exists()) { Files.createDirectories(path) } val pair = LocalStorageFilePair.from(_filesystemBasePath, file) - ?: throw Exception("Что то пошло не так") // TODO + ?: throw WallencException.Storage.UnexpectedState val newMeta = pair.meta.copy(lastModified = Clock.systemUTC().instant()) writeMeta(pair.metaFile, newMeta) _dirsUpdates.emit( @@ -512,7 +514,7 @@ class LocalStorageAccessor( override suspend fun delete(path: String) = withContext(ioDispatcher) { if (path == "/" || path.isBlank()) { - throw IllegalArgumentException("Deleting root path is forbidden") + throw WallencException.Storage.DeleteRootForbidden } val pair = LocalStorageFilePair.from(_filesystemBasePath, path) if (pair != null) { @@ -527,7 +529,7 @@ class LocalStorageAccessor( override suspend fun openWrite(path: String): OutputStream = withContext(ioDispatcher) { touchFileInternal(path, recordJournal = false) val pair = LocalStorageFilePair.from(_filesystemBasePath, path) - ?: throw Exception("Файла нет") // TODO + ?: throw WallencException.Storage.FileNotFound return@withContext pair.file.outputStream().onClosed { CoroutineScope(ioDispatcher).launch { touchFileInternal(path, recordJournal = false) @@ -539,13 +541,13 @@ class LocalStorageAccessor( override suspend fun openRead(path: String): InputStream = withContext(ioDispatcher) { val pair = LocalStorageFilePair.from(_filesystemBasePath, path) - ?: throw Exception("Файла нет") // TODO + ?: throw WallencException.Storage.FileNotFound return@withContext pair.file.inputStream() } override suspend fun moveToTrash(path: String) = withContext(ioDispatcher) { val pair = LocalStorageFilePair.from(_filesystemBasePath, path) - ?: throw Exception("Файла нет") // TODO + ?: throw WallencException.Storage.FileNotFound val newMeta = pair.meta.copy(isDeleted = true) writeMeta(pair.metaFile, newMeta) appendSyncEntry(path = path, operation = StorageSyncOperation.DELETE) diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/yandex/YandexStorageAccessor.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/yandex/YandexStorageAccessor.kt index ce9a0b5..d939fdc 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/yandex/YandexStorageAccessor.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/storages/yandex/YandexStorageAccessor.kt @@ -1,5 +1,8 @@ package com.github.nullptroma.wallenc.domain.vault.storages.yandex +import com.github.nullptroma.wallenc.domain.errors.WallencException +import com.github.nullptroma.wallenc.domain.vault.errors.toVaultWallencException + import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.YandexDiskAuthException import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.dto.ResourceDto @@ -104,19 +107,23 @@ class YandexStorageAccessor( } catch (e: YandexDiskAuthException) { reportAuthFailure() _storageReady.value = false - throw e + throw WallencException.Auth.Failed } catch (e: Exception) { _storageReady.value = false - throw Exception("Yandex storage init failed", e) + throw e.toVaultWallencException() } } private inline fun guard(block: () -> T): T { try { return block() + } catch (e: CancellationException) { + throw e } catch (e: YandexDiskAuthException) { reportAuthFailure() - throw e + throw WallencException.Auth.Failed + } catch (e: Throwable) { + throw e.toVaultWallencException() } } @@ -445,13 +452,13 @@ class YandexStorageAccessor( override suspend fun getFileInfo(path: String): IFile = withContext(ioDispatcher) { val r = guard { repo.get(toDiskPath(path)) } - if (r.type != "file") throw IllegalStateException("Not a file") + if (r.type != "file") throw WallencException.Storage.NotAFile r.toCommonFile(path) } override suspend fun getDirInfo(path: String): IDirectory = withContext(ioDispatcher) { val r = guard { repo.get(toDiskPath(path)) } - if (r.type != "dir") throw IllegalStateException("Not a directory") + if (r.type != "dir") throw WallencException.Storage.NotADirectory r.toCommonDir(path) } @@ -490,7 +497,7 @@ class YandexStorageAccessor( val diskPath = toDiskPath(acc) when (guard { repo.getOrNull(diskPath) }?.type) { "dir" -> continue - "file" -> throw IllegalStateException("Path segment is a file: $acc") + "file" -> throw WallencException.Storage.PathIsFile else -> guard { repo.createFolder(diskPath) } } } @@ -498,7 +505,7 @@ class YandexStorageAccessor( override suspend fun delete(path: String) = withContext(ioDispatcher) { if (path == "/" || path.isBlank()) { - throw IllegalArgumentException("Deleting root path is forbidden") + throw WallencException.Storage.DeleteRootForbidden } val diskPath = toDiskPath(path) val prior = guard { repo.getOrNull(diskPath) } @@ -531,14 +538,14 @@ class YandexStorageAccessor( val diskPath = toDiskPath(path) val prior = guard { repo.getOrNull(diskPath) } if (prior?.type == "dir") { - throw IllegalStateException("Cannot openWrite over directory: $path") + throw WallencException.Storage.CannotWriteOverDirectory } val hadFile = prior?.type == "file" val priorSize = if (prior?.type == "file") prior.size ?: 0L else 0L guard { repo.uploadFile(diskPath, tmp, overwrite = true) } val after = guard { getMetadataAfterWrite(diskPath) } if (after.type != "file") { - throw IllegalStateException("Expected file after upload: $path") + throw WallencException.Storage.UnexpectedState } val newSize = after.size ?: 0L _size.value = ((_size.value ?: 0L) + newSize - priorSize).coerceAtLeast(0L) diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/vaults/local/LocalVault.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/vaults/local/LocalVault.kt index 2762d31..2629d1e 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/vaults/local/LocalVault.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/vaults/local/LocalVault.kt @@ -1,5 +1,7 @@ package com.github.nullptroma.wallenc.domain.vault.vaults.local +import com.github.nullptroma.wallenc.domain.errors.WallencException + import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo import com.github.nullptroma.wallenc.domain.interfaces.IStorage import com.github.nullptroma.wallenc.domain.vault.storages.local.LocalStorage @@ -64,7 +66,7 @@ class LocalVault( private suspend fun readStorages() { val path = path.value if (path == null || !_isAvailable.value) { - throw Exception("Not available") + throw WallencException.Storage.NotAvailable } val dirs = path.listFiles()?.filter { it.isDirectory } @@ -79,7 +81,7 @@ class LocalVault( override suspend fun createStorage(): LocalStorage = withContext(ioDispatcher) { val path = path.value if (path == null || !_isAvailable.value) { - throw Exception("Not available") + throw WallencException.Storage.NotAvailable } val storageUuid = UUID.randomUUID() @@ -104,7 +106,7 @@ class LocalVault( override suspend fun remove(storage: IStorage) = withContext(ioDispatcher) { val path = path.value if (path == null || !_isAvailable.value) { - throw Exception("Not available") + throw WallencException.Storage.NotAvailable } val curStorages = _storages.value.toMutableList() diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/errors/WallencException.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/errors/WallencException.kt new file mode 100644 index 0000000..6109d75 --- /dev/null +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/errors/WallencException.kt @@ -0,0 +1,55 @@ +package com.github.nullptroma.wallenc.domain.errors + +/** + * Единая иерархия сбоев Wallenc. Ожидаемые бизнес-отказы (CanEncryptResult и т.п.) + * остаются отдельными sealed-типами; сюда попадают только операционные ошибки. + */ +sealed class WallencException( + message: String? = null, + cause: Throwable? = null, +) : Exception(message, cause) { + + sealed class Feature : WallencException() { + data object StorageNotFound : Feature() + data object NeedsDecryptedView : Feature() + data object SecretNotFound : Feature() + data object StorageNotWritable : Feature() + } + + sealed class Storage(cause: Throwable? = null) : WallencException(cause = cause) { + data object NotAvailable : Storage() + data object FileNotFound : Storage() + data object IncorrectKey : Storage() + 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() { + data object Failed : Auth() + data object TokenMissing : Auth() + } + + /** Сетевые и удалённые операции (HTTP, блокировки, таймауты). */ + sealed class Network(cause: Throwable? = null) : WallencException(cause = cause) { + data class HttpFailed( + val operation: String, + val statusCode: Int, + override val cause: Throwable? = null, + ) : Network(cause) + data class IoFailed(override val cause: Throwable) : Network(cause) + data object ResourceLocked : Network() + data object OperationFailed : Network() + data object OperationTimedOut : Network() + } + + data class Unknown(override val cause: Throwable?) : WallencException(cause = cause) +} diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/errors/WallencExceptionMapping.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/errors/WallencExceptionMapping.kt new file mode 100644 index 0000000..9036181 --- /dev/null +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/errors/WallencExceptionMapping.kt @@ -0,0 +1,13 @@ +package com.github.nullptroma.wallenc.domain.errors + +import java.io.FileNotFoundException +import java.io.IOException + +fun Throwable.toWallencException(): WallencException = when (this) { + is WallencException -> this + is FileNotFoundException -> WallencException.Storage.FileNotFound + is IOException -> WallencException.Network.IoFailed(this) + else -> WallencException.Unknown(this) +} + +fun Throwable.rethrowAsWallencException(): Nothing = throw toWallencException() diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskContext.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskContext.kt index ea6c8b2..00fb1a7 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskContext.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskContext.kt @@ -1,5 +1,7 @@ package com.github.nullptroma.wallenc.domain.tasks +import com.github.nullptroma.wallenc.domain.errors.WallencException + interface TaskContext { val taskId: TaskId @@ -8,4 +10,6 @@ interface TaskContext { suspend fun reportProgress(progress: TaskProgress) = reportProgress(progress.fraction, progress.label) fun log(level: TaskLogLevel, message: String) + + fun fail(error: WallencException): Nothing } diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskRunState.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskRunState.kt index 6ea47e1..1ace453 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskRunState.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/TaskRunState.kt @@ -1,9 +1,11 @@ package com.github.nullptroma.wallenc.domain.tasks +import com.github.nullptroma.wallenc.domain.errors.WallencException + sealed class TaskRunState { data object Queued : TaskRunState() data class Running(val progress: TaskProgress?) : TaskRunState() data object Completed : TaskRunState() data object Cancelled : TaskRunState() - data class Failed(val message: String) : TaskRunState() + data class Failed(val error: WallencException) : TaskRunState() } diff --git a/domain/src/test/java/com/github/nullptroma/wallenc/domain/errors/WallencExceptionMappingTest.kt b/domain/src/test/java/com/github/nullptroma/wallenc/domain/errors/WallencExceptionMappingTest.kt new file mode 100644 index 0000000..7f19b61 --- /dev/null +++ b/domain/src/test/java/com/github/nullptroma/wallenc/domain/errors/WallencExceptionMappingTest.kt @@ -0,0 +1,39 @@ +package com.github.nullptroma.wallenc.domain.errors + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.FileNotFoundException +import java.io.IOException + +class WallencExceptionMappingTest { + + @Test + fun preservesWallencException() { + val original = WallencException.Feature.StorageNotFound + assertSame(original, original.toWallencException()) + } + + @Test + fun mapsFileNotFoundException() { + val mapped = FileNotFoundException("missing").toWallencException() + assertEquals(WallencException.Storage.FileNotFound, mapped) + } + + @Test + fun mapsIOExceptionToIoFailed() { + val cause = IOException("disk") + val mapped = cause.toWallencException() + assertTrue(mapped is WallencException.Network.IoFailed) + assertSame(cause, (mapped as WallencException.Network.IoFailed).cause) + } + + @Test + fun mapsGenericExceptionToUnknown() { + val cause = Exception("boom") + val mapped = cause.toWallencException() + assertTrue(mapped is WallencException.Unknown) + assertSame(cause, (mapped as WallencException.Unknown).cause) + } +} diff --git a/task-runtime/src/main/java/com/github/nullptroma/wallenc/task/runtime/TaskOrchestrator.kt b/task-runtime/src/main/java/com/github/nullptroma/wallenc/task/runtime/TaskOrchestrator.kt index 66832f8..a598b95 100644 --- a/task-runtime/src/main/java/com/github/nullptroma/wallenc/task/runtime/TaskOrchestrator.kt +++ b/task-runtime/src/main/java/com/github/nullptroma/wallenc/task/runtime/TaskOrchestrator.kt @@ -1,5 +1,7 @@ package com.github.nullptroma.wallenc.task.runtime +import com.github.nullptroma.wallenc.domain.errors.WallencException +import com.github.nullptroma.wallenc.domain.errors.toWallencException import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator import com.github.nullptroma.wallenc.domain.tasks.PipelineState import com.github.nullptroma.wallenc.domain.tasks.PipelineTask @@ -188,9 +190,13 @@ class TaskOrchestrator( replaceTask(taskId) { it.copy(state = TaskRunState.Completed) } } catch (_: CancellationException) { replaceTask(taskId) { it.copy(state = TaskRunState.Cancelled) } + } catch (e: TaskFailedException) { + replaceTask(taskId) { + it.copy(state = TaskRunState.Failed(e.error)) + } } catch (e: Exception) { replaceTask(taskId) { - it.copy(state = TaskRunState.Failed(e.message ?: e.toString())) + it.copy(state = TaskRunState.Failed(e.toWallencException())) } } finally { cancelRequested.remove(taskId) @@ -216,8 +222,14 @@ class TaskOrchestrator( override fun log(level: TaskLogLevel, message: String) { appendLog(level, message) } + + override fun fail(error: WallencException): Nothing { + throw TaskFailedException(error) + } } + private class TaskFailedException(val error: WallencException) : RuntimeException() + companion object { private const val MAX_LOG_LINES = 500 private const val FOREGROUND_DELAY_MS = 1_000L diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/resources/AppStrings.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/resources/AppStrings.kt new file mode 100644 index 0000000..84d3393 --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/resources/AppStrings.kt @@ -0,0 +1,86 @@ +package com.github.nullptroma.wallenc.ui.resources + +import com.github.nullptroma.wallenc.domain.errors.WallencException +import com.github.nullptroma.wallenc.ui.R +import com.github.nullptroma.wallenc.usecases.AddStorageToSyncGroupResult +import com.github.nullptroma.wallenc.vault.contract.VaultLinkFailure + +fun WallencException.toUserNotification(): UserNotification.TextRes = when (this) { + WallencException.Feature.StorageNotFound -> + UserNotification.TextRes(R.string.error_storage_not_found) + WallencException.Feature.NeedsDecryptedView -> + UserNotification.TextRes(R.string.error_storage_locked_view) + WallencException.Feature.SecretNotFound -> + UserNotification.TextRes(R.string.error_secret_not_found) + WallencException.Feature.StorageNotWritable -> + UserNotification.TextRes(R.string.error_storage_not_writable) + + WallencException.Storage.NotAvailable, + WallencException.Storage.NotWritable, + -> + UserNotification.TextRes(R.string.error_storage_not_writable) + WallencException.Storage.FileNotFound -> + UserNotification.TextRes(R.string.error_file_not_found) + WallencException.Storage.IncorrectKey -> + UserNotification.TextRes(R.string.error_incorrect_password) + WallencException.Storage.NotEncrypted -> + UserNotification.TextRes(R.string.error_storage_not_encrypted) + WallencException.Storage.EncInfoMissing -> + UserNotification.TextRes(R.string.error_enc_info_missing) + WallencException.Storage.DeleteRootForbidden -> + UserNotification.TextRes(R.string.error_delete_root_forbidden) + WallencException.Storage.NotAFile -> + UserNotification.TextRes(R.string.error_not_a_file) + WallencException.Storage.NotADirectory -> + UserNotification.TextRes(R.string.error_not_a_directory) + WallencException.Storage.PathIsFile -> + UserNotification.TextRes(R.string.error_path_is_file) + WallencException.Storage.CannotWriteOverDirectory -> + UserNotification.TextRes(R.string.error_cannot_write_over_directory) + WallencException.Storage.UnexpectedState -> + UserNotification.TextRes(R.string.error_unexpected_state) + is WallencException.Storage.IoFailed -> + UserNotification.TextRes(R.string.error_network) + + WallencException.Auth.Failed, + WallencException.Auth.TokenMissing, + -> + UserNotification.TextRes(R.string.vault_link_error_auth) + WallencException.Network.ResourceLocked -> + UserNotification.TextRes(R.string.error_disk_resource_locked) + WallencException.Network.OperationFailed, + WallencException.Network.OperationTimedOut, + is WallencException.Network.HttpFailed, + is WallencException.Network.IoFailed, + -> + UserNotification.TextRes(R.string.error_network) + + is WallencException.Unknown -> + UserNotification.TextRes(R.string.error_unknown) +} + +fun VaultLinkFailure.toUserNotification(): UserNotification.TextRes = when (this) { + VaultLinkFailure.UnsupportedBrand -> + UserNotification.TextRes(R.string.vault_link_error_unsupported_brand) + VaultLinkFailure.NotRegistered -> + UserNotification.TextRes(R.string.vault_link_error_not_registered) + VaultLinkFailure.AuthError -> + UserNotification.TextRes(R.string.vault_link_error_auth) + VaultLinkFailure.Unknown -> + UserNotification.TextRes(R.string.vault_link_error_unknown) +} + +fun AddStorageToSyncGroupResult.toUserNotification(groupId: String): UserNotification.TextRes = when (this) { + AddStorageToSyncGroupResult.Added -> + UserNotification.TextRes(R.string.sync_msg_storage_added, listOf(groupId)) + AddStorageToSyncGroupResult.GroupNotFound -> + UserNotification.TextRes(R.string.sync_error_group_not_found) + AddStorageToSyncGroupResult.AlreadyInGroup -> + UserNotification.TextRes(R.string.sync_msg_storage_already_added) + AddStorageToSyncGroupResult.EncryptedStorageNotAllowed -> + UserNotification.TextRes(R.string.sync_msg_only_plain_storage_allowed) + AddStorageToSyncGroupResult.MissingEncryptionSecret -> + UserNotification.TextRes(R.string.sync_msg_storage_encryption_key_required) + AddStorageToSyncGroupResult.IncompatibleEncryption -> + UserNotification.TextRes(R.string.sync_msg_storage_incompatible_encryption) +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/resources/UserNotificationCompose.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/resources/UserNotificationCompose.kt new file mode 100644 index 0000000..72cd458 --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/resources/UserNotificationCompose.kt @@ -0,0 +1,16 @@ +package com.github.nullptroma.wallenc.ui.resources + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource + +@Composable +fun UserNotification.resolveText(): String = when (this) { + is UserNotification.TextRes -> { + if (formatArgs.isEmpty()) { + stringResource(id) + } else { + stringResource(id, *formatArgs.toTypedArray()) + } + } + is UserNotification.Plain -> message +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsScreen.kt index 4aad9a4..e3c5294 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.window.Dialog import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.github.nullptroma.wallenc.ui.R +import com.github.nullptroma.wallenc.ui.resources.toUserNotification import com.github.nullptroma.wallenc.vault.contract.CloudBrand import com.github.nullptroma.wallenc.vault.contract.VaultLinkOutcome @@ -211,9 +212,18 @@ fun RemoteVaultsScreen( when (outcome) { is VaultLinkOutcome.Success -> viewModel.onLinkSucceeded(outcome.registration) - is VaultLinkOutcome.Failed -> - Toast.makeText(context, outcome.message, Toast.LENGTH_LONG) - .show() + is VaultLinkOutcome.Failed -> { + val notification = outcome.reason.toUserNotification() + val text = if (notification.formatArgs.isEmpty()) { + context.getString(notification.id) + } else { + context.getString( + notification.id, + *notification.formatArgs.toTypedArray(), + ) + } + Toast.makeText(context, text, Toast.LENGTH_LONG).show() + } VaultLinkOutcome.Cancelled -> { } } } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt index 69d3f57..cbbe7b5 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.github.nullptroma.wallenc.ui.R +import com.github.nullptroma.wallenc.ui.resources.resolveText @Composable fun StorageHomeScreen( @@ -80,9 +81,9 @@ fun StorageHomeScreen( ), ) - uiState.errorMessage?.let { + uiState.errorNotification?.let { notification -> Text( - text = it, + text = notification.resolveText(), color = MaterialTheme.colorScheme.error, ) } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreenState.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreenState.kt index 82e6ebf..5df18a0 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreenState.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreenState.kt @@ -1,6 +1,7 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.storage import androidx.compose.runtime.Immutable +import com.github.nullptroma.wallenc.ui.resources.UserNotification @Immutable data class StorageHomeScreenState( @@ -13,5 +14,5 @@ data class StorageHomeScreenState( val twoFaCount: Int = 0, val textSecretsCount: Int = 0, val canManageDomainData: Boolean = false, - val errorMessage: String? = null, + val errorNotification: UserNotification.TextRes? = null, ) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeViewModel.kt index ec307b3..304c7e5 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeViewModel.kt @@ -2,7 +2,9 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.storage import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.github.nullptroma.wallenc.domain.errors.WallencException import com.github.nullptroma.wallenc.ui.ViewModelBase +import com.github.nullptroma.wallenc.ui.resources.toUserNotification import com.github.nullptroma.wallenc.usecases.FindStorageUseCase import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase import com.github.nullptroma.wallenc.usecases.ManageTwoFaTokensUseCase @@ -33,7 +35,7 @@ class StorageHomeViewModel @Inject constructor( state.value.copy( isLoading = false, storageUuid = storageUuid.toString(), - errorMessage = "Storage not found", + errorNotification = WallencException.Feature.StorageNotFound.toUserNotification(), ), ) return@launch @@ -56,8 +58,8 @@ class StorageHomeViewModel @Inject constructor( twoFaCount = twoFa.size, textSecretsCount = secrets.size, canManageDomainData = canManageDomainData, - errorMessage = if (isRawEncrypted) { - "Откройте расшифрованное отображение storage для работы с 2FA и секретами" + errorNotification = if (isRawEncrypted) { + WallencException.Feature.NeedsDecryptedView.toUserNotification() } else { null }, diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreen.kt index 97ad0b1..3610ba8 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.github.nullptroma.wallenc.ui.R +import com.github.nullptroma.wallenc.ui.resources.resolveText import kotlinx.coroutines.launch @Composable @@ -58,8 +59,11 @@ fun TextSecretDetailsScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - uiState.errorMessage?.let { - Text(it, color = MaterialTheme.colorScheme.error) + uiState.errorNotification?.let { notification -> + Text( + text = notification.resolveText(), + color = MaterialTheme.colorScheme.error, + ) } if (uiState.isMutating) { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreenState.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreenState.kt index 8fa8285..54f8a91 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreenState.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsScreenState.kt @@ -1,6 +1,7 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets import androidx.compose.runtime.Immutable +import com.github.nullptroma.wallenc.ui.resources.UserNotification import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord @Immutable @@ -9,5 +10,5 @@ data class TextSecretDetailsScreenState( val isAvailable: Boolean = false, val isMutating: Boolean = false, val secret: TextSecretRecord? = null, - val errorMessage: String? = null, + val errorNotification: UserNotification.TextRes? = null, ) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsViewModel.kt index 73619cd..0dfdfa6 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretDetailsViewModel.kt @@ -6,7 +6,9 @@ import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator import com.github.nullptroma.wallenc.domain.tasks.TaskId import com.github.nullptroma.wallenc.domain.tasks.TaskRunState import com.github.nullptroma.wallenc.ui.R +import com.github.nullptroma.wallenc.domain.errors.WallencException import com.github.nullptroma.wallenc.ui.ViewModelBase +import com.github.nullptroma.wallenc.ui.resources.toUserNotification import com.github.nullptroma.wallenc.ui.resources.UiStringResolver import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStorageUuid import com.github.nullptroma.wallenc.usecases.FindStorageUseCase @@ -44,7 +46,7 @@ class TextSecretDetailsViewModel @Inject constructor( updateState( state.value.copy( isLoading = false, - errorMessage = "Storage not found", + errorNotification = WallencException.Feature.StorageNotFound.toUserNotification(), ), ) return@launch @@ -54,7 +56,7 @@ class TextSecretDetailsViewModel @Inject constructor( state.value.copy( isLoading = false, isAvailable = false, - errorMessage = "Откройте расшифрованное отображение storage для просмотра и редактирования секрета", + errorNotification = WallencException.Feature.NeedsDecryptedView.toUserNotification(), ), ) return@launch @@ -75,7 +77,11 @@ class TextSecretDetailsViewModel @Inject constructor( isAvailable = available, isMutating = isMutating, secret = secret, - errorMessage = if (secret == null) "Secret not found" else null, + errorNotification = if (secret == null) { + WallencException.Feature.SecretNotFound.toUserNotification() + } else { + null + }, ) }.collect { ui -> updateState(ui) @@ -89,7 +95,7 @@ class TextSecretDetailsViewModel @Inject constructor( if (storage.metaInfo.value.encInfo != null && !storage.isVirtualStorage) { updateState( state.value.copy( - errorMessage = "Откройте расшифрованное отображение storage для просмотра и редактирования секрета", + errorNotification = WallencException.Feature.NeedsDecryptedView.toUserNotification(), ), ) return@launch diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreen.kt index f63fc97..98e5b9a 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreen.kt @@ -33,6 +33,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.github.nullptroma.wallenc.domain.datatypes.TextSecretEntryRecord import com.github.nullptroma.wallenc.ui.R +import com.github.nullptroma.wallenc.ui.resources.resolveText @Composable fun TextSecretEditScreen( @@ -42,7 +43,7 @@ fun TextSecretEditScreen( ) { val uiState by viewModel.state.collectAsStateWithLifecycle() val currentOnSaved by rememberUpdatedState(onSaved) - val inputEnabled = uiState.isAvailable && !uiState.isMutating && uiState.errorMessage == null + val inputEnabled = uiState.isAvailable && !uiState.isMutating && uiState.errorNotification == null var title by remember(uiState.initialSecret) { mutableStateOf(uiState.initialSecret?.title.orEmpty()) @@ -75,8 +76,8 @@ fun TextSecretEditScreen( stringResource(R.string.text_secret_edit) }, ) - uiState.errorMessage?.let { err -> - Text(text = err) + uiState.errorNotification?.let { notification -> + Text(text = notification.resolveText()) } if (uiState.isMutating) { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreenState.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreenState.kt index 65fb957..ef13f4c 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreenState.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditScreenState.kt @@ -1,6 +1,7 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets import androidx.compose.runtime.Immutable +import com.github.nullptroma.wallenc.ui.resources.UserNotification import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord @Immutable @@ -9,5 +10,5 @@ data class TextSecretEditScreenState( val isAvailable: Boolean = false, val isMutating: Boolean = false, val initialSecret: TextSecretRecord? = null, - val errorMessage: String? = null, + val errorNotification: UserNotification.TextRes? = null, ) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditViewModel.kt index 0895691..67941e2 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretEditViewModel.kt @@ -8,7 +8,9 @@ import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator import com.github.nullptroma.wallenc.domain.tasks.TaskId import com.github.nullptroma.wallenc.domain.tasks.TaskRunState import com.github.nullptroma.wallenc.ui.R +import com.github.nullptroma.wallenc.domain.errors.WallencException import com.github.nullptroma.wallenc.ui.ViewModelBase +import com.github.nullptroma.wallenc.ui.resources.toUserNotification import com.github.nullptroma.wallenc.ui.resources.UiStringResolver import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.optionalSecretId import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStorageUuid @@ -48,7 +50,7 @@ class TextSecretEditViewModel @Inject constructor( updateState( state.value.copy( isLoading = false, - errorMessage = "Storage not found", + errorNotification = WallencException.Feature.StorageNotFound.toUserNotification(), ), ) return@launch @@ -58,7 +60,7 @@ class TextSecretEditViewModel @Inject constructor( state.value.copy( isLoading = false, isAvailable = false, - errorMessage = "Откройте расшифрованное отображение storage для редактирования секрета", + errorNotification = WallencException.Feature.NeedsDecryptedView.toUserNotification(), ), ) return@launch @@ -78,7 +80,7 @@ class TextSecretEditViewModel @Inject constructor( isAvailable = available, isMutating = isMutating, initialSecret = currentSecret, - errorMessage = null, + errorNotification = null, ) }.collect { ui -> updateState(ui) @@ -96,7 +98,7 @@ class TextSecretEditViewModel @Inject constructor( if (storage.metaInfo.value.encInfo != null && !storage.isVirtualStorage) { updateState( state.value.copy( - errorMessage = "Откройте расшифрованное отображение storage для редактирования секрета", + errorNotification = WallencException.Feature.NeedsDecryptedView.toUserNotification(), ), ) return@launch diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreen.kt index 8b4e777..4ba02af 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreen.kt @@ -33,6 +33,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord import com.github.nullptroma.wallenc.ui.R +import com.github.nullptroma.wallenc.ui.resources.resolveText @Composable fun TextSecretsScreen( @@ -66,8 +67,11 @@ fun TextSecretsScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - uiState.errorMessage?.let { - Text(text = it, color = MaterialTheme.colorScheme.error) + uiState.errorNotification?.let { notification -> + Text( + text = notification.resolveText(), + color = MaterialTheme.colorScheme.error, + ) } if (uiState.items.isEmpty()) { diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreenState.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreenState.kt index 50cf6e2..47748ce 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreenState.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreenState.kt @@ -1,6 +1,7 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets import androidx.compose.runtime.Immutable +import com.github.nullptroma.wallenc.ui.resources.UserNotification import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord @Immutable @@ -8,5 +9,5 @@ data class TextSecretsScreenState( val isLoading: Boolean = true, val isAvailable: Boolean = false, val items: List = emptyList(), - val errorMessage: String? = null, + val errorNotification: UserNotification.TextRes? = null, ) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsViewModel.kt index 32e1fe9..bc91cd2 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsViewModel.kt @@ -2,7 +2,9 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.github.nullptroma.wallenc.domain.errors.WallencException import com.github.nullptroma.wallenc.ui.ViewModelBase +import com.github.nullptroma.wallenc.ui.resources.toUserNotification import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStorageUuid import com.github.nullptroma.wallenc.usecases.FindStorageUseCase import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase @@ -31,7 +33,7 @@ class TextSecretsViewModel @Inject constructor( updateState( state.value.copy( isLoading = false, - errorMessage = "Storage not found", + errorNotification = WallencException.Feature.StorageNotFound.toUserNotification(), ), ) return@launch @@ -41,7 +43,7 @@ class TextSecretsViewModel @Inject constructor( state.value.copy( isLoading = false, isAvailable = false, - errorMessage = "Откройте расшифрованное отображение storage для работы с секретами", + errorNotification = WallencException.Feature.NeedsDecryptedView.toUserNotification(), ), ) return@launch @@ -54,7 +56,7 @@ class TextSecretsViewModel @Inject constructor( isLoading = false, isAvailable = available, items = items, - errorMessage = null, + errorNotification = null, ) }.collect { ui -> updateState(ui) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt index 84f0d63..6964eda 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt @@ -80,6 +80,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord 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.usecases.TwoFaCodeState import com.github.nullptroma.wallenc.usecases.buildTwoFaCodeState @@ -133,8 +134,11 @@ fun TwoFaTokensScreen( return@Column } - uiState.errorMessage?.let { - Text(it) + uiState.errorNotification?.let { notification -> + Text( + text = notification.resolveText(), + color = MaterialTheme.colorScheme.error, + ) } if (uiState.items.isEmpty()) { diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreenState.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreenState.kt index 5cc51ae..32b19f6 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreenState.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreenState.kt @@ -2,6 +2,7 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa import androidx.compose.runtime.Immutable import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord +import com.github.nullptroma.wallenc.ui.resources.UserNotification @Immutable data class TwoFaTokensScreenState( @@ -9,5 +10,5 @@ data class TwoFaTokensScreenState( val isAvailable: Boolean = false, val isMutating: Boolean = false, val items: List = emptyList(), - val errorMessage: String? = null, + val errorNotification: UserNotification.TextRes? = null, ) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt index 3868be2..86ceb15 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt @@ -6,7 +6,9 @@ import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator import com.github.nullptroma.wallenc.domain.tasks.TaskRunState import com.github.nullptroma.wallenc.ui.R +import com.github.nullptroma.wallenc.domain.errors.WallencException import com.github.nullptroma.wallenc.ui.ViewModelBase +import com.github.nullptroma.wallenc.ui.resources.toUserNotification import com.github.nullptroma.wallenc.ui.resources.UiStringResolver import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStorageUuid import com.github.nullptroma.wallenc.usecases.FindStorageUseCase @@ -40,7 +42,7 @@ class TwoFaTokensViewModel @Inject constructor( updateState( state.value.copy( isLoading = false, - errorMessage = "Storage not found", + errorNotification = WallencException.Feature.StorageNotFound.toUserNotification(), ), ) return@launch @@ -50,7 +52,7 @@ class TwoFaTokensViewModel @Inject constructor( state.value.copy( isLoading = false, isAvailable = false, - errorMessage = "Откройте расшифрованное отображение storage для работы с 2FA", + errorNotification = WallencException.Feature.NeedsDecryptedView.toUserNotification(), ), ) return@launch @@ -69,7 +71,7 @@ class TwoFaTokensViewModel @Inject constructor( isAvailable = available, isMutating = isMutating, items = items, - errorMessage = null, + errorNotification = null, ) }.collect { ui -> updateState(ui) @@ -92,7 +94,7 @@ class TwoFaTokensViewModel @Inject constructor( if (storage.metaInfo.value.encInfo != null && !storage.isVirtualStorage) { updateState( state.value.copy( - errorMessage = "Откройте расшифрованное отображение storage для работы с 2FA", + errorNotification = WallencException.Feature.NeedsDecryptedView.toUserNotification(), ), ) return@launch @@ -140,7 +142,7 @@ class TwoFaTokensViewModel @Inject constructor( if (storage.metaInfo.value.encInfo != null && !storage.isVirtualStorage) { updateState( state.value.copy( - errorMessage = "Откройте расшифрованное отображение storage для работы с 2FA", + errorNotification = WallencException.Feature.NeedsDecryptedView.toUserNotification(), ), ) return@launch diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineScreen.kt index ce38d88..26ef34a 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineScreen.kt @@ -36,6 +36,7 @@ import com.github.nullptroma.wallenc.domain.tasks.PipelineTask import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel import com.github.nullptroma.wallenc.domain.tasks.TaskRunState import com.github.nullptroma.wallenc.ui.R +import com.github.nullptroma.wallenc.ui.resources.toUserNotification @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -191,7 +192,17 @@ private fun TaskRow(task: PipelineTask, isRunning: Boolean) { progressLabel ?: stringResource(R.string.task_state_running) TaskRunState.Completed -> stringResource(R.string.task_state_completed) TaskRunState.Cancelled -> stringResource(R.string.task_state_cancelled) - is TaskRunState.Failed -> stringResource(R.string.task_state_failed, s.message) + is TaskRunState.Failed -> { + val notification = s.error.toUserNotification() + stringResource( + R.string.task_state_failed, + if (notification.formatArgs.isEmpty()) { + stringResource(notification.id) + } else { + stringResource(notification.id, *notification.formatArgs.toTypedArray()) + }, + ) + } } Text(stateLabel, style = MaterialTheme.typography.bodySmall) if (task.state is TaskRunState.Running) { diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/AbstractVaultBrowserViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/AbstractVaultBrowserViewModel.kt index 00350ec..f310a44 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/AbstractVaultBrowserViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/AbstractVaultBrowserViewModel.kt @@ -21,8 +21,10 @@ 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.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -266,12 +268,7 @@ abstract class AbstractVaultBrowserViewModel( } } catch (e: Exception) { ctx.log(TaskLogLevel.Error, e.message ?: "Failed to enable encryption") - _userNotifications.emit( - UserNotification.TextRes( - R.string.msg_failed_enable_encryption, - listOf(e.message ?: e.toString()), - ), - ) + emitTaskError(e) } }, ) @@ -296,12 +293,7 @@ abstract class AbstractVaultBrowserViewModel( ctx.log(TaskLogLevel.Info, "Storage opened") } catch (e: Exception) { ctx.log(TaskLogLevel.Error, e.message ?: "Failed to open encrypted storage") - _userNotifications.emit( - UserNotification.TextRes( - R.string.msg_failed_open_storage, - listOf(e.message ?: e.toString()), - ), - ) + emitTaskError(e) } }, ) @@ -325,12 +317,7 @@ abstract class AbstractVaultBrowserViewModel( ctx.log(TaskLogLevel.Info, "Storage closed") } catch (e: Exception) { ctx.log(TaskLogLevel.Error, e.message ?: "Failed to close encrypted storage") - _userNotifications.emit( - UserNotification.TextRes( - R.string.msg_failed_close_storage, - listOf(e.message ?: e.toString()), - ), - ) + emitTaskError(e) } }, ) @@ -359,12 +346,7 @@ abstract class AbstractVaultBrowserViewModel( _userNotifications.emit(UserNotification.TextRes(R.string.msg_encryption_disabled)) } catch (e: Exception) { ctx.log(TaskLogLevel.Error, e.message ?: "Failed") - _userNotifications.emit( - UserNotification.TextRes( - R.string.msg_failed_disable_encryption, - listOf(e.message ?: e.toString()), - ), - ) + emitTaskError(e) } }, ) @@ -464,17 +446,16 @@ abstract class AbstractVaultBrowserViewModel( _userNotifications.emit(UserNotification.TextRes(R.string.msg_sync_lock_cleared)) } catch (e: Exception) { ctx.log(TaskLogLevel.Error, e.message ?: "clear sync lock failed") - _userNotifications.emit( - UserNotification.TextRes( - R.string.msg_sync_lock_clear_failed, - listOf(e.message ?: e.toString()), - ), - ) + emitTaskError(e) } }, ) } + private suspend fun emitTaskError(e: Exception) { + _userNotifications.emit(e.toWallencException().toUserNotification()) + } + private companion object { private const val TAG = "VaultBrowser" } diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 1b8b3eb..68a9be8 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -154,6 +154,29 @@ Сохранение текстового секрета Удаление текстового секрета + Хранилище не найдено + Откройте расшифрованное отображение storage для работы с этим разделом + Секрет не найден + Хранилище недоступно для записи + Файл не найден + Неверный пароль + Хранилище не зашифровано + Отсутствуют метаданные шифрования + Нельзя удалить корень хранилища + Ожидался файл + Ожидалась папка + Путь указывает на файл, а не на папку + Нельзя записать поверх папки + Хранилище в неожиданном состоянии + Ошибка сети или сервера + Ресурс временно заблокирован. Повторите позже. + Что-то пошло не так + Группа синхронизации не найдена + Не удалось войти + Вход не готов. Перезапустите приложение. + Не удалось войти + Этот провайдер не поддерживается + Шифрование включено Хранилище уже зашифровано Хранилище не пустое diff --git a/ui/src/test/java/com/github/nullptroma/wallenc/ui/resources/WallencUserNotificationMappingTest.kt b/ui/src/test/java/com/github/nullptroma/wallenc/ui/resources/WallencUserNotificationMappingTest.kt new file mode 100644 index 0000000..c0d5e10 --- /dev/null +++ b/ui/src/test/java/com/github/nullptroma/wallenc/ui/resources/WallencUserNotificationMappingTest.kt @@ -0,0 +1,27 @@ +package com.github.nullptroma.wallenc.ui.resources + +import com.github.nullptroma.wallenc.domain.errors.WallencException +import com.github.nullptroma.wallenc.ui.R +import org.junit.Assert.assertEquals +import org.junit.Test + +class WallencUserNotificationMappingTest { + + @Test + fun mapsFeatureStorageNotFound() { + val notification = WallencException.Feature.StorageNotFound.toUserNotification() + assertEquals(R.string.error_storage_not_found, notification.id) + } + + @Test + fun mapsStorageIncorrectKey() { + val notification = WallencException.Storage.IncorrectKey.toUserNotification() + assertEquals(R.string.error_incorrect_password, notification.id) + } + + @Test + fun mapsUnknown() { + val notification = WallencException.Unknown(Exception("x")).toUserNotification() + assertEquals(R.string.error_unknown, notification.id) + } +} diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt index 5f858fb..634df77 100644 --- a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt @@ -1,5 +1,6 @@ package com.github.nullptroma.wallenc.usecases +import com.github.nullptroma.wallenc.domain.errors.toWallencException import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncEngine import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator import com.github.nullptroma.wallenc.domain.tasks.TaskId @@ -46,8 +47,10 @@ class RunStorageSyncUseCase( ctx.log(TaskLogLevel.Info, "Storage sync finished") ctx.reportProgress(null, "Storage sync: completed") } catch (e: Exception) { - ctx.log(TaskLogLevel.Error, "Storage sync failed: ${e.message}") - ctx.reportProgress(null, "Storage sync: failed - ${e.message}") + val err = e.toWallencException() + ctx.log(TaskLogLevel.Error, "Storage sync failed: $err") + ctx.reportProgress(null, "Storage sync: failed - $err") + ctx.fail(err) } finally { running.set(false) _syncRunning.value = false diff --git a/vault-contracts/src/main/java/com/github/nullptroma/wallenc/vault/contract/VaultLinkFailure.kt b/vault-contracts/src/main/java/com/github/nullptroma/wallenc/vault/contract/VaultLinkFailure.kt new file mode 100644 index 0000000..2e48bc2 --- /dev/null +++ b/vault-contracts/src/main/java/com/github/nullptroma/wallenc/vault/contract/VaultLinkFailure.kt @@ -0,0 +1,8 @@ +package com.github.nullptroma.wallenc.vault.contract + +enum class VaultLinkFailure { + UnsupportedBrand, + NotRegistered, + AuthError, + Unknown, +} diff --git a/vault-contracts/src/main/java/com/github/nullptroma/wallenc/vault/contract/VaultLinkOutcome.kt b/vault-contracts/src/main/java/com/github/nullptroma/wallenc/vault/contract/VaultLinkOutcome.kt index 1af8505..f38c720 100644 --- a/vault-contracts/src/main/java/com/github/nullptroma/wallenc/vault/contract/VaultLinkOutcome.kt +++ b/vault-contracts/src/main/java/com/github/nullptroma/wallenc/vault/contract/VaultLinkOutcome.kt @@ -13,6 +13,6 @@ sealed interface VaultLinkOutcome { /** Пользователь отменил вход. */ data object Cancelled : VaultLinkOutcome - /** Ошибка SDK/сети/сервера; [message] годится для показа пользователю. */ - data class Failed(val message: String) : VaultLinkOutcome + /** Ошибка SDK/сети/сервера; [reason] маппится в UI через [VaultLinkFailure]. */ + data class Failed(val reason: VaultLinkFailure) : VaultLinkOutcome }