Переключение языка

This commit is contained in:
2026-05-18 15:35:06 +03:00
parent f3f99aed5a
commit f99d79fece
46 changed files with 1368 additions and 424 deletions

View File

@@ -67,6 +67,8 @@ dependencies {
ksp(libs.androidx.hilt.compiler)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)

View File

@@ -21,6 +21,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Wallenc"
android:localeConfig="@xml/locales_config"
android:enableOnBackInvokedCallback="true"
tools:targetApi="37">
<activity

View File

@@ -5,9 +5,9 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -23,7 +23,7 @@ import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
class MainActivity : AppCompatActivity() {
@Inject
lateinit var yandexSignInService: YandexSignInService

View File

@@ -1,8 +1,10 @@
package com.github.nullptroma.wallenc.app
import android.app.Application
import android.content.Context
import androidx.work.Configuration
import com.github.nullptroma.wallenc.app.di.HiltWorkerFactoryEntryPoint
import com.github.nullptroma.wallenc.app.locale.AppLocaleStorage
import com.github.nullptroma.wallenc.app.sync.StorageSyncBootstrap
import com.github.nullptroma.wallenc.app.tasks.TaskPipelineForegroundBootstrap
import dagger.hilt.android.EntryPointAccessors
@@ -18,8 +20,15 @@ class WallencApplication : Application(), Configuration.Provider {
@Inject
lateinit var storageSyncBootstrap: StorageSyncBootstrap
override fun attachBaseContext(base: Context) {
AppLocaleStorage.applyStored(base)
super.attachBaseContext(base)
}
override fun onCreate() {
super.onCreate()
AppLocaleStorage.migrateLegacyDataStoreIfNeeded(this)
AppLocaleStorage.applyStored(this)
taskPipelineForegroundBootstrap.start()
storageSyncBootstrap.start()
}

View File

@@ -0,0 +1,18 @@
package com.github.nullptroma.wallenc.app.di
import com.github.nullptroma.wallenc.app.locale.AppLocaleControllerImpl
import com.github.nullptroma.wallenc.ui.locale.AppLocaleController
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class LocaleModule {
@Binds
@Singleton
abstract fun bindAppLocaleController(impl: AppLocaleControllerImpl): AppLocaleController
}

View File

@@ -0,0 +1,25 @@
package com.github.nullptroma.wallenc.app.locale
import android.content.Context
import com.github.nullptroma.wallenc.ui.locale.AppLanguage
import com.github.nullptroma.wallenc.ui.locale.AppLocaleController
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AppLocaleControllerImpl @Inject constructor(
@param:ApplicationContext private val context: Context,
) : AppLocaleController {
override val language: Flow<AppLanguage> = AppLocaleStorage.languageFlow(context)
override suspend fun setLanguage(language: AppLanguage) {
AppLocaleStorage.persistLanguage(context, language)
}
override suspend fun applyStoredLocale() {
AppLocaleStorage.applyStored(context)
}
}

View File

@@ -0,0 +1,108 @@
package com.github.nullptroma.wallenc.app.locale
import android.content.Context
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.github.nullptroma.wallenc.ui.locale.AppLanguage
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
/**
* Хранение выбранного языка. SharedPreferences — для синхронного чтения в
* [android.app.Application.attachBaseContext]; DataStore — только для миграции со старой версии.
*/
internal object AppLocaleStorage {
private const val PREFS_NAME = "app_locale"
private const val PREFS_KEY_LANGUAGE = "app_language"
private val legacyLanguageKey = stringPreferencesKey(PREFS_KEY_LANGUAGE)
private val Context.legacyLocaleDataStore: DataStore<Preferences> by preferencesDataStore(
name = "app_locale",
)
/** В [android.app.Application.attachBaseContext] [Context.getApplicationContext] ещё null. */
private fun storageContext(context: Context): Context =
context.applicationContext ?: context
private fun prefs(context: Context) =
storageContext(context).getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun languageFlow(context: Context): Flow<AppLanguage> = callbackFlow {
val storage = storageContext(context)
val listener =
android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == PREFS_KEY_LANGUAGE) {
trySend(readLanguageSync(storage))
}
}
trySend(readLanguageSync(storage))
prefs(storage).registerOnSharedPreferenceChangeListener(listener)
awaitClose { prefs(storage).unregisterOnSharedPreferenceChangeListener(listener) }
}
fun persistLanguage(context: Context, language: AppLanguage) {
prefs(context).edit().putString(PREFS_KEY_LANGUAGE, language.storageValue).apply()
applyLanguage(language)
}
fun readLanguageSync(context: Context): AppLanguage {
val raw = prefs(context).getString(PREFS_KEY_LANGUAGE, null)
return raw?.toAppLanguage() ?: AppLanguage.System
}
/** Без блокировок; безопасно из [android.app.Application.attachBaseContext]. */
fun applyStored(context: Context) {
applyLanguage(readLanguageSync(context))
}
fun applyLanguage(language: AppLanguage) {
val localeList = when (language) {
AppLanguage.System -> LocaleListCompat.getEmptyLocaleList()
AppLanguage.English -> LocaleListCompat.forLanguageTags("en")
AppLanguage.Russian -> LocaleListCompat.forLanguageTags("ru")
}
AppCompatDelegate.setApplicationLocales(localeList)
}
/** Однократно переносит значение из DataStore (если SP ещё пуст). */
fun migrateLegacyDataStoreIfNeeded(context: Context) {
val storage = storageContext(context)
if (prefs(storage).contains(PREFS_KEY_LANGUAGE)) {
return
}
runBlocking {
runCatching {
val legacy = storage.legacyLocaleDataStore.data.first()[legacyLanguageKey]
if (legacy != null) {
prefs(storage).edit().putString(PREFS_KEY_LANGUAGE, legacy).apply()
}
}
}
}
private fun String.toAppLanguage(): AppLanguage = when (this) {
STORAGE_EN -> AppLanguage.English
STORAGE_RU -> AppLanguage.Russian
else -> AppLanguage.System
}
private val AppLanguage.storageValue: String
get() = when (this) {
AppLanguage.System -> STORAGE_SYSTEM
AppLanguage.English -> STORAGE_EN
AppLanguage.Russian -> STORAGE_RU
}
private const val STORAGE_SYSTEM = "system"
private const val STORAGE_EN = "en"
private const val STORAGE_RU = "ru"
}

View File

@@ -17,6 +17,8 @@ import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundItem
import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundUiState
import com.github.nullptroma.wallenc.domain.tasks.TaskId
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import com.github.nullptroma.wallenc.ui.resources.resolve
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -43,6 +45,9 @@ class TaskPipelineForegroundService : Service() {
@Inject
lateinit var orchestrator: ITaskOrchestrator
@Inject
lateinit var uiStrings: UiStringResolver
private var repeat = false
private var canPush = true
private var lastUiState: TaskForegroundUiState? = null
@@ -291,7 +296,7 @@ class TaskPipelineForegroundService : Service() {
remoteViews.setViewVisibility(TASK_ROW_IDS[index], View.VISIBLE)
remoteViews.setViewVisibility(TASK_LABEL_BAR_ROW_IDS[index], View.VISIBLE)
remoteViews.setTextViewText(TASK_TITLE_IDS[index], task.title)
val label = task.progress?.label?.trim().orEmpty()
val label = task.progress?.label?.resolve(uiStrings).orEmpty()
val fraction = task.progress?.fraction
if (fraction != null) {
if (label.isNotEmpty()) {

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Wallenc</string>
<string name="task_notification_channel_name">Фоновые задачи</string>
<string name="task_notification_title">Задачи Wallenc</string>
<string name="task_notification_preparing">Подготовка…</string>
<string name="task_notification_indeterminate">Выполняется…</string>
<string name="task_notification_cancel">Отмена</string>
</resources>

View File

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

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Wallenc" parent="android:Theme.Material.Light.NoActionBar">
<style name="Theme.Wallenc" parent="Theme.AppCompat.Light.NoActionBar">
<!-- До первого кадра Compose и системный splash (12+) -->
<item name="android:windowBackground">@color/splash_screen_background</item>
<item name="android:windowSplashScreenBackground" tools:targetApi="31">

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="ru" />
</locale-config>

View File

@@ -7,6 +7,7 @@ import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
@@ -132,7 +133,7 @@ abstract class BaseStorage(
onProgress(
TaskProgress(
fraction = done.toFloat() / total,
label = "$done / $total",
label = TaskProgressLabel.ClearContentProgress(done, total),
),
)
coroutineContext.ensureActive()

View File

@@ -1,5 +1,6 @@
package com.github.nullptroma.wallenc.domain.interfaces
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import java.util.UUID
enum class StorageSyncGroupEncryptionKind {
@@ -24,10 +25,10 @@ interface IStorageSyncGroupStore {
interface IStorageSyncEngine {
suspend fun syncAllGroups(
reportProgress: (suspend (fraction: Float?, label: String?) -> Unit)? = null,
reportProgress: (suspend (fraction: Float?, label: TaskProgressLabel?) -> Unit)? = null,
)
suspend fun syncGroup(
groupId: String,
reportProgress: (suspend (fraction: Float?, label: String?) -> Unit)? = null,
reportProgress: (suspend (fraction: Float?, label: TaskProgressLabel?) -> Unit)? = null,
)
}

View File

@@ -5,11 +5,13 @@ import com.github.nullptroma.wallenc.domain.errors.WallencException
interface TaskContext {
val taskId: TaskId
suspend fun reportProgress(fraction: Float?, label: String?)
suspend fun reportProgress(fraction: Float?, label: TaskProgressLabel? = null)
suspend fun reportProgress(progress: TaskProgress) = reportProgress(progress.fraction, progress.label)
fun log(level: TaskLogLevel, message: String)
fun log(level: TaskLogLevel, key: TaskLogKey)
fun fail(error: WallencException): Nothing
}

View File

@@ -0,0 +1,9 @@
package com.github.nullptroma.wallenc.domain.tasks
import com.github.nullptroma.wallenc.domain.errors.WallencException
sealed class TaskLogKey {
data object SyncStarted : TaskLogKey()
data object SyncFinished : TaskLogKey()
data class SyncFailed(val error: WallencException) : TaskLogKey()
}

View File

@@ -3,5 +3,6 @@ package com.github.nullptroma.wallenc.domain.tasks
data class TaskLogLine(
val timestampMs: Long,
val level: TaskLogLevel,
val message: String,
val message: String = "",
val logKey: TaskLogKey? = null,
)

View File

@@ -3,5 +3,5 @@ package com.github.nullptroma.wallenc.domain.tasks
data class TaskProgress(
/** 0f..1f or null if indeterminate */
val fraction: Float?,
val label: String?,
val label: TaskProgressLabel? = null,
)

View File

@@ -0,0 +1,50 @@
package com.github.nullptroma.wallenc.domain.tasks
sealed class TaskProgressLabel {
data object SyncNoGroups : TaskProgressLabel()
data object SyncStarted : TaskProgressLabel()
data object SyncCompleted : TaskProgressLabel()
data class SyncPreparing(val groupCount: Int) : TaskProgressLabel()
data class SyncGroupPreparing(val groupId: String) : TaskProgressLabel()
data class SyncGroupNotFound(val groupId: String) : TaskProgressLabel()
data class SyncGroupSkippedTooFewStorages(val groupId: String) : TaskProgressLabel()
data class SyncGroupSkippedIncompatibleEncryption(val groupId: String, val count: Int) : TaskProgressLabel()
data class SyncGroupAcquiringLocks(val groupId: String) : TaskProgressLabel()
data class SyncGroupLockProgress(val groupId: String, val current: Int, val total: Int) : TaskProgressLabel()
data class SyncGroupLockFailed(val groupId: String) : TaskProgressLabel()
data class SyncGroupReadingJournals(val groupId: String) : TaskProgressLabel()
data class SyncGroupCancelled(val groupId: String) : TaskProgressLabel()
data class SyncGroupJournalProgress(val groupId: String, val current: Int, val total: Int) : TaskProgressLabel()
data class SyncGroupNoJournalEntries(val groupId: String) : TaskProgressLabel()
data class SyncGroupProcessingEntries(val groupId: String, val count: Int) : TaskProgressLabel()
data class SyncGroupEntryProgress(val groupId: String, val current: Int, val total: Int) : TaskProgressLabel()
data class SyncGroupCompleted(val groupId: String) : TaskProgressLabel()
data class SyncGroupRenewingLocks(val groupId: String) : TaskProgressLabel()
data class SyncGroupLockRenewalFailed(val groupId: String) : TaskProgressLabel()
data class ClearContentProgress(val done: Int, val total: Int) : TaskProgressLabel()
data class VaultTask(val step: VaultTaskStep) : TaskProgressLabel()
data class TestElapsed(val elapsedSec: Int, val totalSec: Int) : TaskProgressLabel()
}
enum class VaultTaskStep {
DumpStorageLog,
CreateStorage,
EnableEncryption,
DecryptRunning,
CloseStorage,
DisableEncryption,
RenameStorage,
RemoveStorage,
ClearSyncLock,
AddRemoteVault,
RemoveRemoteVault,
RetryRemoteVault,
Save2FaToken,
Delete2FaToken,
SaveTextSecret,
DeleteTextSecret,
}

View File

@@ -26,6 +26,8 @@ hiltWork = "1.3.0"
cameraX = "1.6.1"
mlkitBarcode = "17.3.0"
javaOtp = "0.4.0"
appcompat = "1.7.1"
datastore = "1.2.0"
[libraries]
jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" }
@@ -60,6 +62,8 @@ androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref
androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltWork" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }

View File

@@ -10,8 +10,10 @@ import com.github.nullptroma.wallenc.domain.tasks.TaskContext
import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundItem
import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundUiState
import com.github.nullptroma.wallenc.domain.tasks.TaskId
import com.github.nullptroma.wallenc.domain.tasks.TaskLogKey
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLine
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
import kotlinx.coroutines.CancellationException
@@ -143,11 +145,12 @@ class TaskOrchestrator(
}
}
private fun appendLogLine(level: TaskLogLevel, message: String) {
private fun appendLogLine(level: TaskLogLevel, message: String, logKey: TaskLogKey? = null) {
val line = TaskLogLine(
timestampMs = System.currentTimeMillis(),
level = level,
message = message,
logKey = logKey,
)
synchronized(logLock) {
if (logBuffer.size >= MAX_LOG_LINES) {
@@ -180,7 +183,7 @@ class TaskOrchestrator(
val ctx = TaskContextImpl(
taskId = taskId,
onRunningProgress = { p -> onRunningProgress(taskId, p) },
appendLog = { level, msg -> appendLogLine(level, msg) },
appendLog = { level, msg, key -> appendLogLine(level, msg, key) },
)
try {
if (cancelRequested[taskId] == true) {
@@ -213,14 +216,18 @@ class TaskOrchestrator(
private class TaskContextImpl(
override val taskId: TaskId,
private val onRunningProgress: (TaskProgress) -> Unit,
private val appendLog: (TaskLogLevel, String) -> Unit,
private val appendLog: (TaskLogLevel, String, TaskLogKey?) -> Unit,
) : TaskContext {
override suspend fun reportProgress(fraction: Float?, label: String?) {
override suspend fun reportProgress(fraction: Float?, label: TaskProgressLabel?) {
onRunningProgress(TaskProgress(fraction, label))
}
override fun log(level: TaskLogLevel, message: String) {
appendLog(level, message)
appendLog(level, message, null)
}
override fun log(level: TaskLogLevel, key: TaskLogKey) {
appendLog(level, "", key)
}
override fun fail(error: WallencException): Nothing {

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.List
import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Sync
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -99,13 +100,11 @@ fun WallencNavRoot(
StorageSyncRoute::class.qualifiedName!!,
Icons.Rounded.Sync,
),
// Settings temporarily hidden from top-level menu.
// Uncomment to restore:
// SettingsRoute::class.qualifiedName!! to NavBarItemData(
// R.string.nav_label_settings,
// SettingsRoute::class.qualifiedName!!,
// Icons.Rounded.Settings,
// ),
SettingsRoute::class.qualifiedName!! to NavBarItemData(
R.string.nav_label_settings,
SettingsRoute::class.qualifiedName!!,
Icons.Rounded.Settings,
),
)
}

View File

@@ -0,0 +1,18 @@
package com.github.nullptroma.wallenc.ui.locale
import kotlinx.coroutines.flow.Flow
enum class AppLanguage {
System,
English,
Russian,
}
interface AppLocaleController {
val language: Flow<AppLanguage>
suspend fun setLanguage(language: AppLanguage)
/** Applies persisted locale; call from [android.app.Application.onCreate]. */
suspend fun applyStoredLocale()
}

View File

@@ -0,0 +1,13 @@
package com.github.nullptroma.wallenc.ui.resources
import com.github.nullptroma.wallenc.domain.tasks.TaskLogKey
import com.github.nullptroma.wallenc.ui.R
fun TaskLogKey.resolve(resolver: UiStringResolver): String = when (this) {
TaskLogKey.SyncStarted -> resolver(R.string.task_log_sync_started)
TaskLogKey.SyncFinished -> resolver(R.string.task_log_sync_finished)
is TaskLogKey.SyncFailed -> {
val notification = error.toUserNotification().resolve(resolver)
resolver(R.string.task_log_sync_failed, notification)
}
}

View File

@@ -0,0 +1,22 @@
package com.github.nullptroma.wallenc.ui.resources
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLine
@Composable
fun TaskLogLine.displayText(): String {
val key = logKey
if (key != null) {
val context = LocalContext.current
val resolver = UiStringResolver { id, args ->
if (args.isEmpty()) {
context.getString(id)
} else {
context.getString(id, *args)
}
}
return key.resolve(resolver)
}
return message
}

View File

@@ -0,0 +1,68 @@
package com.github.nullptroma.wallenc.ui.resources
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import com.github.nullptroma.wallenc.domain.tasks.VaultTaskStep
import com.github.nullptroma.wallenc.ui.R
fun TaskProgressLabel.resolve(resolver: UiStringResolver): String = when (this) {
TaskProgressLabel.SyncNoGroups -> resolver(R.string.sync_progress_no_groups)
TaskProgressLabel.SyncStarted -> resolver(R.string.sync_progress_started)
TaskProgressLabel.SyncCompleted -> resolver(R.string.sync_progress_completed)
is TaskProgressLabel.SyncPreparing -> resolver(R.string.sync_progress_preparing, groupCount)
is TaskProgressLabel.SyncGroupPreparing -> resolver(R.string.sync_progress_group_preparing, groupId)
is TaskProgressLabel.SyncGroupNotFound -> resolver(R.string.sync_progress_group_not_found, groupId)
is TaskProgressLabel.SyncGroupSkippedTooFewStorages ->
resolver(R.string.sync_progress_group_skipped_few_storages, groupId)
is TaskProgressLabel.SyncGroupSkippedIncompatibleEncryption ->
resolver(R.string.sync_progress_group_skipped_incompatible, groupId, count)
is TaskProgressLabel.SyncGroupAcquiringLocks ->
resolver(R.string.sync_progress_group_acquiring_locks, groupId)
is TaskProgressLabel.SyncGroupLockProgress ->
resolver(R.string.sync_progress_group_lock, groupId, current, total)
is TaskProgressLabel.SyncGroupLockFailed ->
resolver(R.string.sync_progress_group_lock_failed, groupId)
is TaskProgressLabel.SyncGroupReadingJournals ->
resolver(R.string.sync_progress_group_reading_journals, groupId)
is TaskProgressLabel.SyncGroupCancelled ->
resolver(R.string.sync_progress_group_cancelled, groupId)
is TaskProgressLabel.SyncGroupJournalProgress ->
resolver(R.string.sync_progress_group_journal, groupId, current, total)
is TaskProgressLabel.SyncGroupNoJournalEntries ->
resolver(R.string.sync_progress_group_no_entries, groupId)
is TaskProgressLabel.SyncGroupProcessingEntries ->
resolver(R.string.sync_progress_group_processing, groupId, count)
is TaskProgressLabel.SyncGroupEntryProgress ->
resolver(R.string.sync_progress_group_entry, groupId, current, total)
is TaskProgressLabel.SyncGroupCompleted ->
resolver(R.string.sync_progress_group_completed, groupId)
is TaskProgressLabel.SyncGroupRenewingLocks ->
resolver(R.string.sync_progress_group_renewing_locks, groupId)
is TaskProgressLabel.SyncGroupLockRenewalFailed ->
resolver(R.string.sync_progress_group_lock_renewal_failed, groupId)
is TaskProgressLabel.ClearContentProgress ->
resolver(R.string.task_progress_clear_content, done, total)
is TaskProgressLabel.VaultTask -> when (step) {
VaultTaskStep.DumpStorageLog -> resolver(R.string.task_progress_dump_storage_log)
VaultTaskStep.CreateStorage -> resolver(R.string.task_progress_create_storage)
VaultTaskStep.EnableEncryption -> resolver(R.string.task_progress_enable_encryption)
VaultTaskStep.DecryptRunning -> resolver(R.string.task_progress_decrypt_running)
VaultTaskStep.CloseStorage -> resolver(R.string.task_progress_close_storage)
VaultTaskStep.DisableEncryption -> resolver(R.string.task_progress_disable_encryption)
VaultTaskStep.RenameStorage -> resolver(R.string.task_progress_rename_storage)
VaultTaskStep.RemoveStorage -> resolver(R.string.task_progress_remove_storage)
VaultTaskStep.ClearSyncLock -> resolver(R.string.task_progress_clear_sync_lock)
VaultTaskStep.AddRemoteVault -> resolver(R.string.task_progress_add_remote_vault)
VaultTaskStep.RemoveRemoteVault -> resolver(R.string.task_progress_remove_remote_vault)
VaultTaskStep.RetryRemoteVault -> resolver(R.string.task_progress_retry_remote_vault)
VaultTaskStep.Save2FaToken -> resolver(R.string.task_progress_save_2fa_token)
VaultTaskStep.Delete2FaToken -> resolver(R.string.task_progress_delete_2fa_token)
VaultTaskStep.SaveTextSecret -> resolver(R.string.task_progress_save_text_secret)
VaultTaskStep.DeleteTextSecret -> resolver(R.string.task_progress_delete_text_secret)
}
is TaskProgressLabel.TestElapsed ->
resolver(R.string.task_pipeline_test_elapsed, elapsedSec, totalSec)
}

View File

@@ -0,0 +1,19 @@
package com.github.nullptroma.wallenc.ui.resources
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
@Composable
fun TaskProgressLabel.resolveText(): String {
val context = LocalContext.current
return resolve(
UiStringResolver { id, args ->
if (args.isEmpty()) {
context.getString(id)
} else {
context.getString(id, *args)
}
},
)
}

View File

@@ -14,3 +14,14 @@ fun UserNotification.resolveText(): String = when (this) {
}
is UserNotification.Plain -> message
}
fun UserNotification.resolve(resolver: UiStringResolver): String = when (this) {
is UserNotification.TextRes -> {
if (formatArgs.isEmpty()) {
resolver(id)
} else {
resolver(id, *formatArgs.toTypedArray())
}
}
is UserNotification.Plain -> message
}

View File

@@ -14,6 +14,7 @@ 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.resources.resolve
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.remotes.RemoteVaultsRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.LocalVaultRoute
@@ -127,7 +128,7 @@ class MainViewModel @Inject constructor(
private fun activeWorkStatusFromProgress(title: String, progress: TaskProgress?): MainWorkStatus.Active {
val frac = progress?.fraction
val indeterminate = progress == null || frac == null
val label = progress?.label?.takeIf { it.isNotBlank() }
val label = progress?.label?.resolve(uiStrings)
val line = if (label != null) "$title$TITLE_LABEL_SEPARATOR$label" else title
return MainWorkStatus.Active(
line = line,

View File

@@ -4,6 +4,8 @@ import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import com.github.nullptroma.wallenc.domain.tasks.VaultTaskStep
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
@@ -77,12 +79,12 @@ class RemoteVaultsViewModel @Inject constructor(
dispatcher = Dispatchers.IO,
work = { ctx ->
try {
ctx.reportProgress(null, uiStrings(R.string.task_progress_add_remote_vault))
ctx.log(TaskLogLevel.Info, "Adding vault")
ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.AddRemoteVault))
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_adding_vault))
vaultRegistrar.register(registration)
ctx.log(TaskLogLevel.Info, "Vault added")
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_vault_added))
} catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Failed to add vault")
ctx.log(TaskLogLevel.Error, uiStrings(R.string.task_log_add_vault_failed))
} finally {
withContext(Dispatchers.Main.immediate) {
setBusy(false)
@@ -110,12 +112,12 @@ class RemoteVaultsViewModel @Inject constructor(
dispatcher = Dispatchers.IO,
work = { ctx ->
try {
ctx.reportProgress(null, uiStrings(R.string.task_progress_remove_remote_vault))
ctx.log(TaskLogLevel.Info, "Removing remote vault")
ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.RemoveRemoteVault))
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_removing_remote_vault))
vaultRegistrar.unregister(uuid)
ctx.log(TaskLogLevel.Info, "Remote vault removed")
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_remote_vault_removed))
} catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Failed to remove vault")
ctx.log(TaskLogLevel.Error, uiStrings(R.string.task_log_remove_vault_failed))
} finally {
withContext(Dispatchers.Main.immediate) {
setBusy(false)
@@ -133,12 +135,12 @@ class RemoteVaultsViewModel @Inject constructor(
dispatcher = Dispatchers.IO,
work = { ctx ->
try {
ctx.reportProgress(null, uiStrings(R.string.task_progress_retry_remote_vault))
ctx.log(TaskLogLevel.Info, "Retrying remote vault connection…")
ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.RetryRemoteVault))
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_retrying_vault))
vaultRegistrar.retry(vaultUuid)
ctx.log(TaskLogLevel.Info, "Retry requested")
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_retry_requested))
} catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Failed to retry remote vault")
ctx.log(TaskLogLevel.Error, uiStrings(R.string.task_log_retry_vault_failed))
} finally {
withContext(Dispatchers.Main.immediate) {
setBusy(false)

View File

@@ -88,6 +88,7 @@ fun TextSecretDetailsScreen(
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(secret.items) { item ->
val clipboardFallbackLabel = stringResource(R.string.text_secret_clipboard_fallback_label)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors(
@@ -118,7 +119,7 @@ fun TextSecretDetailsScreen(
onClick = {
scope.launch {
val clipData = ClipData.newPlainText(
item.label ?: "value",
item.label ?: clipboardFallbackLabel,
item.value,
)
clipboard.setClipEntry(clipData.toClipEntry())

View File

@@ -3,6 +3,8 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import com.github.nullptroma.wallenc.domain.tasks.VaultTaskStep
import com.github.nullptroma.wallenc.domain.tasks.TaskId
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
import com.github.nullptroma.wallenc.ui.R
@@ -105,7 +107,7 @@ class TextSecretDetailsViewModel @Inject constructor(
dispatcher = Dispatchers.IO,
busyStorageUuid = storage.uuid,
work = { ctx ->
ctx.reportProgress(null, uiStrings(R.string.task_progress_delete_text_secret))
ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.DeleteTextSecret))
manageTextSecretsUseCase.delete(storage, secretId)
},
)

View File

@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretEntryRecord
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import com.github.nullptroma.wallenc.domain.tasks.VaultTaskStep
import com.github.nullptroma.wallenc.domain.tasks.TaskId
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
import com.github.nullptroma.wallenc.ui.R
@@ -110,7 +112,7 @@ class TextSecretEditViewModel @Inject constructor(
dispatcher = Dispatchers.IO,
busyStorageUuid = storage.uuid,
work = { ctx ->
ctx.reportProgress(null, uiStrings(R.string.task_progress_save_text_secret))
ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.SaveTextSecret))
if (existingId == null) {
manageTextSecretsUseCase.create(
storageInfo = storage,

View File

@@ -4,6 +4,8 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import com.github.nullptroma.wallenc.domain.tasks.VaultTaskStep
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.domain.errors.WallencException
@@ -104,7 +106,7 @@ class TwoFaTokensViewModel @Inject constructor(
dispatcher = Dispatchers.IO,
busyStorageUuid = storage.uuid,
work = { ctx ->
ctx.reportProgress(null, uiStrings(R.string.task_progress_save_2fa_token))
ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.Save2FaToken))
if (existingId == null) {
manageTwoFaTokensUseCase.create(
storageInfo = storage,
@@ -152,7 +154,7 @@ class TwoFaTokensViewModel @Inject constructor(
dispatcher = Dispatchers.IO,
busyStorageUuid = storage.uuid,
work = { ctx ->
ctx.reportProgress(null, uiStrings(R.string.task_progress_delete_2fa_token))
ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.Delete2FaToken))
manageTwoFaTokensUseCase.delete(storage, id)
},
)

View File

@@ -36,6 +36,8 @@ import com.github.nullptroma.wallenc.domain.tasks.PipelineTask
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.resources.displayText
import com.github.nullptroma.wallenc.ui.resources.resolveText
import com.github.nullptroma.wallenc.ui.resources.toUserNotification
@OptIn(ExperimentalMaterial3Api::class)
@@ -97,7 +99,7 @@ fun TaskPipelineScreen(
TaskLogLevel.Error -> "E"
}
Text(
"[$prefix] ${line.message}",
"[$prefix] ${line.displayText()}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -185,7 +187,7 @@ private fun TaskRow(task: PipelineTask, isRunning: Boolean) {
else MaterialTheme.typography.bodyMedium,
)
val runningProgress = (task.state as? TaskRunState.Running)?.progress
val progressLabel = runningProgress?.label?.takeIf { it.isNotBlank() }
val progressLabel = runningProgress?.label?.resolveText()
val stateLabel = when (val s = task.state) {
TaskRunState.Queued -> stringResource(R.string.task_state_queued)
is TaskRunState.Running ->

View File

@@ -3,6 +3,7 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.tasks
import androidx.lifecycle.ViewModel
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -29,17 +30,17 @@ class TaskPipelineViewModel @Inject constructor(
dispatcher = Dispatchers.Default,
work = { ctx ->
val steps = if (safeDurationSec == 0) 1 else safeDurationSec * 10
ctx.log(TaskLogLevel.Info, "Test task started for ${safeDurationSec}s")
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_test_started, safeDurationSec))
for (step in 0..steps) {
val fraction = step.toFloat() / steps.toFloat()
val elapsedMs = (fraction * safeDurationSec * 1000).toInt()
val elapsedSec = ((fraction * safeDurationSec * 1000).toInt()) / 1000
ctx.reportProgress(
fraction = if (infinityIndeterminateProgress) null else fraction,
label = "Elapsed: ${elapsedMs / 1000}s / ${safeDurationSec}s",
label = TaskProgressLabel.TestElapsed(elapsedSec, safeDurationSec),
)
if (step < steps) delay(100)
}
ctx.log(TaskLogLevel.Info, "Test task finished")
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_test_finished))
},
)
}

View File

@@ -11,7 +11,9 @@ import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
import com.github.nullptroma.wallenc.domain.tasks.VaultTaskStep
import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase
import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase
import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase
@@ -162,9 +164,9 @@ abstract class AbstractVaultBrowserViewModel(
dispatcher = Dispatchers.IO,
busyStorageUuid = id,
work = { ctx ->
ctx.reportProgress(null, uiStrings(R.string.task_progress_dump_storage_log))
ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.DumpStorageLog))
storageFileManagementUseCase.setStorage(storage)
ctx.log(TaskLogLevel.Info, "Enumerating files and directories…")
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_enumerating))
val files: List<IFile>
val dirs: List<IDirectory>
val time = measureTimeMillis {
@@ -181,7 +183,7 @@ abstract class AbstractVaultBrowserViewModel(
logger.debug("Storage", storage.toPrintable())
ctx.log(
TaskLogLevel.Info,
"Done: ${files.size} files, ${dirs.size} dirs in ${time}ms (see app log for lines)",
uiStrings(R.string.task_log_enumerate_done, files.size, dirs.size, time),
)
},
)
@@ -204,17 +206,17 @@ abstract class AbstractVaultBrowserViewModel(
locksVaultStorageList = true,
work = { ctx ->
try {
ctx.reportProgress(null, uiStrings(R.string.task_progress_create_storage))
ctx.log(TaskLogLevel.Info, "Creating storage")
ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.CreateStorage))
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_creating_storage))
val uuid = resolveCreateVaultUuid()
?: throw IllegalStateException("Vault is not available")
logger.debug(TAG, "createStorage: vaultUuid=$uuid")
val storage = manageVaultUseCase.createStorage(uuid)
ctx.log(TaskLogLevel.Info, "Storage created")
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_storage_created))
logger.debug(TAG, "createStorage: done storageUuid=${storage.uuid}")
} catch (e: Exception) {
logger.debug(TAG, "createStorage failed: ${e.stackTraceToString()}")
ctx.log(TaskLogLevel.Error, e.message ?: e.toString())
ctx.log(TaskLogLevel.Error, uiStrings(R.string.task_log_add_vault_failed))
throw e
}
},
@@ -239,35 +241,35 @@ abstract class AbstractVaultBrowserViewModel(
busyStorageUuid = id,
work = { ctx ->
try {
ctx.reportProgress(null, uiStrings(R.string.task_progress_enable_encryption))
ctx.log(TaskLogLevel.Info, "Checking storage")
ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.EnableEncryption))
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_checking_storage))
when (manageStoragesEncryptionUseCase.canEncrypt(storage)) {
ManageStoragesEncryptionUseCase.CanEncryptResult.Allowed -> {
ctx.log(TaskLogLevel.Info, "Encrypting")
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_encrypting))
manageStoragesEncryptionUseCase.enableEncryption(storage, key, encryptPath)
manageStoragesEncryptionUseCase.openStorage(storage, key, rememberPassword)
ctx.log(TaskLogLevel.Info, "Encryption enabled")
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_encryption_enabled))
_userNotifications.emit(UserNotification.TextRes(R.string.msg_encryption_enabled))
}
ManageStoragesEncryptionUseCase.CanEncryptResult.AlreadyEncrypted -> {
ctx.log(TaskLogLevel.Info, "Storage is already encrypted")
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_already_encrypted))
_userNotifications.emit(UserNotification.TextRes(R.string.msg_storage_already_encrypted))
}
ManageStoragesEncryptionUseCase.CanEncryptResult.StorageIsNotEmpty -> {
ctx.log(TaskLogLevel.Info, "Storage is not empty")
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_not_empty))
_userNotifications.emit(UserNotification.TextRes(R.string.msg_storage_not_empty))
}
ManageStoragesEncryptionUseCase.CanEncryptResult.StorageStateUnknown -> {
ctx.log(TaskLogLevel.Info, "Cannot determine whether storage is empty")
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_empty_unknown))
_userNotifications.emit(UserNotification.TextRes(R.string.msg_storage_empty_state_unknown))
}
ManageStoragesEncryptionUseCase.CanEncryptResult.UnsupportedStorageType -> {
ctx.log(TaskLogLevel.Info, "Unsupported storage type")
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_unsupported_type))
_userNotifications.emit(UserNotification.TextRes(R.string.msg_unsupported_storage_type))
}
}
} catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Failed to enable encryption")
ctx.log(TaskLogLevel.Error, uiStrings(R.string.task_log_enable_encryption_failed))
emitTaskError(e)
}
},
@@ -287,12 +289,12 @@ abstract class AbstractVaultBrowserViewModel(
busyStorageUuid = id,
work = { ctx ->
try {
ctx.reportProgress(null, uiStrings(R.string.task_progress_decrypt_running))
ctx.log(TaskLogLevel.Info, "Opening encrypted storage")
ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.DecryptRunning))
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_opening_storage))
manageStoragesEncryptionUseCase.openStorage(storage, key, rememberPassword)
ctx.log(TaskLogLevel.Info, "Storage opened")
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_storage_opened))
} catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Failed to open encrypted storage")
ctx.log(TaskLogLevel.Error, uiStrings(R.string.task_log_open_storage_failed))
emitTaskError(e)
}
},
@@ -311,12 +313,12 @@ abstract class AbstractVaultBrowserViewModel(
busyStorageUuid = id,
work = { ctx ->
try {
ctx.reportProgress(null, uiStrings(R.string.task_progress_close_storage))
ctx.log(TaskLogLevel.Info, "Closing storage")
ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.CloseStorage))
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_closing_storage))
manageStoragesEncryptionUseCase.closeStorage(storage)
ctx.log(TaskLogLevel.Info, "Storage closed")
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_storage_closed))
} catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Failed to close encrypted storage")
ctx.log(TaskLogLevel.Error, uiStrings(R.string.task_log_close_storage_failed))
emitTaskError(e)
}
},
@@ -335,17 +337,18 @@ abstract class AbstractVaultBrowserViewModel(
busyStorageUuid = id,
work = { ctx ->
try {
ctx.reportProgress(null, uiStrings(R.string.task_progress_disable_encryption))
ctx.log(TaskLogLevel.Info, "Disabling encryption")
ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.DisableEncryption))
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_disabling_encryption))
manageStoragesEncryptionUseCase.clearAndDisableEncryption(storage) { p ->
val label = p.label?.takeIf { it.isNotBlank() }
?: uiStrings(R.string.task_progress_disable_encryption)
ctx.reportProgress(p.fraction, label)
ctx.reportProgress(
p.fraction,
p.label ?: TaskProgressLabel.VaultTask(VaultTaskStep.DisableEncryption),
)
}
ctx.log(TaskLogLevel.Info, "Encryption disabled")
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_encryption_disabled))
_userNotifications.emit(UserNotification.TextRes(R.string.msg_encryption_disabled))
} catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Failed")
ctx.log(TaskLogLevel.Error, uiStrings(R.string.task_log_disable_encryption_failed))
emitTaskError(e)
}
},
@@ -364,12 +367,12 @@ abstract class AbstractVaultBrowserViewModel(
busyStorageUuid = id,
work = { ctx ->
try {
ctx.reportProgress(null, uiStrings(R.string.task_progress_rename_storage))
ctx.log(TaskLogLevel.Info, "Renaming")
ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.RenameStorage))
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_renaming))
renameStorageUseCase.rename(storage, newName)
ctx.log(TaskLogLevel.Info, "Renamed")
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_renamed))
} catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Rename failed")
ctx.log(TaskLogLevel.Error, uiStrings(R.string.task_log_rename_failed))
}
},
)
@@ -388,12 +391,12 @@ abstract class AbstractVaultBrowserViewModel(
locksVaultStorageList = true,
work = { ctx ->
try {
ctx.reportProgress(null, uiStrings(R.string.task_progress_remove_storage))
ctx.log(TaskLogLevel.Info, "Removing storage")
ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.RemoveStorage))
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_removing_storage))
removeStorageUseCase.remove(storage)
ctx.log(TaskLogLevel.Info, "Removed")
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_removed))
} catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Remove failed")
ctx.log(TaskLogLevel.Error, uiStrings(R.string.task_log_remove_failed))
}
},
)
@@ -435,17 +438,17 @@ abstract class AbstractVaultBrowserViewModel(
try {
val s = storage as? IStorage
if (s == null) {
ctx.log(TaskLogLevel.Error, "Invalid storage")
ctx.log(TaskLogLevel.Error, uiStrings(R.string.task_log_invalid_storage))
_userNotifications.emit(UserNotification.TextRes(R.string.msg_invalid_storage_for_sync_lock))
return@enqueue
}
ctx.reportProgress(null, uiStrings(R.string.task_progress_clear_sync_lock))
ctx.log(TaskLogLevel.Info, "Clearing sync lock")
ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.ClearSyncLock))
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_clearing_sync_lock))
s.accessor.forceClearSyncLock()
ctx.log(TaskLogLevel.Info, "Sync lock cleared")
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_sync_lock_cleared))
_userNotifications.emit(UserNotification.TextRes(R.string.msg_sync_lock_cleared))
} catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "clear sync lock failed")
ctx.log(TaskLogLevel.Error, uiStrings(R.string.task_log_clear_sync_lock_failed))
emitTaskError(e)
}
},

View File

@@ -2,21 +2,86 @@ package com.github.nullptroma.wallenc.ui.screens.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.locale.AppLanguage
@Composable
fun SettingsScreen(modifier: Modifier, viewModel: SettingsViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(text = stringResource(id = R.string.settings_title))
Text(
text = stringResource(id = R.string.settings_title),
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = stringResource(R.string.settings_language_section),
style = MaterialTheme.typography.titleMedium,
)
Column(Modifier.selectableGroup()) {
LanguageOption(
label = stringResource(R.string.settings_language_system),
selected = state.selectedLanguage == AppLanguage.System,
onClick = { viewModel.setLanguage(AppLanguage.System) },
)
LanguageOption(
label = stringResource(R.string.settings_language_english),
selected = state.selectedLanguage == AppLanguage.English,
onClick = { viewModel.setLanguage(AppLanguage.English) },
)
LanguageOption(
label = stringResource(R.string.settings_language_russian),
selected = state.selectedLanguage == AppLanguage.Russian,
onClick = { viewModel.setLanguage(AppLanguage.Russian) },
)
}
}
}
@Composable
private fun LanguageOption(
label: String,
selected: Boolean,
onClick: () -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.selectable(
selected = selected,
onClick = onClick,
role = Role.RadioButton,
)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = selected, onClick = onClick)
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 8.dp),
)
}
}

View File

@@ -1,3 +1,7 @@
package com.github.nullptroma.wallenc.ui.screens.settings
class SettingsScreenState
import com.github.nullptroma.wallenc.ui.locale.AppLanguage
data class SettingsScreenState(
val selectedLanguage: AppLanguage = AppLanguage.System,
)

View File

@@ -1,8 +1,29 @@
package com.github.nullptroma.wallenc.ui.screens.settings
import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.ui.locale.AppLanguage
import com.github.nullptroma.wallenc.ui.locale.AppLocaleController
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @javax.inject.Inject constructor() :
ViewModelBase<SettingsScreenState>(SettingsScreenState())
class SettingsViewModel @Inject constructor(
private val appLocaleController: AppLocaleController,
) : ViewModelBase<SettingsScreenState>(SettingsScreenState()) {
init {
viewModelScope.launch {
appLocaleController.language.collect { language ->
updateState(state.value.copy(selectedLanguage = language))
}
}
}
fun setLanguage(language: AppLanguage) {
viewModelScope.launch {
appLocaleController.setLanguage(language)
}
}
}

View File

@@ -9,6 +9,7 @@ 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.resources.resolve
import com.github.nullptroma.wallenc.ui.resources.UserNotification
import com.github.nullptroma.wallenc.usecases.AddStorageToSyncGroupResult
import com.github.nullptroma.wallenc.usecases.ManageStorageSyncGroupsUseCase
@@ -74,7 +75,7 @@ class StorageSyncViewModel @Inject constructor(
Triple(
syncRunning,
progress?.fraction,
progress?.label?.takeIf { it.isNotBlank() },
progress?.label?.resolve(uiStrings),
)
}
.distinctUntilChanged()

View File

@@ -0,0 +1,337 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="nav_label_local_vault">Локальное хранилище</string>
<string name="nav_cd_local_vault">Локальное хранилище</string>
<string name="nav_label_remote_vaults">Удалённые хранилища</string>
<string name="nav_cd_remote_vaults">Удалённые хранилища</string>
<string name="nav_label_main">Главная</string>
<string name="nav_label_sync">Синхронизация</string>
<string name="nav_label_settings">Настройки</string>
<string name="main_work_status_label">Статус:</string>
<string name="main_status_multiple_tasks">Выполняется задач: %1$d</string>
<string name="main_status_vault_scanning_storages">Сканирование vault: загрузка списка хранилищ…</string>
<string name="settings_title">Настройки</string>
<string name="sync_groups_title">Группы синхронизации</string>
<string name="sync_progress_section_title">Синхронизация хранилищ</string>
<string name="sync_groups_busy_section_title">Сохранение групп синхронизации</string>
<string name="sync_run_now">Запустить синхронизацию</string>
<string name="sync_cd_run_now">Запустить синхронизацию сейчас</string>
<string name="sync_refresh">Обновить</string>
<string name="sync_add_storage">Добавить хранилище в группу</string>
<string name="sync_remove_group">Удалить группу</string>
<string name="sync_group_empty">В группе нет хранилищ</string>
<string name="sync_remove_storage">Убрать хранилище из группы</string>
<string name="sync_picker_back">Назад</string>
<string name="sync_cd_picker_back">Закрыть выбор хранилища</string>
<string name="sync_picker_title">Выбор хранилища для %1$s</string>
<string name="sync_picker_add">Добавить</string>
<string name="sync_picker_added">Добавлено</string>
<string name="sync_picker_cd_add">Добавить хранилище в группу</string>
<string name="sync_picker_no_storages">В этом хранилище нет доступных каталогов</string>
<string name="sync_picker_expand">Развернуть</string>
<string name="sync_picker_collapse">Свернуть</string>
<string name="sync_fab_create_group_cd">Создать группу синхронизации</string>
<string name="sync_group_mixed_encryption_warning">В группе разное шифрование: задайте единый режим</string>
<string name="sync_group_incompatible_warning">Несовместимые хранилища в группе: %1$d</string>
<string name="sync_group_policy_line">Политика шифрования группы: %1$s</string>
<string name="sync_group_policy_unset">Не определена (группа пуста)</string>
<string name="sync_group_policy_plain">Только незашифрованные</string>
<string name="sync_group_policy_password">Только зашифрованные с паролем группы</string>
<string name="sync_remove_group_confirm_title">Удалить группу?</string>
<string name="sync_remove_group_confirm_message">Удалить группу синхронизации «%1$s»?</string>
<string name="sync_remove_storage_confirm_title">Убрать хранилище?</string>
<string name="sync_remove_storage_confirm_message">Убрать хранилище «%1$s» из группы?</string>
<string name="sync_confirm_delete">Удалить</string>
<string name="sync_cancel">Отмена</string>
<string name="sync_msg_group_created">Создана группа %1$s</string>
<string name="sync_msg_group_removed">Группа удалена</string>
<string name="sync_msg_storage_added">Хранилище добавлено в %1$s</string>
<string name="sync_msg_storage_removed">Хранилище убрано из %1$s</string>
<string name="sync_msg_storage_already_added">Хранилище уже добавлено в группу</string>
<string name="sync_msg_only_plain_storage_allowed">В группы синхронизации можно добавлять только незашифрованные хранилища</string>
<string name="sync_msg_storage_encryption_key_required">Для зашифрованного хранилища нужно знать пароль (откройте его перед добавлением)</string>
<string name="sync_msg_storage_incompatible_encryption">Хранилище не совместимо с политикой шифрования группы</string>
<string name="sync_msg_virtual_storage_not_supported">Нельзя добавлять открытое виртуальное хранилище: синхронизация работает с исходными raw storage</string>
<string name="sync_msg_task_enqueued">Задача синхронизации поставлена в очередь</string>
<string name="sync_msg_sync_already_running">Синхронизация уже выполняется</string>
<string name="sync_msg_blocked_during_sync">Дождитесь окончания синхронизации</string>
<string name="sync_encryption_unknown">Неизвестно</string>
<string name="sync_storage_encryption_line">Шифрование: %1$s</string>
<string name="sync_storage_missing_title">Не найдено в текущих vault</string>
<string name="sync_storage_pending_vault_scan">Ожидание: список хранилищ в vault ещё загружается</string>
<string name="sync_storage_not_in_vaults">Нет в дереве хранилищ (удалено, другой аккаунт или не прошёл init)</string>
<string name="sync_storage_unreachable">Хранилище недоступно (vault или сеть)</string>
<string name="no_name">&lt;без имени&gt;</string>
<string name="show_storage_item_menu">Меню хранилища</string>
<string name="storage_row_task_running_cd">Выполняется операция с этим хранилищем</string>
<string name="storage_menu_busy">%1$s (задача выполняется)</string>
<string name="rename">Переименовать</string>
<string name="remove">Удалить</string>
<string name="encrypt">Шифрование</string>
<string name="new_name_title">Новое имя</string>
<string name="remove_confirmation_dialog">Удалить хранилище «%1$s»?</string>
<string name="storage_lock_actions">Действия с шифрованием</string>
<string name="storage_sync_lock_checking">Проверка блокировки…</string>
<string name="storage_sync_unlock_action">Снять блокировку синхронизации</string>
<string name="storage_sync_not_locked">Синхронизация не заблокирована</string>
<string name="storage_field_available">Доступно: %1$s</string>
<string name="storage_value_yes">да</string>
<string name="storage_value_no">нет</string>
<string name="storage_field_files">Файлов: %1$s</string>
<string name="storage_field_size">Размер: %1$s</string>
<string name="storage_field_virtual">Виртуальное: %1$s</string>
<string name="storage_unavailable_hint">Хранилище недоступно</string>
<string name="storage_menu_unavailable">Недоступно: %1$s</string>
<string name="storage_status_not_encrypted">Не зашифровано</string>
<string name="storage_status_encrypted_open">Зашифровано (открыто)</string>
<string name="storage_status_encrypted_closed">Зашифровано (закрыто)</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_msg_storage_pipeline_busy">С этим хранилищем уже выполняется операция</string>
<string name="vault_msg_vault_list_mutation_busy">Список хранилищ сейчас меняется — подождите</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_progress_dump_storage_log">Сканирование дерева…</string>
<string name="task_progress_create_storage">Создание хранилища…</string>
<string name="task_progress_enable_encryption">Шифрование…</string>
<string name="task_progress_close_storage">Закрытие хранилища…</string>
<string name="task_progress_disable_encryption">Очистка содержимого…</string>
<string name="task_progress_rename_storage">Переименование…</string>
<string name="task_progress_remove_storage">Удаление…</string>
<string name="task_progress_clear_sync_lock">Снятие блокировки…</string>
<string name="task_progress_add_remote_vault">Добавление…</string>
<string name="task_progress_remove_remote_vault">Удаление…</string>
<string name="task_progress_retry_remote_vault">Подключение…</string>
<string name="task_progress_save_2fa_token">Сохранение…</string>
<string name="task_progress_delete_2fa_token">Удаление…</string>
<string name="task_progress_save_text_secret">Сохранение…</string>
<string name="task_progress_delete_text_secret">Удаление…</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_retry_remote_vault">Повторное подключение удалённого хранилища</string>
<string name="task_title_storage_sync">Синхронизация хранилищ</string>
<string name="task_title_storage_sync_background">Фоновая синхронизация хранилищ</string>
<string name="task_title_save_2fa_token">Сохранение 2FA токена</string>
<string name="task_title_delete_2fa_token">Удаление 2FA токена</string>
<string name="task_title_save_text_secret">Сохранение текстового секрета</string>
<string name="task_title_delete_text_secret">Удаление текстового секрета</string>
<string name="error_storage_not_found">Хранилище не найдено</string>
<string name="error_storage_locked_view">Откройте расшифрованное отображение storage для работы с этим разделом</string>
<string name="error_secret_not_found">Секрет не найден</string>
<string name="error_storage_not_writable">Хранилище недоступно для записи</string>
<string name="error_file_not_found">Файл не найден</string>
<string name="error_incorrect_password">Неверный пароль</string>
<string name="error_storage_not_encrypted">Хранилище не зашифровано</string>
<string name="error_enc_info_missing">Отсутствуют метаданные шифрования</string>
<string name="error_delete_root_forbidden">Нельзя удалить корень хранилища</string>
<string name="error_not_a_file">Ожидался файл</string>
<string name="error_not_a_directory">Ожидалась папка</string>
<string name="error_path_is_file">Путь указывает на файл, а не на папку</string>
<string name="error_cannot_write_over_directory">Нельзя записать поверх папки</string>
<string name="error_unexpected_state">Хранилище в неожиданном состоянии</string>
<string name="error_network">Ошибка сети или сервера</string>
<string name="error_disk_resource_locked">Ресурс временно заблокирован. Повторите позже.</string>
<string name="error_unknown">Что-то пошло не так</string>
<string name="sync_error_group_not_found">Группа синхронизации не найдена</string>
<string name="vault_link_error_auth">Не удалось войти</string>
<string name="vault_link_error_not_registered">Вход не готов. Перезапустите приложение.</string>
<string name="vault_link_error_unknown">Не удалось войти</string>
<string name="vault_link_error_unsupported_brand">Этот провайдер не поддерживается</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_encryption_disabled">Шифрование отключено</string>
<string name="msg_invalid_storage_for_sync_lock">Некорректное хранилище</string>
<string name="msg_sync_lock_cleared">Блокировка синхронизации снята</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_unavailable">Хранилище временно недоступно</string>
<string name="remote_vault_retry_action">Повторить подключение</string>
<string name="remote_vault_retrying">Пробую подключиться…</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="storage_home_unnamed_storage">Storage</string>
<string name="storage_home_status_line">Статус: %1$s, %2$s</string>
<string name="storage_home_status_available">доступно</string>
<string name="storage_home_status_unavailable">недоступно</string>
<string name="storage_home_status_encrypted">зашифровано</string>
<string name="storage_home_status_not_encrypted">не зашифровано</string>
<string name="storage_home_two_fa_title">2FA токены (%1$d)</string>
<string name="storage_home_open_two_fa">Открыть 2FA</string>
<string name="storage_home_two_fa_subtitle">Коды и секреты двухфакторной аутентификации</string>
<string name="storage_home_text_secrets_title">Текстовые секреты (%1$d)</string>
<string name="storage_home_open_text_secrets">Открыть текстовые секреты</string>
<string name="storage_home_text_secrets_subtitle">Заметки, токены и произвольные пары ключ-значение</string>
<string name="storage_home_future_sections">Скоро здесь появятся Files, Media и другие типы данных.</string>
<string name="two_fa_add_token">Добавить токен</string>
<string name="two_fa_empty_state">Пока нет 2FA токенов</string>
<string name="two_fa_create_title">Новый 2FA токен</string>
<string name="two_fa_edit_title">Редактирование 2FA токена</string>
<string name="two_fa_field_issuer">Сервис</string>
<string name="two_fa_field_account">Аккаунт</string>
<string name="two_fa_field_secret">Секрет</string>
<string name="two_fa_field_notes_optional">Заметка (опционально)</string>
<string name="two_fa_field_digits">Количество цифр кода (обычно 6 или 8)</string>
<string name="two_fa_field_period_seconds">Период обновления в секундах (обычно 30)</string>
<string name="two_fa_field_algorithm">Алгоритм (SHA1, SHA256, SHA512)</string>
<string name="two_fa_field_digits_value">Количество цифр: %1$d</string>
<string name="two_fa_field_period_seconds_value">Период обновления: %1$d с</string>
<string name="two_fa_code_unavailable">------</string>
<string name="two_fa_code_refresh_in">Обновление через %1$d с</string>
<string name="two_fa_code_refresh_label">Обновление через</string>
<string name="two_fa_code_refresh_seconds">%1$d с</string>
<string name="two_fa_code_invalid_secret">Неверный секрет или формат</string>
<string name="two_fa_copy_code_hint">Нажмите, чтобы скопировать код</string>
<string name="two_fa_scan_qr_action">Сканировать QR</string>
<string name="two_fa_scan_qr_title">Сканирование QR-кода TOTP</string>
<string name="two_fa_scan_qr_invalid">QR-код не содержит валидный otpauth://totp URI</string>
<string name="two_fa_camera_permission_required">Нужно разрешение на камеру для сканирования QR</string>
<string name="text_secret_create">Создать секрет</string>
<string name="text_secret_edit">Редактировать секрет</string>
<string name="text_secret_title">Название</string>
<string name="text_secret_empty_state">Пока нет текстовых секретов</string>
<string name="text_secret_items_count">Элементов: %1$d</string>
<string name="text_secret_item_without_label">Без названия</string>
<string name="text_secret_more_fields">ещё полей: %1$d</string>
<string name="text_secret_item_label_optional">Название (опционально)</string>
<string name="text_secret_item_value">Значение</string>
<string name="text_secret_add_item">Добавить пару</string>
<string name="text_secret_copy_value">Скопировать значение</string>
<string name="save">Сохранить</string>
<string name="cancel">Отмена</string>
<string name="open">Открыть</string>
<string name="edit">Редактировать</string>
<string name="common_unknown">Неизвестно</string>
<string name="settings_language_section">Язык</string>
<string name="settings_language_system">Как в системе</string>
<string name="settings_language_english">English</string>
<string name="settings_language_russian">Русский</string>
<string name="task_pipeline_test_elapsed">Прошло: %1$d с / %2$d с</string>
<string name="text_secret_clipboard_fallback_label">значение</string>
<string name="sync_progress_no_groups">Синхронизация: группы не настроены</string>
<string name="sync_progress_preparing">Синхронизация: подготовка %1$d групп</string>
<string name="sync_progress_started">Синхронизация: запущена</string>
<string name="sync_progress_completed">Синхронизация: завершена</string>
<string name="sync_progress_group_preparing">Синхронизация: группа «%1$s» — подготовка</string>
<string name="sync_progress_group_not_found">Синхронизация: группа «%1$s» не найдена</string>
<string name="sync_progress_group_skipped_few_storages">Синхронизация: группа «%1$s» пропущена (нужно минимум 2 хранилища)</string>
<string name="sync_progress_group_skipped_incompatible">Синхронизация: группа «%1$s» пропущена (несовместимое шифрование: %2$d)</string>
<string name="sync_progress_group_acquiring_locks">Синхронизация: группа «%1$s» — получение блокировок</string>
<string name="sync_progress_group_lock">Синхронизация: группа «%1$s» — блокировка %2$d/%3$d</string>
<string name="sync_progress_group_lock_failed">Синхронизация: группа «%1$s» — блокировка не получена, пропуск</string>
<string name="sync_progress_group_reading_journals">Синхронизация: группа «%1$s» — чтение журналов</string>
<string name="sync_progress_group_cancelled">Синхронизация: группа «%1$s» отменена новым запуском</string>
<string name="sync_progress_group_journal">Синхронизация: группа «%1$s» — журнал %2$d/%3$d</string>
<string name="sync_progress_group_no_entries">Синхронизация: группа «%1$s» — нет записей в журнале</string>
<string name="sync_progress_group_processing">Синхронизация: группа «%1$s» — обработка %2$d записей</string>
<string name="sync_progress_group_entry">Синхронизация: группа «%1$s» — запись %2$d/%3$d</string>
<string name="sync_progress_group_completed">Синхронизация: группа «%1$s» завершена</string>
<string name="sync_progress_group_renewing_locks">Синхронизация: группа «%1$s» — продление блокировок</string>
<string name="sync_progress_group_lock_renewal_failed">Синхронизация: группа «%1$s» — не удалось продлить блокировку</string>
<string name="task_progress_clear_content">%1$d / %2$d</string>
<string name="task_log_sync_started">Синхронизация хранилищ запущена</string>
<string name="task_log_sync_finished">Синхронизация хранилищ завершена</string>
<string name="task_log_sync_failed">Синхронизация не удалась: %1$s</string>
<string name="task_log_enumerating">Перечисление файлов и папок…</string>
<string name="task_log_enumerate_done">Готово: %1$d файлов, %2$d папок за %3$d мс (подробности в журнале приложения)</string>
<string name="task_log_creating_storage">Создание хранилища…</string>
<string name="task_log_storage_created">Хранилище создано</string>
<string name="task_log_checking_storage">Проверка хранилища…</string>
<string name="task_log_encrypting">Шифрование…</string>
<string name="task_log_encryption_enabled">Шифрование включено</string>
<string name="task_log_already_encrypted">Хранилище уже зашифровано</string>
<string name="task_log_not_empty">Хранилище не пустое</string>
<string name="task_log_empty_unknown">Не удалось определить, пусто ли хранилище</string>
<string name="task_log_unsupported_type">Неподдерживаемый тип хранилища</string>
<string name="task_log_enable_encryption_failed">Не удалось включить шифрование</string>
<string name="task_log_opening_storage">Открытие зашифрованного хранилища…</string>
<string name="task_log_storage_opened">Хранилище открыто</string>
<string name="task_log_open_storage_failed">Не удалось открыть хранилище</string>
<string name="task_log_closing_storage">Закрытие хранилища…</string>
<string name="task_log_storage_closed">Хранилище закрыто</string>
<string name="task_log_close_storage_failed">Не удалось закрыть хранилище</string>
<string name="task_log_disabling_encryption">Отключение шифрования…</string>
<string name="task_log_encryption_disabled">Шифрование отключено</string>
<string name="task_log_disable_encryption_failed">Не удалось отключить шифрование</string>
<string name="task_log_renaming">Переименование…</string>
<string name="task_log_renamed">Переименовано</string>
<string name="task_log_rename_failed">Не удалось переименовать</string>
<string name="task_log_removing_storage">Удаление хранилища…</string>
<string name="task_log_removed">Удалено</string>
<string name="task_log_remove_failed">Не удалось удалить</string>
<string name="task_log_invalid_storage">Некорректное хранилище</string>
<string name="task_log_clearing_sync_lock">Снятие блокировки синхронизации…</string>
<string name="task_log_sync_lock_cleared">Блокировка синхронизации снята</string>
<string name="task_log_clear_sync_lock_failed">Не удалось снять блокировку</string>
<string name="task_log_adding_vault">Добавление хранилища…</string>
<string name="task_log_vault_added">Хранилище добавлено</string>
<string name="task_log_add_vault_failed">Не удалось добавить хранилище</string>
<string name="task_log_removing_remote_vault">Удаление удалённого хранилища…</string>
<string name="task_log_remote_vault_removed">Удалённое хранилище удалено</string>
<string name="task_log_remove_vault_failed">Не удалось удалить хранилище</string>
<string name="task_log_retrying_vault">Повторное подключение…</string>
<string name="task_log_retry_requested">Повтор запрошен</string>
<string name="task_log_retry_vault_failed">Не удалось повторить подключение</string>
<string name="task_log_test_started">Тестовая задача запущена на %1$d с</string>
<string name="task_log_test_finished">Тестовая задача завершена</string>
</resources>

View File

@@ -1,290 +1,337 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="nav_label_local_vault">Локальное хранилище</string>
<string name="nav_cd_local_vault">Локальное хранилище</string>
<string name="nav_label_remote_vaults">Удалённые хранилища</string>
<string name="nav_cd_remote_vaults">Удалённые хранилища</string>
<string name="nav_label_main">Главная</string>
<string name="nav_label_sync">Синхронизация</string>
<string name="nav_label_settings">Настройки</string>
<string name="main_work_status_label">Статус:</string>
<string name="main_status_multiple_tasks">Выполняется задач: %1$d</string>
<string name="main_status_vault_scanning_storages">Сканирование vault: загрузка списка хранилищ…</string>
<string name="settings_title">Настройки</string>
<string name="sync_groups_title">Группы синхронизации</string>
<string name="sync_progress_section_title">Синхронизация хранилищ</string>
<string name="sync_groups_busy_section_title">Сохранение групп синхронизации</string>
<string name="sync_run_now">Запустить синхронизацию</string>
<string name="sync_cd_run_now">Запустить синхронизацию сейчас</string>
<string name="sync_refresh">Обновить</string>
<string name="sync_add_storage">Добавить хранилище в группу</string>
<string name="sync_remove_group">Удалить группу</string>
<string name="sync_group_empty">В группе нет хранилищ</string>
<string name="sync_remove_storage">Убрать хранилище из группы</string>
<string name="sync_picker_back">Назад</string>
<string name="sync_cd_picker_back">Закрыть выбор хранилища</string>
<string name="sync_picker_title">Выбор хранилища для %1$s</string>
<string name="sync_picker_add">Добавить</string>
<string name="sync_picker_added">Добавлено</string>
<string name="sync_picker_cd_add">Добавить хранилище в группу</string>
<string name="sync_picker_no_storages">В этом хранилище нет доступных каталогов</string>
<string name="sync_picker_expand">Развернуть</string>
<string name="sync_picker_collapse">Свернуть</string>
<string name="sync_fab_create_group_cd">Создать группу синхронизации</string>
<string name="sync_group_mixed_encryption_warning">В группе разное шифрование: задайте единый режим</string>
<string name="sync_group_incompatible_warning">Несовместимые хранилища в группе: %1$d</string>
<string name="sync_group_policy_line">Политика шифрования группы: %1$s</string>
<string name="sync_group_policy_unset">Не определена (группа пуста)</string>
<string name="sync_group_policy_plain">Только незашифрованные</string>
<string name="sync_group_policy_password">Только зашифрованные с паролем группы</string>
<string name="sync_remove_group_confirm_title">Удалить группу?</string>
<string name="sync_remove_group_confirm_message">Удалить группу синхронизации «%1$s»?</string>
<string name="sync_remove_storage_confirm_title">Убрать хранилище?</string>
<string name="sync_remove_storage_confirm_message">Убрать хранилище «%1$s» из группы?</string>
<string name="sync_confirm_delete">Удалить</string>
<string name="sync_cancel">Отмена</string>
<string name="sync_msg_group_created">Создана группа %1$s</string>
<string name="sync_msg_group_removed">Группа удалена</string>
<string name="sync_msg_storage_added">Хранилище добавлено в %1$s</string>
<string name="sync_msg_storage_removed">Хранилище убрано из %1$s</string>
<string name="sync_msg_storage_already_added">Хранилище уже добавлено в группу</string>
<string name="sync_msg_only_plain_storage_allowed">В группы синхронизации можно добавлять только незашифрованные хранилища</string>
<string name="sync_msg_storage_encryption_key_required">Для зашифрованного хранилища нужно знать пароль (откройте его перед добавлением)</string>
<string name="sync_msg_storage_incompatible_encryption">Хранилище не совместимо с политикой шифрования группы</string>
<string name="sync_msg_virtual_storage_not_supported">Нельзя добавлять открытое виртуальное хранилище: синхронизация работает с исходными raw storage</string>
<string name="sync_msg_task_enqueued">Задача синхронизации поставлена в очередь</string>
<string name="sync_msg_sync_already_running">Синхронизация уже выполняется</string>
<string name="sync_msg_blocked_during_sync">Дождитесь окончания синхронизации</string>
<string name="sync_encryption_unknown">Неизвестно</string>
<string name="sync_storage_encryption_line">Шифрование: %1$s</string>
<string name="sync_storage_missing_title">Не найдено в текущих vault</string>
<string name="sync_storage_pending_vault_scan">Ожидание: список хранилищ в vault ещё загружается</string>
<string name="sync_storage_not_in_vaults">Нет в дереве хранилищ (удалено, другой аккаунт или не прошёл init)</string>
<string name="sync_storage_unreachable">Хранилище недоступно (vault или сеть)</string>
<string name="no_name">&lt;без имени&gt;</string>
<string name="show_storage_item_menu">Меню хранилища</string>
<string name="storage_row_task_running_cd">Выполняется операция с этим хранилищем</string>
<string name="storage_menu_busy">%1$s (задача выполняется)</string>
<string name="rename">Переименовать</string>
<string name="remove">Удалить</string>
<string name="encrypt">Шифрование</string>
<string name="new_name_title">Новое имя</string>
<string name="remove_confirmation_dialog">Удалить хранилище «%1$s»?</string>
<string name="storage_lock_actions">Действия с шифрованием</string>
<string name="storage_sync_lock_checking">Проверка блокировки…</string>
<string name="storage_sync_unlock_action">Снять блокировку синхронизации</string>
<string name="storage_sync_not_locked">Синхронизация не заблокирована</string>
<string name="storage_field_available">Доступно: %1$s</string>
<string name="storage_value_yes">да</string>
<string name="storage_value_no">нет</string>
<string name="storage_field_files">Файлов: %1$s</string>
<string name="storage_field_size">Размер: %1$s</string>
<string name="storage_field_virtual">Виртуальное: %1$s</string>
<string name="storage_unavailable_hint">Хранилище недоступно</string>
<string name="storage_menu_unavailable">Недоступно: %1$s</string>
<string name="storage_status_not_encrypted">Не зашифровано</string>
<string name="storage_status_encrypted_open">Зашифровано (открыто)</string>
<string name="storage_status_encrypted_closed">Зашифровано (закрыто)</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_msg_storage_pipeline_busy">С этим хранилищем уже выполняется операция</string>
<string name="vault_msg_vault_list_mutation_busy">Список хранилищ сейчас меняется — подождите</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_progress_dump_storage_log">Сканирование дерева</string>
<string name="task_progress_create_storage">Создание хранилища</string>
<string name="task_progress_enable_encryption">Шифрование</string>
<string name="task_progress_close_storage">Закрытие хранилища</string>
<string name="task_progress_disable_encryption">Очистка содержимого</string>
<string name="task_progress_rename_storage">Переименование</string>
<string name="task_progress_remove_storage">Удаление</string>
<string name="task_progress_clear_sync_lock">Снятие блокировки…</string>
<string name="task_progress_add_remote_vault">Добавление…</string>
<string name="task_progress_remove_remote_vault">Удаление…</string>
<string name="task_progress_retry_remote_vault">Подключение…</string>
<string name="task_progress_save_2fa_token">Сохранение…</string>
<string name="task_progress_delete_2fa_token">Удаление…</string>
<string name="task_progress_save_text_secret">Сохранение…</string>
<string name="task_progress_delete_text_secret">Удаление…</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_retry_remote_vault">Повторное подключение удалённого хранилища</string>
<string name="task_title_storage_sync">Синхронизация хранилищ</string>
<string name="task_title_storage_sync_background">Фоновая синхронизация хранилищ</string>
<string name="task_title_save_2fa_token">Сохранение 2FA токена</string>
<string name="task_title_delete_2fa_token">Удаление 2FA токена</string>
<string name="task_title_save_text_secret">Сохранение текстового секрета</string>
<string name="task_title_delete_text_secret">Удаление текстового секрета</string>
<string name="error_storage_not_found">Хранилище не найдено</string>
<string name="error_storage_locked_view">Откройте расшифрованное отображение storage для работы с этим разделом</string>
<string name="error_secret_not_found">Секрет не найден</string>
<string name="error_storage_not_writable">Хранилище недоступно для записи</string>
<string name="error_file_not_found">Файл не найден</string>
<string name="error_incorrect_password">Неверный пароль</string>
<string name="error_storage_not_encrypted">Хранилище не зашифровано</string>
<string name="error_enc_info_missing">Отсутствуют метаданные шифрования</string>
<string name="error_delete_root_forbidden">Нельзя удалить корень хранилища</string>
<string name="error_not_a_file">Ожидался файл</string>
<string name="error_not_a_directory">Ожидалась папка</string>
<string name="error_path_is_file">Путь указывает на файл, а не на папку</string>
<string name="error_cannot_write_over_directory">Нельзя записать поверх папки</string>
<string name="error_unexpected_state">Хранилище в неожиданном состоянии</string>
<string name="error_network">Ошибка сети или сервера</string>
<string name="error_disk_resource_locked">Ресурс временно заблокирован. Повторите позже.</string>
<string name="error_unknown">Что-то пошло не так</string>
<string name="sync_error_group_not_found">Группа синхронизации не найдена</string>
<string name="vault_link_error_auth">Не удалось войти</string>
<string name="vault_link_error_not_registered">Вход не готов. Перезапустите приложение.</string>
<string name="vault_link_error_unknown">Не удалось войти</string>
<string name="vault_link_error_unsupported_brand">Этот провайдер не поддерживается</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_unavailable">Хранилище временно недоступно</string>
<string name="remote_vault_retry_action">Повторить подключение</string>
<string name="remote_vault_retrying">Пробую подключиться…</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="nav_label_local_vault">Local vault</string>
<string name="nav_cd_local_vault">Local vault</string>
<string name="nav_label_remote_vaults">Remote vaults</string>
<string name="nav_cd_remote_vaults">Remote vaults</string>
<string name="nav_label_main">Home</string>
<string name="nav_label_sync">Sync</string>
<string name="nav_label_settings">Settings</string>
<string name="main_work_status_label">Status:</string>
<string name="main_status_multiple_tasks">Running tasks: %1$d</string>
<string name="main_status_vault_scanning_storages">Scanning vault: loading storage list…</string>
<string name="settings_title">Settings</string>
<string name="sync_groups_title">Sync groups</string>
<string name="sync_progress_section_title">Storage sync</string>
<string name="sync_groups_busy_section_title">Saving sync groups</string>
<string name="sync_run_now">Run sync now</string>
<string name="sync_cd_run_now">Run sync now</string>
<string name="sync_refresh">Refresh</string>
<string name="sync_add_storage">Add storage to group</string>
<string name="sync_remove_group">Remove group</string>
<string name="sync_group_empty">No storages in this group</string>
<string name="sync_remove_storage">Remove storage from group</string>
<string name="sync_picker_back">Back</string>
<string name="sync_cd_picker_back">Close storage picker</string>
<string name="sync_picker_title">Pick storage for %1$s</string>
<string name="sync_picker_add">Add</string>
<string name="sync_picker_added">Added</string>
<string name="sync_picker_cd_add">Add storage to group</string>
<string name="sync_picker_no_storages">No available folders in this vault</string>
<string name="sync_picker_expand">Expand</string>
<string name="sync_picker_collapse">Collapse</string>
<string name="sync_fab_create_group_cd">Create sync group</string>
<string name="sync_group_mixed_encryption_warning">Mixed encryption in group: set a single mode</string>
<string name="sync_group_incompatible_warning">Incompatible storages in group: %1$d</string>
<string name="sync_group_policy_line">Group encryption policy: %1$s</string>
<string name="sync_group_policy_unset">Not set (group is empty)</string>
<string name="sync_group_policy_plain">Unencrypted only</string>
<string name="sync_group_policy_password">Password-encrypted with group password</string>
<string name="sync_remove_group_confirm_title">Remove group?</string>
<string name="sync_remove_group_confirm_message">Remove sync group "%1$s"?</string>
<string name="sync_remove_storage_confirm_title">Remove storage?</string>
<string name="sync_remove_storage_confirm_message">Remove storage "%1$s" from the group?</string>
<string name="sync_confirm_delete">Delete</string>
<string name="sync_cancel">Cancel</string>
<string name="sync_msg_group_created">Created group %1$s</string>
<string name="sync_msg_group_removed">Group removed</string>
<string name="sync_msg_storage_added">Storage added to %1$s</string>
<string name="sync_msg_storage_removed">Storage removed from %1$s</string>
<string name="sync_msg_storage_already_added">Storage is already in the group</string>
<string name="sync_msg_only_plain_storage_allowed">Only unencrypted storages can be added to sync groups</string>
<string name="sync_msg_storage_encryption_key_required">Encrypted storage requires the password (open it before adding)</string>
<string name="sync_msg_storage_incompatible_encryption">Storage is not compatible with the group encryption policy</string>
<string name="sync_msg_virtual_storage_not_supported">Cannot add an open virtual storage: sync works with raw storages</string>
<string name="sync_msg_task_enqueued">Sync task queued</string>
<string name="sync_msg_sync_already_running">Sync is already running</string>
<string name="sync_msg_blocked_during_sync">Wait for sync to finish</string>
<string name="sync_encryption_unknown">Unknown</string>
<string name="sync_storage_encryption_line">Encryption: %1$s</string>
<string name="sync_storage_missing_title">Not found in current vaults</string>
<string name="sync_storage_pending_vault_scan">Waiting: vault storage list is still loading</string>
<string name="sync_storage_not_in_vaults">Not in storage tree (removed, different account, or init failed)</string>
<string name="sync_storage_unreachable">Storage unavailable (vault or network)</string>
<string name="no_name">&lt;no name&gt;</string>
<string name="show_storage_item_menu">Storage menu</string>
<string name="storage_row_task_running_cd">Operation in progress for this storage</string>
<string name="storage_menu_busy">%1$s (task running)</string>
<string name="rename">Rename</string>
<string name="remove">Remove</string>
<string name="encrypt">Encryption</string>
<string name="new_name_title">New name</string>
<string name="remove_confirmation_dialog">Remove storage "%1$s"?</string>
<string name="storage_lock_actions">Encryption actions</string>
<string name="storage_sync_lock_checking">Checking lock…</string>
<string name="storage_sync_unlock_action">Clear sync lock</string>
<string name="storage_sync_not_locked">Sync is not locked</string>
<string name="storage_field_available">Available: %1$s</string>
<string name="storage_value_yes">yes</string>
<string name="storage_value_no">no</string>
<string name="storage_field_files">Files: %1$s</string>
<string name="storage_field_size">Size: %1$s</string>
<string name="storage_field_virtual">Virtual: %1$s</string>
<string name="storage_unavailable_hint">Storage unavailable</string>
<string name="storage_menu_unavailable">Unavailable: %1$s</string>
<string name="storage_status_not_encrypted">Not encrypted</string>
<string name="storage_status_encrypted_open">Encrypted (open)</string>
<string name="storage_status_encrypted_closed">Encrypted (locked)</string>
<string name="vault_fab_add_storage_cd">Create storage</string>
<string name="vault_fab_add_storage_disabled_cd">Create unavailable: vault offline</string>
<string name="vault_fab_add_storage_busy_cd">Storage creation already running</string>
<string name="vault_msg_storage_pipeline_busy">An operation is already running for this storage</string>
<string name="vault_msg_vault_list_mutation_busy">Storage list is changing — please wait</string>
<string name="vault_unavailable_banner">Vault unavailable. Check network, path, or unlock.</string>
<string name="vault_loading_storages">Loading storage list…</string>
<string name="vault_empty_list_hint">No folders yet. Create storage with "+" when available.</string>
<string name="task_pipeline_title">Task queue</string>
<string name="task_pipeline_jobs">Tasks</string>
<string name="task_pipeline_log">Log</string>
<string name="task_pipeline_cancel_all">Cancel all</string>
<string name="task_pipeline_open">Open task queue</string>
<string name="task_pipeline_run_test">Test task</string>
<string name="task_pipeline_test_dialog_title">Test task parameters</string>
<string name="task_pipeline_test_dialog_duration">Duration: %1$d s</string>
<string name="task_pipeline_test_dialog_start">Start</string>
<string name="task_pipeline_test_dialog_cancel">Cancel</string>
<string name="task_pipeline_test_dialog_infinity">Infinite (indeterminate progress)</string>
<string name="task_pipeline_test_running">Test task (%1$d s)</string>
<string name="task_pipeline_test_running_infinity">Test task (%1$d s, ∞)</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">Error: %1$s</string>
<string name="task_title_dump_storage_log">Export tree to log</string>
<string name="task_title_create_storage">Create storage</string>
<string name="task_title_enable_encryption">Enable encryption</string>
<string name="task_title_open_encrypted_storage">Decrypt and open storage</string>
<string name="task_progress_decrypt_running">Decrypting…</string>
<string name="task_progress_dump_storage_log">Scanning tree…</string>
<string name="task_progress_create_storage">Creating storage…</string>
<string name="task_progress_enable_encryption">Encrypting…</string>
<string name="task_progress_close_storage">Closing storage…</string>
<string name="task_progress_disable_encryption">Clearing content…</string>
<string name="task_progress_rename_storage">Renaming…</string>
<string name="task_progress_remove_storage">Removing…</string>
<string name="task_progress_clear_sync_lock">Clearing sync lock</string>
<string name="task_progress_add_remote_vault">Adding</string>
<string name="task_progress_remove_remote_vault">Removing</string>
<string name="task_progress_retry_remote_vault">Connecting</string>
<string name="task_progress_save_2fa_token">Saving</string>
<string name="task_progress_delete_2fa_token">Removing</string>
<string name="task_progress_save_text_secret">Saving</string>
<string name="task_progress_delete_text_secret">Removing</string>
<string name="task_title_close_encrypted_storage">Close encrypted storage</string>
<string name="task_title_disable_encryption">Disable encryption</string>
<string name="task_title_rename_storage">Rename storage</string>
<string name="task_title_remove_storage">Remove storage</string>
<string name="task_title_clear_sync_lock">Clear sync lock</string>
<string name="task_title_add_remote_vault">Add remote vault</string>
<string name="task_title_remove_remote_vault">Remove remote vault</string>
<string name="task_title_retry_remote_vault">Retry remote vault connection</string>
<string name="task_title_storage_sync">Storage sync</string>
<string name="task_title_storage_sync_background">Background storage sync</string>
<string name="task_title_save_2fa_token">Save 2FA token</string>
<string name="task_title_delete_2fa_token">Delete 2FA token</string>
<string name="task_title_save_text_secret">Save text secret</string>
<string name="task_title_delete_text_secret">Delete text secret</string>
<string name="error_storage_not_found">Storage not found</string>
<string name="error_storage_locked_view">Open decrypted storage view to use this section</string>
<string name="error_secret_not_found">Secret not found</string>
<string name="error_storage_not_writable">Storage is not writable</string>
<string name="error_file_not_found">File not found</string>
<string name="error_incorrect_password">Incorrect password</string>
<string name="error_storage_not_encrypted">Storage is not encrypted</string>
<string name="error_enc_info_missing">Encryption metadata missing</string>
<string name="error_delete_root_forbidden">Cannot delete storage root</string>
<string name="error_not_a_file">Expected a file</string>
<string name="error_not_a_directory">Expected a folder</string>
<string name="error_path_is_file">Path points to a file, not a folder</string>
<string name="error_cannot_write_over_directory">Cannot write over a folder</string>
<string name="error_unexpected_state">Storage in unexpected state</string>
<string name="error_network">Network or server error</string>
<string name="error_disk_resource_locked">Resource is temporarily locked. Try again later.</string>
<string name="error_unknown">Something went wrong</string>
<string name="sync_error_group_not_found">Sync group not found</string>
<string name="vault_link_error_auth">Sign-in failed</string>
<string name="vault_link_error_not_registered">Sign-in is not ready. Restart the app.</string>
<string name="vault_link_error_unknown">Sign-in failed</string>
<string name="vault_link_error_unsupported_brand">This provider is not supported</string>
<string name="msg_encryption_enabled">Encryption enabled</string>
<string name="msg_storage_already_encrypted">Storage is already encrypted</string>
<string name="msg_storage_not_empty">Storage is not empty</string>
<string name="msg_storage_empty_state_unknown">Could not determine if storage is empty</string>
<string name="msg_unsupported_storage_type">Unsupported storage type</string>
<string name="msg_encryption_disabled">Encryption disabled</string>
<string name="msg_invalid_storage_for_sync_lock">Invalid storage</string>
<string name="msg_sync_lock_cleared">Sync lock cleared</string>
<string name="remote_vaults_add_cd">Add remote vault</string>
<string name="remote_vaults_empty_hint">No remote vaults yet. Tap "+" to add Yandex.</string>
<string name="remote_vaults_add_title">Add vault</string>
<string name="remote_vaults_add_pick_provider">Choose a 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_unavailable">Vault temporarily unavailable</string>
<string name="remote_vault_retry_action">Retry connection</string>
<string name="remote_vault_retrying">Connecting…</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? Server data is not deleted.</string>
<string name="dialog_cancel">Cancel</string>
<string name="dialog_ok">OK</string>
<string name="dialog_encryption_enable_title">Enable encryption</string>
<string name="dialog_password_label">Password</string>
<string name="dialog_encrypt_paths">Encrypt paths</string>
<string name="dialog_apply">Apply</string>
<string name="dialog_open_encrypted_title">Open encrypted storage</string>
<string name="dialog_remember_password">Remember password</string>
<string name="dialog_open">Open</string>
<string name="dialog_close">Close</string>
<string name="dialog_disable_encryption">Disable encryption</string>
<string name="dialog_done">Done</string>
<string name="vault_type_local_device">Local device</string>
<string name="vault_type_remote">Remote: %1$s</string>
<string name="vault_type_unknown">Unknown type</string>
<string name="vault_title_local">Local vault</string>
<string name="vault_title_unknown">Unknown vault</string>
<string name="enc_status_not_encrypted">Not encrypted</string>
<string name="enc_status_encrypted_open">Encrypted (open)</string>
<string name="enc_status_encrypted">Encrypted</string>
<string name="text_edit_screen_title">Text</string>
<string name="text_edit_screen_placeholder">Content: %1$s</string>
<string name="storage_home_unnamed_storage">Storage</string>
<string name="storage_home_status_line">Статус: %1$s, %2$s</string>
<string name="storage_home_status_available">доступно</string>
<string name="storage_home_status_unavailable">недоступно</string>
<string name="storage_home_status_encrypted">зашифровано</string>
<string name="storage_home_status_not_encrypted">не зашифровано</string>
<string name="storage_home_two_fa_title">2FA токены (%1$d)</string>
<string name="storage_home_open_two_fa">Открыть 2FA</string>
<string name="storage_home_two_fa_subtitle">Коды и секреты двухфакторной аутентификации</string>
<string name="storage_home_text_secrets_title">Текстовые секреты (%1$d)</string>
<string name="storage_home_open_text_secrets">Открыть текстовые секреты</string>
<string name="storage_home_text_secrets_subtitle">Заметки, токены и произвольные пары ключ-значение</string>
<string name="storage_home_future_sections">Скоро здесь появятся Files, Media и другие типы данных.</string>
<string name="two_fa_add_token">Добавить токен</string>
<string name="two_fa_empty_state">Пока нет 2FA токенов</string>
<string name="two_fa_create_title">Новый 2FA токен</string>
<string name="two_fa_edit_title">Редактирование 2FA токена</string>
<string name="two_fa_field_issuer">Сервис</string>
<string name="two_fa_field_account">Аккаунт</string>
<string name="two_fa_field_secret">Секрет</string>
<string name="two_fa_field_notes_optional">Заметка (опционально)</string>
<string name="two_fa_field_digits">Количество цифр кода (обычно 6 или 8)</string>
<string name="two_fa_field_period_seconds">Период обновления в секундах (обычно 30)</string>
<string name="two_fa_field_algorithm">Алгоритм (SHA1, SHA256, SHA512)</string>
<string name="two_fa_field_digits_value">Количество цифр: %1$d</string>
<string name="two_fa_field_period_seconds_value">Период обновления: %1$d с</string>
<string name="storage_home_status_line">Status: %1$s, %2$s</string>
<string name="storage_home_status_available">available</string>
<string name="storage_home_status_unavailable">unavailable</string>
<string name="storage_home_status_encrypted">encrypted</string>
<string name="storage_home_status_not_encrypted">not encrypted</string>
<string name="storage_home_two_fa_title">2FA tokens (%1$d)</string>
<string name="storage_home_open_two_fa">Open 2FA</string>
<string name="storage_home_two_fa_subtitle">Two-factor authentication codes and secrets</string>
<string name="storage_home_text_secrets_title">Text secrets (%1$d)</string>
<string name="storage_home_open_text_secrets">Open text secrets</string>
<string name="storage_home_text_secrets_subtitle">Notes, tokens, and arbitrary key-value pairs</string>
<string name="storage_home_future_sections">Files, Media, and more will appear here soon.</string>
<string name="two_fa_add_token">Add token</string>
<string name="two_fa_empty_state">No 2FA tokens yet</string>
<string name="two_fa_create_title">New 2FA token</string>
<string name="two_fa_edit_title">Edit 2FA token</string>
<string name="two_fa_field_issuer">Service</string>
<string name="two_fa_field_account">Account</string>
<string name="two_fa_field_secret">Secret</string>
<string name="two_fa_field_notes_optional">Note (optional)</string>
<string name="two_fa_field_digits">Code digits (usually 6 or 8)</string>
<string name="two_fa_field_period_seconds">Refresh period in seconds (usually 30)</string>
<string name="two_fa_field_algorithm">Algorithm (SHA1, SHA256, SHA512)</string>
<string name="two_fa_field_digits_value">Digits: %1$d</string>
<string name="two_fa_field_period_seconds_value">Refresh period: %1$d s</string>
<string name="two_fa_code_unavailable">------</string>
<string name="two_fa_code_refresh_in">Обновление через %1$d с</string>
<string name="two_fa_code_refresh_label">Обновление через</string>
<string name="two_fa_code_refresh_seconds">%1$d с</string>
<string name="two_fa_code_invalid_secret">Неверный секрет или формат</string>
<string name="two_fa_copy_code_hint">Нажмите, чтобы скопировать код</string>
<string name="two_fa_scan_qr_action">Сканировать QR</string>
<string name="two_fa_scan_qr_title">Сканирование QR-кода TOTP</string>
<string name="two_fa_scan_qr_invalid">QR-код не содержит валидный otpauth://totp URI</string>
<string name="two_fa_camera_permission_required">Нужно разрешение на камеру для сканирования QR</string>
<string name="text_secret_create">Создать секрет</string>
<string name="text_secret_edit">Редактировать секрет</string>
<string name="text_secret_title">Название</string>
<string name="text_secret_empty_state">Пока нет текстовых секретов</string>
<string name="text_secret_items_count">Элементов: %1$d</string>
<string name="text_secret_item_without_label">Без названия</string>
<string name="text_secret_more_fields">ещё полей: %1$d</string>
<string name="text_secret_item_label_optional">Название (опционально)</string>
<string name="text_secret_item_value">Значение</string>
<string name="text_secret_add_item">Добавить пару</string>
<string name="text_secret_copy_value">Скопировать значение</string>
<string name="save">Сохранить</string>
<string name="cancel">Отмена</string>
<string name="open">Открыть</string>
<string name="edit">Редактировать</string>
<string name="common_unknown">Неизвестно</string>
<string name="two_fa_code_refresh_in">Refresh in %1$d s</string>
<string name="two_fa_code_refresh_label">Refresh in</string>
<string name="two_fa_code_refresh_seconds">%1$d s</string>
<string name="two_fa_code_invalid_secret">Invalid secret or format</string>
<string name="two_fa_copy_code_hint">Tap to copy code</string>
<string name="two_fa_scan_qr_action">Scan QR</string>
<string name="two_fa_scan_qr_title">Scan TOTP QR code</string>
<string name="two_fa_scan_qr_invalid">QR code does not contain a valid otpauth://totp URI</string>
<string name="two_fa_camera_permission_required">Camera permission is required to scan QR</string>
<string name="text_secret_create">Create secret</string>
<string name="text_secret_edit">Edit secret</string>
<string name="text_secret_title">Title</string>
<string name="text_secret_empty_state">No text secrets yet</string>
<string name="text_secret_items_count">Items: %1$d</string>
<string name="text_secret_item_without_label">Untitled</string>
<string name="text_secret_more_fields">more fields: %1$d</string>
<string name="text_secret_item_label_optional">Label (optional)</string>
<string name="text_secret_item_value">Value</string>
<string name="text_secret_add_item">Add field</string>
<string name="text_secret_copy_value">Copy value</string>
<string name="save">Save</string>
<string name="cancel">Cancel</string>
<string name="open">Open</string>
<string name="edit">Edit</string>
<string name="common_unknown">Unknown</string>
<string name="settings_language_section">Language</string>
<string name="settings_language_system">System default</string>
<string name="settings_language_english">English</string>
<string name="settings_language_russian">Russian</string>
<string name="task_pipeline_test_elapsed">Elapsed: %1$d s / %2$d s</string>
<string name="text_secret_clipboard_fallback_label">value</string>
<string name="sync_progress_no_groups">Storage sync: no groups configured</string>
<string name="sync_progress_preparing">Storage sync: preparing %1$d groups</string>
<string name="sync_progress_started">Storage sync: started</string>
<string name="sync_progress_completed">Storage sync: completed</string>
<string name="sync_progress_group_preparing">Storage sync: group "%1$s" preparing</string>
<string name="sync_progress_group_not_found">Storage sync: group "%1$s" not found</string>
<string name="sync_progress_group_skipped_few_storages">Storage sync: group "%1$s" skipped (need at least 2 storages)</string>
<string name="sync_progress_group_skipped_incompatible">Storage sync: group "%1$s" skipped (incompatible encryption: %2$d)</string>
<string name="sync_progress_group_acquiring_locks">Storage sync: group "%1$s" acquiring locks</string>
<string name="sync_progress_group_lock">Storage sync: group "%1$s" lock %2$d/%3$d</string>
<string name="sync_progress_group_lock_failed">Storage sync: group "%1$s" lock failed, group skipped</string>
<string name="sync_progress_group_reading_journals">Storage sync: group "%1$s" reading journals</string>
<string name="sync_progress_group_cancelled">Storage sync: group "%1$s" cancelled by newer run</string>
<string name="sync_progress_group_journal">Storage sync: group "%1$s" journal %2$d/%3$d</string>
<string name="sync_progress_group_no_entries">Storage sync: group "%1$s" no journal entries</string>
<string name="sync_progress_group_processing">Storage sync: group "%1$s" processing %2$d entries</string>
<string name="sync_progress_group_entry">Storage sync: group "%1$s" entry %2$d/%3$d</string>
<string name="sync_progress_group_completed">Storage sync: group "%1$s" completed</string>
<string name="sync_progress_group_renewing_locks">Storage sync: group "%1$s" renewing locks</string>
<string name="sync_progress_group_lock_renewal_failed">Storage sync: group "%1$s" lock renewal failed</string>
<string name="task_progress_clear_content">%1$d / %2$d</string>
<string name="task_log_sync_started">Storage sync started</string>
<string name="task_log_sync_finished">Storage sync finished</string>
<string name="task_log_sync_failed">Storage sync failed: %1$s</string>
<string name="task_log_enumerating">Enumerating files and directories…</string>
<string name="task_log_enumerate_done">Done: %1$d files, %2$d dirs in %3$d ms (see app log for lines)</string>
<string name="task_log_creating_storage">Creating storage…</string>
<string name="task_log_storage_created">Storage created</string>
<string name="task_log_checking_storage">Checking storage…</string>
<string name="task_log_encrypting">Encrypting…</string>
<string name="task_log_encryption_enabled">Encryption enabled</string>
<string name="task_log_already_encrypted">Storage is already encrypted</string>
<string name="task_log_not_empty">Storage is not empty</string>
<string name="task_log_empty_unknown">Cannot determine whether storage is empty</string>
<string name="task_log_unsupported_type">Unsupported storage type</string>
<string name="task_log_enable_encryption_failed">Failed to enable encryption</string>
<string name="task_log_opening_storage">Opening encrypted storage…</string>
<string name="task_log_storage_opened">Storage opened</string>
<string name="task_log_open_storage_failed">Failed to open encrypted storage</string>
<string name="task_log_closing_storage">Closing storage…</string>
<string name="task_log_storage_closed">Storage closed</string>
<string name="task_log_close_storage_failed">Failed to close encrypted storage</string>
<string name="task_log_disabling_encryption">Disabling encryption…</string>
<string name="task_log_encryption_disabled">Encryption disabled</string>
<string name="task_log_disable_encryption_failed">Failed to disable encryption</string>
<string name="task_log_renaming">Renaming…</string>
<string name="task_log_renamed">Renamed</string>
<string name="task_log_rename_failed">Rename failed</string>
<string name="task_log_removing_storage">Removing storage…</string>
<string name="task_log_removed">Removed</string>
<string name="task_log_remove_failed">Remove failed</string>
<string name="task_log_invalid_storage">Invalid storage</string>
<string name="task_log_clearing_sync_lock">Clearing sync lock…</string>
<string name="task_log_sync_lock_cleared">Sync lock cleared</string>
<string name="task_log_clear_sync_lock_failed">Failed to clear sync lock</string>
<string name="task_log_adding_vault">Adding vault…</string>
<string name="task_log_vault_added">Vault added</string>
<string name="task_log_add_vault_failed">Failed to add vault</string>
<string name="task_log_removing_remote_vault">Removing remote vault…</string>
<string name="task_log_remote_vault_removed">Remote vault removed</string>
<string name="task_log_remove_vault_failed">Failed to remove vault</string>
<string name="task_log_retrying_vault">Retrying remote vault connection…</string>
<string name="task_log_retry_requested">Retry requested</string>
<string name="task_log_retry_vault_failed">Failed to retry remote vault</string>
<string name="task_log_test_started">Test task started for %1$d s</string>
<string name="task_log_test_finished">Test task finished</string>
</resources>

View File

@@ -0,0 +1,30 @@
package com.github.nullptroma.wallenc.ui.resources
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import com.github.nullptroma.wallenc.domain.tasks.VaultTaskStep
import com.github.nullptroma.wallenc.ui.R
import org.junit.Assert.assertEquals
import org.junit.Test
class TaskProgressLabelsTest {
private val resolver = UiStringResolver { id, _ -> "res:$id" }
@Test
fun syncNoGroups_mapsToStringRes() {
val text = TaskProgressLabel.SyncNoGroups.resolve(resolver)
assertEquals("res:${R.string.sync_progress_no_groups}", text)
}
@Test
fun vaultTask_mapsToStringRes() {
val text = TaskProgressLabel.VaultTask(VaultTaskStep.Save2FaToken).resolve(resolver)
assertEquals("res:${R.string.task_progress_save_2fa_token}", text)
}
@Test
fun clearContentProgress_mapsToStringRes() {
val text = TaskProgressLabel.ClearContentProgress(3, 10).resolve(resolver)
assertEquals("res:${R.string.task_progress_clear_content}", text)
}
}

View File

@@ -4,7 +4,9 @@ import com.github.nullptroma.wallenc.domain.errors.toWallencException
import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncEngine
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskId
import com.github.nullptroma.wallenc.domain.tasks.TaskLogKey
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -28,6 +30,7 @@ class RunStorageSyncUseCase(
* @param logReason техническая метка для логов (не для UI)
* @return false, если синхронизация уже в очереди или выполняется — новая задача не создана
*/
@Suppress("UNUSED_PARAMETER")
fun enqueue(displayTitle: String, logReason: String): Boolean {
if (!running.compareAndSet(false, true)) {
return false
@@ -39,17 +42,16 @@ class RunStorageSyncUseCase(
dispatcher = Dispatchers.IO,
work = { ctx ->
try {
ctx.log(TaskLogLevel.Info, "Storage sync started, reason=$logReason")
ctx.reportProgress(null, "Storage sync: started")
ctx.log(TaskLogLevel.Info, TaskLogKey.SyncStarted)
ctx.reportProgress(null, TaskProgressLabel.SyncStarted)
syncEngine.syncAllGroups { fraction, label ->
ctx.reportProgress(fraction, label)
}
ctx.log(TaskLogLevel.Info, "Storage sync finished")
ctx.reportProgress(null, "Storage sync: completed")
ctx.log(TaskLogLevel.Info, TaskLogKey.SyncFinished)
ctx.reportProgress(null, TaskProgressLabel.SyncCompleted)
} catch (e: Exception) {
val err = e.toWallencException()
ctx.log(TaskLogLevel.Error, "Storage sync failed: $err")
ctx.reportProgress(null, "Storage sync: failed - $err")
ctx.log(TaskLogLevel.Error, TaskLogKey.SyncFailed(err))
ctx.fail(err)
} finally {
running.set(false)

View File

@@ -1,12 +1,13 @@
package com.github.nullptroma.wallenc.usecases
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncOperation
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncEngine
import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncOperation
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -26,29 +27,29 @@ class StorageSyncEngine(
private val syncGeneration = AtomicLong(0)
override suspend fun syncAllGroups(
reportProgress: (suspend (fraction: Float?, label: String?) -> Unit)?,
reportProgress: (suspend (fraction: Float?, label: TaskProgressLabel?) -> Unit)?,
): Unit = withContext(Dispatchers.IO) {
val reporter = reportProgress ?: { _: Float?, _: String? -> }
val reporter = reportProgress ?: { _: Float?, _: TaskProgressLabel? -> }
val groups = groupStore.getGroups()
if (groups.isEmpty()) {
reporter(null, "Storage sync: no groups configured")
reporter(null, TaskProgressLabel.SyncNoGroups)
return@withContext
}
reporter(null, "Storage sync: preparing ${groups.size} groups")
reporter(null, TaskProgressLabel.SyncPreparing(groups.size))
for (group in groups) {
syncGroupInternal(
groupId = group.id,
reportProgress = reporter,
)
}
reporter(null, "Storage sync: completed")
reporter(null, TaskProgressLabel.SyncCompleted)
}
override suspend fun syncGroup(
groupId: String,
reportProgress: (suspend (fraction: Float?, label: String?) -> Unit)?,
reportProgress: (suspend (fraction: Float?, label: TaskProgressLabel?) -> Unit)?,
): Unit = withContext(Dispatchers.IO) {
val reporter = reportProgress ?: { _: Float?, _: String? -> }
val reporter = reportProgress ?: { _: Float?, _: TaskProgressLabel? -> }
syncGroupInternal(
groupId = groupId,
reportProgress = reporter,
@@ -57,20 +58,20 @@ class StorageSyncEngine(
private suspend fun syncGroupInternal(
groupId: String,
reportProgress: suspend (fraction: Float?, label: String?) -> Unit,
reportProgress: suspend (fraction: Float?, label: TaskProgressLabel?) -> Unit,
) {
reportProgress(null, "Storage sync: group \"$groupId\" preparing")
reportProgress(null, TaskProgressLabel.SyncGroupPreparing(groupId))
val mutex = groupMutexes.getOrPut(groupId) { Mutex() }
mutex.withLock {
val generationSnapshot = syncGeneration.incrementAndGet()
val group = groupStore.getGroups().firstOrNull { it.id == groupId }
if (group == null) {
reportProgress(null, "Storage sync: group \"$groupId\" not found")
reportProgress(null, TaskProgressLabel.SyncGroupNotFound(groupId))
return
}
val storages = resolveStorages(group.storageUuids)
if (storages.size < 2) {
reportProgress(null, "Storage sync: group \"$groupId\" skipped (need at least 2 storages)")
reportProgress(null, TaskProgressLabel.SyncGroupSkippedTooFewStorages(groupId))
return
}
val incompatible = storages.filterNot { storage ->
@@ -83,7 +84,7 @@ class StorageSyncEngine(
if (incompatible.isNotEmpty()) {
reportProgress(
null,
"Storage sync: group \"$groupId\" skipped (incompatible encryption: ${incompatible.size})",
TaskProgressLabel.SyncGroupSkippedIncompatibleEncryption(groupId, incompatible.size),
)
return
}
@@ -91,12 +92,15 @@ class StorageSyncEngine(
var leaseUntil = Instant.now().plusSeconds(SYNC_LOCK_LEASE_SECONDS)
val lockedAccessors = mutableListOf<IStorageAccessor>()
try {
reportProgress(null, "Storage sync: group \"$groupId\" acquiring locks")
reportProgress(null, TaskProgressLabel.SyncGroupAcquiringLocks(groupId))
for ((lockIndex, storage) in storages.withIndex()) {
reportProgress(null, "Storage sync: group \"$groupId\" lock ${lockIndex + 1}/${storages.size}")
reportProgress(
null,
TaskProgressLabel.SyncGroupLockProgress(groupId, lockIndex + 1, storages.size),
)
val locked = storage.accessor.tryAcquireSyncLock(holderId, leaseUntil)
if (!locked) {
reportProgress(null, "Storage sync: group \"$groupId\" lock failed, group skipped")
reportProgress(null, TaskProgressLabel.SyncGroupLockFailed(groupId))
return
}
lockedAccessors.add(storage.accessor)
@@ -105,7 +109,7 @@ class StorageSyncEngine(
val latestByPath = mutableMapOf<String, StorageSyncJournalEntry>()
val entriesByStorage = mutableMapOf<UUID, Map<String, StorageSyncJournalEntry>>()
reportProgress(null, "Storage sync: group \"$groupId\" reading journals")
reportProgress(null, TaskProgressLabel.SyncGroupReadingJournals(groupId))
for ((journalIndex, storage) in storages.withIndex()) {
leaseUntil = renewLocksIfNeeded(
groupId = groupId,
@@ -114,10 +118,13 @@ class StorageSyncEngine(
reportProgress = reportProgress,
) ?: return
if (syncGeneration.get() != generationSnapshot) {
reportProgress(null, "Storage sync: group \"$groupId\" cancelled by newer run")
reportProgress(null, TaskProgressLabel.SyncGroupCancelled(groupId))
return
}
reportProgress(null, "Storage sync: group \"$groupId\" journal ${journalIndex + 1}/${storages.size}")
reportProgress(
null,
TaskProgressLabel.SyncGroupJournalProgress(groupId, journalIndex + 1, storages.size),
)
val latestEntries = latestByPath(storage.accessor.readSyncJournal())
entriesByStorage[storage.uuid] = latestEntries
for ((path, entry) in latestEntries) {
@@ -130,11 +137,14 @@ class StorageSyncEngine(
val mergedEntries = latestByPath.entries.toList()
if (mergedEntries.isEmpty()) {
reportProgress(null, "Storage sync: group \"$groupId\" no journal entries")
reportProgress(null, TaskProgressLabel.SyncGroupNoJournalEntries(groupId))
return
}
reportProgress(null, "Storage sync: group \"$groupId\" processing ${mergedEntries.size} entries")
reportProgress(
null,
TaskProgressLabel.SyncGroupProcessingEntries(groupId, mergedEntries.size),
)
for ((pathIndex, merged) in mergedEntries.withIndex()) {
leaseUntil = renewLocksIfNeeded(
groupId = groupId,
@@ -144,9 +154,12 @@ class StorageSyncEngine(
) ?: return
val path = merged.key
val winnerEntry = merged.value
reportProgress(null, "Storage sync: group \"$groupId\" entry ${pathIndex + 1}/${mergedEntries.size}")
reportProgress(
null,
TaskProgressLabel.SyncGroupEntryProgress(groupId, pathIndex + 1, mergedEntries.size),
)
if (syncGeneration.get() != generationSnapshot) {
reportProgress(null, "Storage sync: group \"$groupId\" cancelled by newer run")
reportProgress(null, TaskProgressLabel.SyncGroupCancelled(groupId))
return
}
val sourceStorage = findSourceStorage(storages, entriesByStorage, path, winnerEntry)
@@ -172,7 +185,7 @@ class StorageSyncEngine(
}
}
}
reportProgress(null, "Storage sync: group \"$groupId\" completed")
reportProgress(null, TaskProgressLabel.SyncGroupCompleted(groupId))
} finally {
for (accessor in lockedAccessors) {
runCatching {
@@ -187,20 +200,20 @@ class StorageSyncEngine(
groupId: String,
lockedAccessors: List<IStorageAccessor>,
currentLeaseUntil: Instant,
reportProgress: suspend (fraction: Float?, label: String?) -> Unit,
reportProgress: suspend (fraction: Float?, label: TaskProgressLabel?) -> Unit,
): Instant? {
val now = Instant.now()
if (currentLeaseUntil.isAfter(now.plusSeconds(SYNC_LOCK_RENEW_MARGIN_SECONDS))) {
return currentLeaseUntil
}
val nextLeaseUntil = now.plusSeconds(SYNC_LOCK_LEASE_SECONDS)
reportProgress(null, "Storage sync: group \"$groupId\" renewing locks")
reportProgress(null, TaskProgressLabel.SyncGroupRenewingLocks(groupId))
for (accessor in lockedAccessors) {
val renewed = runCatching {
accessor.tryAcquireSyncLock(holderId, nextLeaseUntil)
}.getOrElse { false }
if (!renewed) {
reportProgress(null, "Storage sync: group \"$groupId\" lock renewal failed")
reportProgress(null, TaskProgressLabel.SyncGroupLockRenewalFailed(groupId))
return null
}
}