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