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)