feat(ui): добавлены новые состояния и компоненты для отображения статуса работы

This commit is contained in:
2026-05-13 17:22:31 +03:00
parent 6c18a1d741
commit f551efe4a6
40 changed files with 1787 additions and 542 deletions

View File

@@ -2,9 +2,10 @@ package com.github.nullptroma.wallenc.app
import android.app.Application import android.app.Application
import androidx.work.Configuration 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.sync.StorageSyncBootstrap
import com.github.nullptroma.wallenc.app.tasks.TaskPipelineForegroundBootstrap import com.github.nullptroma.wallenc.app.tasks.TaskPipelineForegroundBootstrap
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject import javax.inject.Inject
@@ -17,9 +18,6 @@ class WallencApplication : Application(), Configuration.Provider {
@Inject @Inject
lateinit var storageSyncBootstrap: StorageSyncBootstrap lateinit var storageSyncBootstrap: StorageSyncBootstrap
@Inject
lateinit var workerFactory: HiltWorkerFactory
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
taskPipelineForegroundBootstrap.start() taskPipelineForegroundBootstrap.start()
@@ -27,7 +25,13 @@ class WallencApplication : Application(), Configuration.Provider {
} }
override val workManagerConfiguration: Configuration override val workManagerConfiguration: Configuration
get() = Configuration.Builder() get() {
.setWorkerFactory(workerFactory) val factory = EntryPointAccessors.fromApplication(
applicationContext,
HiltWorkerFactoryEntryPoint::class.java,
).hiltWorkerFactory()
return Configuration.Builder()
.setWorkerFactory(factory)
.build() .build()
} }
}

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -1,6 +1,8 @@
package com.github.nullptroma.wallenc.app.sync package com.github.nullptroma.wallenc.app.sync
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager 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 com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -21,6 +23,7 @@ class StorageSyncBootstrap @Inject constructor(
private val scheduler: StorageSyncScheduler, private val scheduler: StorageSyncScheduler,
private val vaultsManager: IVaultsManager, private val vaultsManager: IVaultsManager,
private val syncRunner: RunStorageSyncUseCase, private val syncRunner: RunStorageSyncUseCase,
private val uiStrings: UiStringResolver,
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -40,7 +43,10 @@ class StorageSyncBootstrap @Inject constructor(
merge(*triggers.toTypedArray()) merge(*triggers.toTypedArray())
.debounce(DEBOUNCE_AFTER_CHANGE_MS) .debounce(DEBOUNCE_AFTER_CHANGE_MS)
.collect { .collect {
syncRunner.enqueue("debounce") syncRunner.enqueue(
displayTitle = uiStrings(R.string.task_title_storage_sync_background),
logReason = "debounce",
)
} }
} }
} }

View File

@@ -1,8 +1,8 @@
<resources> <resources>
<string name="app_name">Wallenc</string> <string name="app_name">Wallenc</string>
<string name="task_notification_channel_name">Background tasks</string> <string name="task_notification_channel_name">Фоновые задачи</string>
<string name="task_notification_title">Wallenc tasks</string> <string name="task_notification_title">Задачи Wallenc</string>
<string name="task_notification_preparing">Preparing</string> <string name="task_notification_preparing">Подготовка</string>
<string name="task_notification_indeterminate">Working</string> <string name="task_notification_indeterminate">Выполняется</string>
<string name="task_notification_cancel">Cancel</string> <string name="task_notification_cancel">Отмена</string>
</resources> </resources>

View File

@@ -8,6 +8,7 @@ import okhttp3.OkHttpClient
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.jackson.JacksonConverterFactory import retrofit2.converter.jackson.JacksonConverterFactory
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/** /**
* Фабрика REST-клиента Яндекс.Диска: отдельный [OkHttpClient] с OAuth на каждый vault, * Фабрика REST-клиента Яндекс.Диска: отдельный [OkHttpClient] с OAuth на каждый vault,
@@ -18,6 +19,9 @@ class YandexDiskApiFactory(
private val ioDispatcher: CoroutineDispatcher, private val ioDispatcher: CoroutineDispatcher,
) { ) {
/** Кеш OAuth-токена по vault, чтобы не дергать БД на каждый HTTP-запрос к cloud-api. */
private val oauthTokenCache = ConcurrentHashMap<String, Pair<Long, String>>()
private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() } private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() }
/** Без авторизации — только для одноразовых ссылок upload/download. */ /** Без авторизации — только для одноразовых ссылок upload/download. */
@@ -54,13 +58,21 @@ class YandexDiskApiFactory(
fun createApiForVault(vaultUuid: UUID): YandexDiskApi { fun createApiForVault(vaultUuid: UUID): YandexDiskApi {
val id = vaultUuid.toString() val id = vaultUuid.toString()
return createAuthenticatedApi { return createAuthenticatedApi {
runBlocking(ioDispatcher) { val now = System.currentTimeMillis()
accountRepository.getByVaultUuid(id)?.oauthToken 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 { companion object {
private const val BASE_URL = "https://cloud-api.yandex.net/" private const val BASE_URL = "https://cloud-api.yandex.net/"
private const val OAUTH_TOKEN_CACHE_TTL_MS = 120_000L
} }
} }

View File

@@ -20,9 +20,11 @@ import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.ResponseBody import okhttp3.ResponseBody
import retrofit2.HttpException import retrofit2.HttpException
import retrofit2.Response import retrofit2.Response
import java.io.FileNotFoundException
import java.io.FilterInputStream import java.io.FilterInputStream
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.util.concurrent.ConcurrentHashMap
class YandexDiskRepository( class YandexDiskRepository(
private val api: YandexDiskApi, private val api: YandexDiskApi,
@@ -30,40 +32,68 @@ class YandexDiskRepository(
private val ioDispatcher: CoroutineDispatcher, private val ioDispatcher: CoroutineDispatcher,
) { ) {
private val diskCacheLock = Any()
private var diskInfoCached: DiskInfoDto? = null
private var diskInfoCachedUntilMs: Long = 0L
private val listCache = ConcurrentHashMap<ListCacheKey, ResourceDto>()
private val getCache = ConcurrentHashMap<String, ResourceDto>()
suspend fun diskInfo(): DiskInfoDto = withContext(ioDispatcher) { 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 = suspend fun list(path: String, limit: Int, offset: Int, sort: String? = null): ResourceDto =
withContext(ioDispatcher) { withContext(ioDispatcher) {
try { val key = ListCacheKey(path = path, limit = limit, offset = offset, sort = sort)
wrapAuth { api.listResources(path, limit, offset, sort, FIELDS_LIST) } listCache[key]?.let { return@withContext it }
} catch (e: HttpException) {
if (e.code() == 404) {
ResourceDto(embedded = EmbeddedResourceListDto(items = emptyList()))
} else {
throw e
}
}
}
suspend fun get(path: String): ResourceDto = withContext(ioDispatcher) { suspend fun tryList(p: String): ResourceDto? =
wrapAuth { api.getResource(path, FIELDS_RESOURCE) }
}
suspend fun getOrNull(path: String): ResourceDto? = withContext(ioDispatcher) {
try { try {
wrapAuth { api.getResource(path, FIELDS_RESOURCE) } wrapAuth { api.listResources(p, limit, offset, sort, FIELDS_LIST) }
} catch (e: HttpException) { } catch (e: HttpException) {
if (e.code() == 404) null else throw e 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) {
getCache[path]?.let { return@withContext it }
val result = fetchResource(path)
putGetCache(path, result)
result
}
suspend fun getOrNull(path: String): ResourceDto? = withContext(ioDispatcher) {
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) { suspend fun createFolder(path: String): Unit = withContext(ioDispatcher) {
val resp = wrapAuth { api.createFolder(path) } val resp = wrapAuth { api.createFolder(path) }
when (resp.code()) { when (resp.code()) {
201 -> Unit 201, 409 -> invalidateDiskMetaCaches()
409 -> Unit
else -> throw failure("createFolder", resp) else -> throw failure("createFolder", resp)
} }
} }
@@ -71,13 +101,14 @@ class YandexDiskRepository(
suspend fun delete(path: String, permanently: Boolean = true): Unit = withContext(ioDispatcher) { suspend fun delete(path: String, permanently: Boolean = true): Unit = withContext(ioDispatcher) {
val resp = wrapAuth { api.deleteResource(path, permanently) } val resp = wrapAuth { api.deleteResource(path, permanently) }
when (resp.code()) { when (resp.code()) {
204 -> Unit 204 -> invalidateDiskMetaCaches()
202 -> { 202 -> {
val link = resp.body()?.use { body -> parseLink(body) } val link = resp.body()?.use { body -> parseLink(body) }
?: throw IOException("DELETE 202 without body") ?: throw IOException("DELETE 202 without body")
awaitOperation(link.href) awaitOperation(link.href)
invalidateDiskMetaCaches()
} }
404 -> Unit 404 -> invalidateDiskMetaCaches()
else -> throw failure("delete", resp) else -> throw failure("delete", resp)
} }
} }
@@ -91,56 +122,79 @@ class YandexDiskRepository(
throw failure("patch", resp) throw failure("patch", resp)
} }
resp.body()?.close() resp.body()?.close()
invalidateDiskMetaCaches()
} }
suspend fun uploadBytes(path: String, bytes: ByteArray, overwrite: Boolean = true): Unit = suspend fun uploadBytes(path: String, bytes: ByteArray, overwrite: Boolean = true): Unit =
withContext(ioDispatcher) { withContext(ioDispatcher) {
val link = wrapAuth { api.getUploadLink(path, overwrite) } val link = uploadLinkOrThrow(path, overwrite)
require(link.method.equals("PUT", ignoreCase = true)) { require(link.method.equals("PUT", ignoreCase = true)) {
"Unexpected upload method ${link.method}" "Unexpected upload method ${link.method}"
} }
val body = bytes.toRequestBody(OCTET_STREAM) val body = bytes.toRequestBody(OCTET_STREAM)
val req = Request.Builder().url(link.href).put(body).build() val req = Request.Builder().url(link.href).put(body).build()
repeat(LOCKED_RETRY_MAX) { attempt ->
rawHttp.newCall(req).execute().use { resp -> rawHttp.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) { 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}") throw IOException("Upload failed: HTTP ${resp.code}")
} }
} }
} }
}
suspend fun uploadFile(path: String, file: java.io.File, overwrite: Boolean = true): Unit = suspend fun uploadFile(path: String, file: java.io.File, overwrite: Boolean = true): Unit =
withContext(ioDispatcher) { withContext(ioDispatcher) {
val link = wrapAuth { api.getUploadLink(path, overwrite) } val link = uploadLinkOrThrow(path, overwrite)
require(link.method.equals("PUT", ignoreCase = true)) { require(link.method.equals("PUT", ignoreCase = true)) {
"Unexpected upload method ${link.method}" "Unexpected upload method ${link.method}"
} }
val body = file.asRequestBody(OCTET_STREAM) val body = file.asRequestBody(OCTET_STREAM)
val req = Request.Builder().url(link.href).put(body).build() val req = Request.Builder().url(link.href).put(body).build()
repeat(LOCKED_RETRY_MAX) { attempt ->
rawHttp.newCall(req).execute().use { resp -> rawHttp.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) { 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}") throw IOException("Upload failed: HTTP ${resp.code}")
} }
} }
} }
}
/** Поток должен быть закрыт вызывающим кодом — закроет HTTP-ответ. */ /** Поток должен быть закрыт вызывающим кодом — закроет HTTP-ответ. */
suspend fun openDownloadStream(path: String): InputStream = withContext(ioDispatcher) { 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)) { require(link.method.equals("GET", ignoreCase = true)) {
"Unexpected download method ${link.method}" "Unexpected download method ${link.method}"
} }
val req = Request.Builder().url(link.href).get().build() val req = Request.Builder().url(link.href).get().build()
repeat(LOCKED_RETRY_MAX) { attempt ->
val resp = rawHttp.newCall(req).execute() val resp = rawHttp.newCall(req).execute()
if (!resp.isSuccessful) { when {
resp.close() resp.isSuccessful -> {
throw IOException("Download failed: HTTP ${resp.code}")
}
val body = resp.body val body = resp.body
val stream = body?.byteStream() ?: run { val stream = body?.byteStream() ?: run {
resp.close() resp.close()
throw IOException("Download failed: missing body") throw IOException("Download failed: missing body")
} }
object : FilterInputStream(stream) { return@withContext object : FilterInputStream(stream) {
override fun close() { override fun close() {
try { try {
`in`.close() `in`.close()
@@ -150,11 +204,31 @@ class YandexDiskRepository(
} }
} }
} }
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) { private suspend fun awaitOperation(href: String) {
repeat(OPERATION_POLL_MAX) { repeat(OPERATION_POLL_MAX) {
delay(OPERATION_POLL_DELAY_MS) 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()) { when (st.status?.lowercase()) {
"success" -> return "success" -> return
"failure", "failed" -> throw IOException("Disk async operation failed") "failure", "failed" -> throw IOException("Disk async operation failed")
@@ -164,19 +238,84 @@ class YandexDiskRepository(
throw IOException("Disk async operation timed out") throw IOException("Disk async operation timed out")
} }
private suspend fun uploadLinkOrThrow(path: String, overwrite: Boolean): LinkDto {
try {
return wrapAuth { api.getUploadLink(path, overwrite) }
} catch (e: HttpException) {
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 <T> wrapAuth(crossinline block: suspend () -> T): T { private suspend inline fun <T> wrapAuth(crossinline block: suspend () -> T): T {
repeat(LOCKED_RETRY_MAX) { attempt ->
try { try {
return block() return block()
} catch (e: HttpException) { } catch (e: HttpException) {
when (e.code()) { when (e.code()) {
401 -> { 401 -> throw YandexDiskAuthException(e.message())
throw YandexDiskAuthException(e.message()) 423 -> {
if (attempt >= LOCKED_RETRY_MAX - 1) {
throw IOException(
"Yandex Disk: ресурс временно заблокирован (HTTP 423). Повторите позже.",
e,
)
}
delay(lockedBackoffMs(attempt))
} }
404 -> throw e
else -> throw e else -> throw e
} }
} }
} }
error("unreachable")
}
private fun failure(op: String, resp: Response<ResponseBody>): IOException { private fun failure(op: String, resp: Response<ResponseBody>): IOException {
val msg = resp.errorBody()?.string() ?: resp.message() val msg = resp.errorBody()?.string() ?: resp.message()
@@ -186,12 +325,29 @@ class YandexDiskRepository(
private fun parseLink(body: ResponseBody): LinkDto = private fun parseLink(body: ResponseBody): LinkDto =
jackson.readValue(body.string()) jackson.readValue(body.string())
private data class ListCacheKey(
val path: String,
val limit: Int,
val offset: Int,
val sort: String?,
)
companion object { companion object {
private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() } private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() }
private val OCTET_STREAM = "application/octet-stream".toMediaType() private val OCTET_STREAM = "application/octet-stream".toMediaType()
private const val OPERATION_POLL_DELAY_MS = 300L private const val OPERATION_POLL_DELAY_MS = 300L
private const val OPERATION_POLL_MAX = 200 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). * Урезанный набор полей для листинга каталога (см. параметр `fields` в Disk API).
*/ */

View File

@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import java.io.FileNotFoundException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.time.Instant import java.time.Instant
@@ -287,7 +288,13 @@ class EncryptedStorageAccessor(
override suspend fun openReadSystemFile(name: String): InputStream = scope.run { override suspend fun openReadSystemFile(name: String): InputStream = scope.run {
val path = Path(systemHiddenDirName, name).pathString 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 { override suspend fun openWriteSystemFile(name: String): OutputStream = scope.run {

View File

@@ -39,11 +39,14 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.time.Instant import java.time.Instant
import java.util.UUID import java.util.UUID
import kotlin.jvm.Volatile
/** /**
* Реализация [IStorageAccessor] для дерева файлов `app:/<storageUuid>/…` на Яндекс.Диске. * Реализация [IStorageAccessor] для дерева файлов `app:/<storageUuid>/…` на Яндекс.Диске.
@@ -84,6 +87,9 @@ class YandexStorageAccessor(
private var statsPersistJob: Job? = null private var statsPersistJob: Job? = null
@Volatile
private var systemDirEnsured: Boolean = false
suspend fun init() = withContext(ioDispatcher) { suspend fun init() = withContext(ioDispatcher) {
try { try {
val persisted = readPersistedStats() val persisted = readPersistedStats()
@@ -119,7 +125,8 @@ class YandexStorageAccessor(
rel.isBlank() || rel == "/" -> "" rel.isBlank() || rel == "/" -> ""
else -> rel.trimStart('/') 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 { private fun toRelPath(diskPath: String): String {
@@ -135,6 +142,10 @@ class YandexStorageAccessor(
val tail = diskPath.substring(i + needle.length).removeSuffix("/") val tail = diskPath.substring(i + needle.length).removeSuffix("/")
return if (tail.isEmpty()) "/" else "/$tail" return if (tail.isEmpty()) "/" else "/$tail"
} }
val needleEnd = "/$u"
if (diskPath.endsWith(needleEnd, ignoreCase = true)) {
return "/"
}
return "/" return "/"
} }
@@ -148,9 +159,14 @@ class YandexStorageAccessor(
rel == "/$SYSTEM_HIDDEN_DIRNAME" || rel.startsWith("/$SYSTEM_HIDDEN_DIRNAME/") rel == "/$SYSTEM_HIDDEN_DIRNAME" || rel.startsWith("/$SYSTEM_HIDDEN_DIRNAME/")
private suspend fun ensureSystemDirExists() { private suspend fun ensureSystemDirExists() {
if (systemDirEnsured) return
val p = toDiskPath("/$SYSTEM_HIDDEN_DIRNAME") 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) } guard { repo.createFolder(p) }
systemDirEnsured = true
} }
private fun statsFileRel(): String = "/$SYSTEM_HIDDEN_DIRNAME/$STATS_FILENAME" private fun statsFileRel(): String = "/$SYSTEM_HIDDEN_DIRNAME/$STATS_FILENAME"
@@ -199,6 +215,8 @@ class YandexStorageAccessor(
} catch (e: YandexDiskAuthException) { } catch (e: YandexDiskAuthException) {
reportAuthFailure() reportAuthFailure()
throw e throw e
} catch (_: IOException) {
// Запись stats — best-effort; сетевые сбои не роняем процесс (ошибки в лог UI не выводятся).
} }
} }
} }
@@ -274,6 +292,19 @@ class YandexStorageAccessor(
return files to dirs 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 = private fun ResourceDto.toCommonFile(rel: String): CommonFile =
CommonFile( CommonFile(
CommonMetaInfo( CommonMetaInfo(
@@ -505,7 +536,7 @@ class YandexStorageAccessor(
val hadFile = prior?.type == "file" val hadFile = prior?.type == "file"
val priorSize = if (prior?.type == "file") prior.size ?: 0L else 0L val priorSize = if (prior?.type == "file") prior.size ?: 0L else 0L
guard { repo.uploadFile(diskPath, tmp, overwrite = true) } guard { repo.uploadFile(diskPath, tmp, overwrite = true) }
val after = guard { repo.get(diskPath) } val after = guard { getMetadataAfterWrite(diskPath) }
if (after.type != "file") { if (after.type != "file") {
throw IllegalStateException("Expected file after upload: $path") 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 SYNC_LOCK_FILENAME = "sync-lock.json"
private const val STATS_DEBOUNCE_MS = 450L private const val STATS_DEBOUNCE_MS = 450L
private const val DATA_PAGE_LENGTH = 10 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_HIDDEN = "wallenc.hidden"
private const val PROP_DELETED = "wallenc.deleted" private const val PROP_DELETED = "wallenc.deleted"
} }

View File

@@ -33,6 +33,9 @@ class LocalVault(
private val _storages = MutableStateFlow<List<IStorage>>(emptyList()) private val _storages = MutableStateFlow<List<IStorage>>(emptyList())
override val storages: StateFlow<List<IStorage>> = _storages override val storages: StateFlow<List<IStorage>> = _storages
private val _storagesScanInProgress = MutableStateFlow(false)
override val storagesScanInProgress: StateFlow<Boolean> = _storagesScanInProgress
private val _isAvailable = MutableStateFlow(false) private val _isAvailable = MutableStateFlow(false)
override val isAvailable: StateFlow<Boolean> = _isAvailable override val isAvailable: StateFlow<Boolean> = _isAvailable
@@ -46,10 +49,15 @@ class LocalVault(
init { init {
CoroutineScope(ioDispatcher).launch { CoroutineScope(ioDispatcher).launch {
_storagesScanInProgress.value = true
try {
_isAvailable.value = path.value != null _isAvailable.value = path.value != null
if (path.value != null) { if (path.value != null) {
readStorages() readStorages()
} }
} finally {
_storagesScanInProgress.value = false
}
} }
} }

View File

@@ -10,6 +10,9 @@ import com.github.nullptroma.wallenc.vault.contract.DescribedVault
import com.github.nullptroma.wallenc.vault.contract.VaultDescriptor import com.github.nullptroma.wallenc.vault.contract.VaultDescriptor
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -42,6 +45,9 @@ class YandexVault(
private val _storages = MutableStateFlow<List<IStorage>>(emptyList()) private val _storages = MutableStateFlow<List<IStorage>>(emptyList())
override val storages: StateFlow<List<IStorage>> = _storages override val storages: StateFlow<List<IStorage>> = _storages
private val _storagesScanInProgress = MutableStateFlow(false)
override val storagesScanInProgress: StateFlow<Boolean> = _storagesScanInProgress
private val _totalSpace = MutableStateFlow<Long?>(null) private val _totalSpace = MutableStateFlow<Long?>(null)
override val totalSpace: StateFlow<Long?> = _totalSpace override val totalSpace: StateFlow<Long?> = _totalSpace
@@ -55,6 +61,7 @@ class YandexVault(
} }
private suspend fun refreshFromDisk() { private suspend fun refreshFromDisk() {
_storagesScanInProgress.value = true
_vaultReachable.value = false _vaultReachable.value = false
try { try {
val info = repo.diskInfo() val info = repo.diskInfo()
@@ -70,11 +77,13 @@ class YandexVault(
} catch (_: Exception) { } catch (_: Exception) {
_vaultReachable.value = false _vaultReachable.value = false
_storages.value = emptyList() _storages.value = emptyList()
} finally {
_storagesScanInProgress.value = false
} }
} }
private suspend fun loadStoragesList(): List<IStorage> { private suspend fun loadStoragesList(): List<IStorage> {
val out = mutableListOf<YandexStorage>() val pending = mutableListOf<YandexStorage>()
var offset = 0 var offset = 0
while (true) { while (true) {
val root = repo.list("app:/", APP_LIST_LIMIT, offset) val root = repo.list("app:/", APP_LIST_LIMIT, offset)
@@ -83,7 +92,8 @@ class YandexVault(
if (item.type != "dir") continue if (item.type != "dir") continue
val name = item.name ?: continue val name = item.name ?: continue
val storageUuid = runCatching { UUID.fromString(name) }.getOrNull() ?: continue val storageUuid = runCatching { UUID.fromString(name) }.getOrNull() ?: continue
val storage = YandexStorage( pending.add(
YandexStorage(
uuid = storageUuid, uuid = storageUuid,
repo = repo, repo = repo,
vaultAvailability = _vaultReachable, vaultAvailability = _vaultReachable,
@@ -92,16 +102,20 @@ class YandexVault(
reportAuthFailure = { reportAuthFailure = {
parentScope.launch { _vaultReachable.value = false } parentScope.launch { _vaultReachable.value = false }
}, },
),
) )
try {
storage.init()
out.add(storage)
} catch (_: Exception) { }
} }
if (items.size < APP_LIST_LIMIT) break if (items.size < APP_LIST_LIMIT) break
offset += items.size 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) { override suspend fun createStorage(): IStorage = withContext(ioDispatcher) {
@@ -135,6 +149,6 @@ class YandexVault(
} }
private companion object { private companion object {
private const val APP_LIST_LIMIT = 200 private const val APP_LIST_LIMIT = 1000
} }
} }

View File

@@ -10,6 +10,10 @@ import kotlinx.coroutines.flow.StateFlow
*/ */
interface IVault : IVaultInfo { interface IVault : IVaultInfo {
val storages: StateFlow<List<IStorage>> val storages: StateFlow<List<IStorage>>
/**
* Идёт загрузка/пересканирование списка storages (например, листинг удалённого vault и init каждого storage).
*/
val storagesScanInProgress: StateFlow<Boolean>
val isAvailable: StateFlow<Boolean> val isAvailable: StateFlow<Boolean>
val totalSpace: StateFlow<Long?> val totalSpace: StateFlow<Long?>
val availableSpace: StateFlow<Long?> val availableSpace: StateFlow<Long?>

View File

@@ -2,6 +2,7 @@ package com.github.nullptroma.wallenc.domain.tasks
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import java.util.UUID
interface ITaskOrchestrator { interface ITaskOrchestrator {
val pipelineState: StateFlow<PipelineState> val pipelineState: StateFlow<PipelineState>
@@ -12,6 +13,8 @@ interface ITaskOrchestrator {
title: String, title: String,
dispatcher: CoroutineDispatcher, dispatcher: CoroutineDispatcher,
work: PipelineWork, work: PipelineWork,
busyStorageUuid: UUID? = null,
locksVaultStorageList: Boolean = false,
): TaskId ): TaskId
fun cancel(taskId: TaskId): Boolean fun cancel(taskId: TaskId): Boolean

View File

@@ -1,10 +1,15 @@
package com.github.nullptroma.wallenc.domain.tasks package com.github.nullptroma.wallenc.domain.tasks
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import java.util.UUID
data class PipelineTask( data class PipelineTask(
val id: TaskId, val id: TaskId,
val title: String, val title: String,
val dispatcher: CoroutineDispatcher, val dispatcher: CoroutineDispatcher,
val state: TaskRunState, val state: TaskRunState,
/** UUID storage, для которого идёт задача (кнопки только этой строки в UI). */
val busyStorageUuid: UUID? = null,
/** Задача меняет список storages в vault (например создание) — блокируем FAB «+». */
val locksVaultStorageList: Boolean = false,
) )

View File

@@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Collections import java.util.Collections
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
class TaskOrchestrator( class TaskOrchestrator(
@@ -57,13 +58,21 @@ class TaskOrchestrator(
emitForegroundUiState() 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 id = TaskId()
val task = PipelineTask( val task = PipelineTask(
id = id, id = id,
title = title, title = title,
dispatcher = dispatcher, dispatcher = dispatcher,
state = TaskRunState.Queued, state = TaskRunState.Queued,
busyStorageUuid = busyStorageUuid,
locksVaultStorageList = locksVaultStorageList,
) )
synchronized(tasksById) { synchronized(tasksById) {
tasksById[id] = task tasksById[id] = task

View File

@@ -17,7 +17,6 @@ import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.NavBarItemData
import com.github.nullptroma.wallenc.ui.navigation.WallencDeepLinks import com.github.nullptroma.wallenc.ui.navigation.WallencDeepLinks
import com.github.nullptroma.wallenc.ui.navigation.matchesWallencDeepLink 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.navigation.rememberNavigationState
import com.github.nullptroma.wallenc.ui.screens.main.MainRoute import com.github.nullptroma.wallenc.ui.screens.main.MainRoute
import com.github.nullptroma.wallenc.ui.screens.main.MainScreen import com.github.nullptroma.wallenc.ui.screens.main.MainScreen
@@ -124,7 +124,11 @@ fun WallencNavRoot(
contentDescription = stringResource(navBarItemData.iconContentDescriptionResourceId), contentDescription = stringResource(navBarItemData.iconContentDescriptionResourceId),
) )
}, },
label = { Text(stringResource(navBarItemData.nameStringResourceId)) }, label = {
NavigationBarMarqueeText(
text = stringResource(navBarItemData.nameStringResourceId),
)
},
selected = currentRoute?.startsWith(routeClassName) == true, selected = currentRoute?.startsWith(routeClassName) == true,
onClick = { onClick = {
val route = topLevelRoutes[navBarItemData.screenRouteClass] val route = topLevelRoutes[navBarItemData.screenRouteClass]

View File

@@ -8,10 +8,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.Checkbox
import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -26,35 +26,47 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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 androidx.compose.ui.unit.dp
import com.github.nullptroma.wallenc.ui.R
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @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) } var name by remember { mutableStateOf(startString) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
BasicAlertDialog( BasicAlertDialog(
onDismissRequest = { onDismiss() } onDismissRequest = { onDismiss() },
) { ) {
Card { Card {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge) Text(title, style = MaterialTheme.typography.titleLarge)
TextField(modifier = Modifier.focusRequester(focusRequester), value = name, onValueChange = { TextField(
name = it modifier = Modifier.focusRequester(focusRequester),
}) value = name,
onValueChange = { name = it },
)
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly, horizontalArrangement = Arrangement.SpaceEvenly,
) { ) {
Button(modifier = Modifier.weight(1f), onClick = onDismiss) { Button(modifier = Modifier.weight(1f), onClick = onDismiss) {
Text("Cancel") Text(stringResource(R.string.dialog_cancel))
} }
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Button(modifier = Modifier.weight(1f), onClick = { Button(
modifier = Modifier.weight(1f),
onClick = {
onConfirmation(name) onConfirmation(name)
}) { },
Text("Ok") ) {
Text(stringResource(R.string.dialog_ok))
} }
} }
} }
@@ -66,12 +78,11 @@ fun TextEditCancelOkDialog(onDismiss: () -> Unit, onConfirmation: (String) -> Un
} }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ConfirmationCancelOkDialog(onDismiss: () -> Unit, onConfirmation: () -> Unit, title: String) { fun ConfirmationCancelOkDialog(onDismiss: () -> Unit, onConfirmation: () -> Unit, title: String) {
BasicAlertDialog( BasicAlertDialog(
onDismissRequest = { onDismiss() } onDismissRequest = { onDismiss() },
) { ) {
Card { Card {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {
@@ -82,13 +93,16 @@ fun ConfirmationCancelOkDialog(onDismiss: () -> Unit, onConfirmation: () -> Unit
horizontalArrangement = Arrangement.SpaceEvenly, horizontalArrangement = Arrangement.SpaceEvenly,
) { ) {
Button(modifier = Modifier.weight(1f), onClick = onDismiss) { Button(modifier = Modifier.weight(1f), onClick = onDismiss) {
Text("Cancel") Text(stringResource(R.string.dialog_cancel))
} }
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Button(modifier = Modifier.weight(1f), onClick = { Button(
modifier = Modifier.weight(1f),
onClick = {
onConfirmation() onConfirmation()
}) { },
Text("Ok") ) {
Text(stringResource(R.string.dialog_ok))
} }
} }
} }
@@ -107,22 +121,33 @@ fun EncryptionSetupDialog(
BasicAlertDialog(onDismissRequest = onDismiss) { BasicAlertDialog(onDismissRequest = onDismiss) {
Card { Card {
Column(modifier = Modifier.padding(12.dp)) { 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)) 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) { Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = encryptPath, onCheckedChange = { encryptPath = it }) Checkbox(checked = encryptPath, onCheckedChange = { encryptPath = it })
Text("Encrypt paths") Text(stringResource(R.string.dialog_encrypt_paths))
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { 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)) Spacer(modifier = Modifier.width(12.dp))
Button( Button(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
onClick = { onConfirmation(password, encryptPath) }, onClick = { onConfirmation(password, encryptPath) },
enabled = password.isNotEmpty() enabled = password.isNotEmpty(),
) { Text("Apply") } ) {
Text(stringResource(R.string.dialog_apply))
}
} }
} }
} }
@@ -140,22 +165,33 @@ fun OpenEncryptedStorageDialog(
BasicAlertDialog(onDismissRequest = onDismiss) { BasicAlertDialog(onDismissRequest = onDismiss) {
Card { Card {
Column(modifier = Modifier.padding(12.dp)) { 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)) 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) { Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = rememberPassword, onCheckedChange = { rememberPassword = it }) Checkbox(checked = rememberPassword, onCheckedChange = { rememberPassword = it })
Text("Remember password") Text(stringResource(R.string.dialog_remember_password))
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { 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)) Spacer(modifier = Modifier.width(12.dp))
Button( Button(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
onClick = { onConfirmation(password, rememberPassword) }, onClick = { onConfirmation(password, rememberPassword) },
enabled = password.isNotEmpty() enabled = password.isNotEmpty(),
) { Text("Open") } ) {
Text(stringResource(R.string.dialog_open))
}
} }
} }
} }
@@ -178,14 +214,22 @@ fun StorageEncryptionActionsDialog(
Text(title, style = MaterialTheme.typography.titleLarge) Text(title, style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
if (isOpened) { if (isOpened) {
Button(onClick = onClose, modifier = Modifier.fillMaxWidth()) { Text("Close") } Button(onClick = onClose, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.dialog_close))
}
} else { } 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)) 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)) 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))
}
} }
} }
} }

View File

@@ -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,
)
}

View File

@@ -17,8 +17,10 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -36,7 +38,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource 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.domain.interfaces.IStorageInfo
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.utils.debouncedLambda import com.github.nullptroma.wallenc.ui.utils.debouncedLambda
import java.util.UUID
@Composable @Composable
fun StorageTree( fun StorageTree(
modifier: Modifier, modifier: Modifier,
tree: Tree<IStorageInfo>, tree: Tree<IStorageInfo>,
isUuidBusy: (UUID) -> Boolean,
onClick: (Tree<IStorageInfo>) -> Unit, onClick: (Tree<IStorageInfo>) -> Unit,
onRename: (Tree<IStorageInfo>, String) -> Unit, onRename: (Tree<IStorageInfo>, String) -> Unit,
onRemove: (Tree<IStorageInfo>) -> Unit, onRemove: (Tree<IStorageInfo>) -> Unit,
@@ -62,13 +65,13 @@ fun StorageTree(
onOpenEncrypted: (Tree<IStorageInfo>, String, Boolean) -> Unit, onOpenEncrypted: (Tree<IStorageInfo>, String, Boolean) -> Unit,
onCloseEncrypted: (Tree<IStorageInfo>) -> Unit, onCloseEncrypted: (Tree<IStorageInfo>) -> Unit,
onDisableEncryption: (Tree<IStorageInfo>) -> Unit, onDisableEncryption: (Tree<IStorageInfo>) -> Unit,
getStatusText: (Tree<IStorageInfo>) -> String, getStatusTextRes: (Tree<IStorageInfo>) -> Int,
isEncryptionOpened: (Tree<IStorageInfo>) -> Boolean, isEncryptionOpened: (Tree<IStorageInfo>) -> Boolean,
isStorageSyncLockHeld: suspend (IStorageInfo) -> Boolean, isStorageSyncLockHeld: suspend (IStorageInfo) -> Boolean,
onClearStorageSyncLock: (IStorageInfo) -> Unit, onClearStorageSyncLock: (IStorageInfo) -> Unit,
) { ) {
val cur = tree.value val cur = tree.value
val available by cur.isAvailable.collectAsStateWithLifecycle() val rowBusy = isUuidBusy(cur.uuid)
val numOfFiles by cur.numberOfFiles.collectAsStateWithLifecycle() val numOfFiles by cur.numberOfFiles.collectAsStateWithLifecycle()
val size by cur.size.collectAsStateWithLifecycle() val size by cur.size.collectAsStateWithLifecycle()
val metaInfo by cur.metaInfo.collectAsStateWithLifecycle() val metaInfo by cur.metaInfo.collectAsStateWithLifecycle()
@@ -77,17 +80,20 @@ fun StorageTree(
val isOpened = isEncryptionOpened(tree) val isOpened = isEncryptionOpened(tree)
val borderColor = val borderColor =
if (cur.isVirtualStorage) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.primary 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) { Column(modifier) {
Box( Box(
modifier = Modifier modifier = Modifier
.height(IntrinsicSize.Min) .height(IntrinsicSize.Min)
.zIndex(100f) .zIndex(100f),
) { ) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
Box( Box(
modifier = Modifier modifier = Modifier
.clip( .clip(
CardDefaults.shape CardDefaults.shape,
) )
.padding(0.dp, 0.dp, 16.dp, 0.dp) .padding(0.dp, 0.dp, 16.dp, 0.dp)
.fillMaxSize() .fillMaxSize()
@@ -96,8 +102,8 @@ fun StorageTree(
interactionSource = interactionSource, interactionSource = interactionSource,
indication = ripple(), indication = ripple(),
enabled = false, enabled = false,
onClick = { } onClick = { },
) ),
) )
Card( Card(
interactionSource = interactionSource, interactionSource = interactionSource,
@@ -105,27 +111,57 @@ fun StorageTree(
.padding(8.dp, 0.dp, 0.dp, 0.dp) .padding(8.dp, 0.dp, 0.dp, 0.dp)
.fillMaxWidth(), .fillMaxWidth(),
elevation = CardDefaults.cardElevation( elevation = CardDefaults.cardElevation(
defaultElevation = 4.dp defaultElevation = 4.dp,
), ),
enabled = isAvailable && !rowBusy,
onClick = debouncedLambda(debounceMs = 500) { onClick = debouncedLambda(debounceMs = 500) {
if (isAvailable && !rowBusy) {
onClick(tree) onClick(tree)
} }
},
) { ) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) { Row(modifier = Modifier.height(IntrinsicSize.Min)) {
Column(modifier = Modifier.padding(8.dp)) { Column(modifier = Modifier.padding(8.dp)) {
Text(metaInfo.name ?: stringResource(R.string.no_name)) Text(metaInfo.name ?: stringResource(R.string.no_name))
Text( Text(
text = "IsAvailable: $available" text = stringResource(
R.string.storage_field_available,
if (isAvailable) yesWord else noWord,
),
style = MaterialTheme.typography.bodySmall,
) )
Text("Files: $numOfFiles") Text(
Text("Size: $size") text = stringResource(
Text("IsVirtual: ${cur.isVirtualStorage}") 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( Column(
modifier = Modifier, modifier = Modifier,
horizontalAlignment = Alignment.End horizontalAlignment = Alignment.End,
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
var syncLockHeld by remember { mutableStateOf<Boolean?>(null) } var syncLockHeld by remember { mutableStateOf<Boolean?>(null) }
@@ -143,47 +179,104 @@ fun StorageTree(
var showSetupEncryptionDialog by remember { mutableStateOf(false) } var showSetupEncryptionDialog by remember { mutableStateOf(false) }
var showOpenEncryptionDialog by remember { mutableStateOf(false) } var showOpenEncryptionDialog by remember { mutableStateOf(false) }
Box(modifier = Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp)) { Box(modifier = Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp)) {
IconButton(onClick = { expanded = !expanded }) { 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( Icon(
Icons.Default.MoreVert, Icons.Default.MoreVert,
contentDescription = stringResource(R.string.show_storage_item_menu) contentDescription = stringResource(
if (rowBusy) {
R.string.storage_row_task_running_cd
} else {
R.string.show_storage_item_menu
},
),
) )
} }
}
DropdownMenu( DropdownMenu(
expanded = expanded, expanded = expanded,
onDismissRequest = { expanded = false } onDismissRequest = { expanded = false },
) { ) {
DropdownMenuItem( DropdownMenuItem(
enabled = isAvailable && !rowBusy,
onClick = { onClick = {
expanded = false 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() HorizontalDivider()
DropdownMenuItem( DropdownMenuItem(
enabled = isAvailable && !rowBusy,
onClick = { onClick = {
expanded = false 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) { if (!isEncrypted) {
HorizontalDivider() HorizontalDivider()
DropdownMenuItem( DropdownMenuItem(
enabled = isAvailable && !rowBusy,
onClick = { onClick = {
expanded = false 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() HorizontalDivider()
DropdownMenuItem( DropdownMenuItem(
enabled = syncLockHeld == true, enabled = syncLockHeld == true && !rowBusy,
onClick = { onClick = {
expanded = false expanded = false
if (syncLockHeld == true) { if (syncLockHeld == true && !rowBusy) {
onClearStorageSyncLock(cur) onClearStorageSyncLock(cur)
} }
}, },
@@ -207,7 +300,7 @@ fun StorageTree(
onRename(tree, newName) onRename(tree, newName)
}, },
title = stringResource(R.string.new_name_title), title = stringResource(R.string.new_name_title),
startString = metaInfo.name ?: "" startString = metaInfo.name ?: "",
) )
} }
@@ -216,12 +309,12 @@ fun StorageTree(
onDismiss = { showRemoveConfirmDialog = false }, onDismiss = { showRemoveConfirmDialog = false },
title = stringResource( title = stringResource(
R.string.remove_confirmation_dialog, R.string.remove_confirmation_dialog,
metaInfo.name ?: "<noname>" metaInfo.name ?: stringResource(R.string.no_name),
), ),
onConfirmation = { onConfirmation = {
showRemoveConfirmDialog = false showRemoveConfirmDialog = false
onRemove(tree) onRemove(tree)
} },
) )
} }
@@ -241,7 +334,7 @@ fun StorageTree(
onDisable = { onDisable = {
showLockDialog = false showLockDialog = false
onDisableEncryption(tree) onDisableEncryption(tree)
} },
) )
} }
@@ -251,7 +344,7 @@ fun StorageTree(
onConfirmation = { password, encryptPath -> onConfirmation = { password, encryptPath ->
showSetupEncryptionDialog = false showSetupEncryptionDialog = false
onEncrypt(tree, password, encryptPath) onEncrypt(tree, password, encryptPath)
} },
) )
} }
@@ -261,16 +354,19 @@ fun StorageTree(
onConfirmation = { password, rememberPassword -> onConfirmation = { password, rememberPassword ->
showOpenEncryptionDialog = false showOpenEncryptionDialog = false
onOpenEncrypted(tree, password, rememberPassword) onOpenEncrypted(tree, password, rememberPassword)
} },
) )
} }
} }
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
if (isEncrypted) { if (isEncrypted) {
IconButton(onClick = { showLockDialog = true }) { IconButton(
onClick = { showLockDialog = true },
enabled = isAvailable && !rowBusy,
) {
Icon( Icon(
if (isOpened) Icons.Default.LockOpen else Icons.Default.Lock, 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() .fillMaxWidth()
.padding(0.dp, 0.dp, 12.dp, 0.dp) .padding(0.dp, 0.dp, 12.dp, 0.dp)
.align(Alignment.End), .align(Alignment.End),
text = getStatusText(tree), text = stringResource(getStatusTextRes(tree)),
textAlign = TextAlign.End, textAlign = TextAlign.End,
fontSize = 11.sp, fontSize = 11.sp,
) )
@@ -293,39 +389,29 @@ fun StorageTree(
fontSize = 8.sp, fontSize = 8.sp,
style = LocalTextStyle.current.copy( style = LocalTextStyle.current.copy(
platformStyle = PlatformTextStyle( 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()) { for (i in tree.children ?: listOf()) {
StorageTree( StorageTree(
Modifier Modifier
.padding(16.dp, 0.dp, 0.dp, 0.dp) .padding(16.dp, 0.dp, 0.dp, 0.dp)
.offset(y = (-4).dp), .offset(y = (-4).dp),
i, tree = i,
onClick, isUuidBusy = isUuidBusy,
onClick = onClick,
onRename, onRename,
onRemove, onRemove,
onEncrypt, onEncrypt,
onOpenEncrypted, onOpenEncrypted,
onCloseEncrypted, onCloseEncrypted,
onDisableEncryption, onDisableEncryption,
getStatusText, getStatusTextRes,
isEncryptionOpened, isEncryptionOpened,
isStorageSyncLockHeld, isStorageSyncLockHeld,
onClearStorageSyncLock, onClearStorageSyncLock,
@@ -333,4 +419,3 @@ fun StorageTree(
} }
} }
} }

View File

@@ -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
}

View File

@@ -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<Any> = emptyList()) : UserNotification()
data class Plain(val message: String) : UserNotification()
}

View File

@@ -5,24 +5,29 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets 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.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.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.toRoute 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.R
import com.github.nullptroma.wallenc.ui.navigation.NavBarItemData import com.github.nullptroma.wallenc.ui.navigation.NavBarItemData
import com.github.nullptroma.wallenc.ui.navigation.NavigationState 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.TextEditRoute
import com.github.nullptroma.wallenc.ui.screens.shared.TextEditScreen 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) @OptIn(ExperimentalMaterial3Api::class)
@androidx.compose.runtime.Composable @androidx.compose.runtime.Composable
@@ -48,85 +57,112 @@ fun MainScreen(
navState: NavigationState = rememberNavigationState(), navState: NavigationState = rememberNavigationState(),
) { ) {
val routes = viewModel.routes val routes = viewModel.routes
val mainUi by viewModel.state.collectAsStateWithLifecycle()
val localVaultViewModel: LocalVaultViewModel = hiltViewModel() val localVaultViewModel: LocalVaultViewModel = hiltViewModel()
val remoteVaultsViewModel: RemoteVaultsViewModel = hiltViewModel() val remoteVaultsViewModel: RemoteVaultsViewModel = hiltViewModel()
val childBackStackEntry by navState.navHostController.currentBackStackEntryAsState()
val showWorkStatusBar = !isTextEditDestination(childBackStackEntry?.destination?.route)
val workStatus = mainUi.workStatus
val topLevelNavBarItems = remember { val topLevelNavBarItems = remember {
mapOf( mapOf(
LocalVaultRoute::class.qualifiedName!! to NavBarItemData( 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( 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 = { Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0.dp),
topBar = {
if (showWorkStatusBar) {
MainWorkStatusBar(status = workStatus)
}
},
bottomBar = {
Column { Column {
NavigationBar(windowInsets = WindowInsets(0), modifier = Modifier.height(48.dp)) { NavigationBar(windowInsets = WindowInsets(0)) {
val navBackStackEntry by navState.navHostController.currentBackStackEntryAsState() val navBackStackEntry by navState.navHostController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route val currentRoute = navBackStackEntry?.destination?.route
topLevelNavBarItems.forEach { topLevelNavBarItems.forEach {
val routeClassName = it.key val routeClassName = it.key
val navBarItemData = it.value val navBarItemData = it.value
NavigationBarItem(modifier = Modifier val iconVector = navBarItemData.icon
.weight(1f), ?: error("Main tab requires icon")
icon = { Text(stringResource(navBarItemData.nameStringResourceId)) }, NavigationBarItem(
modifier = Modifier.weight(1f),
icon = {
Icon(
imageVector = iconVector,
contentDescription = stringResource(navBarItemData.iconContentDescriptionResourceId),
)
},
label = {
NavigationBarMarqueeText(
text = stringResource(navBarItemData.nameStringResourceId),
)
},
selected = currentRoute?.startsWith(routeClassName) == true, selected = currentRoute?.startsWith(routeClassName) == true,
onClick = { onClick = {
val route = routes[navBarItemData.screenRouteClass] val route = routes[navBarItemData.screenRouteClass]
?: throw NullPointerException("Route ${navBarItemData.screenRouteClass} not found") ?: throw NullPointerException("Route ${navBarItemData.screenRouteClass} not found")
if (currentRoute?.startsWith(routeClassName) != true) if (currentRoute?.startsWith(routeClassName) != true) {
navState.changeTop( navState.changeTop(route)
route }
)
}, },
label = null
) )
} }
} }
HorizontalDivider() HorizontalDivider()
} }
}) { innerPaddings -> },
) { innerPaddings ->
NavHost( NavHost(
navState.navHostController, navController = navState.navHostController,
startDestination = routes[LocalVaultRoute::class.qualifiedName]!! startDestination = routes[LocalVaultRoute::class.qualifiedName]!!,
modifier = Modifier
.fillMaxSize()
.padding(innerPaddings),
) {
composable<LocalVaultRoute>(
enterTransition = { fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(200)) },
) { ) {
composable<LocalVaultRoute>(enterTransition = {
fadeIn(tween(200))
}, exitTransition = {
fadeOut(tween(200))
}) {
LocalVaultScreen( LocalVaultScreen(
modifier = Modifier.padding(innerPaddings),
viewModel = localVaultViewModel, viewModel = localVaultViewModel,
openTextEdit = { text -> openTextEdit = { text ->
navState.push(TextEditRoute(text)) navState.push(TextEditRoute(text))
}, },
) )
} }
composable<RemoteVaultsRoute>(enterTransition = { composable<RemoteVaultsRoute>(
fadeIn(tween(200)) enterTransition = { fadeIn(tween(200)) },
}, exitTransition = { exitTransition = { fadeOut(tween(200)) },
fadeOut(tween(200)) ) {
}) {
RemoteVaultsScreen( RemoteVaultsScreen(
modifier = Modifier.padding(innerPaddings),
viewModel = remoteVaultsViewModel, viewModel = remoteVaultsViewModel,
onOpenVault = { item -> onOpenVault = { item ->
navState.push(VaultBrowserRoute(item.uuid.toString())) navState.push(VaultBrowserRoute(item.uuid.toString()))
}, },
) )
} }
composable<VaultBrowserRoute>(enterTransition = { composable<VaultBrowserRoute>(
fadeIn(tween(200)) enterTransition = { fadeIn(tween(200)) },
}, exitTransition = { exitTransition = { fadeOut(tween(200)) },
fadeOut(tween(200)) ) { entry ->
}) { entry ->
val remoteVaultViewModel: RemoteVaultViewModel = hiltViewModel(entry) val remoteVaultViewModel: RemoteVaultViewModel = hiltViewModel(entry)
VaultBrowserScreen( VaultBrowserScreen(
modifier = Modifier.padding(innerPaddings),
viewModel = remoteVaultViewModel, viewModel = remoteVaultViewModel,
openTextEdit = { text -> openTextEdit = { text ->
navState.push(TextEditRoute(text)) navState.push(TextEditRoute(text))

View File

@@ -1,3 +1,8 @@
package com.github.nullptroma.wallenc.ui.screens.main package com.github.nullptroma.wallenc.ui.screens.main
class MainScreenState import androidx.compose.runtime.Immutable
@Immutable
data class MainScreenState(
val workStatus: MainWorkStatus = MainWorkStatus.Idle,
)

View File

@@ -2,32 +2,142 @@ package com.github.nullptroma.wallenc.ui.screens.main
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable import androidx.lifecycle.viewmodel.compose.saveable
import com.github.nullptroma.wallenc.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.ScreenRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.remotes.RemoteVaultsRoute 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.screens.main.screens.vault.LocalVaultRoute
import com.github.nullptroma.wallenc.ui.ViewModelBase
import dagger.hilt.android.lifecycle.HiltViewModel 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 @HiltViewModel
class MainViewModel @javax.inject.Inject constructor(savedStateHandle: SavedStateHandle) : class MainViewModel @Inject constructor(
ViewModelBase<MainScreenState>(MainScreenState()) { savedStateHandle: SavedStateHandle,
private val taskOrchestrator: ITaskOrchestrator,
private val vaultsManager: IVaultsManager,
private val uiStrings: UiStringResolver,
) : ViewModelBase<MainScreenState>(MainScreenState()) {
@OptIn(SavedStateHandleSaveableApi::class) @OptIn(SavedStateHandleSaveableApi::class)
var routes by savedStateHandle.saveable { var routes by savedStateHandle.saveable {
mutableStateOf( mutableStateOf(
mapOf<String, ScreenRoute>( mapOf<String, ScreenRoute>(
LocalVaultRoute::class.qualifiedName!! to LocalVaultRoute(), LocalVaultRoute::class.qualifiedName!! to LocalVaultRoute(),
RemoteVaultsRoute::class.qualifiedName!! to RemoteVaultsRoute() RemoteVaultsRoute::class.qualifiedName!! to RemoteVaultsRoute(),
) ),
) )
} }
private set 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) { fun updateRoute(qualifiedName: String, route: ScreenRoute) {
routes = routes.toMutableMap().apply { routes = routes.toMutableMap().apply {
this[qualifiedName] = route this[qualifiedName] = route
} }
} }
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,
)
}
} }

View File

@@ -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()
}

View File

@@ -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),
)
}
}
}
}
}
}

View File

@@ -37,6 +37,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -66,6 +67,7 @@ fun RemoteVaultsScreen(
onClick = { onClick = {
if (!uiState.isBusy) viewModel.setAddChoiceVisible(true) if (!uiState.isBusy) viewModel.setAddChoiceVisible(true)
}, },
modifier = Modifier.alpha(if (uiState.isBusy) 0.38f else 1f),
) { ) {
Icon( Icon(
Icons.Filled.Add, Icons.Filled.Add,

View File

@@ -4,7 +4,9 @@ import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel 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.ViewModelBase
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import com.github.nullptroma.wallenc.vault.contract.RemoteVaultAuthenticator import com.github.nullptroma.wallenc.vault.contract.RemoteVaultAuthenticator
import com.github.nullptroma.wallenc.vault.contract.VaultDescriptor import com.github.nullptroma.wallenc.vault.contract.VaultDescriptor
import com.github.nullptroma.wallenc.vault.contract.VaultRegistrar import com.github.nullptroma.wallenc.vault.contract.VaultRegistrar
@@ -25,6 +27,7 @@ class RemoteVaultsViewModel @Inject constructor(
private val vaultRegistrar: VaultRegistrar, private val vaultRegistrar: VaultRegistrar,
val remoteAuthenticator: RemoteVaultAuthenticator, val remoteAuthenticator: RemoteVaultAuthenticator,
private val taskOrchestrator: ITaskOrchestrator, private val taskOrchestrator: ITaskOrchestrator,
private val uiStrings: UiStringResolver,
) : ViewModelBase<RemoteVaultsScreenState>(RemoteVaultsScreenState()) { ) : ViewModelBase<RemoteVaultsScreenState>(RemoteVaultsScreenState()) {
val uiState = combine( val uiState = combine(
@@ -58,7 +61,7 @@ class RemoteVaultsViewModel @Inject constructor(
fun onLinkSucceeded(registration: VaultRegistration) { fun onLinkSucceeded(registration: VaultRegistration) {
setBusy(true) setBusy(true)
taskOrchestrator.enqueue( taskOrchestrator.enqueue(
title = "Add remote vault", title = uiStrings(R.string.task_title_add_remote_vault),
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
work = { ctx -> work = { ctx ->
try { try {
@@ -90,7 +93,7 @@ class RemoteVaultsViewModel @Inject constructor(
val uuid = pending.uuid val uuid = pending.uuid
setBusy(true) setBusy(true)
taskOrchestrator.enqueue( taskOrchestrator.enqueue(
title = "Remove remote vault", title = uiStrings(R.string.task_title_remove_remote_vault),
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
work = { ctx -> work = { ctx ->
try { try {

View File

@@ -3,6 +3,8 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.tasks
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -11,13 +13,17 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class TaskPipelineViewModel @Inject constructor( class TaskPipelineViewModel @Inject constructor(
val orchestrator: ITaskOrchestrator, val orchestrator: ITaskOrchestrator,
private val uiStrings: UiStringResolver,
) : ViewModel() { ) : ViewModel() {
fun startTestTask(durationSec: Int, infinityIndeterminateProgress: Boolean) { fun startTestTask(durationSec: Int, infinityIndeterminateProgress: Boolean) {
val safeDurationSec = durationSec.coerceIn(0, 60) val safeDurationSec = durationSec.coerceIn(0, 60)
val title = val title =
if (infinityIndeterminateProgress) "Test task (${safeDurationSec}s, ∞)" if (infinityIndeterminateProgress) {
else "Test task (${safeDurationSec}s)" uiStrings(R.string.task_pipeline_test_running_infinity, safeDurationSec)
} else {
uiStrings(R.string.task_pipeline_test_running, safeDurationSec)
}
orchestrator.enqueue( orchestrator.enqueue(
title = title, title = title,
dispatcher = Dispatchers.Default, dispatcher = Dispatchers.Default,

View File

@@ -1,5 +1,6 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.vault package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
import androidx.annotation.StringRes
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
import com.github.nullptroma.wallenc.domain.datatypes.Tree import com.github.nullptroma.wallenc.domain.datatypes.Tree
@@ -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.interfaces.IStorageInfo
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel 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.GetOpenedStoragesUseCase
import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase
import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase
import com.github.nullptroma.wallenc.usecases.RemoveStorageUseCase import com.github.nullptroma.wallenc.usecases.RemoveStorageUseCase
import com.github.nullptroma.wallenc.usecases.RenameStorageUseCase import com.github.nullptroma.wallenc.usecases.RenameStorageUseCase
import com.github.nullptroma.wallenc.usecases.StorageFileManagementUseCase import com.github.nullptroma.wallenc.usecases.StorageFileManagementUseCase
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.ViewModelBase import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.ui.extensions.toPrintable 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.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID import java.util.UUID
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
@@ -42,28 +48,24 @@ abstract class AbstractVaultBrowserViewModel(
private val renameStorageUseCase: RenameStorageUseCase, private val renameStorageUseCase: RenameStorageUseCase,
private val manageVaultUseCase: ManageVaultUseCase, private val manageVaultUseCase: ManageVaultUseCase,
private val taskOrchestrator: ITaskOrchestrator, private val taskOrchestrator: ITaskOrchestrator,
private val uiStrings: UiStringResolver,
private val logger: ILogger, private val logger: ILogger,
) : ViewModelBase<VaultBrowserScreenState>( ) : ViewModelBase<VaultBrowserScreenState>(
VaultBrowserScreenState(storagesList = emptyList(), isLoading = true, addStorageFabEnabled = false), VaultBrowserScreenState(
storagesList = emptyList(),
storagesRefreshing = true,
busyStorageUuids = emptySet(),
vaultListMutationActive = false,
addStorageFabEnabled = false,
),
) { ) {
private val _messages = MutableSharedFlow<String>() private val _userNotifications = MutableSharedFlow<UserNotification>(extraBufferCapacity = 8)
val messages: SharedFlow<String> = _messages val userNotifications: SharedFlow<UserNotification> = _userNotifications
private var taskCount: Int = 0
set(value) {
field = value
updateStateLoading()
}
private var storagesLoading: Boolean = false
set(value) {
field = value
updateStateLoading()
}
init { init {
collectFlows(storagesFlow) collectStoragesFlow(storagesFlow)
collectPipelineBusyFlags()
viewModelScope.launch { viewModelScope.launch {
vaultAvailabilityFlow vaultAvailabilityFlow
.distinctUntilChanged() .distinctUntilChanged()
@@ -74,17 +76,31 @@ abstract class AbstractVaultBrowserViewModel(
} }
} }
private fun updateStateLoading() { private fun isPipelineTaskActive(state: TaskRunState): Boolean =
updateState( when (state) {
state.value.copy( is TaskRunState.Queued,
isLoading = storagesLoading || taskCount > 0, is TaskRunState.Running,
), -> true
) else -> false
} }
private fun collectFlows(storagesFlow: Flow<List<IStorage>>) { 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<List<IStorage>>) {
viewModelScope.launch { viewModelScope.launch {
storagesFlow.combine(getOpenedStoragesUseCase.openedStorages) { storages, opened -> combine(
storagesFlow,
getOpenedStoragesUseCase.openedStorages,
) { storages, opened -> storages to opened }
.collect { (storages, opened) ->
val list = mutableListOf<Tree<IStorageInfo>>() val list = mutableListOf<Tree<IStorageInfo>>()
for (storage in storages) { for (storage in storages) {
var tree = Tree<IStorageInfo>(storage) var tree = Tree<IStorageInfo>(storage)
@@ -96,18 +112,44 @@ abstract class AbstractVaultBrowserViewModel(
tree = nextTree tree = nextTree
} }
} }
list updateState(
}.collect { trees -> state.value.copy(
storagesLoading = false storagesList = list,
updateState(state.value.copy(storagesList = trees)) storagesRefreshing = false,
),
)
}
}
}
private fun collectPipelineBusyFlags() {
viewModelScope.launch {
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,
),
)
} }
} }
} }
fun printStorageInfoToLog(storage: IStorageInfo) { fun printStorageInfoToLog(storage: IStorageInfo) {
val id = storage.uuid
if (isStorageTaskActive(id)) return
taskOrchestrator.enqueue( taskOrchestrator.enqueue(
title = "Dump storage to log", title = uiStrings(R.string.task_title_dump_storage_log),
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
busyStorageUuid = id,
work = { ctx -> work = { ctx ->
storageFileManagementUseCase.setStorage(storage) storageFileManagementUseCase.setStorage(storage)
ctx.log(TaskLogLevel.Info, "Enumerating files and directories…") 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)") logger.debug(TAG, "createStorage ignored (vault unavailable or FAB disabled)")
return return
} }
if (isVaultListMutationActive()) {
logger.debug(TAG, "createStorage ignored (vault list mutation already running)")
return
}
logger.debug(TAG, "createStorage: enqueue task") logger.debug(TAG, "createStorage: enqueue task")
taskOrchestrator.enqueue( taskOrchestrator.enqueue(
title = "Create storage", title = uiStrings(R.string.task_title_create_storage),
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
locksVaultStorageList = true,
work = { ctx -> work = { ctx ->
try { try {
ctx.log(TaskLogLevel.Info, "Creating storage…") 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<UUID>()
fun enableEncryption(storage: IStorageInfo, password: String, encryptPath: Boolean) { fun enableEncryption(storage: IStorageInfo, password: String, encryptPath: Boolean) {
val id = storage.uuid val id = storage.uuid
synchronized(storageOpMutex) { if (isStorageTaskActive(id)) return
if (runningStorages.contains(id)) return
runningStorages.add(id)
taskCount++
}
val key = EncryptKey(password) val key = EncryptKey(password)
taskOrchestrator.enqueue( taskOrchestrator.enqueue(
title = "Enable encryption", title = uiStrings(R.string.task_title_enable_encryption),
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
busyStorageUuid = id,
work = { ctx -> work = { ctx ->
try { try {
ctx.log(TaskLogLevel.Info, "Checking storage…") ctx.log(TaskLogLevel.Info, "Checking storage…")
@@ -187,33 +224,33 @@ abstract class AbstractVaultBrowserViewModel(
manageStoragesEncryptionUseCase.enableEncryption(storage, key, encryptPath) manageStoragesEncryptionUseCase.enableEncryption(storage, key, encryptPath)
manageStoragesEncryptionUseCase.openStorage(storage, key, true) manageStoragesEncryptionUseCase.openStorage(storage, key, true)
ctx.log(TaskLogLevel.Info, "Encryption enabled") ctx.log(TaskLogLevel.Info, "Encryption enabled")
_messages.emit("Encryption enabled") _userNotifications.emit(UserNotification.TextRes(R.string.msg_encryption_enabled))
} }
ManageStoragesEncryptionUseCase.CanEncryptResult.AlreadyEncrypted -> { ManageStoragesEncryptionUseCase.CanEncryptResult.AlreadyEncrypted -> {
ctx.log(TaskLogLevel.Info, "Storage is already encrypted") 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 -> { ManageStoragesEncryptionUseCase.CanEncryptResult.StorageIsNotEmpty -> {
ctx.log(TaskLogLevel.Info, "Storage is not empty") 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 -> { ManageStoragesEncryptionUseCase.CanEncryptResult.StorageStateUnknown -> {
ctx.log(TaskLogLevel.Info, "Cannot determine whether storage is empty") 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 -> { ManageStoragesEncryptionUseCase.CanEncryptResult.UnsupportedStorageType -> {
ctx.log(TaskLogLevel.Info, "Unsupported storage type") 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) { } catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Failed to enable encryption") ctx.log(TaskLogLevel.Error, e.message ?: "Failed to enable encryption")
_messages.emit(e.message ?: "Failed to enable encryption") _userNotifications.emit(
} finally { UserNotification.TextRes(
synchronized(storageOpMutex) { R.string.msg_failed_enable_encryption,
runningStorages.remove(id) listOf(e.message ?: e.toString()),
taskCount-- ),
} )
} }
}, },
) )
@@ -221,37 +258,38 @@ abstract class AbstractVaultBrowserViewModel(
fun openEncryptedStorage(storage: IStorageInfo, password: String, rememberPassword: Boolean) { fun openEncryptedStorage(storage: IStorageInfo, password: String, rememberPassword: Boolean) {
val id = storage.uuid val id = storage.uuid
synchronized(storageOpMutex) { if (isStorageTaskActive(id)) return
if (runningStorages.contains(id)) return
runningStorages.add(id)
taskCount++
}
val key = EncryptKey(password) val key = EncryptKey(password)
taskOrchestrator.enqueue( taskOrchestrator.enqueue(
title = "Open encrypted storage", title = uiStrings(R.string.task_title_open_encrypted_storage),
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
busyStorageUuid = id,
work = { ctx -> work = { ctx ->
try { 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) manageStoragesEncryptionUseCase.openStorage(storage, key, rememberPassword)
ctx.log(TaskLogLevel.Info, "Storage opened") ctx.log(TaskLogLevel.Info, "Storage opened")
} catch (e: Exception) { } catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Failed to open encrypted storage") ctx.log(TaskLogLevel.Error, e.message ?: "Failed to open encrypted storage")
_messages.emit(e.message ?: "Failed to open encrypted storage") _userNotifications.emit(
} finally { UserNotification.TextRes(
synchronized(storageOpMutex) { R.string.msg_failed_open_storage,
runningStorages.remove(id) listOf(e.message ?: e.toString()),
taskCount-- ),
} )
} }
}, },
) )
} }
fun closeEncryptedStorage(storage: IStorageInfo) { fun closeEncryptedStorage(storage: IStorageInfo) {
val id = storage.uuid
if (isStorageTaskActive(id)) return
taskOrchestrator.enqueue( taskOrchestrator.enqueue(
title = "Close encrypted storage", title = uiStrings(R.string.task_title_close_encrypted_storage),
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
busyStorageUuid = id,
work = { ctx -> work = { ctx ->
try { try {
ctx.log(TaskLogLevel.Info, "Closing storage…") ctx.log(TaskLogLevel.Info, "Closing storage…")
@@ -259,16 +297,24 @@ abstract class AbstractVaultBrowserViewModel(
ctx.log(TaskLogLevel.Info, "Storage closed") ctx.log(TaskLogLevel.Info, "Storage closed")
} catch (e: Exception) { } catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Failed to close encrypted storage") 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) { fun disableEncryption(storage: IStorageInfo) {
val id = storage.uuid
if (isStorageTaskActive(id)) return
taskOrchestrator.enqueue( taskOrchestrator.enqueue(
title = "Disable encryption", title = uiStrings(R.string.task_title_disable_encryption),
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
busyStorageUuid = id,
work = { ctx -> work = { ctx ->
try { try {
ctx.log(TaskLogLevel.Info, "Disabling encryption…") ctx.log(TaskLogLevel.Info, "Disabling encryption…")
@@ -276,19 +322,27 @@ abstract class AbstractVaultBrowserViewModel(
ctx.reportProgress(p) ctx.reportProgress(p)
} }
ctx.log(TaskLogLevel.Info, "Encryption disabled") ctx.log(TaskLogLevel.Info, "Encryption disabled")
_messages.emit("Encryption disabled") _userNotifications.emit(UserNotification.TextRes(R.string.msg_encryption_disabled))
} catch (e: Exception) { } catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Failed") 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) { fun rename(storage: IStorageInfo, newName: String) {
val id = storage.uuid
if (isStorageTaskActive(id)) return
taskOrchestrator.enqueue( taskOrchestrator.enqueue(
title = "Rename storage", title = uiStrings(R.string.task_title_rename_storage),
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
busyStorageUuid = id,
work = { ctx -> work = { ctx ->
try { try {
ctx.log(TaskLogLevel.Info, "Renaming…") ctx.log(TaskLogLevel.Info, "Renaming…")
@@ -302,9 +356,13 @@ abstract class AbstractVaultBrowserViewModel(
} }
fun remove(storage: IStorageInfo) { fun remove(storage: IStorageInfo) {
val id = storage.uuid
if (isStorageTaskActive(id)) return
taskOrchestrator.enqueue( taskOrchestrator.enqueue(
title = "Remove storage", title = uiStrings(R.string.task_title_remove_storage),
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
busyStorageUuid = id,
locksVaultStorageList = true,
work = { ctx -> work = { ctx ->
try { try {
ctx.log(TaskLogLevel.Info, "Removing storage…") 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 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) 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 { fun isEncryptionSessionOpen(storage: IStorageInfo): Boolean {
@@ -339,26 +398,38 @@ abstract class AbstractVaultBrowserViewModel(
} }
fun clearStorageSyncLock(storage: IStorageInfo) { fun clearStorageSyncLock(storage: IStorageInfo) {
val id = storage.uuid
if (isStorageTaskActive(id)) return
taskOrchestrator.enqueue( taskOrchestrator.enqueue(
title = "Снятие блокировки синхронизации", title = uiStrings(R.string.task_title_clear_sync_lock),
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
busyStorageUuid = id,
work = { ctx -> work = { ctx ->
try { try {
val s = storage as? IStorage val s = storage as? IStorage
if (s == null) { if (s == null) {
ctx.log(TaskLogLevel.Error, "Некорректное хранилище") ctx.log(TaskLogLevel.Error, "Invalid storage")
_messages.emit("Некорректное хранилище") _userNotifications.emit(UserNotification.TextRes(R.string.msg_invalid_storage_for_sync_lock))
return@enqueue return@enqueue
} }
ctx.log(TaskLogLevel.Info, "Снимаю блокировку синхронизации") ctx.log(TaskLogLevel.Info, "Clearing sync lock")
s.accessor.forceClearSyncLock() s.accessor.forceClearSyncLock()
ctx.log(TaskLogLevel.Info, "Блокировка синхронизации снята") ctx.log(TaskLogLevel.Info, "Sync lock cleared")
_messages.emit("Блокировка синхронизации снята") _userNotifications.emit(UserNotification.TextRes(R.string.msg_sync_lock_cleared))
} catch (e: Exception) { } catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Не удалось снять блокировку") ctx.log(TaskLogLevel.Error, e.message ?: "clear sync lock failed")
_messages.emit(e.message ?: "Не удалось снять блокировку синхронизации") _userNotifications.emit(
UserNotification.TextRes(
R.string.msg_sync_lock_clear_failed,
listOf(e.message ?: e.toString()),
),
)
} }
}, },
) )
} }
private companion object {
private const val TAG = "VaultBrowser"
}
} }

View File

@@ -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.ILogger
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator 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.GetOpenedStoragesUseCase
import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase
import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase
@@ -29,6 +30,7 @@ class LocalVaultViewModel @Inject constructor(
manageStoragesEncryptionUseCase: ManageStoragesEncryptionUseCase, manageStoragesEncryptionUseCase: ManageStoragesEncryptionUseCase,
renameStorageUseCase: RenameStorageUseCase, renameStorageUseCase: RenameStorageUseCase,
taskOrchestrator: ITaskOrchestrator, taskOrchestrator: ITaskOrchestrator,
uiStrings: UiStringResolver,
logger: ILogger, logger: ILogger,
) : AbstractVaultBrowserViewModel( ) : AbstractVaultBrowserViewModel(
storagesFlow = vaultsManager.vaults storagesFlow = vaultsManager.vaults
@@ -45,5 +47,6 @@ class LocalVaultViewModel @Inject constructor(
renameStorageUseCase = renameStorageUseCase, renameStorageUseCase = renameStorageUseCase,
manageVaultUseCase = manageVaultUseCase, manageVaultUseCase = manageVaultUseCase,
taskOrchestrator = taskOrchestrator, taskOrchestrator = taskOrchestrator,
uiStrings = uiStrings,
logger = logger, logger = logger,
) )

View File

@@ -3,6 +3,7 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import com.github.nullptroma.wallenc.domain.interfaces.ILogger import com.github.nullptroma.wallenc.domain.interfaces.ILogger
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator 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.GetOpenedStoragesUseCase
import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase
import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase
@@ -27,6 +28,7 @@ class RemoteVaultViewModel @Inject constructor(
manageStoragesEncryptionUseCase: ManageStoragesEncryptionUseCase, manageStoragesEncryptionUseCase: ManageStoragesEncryptionUseCase,
renameStorageUseCase: RenameStorageUseCase, renameStorageUseCase: RenameStorageUseCase,
taskOrchestrator: ITaskOrchestrator, taskOrchestrator: ITaskOrchestrator,
uiStrings: UiStringResolver,
logger: ILogger, logger: ILogger,
) : AbstractVaultBrowserViewModel( ) : AbstractVaultBrowserViewModel(
storagesFlow = manageVaultUseCase.storagesOf(savedStateHandle.requireVaultUuid()), storagesFlow = manageVaultUseCase.storagesOf(savedStateHandle.requireVaultUuid()),
@@ -40,6 +42,7 @@ class RemoteVaultViewModel @Inject constructor(
renameStorageUseCase = renameStorageUseCase, renameStorageUseCase = renameStorageUseCase,
manageVaultUseCase = manageVaultUseCase, manageVaultUseCase = manageVaultUseCase,
taskOrchestrator = taskOrchestrator, taskOrchestrator = taskOrchestrator,
uiStrings = uiStrings,
logger = logger, logger = logger,
) )

View File

@@ -2,10 +2,13 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -18,6 +21,7 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -26,10 +30,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext 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.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.elements.StorageTree
import com.github.nullptroma.wallenc.ui.extensions.gesturesDisabled import com.github.nullptroma.wallenc.ui.resources.UserNotification
import java.util.UUID
@Composable @Composable
fun VaultBrowserScreen( fun VaultBrowserScreen(
@@ -40,43 +48,97 @@ fun VaultBrowserScreen(
val uiState by viewModel.state.collectAsStateWithLifecycle() val uiState by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.messages.collect { message -> viewModel.userNotifications.collect { notification ->
Toast.makeText(context, message, Toast.LENGTH_SHORT).show() 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 { Box {
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
contentWindowInsets = WindowInsets(0.dp), contentWindowInsets = WindowInsets(0.dp),
floatingActionButton = { floatingActionButton = {
val fabEnabled = uiState.addStorageFabEnabled
FloatingActionButton( FloatingActionButton(
onClick = { if (fabEnabled) viewModel.createStorage() }, onClick = {
containerColor = if (fabEnabled) { if (fabEnabled && !fabBusy) {
MaterialTheme.colorScheme.primaryContainer viewModel.createStorage()
} else { }
MaterialTheme.colorScheme.surfaceVariant
},
contentColor = if (fabEnabled) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f)
}, },
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 -> ) { innerPadding ->
LazyColumn( Column(
modifier = Modifier modifier = Modifier
.padding(innerPadding) .padding(innerPadding)
.gesturesDisabled(uiState.isLoading), .fillMaxSize(),
) {
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),
)
}
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 -> items(uiState.storagesList) { listItem ->
StorageTree( StorageTree(
modifier = Modifier.padding(8.dp, 8.dp, 8.dp, 0.dp), modifier = Modifier.padding(8.dp, 8.dp, 8.dp, 0.dp),
tree = listItem, tree = listItem,
isUuidBusy = isUuidBusy,
onClick = { openTextEdit(it.value.uuid.toString()) }, onClick = { openTextEdit(it.value.uuid.toString()) },
onRename = { tree, newName -> viewModel.rename(tree.value, newName) }, onRename = { tree, newName -> viewModel.rename(tree.value, newName) },
onRemove = { tree -> viewModel.remove(tree.value) }, onRemove = { tree -> viewModel.remove(tree.value) },
@@ -88,7 +150,7 @@ fun VaultBrowserScreen(
}, },
onCloseEncrypted = { tree -> viewModel.closeEncryptedStorage(tree.value) }, onCloseEncrypted = { tree -> viewModel.closeEncryptedStorage(tree.value) },
onDisableEncryption = { tree -> viewModel.disableEncryption(tree.value) }, onDisableEncryption = { tree -> viewModel.disableEncryption(tree.value) },
getStatusText = { tree -> viewModel.getStorageStatus(tree.value) }, getStatusTextRes = { tree -> viewModel.getStorageStatusRes(tree.value) },
isEncryptionOpened = { tree -> viewModel.isEncryptionSessionOpen(tree.value) }, isEncryptionOpened = { tree -> viewModel.isEncryptionSessionOpen(tree.value) },
isStorageSyncLockHeld = { info -> viewModel.isStorageSyncLockHeld(info) }, isStorageSyncLockHeld = { info -> viewModel.isStorageSyncLockHeld(info) },
onClearStorageSyncLock = { info -> viewModel.clearStorageSyncLock(info) }, onClearStorageSyncLock = { info -> viewModel.clearStorageSyncLock(info) },
@@ -97,15 +159,31 @@ fun VaultBrowserScreen(
item { Spacer(modifier = Modifier.height(8.dp)) } item { Spacer(modifier = Modifier.height(8.dp)) }
} }
} }
}
}
}
}
if (uiState.isLoading) { if (showFullscreenLoader) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.fillMaxSize().alpha(0.6f).background(Color.Black)) Box(modifier = Modifier.fillMaxSize().alpha(0.6f).background(Color.Black))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(64.dp), modifier = Modifier.size(64.dp),
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
trackColor = MaterialTheme.colorScheme.surfaceVariant, 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),
)
}
} }
} }
} }

View File

@@ -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.datatypes.Tree
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
import java.util.UUID
data class VaultBrowserScreenState( data class VaultBrowserScreenState(
val storagesList: List<Tree<IStorageInfo>>, val storagesList: List<Tree<IStorageInfo>>,
val isLoading: Boolean, /** Первый снимок списка storages ещё не получен (удалённый vault). */
/** FAB «добавить storage»: активна только когда vault доступен (сеть/API/путь). */ val storagesRefreshing: Boolean,
/** Storages с активной задачей в [com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator]. */
val busyStorageUuids: Set<UUID>,
/** Активна задача, меняющая состав списка storages (создание и т.п.). */
val vaultListMutationActive: Boolean,
val addStorageFabEnabled: Boolean = false, val addStorageFabEnabled: Boolean = false,
) )

View File

@@ -1,9 +1,19 @@
package com.github.nullptroma.wallenc.ui.screens.shared 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.material3.Text
import androidx.compose.runtime.Composable 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 @Composable
fun TextEditScreen(text: String) { fun TextEditScreen(text: String) {
Text("Hello from TextEdit with text $text") Text(
text = stringResource(R.string.text_edit_screen_placeholder, text),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(16.dp),
)
} }

View File

@@ -3,6 +3,7 @@ package com.github.nullptroma.wallenc.ui.screens.sync
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.lazy.items
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons 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.Add
import androidx.compose.material.icons.rounded.Delete 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.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -27,12 +34,16 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.resources.UserNotification
import java.util.UUID import java.util.UUID
@Composable @Composable
@@ -64,9 +75,13 @@ fun StorageSyncScreen(
modifier = modifier, modifier = modifier,
floatingActionButton = { floatingActionButton = {
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 -> ) { inner ->
@@ -77,14 +92,38 @@ fun StorageSyncScreen(
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (state.isBusy) {
Button(onClick = viewModel::runSyncNow, enabled = !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)) Text(stringResource(id = R.string.sync_run_now))
} }
} }
state.message?.let { state.userMessage?.let { um ->
Text(text = it, style = MaterialTheme.typography.bodyMedium) 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( Text(
@@ -105,20 +144,24 @@ fun StorageSyncScreen(
), ),
) { ) {
Column( Column(
modifier = Modifier.padding(10.dp), modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text(text = group.id, style = MaterialTheme.typography.titleSmall) Row(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { modifier = Modifier.fillMaxWidth(),
IconButton( horizontalArrangement = Arrangement.SpaceBetween,
onClick = { viewModel.openPicker(group.id) }, verticalAlignment = Alignment.Top,
enabled = !state.isBusy,
) { ) {
Icon( Text(
imageVector = Icons.Rounded.Add, text = group.id,
contentDescription = stringResource(id = R.string.sync_add_storage), style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.weight(1f)
.padding(end = 4.dp),
maxLines = 4,
) )
}
IconButton( IconButton(
onClick = { pendingRemoveGroupId = group.id }, onClick = { pendingRemoveGroupId = group.id },
enabled = !state.isBusy, enabled = !state.isBusy,
@@ -134,6 +177,7 @@ fun StorageSyncScreen(
Text( Text(
text = stringResource(id = R.string.sync_group_empty), text = stringResource(id = R.string.sync_group_empty),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} else { } else {
val hasMixedEncryption = hasEncryptionMismatch(group, state.vaults) val hasMixedEncryption = hasEncryptionMismatch(group, state.vaults)
@@ -147,8 +191,25 @@ fun StorageSyncScreen(
group.storageUuids.forEach { storageUuid -> group.storageUuids.forEach { storageUuid ->
val storage = storageByUuid[storageUuid] val storage = storageByUuid[storageUuid]
val storageLabel = storage?.name ?: storageUuid.toString() val titleText = storage?.name
val encryptionStatus = storage?.encryptionStatus ?: "Unknown" ?: 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( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
@@ -158,14 +219,39 @@ fun StorageSyncScreen(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(8.dp), .padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Text( Text(
text = "$storageLabel ($storageUuid) | $encryptionStatus", text = titleText,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
) )
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( IconButton(
onClick = { pendingRemoveStorage = group.id to storageUuid }, onClick = { pendingRemoveStorage = group.id to storageUuid },
enabled = !state.isBusy, 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 @Composable
private fun StoragePickerScreen( private fun StoragePickerScreen(
modifier: Modifier, modifier: Modifier,
@@ -271,9 +384,15 @@ private fun StoragePickerScreen(
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(
Button(onClick = onBack) { horizontalArrangement = Arrangement.spacedBy(8.dp),
Text(stringResource(id = R.string.sync_picker_back)) verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.sync_cd_picker_back),
)
} }
Text( Text(
text = stringResource(id = R.string.sync_picker_title, groupId), text = stringResource(id = R.string.sync_picker_title, groupId),
@@ -306,19 +425,32 @@ private fun StoragePickerScreen(
.fillMaxWidth() .fillMaxWidth()
.clickable { onToggleVault(vault.uuid) }, .clickable { onToggleVault(vault.uuid) },
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = vault.title, text = vault.title,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
) )
Text( Icon(
text = if (expanded) "Hide" else "Show", imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
style = MaterialTheme.typography.bodySmall, contentDescription = if (expanded) {
stringResource(R.string.sync_picker_collapse)
} else {
stringResource(R.string.sync_picker_expand)
},
) )
} }
Text( Text(
text = "${vault.type} | ${vault.uuid}", text = vault.type,
style = MaterialTheme.typography.bodySmall, 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) {
@@ -369,30 +501,46 @@ private fun StoragePickerNode(
.fillMaxWidth() .fillMaxWidth()
.padding(8.dp), .padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) { ) {
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Text( Text(
text = node.name, text = node.name,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
Text( 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, 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, enabled = !isSelected && !isBusy,
onClick = { onAddStorage(node.uuid) }, onClick = { onAddStorage(node.uuid) },
) { ) {
Text( Icon(
text = if (isSelected) { imageVector = Icons.Rounded.Add,
stringResource(id = R.string.sync_picker_added) contentDescription = stringResource(
} else { if (isSelected) R.string.sync_picker_added else R.string.sync_picker_cd_add,
stringResource(id = R.string.sync_picker_add) ),
},
) )
} }
} }
@@ -420,8 +568,9 @@ private fun hasEncryptionMismatch(
): Boolean { ): Boolean {
if (group.storageUuids.isEmpty()) return false if (group.storageUuids.isEmpty()) return false
val byUuid = flattenStorageTree(vaults.flatMap { it.storages }).associateBy { it.uuid } val byUuid = flattenStorageTree(vaults.flatMap { it.storages }).associateBy { it.uuid }
val statuses = group.storageUuids.mapNotNull { byUuid[it]?.encryptionStatus }.toSet() val kinds = group.storageUuids.mapNotNull { byUuid[it]?.encryptionKind }.toSet()
val hasEncrypted = statuses.any { it.startsWith("Encrypted") } if (kinds.isEmpty()) return false
val hasPlain = statuses.any { it == "Not encrypted" } val hasEncrypted = kinds.any { it != StorageSyncEncryptionKind.NotEncrypted }
val hasPlain = kinds.contains(StorageSyncEncryptionKind.NotEncrypted)
return hasEncrypted && hasPlain return hasEncrypted && hasPlain
} }

View File

@@ -1,11 +1,20 @@
package com.github.nullptroma.wallenc.ui.screens.sync package com.github.nullptroma.wallenc.ui.screens.sync
import com.github.nullptroma.wallenc.ui.resources.UserNotification
import java.util.UUID import java.util.UUID
enum class StorageSyncEncryptionKind {
NotEncrypted,
EncryptedOpened,
EncryptedClosed,
}
data class StorageSyncStorageUi( data class StorageSyncStorageUi(
val uuid: UUID, val uuid: UUID,
val name: String, val name: String,
val encryptionStatus: String, val encryptionKind: StorageSyncEncryptionKind,
/** false, если storage есть в дереве, но сейчас недоступен (например, vault offline). */
val isReachable: Boolean = true,
val children: List<StorageSyncStorageUi> = emptyList(), val children: List<StorageSyncStorageUi> = emptyList(),
) )
@@ -27,5 +36,7 @@ data class StorageSyncScreenState(
val expandedVaultUuids: Set<UUID> = emptySet(), val expandedVaultUuids: Set<UUID> = emptySet(),
val pickerGroupId: String? = null, val pickerGroupId: String? = null,
val isBusy: Boolean = false, val isBusy: Boolean = false,
val message: String? = null, /** Любой vault ещё загружает список storages — UUID из группы могут появиться позже. */
val anyVaultStoragesScanning: Boolean = false,
val userMessage: UserNotification? = null,
) )

View File

@@ -2,8 +2,12 @@ package com.github.nullptroma.wallenc.ui.screens.sync
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.interfaces.IStorage import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager 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.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.ManageStorageSyncGroupsUseCase
import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase
import com.github.nullptroma.wallenc.vault.contract.DescribedVault 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID import java.util.UUID
import javax.inject.Inject
@HiltViewModel @HiltViewModel
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class StorageSyncViewModel @javax.inject.Inject constructor( class StorageSyncViewModel @Inject constructor(
private val groupsUseCase: ManageStorageSyncGroupsUseCase, private val groupsUseCase: ManageStorageSyncGroupsUseCase,
private val runStorageSyncUseCase: RunStorageSyncUseCase, private val runStorageSyncUseCase: RunStorageSyncUseCase,
private val vaultsManager: IVaultsManager, private val vaultsManager: IVaultsManager,
private val uiStrings: UiStringResolver,
) : ViewModelBase<StorageSyncScreenState>(StorageSyncScreenState()) { ) : ViewModelBase<StorageSyncScreenState>(StorageSyncScreenState()) {
init { init {
refreshGroups() refreshGroups()
observeVaults() 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() { fun refreshGroups() {
@@ -42,7 +65,7 @@ class StorageSyncViewModel @javax.inject.Inject constructor(
fun createGroup() { fun createGroup() {
viewModelScope.launch { viewModelScope.launch {
updateState(state.value.copy(isBusy = true, message = null)) updateState(state.value.copy(isBusy = true, userMessage = null))
val group = groupsUseCase.createGroup() val group = groupsUseCase.createGroup()
val groups = groupsUseCase.getGroups() val groups = groupsUseCase.getGroups()
updateState( updateState(
@@ -50,7 +73,10 @@ class StorageSyncViewModel @javax.inject.Inject constructor(
groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) }, groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) },
pickerGroupId = null, pickerGroupId = null,
isBusy = false, 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) { fun removeGroup(groupId: String) {
viewModelScope.launch { viewModelScope.launch {
updateState(state.value.copy(isBusy = true, message = null)) updateState(state.value.copy(isBusy = true, userMessage = null))
groupsUseCase.removeGroup(groupId) groupsUseCase.removeGroup(groupId)
val groups = groupsUseCase.getGroups() val groups = groupsUseCase.getGroups()
updateState( updateState(
state.value.copy( state.value.copy(
groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) }, groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) },
isBusy = false, 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( updateState(
state.value.copy( state.value.copy(
pickerGroupId = groupId, pickerGroupId = groupId,
message = null, userMessage = null,
), ),
) )
} }
@@ -84,7 +110,7 @@ class StorageSyncViewModel @javax.inject.Inject constructor(
updateState( updateState(
state.value.copy( state.value.copy(
pickerGroupId = null, pickerGroupId = null,
message = null, userMessage = null,
), ),
) )
} }
@@ -100,14 +126,17 @@ class StorageSyncViewModel @javax.inject.Inject constructor(
fun addStorageToCurrentGroup(storageUuid: UUID) { fun addStorageToCurrentGroup(storageUuid: UUID) {
val groupId = state.value.pickerGroupId ?: return val groupId = state.value.pickerGroupId ?: return
viewModelScope.launch { viewModelScope.launch {
updateState(state.value.copy(isBusy = true, message = null)) updateState(state.value.copy(isBusy = true, userMessage = null))
groupsUseCase.addStorageToGroup(groupId, storageUuid) groupsUseCase.addStorageToGroup(groupId, storageUuid)
val groups = groupsUseCase.getGroups() val groups = groupsUseCase.getGroups()
updateState( updateState(
state.value.copy( state.value.copy(
groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) }, groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) },
isBusy = false, 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) { fun removeStorageFromGroup(groupId: String, storageUuid: UUID) {
viewModelScope.launch { viewModelScope.launch {
updateState(state.value.copy(isBusy = true, message = null)) updateState(state.value.copy(isBusy = true, userMessage = null))
groupsUseCase.removeStorageFromGroup(groupId, storageUuid) groupsUseCase.removeStorageFromGroup(groupId, storageUuid)
val groups = groupsUseCase.getGroups() val groups = groupsUseCase.getGroups()
updateState( updateState(
state.value.copy( state.value.copy(
groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) }, groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) },
isBusy = false, isBusy = false,
message = "Storage removed from $groupId", userMessage = UserNotification.TextRes(
R.string.sync_msg_storage_removed,
listOf(groupId),
),
), ),
) )
} }
} }
fun runSyncNow() { fun runSyncNow() {
runStorageSyncUseCase.enqueue("sync-tab") runStorageSyncUseCase.enqueue(
updateState(state.value.copy(message = "Sync task enqueued")) 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() { private fun observeVaults() {
@@ -170,18 +209,21 @@ class StorageSyncViewModel @javax.inject.Inject constructor(
}, },
) )
} else { } else {
combine(allStorages.map { it.metaInfo }) { combine(
val metaByStorageUuid = allStorages allStorages.map { storage ->
.mapIndexed { index, storage -> storage.uuid to it[index] } combine(storage.metaInfo, storage.isAvailable) { meta, avail ->
.toMap() storage.uuid to StorageSnapshot(meta, avail)
}
},
) { pairs ->
val snapByUuid = pairs.associate { it.first to it.second }
vaultNodes.map { (vault, trees) -> vaultNodes.map { (vault, trees) ->
StorageSyncVaultUi( StorageSyncVaultUi(
uuid = vault.uuid, uuid = vault.uuid,
title = vaultTitle(vault as? DescribedVault), title = vaultTitle(vault as? DescribedVault),
type = vaultType(vault as? DescribedVault), type = vaultType(vault as? DescribedVault),
storages = trees.map { tree -> 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 { private fun vaultType(vault: DescribedVault?): String {
val descriptor = vault?.descriptor val descriptor = vault?.descriptor
return when (descriptor) { return when (descriptor) {
is VaultDescriptor.LocalDevice -> "Local device" is VaultDescriptor.LocalDevice -> uiStrings(R.string.vault_type_local_device)
is VaultDescriptor.LinkedRemote -> "Remote ${descriptor.brand.name.lowercase()}" is VaultDescriptor.LinkedRemote -> uiStrings(R.string.vault_type_remote, descriptor.brand.name)
null -> "Unknown" null -> uiStrings(R.string.vault_type_unknown)
} }
} }
private fun vaultTitle(vault: DescribedVault?): String { private fun vaultTitle(vault: DescribedVault?): String {
val descriptor = vault?.descriptor val descriptor = vault?.descriptor
return when (descriptor) { return when (descriptor) {
is VaultDescriptor.LocalDevice -> "Local vault" is VaultDescriptor.LocalDevice -> uiStrings(R.string.vault_title_local)
is VaultDescriptor.LinkedRemote -> descriptor.accountDisplayName 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( private fun toStorageUi(
node: StorageTreeNode, node: StorageTreeNode,
metaByStorageUuid: Map<UUID, com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo> = emptyMap(), snapshotByUuid: Map<UUID, StorageSnapshot> = emptyMap(),
): StorageSyncStorageUi { ): StorageSyncStorageUi {
val meta = metaByStorageUuid[node.storage.uuid] ?: node.storage.metaInfo.value val snap = snapshotByUuid[node.storage.uuid]
val encryptionStatus = when { val meta = snap?.meta ?: node.storage.metaInfo.value
meta.encInfo == null -> "Not encrypted" val isReachable = snap?.isAvailable ?: node.storage.isAvailable.value
node.storage.isVirtualStorage -> "Encrypted (opened)" val encryptionKind = when {
else -> "Encrypted" meta.encInfo == null -> StorageSyncEncryptionKind.NotEncrypted
node.storage.isVirtualStorage -> StorageSyncEncryptionKind.EncryptedOpened
else -> StorageSyncEncryptionKind.EncryptedClosed
} }
return StorageSyncStorageUi( return StorageSyncStorageUi(
uuid = node.storage.uuid, uuid = node.storage.uuid,
name = meta.name ?: "<noname>", name = meta.name ?: uiStrings(R.string.no_name),
encryptionStatus = encryptionStatus, encryptionKind = encryptionKind,
isReachable = isReachable,
children = node.children.map { child -> children = node.children.map { child ->
toStorageUi( toStorageUi(
node = child, node = child,
metaByStorageUuid = metaByStorageUuid, snapshotByUuid = snapshotByUuid,
) )
}, },
) )
} }
private data class StorageSnapshot(
val meta: IStorageMetaInfo,
val isAvailable: Boolean,
)
private data class StorageTreeNode( private data class StorageTreeNode(
val storage: IStorage, val storage: IStorage,
val children: List<StorageTreeNode>, val children: List<StorageTreeNode>,

View File

@@ -1,69 +1,173 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="nav_label_local_vault">Local</string> <string name="nav_label_local_vault">Локальное хранилище</string>
<string name="nav_label_remote_vaults">Remotes</string> <string name="nav_cd_local_vault">Локальное хранилище</string>
<string name="nav_label_main">Main</string> <string name="nav_label_remote_vaults">Удалённые хранилища</string>
<string name="nav_label_sync">Sync</string> <string name="nav_cd_remote_vaults">Удалённые хранилища</string>
<string name="nav_label_settings">Settings</string> <string name="nav_label_main">Главная</string>
<string name="nav_label_sync">Синхронизация</string>
<string name="nav_label_settings">Настройки</string>
<string name="settings_title">Settings</string> <string name="main_work_status_label">Статус:</string>
<string name="sync_groups_title">Sync groups</string> <string name="main_status_multiple_tasks">Выполняется задач: %1$d</string>
<string name="sync_run_now">Run sync now</string> <string name="main_status_vault_scanning_storages">Сканирование vault: загрузка списка хранилищ…</string>
<string name="sync_refresh">Refresh</string>
<string name="sync_add_storage">Add</string> <string name="settings_title">Настройки</string>
<string name="sync_remove_group">Remove group</string> <string name="sync_groups_title">Группы синхронизации</string>
<string name="sync_group_empty">No storages in group</string> <string name="sync_run_now">Запустить синхронизацию</string>
<string name="sync_remove_storage">Remove</string> <string name="sync_cd_run_now">Запустить синхронизацию сейчас</string>
<string name="sync_picker_back">Back</string> <string name="sync_refresh">Обновить</string>
<string name="sync_picker_title">Select storage for %1$s</string> <string name="sync_add_storage">Добавить хранилище в группу</string>
<string name="sync_picker_add">Add</string> <string name="sync_remove_group">Удалить группу</string>
<string name="sync_picker_added">Added</string> <string name="sync_group_empty">В группе нет хранилищ</string>
<string name="sync_picker_no_storages">No storages in this vault</string> <string name="sync_remove_storage">Убрать хранилище из группы</string>
<string name="sync_group_mixed_encryption_warning">Mixed encryption in group: define one canonical encryption mode</string> <string name="sync_picker_back">Назад</string>
<string name="sync_remove_group_confirm_title">Remove group?</string> <string name="sync_cd_picker_back">Закрыть выбор хранилища</string>
<string name="sync_remove_group_confirm_message">Delete sync group \"%1$s\"?</string> <string name="sync_picker_title">Выбор хранилища для %1$s</string>
<string name="sync_remove_storage_confirm_title">Remove storage?</string> <string name="sync_picker_add">Добавить</string>
<string name="sync_remove_storage_confirm_message">Remove storage \"%1$s\" from the group?</string> <string name="sync_picker_added">Добавлено</string>
<string name="sync_confirm_delete">Delete</string> <string name="sync_picker_cd_add">Добавить хранилище в группу</string>
<string name="sync_cancel">Cancel</string> <string name="sync_picker_no_storages">В этом хранилище нет доступных каталогов</string>
<string name="no_name">&lt;noname&gt;</string> <string name="sync_picker_expand">Развернуть</string>
<string name="show_storage_item_menu">Show storage item menu</string> <string name="sync_picker_collapse">Свернуть</string>
<string name="rename">Rename</string> <string name="sync_fab_create_group_cd">Создать группу синхронизации</string>
<string name="remove">Remove</string> <string name="sync_group_mixed_encryption_warning">В группе разное шифрование: задайте единый режим</string>
<string name="encrypt">Encrypt</string> <string name="sync_remove_group_confirm_title">Удалить группу?</string>
<string name="new_name_title">New name</string> <string name="sync_remove_group_confirm_message">Удалить группу синхронизации «%1$s»?</string>
<string name="remove_confirmation_dialog">Delete storage "%1$s"?</string> <string name="sync_remove_storage_confirm_title">Убрать хранилище?</string>
<string name="storage_lock_actions">Storage encryption actions</string> <string name="sync_remove_storage_confirm_message">Убрать хранилище «%1$s» из группы?</string>
<string name="sync_confirm_delete">Удалить</string>
<string name="sync_cancel">Отмена</string>
<string name="sync_msg_group_created">Создана группа %1$s</string>
<string name="sync_msg_group_removed">Группа удалена</string>
<string name="sync_msg_storage_added">Хранилище добавлено в %1$s</string>
<string name="sync_msg_storage_removed">Хранилище убрано из %1$s</string>
<string name="sync_msg_task_enqueued">Задача синхронизации поставлена в очередь</string>
<string name="sync_encryption_unknown">Неизвестно</string>
<string name="sync_storage_encryption_line">Шифрование: %1$s</string>
<string name="sync_storage_missing_title">Не найдено в текущих vault</string>
<string name="sync_storage_pending_vault_scan">Ожидание: список хранилищ в vault ещё загружается</string>
<string name="sync_storage_not_in_vaults">Нет в дереве хранилищ (удалено, другой аккаунт или не прошёл init)</string>
<string name="sync_storage_unreachable">Хранилище недоступно (vault или сеть)</string>
<string name="no_name">&lt;без имени&gt;</string>
<string name="show_storage_item_menu">Меню хранилища</string>
<string name="storage_row_task_running_cd">Выполняется операция с этим хранилищем</string>
<string name="storage_menu_busy">%1$s (задача выполняется)</string>
<string name="rename">Переименовать</string>
<string name="remove">Удалить</string>
<string name="encrypt">Шифрование</string>
<string name="new_name_title">Новое имя</string>
<string name="remove_confirmation_dialog">Удалить хранилище «%1$s»?</string>
<string name="storage_lock_actions">Действия с шифрованием</string>
<string name="storage_sync_lock_checking">Проверка блокировки…</string> <string name="storage_sync_lock_checking">Проверка блокировки…</string>
<string name="storage_sync_unlock_action">Снять блокировку синхронизации</string> <string name="storage_sync_unlock_action">Снять блокировку синхронизации</string>
<string name="storage_sync_not_locked">Синхронизация не заблокирована</string> <string name="storage_sync_not_locked">Синхронизация не заблокирована</string>
<string name="task_pipeline_title">Task pipeline</string> <string name="storage_field_available">Доступно: %1$s</string>
<string name="task_pipeline_jobs">Jobs</string> <string name="storage_value_yes">да</string>
<string name="task_pipeline_log">Log</string> <string name="storage_value_no">нет</string>
<string name="task_pipeline_cancel_all">Cancel all</string> <string name="storage_field_files">Файлов: %1$s</string>
<string name="task_pipeline_open">Open task pipeline</string> <string name="storage_field_size">Размер: %1$s</string>
<string name="task_pipeline_run_test">Run test task</string> <string name="storage_field_virtual">Виртуальное: %1$s</string>
<string name="task_pipeline_test_dialog_title">Test task setup</string> <string name="storage_unavailable_hint">Хранилище недоступно</string>
<string name="task_pipeline_test_dialog_duration">Duration: %1$d s</string> <string name="storage_menu_unavailable">Недоступно: %1$s</string>
<string name="task_pipeline_test_dialog_start">Start</string>
<string name="task_pipeline_test_dialog_cancel">Cancel</string>
<string name="task_pipeline_test_dialog_infinity">Infinity (indeterminate progress)</string>
<string name="task_state_queued">Queued</string>
<string name="task_state_running">Running</string>
<string name="task_state_completed">Completed</string>
<string name="task_state_cancelled">Cancelled</string>
<string name="task_state_failed">Failed: %1$s</string>
<string name="remote_vaults_add_cd">Add remote vault</string> <string name="storage_status_not_encrypted">Не зашифровано</string>
<string name="remote_vaults_empty_hint">No remote vaults yet. Tap + to add Yandex.</string> <string name="storage_status_encrypted_open">Зашифровано (открыто)</string>
<string name="remote_vaults_add_title">Add vault</string> <string name="storage_status_encrypted_closed">Зашифровано (закрыто)</string>
<string name="remote_vaults_add_pick_provider">Choose provider:</string>
<string name="remote_vaults_provider_yandex">Yandex</string>
<string name="remote_vaults_add_cancel">Cancel</string>
<string name="remote_vault_type_yandex">Yandex</string>
<string name="remote_vault_delete_cd">Remove remote vault</string>
<string name="remote_vault_remove_title">Remove remote vault?</string>
<string name="remote_vault_remove_message">Remove \"%1$s\" from this device? The account data on the server is not deleted.</string>
<string name="vault_fab_add_storage_cd">Создать хранилище</string>
<string name="vault_fab_add_storage_disabled_cd">Создание недоступно: хранилище недоступно</string>
<string name="vault_fab_add_storage_busy_cd">Создание хранилища уже выполняется</string>
<string name="vault_unavailable_banner">Хранилище недоступно. Проверьте сеть, путь или разблокировку.</string>
<string name="vault_loading_storages">Загрузка списка хранилищ…</string>
<string name="vault_empty_list_hint">В этом хранилище пока нет каталогов. Создайте хранилище кнопкой «+», когда оно доступно.</string>
<string name="task_pipeline_title">Очередь задач</string>
<string name="task_pipeline_jobs">Задачи</string>
<string name="task_pipeline_log">Журнал</string>
<string name="task_pipeline_cancel_all">Отменить все</string>
<string name="task_pipeline_open">Открыть очередь задач</string>
<string name="task_pipeline_run_test">Тестовая задача</string>
<string name="task_pipeline_test_dialog_title">Параметры тестовой задачи</string>
<string name="task_pipeline_test_dialog_duration">Длительность: %1$d с</string>
<string name="task_pipeline_test_dialog_start">Запустить</string>
<string name="task_pipeline_test_dialog_cancel">Отмена</string>
<string name="task_pipeline_test_dialog_infinity">Бесконечно (неопределённый прогресс)</string>
<string name="task_pipeline_test_running">Тестовая задача (%1$d с)</string>
<string name="task_pipeline_test_running_infinity">Тестовая задача (%1$d с, ∞)</string>
<string name="task_state_queued">В очереди</string>
<string name="task_state_running">Выполняется</string>
<string name="task_state_completed">Завершено</string>
<string name="task_state_cancelled">Отменено</string>
<string name="task_state_failed">Ошибка: %1$s</string>
<string name="task_title_dump_storage_log">Выгрузка дерева в журнал</string>
<string name="task_title_create_storage">Создание хранилища</string>
<string name="task_title_enable_encryption">Включение шифрования</string>
<string name="task_title_open_encrypted_storage">Расшифровка и открытие хранилища</string>
<string name="task_progress_decrypt_running">Расшифровка…</string>
<string name="task_title_close_encrypted_storage">Закрытие зашифрованного хранилища</string>
<string name="task_title_disable_encryption">Отключение шифрования</string>
<string name="task_title_rename_storage">Переименование хранилища</string>
<string name="task_title_remove_storage">Удаление хранилища</string>
<string name="task_title_clear_sync_lock">Снятие блокировки синхронизации</string>
<string name="task_title_add_remote_vault">Добавление удалённого хранилища</string>
<string name="task_title_remove_remote_vault">Удаление удалённого хранилища</string>
<string name="task_title_storage_sync">Синхронизация хранилищ</string>
<string name="task_title_storage_sync_background">Фоновая синхронизация хранилищ</string>
<string name="msg_encryption_enabled">Шифрование включено</string>
<string name="msg_storage_already_encrypted">Хранилище уже зашифровано</string>
<string name="msg_storage_not_empty">Хранилище не пустое</string>
<string name="msg_storage_empty_state_unknown">Не удалось определить, пусто ли хранилище</string>
<string name="msg_unsupported_storage_type">Неподдерживаемый тип хранилища</string>
<string name="msg_failed_enable_encryption">Не удалось включить шифрование: %1$s</string>
<string name="msg_failed_open_storage">Не удалось открыть хранилище: %1$s</string>
<string name="msg_failed_close_storage">Не удалось закрыть хранилище: %1$s</string>
<string name="msg_encryption_disabled">Шифрование отключено</string>
<string name="msg_failed_disable_encryption">Не удалось отключить шифрование: %1$s</string>
<string name="msg_invalid_storage_for_sync_lock">Некорректное хранилище</string>
<string name="msg_sync_lock_cleared">Блокировка синхронизации снята</string>
<string name="msg_sync_lock_clear_failed">Не удалось снять блокировку: %1$s</string>
<string name="remote_vaults_add_cd">Добавить удалённое хранилище</string>
<string name="remote_vaults_empty_hint">Пока нет удалённых хранилищ. Нажмите «+», чтобы добавить Yandex.</string>
<string name="remote_vaults_add_title">Добавить хранилище</string>
<string name="remote_vaults_add_pick_provider">Выберите провайдера:</string>
<string name="remote_vaults_provider_yandex">Яндекс</string>
<string name="remote_vaults_add_cancel">Отмена</string>
<string name="remote_vault_type_yandex">Яндекс</string>
<string name="remote_vault_delete_cd">Удалить удалённое хранилище</string>
<string name="remote_vault_remove_title">Удалить удалённое хранилище?</string>
<string name="remote_vault_remove_message">Удалить «%1$s» с этого устройства? Данные на сервере не удаляются.</string>
<string name="dialog_cancel">Отмена</string>
<string name="dialog_ok">ОК</string>
<string name="dialog_encryption_enable_title">Включить шифрование</string>
<string name="dialog_password_label">Пароль</string>
<string name="dialog_encrypt_paths">Шифровать пути</string>
<string name="dialog_apply">Применить</string>
<string name="dialog_open_encrypted_title">Открыть зашифрованное хранилище</string>
<string name="dialog_remember_password">Запомнить пароль</string>
<string name="dialog_open">Открыть</string>
<string name="dialog_close">Закрыть</string>
<string name="dialog_disable_encryption">Отключить шифрование</string>
<string name="dialog_done">Готово</string>
<string name="vault_type_local_device">Локальное устройство</string>
<string name="vault_type_remote">Удалённое: %1$s</string>
<string name="vault_type_unknown">Неизвестный тип</string>
<string name="vault_title_local">Локальное хранилище</string>
<string name="vault_title_unknown">Неизвестное хранилище</string>
<string name="enc_status_not_encrypted">Не зашифровано</string>
<string name="enc_status_encrypted_open">Зашифровано (открыто)</string>
<string name="enc_status_encrypted">Зашифровано</string>
<string name="text_edit_screen_title">Текст</string>
<string name="text_edit_screen_placeholder">Содержимое: %1$s</string>
<string name="common_unknown">Неизвестно</string>
</resources> </resources>

View File

@@ -12,17 +12,21 @@ class RunStorageSyncUseCase(
) { ) {
private val running = AtomicBoolean(false) private val running = AtomicBoolean(false)
fun enqueue(reason: String) { /**
* @param displayTitle заголовок задачи в UI (локализованный на стороне вызова)
* @param logReason техническая метка для логов (не для UI)
*/
fun enqueue(displayTitle: String, logReason: String) {
orchestrator.enqueue( orchestrator.enqueue(
title = "Storage sync ($reason)", title = displayTitle,
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
work = { ctx -> work = { ctx ->
if (!running.compareAndSet(false, true)) { 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 return@enqueue
} }
try { try {
ctx.log(TaskLogLevel.Info, "Storage sync started") ctx.log(TaskLogLevel.Info, "Storage sync started, reason=$logReason")
ctx.reportProgress(null, "Storage sync: started") ctx.reportProgress(null, "Storage sync: started")
syncEngine.syncAllGroups { fraction, label -> syncEngine.syncAllGroups { fraction, label ->
ctx.reportProgress(fraction, label) ctx.reportProgress(fraction, label)