diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/WallencApplication.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/WallencApplication.kt index 875e463..3248ec5 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/WallencApplication.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/WallencApplication.kt @@ -2,9 +2,10 @@ package com.github.nullptroma.wallenc.app import android.app.Application import androidx.work.Configuration -import androidx.hilt.work.HiltWorkerFactory +import com.github.nullptroma.wallenc.app.di.HiltWorkerFactoryEntryPoint import com.github.nullptroma.wallenc.app.sync.StorageSyncBootstrap import com.github.nullptroma.wallenc.app.tasks.TaskPipelineForegroundBootstrap +import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject @@ -17,9 +18,6 @@ class WallencApplication : Application(), Configuration.Provider { @Inject lateinit var storageSyncBootstrap: StorageSyncBootstrap - @Inject - lateinit var workerFactory: HiltWorkerFactory - override fun onCreate() { super.onCreate() taskPipelineForegroundBootstrap.start() @@ -27,7 +25,13 @@ class WallencApplication : Application(), Configuration.Provider { } override val workManagerConfiguration: Configuration - get() = Configuration.Builder() - .setWorkerFactory(workerFactory) - .build() + get() { + val factory = EntryPointAccessors.fromApplication( + applicationContext, + HiltWorkerFactoryEntryPoint::class.java, + ).hiltWorkerFactory() + return Configuration.Builder() + .setWorkerFactory(factory) + .build() + } } \ No newline at end of file diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/di/HiltWorkerFactoryEntryPoint.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/di/HiltWorkerFactoryEntryPoint.kt new file mode 100644 index 0000000..15a53c5 --- /dev/null +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/di/HiltWorkerFactoryEntryPoint.kt @@ -0,0 +1,17 @@ +package com.github.nullptroma.wallenc.app.di + +import androidx.hilt.work.HiltWorkerFactory +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +/** + * WorkManager инициализируется до [android.app.Application.onCreate], поэтому + * нельзя читать `@Inject lateinit var workerFactory` в [Configuration.Provider]. + * Фабрика берётся через EntryPoint. + */ +@EntryPoint +@InstallIn(SingletonComponent::class) +interface HiltWorkerFactoryEntryPoint { + fun hiltWorkerFactory(): HiltWorkerFactory +} diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/di/UiStringModule.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/di/UiStringModule.kt new file mode 100644 index 0000000..61d55cc --- /dev/null +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/di/UiStringModule.kt @@ -0,0 +1,26 @@ +package com.github.nullptroma.wallenc.app.di + +import android.content.Context +import com.github.nullptroma.wallenc.ui.resources.UiStringResolver +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object UiStringModule { + + @Provides + @Singleton + fun provideUiStringResolver(@ApplicationContext context: Context): UiStringResolver = + UiStringResolver { id, args -> + if (args.isEmpty()) { + context.getString(id) + } else { + context.getString(id, *args) + } + } +} diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncBootstrap.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncBootstrap.kt index eb0fd38..f91f77f 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncBootstrap.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/sync/StorageSyncBootstrap.kt @@ -1,6 +1,8 @@ package com.github.nullptroma.wallenc.app.sync import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager +import com.github.nullptroma.wallenc.ui.R +import com.github.nullptroma.wallenc.ui.resources.UiStringResolver import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -21,6 +23,7 @@ class StorageSyncBootstrap @Inject constructor( private val scheduler: StorageSyncScheduler, private val vaultsManager: IVaultsManager, private val syncRunner: RunStorageSyncUseCase, + private val uiStrings: UiStringResolver, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -40,7 +43,10 @@ class StorageSyncBootstrap @Inject constructor( merge(*triggers.toTypedArray()) .debounce(DEBOUNCE_AFTER_CHANGE_MS) .collect { - syncRunner.enqueue("debounce") + syncRunner.enqueue( + displayTitle = uiStrings(R.string.task_title_storage_sync_background), + logReason = "debounce", + ) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c0d265b..86e9e74 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,8 +1,8 @@ Wallenc - Background tasks - Wallenc tasks - Preparing… - Working… - Cancel - \ No newline at end of file + Фоновые задачи + Задачи Wallenc + Подготовка… + Выполняется… + Отмена + diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/YandexDiskApiFactory.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/YandexDiskApiFactory.kt index 1bbb320..e87e6b2 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/YandexDiskApiFactory.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/YandexDiskApiFactory.kt @@ -8,6 +8,7 @@ import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.jackson.JacksonConverterFactory import java.util.UUID +import java.util.concurrent.ConcurrentHashMap /** * Фабрика REST-клиента Яндекс.Диска: отдельный [OkHttpClient] с OAuth на каждый vault, @@ -18,6 +19,9 @@ class YandexDiskApiFactory( private val ioDispatcher: CoroutineDispatcher, ) { + /** Кеш OAuth-токена по vault, чтобы не дергать БД на каждый HTTP-запрос к cloud-api. */ + private val oauthTokenCache = ConcurrentHashMap>() + private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() } /** Без авторизации — только для одноразовых ссылок upload/download. */ @@ -54,13 +58,21 @@ class YandexDiskApiFactory( fun createApiForVault(vaultUuid: UUID): YandexDiskApi { val id = vaultUuid.toString() return createAuthenticatedApi { - runBlocking(ioDispatcher) { - accountRepository.getByVaultUuid(id)?.oauthToken + val now = System.currentTimeMillis() + val hit = oauthTokenCache[id] + if (hit != null && now - hit.first < OAUTH_TOKEN_CACHE_TTL_MS) { + return@createAuthenticatedApi hit.second } + val token = runBlocking(ioDispatcher) { + accountRepository.getByVaultUuid(id)?.oauthToken + } ?: throw java.io.IOException("Yandex OAuth token is missing") + oauthTokenCache[id] = now to token + token } } companion object { private const val BASE_URL = "https://cloud-api.yandex.net/" + private const val OAUTH_TOKEN_CACHE_TTL_MS = 120_000L } } diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/repository/YandexDiskRepository.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/repository/YandexDiskRepository.kt index c725824..1fd63c3 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/repository/YandexDiskRepository.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/repository/YandexDiskRepository.kt @@ -20,9 +20,11 @@ import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.ResponseBody import retrofit2.HttpException import retrofit2.Response +import java.io.FileNotFoundException import java.io.FilterInputStream import java.io.IOException import java.io.InputStream +import java.util.concurrent.ConcurrentHashMap class YandexDiskRepository( private val api: YandexDiskApi, @@ -30,40 +32,68 @@ class YandexDiskRepository( private val ioDispatcher: CoroutineDispatcher, ) { + private val diskCacheLock = Any() + private var diskInfoCached: DiskInfoDto? = null + private var diskInfoCachedUntilMs: Long = 0L + + private val listCache = ConcurrentHashMap() + private val getCache = ConcurrentHashMap() + suspend fun diskInfo(): DiskInfoDto = withContext(ioDispatcher) { - wrapAuth { api.getDisk() } + val now = System.currentTimeMillis() + synchronized(diskCacheLock) { + val cached = diskInfoCached + if (cached != null && now < diskInfoCachedUntilMs) { + return@withContext cached + } + } + val fresh = wrapAuth { api.getDisk() } + synchronized(diskCacheLock) { + diskInfoCached = fresh + diskInfoCachedUntilMs = System.currentTimeMillis() + DISK_INFO_TTL_MS + } + fresh } suspend fun list(path: String, limit: Int, offset: Int, sort: String? = null): ResourceDto = withContext(ioDispatcher) { - try { - wrapAuth { api.listResources(path, limit, offset, sort, FIELDS_LIST) } - } catch (e: HttpException) { - if (e.code() == 404) { - ResourceDto(embedded = EmbeddedResourceListDto(items = emptyList())) - } else { - throw e + val key = ListCacheKey(path = path, limit = limit, offset = offset, sort = sort) + listCache[key]?.let { return@withContext it } + + suspend fun tryList(p: String): ResourceDto? = + try { + wrapAuth { api.listResources(p, limit, offset, sort, FIELDS_LIST) } + } catch (e: HttpException) { + if (e.code() == 404) null else throw e } - } + val primary = tryList(path) + val secondary = if (!path.endsWith('/') && path != "app:/") tryList("$path/") else null + val result = primary ?: secondary + ?: ResourceDto(embedded = EmbeddedResourceListDto(items = emptyList())) + putListCache(key, result) + result } suspend fun get(path: String): ResourceDto = withContext(ioDispatcher) { - wrapAuth { api.getResource(path, FIELDS_RESOURCE) } + getCache[path]?.let { return@withContext it } + val result = fetchResource(path) + putGetCache(path, result) + result } suspend fun getOrNull(path: String): ResourceDto? = withContext(ioDispatcher) { - try { - wrapAuth { api.getResource(path, FIELDS_RESOURCE) } - } catch (e: HttpException) { - if (e.code() == 404) null else throw e + getCache[path]?.let { return@withContext it } + val result = fetchResourceOrNull(path) + if (result != null) { + putGetCache(path, result) } + result } suspend fun createFolder(path: String): Unit = withContext(ioDispatcher) { val resp = wrapAuth { api.createFolder(path) } when (resp.code()) { - 201 -> Unit - 409 -> Unit + 201, 409 -> invalidateDiskMetaCaches() else -> throw failure("createFolder", resp) } } @@ -71,13 +101,14 @@ class YandexDiskRepository( suspend fun delete(path: String, permanently: Boolean = true): Unit = withContext(ioDispatcher) { val resp = wrapAuth { api.deleteResource(path, permanently) } when (resp.code()) { - 204 -> Unit + 204 -> invalidateDiskMetaCaches() 202 -> { val link = resp.body()?.use { body -> parseLink(body) } ?: throw IOException("DELETE 202 without body") awaitOperation(link.href) + invalidateDiskMetaCaches() } - 404 -> Unit + 404 -> invalidateDiskMetaCaches() else -> throw failure("delete", resp) } } @@ -91,70 +122,113 @@ class YandexDiskRepository( throw failure("patch", resp) } resp.body()?.close() + invalidateDiskMetaCaches() } suspend fun uploadBytes(path: String, bytes: ByteArray, overwrite: Boolean = true): Unit = withContext(ioDispatcher) { - val link = wrapAuth { api.getUploadLink(path, overwrite) } + val link = uploadLinkOrThrow(path, overwrite) require(link.method.equals("PUT", ignoreCase = true)) { "Unexpected upload method ${link.method}" } val body = bytes.toRequestBody(OCTET_STREAM) val req = Request.Builder().url(link.href).put(body).build() - rawHttp.newCall(req).execute().use { resp -> - if (!resp.isSuccessful) { - throw IOException("Upload failed: HTTP ${resp.code}") + repeat(LOCKED_RETRY_MAX) { attempt -> + rawHttp.newCall(req).execute().use { resp -> + when { + resp.isSuccessful -> { + invalidateDiskMetaCaches() + return@withContext + } + resp.code == 423 && attempt < LOCKED_RETRY_MAX - 1 -> + delay(lockedBackoffMs(attempt)) + else -> + throw IOException("Upload failed: HTTP ${resp.code}") + } } } } suspend fun uploadFile(path: String, file: java.io.File, overwrite: Boolean = true): Unit = withContext(ioDispatcher) { - val link = wrapAuth { api.getUploadLink(path, overwrite) } + val link = uploadLinkOrThrow(path, overwrite) require(link.method.equals("PUT", ignoreCase = true)) { "Unexpected upload method ${link.method}" } val body = file.asRequestBody(OCTET_STREAM) val req = Request.Builder().url(link.href).put(body).build() - rawHttp.newCall(req).execute().use { resp -> - if (!resp.isSuccessful) { - throw IOException("Upload failed: HTTP ${resp.code}") + repeat(LOCKED_RETRY_MAX) { attempt -> + rawHttp.newCall(req).execute().use { resp -> + when { + resp.isSuccessful -> { + invalidateDiskMetaCaches() + return@withContext + } + resp.code == 423 && attempt < LOCKED_RETRY_MAX - 1 -> + delay(lockedBackoffMs(attempt)) + else -> + throw IOException("Upload failed: HTTP ${resp.code}") + } } } } /** Поток должен быть закрыт вызывающим кодом — закроет HTTP-ответ. */ suspend fun openDownloadStream(path: String): InputStream = withContext(ioDispatcher) { - val link = wrapAuth { api.getDownloadLink(path) } + val link = try { + wrapAuth { api.getDownloadLink(path) } + } catch (e: HttpException) { + if (e.code() == 404) throw FileNotFoundException(path) + throw e + } require(link.method.equals("GET", ignoreCase = true)) { "Unexpected download method ${link.method}" } val req = Request.Builder().url(link.href).get().build() - val resp = rawHttp.newCall(req).execute() - if (!resp.isSuccessful) { - resp.close() - throw IOException("Download failed: HTTP ${resp.code}") - } - val body = resp.body - val stream = body?.byteStream() ?: run { - resp.close() - throw IOException("Download failed: missing body") - } - object : FilterInputStream(stream) { - override fun close() { - try { - `in`.close() - } finally { + repeat(LOCKED_RETRY_MAX) { attempt -> + val resp = rawHttp.newCall(req).execute() + when { + resp.isSuccessful -> { + val body = resp.body + val stream = body?.byteStream() ?: run { + resp.close() + throw IOException("Download failed: missing body") + } + return@withContext object : FilterInputStream(stream) { + override fun close() { + try { + `in`.close() + } finally { + resp.close() + } + } + } + } + resp.code == 423 && attempt < LOCKED_RETRY_MAX - 1 -> { resp.close() + delay(lockedBackoffMs(attempt)) + } + else -> { + val code = resp.code + resp.close() + throw IOException("Download failed: HTTP $code") } } } + throw IOException("Download failed: HTTP 423 after retries") } private suspend fun awaitOperation(href: String) { repeat(OPERATION_POLL_MAX) { delay(OPERATION_POLL_DELAY_MS) - val st: OperationStatusDto = wrapAuth { api.getOperationByUrl(href) } + val st: OperationStatusDto = try { + wrapAuth { api.getOperationByUrl(href) } + } catch (e: HttpException) { + if (e.code() == 404) { + throw IOException("Disk async operation status not found (404)") + } + throw e + } when (st.status?.lowercase()) { "success" -> return "failure", "failed" -> throw IOException("Disk async operation failed") @@ -164,18 +238,83 @@ class YandexDiskRepository( throw IOException("Disk async operation timed out") } - private suspend inline fun wrapAuth(crossinline block: suspend () -> T): T { + private suspend fun uploadLinkOrThrow(path: String, overwrite: Boolean): LinkDto { try { - return block() + return wrapAuth { api.getUploadLink(path, overwrite) } } catch (e: HttpException) { - when (e.code()) { - 401 -> { - throw YandexDiskAuthException(e.message()) + if (e.code() == 404) { + throw FileNotFoundException("Upload path or parent not found: $path") + } + throw e + } + } + + private suspend fun fetchResource(path: String): ResourceDto { + suspend fun tryGet(p: String): ResourceDto? = + try { + wrapAuth { api.getResource(p, FIELDS_RESOURCE) } + } catch (e: HttpException) { + if (e.code() == 404) null else throw e + } + val primary = tryGet(path) + val secondary = if (!path.endsWith('/')) tryGet("$path/") else null + return primary ?: secondary ?: throw FileNotFoundException(path) + } + + private suspend fun fetchResourceOrNull(path: String): ResourceDto? { + suspend fun tryGet(p: String): ResourceDto? = + try { + wrapAuth { api.getResource(p, FIELDS_RESOURCE) } + } catch (e: HttpException) { + if (e.code() == 404) null else throw e + } + return tryGet(path) ?: if (!path.endsWith('/')) tryGet("$path/") else null + } + + private fun putListCache(key: ListCacheKey, value: ResourceDto) { + if (listCache.size >= LIST_CACHE_MAX_ENTRIES) { + listCache.clear() + } + listCache[key] = value + } + + private fun putGetCache(path: String, value: ResourceDto) { + if (getCache.size >= GET_CACHE_MAX_ENTRIES) { + getCache.clear() + } + getCache[path] = value + } + + private fun invalidateDiskMetaCaches() { + synchronized(diskCacheLock) { + diskInfoCached = null + diskInfoCachedUntilMs = 0L + } + listCache.clear() + getCache.clear() + } + + private suspend inline fun wrapAuth(crossinline block: suspend () -> T): T { + repeat(LOCKED_RETRY_MAX) { attempt -> + try { + return block() + } catch (e: HttpException) { + when (e.code()) { + 401 -> throw YandexDiskAuthException(e.message()) + 423 -> { + if (attempt >= LOCKED_RETRY_MAX - 1) { + throw IOException( + "Yandex Disk: ресурс временно заблокирован (HTTP 423). Повторите позже.", + e, + ) + } + delay(lockedBackoffMs(attempt)) + } + else -> throw e } - 404 -> throw e - else -> throw e } } + error("unreachable") } private fun failure(op: String, resp: Response): IOException { @@ -186,12 +325,29 @@ class YandexDiskRepository( private fun parseLink(body: ResponseBody): LinkDto = jackson.readValue(body.string()) + private data class ListCacheKey( + val path: String, + val limit: Int, + val offset: Int, + val sort: String?, + ) + companion object { private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() } private val OCTET_STREAM = "application/octet-stream".toMediaType() private const val OPERATION_POLL_DELAY_MS = 300L private const val OPERATION_POLL_MAX = 200 + private const val DISK_INFO_TTL_MS = 45_000L + private const val LIST_CACHE_MAX_ENTRIES = 384 + private const val GET_CACHE_MAX_ENTRIES = 384 + + /** Сколько раз повторять запрос при HTTP 423 (ресурс временно заблокирован). */ + private const val LOCKED_RETRY_MAX = 6 + + private fun lockedBackoffMs(attempt: Int): Long = + (100L * (1L shl attempt)).coerceAtMost(2000L) + /** * Урезанный набор полей для листинга каталога (см. параметр `fields` в Disk API). */ diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/encrypt/EncryptedStorageAccessor.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/encrypt/EncryptedStorageAccessor.kt index f524e7e..979c6cb 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/encrypt/EncryptedStorageAccessor.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/encrypt/EncryptedStorageAccessor.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import java.io.FileNotFoundException import java.io.InputStream import java.io.OutputStream import java.time.Instant @@ -287,7 +288,13 @@ class EncryptedStorageAccessor( override suspend fun openReadSystemFile(name: String): InputStream = scope.run { val path = Path(systemHiddenDirName, name).pathString - return@run openRead(path) + return@run try { + openRead(path) + } catch (_: FileNotFoundException) { + // Как у Yandex/Local: системного файла ещё нет — создаём пустой и читаем снова. + openWriteSystemFile(name).use { } + openRead(path) + } } override suspend fun openWriteSystemFile(name: String): OutputStream = scope.run { diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/yandex/YandexStorageAccessor.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/yandex/YandexStorageAccessor.kt index 46e57a9..92249db 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/yandex/YandexStorageAccessor.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/yandex/YandexStorageAccessor.kt @@ -39,11 +39,14 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.io.ByteArrayOutputStream import java.io.File +import java.io.FileNotFoundException import java.io.FileOutputStream +import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.time.Instant import java.util.UUID +import kotlin.jvm.Volatile /** * Реализация [IStorageAccessor] для дерева файлов `app://…` на Яндекс.Диске. @@ -84,6 +87,9 @@ class YandexStorageAccessor( private var statsPersistJob: Job? = null + @Volatile + private var systemDirEnsured: Boolean = false + suspend fun init() = withContext(ioDispatcher) { try { val persisted = readPersistedStats() @@ -119,7 +125,8 @@ class YandexStorageAccessor( rel.isBlank() || rel == "/" -> "" else -> rel.trimStart('/') } - return if (tail.isEmpty()) diskRoot else "$diskRoot/$tail" + // Корень хранилища — каталог в app:; для list/get API стабильнее с завершающим «/». + return if (tail.isEmpty()) "$diskRoot/" else "$diskRoot/$tail" } private fun toRelPath(diskPath: String): String { @@ -135,6 +142,10 @@ class YandexStorageAccessor( val tail = diskPath.substring(i + needle.length).removeSuffix("/") return if (tail.isEmpty()) "/" else "/$tail" } + val needleEnd = "/$u" + if (diskPath.endsWith(needleEnd, ignoreCase = true)) { + return "/" + } return "/" } @@ -148,9 +159,14 @@ class YandexStorageAccessor( rel == "/$SYSTEM_HIDDEN_DIRNAME" || rel.startsWith("/$SYSTEM_HIDDEN_DIRNAME/") private suspend fun ensureSystemDirExists() { + if (systemDirEnsured) return val p = toDiskPath("/$SYSTEM_HIDDEN_DIRNAME") - if (guard { repo.getOrNull(p) }?.type == "dir") return + if (guard { repo.getOrNull(p) }?.type == "dir") { + systemDirEnsured = true + return + } guard { repo.createFolder(p) } + systemDirEnsured = true } private fun statsFileRel(): String = "/$SYSTEM_HIDDEN_DIRNAME/$STATS_FILENAME" @@ -199,6 +215,8 @@ class YandexStorageAccessor( } catch (e: YandexDiskAuthException) { reportAuthFailure() throw e + } catch (_: IOException) { + // Запись stats — best-effort; сетевые сбои не роняем процесс (ошибки в лог UI не выводятся). } } } @@ -274,6 +292,19 @@ class YandexStorageAccessor( return files to dirs } + private suspend fun getMetadataAfterWrite(diskPath: String): ResourceDto { + val maxAttempts = 6 + repeat(maxAttempts) { attempt -> + try { + return guard { repo.get(diskPath) } + } catch (e: FileNotFoundException) { + if (attempt == maxAttempts - 1) throw e + delay(200L * (attempt + 1)) + } + } + error("unreachable") + } + private fun ResourceDto.toCommonFile(rel: String): CommonFile = CommonFile( CommonMetaInfo( @@ -505,7 +536,7 @@ class YandexStorageAccessor( 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 { repo.get(diskPath) } + val after = guard { getMetadataAfterWrite(diskPath) } if (after.type != "file") { throw IllegalStateException("Expected file after upload: $path") } @@ -687,7 +718,7 @@ class YandexStorageAccessor( private const val SYNC_LOCK_FILENAME = "sync-lock.json" private const val STATS_DEBOUNCE_MS = 450L private const val DATA_PAGE_LENGTH = 10 - private const val API_LIST_LIMIT = 200 + private const val API_LIST_LIMIT = 1000 private const val PROP_HIDDEN = "wallenc.hidden" private const val PROP_DELETED = "wallenc.deleted" } diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/local/LocalVault.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/local/LocalVault.kt index 016734b..9edefea 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/local/LocalVault.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/local/LocalVault.kt @@ -33,6 +33,9 @@ class LocalVault( private val _storages = MutableStateFlow>(emptyList()) override val storages: StateFlow> = _storages + private val _storagesScanInProgress = MutableStateFlow(false) + override val storagesScanInProgress: StateFlow = _storagesScanInProgress + private val _isAvailable = MutableStateFlow(false) override val isAvailable: StateFlow = _isAvailable @@ -46,9 +49,14 @@ class LocalVault( init { CoroutineScope(ioDispatcher).launch { - _isAvailable.value = path.value != null - if (path.value != null) { - readStorages() + _storagesScanInProgress.value = true + try { + _isAvailable.value = path.value != null + if (path.value != null) { + readStorages() + } + } finally { + _storagesScanInProgress.value = false } } } diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/yandex/YandexVault.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/yandex/YandexVault.kt index 1f07fde..7ddb3f3 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/yandex/YandexVault.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/yandex/YandexVault.kt @@ -10,6 +10,9 @@ import com.github.nullptroma.wallenc.vault.contract.DescribedVault import com.github.nullptroma.wallenc.vault.contract.VaultDescriptor import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -42,6 +45,9 @@ class YandexVault( private val _storages = MutableStateFlow>(emptyList()) override val storages: StateFlow> = _storages + private val _storagesScanInProgress = MutableStateFlow(false) + override val storagesScanInProgress: StateFlow = _storagesScanInProgress + private val _totalSpace = MutableStateFlow(null) override val totalSpace: StateFlow = _totalSpace @@ -55,6 +61,7 @@ class YandexVault( } private suspend fun refreshFromDisk() { + _storagesScanInProgress.value = true _vaultReachable.value = false try { val info = repo.diskInfo() @@ -70,11 +77,13 @@ class YandexVault( } catch (_: Exception) { _vaultReachable.value = false _storages.value = emptyList() + } finally { + _storagesScanInProgress.value = false } } private suspend fun loadStoragesList(): List { - val out = mutableListOf() + val pending = mutableListOf() var offset = 0 while (true) { val root = repo.list("app:/", APP_LIST_LIMIT, offset) @@ -83,25 +92,30 @@ class YandexVault( if (item.type != "dir") continue val name = item.name ?: continue val storageUuid = runCatching { UUID.fromString(name) }.getOrNull() ?: continue - val storage = YandexStorage( - uuid = storageUuid, - repo = repo, - vaultAvailability = _vaultReachable, - ioDispatcher = ioDispatcher, - accessorScope = parentScope, - reportAuthFailure = { - parentScope.launch { _vaultReachable.value = false } - }, + pending.add( + YandexStorage( + uuid = storageUuid, + repo = repo, + vaultAvailability = _vaultReachable, + ioDispatcher = ioDispatcher, + accessorScope = parentScope, + reportAuthFailure = { + parentScope.launch { _vaultReachable.value = false } + }, + ), ) - try { - storage.init() - out.add(storage) - } catch (_: Exception) { } } if (items.size < APP_LIST_LIMIT) break offset += items.size } - return out + if (pending.isEmpty()) return emptyList() + return coroutineScope { + pending.map { storage -> + async(ioDispatcher) { + if (runCatching { storage.init() }.isSuccess) storage else null + } + }.awaitAll().filterNotNull() + } } override suspend fun createStorage(): IStorage = withContext(ioDispatcher) { @@ -135,6 +149,6 @@ class YandexVault( } private companion object { - private const val APP_LIST_LIMIT = 200 + private const val APP_LIST_LIMIT = 1000 } } diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVault.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVault.kt index e2eace9..f5001b2 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVault.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVault.kt @@ -10,6 +10,10 @@ import kotlinx.coroutines.flow.StateFlow */ interface IVault : IVaultInfo { val storages: StateFlow> + /** + * Идёт загрузка/пересканирование списка storages (например, листинг удалённого vault и init каждого storage). + */ + val storagesScanInProgress: StateFlow val isAvailable: StateFlow val totalSpace: StateFlow val availableSpace: StateFlow diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/ITaskOrchestrator.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/ITaskOrchestrator.kt index ef2ee6c..4b204df 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/ITaskOrchestrator.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/ITaskOrchestrator.kt @@ -2,6 +2,7 @@ package com.github.nullptroma.wallenc.domain.tasks import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.CoroutineDispatcher +import java.util.UUID interface ITaskOrchestrator { val pipelineState: StateFlow @@ -12,6 +13,8 @@ interface ITaskOrchestrator { title: String, dispatcher: CoroutineDispatcher, work: PipelineWork, + busyStorageUuid: UUID? = null, + locksVaultStorageList: Boolean = false, ): TaskId fun cancel(taskId: TaskId): Boolean diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/PipelineTask.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/PipelineTask.kt index af601da..c7bb443 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/PipelineTask.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/tasks/PipelineTask.kt @@ -1,10 +1,15 @@ package com.github.nullptroma.wallenc.domain.tasks import kotlinx.coroutines.CoroutineDispatcher +import java.util.UUID data class PipelineTask( val id: TaskId, val title: String, val dispatcher: CoroutineDispatcher, val state: TaskRunState, + /** UUID storage, для которого идёт задача (кнопки только этой строки в UI). */ + val busyStorageUuid: UUID? = null, + /** Задача меняет список storages в vault (например создание) — блокируем FAB «+». */ + val locksVaultStorageList: Boolean = false, ) 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 83c279c..66832f8 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 @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import java.util.Collections +import java.util.UUID import java.util.concurrent.ConcurrentHashMap class TaskOrchestrator( @@ -57,13 +58,21 @@ class TaskOrchestrator( emitForegroundUiState() } - override fun enqueue(title: String, dispatcher: CoroutineDispatcher, work: PipelineWork): TaskId { + override fun enqueue( + title: String, + dispatcher: CoroutineDispatcher, + work: PipelineWork, + busyStorageUuid: UUID?, + locksVaultStorageList: Boolean, + ): TaskId { val id = TaskId() val task = PipelineTask( id = id, title = title, dispatcher = dispatcher, state = TaskRunState.Queued, + busyStorageUuid = busyStorageUuid, + locksVaultStorageList = locksVaultStorageList, ) synchronized(tasksById) { tasksById[id] = task diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/WallencUi.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/WallencUi.kt index d4b382b..d953245 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/WallencUi.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/WallencUi.kt @@ -17,7 +17,6 @@ import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -32,6 +31,7 @@ import androidx.navigation.navDeepLink import com.github.nullptroma.wallenc.ui.navigation.NavBarItemData import com.github.nullptroma.wallenc.ui.navigation.WallencDeepLinks import com.github.nullptroma.wallenc.ui.navigation.matchesWallencDeepLink +import com.github.nullptroma.wallenc.ui.elements.NavigationBarMarqueeText import com.github.nullptroma.wallenc.ui.navigation.rememberNavigationState import com.github.nullptroma.wallenc.ui.screens.main.MainRoute import com.github.nullptroma.wallenc.ui.screens.main.MainScreen @@ -124,7 +124,11 @@ fun WallencNavRoot( contentDescription = stringResource(navBarItemData.iconContentDescriptionResourceId), ) }, - label = { Text(stringResource(navBarItemData.nameStringResourceId)) }, + label = { + NavigationBarMarqueeText( + text = stringResource(navBarItemData.nameStringResourceId), + ) + }, selected = currentRoute?.startsWith(routeClassName) == true, onClick = { val route = topLevelRoutes[navBarItemData.screenRouteClass] diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/elements/Dialogs.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/elements/Dialogs.kt index 09df621..712bdae 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/elements/Dialogs.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/elements/Dialogs.kt @@ -8,10 +8,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.material3.Checkbox import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -26,35 +26,47 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.github.nullptroma.wallenc.ui.R @OptIn(ExperimentalMaterial3Api::class) @Composable -fun TextEditCancelOkDialog(onDismiss: () -> Unit, onConfirmation: (String) -> Unit, title: String, startString: String = "") { +fun TextEditCancelOkDialog( + onDismiss: () -> Unit, + onConfirmation: (String) -> Unit, + title: String, + startString: String = "", +) { var name by remember { mutableStateOf(startString) } val focusRequester = remember { FocusRequester() } BasicAlertDialog( - onDismissRequest = { onDismiss() } + onDismissRequest = { onDismiss() }, ) { Card { Column(modifier = Modifier.padding(12.dp)) { Text(title, style = MaterialTheme.typography.titleLarge) - TextField(modifier = Modifier.focusRequester(focusRequester), value = name, onValueChange = { - name = it - }) + TextField( + modifier = Modifier.focusRequester(focusRequester), + value = name, + onValueChange = { name = it }, + ) Spacer(modifier = Modifier.height(24.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, ) { Button(modifier = Modifier.weight(1f), onClick = onDismiss) { - Text("Cancel") + Text(stringResource(R.string.dialog_cancel)) } Spacer(modifier = Modifier.width(12.dp)) - Button(modifier = Modifier.weight(1f), onClick = { - onConfirmation(name) - }) { - Text("Ok") + Button( + modifier = Modifier.weight(1f), + onClick = { + onConfirmation(name) + }, + ) { + Text(stringResource(R.string.dialog_ok)) } } } @@ -66,12 +78,11 @@ fun TextEditCancelOkDialog(onDismiss: () -> Unit, onConfirmation: (String) -> Un } } - @OptIn(ExperimentalMaterial3Api::class) @Composable fun ConfirmationCancelOkDialog(onDismiss: () -> Unit, onConfirmation: () -> Unit, title: String) { BasicAlertDialog( - onDismissRequest = { onDismiss() } + onDismissRequest = { onDismiss() }, ) { Card { Column(modifier = Modifier.padding(12.dp)) { @@ -82,13 +93,16 @@ fun ConfirmationCancelOkDialog(onDismiss: () -> Unit, onConfirmation: () -> Unit horizontalArrangement = Arrangement.SpaceEvenly, ) { Button(modifier = Modifier.weight(1f), onClick = onDismiss) { - Text("Cancel") + Text(stringResource(R.string.dialog_cancel)) } Spacer(modifier = Modifier.width(12.dp)) - Button(modifier = Modifier.weight(1f), onClick = { - onConfirmation() - }) { - Text("Ok") + Button( + modifier = Modifier.weight(1f), + onClick = { + onConfirmation() + }, + ) { + Text(stringResource(R.string.dialog_ok)) } } } @@ -107,22 +121,33 @@ fun EncryptionSetupDialog( BasicAlertDialog(onDismissRequest = onDismiss) { Card { Column(modifier = Modifier.padding(12.dp)) { - Text("Enable encryption", style = MaterialTheme.typography.titleLarge) + Text( + stringResource(R.string.dialog_encryption_enable_title), + style = MaterialTheme.typography.titleLarge, + ) Spacer(modifier = Modifier.height(12.dp)) - TextField(value = password, onValueChange = { password = it }, label = { Text("Password") }) + TextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.dialog_password_label)) }, + ) Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = encryptPath, onCheckedChange = { encryptPath = it }) - Text("Encrypt paths") + Text(stringResource(R.string.dialog_encrypt_paths)) } Spacer(modifier = Modifier.height(16.dp)) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { - Button(modifier = Modifier.weight(1f), onClick = onDismiss) { Text("Cancel") } + Button(modifier = Modifier.weight(1f), onClick = onDismiss) { + Text(stringResource(R.string.dialog_cancel)) + } Spacer(modifier = Modifier.width(12.dp)) Button( modifier = Modifier.weight(1f), onClick = { onConfirmation(password, encryptPath) }, - enabled = password.isNotEmpty() - ) { Text("Apply") } + enabled = password.isNotEmpty(), + ) { + Text(stringResource(R.string.dialog_apply)) + } } } } @@ -140,22 +165,33 @@ fun OpenEncryptedStorageDialog( BasicAlertDialog(onDismissRequest = onDismiss) { Card { Column(modifier = Modifier.padding(12.dp)) { - Text("Open encrypted storage", style = MaterialTheme.typography.titleLarge) + Text( + stringResource(R.string.dialog_open_encrypted_title), + style = MaterialTheme.typography.titleLarge, + ) Spacer(modifier = Modifier.height(12.dp)) - TextField(value = password, onValueChange = { password = it }, label = { Text("Password") }) + TextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.dialog_password_label)) }, + ) Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = rememberPassword, onCheckedChange = { rememberPassword = it }) - Text("Remember password") + Text(stringResource(R.string.dialog_remember_password)) } Spacer(modifier = Modifier.height(16.dp)) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { - Button(modifier = Modifier.weight(1f), onClick = onDismiss) { Text("Cancel") } + Button(modifier = Modifier.weight(1f), onClick = onDismiss) { + Text(stringResource(R.string.dialog_cancel)) + } Spacer(modifier = Modifier.width(12.dp)) Button( modifier = Modifier.weight(1f), onClick = { onConfirmation(password, rememberPassword) }, - enabled = password.isNotEmpty() - ) { Text("Open") } + enabled = password.isNotEmpty(), + ) { + Text(stringResource(R.string.dialog_open)) + } } } } @@ -178,14 +214,22 @@ fun StorageEncryptionActionsDialog( Text(title, style = MaterialTheme.typography.titleLarge) Spacer(modifier = Modifier.height(12.dp)) if (isOpened) { - Button(onClick = onClose, modifier = Modifier.fillMaxWidth()) { Text("Close") } + Button(onClick = onClose, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.dialog_close)) + } } else { - Button(onClick = onOpen, modifier = Modifier.fillMaxWidth()) { Text("Open") } + Button(onClick = onOpen, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.dialog_open)) + } } Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = onDisable, modifier = Modifier.fillMaxWidth()) { Text("Disable encryption") } + Button(onClick = onDisable, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.dialog_disable_encryption)) + } Spacer(modifier = Modifier.height(12.dp)) - Button(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { Text("Done") } + Button(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.dialog_done)) + } } } } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/elements/NavigationBarMarqueeText.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/elements/NavigationBarMarqueeText.kt new file mode 100644 index 0000000..2d8a2ef --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/elements/NavigationBarMarqueeText.kt @@ -0,0 +1,34 @@ +package com.github.nullptroma.wallenc.ui.elements + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow + +/** + * Однострочная подпись таба нижней навигации: при нехватке ширины текст + * прокручивается (marquee), без переноса последних букв на вторую строку. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun NavigationBarMarqueeText( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + modifier = modifier + .fillMaxWidth() + .basicMarquee(), + style = LocalTextStyle.current, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + textAlign = TextAlign.Center, + ) +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/elements/StorageTree.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/elements/StorageTree.kt index fe1f72f..4c7cf2f 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/elements/StorageTree.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/elements/StorageTree.kt @@ -17,8 +17,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.foundation.layout.size import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider @@ -36,7 +38,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource @@ -50,11 +51,13 @@ import com.github.nullptroma.wallenc.domain.datatypes.Tree import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.utils.debouncedLambda +import java.util.UUID @Composable fun StorageTree( modifier: Modifier, tree: Tree, + isUuidBusy: (UUID) -> Boolean, onClick: (Tree) -> Unit, onRename: (Tree, String) -> Unit, onRemove: (Tree) -> Unit, @@ -62,13 +65,13 @@ fun StorageTree( onOpenEncrypted: (Tree, String, Boolean) -> Unit, onCloseEncrypted: (Tree) -> Unit, onDisableEncryption: (Tree) -> Unit, - getStatusText: (Tree) -> String, + getStatusTextRes: (Tree) -> Int, isEncryptionOpened: (Tree) -> Boolean, isStorageSyncLockHeld: suspend (IStorageInfo) -> Boolean, onClearStorageSyncLock: (IStorageInfo) -> Unit, ) { val cur = tree.value - val available by cur.isAvailable.collectAsStateWithLifecycle() + val rowBusy = isUuidBusy(cur.uuid) val numOfFiles by cur.numberOfFiles.collectAsStateWithLifecycle() val size by cur.size.collectAsStateWithLifecycle() val metaInfo by cur.metaInfo.collectAsStateWithLifecycle() @@ -77,17 +80,20 @@ fun StorageTree( val isOpened = isEncryptionOpened(tree) val borderColor = if (cur.isVirtualStorage) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.primary + val yesWord = stringResource(R.string.storage_value_yes) + val noWord = stringResource(R.string.storage_value_no) + val unavailableHint = stringResource(R.string.storage_unavailable_hint) Column(modifier) { Box( modifier = Modifier .height(IntrinsicSize.Min) - .zIndex(100f) + .zIndex(100f), ) { val interactionSource = remember { MutableInteractionSource() } Box( modifier = Modifier .clip( - CardDefaults.shape + CardDefaults.shape, ) .padding(0.dp, 0.dp, 16.dp, 0.dp) .fillMaxSize() @@ -96,8 +102,8 @@ fun StorageTree( interactionSource = interactionSource, indication = ripple(), enabled = false, - onClick = { } - ) + onClick = { }, + ), ) Card( interactionSource = interactionSource, @@ -105,27 +111,57 @@ fun StorageTree( .padding(8.dp, 0.dp, 0.dp, 0.dp) .fillMaxWidth(), elevation = CardDefaults.cardElevation( - defaultElevation = 4.dp + defaultElevation = 4.dp, ), + enabled = isAvailable && !rowBusy, onClick = debouncedLambda(debounceMs = 500) { - onClick(tree) - } + if (isAvailable && !rowBusy) { + onClick(tree) + } + }, ) { - - Row(modifier = Modifier.height(IntrinsicSize.Min)) { Column(modifier = Modifier.padding(8.dp)) { Text(metaInfo.name ?: stringResource(R.string.no_name)) Text( - text = "IsAvailable: $available" + text = stringResource( + R.string.storage_field_available, + if (isAvailable) yesWord else noWord, + ), + style = MaterialTheme.typography.bodySmall, ) - Text("Files: $numOfFiles") - Text("Size: $size") - Text("IsVirtual: ${cur.isVirtualStorage}") + Text( + text = stringResource( + R.string.storage_field_files, + numOfFiles?.toString() ?: "—", + ), + style = MaterialTheme.typography.bodySmall, + ) + Text( + text = stringResource( + R.string.storage_field_size, + size?.toString() ?: "—", + ), + style = MaterialTheme.typography.bodySmall, + ) + Text( + text = stringResource( + R.string.storage_field_virtual, + if (cur.isVirtualStorage) yesWord else noWord, + ), + style = MaterialTheme.typography.bodySmall, + ) + if (!isAvailable) { + Text( + text = unavailableHint, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } } Column( modifier = Modifier, - horizontalAlignment = Alignment.End + horizontalAlignment = Alignment.End, ) { var expanded by remember { mutableStateOf(false) } var syncLockHeld by remember { mutableStateOf(null) } @@ -143,47 +179,104 @@ fun StorageTree( var showSetupEncryptionDialog by remember { mutableStateOf(false) } var showOpenEncryptionDialog by remember { mutableStateOf(false) } Box(modifier = Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp)) { - IconButton(onClick = { expanded = !expanded }) { - Icon( - Icons.Default.MoreVert, - contentDescription = stringResource(R.string.show_storage_item_menu) - ) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + if (rowBusy) { + CircularProgressIndicator( + modifier = Modifier + .padding(end = 4.dp) + .size(22.dp), + strokeWidth = 2.dp, + ) + } + IconButton( + onClick = { expanded = !expanded }, + enabled = isAvailable && !rowBusy, + ) { + Icon( + Icons.Default.MoreVert, + contentDescription = stringResource( + if (rowBusy) { + R.string.storage_row_task_running_cd + } else { + R.string.show_storage_item_menu + }, + ), + ) + } } DropdownMenu( expanded = expanded, - onDismissRequest = { expanded = false } + onDismissRequest = { expanded = false }, ) { DropdownMenuItem( + enabled = isAvailable && !rowBusy, onClick = { expanded = false - showRenameDialog = true + if (isAvailable && !rowBusy) showRenameDialog = true + }, + text = { + Text( + when { + !isAvailable -> stringResource( + R.string.storage_menu_unavailable, + stringResource(R.string.rename), + ) + rowBusy -> stringResource(R.string.storage_menu_busy, stringResource(R.string.rename)) + else -> stringResource(R.string.rename) + }, + ) }, - text = { Text(stringResource(R.string.rename)) } ) HorizontalDivider() DropdownMenuItem( + enabled = isAvailable && !rowBusy, onClick = { expanded = false - showRemoveConfirmDialog = true + if (isAvailable && !rowBusy) showRemoveConfirmDialog = true + }, + text = { + Text( + when { + !isAvailable -> stringResource( + R.string.storage_menu_unavailable, + stringResource(R.string.remove), + ) + rowBusy -> stringResource(R.string.storage_menu_busy, stringResource(R.string.remove)) + else -> stringResource(R.string.remove) + }, + ) }, - text = { Text(stringResource(R.string.remove)) } ) if (!isEncrypted) { HorizontalDivider() DropdownMenuItem( + enabled = isAvailable && !rowBusy, onClick = { expanded = false - showSetupEncryptionDialog = true + if (isAvailable && !rowBusy) showSetupEncryptionDialog = true + }, + text = { + Text( + when { + !isAvailable -> stringResource( + R.string.storage_menu_unavailable, + stringResource(R.string.encrypt), + ) + rowBusy -> stringResource(R.string.storage_menu_busy, stringResource(R.string.encrypt)) + else -> stringResource(R.string.encrypt) + }, + ) }, - text = { Text(stringResource(R.string.encrypt)) } ) } HorizontalDivider() DropdownMenuItem( - enabled = syncLockHeld == true, + enabled = syncLockHeld == true && !rowBusy, onClick = { expanded = false - if (syncLockHeld == true) { + if (syncLockHeld == true && !rowBusy) { onClearStorageSyncLock(cur) } }, @@ -207,7 +300,7 @@ fun StorageTree( onRename(tree, newName) }, title = stringResource(R.string.new_name_title), - startString = metaInfo.name ?: "" + startString = metaInfo.name ?: "", ) } @@ -216,12 +309,12 @@ fun StorageTree( onDismiss = { showRemoveConfirmDialog = false }, title = stringResource( R.string.remove_confirmation_dialog, - metaInfo.name ?: "" + metaInfo.name ?: stringResource(R.string.no_name), ), onConfirmation = { showRemoveConfirmDialog = false onRemove(tree) - } + }, ) } @@ -241,7 +334,7 @@ fun StorageTree( onDisable = { showLockDialog = false onDisableEncryption(tree) - } + }, ) } @@ -251,7 +344,7 @@ fun StorageTree( onConfirmation = { password, encryptPath -> showSetupEncryptionDialog = false onEncrypt(tree, password, encryptPath) - } + }, ) } @@ -261,16 +354,19 @@ fun StorageTree( onConfirmation = { password, rememberPassword -> showOpenEncryptionDialog = false onOpenEncrypted(tree, password, rememberPassword) - } + }, ) } } Spacer(modifier = Modifier.weight(1f)) if (isEncrypted) { - IconButton(onClick = { showLockDialog = true }) { + IconButton( + onClick = { showLockDialog = true }, + enabled = isAvailable && !rowBusy, + ) { Icon( if (isOpened) Icons.Default.LockOpen else Icons.Default.Lock, - contentDescription = stringResource(R.string.storage_lock_actions) + contentDescription = stringResource(R.string.storage_lock_actions), ) } } @@ -279,7 +375,7 @@ fun StorageTree( .fillMaxWidth() .padding(0.dp, 0.dp, 12.dp, 0.dp) .align(Alignment.End), - text = getStatusText(tree), + text = stringResource(getStatusTextRes(tree)), textAlign = TextAlign.End, fontSize = 11.sp, ) @@ -293,39 +389,29 @@ fun StorageTree( fontSize = 8.sp, style = LocalTextStyle.current.copy( platformStyle = PlatformTextStyle( - includeFontPadding = true - ) - ) + includeFontPadding = true, + ), + ), ) } } } - if(!isAvailable) { - Box( - modifier = Modifier - .clip( - CardDefaults.shape - ) - .fillMaxSize() - .alpha(0.5f) - .background(Color.Black) - ) - } } for (i in tree.children ?: listOf()) { StorageTree( Modifier .padding(16.dp, 0.dp, 0.dp, 0.dp) .offset(y = (-4).dp), - i, - onClick, + tree = i, + isUuidBusy = isUuidBusy, + onClick = onClick, onRename, onRemove, onEncrypt, onOpenEncrypted, onCloseEncrypted, onDisableEncryption, - getStatusText, + getStatusTextRes, isEncryptionOpened, isStorageSyncLockHeld, onClearStorageSyncLock, @@ -333,4 +419,3 @@ fun StorageTree( } } } - diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/resources/UiStringResolver.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/resources/UiStringResolver.kt new file mode 100644 index 0000000..1ecebe9 --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/resources/UiStringResolver.kt @@ -0,0 +1,8 @@ +package com.github.nullptroma.wallenc.ui.resources + +import androidx.annotation.StringRes + +/** Разрешение Android-строк для код-домена (ViewModel, без Compose). */ +fun interface UiStringResolver { + operator fun invoke(@StringRes id: Int, vararg formatArgs: Any): String +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/resources/UserNotification.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/resources/UserNotification.kt new file mode 100644 index 0000000..44b4008 --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/resources/UserNotification.kt @@ -0,0 +1,8 @@ +package com.github.nullptroma.wallenc.ui.resources + +import androidx.annotation.StringRes + +sealed class UserNotification { + data class TextRes(@param:StringRes val id: Int, val formatArgs: List = emptyList()) : UserNotification() + data class Plain(val message: String) : UserNotification() +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainScreen.kt index 3a5fd97..214d1d1 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainScreen.kt @@ -5,24 +5,29 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Cloud +import androidx.compose.material.icons.outlined.Folder import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.toRoute +import com.github.nullptroma.wallenc.ui.elements.NavigationBarMarqueeText import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.navigation.NavBarItemData import com.github.nullptroma.wallenc.ui.navigation.NavigationState @@ -39,6 +44,10 @@ import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.VaultBrowserS import com.github.nullptroma.wallenc.ui.screens.shared.TextEditRoute import com.github.nullptroma.wallenc.ui.screens.shared.TextEditScreen +private fun isTextEditDestination(route: String?): Boolean { + val q = TextEditRoute::class.qualifiedName ?: return false + return route?.startsWith(q) == true +} @OptIn(ExperimentalMaterial3Api::class) @androidx.compose.runtime.Composable @@ -48,85 +57,112 @@ fun MainScreen( navState: NavigationState = rememberNavigationState(), ) { val routes = viewModel.routes + val mainUi by viewModel.state.collectAsStateWithLifecycle() val localVaultViewModel: LocalVaultViewModel = hiltViewModel() val remoteVaultsViewModel: RemoteVaultsViewModel = hiltViewModel() + val childBackStackEntry by navState.navHostController.currentBackStackEntryAsState() + val showWorkStatusBar = !isTextEditDestination(childBackStackEntry?.destination?.route) + val workStatus = mainUi.workStatus + val topLevelNavBarItems = remember { mapOf( LocalVaultRoute::class.qualifiedName!! to NavBarItemData( - R.string.nav_label_local_vault, LocalVaultRoute::class.qualifiedName!!, null + nameStringResourceId = R.string.nav_label_local_vault, + screenRouteClass = LocalVaultRoute::class.qualifiedName!!, + icon = Icons.Outlined.Folder, + iconContentDescriptionResourceId = R.string.nav_cd_local_vault, ), RemoteVaultsRoute::class.qualifiedName!! to NavBarItemData( - R.string.nav_label_remote_vaults, RemoteVaultsRoute::class.qualifiedName!!, null - ) + nameStringResourceId = R.string.nav_label_remote_vaults, + screenRouteClass = RemoteVaultsRoute::class.qualifiedName!!, + icon = Icons.Outlined.Cloud, + iconContentDescriptionResourceId = R.string.nav_cd_remote_vaults, + ), ) } - Scaffold(modifier = modifier, contentWindowInsets = WindowInsets(0.dp), bottomBar = { - Column { - NavigationBar(windowInsets = WindowInsets(0), modifier = Modifier.height(48.dp)) { - val navBackStackEntry by navState.navHostController.currentBackStackEntryAsState() - val currentRoute = navBackStackEntry?.destination?.route - topLevelNavBarItems.forEach { - val routeClassName = it.key - val navBarItemData = it.value - NavigationBarItem(modifier = Modifier - .weight(1f), - icon = { Text(stringResource(navBarItemData.nameStringResourceId)) }, - selected = currentRoute?.startsWith(routeClassName) == true, - onClick = { - val route = routes[navBarItemData.screenRouteClass] - ?: throw NullPointerException("Route ${navBarItemData.screenRouteClass} not found") - if (currentRoute?.startsWith(routeClassName) != true) - navState.changeTop( - route - ) - }, - label = null - ) - } + Scaffold( + modifier = modifier, + contentWindowInsets = WindowInsets(0.dp), + topBar = { + if (showWorkStatusBar) { + MainWorkStatusBar(status = workStatus) } - HorizontalDivider() - } - }) { innerPaddings -> + }, + bottomBar = { + Column { + NavigationBar(windowInsets = WindowInsets(0)) { + val navBackStackEntry by navState.navHostController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + topLevelNavBarItems.forEach { + val routeClassName = it.key + val navBarItemData = it.value + val iconVector = navBarItemData.icon + ?: error("Main tab requires icon") + NavigationBarItem( + modifier = Modifier.weight(1f), + icon = { + Icon( + imageVector = iconVector, + contentDescription = stringResource(navBarItemData.iconContentDescriptionResourceId), + ) + }, + label = { + NavigationBarMarqueeText( + text = stringResource(navBarItemData.nameStringResourceId), + ) + }, + selected = currentRoute?.startsWith(routeClassName) == true, + onClick = { + val route = routes[navBarItemData.screenRouteClass] + ?: throw NullPointerException("Route ${navBarItemData.screenRouteClass} not found") + if (currentRoute?.startsWith(routeClassName) != true) { + navState.changeTop(route) + } + }, + ) + } + } + HorizontalDivider() + } + }, + ) { innerPaddings -> NavHost( - navState.navHostController, - startDestination = routes[LocalVaultRoute::class.qualifiedName]!! + navController = navState.navHostController, + startDestination = routes[LocalVaultRoute::class.qualifiedName]!!, + modifier = Modifier + .fillMaxSize() + .padding(innerPaddings), ) { - composable(enterTransition = { - fadeIn(tween(200)) - }, exitTransition = { - fadeOut(tween(200)) - }) { + composable( + enterTransition = { fadeIn(tween(200)) }, + exitTransition = { fadeOut(tween(200)) }, + ) { LocalVaultScreen( - modifier = Modifier.padding(innerPaddings), viewModel = localVaultViewModel, openTextEdit = { text -> navState.push(TextEditRoute(text)) }, ) } - composable(enterTransition = { - fadeIn(tween(200)) - }, exitTransition = { - fadeOut(tween(200)) - }) { + composable( + enterTransition = { fadeIn(tween(200)) }, + exitTransition = { fadeOut(tween(200)) }, + ) { RemoteVaultsScreen( - modifier = Modifier.padding(innerPaddings), viewModel = remoteVaultsViewModel, onOpenVault = { item -> navState.push(VaultBrowserRoute(item.uuid.toString())) }, ) } - composable(enterTransition = { - fadeIn(tween(200)) - }, exitTransition = { - fadeOut(tween(200)) - }) { entry -> + composable( + enterTransition = { fadeIn(tween(200)) }, + exitTransition = { fadeOut(tween(200)) }, + ) { entry -> val remoteVaultViewModel: RemoteVaultViewModel = hiltViewModel(entry) VaultBrowserScreen( - modifier = Modifier.padding(innerPaddings), viewModel = remoteVaultViewModel, openTextEdit = { text -> navState.push(TextEditRoute(text)) @@ -139,4 +175,4 @@ fun MainScreen( } } } -} \ No newline at end of file +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainScreenState.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainScreenState.kt index 46a2298..64e5f10 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainScreenState.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainScreenState.kt @@ -1,3 +1,8 @@ package com.github.nullptroma.wallenc.ui.screens.main -class MainScreenState \ No newline at end of file +import androidx.compose.runtime.Immutable + +@Immutable +data class MainScreenState( + val workStatus: MainWorkStatus = MainWorkStatus.Idle, +) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainViewModel.kt index e586222..7ae4564 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainViewModel.kt @@ -2,32 +2,142 @@ package com.github.nullptroma.wallenc.ui.screens.main import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi import androidx.lifecycle.viewmodel.compose.saveable +import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager +import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator +import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundUiState +import com.github.nullptroma.wallenc.domain.tasks.TaskRunState +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.screens.ScreenRoute import com.github.nullptroma.wallenc.ui.screens.main.screens.remotes.RemoteVaultsRoute import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.LocalVaultRoute -import com.github.nullptroma.wallenc.ui.ViewModelBase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import javax.inject.Inject +@OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel -class MainViewModel @javax.inject.Inject constructor(savedStateHandle: SavedStateHandle) : - ViewModelBase(MainScreenState()) { +class MainViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val taskOrchestrator: ITaskOrchestrator, + private val vaultsManager: IVaultsManager, + private val uiStrings: UiStringResolver, +) : ViewModelBase(MainScreenState()) { @OptIn(SavedStateHandleSaveableApi::class) var routes by savedStateHandle.saveable { mutableStateOf( mapOf( LocalVaultRoute::class.qualifiedName!! to LocalVaultRoute(), - RemoteVaultsRoute::class.qualifiedName!! to RemoteVaultsRoute() - ) + RemoteVaultsRoute::class.qualifiedName!! to RemoteVaultsRoute(), + ), ) } private set + init { + viewModelScope.launch { + combine( + taskOrchestrator.foregroundUi, + taskOrchestrator.pipelineState, + vaultsManager.vaults.flatMapLatest { vaults -> + if (vaults.isEmpty()) { + flowOf(false) + } else { + combine(vaults.map { it.storagesScanInProgress }) { flags -> + flags.any { it } + } + } + }, + ) { fg, pipe, anyVaultScanning -> + mapWorkStatus(fg, pipe, anyVaultScanning) + } + .distinctUntilChanged() + .collect { status -> + updateState(state.value.copy(workStatus = status)) + } + } + } + fun updateRoute(qualifiedName: String, route: ScreenRoute) { routes = routes.toMutableMap().apply { this[qualifiedName] = route } } -} \ No newline at end of file + + private fun mapWorkStatus( + fg: TaskForegroundUiState, + pipe: com.github.nullptroma.wallenc.domain.tasks.PipelineState, + anyVaultScanning: Boolean, + ): MainWorkStatus { + when (fg) { + is TaskForegroundUiState.Visible -> { + if (fg.tasks.isEmpty()) { + return mapBackgroundWork(pipe, anyVaultScanning) + } + val head = fg.tasks.first() + val p = head.progress + val frac = p?.fraction + val indeterminate = p == null || frac == null + val label = p?.label?.takeIf { it.isNotBlank() } + val line = if (label != null) "${head.title} — $label" else head.title + return MainWorkStatus.Active( + line = line, + progressFraction = frac, + indeterminate = indeterminate, + ) + } + TaskForegroundUiState.Hidden -> return mapBackgroundWork(pipe, anyVaultScanning) + } + } + + private fun mapBackgroundWork( + pipe: com.github.nullptroma.wallenc.domain.tasks.PipelineState, + anyVaultScanning: Boolean, + ): MainWorkStatus { + val fromPipeline = mapPipelineRunningOnly(pipe) + if (fromPipeline != null) return fromPipeline + if (anyVaultScanning) { + return MainWorkStatus.Active( + line = uiStrings(R.string.main_status_vault_scanning_storages), + progressFraction = null, + indeterminate = true, + ) + } + return MainWorkStatus.Idle + } + + private fun mapPipelineRunningOnly( + pipe: com.github.nullptroma.wallenc.domain.tasks.PipelineState, + ): MainWorkStatus? { + val running = pipe.tasks.filter { it.id in pipe.runningTaskIds } + if (running.isEmpty()) return null + if (running.size == 1) { + val t = running.first() + val prog = (t.state as? TaskRunState.Running)?.progress + val frac = prog?.fraction + val indeterminate = prog == null || frac == null + val label = prog?.label?.takeIf { it.isNotBlank() } + val line = if (label != null) "${t.title} — $label" else t.title + return MainWorkStatus.Active( + line = line, + progressFraction = frac, + indeterminate = indeterminate, + ) + } + return MainWorkStatus.Active( + line = uiStrings(R.string.main_status_multiple_tasks, running.size), + progressFraction = null, + indeterminate = true, + ) + } +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainWorkStatus.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainWorkStatus.kt new file mode 100644 index 0000000..e265fa2 --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainWorkStatus.kt @@ -0,0 +1,13 @@ +package com.github.nullptroma.wallenc.ui.screens.main + +/** Состояние полосы «текущая работа» на Main. */ +sealed class MainWorkStatus { + data object Idle : MainWorkStatus() + + /** [line] — строка для отображения (уже локализованная, из оркестратора или фолбэк). */ + data class Active( + val line: String, + val progressFraction: Float?, + val indeterminate: Boolean, + ) : MainWorkStatus() +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainWorkStatusBar.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainWorkStatusBar.kt new file mode 100644 index 0000000..311778c --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainWorkStatusBar.kt @@ -0,0 +1,97 @@ +package com.github.nullptroma.wallenc.ui.screens.main + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.github.nullptroma.wallenc.ui.R + +private val progressTrackHeight = 2.dp + +/** + * Полоса статуса работы на Main: всегда видна (пока экран не скрывает её целиком). + * Слева подпись «Статус:», справа — описание текущей задачи или пусто; внизу — тонкий индикатор прогресса. + */ +@Composable +fun MainWorkStatusBar( + status: MainWorkStatus, + modifier: Modifier = Modifier, +) { + val taskLine = when (status) { + MainWorkStatus.Idle -> "" + is MainWorkStatus.Active -> status.line + } + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = 1.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.main_work_status_label), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = taskLine, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + textAlign = TextAlign.End, + ) + } + when (status) { + MainWorkStatus.Idle -> { + LinearProgressIndicator( + progress = { 0f }, + modifier = Modifier + .fillMaxWidth() + .height(progressTrackHeight) + .padding(top = 6.dp), + ) + } + is MainWorkStatus.Active -> { + if (status.indeterminate) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(progressTrackHeight) + .padding(top = 6.dp), + ) + } else { + val frac = status.progressFraction ?: 0f + LinearProgressIndicator( + progress = { frac }, + modifier = Modifier + .fillMaxWidth() + .height(progressTrackHeight) + .padding(top = 6.dp), + ) + } + } + } + } + } +} 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 d39b266..fb68caf 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 @@ -37,6 +37,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.alpha import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -66,6 +67,7 @@ fun RemoteVaultsScreen( onClick = { if (!uiState.isBusy) viewModel.setAddChoiceVisible(true) }, + modifier = Modifier.alpha(if (uiState.isBusy) 0.38f else 1f), ) { Icon( Icons.Filled.Add, diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsViewModel.kt index f133f1e..0d925e6 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsViewModel.kt @@ -4,7 +4,9 @@ import androidx.lifecycle.viewModelScope import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel +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.vault.contract.RemoteVaultAuthenticator import com.github.nullptroma.wallenc.vault.contract.VaultDescriptor import com.github.nullptroma.wallenc.vault.contract.VaultRegistrar @@ -25,6 +27,7 @@ class RemoteVaultsViewModel @Inject constructor( private val vaultRegistrar: VaultRegistrar, val remoteAuthenticator: RemoteVaultAuthenticator, private val taskOrchestrator: ITaskOrchestrator, + private val uiStrings: UiStringResolver, ) : ViewModelBase(RemoteVaultsScreenState()) { val uiState = combine( @@ -58,7 +61,7 @@ class RemoteVaultsViewModel @Inject constructor( fun onLinkSucceeded(registration: VaultRegistration) { setBusy(true) taskOrchestrator.enqueue( - title = "Add remote vault", + title = uiStrings(R.string.task_title_add_remote_vault), dispatcher = Dispatchers.IO, work = { ctx -> try { @@ -90,7 +93,7 @@ class RemoteVaultsViewModel @Inject constructor( val uuid = pending.uuid setBusy(true) taskOrchestrator.enqueue( - title = "Remove remote vault", + title = uiStrings(R.string.task_title_remove_remote_vault), dispatcher = Dispatchers.IO, work = { ctx -> try { diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineViewModel.kt index b07f008..907e91b 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineViewModel.kt @@ -3,6 +3,8 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.tasks import androidx.lifecycle.ViewModel import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel +import com.github.nullptroma.wallenc.ui.R +import com.github.nullptroma.wallenc.ui.resources.UiStringResolver import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -11,13 +13,17 @@ import javax.inject.Inject @HiltViewModel class TaskPipelineViewModel @Inject constructor( val orchestrator: ITaskOrchestrator, + private val uiStrings: UiStringResolver, ) : ViewModel() { fun startTestTask(durationSec: Int, infinityIndeterminateProgress: Boolean) { val safeDurationSec = durationSec.coerceIn(0, 60) val title = - if (infinityIndeterminateProgress) "Test task (${safeDurationSec}s, ∞)" - else "Test task (${safeDurationSec}s)" + if (infinityIndeterminateProgress) { + uiStrings(R.string.task_pipeline_test_running_infinity, safeDurationSec) + } else { + uiStrings(R.string.task_pipeline_test_running, safeDurationSec) + } orchestrator.enqueue( title = title, dispatcher = Dispatchers.Default, 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 0fe6a43..b823095 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 @@ -1,5 +1,6 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.vault +import androidx.annotation.StringRes import androidx.lifecycle.viewModelScope import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey import com.github.nullptroma.wallenc.domain.datatypes.Tree @@ -10,20 +11,25 @@ import com.github.nullptroma.wallenc.domain.interfaces.IStorage import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel +import com.github.nullptroma.wallenc.domain.tasks.TaskRunState import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase import com.github.nullptroma.wallenc.usecases.RemoveStorageUseCase import com.github.nullptroma.wallenc.usecases.RenameStorageUseCase 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.ui.resources.UiStringResolver +import com.github.nullptroma.wallenc.ui.resources.UserNotification import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import java.util.UUID import kotlin.system.measureTimeMillis @@ -42,28 +48,24 @@ abstract class AbstractVaultBrowserViewModel( private val renameStorageUseCase: RenameStorageUseCase, private val manageVaultUseCase: ManageVaultUseCase, private val taskOrchestrator: ITaskOrchestrator, + private val uiStrings: UiStringResolver, private val logger: ILogger, ) : ViewModelBase( - VaultBrowserScreenState(storagesList = emptyList(), isLoading = true, addStorageFabEnabled = false), + VaultBrowserScreenState( + storagesList = emptyList(), + storagesRefreshing = true, + busyStorageUuids = emptySet(), + vaultListMutationActive = false, + addStorageFabEnabled = false, + ), ) { - private val _messages = MutableSharedFlow() - val messages: SharedFlow = _messages - - private var taskCount: Int = 0 - set(value) { - field = value - updateStateLoading() - } - - private var storagesLoading: Boolean = false - set(value) { - field = value - updateStateLoading() - } + private val _userNotifications = MutableSharedFlow(extraBufferCapacity = 8) + val userNotifications: SharedFlow = _userNotifications init { - collectFlows(storagesFlow) + collectStoragesFlow(storagesFlow) + collectPipelineBusyFlags() viewModelScope.launch { vaultAvailabilityFlow .distinctUntilChanged() @@ -74,40 +76,80 @@ abstract class AbstractVaultBrowserViewModel( } } - private fun updateStateLoading() { - updateState( - state.value.copy( - isLoading = storagesLoading || taskCount > 0, - ), - ) + private fun isPipelineTaskActive(state: TaskRunState): Boolean = + when (state) { + is TaskRunState.Queued, + is TaskRunState.Running, + -> true + else -> false + } + + private fun isStorageTaskActive(storageUuid: UUID): Boolean = + taskOrchestrator.pipelineState.value.tasks.any { t -> + t.busyStorageUuid == storageUuid && isPipelineTaskActive(t.state) + } + + private fun isVaultListMutationActive(): Boolean = + taskOrchestrator.pipelineState.value.tasks.any { t -> + t.locksVaultStorageList && isPipelineTaskActive(t.state) + } + + private fun collectStoragesFlow(storagesFlow: Flow>) { + viewModelScope.launch { + combine( + storagesFlow, + getOpenedStoragesUseCase.openedStorages, + ) { storages, opened -> storages to opened } + .collect { (storages, opened) -> + val list = mutableListOf>() + for (storage in storages) { + var tree = Tree(storage) + list.add(tree) + while (opened.containsKey(tree.value.uuid)) { + val child = opened.getValue(tree.value.uuid) + val nextTree = Tree(child) + tree.children = listOf(nextTree) + tree = nextTree + } + } + updateState( + state.value.copy( + storagesList = list, + storagesRefreshing = false, + ), + ) + } + } } - private fun collectFlows(storagesFlow: Flow>) { + private fun collectPipelineBusyFlags() { viewModelScope.launch { - storagesFlow.combine(getOpenedStoragesUseCase.openedStorages) { storages, opened -> - val list = mutableListOf>() - for (storage in storages) { - var tree = Tree(storage) - list.add(tree) - while (opened.containsKey(tree.value.uuid)) { - val child = opened.getValue(tree.value.uuid) - val nextTree = Tree(child) - tree.children = listOf(nextTree) - tree = nextTree - } + taskOrchestrator.pipelineState + .map { pipe -> + val activeTasks = pipe.tasks.filter { isPipelineTaskActive(it.state) } + val busyUuids = activeTasks.mapNotNull { it.busyStorageUuid }.toSet() + val listMut = activeTasks.any { it.locksVaultStorageList } + busyUuids to listMut + } + .distinctUntilChanged() + .collect { (busyUuids, listMut) -> + updateState( + state.value.copy( + busyStorageUuids = busyUuids, + vaultListMutationActive = listMut, + ), + ) } - list - }.collect { trees -> - storagesLoading = false - updateState(state.value.copy(storagesList = trees)) - } } } fun printStorageInfoToLog(storage: IStorageInfo) { + val id = storage.uuid + if (isStorageTaskActive(id)) return taskOrchestrator.enqueue( - title = "Dump storage to log", + title = uiStrings(R.string.task_title_dump_storage_log), dispatcher = Dispatchers.IO, + busyStorageUuid = id, work = { ctx -> storageFileManagementUseCase.setStorage(storage) ctx.log(TaskLogLevel.Info, "Enumerating files and directories…") @@ -138,10 +180,15 @@ abstract class AbstractVaultBrowserViewModel( logger.debug(TAG, "createStorage ignored (vault unavailable or FAB disabled)") return } + if (isVaultListMutationActive()) { + logger.debug(TAG, "createStorage ignored (vault list mutation already running)") + return + } logger.debug(TAG, "createStorage: enqueue task") taskOrchestrator.enqueue( - title = "Create storage", + title = uiStrings(R.string.task_title_create_storage), dispatcher = Dispatchers.IO, + locksVaultStorageList = true, work = { ctx -> try { ctx.log(TaskLogLevel.Info, "Creating storage…") @@ -160,24 +207,14 @@ abstract class AbstractVaultBrowserViewModel( ) } - private companion object { - private const val TAG = "VaultBrowser" - } - - private val storageOpMutex = Any() - private val runningStorages = mutableSetOf() - fun enableEncryption(storage: IStorageInfo, password: String, encryptPath: Boolean) { val id = storage.uuid - synchronized(storageOpMutex) { - if (runningStorages.contains(id)) return - runningStorages.add(id) - taskCount++ - } + if (isStorageTaskActive(id)) return val key = EncryptKey(password) taskOrchestrator.enqueue( - title = "Enable encryption", + title = uiStrings(R.string.task_title_enable_encryption), dispatcher = Dispatchers.IO, + busyStorageUuid = id, work = { ctx -> try { ctx.log(TaskLogLevel.Info, "Checking storage…") @@ -187,33 +224,33 @@ abstract class AbstractVaultBrowserViewModel( manageStoragesEncryptionUseCase.enableEncryption(storage, key, encryptPath) manageStoragesEncryptionUseCase.openStorage(storage, key, true) ctx.log(TaskLogLevel.Info, "Encryption enabled") - _messages.emit("Encryption enabled") + _userNotifications.emit(UserNotification.TextRes(R.string.msg_encryption_enabled)) } ManageStoragesEncryptionUseCase.CanEncryptResult.AlreadyEncrypted -> { ctx.log(TaskLogLevel.Info, "Storage is already encrypted") - _messages.emit("Storage is already encrypted") + _userNotifications.emit(UserNotification.TextRes(R.string.msg_storage_already_encrypted)) } ManageStoragesEncryptionUseCase.CanEncryptResult.StorageIsNotEmpty -> { ctx.log(TaskLogLevel.Info, "Storage is not empty") - _messages.emit("Storage is not empty") + _userNotifications.emit(UserNotification.TextRes(R.string.msg_storage_not_empty)) } ManageStoragesEncryptionUseCase.CanEncryptResult.StorageStateUnknown -> { ctx.log(TaskLogLevel.Info, "Cannot determine whether storage is empty") - _messages.emit("Cannot determine whether storage is empty") + _userNotifications.emit(UserNotification.TextRes(R.string.msg_storage_empty_state_unknown)) } ManageStoragesEncryptionUseCase.CanEncryptResult.UnsupportedStorageType -> { ctx.log(TaskLogLevel.Info, "Unsupported storage type") - _messages.emit("Unsupported storage type") + _userNotifications.emit(UserNotification.TextRes(R.string.msg_unsupported_storage_type)) } } } catch (e: Exception) { ctx.log(TaskLogLevel.Error, e.message ?: "Failed to enable encryption") - _messages.emit(e.message ?: "Failed to enable encryption") - } finally { - synchronized(storageOpMutex) { - runningStorages.remove(id) - taskCount-- - } + _userNotifications.emit( + UserNotification.TextRes( + R.string.msg_failed_enable_encryption, + listOf(e.message ?: e.toString()), + ), + ) } }, ) @@ -221,37 +258,38 @@ abstract class AbstractVaultBrowserViewModel( fun openEncryptedStorage(storage: IStorageInfo, password: String, rememberPassword: Boolean) { val id = storage.uuid - synchronized(storageOpMutex) { - if (runningStorages.contains(id)) return - runningStorages.add(id) - taskCount++ - } + if (isStorageTaskActive(id)) return val key = EncryptKey(password) taskOrchestrator.enqueue( - title = "Open encrypted storage", + title = uiStrings(R.string.task_title_open_encrypted_storage), dispatcher = Dispatchers.IO, + busyStorageUuid = id, work = { ctx -> try { - ctx.log(TaskLogLevel.Info, "Opening storage…") + ctx.reportProgress(null, uiStrings(R.string.task_progress_decrypt_running)) + ctx.log(TaskLogLevel.Info, "Opening encrypted storage…") manageStoragesEncryptionUseCase.openStorage(storage, key, rememberPassword) ctx.log(TaskLogLevel.Info, "Storage opened") } catch (e: Exception) { ctx.log(TaskLogLevel.Error, e.message ?: "Failed to open encrypted storage") - _messages.emit(e.message ?: "Failed to open encrypted storage") - } finally { - synchronized(storageOpMutex) { - runningStorages.remove(id) - taskCount-- - } + _userNotifications.emit( + UserNotification.TextRes( + R.string.msg_failed_open_storage, + listOf(e.message ?: e.toString()), + ), + ) } }, ) } fun closeEncryptedStorage(storage: IStorageInfo) { + val id = storage.uuid + if (isStorageTaskActive(id)) return taskOrchestrator.enqueue( - title = "Close encrypted storage", + title = uiStrings(R.string.task_title_close_encrypted_storage), dispatcher = Dispatchers.IO, + busyStorageUuid = id, work = { ctx -> try { ctx.log(TaskLogLevel.Info, "Closing storage…") @@ -259,16 +297,24 @@ abstract class AbstractVaultBrowserViewModel( ctx.log(TaskLogLevel.Info, "Storage closed") } catch (e: Exception) { ctx.log(TaskLogLevel.Error, e.message ?: "Failed to close encrypted storage") - _messages.emit(e.message ?: "Failed to close encrypted storage") + _userNotifications.emit( + UserNotification.TextRes( + R.string.msg_failed_close_storage, + listOf(e.message ?: e.toString()), + ), + ) } }, ) } fun disableEncryption(storage: IStorageInfo) { + val id = storage.uuid + if (isStorageTaskActive(id)) return taskOrchestrator.enqueue( - title = "Disable encryption", + title = uiStrings(R.string.task_title_disable_encryption), dispatcher = Dispatchers.IO, + busyStorageUuid = id, work = { ctx -> try { ctx.log(TaskLogLevel.Info, "Disabling encryption…") @@ -276,19 +322,27 @@ abstract class AbstractVaultBrowserViewModel( ctx.reportProgress(p) } ctx.log(TaskLogLevel.Info, "Encryption disabled") - _messages.emit("Encryption disabled") + _userNotifications.emit(UserNotification.TextRes(R.string.msg_encryption_disabled)) } catch (e: Exception) { ctx.log(TaskLogLevel.Error, e.message ?: "Failed") - _messages.emit(e.message ?: "Failed to disable encryption") + _userNotifications.emit( + UserNotification.TextRes( + R.string.msg_failed_disable_encryption, + listOf(e.message ?: e.toString()), + ), + ) } }, ) } fun rename(storage: IStorageInfo, newName: String) { + val id = storage.uuid + if (isStorageTaskActive(id)) return taskOrchestrator.enqueue( - title = "Rename storage", + title = uiStrings(R.string.task_title_rename_storage), dispatcher = Dispatchers.IO, + busyStorageUuid = id, work = { ctx -> try { ctx.log(TaskLogLevel.Info, "Renaming…") @@ -302,9 +356,13 @@ abstract class AbstractVaultBrowserViewModel( } fun remove(storage: IStorageInfo) { + val id = storage.uuid + if (isStorageTaskActive(id)) return taskOrchestrator.enqueue( - title = "Remove storage", + title = uiStrings(R.string.task_title_remove_storage), dispatcher = Dispatchers.IO, + busyStorageUuid = id, + locksVaultStorageList = true, work = { ctx -> try { ctx.log(TaskLogLevel.Info, "Removing storage…") @@ -317,11 +375,12 @@ abstract class AbstractVaultBrowserViewModel( ) } - fun getStorageStatus(storage: IStorageInfo): String { + @StringRes + fun getStorageStatusRes(storage: IStorageInfo): Int { val encrypted = storage.metaInfo.value.encInfo != null - if (!encrypted) return "Not encrypted" + if (!encrypted) return R.string.storage_status_not_encrypted val opened = isEncryptionSessionOpen(storage) - return if (opened) "Encrypted (opened)" else "Encrypted (closed)" + return if (opened) R.string.storage_status_encrypted_open else R.string.storage_status_encrypted_closed } fun isEncryptionSessionOpen(storage: IStorageInfo): Boolean { @@ -339,26 +398,38 @@ abstract class AbstractVaultBrowserViewModel( } fun clearStorageSyncLock(storage: IStorageInfo) { + val id = storage.uuid + if (isStorageTaskActive(id)) return taskOrchestrator.enqueue( - title = "Снятие блокировки синхронизации", + title = uiStrings(R.string.task_title_clear_sync_lock), dispatcher = Dispatchers.IO, + busyStorageUuid = id, work = { ctx -> try { val s = storage as? IStorage if (s == null) { - ctx.log(TaskLogLevel.Error, "Некорректное хранилище") - _messages.emit("Некорректное хранилище") + ctx.log(TaskLogLevel.Error, "Invalid storage") + _userNotifications.emit(UserNotification.TextRes(R.string.msg_invalid_storage_for_sync_lock)) return@enqueue } - ctx.log(TaskLogLevel.Info, "Снимаю блокировку синхронизации…") + ctx.log(TaskLogLevel.Info, "Clearing sync lock…") s.accessor.forceClearSyncLock() - ctx.log(TaskLogLevel.Info, "Блокировка синхронизации снята") - _messages.emit("Блокировка синхронизации снята") + ctx.log(TaskLogLevel.Info, "Sync lock cleared") + _userNotifications.emit(UserNotification.TextRes(R.string.msg_sync_lock_cleared)) } catch (e: Exception) { - ctx.log(TaskLogLevel.Error, e.message ?: "Не удалось снять блокировку") - _messages.emit(e.message ?: "Не удалось снять блокировку синхронизации") + 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()), + ), + ) } }, ) } + + private companion object { + private const val TAG = "VaultBrowser" + } } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/LocalVaultViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/LocalVaultViewModel.kt index 72f68ee..c030c01 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/LocalVaultViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/LocalVaultViewModel.kt @@ -3,6 +3,7 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.vault import com.github.nullptroma.wallenc.domain.interfaces.ILogger import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator +import com.github.nullptroma.wallenc.ui.resources.UiStringResolver import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase @@ -29,6 +30,7 @@ class LocalVaultViewModel @Inject constructor( manageStoragesEncryptionUseCase: ManageStoragesEncryptionUseCase, renameStorageUseCase: RenameStorageUseCase, taskOrchestrator: ITaskOrchestrator, + uiStrings: UiStringResolver, logger: ILogger, ) : AbstractVaultBrowserViewModel( storagesFlow = vaultsManager.vaults @@ -45,5 +47,6 @@ class LocalVaultViewModel @Inject constructor( renameStorageUseCase = renameStorageUseCase, manageVaultUseCase = manageVaultUseCase, taskOrchestrator = taskOrchestrator, + uiStrings = uiStrings, logger = logger, ) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/RemoteVaultViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/RemoteVaultViewModel.kt index 7e0a766..a5e3b88 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/RemoteVaultViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/RemoteVaultViewModel.kt @@ -3,6 +3,7 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.vault import androidx.lifecycle.SavedStateHandle import com.github.nullptroma.wallenc.domain.interfaces.ILogger import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator +import com.github.nullptroma.wallenc.ui.resources.UiStringResolver import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase @@ -27,6 +28,7 @@ class RemoteVaultViewModel @Inject constructor( manageStoragesEncryptionUseCase: ManageStoragesEncryptionUseCase, renameStorageUseCase: RenameStorageUseCase, taskOrchestrator: ITaskOrchestrator, + uiStrings: UiStringResolver, logger: ILogger, ) : AbstractVaultBrowserViewModel( storagesFlow = manageVaultUseCase.storagesOf(savedStateHandle.requireVaultUuid()), @@ -40,6 +42,7 @@ class RemoteVaultViewModel @Inject constructor( renameStorageUseCase = renameStorageUseCase, manageVaultUseCase = manageVaultUseCase, taskOrchestrator = taskOrchestrator, + uiStrings = uiStrings, logger = logger, ) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreen.kt index 46565a3..0375ac0 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreen.kt @@ -2,10 +2,13 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.vault import android.widget.Toast import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -18,6 +21,7 @@ import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -26,10 +30,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.elements.StorageTree -import com.github.nullptroma.wallenc.ui.extensions.gesturesDisabled +import com.github.nullptroma.wallenc.ui.resources.UserNotification +import java.util.UUID @Composable fun VaultBrowserScreen( @@ -40,72 +48,142 @@ fun VaultBrowserScreen( val uiState by viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current LaunchedEffect(Unit) { - viewModel.messages.collect { message -> - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + viewModel.userNotifications.collect { notification -> + val text = when (notification) { + is UserNotification.TextRes -> { + if (notification.formatArgs.isEmpty()) { + context.getString(notification.id) + } else { + context.getString(notification.id, *notification.formatArgs.toTypedArray()) + } + } + is UserNotification.Plain -> notification.message + } + Toast.makeText(context, text, Toast.LENGTH_SHORT).show() } } + val fabEnabled = uiState.addStorageFabEnabled + val fabBusy = uiState.vaultListMutationActive + val showFullscreenLoader = uiState.storagesList.isEmpty() && uiState.storagesRefreshing + val showEmptyState = uiState.storagesList.isEmpty() && !uiState.storagesRefreshing + val isUuidBusy: (UUID) -> Boolean = { uuid -> uuid in uiState.busyStorageUuids } + Box { Scaffold( modifier = modifier, contentWindowInsets = WindowInsets(0.dp), floatingActionButton = { - val fabEnabled = uiState.addStorageFabEnabled FloatingActionButton( - onClick = { if (fabEnabled) viewModel.createStorage() }, - containerColor = if (fabEnabled) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - contentColor = if (fabEnabled) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f) + onClick = { + if (fabEnabled && !fabBusy) { + viewModel.createStorage() + } }, + modifier = Modifier.alpha(if (fabEnabled && !fabBusy) 1f else 0.38f), ) { - Icon(Icons.Filled.Add, contentDescription = null) + Icon( + Icons.Filled.Add, + contentDescription = stringResource( + when { + !fabEnabled -> R.string.vault_fab_add_storage_disabled_cd + fabBusy -> R.string.vault_fab_add_storage_busy_cd + else -> R.string.vault_fab_add_storage_cd + }, + ), + ) } }, ) { innerPadding -> - LazyColumn( + Column( modifier = Modifier .padding(innerPadding) - .gesturesDisabled(uiState.isLoading), + .fillMaxSize(), ) { - items(uiState.storagesList) { listItem -> - StorageTree( - modifier = Modifier.padding(8.dp, 8.dp, 8.dp, 0.dp), - tree = listItem, - onClick = { openTextEdit(it.value.uuid.toString()) }, - onRename = { tree, newName -> viewModel.rename(tree.value, newName) }, - onRemove = { tree -> viewModel.remove(tree.value) }, - onEncrypt = { tree, password, encryptPath -> - viewModel.enableEncryption(tree.value, password, encryptPath) - }, - onOpenEncrypted = { tree, password, remember -> - viewModel.openEncryptedStorage(tree.value, password, remember) - }, - onCloseEncrypted = { tree -> viewModel.closeEncryptedStorage(tree.value) }, - onDisableEncryption = { tree -> viewModel.disableEncryption(tree.value) }, - getStatusText = { tree -> viewModel.getStorageStatus(tree.value) }, - isEncryptionOpened = { tree -> viewModel.isEncryptionSessionOpen(tree.value) }, - isStorageSyncLockHeld = { info -> viewModel.isStorageSyncLockHeld(info) }, - onClearStorageSyncLock = { info -> viewModel.clearStorageSyncLock(info) }, + if (!fabEnabled) { + Text( + text = stringResource(R.string.vault_unavailable_banner), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), ) } - item { Spacer(modifier = Modifier.height(8.dp)) } + Box( + modifier = Modifier.fillMaxSize(), + ) { + when { + showEmptyState -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.vault_empty_list_hint), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(uiState.storagesList) { listItem -> + StorageTree( + modifier = Modifier.padding(8.dp, 8.dp, 8.dp, 0.dp), + tree = listItem, + isUuidBusy = isUuidBusy, + onClick = { openTextEdit(it.value.uuid.toString()) }, + onRename = { tree, newName -> viewModel.rename(tree.value, newName) }, + onRemove = { tree -> viewModel.remove(tree.value) }, + onEncrypt = { tree, password, encryptPath -> + viewModel.enableEncryption(tree.value, password, encryptPath) + }, + onOpenEncrypted = { tree, password, remember -> + viewModel.openEncryptedStorage(tree.value, password, remember) + }, + onCloseEncrypted = { tree -> viewModel.closeEncryptedStorage(tree.value) }, + onDisableEncryption = { tree -> viewModel.disableEncryption(tree.value) }, + getStatusTextRes = { tree -> viewModel.getStorageStatusRes(tree.value) }, + isEncryptionOpened = { tree -> viewModel.isEncryptionSessionOpen(tree.value) }, + isStorageSyncLockHeld = { info -> viewModel.isStorageSyncLockHeld(info) }, + onClearStorageSyncLock = { info -> viewModel.clearStorageSyncLock(info) }, + ) + } + item { Spacer(modifier = Modifier.height(8.dp)) } + } + } + } + } } } - if (uiState.isLoading) { + if (showFullscreenLoader) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize().alpha(0.6f).background(Color.Black)) - CircularProgressIndicator( - modifier = Modifier.size(64.dp), - color = MaterialTheme.colorScheme.secondary, - trackColor = MaterialTheme.colorScheme.surfaceVariant, - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.size(64.dp), + color = MaterialTheme.colorScheme.secondary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + Text( + text = stringResource(R.string.vault_loading_storages), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onPrimary, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 24.dp), + ) + } } } } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreenState.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreenState.kt index 3508ad1..09cb4f6 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreenState.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreenState.kt @@ -2,10 +2,15 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.vault import com.github.nullptroma.wallenc.domain.datatypes.Tree import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo +import java.util.UUID data class VaultBrowserScreenState( val storagesList: List>, - val isLoading: Boolean, - /** FAB «добавить storage»: активна только когда vault доступен (сеть/API/путь). */ + /** Первый снимок списка storages ещё не получен (удалённый vault). */ + val storagesRefreshing: Boolean, + /** Storages с активной задачей в [com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator]. */ + val busyStorageUuids: Set, + /** Активна задача, меняющая состав списка storages (создание и т.п.). */ + val vaultListMutationActive: Boolean, val addStorageFabEnabled: Boolean = false, ) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/shared/TextEditScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/shared/TextEditScreen.kt index 552ab05..fd151ff 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/shared/TextEditScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/shared/TextEditScreen.kt @@ -1,9 +1,19 @@ package com.github.nullptroma.wallenc.ui.screens.shared +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.nullptroma.wallenc.ui.R @Composable fun TextEditScreen(text: String) { - Text("Hello from TextEdit with text $text") -} \ No newline at end of file + Text( + text = stringResource(R.string.text_edit_screen_placeholder, text), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(16.dp), + ) +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreen.kt index 45e80d0..2d60a70 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreen.kt @@ -3,6 +3,7 @@ package com.github.nullptroma.wallenc.ui.screens.sync import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -10,15 +11,21 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.clickable import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.ExpandLess +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.material.icons.rounded.Sync import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -27,12 +34,16 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily 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.UserNotification import java.util.UUID @Composable @@ -64,9 +75,13 @@ fun StorageSyncScreen( modifier = modifier, floatingActionButton = { FloatingActionButton( - onClick = viewModel::createGroup, + onClick = { if (!state.isBusy) viewModel.createGroup() }, + modifier = Modifier.alpha(if (state.isBusy) 0.38f else 1f), ) { - Text("+") + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = stringResource(R.string.sync_fab_create_group_cd), + ) } }, ) { inner -> @@ -77,14 +92,38 @@ fun StorageSyncScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = viewModel::runSyncNow, enabled = !state.isBusy) { + if (state.isBusy) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + onClick = viewModel::runSyncNow, + enabled = !state.isBusy, + ) { + Icon( + Icons.Rounded.Sync, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + ) Text(stringResource(id = R.string.sync_run_now)) } } - state.message?.let { - Text(text = it, style = MaterialTheme.typography.bodyMedium) + state.userMessage?.let { um -> + val text = when (um) { + is UserNotification.TextRes -> { + if (um.formatArgs.isEmpty()) { + stringResource(um.id) + } else { + stringResource(um.id, *um.formatArgs.toTypedArray()) + } + } + is UserNotification.Plain -> um.message + } + Text(text = text, style = MaterialTheme.typography.bodyMedium) } Text( @@ -105,20 +144,24 @@ fun StorageSyncScreen( ), ) { Column( - modifier = Modifier.padding(10.dp), + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text(text = group.id, style = MaterialTheme.typography.titleSmall) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - IconButton( - onClick = { viewModel.openPicker(group.id) }, - enabled = !state.isBusy, - ) { - Icon( - imageVector = Icons.Rounded.Add, - contentDescription = stringResource(id = R.string.sync_add_storage), - ) - } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Text( + text = group.id, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .weight(1f) + .padding(end = 4.dp), + maxLines = 4, + ) IconButton( onClick = { pendingRemoveGroupId = group.id }, enabled = !state.isBusy, @@ -134,6 +177,7 @@ fun StorageSyncScreen( Text( text = stringResource(id = R.string.sync_group_empty), style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { val hasMixedEncryption = hasEncryptionMismatch(group, state.vaults) @@ -147,8 +191,25 @@ fun StorageSyncScreen( group.storageUuids.forEach { storageUuid -> val storage = storageByUuid[storageUuid] - val storageLabel = storage?.name ?: storageUuid.toString() - val encryptionStatus = storage?.encryptionStatus ?: "Unknown" + val titleText = storage?.name + ?: stringResource(R.string.sync_storage_missing_title) + val encLabel = encryptionKindLabel(storage?.encryptionKind) + val statusLine = when { + storage != null && !storage.isReachable -> + stringResource(R.string.sync_storage_unreachable) + storage == null && state.anyVaultStoragesScanning -> + stringResource(R.string.sync_storage_pending_vault_scan) + storage == null && !state.anyVaultStoragesScanning -> + stringResource(R.string.sync_storage_not_in_vaults) + else -> null + } + val statusColor = when { + storage != null && !storage.isReachable -> + MaterialTheme.colorScheme.error + storage == null -> + MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.onSurfaceVariant + } Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( @@ -158,14 +219,39 @@ fun StorageSyncScreen( Row( modifier = Modifier .fillMaxWidth() - .padding(8.dp), + .padding(12.dp), horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, ) { - Text( - text = "$storageLabel ($storageUuid) | $encryptionStatus", - style = MaterialTheme.typography.bodySmall, + Column( modifier = Modifier.weight(1f), - ) + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = titleText, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = storageUuid.toString(), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + statusLine?.let { line -> + Text( + text = line, + style = MaterialTheme.typography.bodySmall, + color = statusColor, + ) + } + Text( + text = stringResource( + R.string.sync_storage_encryption_line, + encLabel, + ), + style = MaterialTheme.typography.bodySmall, + ) + } IconButton( onClick = { pendingRemoveStorage = group.id to storageUuid }, enabled = !state.isBusy, @@ -179,6 +265,23 @@ fun StorageSyncScreen( } } } + + HorizontalDivider(modifier = Modifier.padding(top = 4.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + IconButton( + onClick = { viewModel.openPicker(group.id) }, + enabled = !state.isBusy, + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = stringResource(id = R.string.sync_add_storage), + ) + } + } } } } @@ -253,6 +356,16 @@ fun StorageSyncScreen( } } +@Composable +private fun encryptionKindLabel(kind: StorageSyncEncryptionKind?): String { + return when (kind) { + null -> stringResource(R.string.sync_encryption_unknown) + StorageSyncEncryptionKind.NotEncrypted -> stringResource(R.string.enc_status_not_encrypted) + StorageSyncEncryptionKind.EncryptedOpened -> stringResource(R.string.enc_status_encrypted_open) + StorageSyncEncryptionKind.EncryptedClosed -> stringResource(R.string.enc_status_encrypted) + } +} + @Composable private fun StoragePickerScreen( modifier: Modifier, @@ -271,9 +384,15 @@ private fun StoragePickerScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = onBack) { - Text(stringResource(id = R.string.sync_picker_back)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.sync_cd_picker_back), + ) } Text( text = stringResource(id = R.string.sync_picker_title, groupId), @@ -306,22 +425,35 @@ private fun StoragePickerScreen( .fillMaxWidth() .clickable { onToggleVault(vault.uuid) }, horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = vault.title, - style = MaterialTheme.typography.titleSmall, - ) - Text( - text = if (expanded) "Hide" else "Show", - style = MaterialTheme.typography.bodySmall, - ) - } Text( - text = "${vault.type} | ${vault.uuid}", - style = MaterialTheme.typography.bodySmall, + text = vault.title, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), ) + Icon( + imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, + contentDescription = if (expanded) { + stringResource(R.string.sync_picker_collapse) + } else { + stringResource(R.string.sync_picker_expand) + }, + ) + } + Text( + text = vault.type, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = vault.uuid.toString(), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) - if (expanded) { + if (expanded) { if (vault.storages.isEmpty()) { Text( text = stringResource(id = R.string.sync_picker_no_storages), @@ -369,30 +501,46 @@ private fun StoragePickerNode( .fillMaxWidth() .padding(8.dp), horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, ) { Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( text = node.name, style = MaterialTheme.typography.bodyMedium, ) Text( - text = "${node.uuid} | ${node.encryptionStatus}", + text = node.uuid.toString(), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource( + R.string.sync_storage_encryption_line, + encryptionKindLabel(node.encryptionKind), + ), style = MaterialTheme.typography.bodySmall, ) + if (!node.isReachable) { + Text( + text = stringResource(R.string.sync_storage_unreachable), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } } - Button( + IconButton( enabled = !isSelected && !isBusy, onClick = { onAddStorage(node.uuid) }, ) { - Text( - text = if (isSelected) { - stringResource(id = R.string.sync_picker_added) - } else { - stringResource(id = R.string.sync_picker_add) - }, + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = stringResource( + if (isSelected) R.string.sync_picker_added else R.string.sync_picker_cd_add, + ), ) } } @@ -420,8 +568,9 @@ private fun hasEncryptionMismatch( ): Boolean { if (group.storageUuids.isEmpty()) return false val byUuid = flattenStorageTree(vaults.flatMap { it.storages }).associateBy { it.uuid } - val statuses = group.storageUuids.mapNotNull { byUuid[it]?.encryptionStatus }.toSet() - val hasEncrypted = statuses.any { it.startsWith("Encrypted") } - val hasPlain = statuses.any { it == "Not encrypted" } + val kinds = group.storageUuids.mapNotNull { byUuid[it]?.encryptionKind }.toSet() + if (kinds.isEmpty()) return false + val hasEncrypted = kinds.any { it != StorageSyncEncryptionKind.NotEncrypted } + val hasPlain = kinds.contains(StorageSyncEncryptionKind.NotEncrypted) return hasEncrypted && hasPlain } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreenState.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreenState.kt index 01a0bdc..98d2c50 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreenState.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreenState.kt @@ -1,11 +1,20 @@ package com.github.nullptroma.wallenc.ui.screens.sync +import com.github.nullptroma.wallenc.ui.resources.UserNotification import java.util.UUID +enum class StorageSyncEncryptionKind { + NotEncrypted, + EncryptedOpened, + EncryptedClosed, +} + data class StorageSyncStorageUi( val uuid: UUID, val name: String, - val encryptionStatus: String, + val encryptionKind: StorageSyncEncryptionKind, + /** false, если storage есть в дереве, но сейчас недоступен (например, vault offline). */ + val isReachable: Boolean = true, val children: List = emptyList(), ) @@ -27,5 +36,7 @@ data class StorageSyncScreenState( val expandedVaultUuids: Set = emptySet(), val pickerGroupId: String? = null, val isBusy: Boolean = false, - val message: String? = null, + /** Любой vault ещё загружает список storages — UUID из группы могут появиться позже. */ + val anyVaultStoragesScanning: Boolean = false, + val userMessage: UserNotification? = null, ) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncViewModel.kt index ae6bb8f..4932b38 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncViewModel.kt @@ -2,8 +2,12 @@ package com.github.nullptroma.wallenc.ui.screens.sync import androidx.lifecycle.viewModelScope import com.github.nullptroma.wallenc.domain.interfaces.IStorage +import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager +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.usecases.ManageStorageSyncGroupsUseCase import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase import com.github.nullptroma.wallenc.vault.contract.DescribedVault @@ -11,22 +15,41 @@ import com.github.nullptroma.wallenc.vault.contract.VaultDescriptor import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import java.util.UUID +import javax.inject.Inject @HiltViewModel @OptIn(ExperimentalCoroutinesApi::class) -class StorageSyncViewModel @javax.inject.Inject constructor( +class StorageSyncViewModel @Inject constructor( private val groupsUseCase: ManageStorageSyncGroupsUseCase, private val runStorageSyncUseCase: RunStorageSyncUseCase, private val vaultsManager: IVaultsManager, + private val uiStrings: UiStringResolver, ) : ViewModelBase(StorageSyncScreenState()) { init { refreshGroups() observeVaults() + viewModelScope.launch { + vaultsManager.vaults + .flatMapLatest { vaults -> + if (vaults.isEmpty()) { + flowOf(false) + } else { + combine(vaults.map { it.storagesScanInProgress }) { flags -> + flags.any { it } + } + } + } + .distinctUntilChanged() + .collect { scanning -> + updateState(state.value.copy(anyVaultStoragesScanning = scanning)) + } + } } fun refreshGroups() { @@ -42,7 +65,7 @@ class StorageSyncViewModel @javax.inject.Inject constructor( fun createGroup() { viewModelScope.launch { - updateState(state.value.copy(isBusy = true, message = null)) + updateState(state.value.copy(isBusy = true, userMessage = null)) val group = groupsUseCase.createGroup() val groups = groupsUseCase.getGroups() updateState( @@ -50,7 +73,10 @@ class StorageSyncViewModel @javax.inject.Inject constructor( groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) }, pickerGroupId = null, isBusy = false, - message = "Group ${group.id} created", + userMessage = UserNotification.TextRes( + R.string.sync_msg_group_created, + listOf(group.id), + ), ), ) } @@ -58,14 +84,14 @@ class StorageSyncViewModel @javax.inject.Inject constructor( fun removeGroup(groupId: String) { viewModelScope.launch { - updateState(state.value.copy(isBusy = true, message = null)) + updateState(state.value.copy(isBusy = true, userMessage = null)) groupsUseCase.removeGroup(groupId) val groups = groupsUseCase.getGroups() updateState( state.value.copy( groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) }, isBusy = false, - message = "Group removed", + userMessage = UserNotification.TextRes(R.string.sync_msg_group_removed), ), ) } @@ -75,7 +101,7 @@ class StorageSyncViewModel @javax.inject.Inject constructor( updateState( state.value.copy( pickerGroupId = groupId, - message = null, + userMessage = null, ), ) } @@ -84,7 +110,7 @@ class StorageSyncViewModel @javax.inject.Inject constructor( updateState( state.value.copy( pickerGroupId = null, - message = null, + userMessage = null, ), ) } @@ -100,14 +126,17 @@ class StorageSyncViewModel @javax.inject.Inject constructor( fun addStorageToCurrentGroup(storageUuid: UUID) { val groupId = state.value.pickerGroupId ?: return viewModelScope.launch { - updateState(state.value.copy(isBusy = true, message = null)) + updateState(state.value.copy(isBusy = true, userMessage = null)) groupsUseCase.addStorageToGroup(groupId, storageUuid) val groups = groupsUseCase.getGroups() updateState( state.value.copy( groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) }, isBusy = false, - message = "Storage added to $groupId", + userMessage = UserNotification.TextRes( + R.string.sync_msg_storage_added, + listOf(groupId), + ), ), ) } @@ -115,22 +144,32 @@ class StorageSyncViewModel @javax.inject.Inject constructor( fun removeStorageFromGroup(groupId: String, storageUuid: UUID) { viewModelScope.launch { - updateState(state.value.copy(isBusy = true, message = null)) + updateState(state.value.copy(isBusy = true, userMessage = null)) groupsUseCase.removeStorageFromGroup(groupId, storageUuid) val groups = groupsUseCase.getGroups() updateState( state.value.copy( groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) }, isBusy = false, - message = "Storage removed from $groupId", + userMessage = UserNotification.TextRes( + R.string.sync_msg_storage_removed, + listOf(groupId), + ), ), ) } } fun runSyncNow() { - runStorageSyncUseCase.enqueue("sync-tab") - updateState(state.value.copy(message = "Sync task enqueued")) + runStorageSyncUseCase.enqueue( + displayTitle = uiStrings(R.string.task_title_storage_sync), + logReason = "sync-tab", + ) + updateState( + state.value.copy( + userMessage = UserNotification.TextRes(R.string.sync_msg_task_enqueued), + ), + ) } private fun observeVaults() { @@ -170,18 +209,21 @@ class StorageSyncViewModel @javax.inject.Inject constructor( }, ) } else { - combine(allStorages.map { it.metaInfo }) { - val metaByStorageUuid = allStorages - .mapIndexed { index, storage -> storage.uuid to it[index] } - .toMap() - + combine( + allStorages.map { storage -> + combine(storage.metaInfo, storage.isAvailable) { meta, avail -> + storage.uuid to StorageSnapshot(meta, avail) + } + }, + ) { pairs -> + val snapByUuid = pairs.associate { it.first to it.second } vaultNodes.map { (vault, trees) -> StorageSyncVaultUi( uuid = vault.uuid, title = vaultTitle(vault as? DescribedVault), type = vaultType(vault as? DescribedVault), storages = trees.map { tree -> - toStorageUi(tree, metaByStorageUuid) + toStorageUi(tree, snapByUuid) }, ) } @@ -200,18 +242,18 @@ class StorageSyncViewModel @javax.inject.Inject constructor( private fun vaultType(vault: DescribedVault?): String { val descriptor = vault?.descriptor return when (descriptor) { - is VaultDescriptor.LocalDevice -> "Local device" - is VaultDescriptor.LinkedRemote -> "Remote ${descriptor.brand.name.lowercase()}" - null -> "Unknown" + is VaultDescriptor.LocalDevice -> uiStrings(R.string.vault_type_local_device) + is VaultDescriptor.LinkedRemote -> uiStrings(R.string.vault_type_remote, descriptor.brand.name) + null -> uiStrings(R.string.vault_type_unknown) } } private fun vaultTitle(vault: DescribedVault?): String { val descriptor = vault?.descriptor return when (descriptor) { - is VaultDescriptor.LocalDevice -> "Local vault" + is VaultDescriptor.LocalDevice -> uiStrings(R.string.vault_title_local) is VaultDescriptor.LinkedRemote -> descriptor.accountDisplayName - null -> "Unknown vault" + null -> uiStrings(R.string.vault_title_unknown) } } @@ -244,27 +286,35 @@ class StorageSyncViewModel @javax.inject.Inject constructor( private fun toStorageUi( node: StorageTreeNode, - metaByStorageUuid: Map = emptyMap(), + snapshotByUuid: Map = emptyMap(), ): StorageSyncStorageUi { - val meta = metaByStorageUuid[node.storage.uuid] ?: node.storage.metaInfo.value - val encryptionStatus = when { - meta.encInfo == null -> "Not encrypted" - node.storage.isVirtualStorage -> "Encrypted (opened)" - else -> "Encrypted" + val snap = snapshotByUuid[node.storage.uuid] + val meta = snap?.meta ?: node.storage.metaInfo.value + val isReachable = snap?.isAvailable ?: node.storage.isAvailable.value + val encryptionKind = when { + meta.encInfo == null -> StorageSyncEncryptionKind.NotEncrypted + node.storage.isVirtualStorage -> StorageSyncEncryptionKind.EncryptedOpened + else -> StorageSyncEncryptionKind.EncryptedClosed } return StorageSyncStorageUi( uuid = node.storage.uuid, - name = meta.name ?: "", - encryptionStatus = encryptionStatus, + name = meta.name ?: uiStrings(R.string.no_name), + encryptionKind = encryptionKind, + isReachable = isReachable, children = node.children.map { child -> toStorageUi( node = child, - metaByStorageUuid = metaByStorageUuid, + snapshotByUuid = snapshotByUuid, ) }, ) } + private data class StorageSnapshot( + val meta: IStorageMetaInfo, + val isAvailable: Boolean, + ) + private data class StorageTreeNode( val storage: IStorage, val children: List, diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 641a216..8ca08b1 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1,69 +1,173 @@ - Local - Remotes - Main - Sync - Settings + Локальное хранилище + Локальное хранилище + Удалённые хранилища + Удалённые хранилища + Главная + Синхронизация + Настройки - Settings - Sync groups - Run sync now - Refresh - Add - Remove group - No storages in group - Remove - Back - Select storage for %1$s - Add - Added - No storages in this vault - Mixed encryption in group: define one canonical encryption mode - Remove group? - Delete sync group \"%1$s\"? - Remove storage? - Remove storage \"%1$s\" from the group? - Delete - Cancel - <noname> - Show storage item menu - Rename - Remove - Encrypt - New name - Delete storage "%1$s"? - Storage encryption actions + Статус: + Выполняется задач: %1$d + Сканирование vault: загрузка списка хранилищ… + + Настройки + Группы синхронизации + Запустить синхронизацию + Запустить синхронизацию сейчас + Обновить + Добавить хранилище в группу + Удалить группу + В группе нет хранилищ + Убрать хранилище из группы + Назад + Закрыть выбор хранилища + Выбор хранилища для %1$s + Добавить + Добавлено + Добавить хранилище в группу + В этом хранилище нет доступных каталогов + Развернуть + Свернуть + Создать группу синхронизации + В группе разное шифрование: задайте единый режим + Удалить группу? + Удалить группу синхронизации «%1$s»? + Убрать хранилище? + Убрать хранилище «%1$s» из группы? + Удалить + Отмена + Создана группа %1$s + Группа удалена + Хранилище добавлено в %1$s + Хранилище убрано из %1$s + Задача синхронизации поставлена в очередь + Неизвестно + Шифрование: %1$s + Не найдено в текущих vault + Ожидание: список хранилищ в vault ещё загружается + Нет в дереве хранилищ (удалено, другой аккаунт или не прошёл init) + Хранилище недоступно (vault или сеть) + + <без имени> + Меню хранилища + Выполняется операция с этим хранилищем + %1$s (задача выполняется) + Переименовать + Удалить + Шифрование + Новое имя + Удалить хранилище «%1$s»? + Действия с шифрованием Проверка блокировки… Снять блокировку синхронизации Синхронизация не заблокирована - Task pipeline - Jobs - Log - Cancel all - Open task pipeline - Run test task - Test task setup - Duration: %1$d s - Start - Cancel - Infinity (indeterminate progress) - Queued - Running - Completed - Cancelled - Failed: %1$s + Доступно: %1$s + да + нет + Файлов: %1$s + Размер: %1$s + Виртуальное: %1$s + Хранилище недоступно + Недоступно: %1$s - Add remote vault - No remote vaults yet. Tap + to add Yandex. - Add vault - Choose provider: - Yandex - Cancel - Yandex - Remove remote vault - Remove remote vault? - Remove \"%1$s\" from this device? The account data on the server is not deleted. + Не зашифровано + Зашифровано (открыто) + Зашифровано (закрыто) - \ No newline at end of file + Создать хранилище + Создание недоступно: хранилище недоступно + Создание хранилища уже выполняется + Хранилище недоступно. Проверьте сеть, путь или разблокировку. + Загрузка списка хранилищ… + В этом хранилище пока нет каталогов. Создайте хранилище кнопкой «+», когда оно доступно. + + Очередь задач + Задачи + Журнал + Отменить все + Открыть очередь задач + Тестовая задача + Параметры тестовой задачи + Длительность: %1$d с + Запустить + Отмена + Бесконечно (неопределённый прогресс) + Тестовая задача (%1$d с) + Тестовая задача (%1$d с, ∞) + В очереди + Выполняется + Завершено + Отменено + Ошибка: %1$s + + Выгрузка дерева в журнал + Создание хранилища + Включение шифрования + Расшифровка и открытие хранилища + Расшифровка… + Закрытие зашифрованного хранилища + Отключение шифрования + Переименование хранилища + Удаление хранилища + Снятие блокировки синхронизации + Добавление удалённого хранилища + Удаление удалённого хранилища + Синхронизация хранилищ + Фоновая синхронизация хранилищ + + Шифрование включено + Хранилище уже зашифровано + Хранилище не пустое + Не удалось определить, пусто ли хранилище + Неподдерживаемый тип хранилища + Не удалось включить шифрование: %1$s + Не удалось открыть хранилище: %1$s + Не удалось закрыть хранилище: %1$s + Шифрование отключено + Не удалось отключить шифрование: %1$s + Некорректное хранилище + Блокировка синхронизации снята + Не удалось снять блокировку: %1$s + + Добавить удалённое хранилище + Пока нет удалённых хранилищ. Нажмите «+», чтобы добавить Yandex. + Добавить хранилище + Выберите провайдера: + Яндекс + Отмена + Яндекс + Удалить удалённое хранилище + Удалить удалённое хранилище? + Удалить «%1$s» с этого устройства? Данные на сервере не удаляются. + + Отмена + ОК + Включить шифрование + Пароль + Шифровать пути + Применить + Открыть зашифрованное хранилище + Запомнить пароль + Открыть + Закрыть + Отключить шифрование + Готово + + Локальное устройство + Удалённое: %1$s + Неизвестный тип + Локальное хранилище + Неизвестное хранилище + + Не зашифровано + Зашифровано (открыто) + Зашифровано + + Текст + Содержимое: %1$s + + Неизвестно + 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 0f6f023..3aac372 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 @@ -12,17 +12,21 @@ class RunStorageSyncUseCase( ) { private val running = AtomicBoolean(false) - fun enqueue(reason: String) { + /** + * @param displayTitle заголовок задачи в UI (локализованный на стороне вызова) + * @param logReason техническая метка для логов (не для UI) + */ + fun enqueue(displayTitle: String, logReason: String) { orchestrator.enqueue( - title = "Storage sync ($reason)", + title = displayTitle, dispatcher = Dispatchers.IO, work = { ctx -> if (!running.compareAndSet(false, true)) { - ctx.log(TaskLogLevel.Info, "Storage sync skipped: already running") + ctx.log(TaskLogLevel.Info, "Storage sync skipped (already running), reason=$logReason") return@enqueue } try { - ctx.log(TaskLogLevel.Info, "Storage sync started") + ctx.log(TaskLogLevel.Info, "Storage sync started, reason=$logReason") ctx.reportProgress(null, "Storage sync: started") syncEngine.syncAllGroups { fraction, label -> ctx.reportProgress(fraction, label)