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