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

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) ksp(libs.androidx.hilt.compiler)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)

View File

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

View File

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

View File

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

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.TaskForegroundItem
import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundUiState import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundUiState
import com.github.nullptroma.wallenc.domain.tasks.TaskId 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 dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -43,6 +45,9 @@ class TaskPipelineForegroundService : Service() {
@Inject @Inject
lateinit var orchestrator: ITaskOrchestrator lateinit var orchestrator: ITaskOrchestrator
@Inject
lateinit var uiStrings: UiStringResolver
private var repeat = false private var repeat = false
private var canPush = true private var canPush = true
private var lastUiState: TaskForegroundUiState? = null private var lastUiState: TaskForegroundUiState? = null
@@ -291,7 +296,7 @@ class TaskPipelineForegroundService : Service() {
remoteViews.setViewVisibility(TASK_ROW_IDS[index], View.VISIBLE) remoteViews.setViewVisibility(TASK_ROW_IDS[index], View.VISIBLE)
remoteViews.setViewVisibility(TASK_LABEL_BAR_ROW_IDS[index], View.VISIBLE) remoteViews.setViewVisibility(TASK_LABEL_BAR_ROW_IDS[index], View.VISIBLE)
remoteViews.setTextViewText(TASK_TITLE_IDS[index], task.title) 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 val fraction = task.progress?.fraction
if (fraction != null) { if (fraction != null) {
if (label.isNotEmpty()) { 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> <resources>
<string name="app_name">Wallenc</string> <string name="app_name">Wallenc</string>
<string name="task_notification_channel_name">Фоновые задачи</string> <string name="task_notification_channel_name">Background tasks</string>
<string name="task_notification_title">Задачи Wallenc</string> <string name="task_notification_title">Wallenc tasks</string>
<string name="task_notification_preparing">Подготовка</string> <string name="task_notification_preparing">Preparing</string>
<string name="task_notification_indeterminate">Выполняется</string> <string name="task_notification_indeterminate">Running</string>
<string name="task_notification_cancel">Отмена</string> <string name="task_notification_cancel">Cancel</string>
</resources> </resources>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <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+) --> <!-- До первого кадра Compose и системный splash (12+) -->
<item name="android:windowBackground">@color/splash_screen_background</item> <item name="android:windowBackground">@color/splash_screen_background</item>
<item name="android:windowSplashScreenBackground" tools:targetApi="31"> <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.IStorageAccessor
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -132,7 +133,7 @@ abstract class BaseStorage(
onProgress( onProgress(
TaskProgress( TaskProgress(
fraction = done.toFloat() / total, fraction = done.toFloat() / total,
label = "$done / $total", label = TaskProgressLabel.ClearContentProgress(done, total),
), ),
) )
coroutineContext.ensureActive() coroutineContext.ensureActive()

View File

@@ -1,5 +1,6 @@
package com.github.nullptroma.wallenc.domain.interfaces package com.github.nullptroma.wallenc.domain.interfaces
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import java.util.UUID import java.util.UUID
enum class StorageSyncGroupEncryptionKind { enum class StorageSyncGroupEncryptionKind {
@@ -24,10 +25,10 @@ interface IStorageSyncGroupStore {
interface IStorageSyncEngine { interface IStorageSyncEngine {
suspend fun syncAllGroups( suspend fun syncAllGroups(
reportProgress: (suspend (fraction: Float?, label: String?) -> Unit)? = null, reportProgress: (suspend (fraction: Float?, label: TaskProgressLabel?) -> Unit)? = null,
) )
suspend fun syncGroup( suspend fun syncGroup(
groupId: String, 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 { interface TaskContext {
val taskId: TaskId 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) suspend fun reportProgress(progress: TaskProgress) = reportProgress(progress.fraction, progress.label)
fun log(level: TaskLogLevel, message: String) fun log(level: TaskLogLevel, message: String)
fun log(level: TaskLogLevel, key: TaskLogKey)
fun fail(error: WallencException): Nothing 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( data class TaskLogLine(
val timestampMs: Long, val timestampMs: Long,
val level: TaskLogLevel, 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( data class TaskProgress(
/** 0f..1f or null if indeterminate */ /** 0f..1f or null if indeterminate */
val fraction: Float?, 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" cameraX = "1.6.1"
mlkitBarcode = "17.3.0" mlkitBarcode = "17.3.0"
javaOtp = "0.4.0" javaOtp = "0.4.0"
appcompat = "1.7.1"
datastore = "1.2.0"
[libraries] [libraries]
jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" } 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-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltWork" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } 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.TaskForegroundItem
import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundUiState import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundUiState
import com.github.nullptroma.wallenc.domain.tasks.TaskId 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.TaskLogLevel
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLine 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.TaskProgress
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
import kotlinx.coroutines.CancellationException 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( val line = TaskLogLine(
timestampMs = System.currentTimeMillis(), timestampMs = System.currentTimeMillis(),
level = level, level = level,
message = message, message = message,
logKey = logKey,
) )
synchronized(logLock) { synchronized(logLock) {
if (logBuffer.size >= MAX_LOG_LINES) { if (logBuffer.size >= MAX_LOG_LINES) {
@@ -180,7 +183,7 @@ class TaskOrchestrator(
val ctx = TaskContextImpl( val ctx = TaskContextImpl(
taskId = taskId, taskId = taskId,
onRunningProgress = { p -> onRunningProgress(taskId, p) }, onRunningProgress = { p -> onRunningProgress(taskId, p) },
appendLog = { level, msg -> appendLogLine(level, msg) }, appendLog = { level, msg, key -> appendLogLine(level, msg, key) },
) )
try { try {
if (cancelRequested[taskId] == true) { if (cancelRequested[taskId] == true) {
@@ -213,14 +216,18 @@ class TaskOrchestrator(
private class TaskContextImpl( private class TaskContextImpl(
override val taskId: TaskId, override val taskId: TaskId,
private val onRunningProgress: (TaskProgress) -> Unit, private val onRunningProgress: (TaskProgress) -> Unit,
private val appendLog: (TaskLogLevel, String) -> Unit, private val appendLog: (TaskLogLevel, String, TaskLogKey?) -> Unit,
) : TaskContext { ) : TaskContext {
override suspend fun reportProgress(fraction: Float?, label: String?) { override suspend fun reportProgress(fraction: Float?, label: TaskProgressLabel?) {
onRunningProgress(TaskProgress(fraction, label)) onRunningProgress(TaskProgress(fraction, label))
} }
override fun log(level: TaskLogLevel, message: String) { 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 { 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.Icons
import androidx.compose.material.icons.automirrored.rounded.List import androidx.compose.material.icons.automirrored.rounded.List
import androidx.compose.material.icons.rounded.Menu import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Sync import androidx.compose.material.icons.rounded.Sync
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -99,13 +100,11 @@ fun WallencNavRoot(
StorageSyncRoute::class.qualifiedName!!, StorageSyncRoute::class.qualifiedName!!,
Icons.Rounded.Sync, Icons.Rounded.Sync,
), ),
// Settings temporarily hidden from top-level menu. SettingsRoute::class.qualifiedName!! to NavBarItemData(
// Uncomment to restore: R.string.nav_label_settings,
// SettingsRoute::class.qualifiedName!! to NavBarItemData( SettingsRoute::class.qualifiedName!!,
// R.string.nav_label_settings, Icons.Rounded.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 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.R
import com.github.nullptroma.wallenc.ui.ViewModelBase import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import com.github.nullptroma.wallenc.ui.resources.resolve
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.remotes.RemoteVaultsRoute import com.github.nullptroma.wallenc.ui.screens.main.screens.remotes.RemoteVaultsRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.LocalVaultRoute import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.LocalVaultRoute
@@ -127,7 +128,7 @@ class MainViewModel @Inject constructor(
private fun activeWorkStatusFromProgress(title: String, progress: TaskProgress?): MainWorkStatus.Active { private fun activeWorkStatusFromProgress(title: String, progress: TaskProgress?): MainWorkStatus.Active {
val frac = progress?.fraction val frac = progress?.fraction
val indeterminate = progress == null || frac == null 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 val line = if (label != null) "$title$TITLE_LABEL_SEPARATOR$label" else title
return MainWorkStatus.Active( return MainWorkStatus.Active(
line = line, 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.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import com.github.nullptroma.wallenc.domain.tasks.VaultTaskStep
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.ViewModelBase import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
@@ -77,12 +79,12 @@ class RemoteVaultsViewModel @Inject constructor(
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
work = { ctx -> work = { ctx ->
try { try {
ctx.reportProgress(null, uiStrings(R.string.task_progress_add_remote_vault)) ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.AddRemoteVault))
ctx.log(TaskLogLevel.Info, "Adding vault") ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_adding_vault))
vaultRegistrar.register(registration) vaultRegistrar.register(registration)
ctx.log(TaskLogLevel.Info, "Vault added") ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_vault_added))
} catch (e: Exception) { } 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 { } finally {
withContext(Dispatchers.Main.immediate) { withContext(Dispatchers.Main.immediate) {
setBusy(false) setBusy(false)
@@ -110,12 +112,12 @@ class RemoteVaultsViewModel @Inject constructor(
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
work = { ctx -> work = { ctx ->
try { try {
ctx.reportProgress(null, uiStrings(R.string.task_progress_remove_remote_vault)) ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.RemoveRemoteVault))
ctx.log(TaskLogLevel.Info, "Removing remote vault") ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_removing_remote_vault))
vaultRegistrar.unregister(uuid) 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) { } 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 { } finally {
withContext(Dispatchers.Main.immediate) { withContext(Dispatchers.Main.immediate) {
setBusy(false) setBusy(false)
@@ -133,12 +135,12 @@ class RemoteVaultsViewModel @Inject constructor(
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
work = { ctx -> work = { ctx ->
try { try {
ctx.reportProgress(null, uiStrings(R.string.task_progress_retry_remote_vault)) ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.RetryRemoteVault))
ctx.log(TaskLogLevel.Info, "Retrying remote vault connection…") ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_retrying_vault))
vaultRegistrar.retry(vaultUuid) vaultRegistrar.retry(vaultUuid)
ctx.log(TaskLogLevel.Info, "Retry requested") ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_retry_requested))
} catch (e: Exception) { } 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 { } finally {
withContext(Dispatchers.Main.immediate) { withContext(Dispatchers.Main.immediate) {
setBusy(false) setBusy(false)

View File

@@ -88,6 +88,7 @@ fun TextSecretDetailsScreen(
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(secret.items) { item -> items(secret.items) { item ->
val clipboardFallbackLabel = stringResource(R.string.text_secret_clipboard_fallback_label)
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors( colors = CardDefaults.elevatedCardColors(
@@ -118,7 +119,7 @@ fun TextSecretDetailsScreen(
onClick = { onClick = {
scope.launch { scope.launch {
val clipData = ClipData.newPlainText( val clipData = ClipData.newPlainText(
item.label ?: "value", item.label ?: clipboardFallbackLabel,
item.value, item.value,
) )
clipboard.setClipEntry(clipData.toClipEntry()) 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.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator 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.TaskId
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
@@ -105,7 +107,7 @@ class TextSecretDetailsViewModel @Inject constructor(
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
busyStorageUuid = storage.uuid, busyStorageUuid = storage.uuid,
work = { ctx -> work = { ctx ->
ctx.reportProgress(null, uiStrings(R.string.task_progress_delete_text_secret)) ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.DeleteTextSecret))
manageTextSecretsUseCase.delete(storage, secretId) 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.TextSecretEntryRecord
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator 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.TaskId
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
@@ -110,7 +112,7 @@ class TextSecretEditViewModel @Inject constructor(
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
busyStorageUuid = storage.uuid, busyStorageUuid = storage.uuid,
work = { ctx -> work = { ctx ->
ctx.reportProgress(null, uiStrings(R.string.task_progress_save_text_secret)) ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.SaveTextSecret))
if (existingId == null) { if (existingId == null) {
manageTextSecretsUseCase.create( manageTextSecretsUseCase.create(
storageInfo = storage, storageInfo = storage,

View File

@@ -4,6 +4,8 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator 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.domain.tasks.TaskRunState
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.domain.errors.WallencException import com.github.nullptroma.wallenc.domain.errors.WallencException
@@ -104,7 +106,7 @@ class TwoFaTokensViewModel @Inject constructor(
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
busyStorageUuid = storage.uuid, busyStorageUuid = storage.uuid,
work = { ctx -> work = { ctx ->
ctx.reportProgress(null, uiStrings(R.string.task_progress_save_2fa_token)) ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.Save2FaToken))
if (existingId == null) { if (existingId == null) {
manageTwoFaTokensUseCase.create( manageTwoFaTokensUseCase.create(
storageInfo = storage, storageInfo = storage,
@@ -152,7 +154,7 @@ class TwoFaTokensViewModel @Inject constructor(
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
busyStorageUuid = storage.uuid, busyStorageUuid = storage.uuid,
work = { ctx -> work = { ctx ->
ctx.reportProgress(null, uiStrings(R.string.task_progress_delete_2fa_token)) ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.Delete2FaToken))
manageTwoFaTokensUseCase.delete(storage, id) 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.TaskLogLevel
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
import com.github.nullptroma.wallenc.ui.R 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 import com.github.nullptroma.wallenc.ui.resources.toUserNotification
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -97,7 +99,7 @@ fun TaskPipelineScreen(
TaskLogLevel.Error -> "E" TaskLogLevel.Error -> "E"
} }
Text( Text(
"[$prefix] ${line.message}", "[$prefix] ${line.displayText()}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -185,7 +187,7 @@ private fun TaskRow(task: PipelineTask, isRunning: Boolean) {
else MaterialTheme.typography.bodyMedium, else MaterialTheme.typography.bodyMedium,
) )
val runningProgress = (task.state as? TaskRunState.Running)?.progress 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) { val stateLabel = when (val s = task.state) {
TaskRunState.Queued -> stringResource(R.string.task_state_queued) TaskRunState.Queued -> stringResource(R.string.task_state_queued)
is TaskRunState.Running -> is TaskRunState.Running ->

View File

@@ -3,6 +3,7 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.tasks
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -29,17 +30,17 @@ class TaskPipelineViewModel @Inject constructor(
dispatcher = Dispatchers.Default, dispatcher = Dispatchers.Default,
work = { ctx -> work = { ctx ->
val steps = if (safeDurationSec == 0) 1 else safeDurationSec * 10 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) { for (step in 0..steps) {
val fraction = step.toFloat() / steps.toFloat() val fraction = step.toFloat() / steps.toFloat()
val elapsedMs = (fraction * safeDurationSec * 1000).toInt() val elapsedSec = ((fraction * safeDurationSec * 1000).toInt()) / 1000
ctx.reportProgress( ctx.reportProgress(
fraction = if (infinityIndeterminateProgress) null else fraction, fraction = if (infinityIndeterminateProgress) null else fraction,
label = "Elapsed: ${elapsedMs / 1000}s / ${safeDurationSec}s", label = TaskProgressLabel.TestElapsed(elapsedSec, safeDurationSec),
) )
if (step < steps) delay(100) 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.interfaces.IStorageInfo
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState 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.GetOpenedStoragesUseCase
import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase
import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase
@@ -162,9 +164,9 @@ abstract class AbstractVaultBrowserViewModel(
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
busyStorageUuid = id, busyStorageUuid = id,
work = { ctx -> work = { ctx ->
ctx.reportProgress(null, uiStrings(R.string.task_progress_dump_storage_log)) ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.DumpStorageLog))
storageFileManagementUseCase.setStorage(storage) 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 files: List<IFile>
val dirs: List<IDirectory> val dirs: List<IDirectory>
val time = measureTimeMillis { val time = measureTimeMillis {
@@ -181,7 +183,7 @@ abstract class AbstractVaultBrowserViewModel(
logger.debug("Storage", storage.toPrintable()) logger.debug("Storage", storage.toPrintable())
ctx.log( ctx.log(
TaskLogLevel.Info, 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, locksVaultStorageList = true,
work = { ctx -> work = { ctx ->
try { try {
ctx.reportProgress(null, uiStrings(R.string.task_progress_create_storage)) ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.CreateStorage))
ctx.log(TaskLogLevel.Info, "Creating storage") ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_creating_storage))
val uuid = resolveCreateVaultUuid() val uuid = resolveCreateVaultUuid()
?: throw IllegalStateException("Vault is not available") ?: throw IllegalStateException("Vault is not available")
logger.debug(TAG, "createStorage: vaultUuid=$uuid") logger.debug(TAG, "createStorage: vaultUuid=$uuid")
val storage = manageVaultUseCase.createStorage(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}") logger.debug(TAG, "createStorage: done storageUuid=${storage.uuid}")
} catch (e: Exception) { } catch (e: Exception) {
logger.debug(TAG, "createStorage failed: ${e.stackTraceToString()}") 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 throw e
} }
}, },
@@ -239,35 +241,35 @@ abstract class AbstractVaultBrowserViewModel(
busyStorageUuid = id, busyStorageUuid = id,
work = { ctx -> work = { ctx ->
try { try {
ctx.reportProgress(null, uiStrings(R.string.task_progress_enable_encryption)) ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.EnableEncryption))
ctx.log(TaskLogLevel.Info, "Checking storage") ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_checking_storage))
when (manageStoragesEncryptionUseCase.canEncrypt(storage)) { when (manageStoragesEncryptionUseCase.canEncrypt(storage)) {
ManageStoragesEncryptionUseCase.CanEncryptResult.Allowed -> { ManageStoragesEncryptionUseCase.CanEncryptResult.Allowed -> {
ctx.log(TaskLogLevel.Info, "Encrypting") ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_encrypting))
manageStoragesEncryptionUseCase.enableEncryption(storage, key, encryptPath) manageStoragesEncryptionUseCase.enableEncryption(storage, key, encryptPath)
manageStoragesEncryptionUseCase.openStorage(storage, key, rememberPassword) 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)) _userNotifications.emit(UserNotification.TextRes(R.string.msg_encryption_enabled))
} }
ManageStoragesEncryptionUseCase.CanEncryptResult.AlreadyEncrypted -> { ManageStoragesEncryptionUseCase.CanEncryptResult.AlreadyEncrypted -> {
ctx.log(TaskLogLevel.Info, "Storage is already encrypted") ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_already_encrypted))
_userNotifications.emit(UserNotification.TextRes(R.string.msg_storage_already_encrypted)) _userNotifications.emit(UserNotification.TextRes(R.string.msg_storage_already_encrypted))
} }
ManageStoragesEncryptionUseCase.CanEncryptResult.StorageIsNotEmpty -> { ManageStoragesEncryptionUseCase.CanEncryptResult.StorageIsNotEmpty -> {
ctx.log(TaskLogLevel.Info, "Storage is not empty") ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_not_empty))
_userNotifications.emit(UserNotification.TextRes(R.string.msg_storage_not_empty)) _userNotifications.emit(UserNotification.TextRes(R.string.msg_storage_not_empty))
} }
ManageStoragesEncryptionUseCase.CanEncryptResult.StorageStateUnknown -> { ManageStoragesEncryptionUseCase.CanEncryptResult.StorageStateUnknown -> {
ctx.log(TaskLogLevel.Info, "Cannot determine whether storage is empty") ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_empty_unknown))
_userNotifications.emit(UserNotification.TextRes(R.string.msg_storage_empty_state_unknown)) _userNotifications.emit(UserNotification.TextRes(R.string.msg_storage_empty_state_unknown))
} }
ManageStoragesEncryptionUseCase.CanEncryptResult.UnsupportedStorageType -> { ManageStoragesEncryptionUseCase.CanEncryptResult.UnsupportedStorageType -> {
ctx.log(TaskLogLevel.Info, "Unsupported storage type") ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_unsupported_type))
_userNotifications.emit(UserNotification.TextRes(R.string.msg_unsupported_storage_type)) _userNotifications.emit(UserNotification.TextRes(R.string.msg_unsupported_storage_type))
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Failed to enable encryption") ctx.log(TaskLogLevel.Error, uiStrings(R.string.task_log_enable_encryption_failed))
emitTaskError(e) emitTaskError(e)
} }
}, },
@@ -287,12 +289,12 @@ abstract class AbstractVaultBrowserViewModel(
busyStorageUuid = id, busyStorageUuid = id,
work = { ctx -> work = { ctx ->
try { try {
ctx.reportProgress(null, uiStrings(R.string.task_progress_decrypt_running)) ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.DecryptRunning))
ctx.log(TaskLogLevel.Info, "Opening encrypted storage") ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_opening_storage))
manageStoragesEncryptionUseCase.openStorage(storage, key, rememberPassword) 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) { } 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) emitTaskError(e)
} }
}, },
@@ -311,12 +313,12 @@ abstract class AbstractVaultBrowserViewModel(
busyStorageUuid = id, busyStorageUuid = id,
work = { ctx -> work = { ctx ->
try { try {
ctx.reportProgress(null, uiStrings(R.string.task_progress_close_storage)) ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.CloseStorage))
ctx.log(TaskLogLevel.Info, "Closing storage") ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_closing_storage))
manageStoragesEncryptionUseCase.closeStorage(storage) manageStoragesEncryptionUseCase.closeStorage(storage)
ctx.log(TaskLogLevel.Info, "Storage closed") ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_storage_closed))
} catch (e: Exception) { } 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) emitTaskError(e)
} }
}, },
@@ -335,17 +337,18 @@ abstract class AbstractVaultBrowserViewModel(
busyStorageUuid = id, busyStorageUuid = id,
work = { ctx -> work = { ctx ->
try { try {
ctx.reportProgress(null, uiStrings(R.string.task_progress_disable_encryption)) ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.DisableEncryption))
ctx.log(TaskLogLevel.Info, "Disabling encryption") ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_disabling_encryption))
manageStoragesEncryptionUseCase.clearAndDisableEncryption(storage) { p -> manageStoragesEncryptionUseCase.clearAndDisableEncryption(storage) { p ->
val label = p.label?.takeIf { it.isNotBlank() } ctx.reportProgress(
?: uiStrings(R.string.task_progress_disable_encryption) p.fraction,
ctx.reportProgress(p.fraction, label) 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)) _userNotifications.emit(UserNotification.TextRes(R.string.msg_encryption_disabled))
} catch (e: Exception) { } catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Failed") ctx.log(TaskLogLevel.Error, uiStrings(R.string.task_log_disable_encryption_failed))
emitTaskError(e) emitTaskError(e)
} }
}, },
@@ -364,12 +367,12 @@ abstract class AbstractVaultBrowserViewModel(
busyStorageUuid = id, busyStorageUuid = id,
work = { ctx -> work = { ctx ->
try { try {
ctx.reportProgress(null, uiStrings(R.string.task_progress_rename_storage)) ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.RenameStorage))
ctx.log(TaskLogLevel.Info, "Renaming") ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_renaming))
renameStorageUseCase.rename(storage, newName) renameStorageUseCase.rename(storage, newName)
ctx.log(TaskLogLevel.Info, "Renamed") ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_renamed))
} catch (e: Exception) { } 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, locksVaultStorageList = true,
work = { ctx -> work = { ctx ->
try { try {
ctx.reportProgress(null, uiStrings(R.string.task_progress_remove_storage)) ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.RemoveStorage))
ctx.log(TaskLogLevel.Info, "Removing storage") ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_removing_storage))
removeStorageUseCase.remove(storage) removeStorageUseCase.remove(storage)
ctx.log(TaskLogLevel.Info, "Removed") ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_removed))
} catch (e: Exception) { } 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 { try {
val s = storage as? IStorage val s = storage as? IStorage
if (s == null) { 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)) _userNotifications.emit(UserNotification.TextRes(R.string.msg_invalid_storage_for_sync_lock))
return@enqueue return@enqueue
} }
ctx.reportProgress(null, uiStrings(R.string.task_progress_clear_sync_lock)) ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.ClearSyncLock))
ctx.log(TaskLogLevel.Info, "Clearing sync lock") ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_clearing_sync_lock))
s.accessor.forceClearSyncLock() 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)) _userNotifications.emit(UserNotification.TextRes(R.string.msg_sync_lock_cleared))
} catch (e: Exception) { } 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) 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.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.R
import com.github.nullptroma.wallenc.ui.locale.AppLanguage
@Composable @Composable
fun SettingsScreen(modifier: Modifier, viewModel: SettingsViewModel) { fun SettingsScreen(modifier: Modifier, viewModel: SettingsViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
Column( Column(
modifier = modifier.fillMaxSize(), modifier = modifier
horizontalAlignment = Alignment.CenterHorizontally, .fillMaxSize()
verticalArrangement = Arrangement.Center, .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 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 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.ViewModelBase
import com.github.nullptroma.wallenc.ui.locale.AppLanguage
import com.github.nullptroma.wallenc.ui.locale.AppLocaleController
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SettingsViewModel @javax.inject.Inject constructor() : class SettingsViewModel @Inject constructor(
ViewModelBase<SettingsScreenState>(SettingsScreenState()) 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.R
import com.github.nullptroma.wallenc.ui.ViewModelBase import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import com.github.nullptroma.wallenc.ui.resources.resolve
import com.github.nullptroma.wallenc.ui.resources.UserNotification import com.github.nullptroma.wallenc.ui.resources.UserNotification
import com.github.nullptroma.wallenc.usecases.AddStorageToSyncGroupResult import com.github.nullptroma.wallenc.usecases.AddStorageToSyncGroupResult
import com.github.nullptroma.wallenc.usecases.ManageStorageSyncGroupsUseCase import com.github.nullptroma.wallenc.usecases.ManageStorageSyncGroupsUseCase
@@ -74,7 +75,7 @@ class StorageSyncViewModel @Inject constructor(
Triple( Triple(
syncRunning, syncRunning,
progress?.fraction, progress?.fraction,
progress?.label?.takeIf { it.isNotBlank() }, progress?.label?.resolve(uiStrings),
) )
} }
.distinctUntilChanged() .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"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="nav_label_local_vault">Локальное хранилище</string> <string name="nav_label_local_vault">Local vault</string>
<string name="nav_cd_local_vault">Локальное хранилище</string> <string name="nav_cd_local_vault">Local vault</string>
<string name="nav_label_remote_vaults">Удалённые хранилища</string> <string name="nav_label_remote_vaults">Remote vaults</string>
<string name="nav_cd_remote_vaults">Удалённые хранилища</string> <string name="nav_cd_remote_vaults">Remote vaults</string>
<string name="nav_label_main">Главная</string> <string name="nav_label_main">Home</string>
<string name="nav_label_sync">Синхронизация</string> <string name="nav_label_sync">Sync</string>
<string name="nav_label_settings">Настройки</string> <string name="nav_label_settings">Settings</string>
<string name="main_work_status_label">Status:</string>
<string name="main_work_status_label">Статус:</string> <string name="main_status_multiple_tasks">Running tasks: %1$d</string>
<string name="main_status_multiple_tasks">Выполняется задач: %1$d</string> <string name="main_status_vault_scanning_storages">Scanning vault: loading storage list…</string>
<string name="main_status_vault_scanning_storages">Сканирование vault: загрузка списка хранилищ…</string> <string name="settings_title">Settings</string>
<string name="sync_groups_title">Sync groups</string>
<string name="settings_title">Настройки</string> <string name="sync_progress_section_title">Storage sync</string>
<string name="sync_groups_title">Группы синхронизации</string> <string name="sync_groups_busy_section_title">Saving sync groups</string>
<string name="sync_progress_section_title">Синхронизация хранилищ</string> <string name="sync_run_now">Run sync now</string>
<string name="sync_groups_busy_section_title">Сохранение групп синхронизации</string> <string name="sync_cd_run_now">Run sync now</string>
<string name="sync_run_now">Запустить синхронизацию</string> <string name="sync_refresh">Refresh</string>
<string name="sync_cd_run_now">Запустить синхронизацию сейчас</string> <string name="sync_add_storage">Add storage to group</string>
<string name="sync_refresh">Обновить</string> <string name="sync_remove_group">Remove group</string>
<string name="sync_add_storage">Добавить хранилище в группу</string> <string name="sync_group_empty">No storages in this group</string>
<string name="sync_remove_group">Удалить группу</string> <string name="sync_remove_storage">Remove storage from group</string>
<string name="sync_group_empty">В группе нет хранилищ</string> <string name="sync_picker_back">Back</string>
<string name="sync_remove_storage">Убрать хранилище из группы</string> <string name="sync_cd_picker_back">Close storage picker</string>
<string name="sync_picker_back">Назад</string> <string name="sync_picker_title">Pick storage for %1$s</string>
<string name="sync_cd_picker_back">Закрыть выбор хранилища</string> <string name="sync_picker_add">Add</string>
<string name="sync_picker_title">Выбор хранилища для %1$s</string> <string name="sync_picker_added">Added</string>
<string name="sync_picker_add">Добавить</string> <string name="sync_picker_cd_add">Add storage to group</string>
<string name="sync_picker_added">Добавлено</string> <string name="sync_picker_no_storages">No available folders in this vault</string>
<string name="sync_picker_cd_add">Добавить хранилище в группу</string> <string name="sync_picker_expand">Expand</string>
<string name="sync_picker_no_storages">В этом хранилище нет доступных каталогов</string> <string name="sync_picker_collapse">Collapse</string>
<string name="sync_picker_expand">Развернуть</string> <string name="sync_fab_create_group_cd">Create sync group</string>
<string name="sync_picker_collapse">Свернуть</string> <string name="sync_group_mixed_encryption_warning">Mixed encryption in group: set a single mode</string>
<string name="sync_fab_create_group_cd">Создать группу синхронизации</string> <string name="sync_group_incompatible_warning">Incompatible storages in group: %1$d</string>
<string name="sync_group_mixed_encryption_warning">В группе разное шифрование: задайте единый режим</string> <string name="sync_group_policy_line">Group encryption policy: %1$s</string>
<string name="sync_group_incompatible_warning">Несовместимые хранилища в группе: %1$d</string> <string name="sync_group_policy_unset">Not set (group is empty)</string>
<string name="sync_group_policy_line">Политика шифрования группы: %1$s</string> <string name="sync_group_policy_plain">Unencrypted only</string>
<string name="sync_group_policy_unset">Не определена (группа пуста)</string> <string name="sync_group_policy_password">Password-encrypted with group password</string>
<string name="sync_group_policy_plain">Только незашифрованные</string> <string name="sync_remove_group_confirm_title">Remove group?</string>
<string name="sync_group_policy_password">Только зашифрованные с паролем группы</string> <string name="sync_remove_group_confirm_message">Remove sync group "%1$s"?</string>
<string name="sync_remove_group_confirm_title">Удалить группу?</string> <string name="sync_remove_storage_confirm_title">Remove storage?</string>
<string name="sync_remove_group_confirm_message">Удалить группу синхронизации «%1$s»?</string> <string name="sync_remove_storage_confirm_message">Remove storage "%1$s" from the group?</string>
<string name="sync_remove_storage_confirm_title">Убрать хранилище?</string> <string name="sync_confirm_delete">Delete</string>
<string name="sync_remove_storage_confirm_message">Убрать хранилище «%1$s» из группы?</string> <string name="sync_cancel">Cancel</string>
<string name="sync_confirm_delete">Удалить</string> <string name="sync_msg_group_created">Created group %1$s</string>
<string name="sync_cancel">Отмена</string> <string name="sync_msg_group_removed">Group removed</string>
<string name="sync_msg_group_created">Создана группа %1$s</string> <string name="sync_msg_storage_added">Storage added to %1$s</string>
<string name="sync_msg_group_removed">Группа удалена</string> <string name="sync_msg_storage_removed">Storage removed from %1$s</string>
<string name="sync_msg_storage_added">Хранилище добавлено в %1$s</string> <string name="sync_msg_storage_already_added">Storage is already in the group</string>
<string name="sync_msg_storage_removed">Хранилище убрано из %1$s</string> <string name="sync_msg_only_plain_storage_allowed">Only unencrypted storages can be added to sync groups</string>
<string name="sync_msg_storage_already_added">Хранилище уже добавлено в группу</string> <string name="sync_msg_storage_encryption_key_required">Encrypted storage requires the password (open it before adding)</string>
<string name="sync_msg_only_plain_storage_allowed">В группы синхронизации можно добавлять только незашифрованные хранилища</string> <string name="sync_msg_storage_incompatible_encryption">Storage is not compatible with the group encryption policy</string>
<string name="sync_msg_storage_encryption_key_required">Для зашифрованного хранилища нужно знать пароль (откройте его перед добавлением)</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_storage_incompatible_encryption">Хранилище не совместимо с политикой шифрования группы</string> <string name="sync_msg_task_enqueued">Sync task queued</string>
<string name="sync_msg_virtual_storage_not_supported">Нельзя добавлять открытое виртуальное хранилище: синхронизация работает с исходными raw storage</string> <string name="sync_msg_sync_already_running">Sync is already running</string>
<string name="sync_msg_task_enqueued">Задача синхронизации поставлена в очередь</string> <string name="sync_msg_blocked_during_sync">Wait for sync to finish</string>
<string name="sync_msg_sync_already_running">Синхронизация уже выполняется</string> <string name="sync_encryption_unknown">Unknown</string>
<string name="sync_msg_blocked_during_sync">Дождитесь окончания синхронизации</string> <string name="sync_storage_encryption_line">Encryption: %1$s</string>
<string name="sync_encryption_unknown">Неизвестно</string> <string name="sync_storage_missing_title">Not found in current vaults</string>
<string name="sync_storage_encryption_line">Шифрование: %1$s</string> <string name="sync_storage_pending_vault_scan">Waiting: vault storage list is still loading</string>
<string name="sync_storage_missing_title">Не найдено в текущих vault</string> <string name="sync_storage_not_in_vaults">Not in storage tree (removed, different account, or init failed)</string>
<string name="sync_storage_pending_vault_scan">Ожидание: список хранилищ в vault ещё загружается</string> <string name="sync_storage_unreachable">Storage unavailable (vault or network)</string>
<string name="sync_storage_not_in_vaults">Нет в дереве хранилищ (удалено, другой аккаунт или не прошёл init)</string> <string name="no_name">&lt;no name&gt;</string>
<string name="sync_storage_unreachable">Хранилище недоступно (vault или сеть)</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="no_name">&lt;без имени&gt;</string> <string name="storage_menu_busy">%1$s (task running)</string>
<string name="show_storage_item_menu">Меню хранилища</string> <string name="rename">Rename</string>
<string name="storage_row_task_running_cd">Выполняется операция с этим хранилищем</string> <string name="remove">Remove</string>
<string name="storage_menu_busy">%1$s (задача выполняется)</string> <string name="encrypt">Encryption</string>
<string name="rename">Переименовать</string> <string name="new_name_title">New name</string>
<string name="remove">Удалить</string> <string name="remove_confirmation_dialog">Remove storage "%1$s"?</string>
<string name="encrypt">Шифрование</string> <string name="storage_lock_actions">Encryption actions</string>
<string name="new_name_title">Новое имя</string> <string name="storage_sync_lock_checking">Checking lock…</string>
<string name="remove_confirmation_dialog">Удалить хранилище «%1$s»?</string> <string name="storage_sync_unlock_action">Clear sync lock</string>
<string name="storage_lock_actions">Действия с шифрованием</string> <string name="storage_sync_not_locked">Sync is not locked</string>
<string name="storage_sync_lock_checking">Проверка блокировки…</string> <string name="storage_field_available">Available: %1$s</string>
<string name="storage_sync_unlock_action">Снять блокировку синхронизации</string> <string name="storage_value_yes">yes</string>
<string name="storage_sync_not_locked">Синхронизация не заблокирована</string> <string name="storage_value_no">no</string>
<string name="storage_field_files">Files: %1$s</string>
<string name="storage_field_available">Доступно: %1$s</string> <string name="storage_field_size">Size: %1$s</string>
<string name="storage_value_yes">да</string> <string name="storage_field_virtual">Virtual: %1$s</string>
<string name="storage_value_no">нет</string> <string name="storage_unavailable_hint">Storage unavailable</string>
<string name="storage_field_files">Файлов: %1$s</string> <string name="storage_menu_unavailable">Unavailable: %1$s</string>
<string name="storage_field_size">Размер: %1$s</string> <string name="storage_status_not_encrypted">Not encrypted</string>
<string name="storage_field_virtual">Виртуальное: %1$s</string> <string name="storage_status_encrypted_open">Encrypted (open)</string>
<string name="storage_unavailable_hint">Хранилище недоступно</string> <string name="storage_status_encrypted_closed">Encrypted (locked)</string>
<string name="storage_menu_unavailable">Недоступно: %1$s</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="storage_status_not_encrypted">Не зашифровано</string> <string name="vault_fab_add_storage_busy_cd">Storage creation already running</string>
<string name="storage_status_encrypted_open">Зашифровано (открыто)</string> <string name="vault_msg_storage_pipeline_busy">An operation is already running for this storage</string>
<string name="storage_status_encrypted_closed">Зашифровано (закрыто)</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_fab_add_storage_cd">Создать хранилище</string> <string name="vault_loading_storages">Loading storage list…</string>
<string name="vault_fab_add_storage_disabled_cd">Создание недоступно: хранилище недоступно</string> <string name="vault_empty_list_hint">No folders yet. Create storage with "+" when available.</string>
<string name="vault_fab_add_storage_busy_cd">Создание хранилища уже выполняется</string> <string name="task_pipeline_title">Task queue</string>
<string name="vault_msg_storage_pipeline_busy">С этим хранилищем уже выполняется операция</string> <string name="task_pipeline_jobs">Tasks</string>
<string name="vault_msg_vault_list_mutation_busy">Список хранилищ сейчас меняется — подождите</string> <string name="task_pipeline_log">Log</string>
<string name="vault_unavailable_banner">Хранилище недоступно. Проверьте сеть, путь или разблокировку.</string> <string name="task_pipeline_cancel_all">Cancel all</string>
<string name="vault_loading_storages">Загрузка списка хранилищ…</string> <string name="task_pipeline_open">Open task queue</string>
<string name="vault_empty_list_hint">В этом хранилище пока нет каталогов. Создайте хранилище кнопкой «+», когда оно доступно.</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_title">Очередь задач</string> <string name="task_pipeline_test_dialog_duration">Duration: %1$d s</string>
<string name="task_pipeline_jobs">Задачи</string> <string name="task_pipeline_test_dialog_start">Start</string>
<string name="task_pipeline_log">Журнал</string> <string name="task_pipeline_test_dialog_cancel">Cancel</string>
<string name="task_pipeline_cancel_all">Отменить все</string> <string name="task_pipeline_test_dialog_infinity">Infinite (indeterminate progress)</string>
<string name="task_pipeline_open">Открыть очередь задач</string> <string name="task_pipeline_test_running">Test task (%1$d s)</string>
<string name="task_pipeline_run_test">Тестовая задача</string> <string name="task_pipeline_test_running_infinity">Test task (%1$d s, ∞)</string>
<string name="task_pipeline_test_dialog_title">Параметры тестовой задачи</string> <string name="task_state_queued">Queued</string>
<string name="task_pipeline_test_dialog_duration">Длительность: %1$d с</string> <string name="task_state_running">Running</string>
<string name="task_pipeline_test_dialog_start">Запустить</string> <string name="task_state_completed">Completed</string>
<string name="task_pipeline_test_dialog_cancel">Отмена</string> <string name="task_state_cancelled">Cancelled</string>
<string name="task_pipeline_test_dialog_infinity">Бесконечно (неопределённый прогресс)</string> <string name="task_state_failed">Error: %1$s</string>
<string name="task_pipeline_test_running">Тестовая задача (%1$d с)</string> <string name="task_title_dump_storage_log">Export tree to log</string>
<string name="task_pipeline_test_running_infinity">Тестовая задача (%1$d с, ∞)</string> <string name="task_title_create_storage">Create storage</string>
<string name="task_state_queued">В очереди</string> <string name="task_title_enable_encryption">Enable encryption</string>
<string name="task_state_running">Выполняется</string> <string name="task_title_open_encrypted_storage">Decrypt and open storage</string>
<string name="task_state_completed">Завершено</string> <string name="task_progress_decrypt_running">Decrypting…</string>
<string name="task_state_cancelled">Отменено</string> <string name="task_progress_dump_storage_log">Scanning tree…</string>
<string name="task_state_failed">Ошибка: %1$s</string> <string name="task_progress_create_storage">Creating storage…</string>
<string name="task_progress_enable_encryption">Encrypting…</string>
<string name="task_title_dump_storage_log">Выгрузка дерева в журнал</string> <string name="task_progress_close_storage">Closing storage…</string>
<string name="task_title_create_storage">Создание хранилища</string> <string name="task_progress_disable_encryption">Clearing content…</string>
<string name="task_title_enable_encryption">Включение шифрования</string> <string name="task_progress_rename_storage">Renaming…</string>
<string name="task_title_open_encrypted_storage">Расшифровка и открытие хранилища</string> <string name="task_progress_remove_storage">Removing…</string>
<string name="task_progress_decrypt_running">Расшифровка</string> <string name="task_progress_clear_sync_lock">Clearing sync lock</string>
<string name="task_progress_dump_storage_log">Сканирование дерева</string> <string name="task_progress_add_remote_vault">Adding</string>
<string name="task_progress_create_storage">Создание хранилища</string> <string name="task_progress_remove_remote_vault">Removing</string>
<string name="task_progress_enable_encryption">Шифрование</string> <string name="task_progress_retry_remote_vault">Connecting</string>
<string name="task_progress_close_storage">Закрытие хранилища</string> <string name="task_progress_save_2fa_token">Saving</string>
<string name="task_progress_disable_encryption">Очистка содержимого</string> <string name="task_progress_delete_2fa_token">Removing</string>
<string name="task_progress_rename_storage">Переименование</string> <string name="task_progress_save_text_secret">Saving</string>
<string name="task_progress_remove_storage">Удаление</string> <string name="task_progress_delete_text_secret">Removing</string>
<string name="task_progress_clear_sync_lock">Снятие блокировки…</string> <string name="task_title_close_encrypted_storage">Close encrypted storage</string>
<string name="task_progress_add_remote_vault">Добавление…</string> <string name="task_title_disable_encryption">Disable encryption</string>
<string name="task_progress_remove_remote_vault">Удаление…</string> <string name="task_title_rename_storage">Rename storage</string>
<string name="task_progress_retry_remote_vault">Подключение…</string> <string name="task_title_remove_storage">Remove storage</string>
<string name="task_progress_save_2fa_token">Сохранение…</string> <string name="task_title_clear_sync_lock">Clear sync lock</string>
<string name="task_progress_delete_2fa_token">Удаление…</string> <string name="task_title_add_remote_vault">Add remote vault</string>
<string name="task_progress_save_text_secret">Сохранение…</string> <string name="task_title_remove_remote_vault">Remove remote vault</string>
<string name="task_progress_delete_text_secret">Удаление…</string> <string name="task_title_retry_remote_vault">Retry remote vault connection</string>
<string name="task_title_close_encrypted_storage">Закрытие зашифрованного хранилища</string> <string name="task_title_storage_sync">Storage sync</string>
<string name="task_title_disable_encryption">Отключение шифрования</string> <string name="task_title_storage_sync_background">Background storage sync</string>
<string name="task_title_rename_storage">Переименование хранилища</string> <string name="task_title_save_2fa_token">Save 2FA token</string>
<string name="task_title_remove_storage">Удаление хранилища</string> <string name="task_title_delete_2fa_token">Delete 2FA token</string>
<string name="task_title_clear_sync_lock">Снятие блокировки синхронизации</string> <string name="task_title_save_text_secret">Save text secret</string>
<string name="task_title_add_remote_vault">Добавление удалённого хранилища</string> <string name="task_title_delete_text_secret">Delete text secret</string>
<string name="task_title_remove_remote_vault">Удаление удалённого хранилища</string> <string name="error_storage_not_found">Storage not found</string>
<string name="task_title_retry_remote_vault">Повторное подключение удалённого хранилища</string> <string name="error_storage_locked_view">Open decrypted storage view to use this section</string>
<string name="task_title_storage_sync">Синхронизация хранилищ</string> <string name="error_secret_not_found">Secret not found</string>
<string name="task_title_storage_sync_background">Фоновая синхронизация хранилищ</string> <string name="error_storage_not_writable">Storage is not writable</string>
<string name="task_title_save_2fa_token">Сохранение 2FA токена</string> <string name="error_file_not_found">File not found</string>
<string name="task_title_delete_2fa_token">Удаление 2FA токена</string> <string name="error_incorrect_password">Incorrect password</string>
<string name="task_title_save_text_secret">Сохранение текстового секрета</string> <string name="error_storage_not_encrypted">Storage is not encrypted</string>
<string name="task_title_delete_text_secret">Удаление текстового секрета</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_storage_not_found">Хранилище не найдено</string> <string name="error_not_a_file">Expected a file</string>
<string name="error_storage_locked_view">Откройте расшифрованное отображение storage для работы с этим разделом</string> <string name="error_not_a_directory">Expected a folder</string>
<string name="error_secret_not_found">Секрет не найден</string> <string name="error_path_is_file">Path points to a file, not a folder</string>
<string name="error_storage_not_writable">Хранилище недоступно для записи</string> <string name="error_cannot_write_over_directory">Cannot write over a folder</string>
<string name="error_file_not_found">Файл не найден</string> <string name="error_unexpected_state">Storage in unexpected state</string>
<string name="error_incorrect_password">Неверный пароль</string> <string name="error_network">Network or server error</string>
<string name="error_storage_not_encrypted">Хранилище не зашифровано</string> <string name="error_disk_resource_locked">Resource is temporarily locked. Try again later.</string>
<string name="error_enc_info_missing">Отсутствуют метаданные шифрования</string> <string name="error_unknown">Something went wrong</string>
<string name="error_delete_root_forbidden">Нельзя удалить корень хранилища</string> <string name="sync_error_group_not_found">Sync group not found</string>
<string name="error_not_a_file">Ожидался файл</string> <string name="vault_link_error_auth">Sign-in failed</string>
<string name="error_not_a_directory">Ожидалась папка</string> <string name="vault_link_error_not_registered">Sign-in is not ready. Restart the app.</string>
<string name="error_path_is_file">Путь указывает на файл, а не на папку</string> <string name="vault_link_error_unknown">Sign-in failed</string>
<string name="error_cannot_write_over_directory">Нельзя записать поверх папки</string> <string name="vault_link_error_unsupported_brand">This provider is not supported</string>
<string name="error_unexpected_state">Хранилище в неожиданном состоянии</string> <string name="msg_encryption_enabled">Encryption enabled</string>
<string name="error_network">Ошибка сети или сервера</string> <string name="msg_storage_already_encrypted">Storage is already encrypted</string>
<string name="error_disk_resource_locked">Ресурс временно заблокирован. Повторите позже.</string> <string name="msg_storage_not_empty">Storage is not empty</string>
<string name="error_unknown">Что-то пошло не так</string> <string name="msg_storage_empty_state_unknown">Could not determine if storage is empty</string>
<string name="sync_error_group_not_found">Группа синхронизации не найдена</string> <string name="msg_unsupported_storage_type">Unsupported storage type</string>
<string name="vault_link_error_auth">Не удалось войти</string> <string name="msg_encryption_disabled">Encryption disabled</string>
<string name="vault_link_error_not_registered">Вход не готов. Перезапустите приложение.</string> <string name="msg_invalid_storage_for_sync_lock">Invalid storage</string>
<string name="vault_link_error_unknown">Не удалось войти</string> <string name="msg_sync_lock_cleared">Sync lock cleared</string>
<string name="vault_link_error_unsupported_brand">Этот провайдер не поддерживается</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="msg_encryption_enabled">Шифрование включено</string> <string name="remote_vaults_add_title">Add vault</string>
<string name="msg_storage_already_encrypted">Хранилище уже зашифровано</string> <string name="remote_vaults_add_pick_provider">Choose a provider:</string>
<string name="msg_storage_not_empty">Хранилище не пустое</string> <string name="remote_vaults_provider_yandex">Yandex</string>
<string name="msg_storage_empty_state_unknown">Не удалось определить, пусто ли хранилище</string> <string name="remote_vaults_add_cancel">Cancel</string>
<string name="msg_unsupported_storage_type">Неподдерживаемый тип хранилища</string> <string name="remote_vault_type_yandex">Yandex</string>
<string name="msg_failed_enable_encryption">Не удалось включить шифрование: %1$s</string> <string name="remote_vault_unavailable">Vault temporarily unavailable</string>
<string name="msg_failed_open_storage">Не удалось открыть хранилище: %1$s</string> <string name="remote_vault_retry_action">Retry connection</string>
<string name="msg_failed_close_storage">Не удалось закрыть хранилище: %1$s</string> <string name="remote_vault_retrying">Connecting…</string>
<string name="msg_encryption_disabled">Шифрование отключено</string> <string name="remote_vault_delete_cd">Remove remote vault</string>
<string name="msg_failed_disable_encryption">Не удалось отключить шифрование: %1$s</string> <string name="remote_vault_remove_title">Remove remote vault?</string>
<string name="msg_invalid_storage_for_sync_lock">Некорректное хранилище</string> <string name="remote_vault_remove_message">Remove "%1$s" from this device? Server data is not deleted.</string>
<string name="msg_sync_lock_cleared">Блокировка синхронизации снята</string> <string name="dialog_cancel">Cancel</string>
<string name="msg_sync_lock_clear_failed">Не удалось снять блокировку: %1$s</string> <string name="dialog_ok">OK</string>
<string name="dialog_encryption_enable_title">Enable encryption</string>
<string name="remote_vaults_add_cd">Добавить удалённое хранилище</string> <string name="dialog_password_label">Password</string>
<string name="remote_vaults_empty_hint">Пока нет удалённых хранилищ. Нажмите «+», чтобы добавить Yandex.</string> <string name="dialog_encrypt_paths">Encrypt paths</string>
<string name="remote_vaults_add_title">Добавить хранилище</string> <string name="dialog_apply">Apply</string>
<string name="remote_vaults_add_pick_provider">Выберите провайдера:</string> <string name="dialog_open_encrypted_title">Open encrypted storage</string>
<string name="remote_vaults_provider_yandex">Яндекс</string> <string name="dialog_remember_password">Remember password</string>
<string name="remote_vaults_add_cancel">Отмена</string> <string name="dialog_open">Open</string>
<string name="remote_vault_type_yandex">Яндекс</string> <string name="dialog_close">Close</string>
<string name="remote_vault_unavailable">Хранилище временно недоступно</string> <string name="dialog_disable_encryption">Disable encryption</string>
<string name="remote_vault_retry_action">Повторить подключение</string> <string name="dialog_done">Done</string>
<string name="remote_vault_retrying">Пробую подключиться…</string> <string name="vault_type_local_device">Local device</string>
<string name="remote_vault_delete_cd">Удалить удалённое хранилище</string> <string name="vault_type_remote">Remote: %1$s</string>
<string name="remote_vault_remove_title">Удалить удалённое хранилище?</string> <string name="vault_type_unknown">Unknown type</string>
<string name="remote_vault_remove_message">Удалить «%1$s» с этого устройства? Данные на сервере не удаляются.</string> <string name="vault_title_local">Local vault</string>
<string name="vault_title_unknown">Unknown vault</string>
<string name="dialog_cancel">Отмена</string> <string name="enc_status_not_encrypted">Not encrypted</string>
<string name="dialog_ok">ОК</string> <string name="enc_status_encrypted_open">Encrypted (open)</string>
<string name="dialog_encryption_enable_title">Включить шифрование</string> <string name="enc_status_encrypted">Encrypted</string>
<string name="dialog_password_label">Пароль</string> <string name="text_edit_screen_title">Text</string>
<string name="dialog_encrypt_paths">Шифровать пути</string> <string name="text_edit_screen_placeholder">Content: %1$s</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_unnamed_storage">Storage</string>
<string name="storage_home_status_line">Статус: %1$s, %2$s</string> <string name="storage_home_status_line">Status: %1$s, %2$s</string>
<string name="storage_home_status_available">доступно</string> <string name="storage_home_status_available">available</string>
<string name="storage_home_status_unavailable">недоступно</string> <string name="storage_home_status_unavailable">unavailable</string>
<string name="storage_home_status_encrypted">зашифровано</string> <string name="storage_home_status_encrypted">encrypted</string>
<string name="storage_home_status_not_encrypted">не зашифровано</string> <string name="storage_home_status_not_encrypted">not encrypted</string>
<string name="storage_home_two_fa_title">2FA токены (%1$d)</string> <string name="storage_home_two_fa_title">2FA tokens (%1$d)</string>
<string name="storage_home_open_two_fa">Открыть 2FA</string> <string name="storage_home_open_two_fa">Open 2FA</string>
<string name="storage_home_two_fa_subtitle">Коды и секреты двухфакторной аутентификации</string> <string name="storage_home_two_fa_subtitle">Two-factor authentication codes and secrets</string>
<string name="storage_home_text_secrets_title">Текстовые секреты (%1$d)</string> <string name="storage_home_text_secrets_title">Text secrets (%1$d)</string>
<string name="storage_home_open_text_secrets">Открыть текстовые секреты</string> <string name="storage_home_open_text_secrets">Open text secrets</string>
<string name="storage_home_text_secrets_subtitle">Заметки, токены и произвольные пары ключ-значение</string> <string name="storage_home_text_secrets_subtitle">Notes, tokens, and arbitrary key-value pairs</string>
<string name="storage_home_future_sections">Скоро здесь появятся Files, Media и другие типы данных.</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_add_token">Добавить токен</string> <string name="two_fa_empty_state">No 2FA tokens yet</string>
<string name="two_fa_empty_state">Пока нет 2FA токенов</string> <string name="two_fa_create_title">New 2FA token</string>
<string name="two_fa_create_title">Новый 2FA токен</string> <string name="two_fa_edit_title">Edit 2FA token</string>
<string name="two_fa_edit_title">Редактирование 2FA токена</string> <string name="two_fa_field_issuer">Service</string>
<string name="two_fa_field_issuer">Сервис</string> <string name="two_fa_field_account">Account</string>
<string name="two_fa_field_account">Аккаунт</string> <string name="two_fa_field_secret">Secret</string>
<string name="two_fa_field_secret">Секрет</string> <string name="two_fa_field_notes_optional">Note (optional)</string>
<string name="two_fa_field_notes_optional">Заметка (опционально)</string> <string name="two_fa_field_digits">Code digits (usually 6 or 8)</string>
<string name="two_fa_field_digits">Количество цифр кода (обычно 6 или 8)</string> <string name="two_fa_field_period_seconds">Refresh period in seconds (usually 30)</string>
<string name="two_fa_field_period_seconds">Период обновления в секундах (обычно 30)</string> <string name="two_fa_field_algorithm">Algorithm (SHA1, SHA256, SHA512)</string>
<string name="two_fa_field_algorithm">Алгоритм (SHA1, SHA256, SHA512)</string> <string name="two_fa_field_digits_value">Digits: %1$d</string>
<string name="two_fa_field_digits_value">Количество цифр: %1$d</string> <string name="two_fa_field_period_seconds_value">Refresh period: %1$d s</string>
<string name="two_fa_field_period_seconds_value">Период обновления: %1$d с</string>
<string name="two_fa_code_unavailable">------</string> <string name="two_fa_code_unavailable">------</string>
<string name="two_fa_code_refresh_in">Обновление через %1$d с</string> <string name="two_fa_code_refresh_in">Refresh in %1$d s</string>
<string name="two_fa_code_refresh_label">Обновление через</string> <string name="two_fa_code_refresh_label">Refresh in</string>
<string name="two_fa_code_refresh_seconds">%1$d с</string> <string name="two_fa_code_refresh_seconds">%1$d s</string>
<string name="two_fa_code_invalid_secret">Неверный секрет или формат</string> <string name="two_fa_code_invalid_secret">Invalid secret or format</string>
<string name="two_fa_copy_code_hint">Нажмите, чтобы скопировать код</string> <string name="two_fa_copy_code_hint">Tap to copy code</string>
<string name="two_fa_scan_qr_action">Сканировать QR</string> <string name="two_fa_scan_qr_action">Scan QR</string>
<string name="two_fa_scan_qr_title">Сканирование QR-кода TOTP</string> <string name="two_fa_scan_qr_title">Scan TOTP QR code</string>
<string name="two_fa_scan_qr_invalid">QR-код не содержит валидный otpauth://totp URI</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">Нужно разрешение на камеру для сканирования QR</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_create">Создать секрет</string> <string name="text_secret_edit">Edit secret</string>
<string name="text_secret_edit">Редактировать секрет</string> <string name="text_secret_title">Title</string>
<string name="text_secret_title">Название</string> <string name="text_secret_empty_state">No text secrets yet</string>
<string name="text_secret_empty_state">Пока нет текстовых секретов</string> <string name="text_secret_items_count">Items: %1$d</string>
<string name="text_secret_items_count">Элементов: %1$d</string> <string name="text_secret_item_without_label">Untitled</string>
<string name="text_secret_item_without_label">Без названия</string> <string name="text_secret_more_fields">more fields: %1$d</string>
<string name="text_secret_more_fields">ещё полей: %1$d</string> <string name="text_secret_item_label_optional">Label (optional)</string>
<string name="text_secret_item_label_optional">Название (опционально)</string> <string name="text_secret_item_value">Value</string>
<string name="text_secret_item_value">Значение</string> <string name="text_secret_add_item">Add field</string>
<string name="text_secret_add_item">Добавить пару</string> <string name="text_secret_copy_value">Copy value</string>
<string name="text_secret_copy_value">Скопировать значение</string> <string name="save">Save</string>
<string name="cancel">Cancel</string>
<string name="save">Сохранить</string> <string name="open">Open</string>
<string name="cancel">Отмена</string> <string name="edit">Edit</string>
<string name="open">Открыть</string> <string name="common_unknown">Unknown</string>
<string name="edit">Редактировать</string> <string name="settings_language_section">Language</string>
<string name="settings_language_system">System default</string>
<string name="common_unknown">Неизвестно</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> </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.interfaces.IStorageSyncEngine
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskId 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.TaskLogLevel
import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -28,6 +30,7 @@ class RunStorageSyncUseCase(
* @param logReason техническая метка для логов (не для UI) * @param logReason техническая метка для логов (не для UI)
* @return false, если синхронизация уже в очереди или выполняется — новая задача не создана * @return false, если синхронизация уже в очереди или выполняется — новая задача не создана
*/ */
@Suppress("UNUSED_PARAMETER")
fun enqueue(displayTitle: String, logReason: String): Boolean { fun enqueue(displayTitle: String, logReason: String): Boolean {
if (!running.compareAndSet(false, true)) { if (!running.compareAndSet(false, true)) {
return false return false
@@ -39,17 +42,16 @@ class RunStorageSyncUseCase(
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
work = { ctx -> work = { ctx ->
try { try {
ctx.log(TaskLogLevel.Info, "Storage sync started, reason=$logReason") ctx.log(TaskLogLevel.Info, TaskLogKey.SyncStarted)
ctx.reportProgress(null, "Storage sync: started") ctx.reportProgress(null, TaskProgressLabel.SyncStarted)
syncEngine.syncAllGroups { fraction, label -> syncEngine.syncAllGroups { fraction, label ->
ctx.reportProgress(fraction, label) ctx.reportProgress(fraction, label)
} }
ctx.log(TaskLogLevel.Info, "Storage sync finished") ctx.log(TaskLogLevel.Info, TaskLogKey.SyncFinished)
ctx.reportProgress(null, "Storage sync: completed") ctx.reportProgress(null, TaskProgressLabel.SyncCompleted)
} catch (e: Exception) { } catch (e: Exception) {
val err = e.toWallencException() val err = e.toWallencException()
ctx.log(TaskLogLevel.Error, "Storage sync failed: $err") ctx.log(TaskLogLevel.Error, TaskLogKey.SyncFailed(err))
ctx.reportProgress(null, "Storage sync: failed - $err")
ctx.fail(err) ctx.fail(err)
} finally { } finally {
running.set(false) running.set(false)

View File

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