Compare commits

..

8 Commits

Author SHA1 Message Date
08caf08fad Обновлён gradle 2026-05-21 11:40:31 +03:00
7dd4a43c3d Плавный прогрессбар 2fa 2026-05-21 11:12:39 +03:00
671f1f1c2a fix(ui): улучшил vault/sync UX и подписи прогресса
Rescan в заголовке vault, sync-кнопка только при скане релевантных vault,
блокировка UI при недоступных meta, remember/open после encrypt,
убрал … из task_progress (точки остаются в foreground-сервисе).
2026-05-21 11:05:25 +03:00
467ed64426 fix(vault): исправил шифрование, meta Yandex и enc-meta при первом открытии
Remember key после encrypt, мягкий auto-open в UnlockManager,
StorageMetaLoadState без затирания meta на сетевых ошибках,
фильтр storages в YandexVault и создание .enc-meta при FileNotFound.
2026-05-21 11:05:14 +03:00
da8b970078 fix(sync): обработал отсутствие journal и lock при синхронизации
Добавил readSystemFileBytesOrEmpty и подключил в Local/Yandex accessors,
чтобы фоновый sync не падал с FileNotFound на пустых journal/lock.
2026-05-21 11:05:04 +03:00
c58bcdc35b Сильно улучшен UX при работе с Yandex vault 2026-05-21 01:40:30 +03:00
9c38da76d2 Красивый UI 2026-05-21 01:10:55 +03:00
184edc0b67 Иконка приложения 2026-05-21 00:37:18 +03:00
57 changed files with 1265 additions and 605 deletions

View File

@@ -150,7 +150,7 @@ class TaskPipelineForegroundService : Service() {
NotificationCompat.Builder(this, WallencExternalLaunch.ForegroundTaskPipelineNotification.CHANNEL_ID) NotificationCompat.Builder(this, WallencExternalLaunch.ForegroundTaskPipelineNotification.CHANNEL_ID)
.setContentTitle(getString(R.string.task_notification_title)) .setContentTitle(getString(R.string.task_notification_title))
.setContentText(getString(R.string.task_notification_preparing)) .setContentText(getString(R.string.task_notification_preparing))
.setSmallIcon(android.R.drawable.stat_sys_download) .setSmallIcon(R.drawable.ic_stat_wallenc)
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setContentIntent(openTaskPipelinePendingIntent()) .setContentIntent(openTaskPipelinePendingIntent())
@@ -187,7 +187,7 @@ class TaskPipelineForegroundService : Service() {
) )
.setContentTitle(getString(R.string.task_notification_title)) .setContentTitle(getString(R.string.task_notification_title))
.setContentText(collapsedSubtext) .setContentText(collapsedSubtext)
.setSmallIcon(android.R.drawable.stat_sys_download) .setSmallIcon(R.drawable.ic_stat_wallenc)
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setContentIntent(openTaskPipelinePendingIntent()) .setContentIntent(openTaskPipelinePendingIntent())

View File

@@ -1,170 +1,25 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<path <path android:pathData="M0,0h108v108h-108z">
android:fillColor="#3DDC84" <aapt:attr name="android:fillColor">
android:pathData="M0,0h108v108h-108z" /> <gradient
<path android:endX="92"
android:fillColor="#00000000" android:endY="100"
android:pathData="M9,0L9,108" android:startX="16"
android:strokeWidth="0.8" android:startY="8"
android:strokeColor="#33FFFFFF" /> android:type="linear">
<path <item
android:fillColor="#00000000" android:color="@color/launcher_background"
android:pathData="M19,0L19,108" android:offset="0" />
android:strokeWidth="0.8" <item
android:strokeColor="#33FFFFFF" /> android:color="@color/launcher_background_dark"
<path android:offset="1" />
android:fillColor="#00000000" </gradient>
android:pathData="M29,0L29,108" </aapt:attr>
android:strokeWidth="0.8" </path>
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector> </vector>

View File

@@ -1,30 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z"> <!-- Shackle -->
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path <path
android:fillColor="#FFFFFF" android:fillColor="#FFFFFF"
android:fillType="nonZero" android:pathData="M44,50 C44,40 54,35 54,35 C64,35 64,50 64,50 L60,50 C60,43 54,39 54,39 C48,39 44,43 44,50 Z" />
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z" <!-- Lock body: three bricks -->
android:strokeWidth="1" <path
android:strokeColor="#00000000" /> android:fillColor="#FFFFFF"
</vector> android:pathData="M42,52 H66 V59 H42 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M42,61 H66 V68 H42 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M42,70 H66 V78 C66,80 64,82 54,82 C44,82 42,80 42,78 Z" />
<!-- Side wall bricks -->
<path
android:fillColor="#FFFFFF"
android:pathData="M30,56 H38 V63 H30 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M30,66 H38 V73 H30 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M70,56 H78 V63 H70 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M70,66 H78 V73 H70 Z" />
</vector>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M44,50 C44,40 54,35 54,35 C64,35 64,50 64,50 L60,50 C60,43 54,39 54,39 C48,39 44,43 44,50 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M42,52 H66 V59 H42 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M42,61 H66 V68 H42 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M42,70 H66 V78 C66,80 64,82 54,82 C44,82 42,80 42,78 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M30,56 H38 V63 H30 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M30,66 H38 V73 H30 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M70,56 H78 V63 H70 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M70,66 H78 V73 H70 Z" />
</vector>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M9,10 C9,8 10.5,7 12,7 C13.5,7 15,8 15,10 L14,10 C14,8.9 13.1,8 12,8 C10.9,8 10,8.9 10,10 Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M9,11 H15 V13 H9 Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M9,14 H15 V16 H9 Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M9,17 H15 V19 C15,19.6 14.6,20 12,20 C9.4,20 9,19.6 9,19 Z" />
</vector>

View File

@@ -2,5 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" /> <monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon> </adaptive-icon>

View File

@@ -2,5 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" /> <monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon> </adaptive-icon>

View File

@@ -1,4 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="splash_screen_background">#FFFFFFFF</color> <color name="splash_screen_background">#FFF7F2FF</color>
<color name="launcher_background">#FF6650A4</color>
<color name="launcher_background_dark">#FF4A3F7A</color>
</resources> </resources>

View File

@@ -7,5 +7,11 @@
<item name="android:windowSplashScreenBackground" tools:targetApi="31"> <item name="android:windowSplashScreenBackground" tools:targetApi="31">
@color/splash_screen_background @color/splash_screen_background
</item> </item>
<item name="android:windowSplashScreenAnimatedIcon" tools:targetApi="31">
@drawable/ic_launcher_foreground
</item>
<item name="android:windowSplashScreenIconBackgroundColor" tools:targetApi="31">
@color/launcher_background
</item>
</style> </style>
</resources> </resources>

View File

@@ -58,11 +58,17 @@ class UnlockManager(
allStorages.removeAt(allStorages.size - 1) allStorages.removeAt(allStorages.size - 1)
allStorages.add(encStorage) allStorages.add(encStorage)
} }
catch (_: Exception) { catch (e: WallencException.Storage.IncorrectKey) {
// ключ не подошёл
keysToRemove.add(key) keysToRemove.add(key)
allStorages.removeAt(allStorages.size - 1) allStorages.removeAt(allStorages.size - 1)
} }
catch (_: WallencException.Storage.EncInfoMissing) {
keysToRemove.add(key)
allStorages.removeAt(allStorages.size - 1)
}
catch (_: Exception) {
allStorages.removeAt(allStorages.size - 1)
}
} }
keymapRepository.delete(*keysToRemove.toTypedArray()) // удалить мёртвые ключи keymapRepository.delete(*keysToRemove.toTypedArray()) // удалить мёртвые ключи
_openedStorages.value = map.toMap() _openedStorages.value = map.toMap()
@@ -104,6 +110,21 @@ class UnlockManager(
return UUID(bb.long, bb.long) return UUID(bb.long, bb.long)
} }
override suspend fun rememberKey(storage: IStorage, key: EncryptKey) = withContext(ioDispatcher) {
mutex.withLock {
val encInfo = storage.metaInfo.value.encInfo ?: throw WallencException.Storage.EncInfoMissing()
if (!Encryptor.checkKey(key, encInfo)) {
throw WallencException.Storage.IncorrectKey()
}
keymapRepository.add(
StorageKeyMap(
sourceUuid = storage.uuid,
key = key,
),
)
}
}
override suspend fun open( override suspend fun open(
storage: IStorage, storage: IStorage,
key: EncryptKey, key: EncryptKey,

View File

@@ -3,6 +3,8 @@ package com.github.nullptroma.wallenc.domain.vault.storages.common
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.github.nullptroma.wallenc.domain.common.impl.CommonStorageMetaInfo import com.github.nullptroma.wallenc.domain.common.impl.CommonStorageMetaInfo
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.datatypes.StorageMetaLoadState
import com.github.nullptroma.wallenc.domain.errors.WallencException
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.IStorageMetaInfo import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
@@ -38,6 +40,10 @@ abstract class BaseStorage(
final override val metaInfo: StateFlow<IStorageMetaInfo> final override val metaInfo: StateFlow<IStorageMetaInfo>
get() = _metaInfo get() = _metaInfo
private val _metaLoadState = MutableStateFlow(StorageMetaLoadState.Loading)
final override val metaLoadState: StateFlow<StorageMetaLoadState>
get() = _metaLoadState
final override val size: StateFlow<Long?> final override val size: StateFlow<Long?>
get() = accessor.size get() = accessor.size
@@ -70,19 +76,39 @@ abstract class BaseStorage(
} }
private suspend fun readMetaInfo() = withContext(ioDispatcher) { private suspend fun readMetaInfo() = withContext(ioDispatcher) {
var meta: CommonStorageMetaInfo val (meta, state) = loadMetaFromDisk()
var reader: InputStream? = null
try {
reader = accessor.openReadSystemFile(metaInfoFileName)
meta = jackson.readValue(reader, CommonStorageMetaInfo::class.java)
} catch (_: Exception) {
// чтение не удалось — пишем дефолт, чтобы файл появился
meta = CommonStorageMetaInfo()
updateMetaInfo(meta)
} finally {
reader?.close()
}
_metaInfo.value = meta _metaInfo.value = meta
_metaLoadState.value = state
}
private suspend fun loadMetaFromDisk(): Pair<IStorageMetaInfo, StorageMetaLoadState> {
return try {
val bytes = accessor.openReadSystemFile(metaInfoFileName).use { it.readBytes() }
when {
bytes.isEmpty() -> {
val default = CommonStorageMetaInfo()
updateMetaInfo(default)
default to StorageMetaLoadState.Ready
}
else -> try {
jackson.readValue(bytes, CommonStorageMetaInfo::class.java) to StorageMetaLoadState.Ready
} catch (_: Exception) {
CommonStorageMetaInfo() to StorageMetaLoadState.Unavailable
}
}
} catch (_: WallencException.Storage.FileNotFound) {
val default = CommonStorageMetaInfo()
updateMetaInfo(default)
default to StorageMetaLoadState.Ready
} catch (_: Exception) {
CommonStorageMetaInfo() to StorageMetaLoadState.Unavailable
}
}
private suspend fun requireMetaReady() {
if (_metaLoadState.value != StorageMetaLoadState.Ready) {
throw WallencException.Storage.NotAvailable()
}
} }
private suspend fun updateMetaInfo(meta: IStorageMetaInfo) = withContext(ioDispatcher) { private suspend fun updateMetaInfo(meta: IStorageMetaInfo) = withContext(ioDispatcher) {
@@ -94,6 +120,7 @@ abstract class BaseStorage(
} }
final override suspend fun rename(newName: String) = withContext(ioDispatcher) { final override suspend fun rename(newName: String) = withContext(ioDispatcher) {
requireMetaReady()
val cur = metaInfo.value val cur = metaInfo.value
updateMetaInfo( updateMetaInfo(
CommonStorageMetaInfo( CommonStorageMetaInfo(
@@ -104,6 +131,7 @@ abstract class BaseStorage(
} }
final override suspend fun setEncInfo(encInfo: StorageEncryptionInfo?) = withContext(ioDispatcher) { final override suspend fun setEncInfo(encInfo: StorageEncryptionInfo?) = withContext(ioDispatcher) {
requireMetaReady()
val cur = metaInfo.value val cur = metaInfo.value
updateMetaInfo( updateMetaInfo(
CommonStorageMetaInfo( CommonStorageMetaInfo(

View File

@@ -0,0 +1,12 @@
package com.github.nullptroma.wallenc.domain.vault.storages.common
import com.github.nullptroma.wallenc.domain.errors.WallencException
import java.io.InputStream
/** Читает системный файл; отсутствие файла — пустой массив байт (не исключение). */
internal suspend fun readSystemFileBytesOrEmpty(open: suspend () -> InputStream): ByteArray =
try {
open().use { it.readBytes() }
} catch (_: WallencException.Storage.FileNotFound) {
ByteArray(0)
}

View File

@@ -1,5 +1,7 @@
package com.github.nullptroma.wallenc.domain.vault.storages.encrypt package com.github.nullptroma.wallenc.domain.vault.storages.encrypt
import com.github.nullptroma.wallenc.domain.errors.WallencException
import com.github.nullptroma.wallenc.domain.vault.storages.common.readSystemFileBytesOrEmpty
import com.github.nullptroma.wallenc.domain.vault.utils.CloseHandledStreamExtension.Companion.onClosed import com.github.nullptroma.wallenc.domain.vault.utils.CloseHandledStreamExtension.Companion.onClosed
import com.github.nullptroma.wallenc.domain.vault.utils.CloseHandledStreamExtension.Companion.onClosing import com.github.nullptroma.wallenc.domain.vault.utils.CloseHandledStreamExtension.Companion.onClosing
import com.github.nullptroma.wallenc.domain.common.impl.CommonDirectory import com.github.nullptroma.wallenc.domain.common.impl.CommonDirectory
@@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import java.io.ByteArrayInputStream
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.time.Instant import java.time.Instant
@@ -272,12 +275,10 @@ class EncryptedStorageAccessor(
override suspend fun openReadSystemFile(name: String): InputStream = scope.run { override suspend fun openReadSystemFile(name: String): InputStream = scope.run {
val path = Path(systemHiddenDirName, name).pathString val path = Path(systemHiddenDirName, name).pathString
return@run try { try {
openRead(path)
} catch (_: Exception) {
// Как у Yandex/Local: системного файла ещё нет — создаём пустой и читаем снова.
openWriteSystemFile(name).use { }
openRead(path) openRead(path)
} catch (_: WallencException.Storage.FileNotFound) {
ByteArrayInputStream(ByteArray(0))
} }
} }
@@ -290,7 +291,7 @@ class EncryptedStorageAccessor(
} }
override suspend fun readSyncJournal(): List<StorageSyncJournalEntry> { override suspend fun readSyncJournal(): List<StorageSyncJournalEntry> {
val bytes = openReadSystemFile(SYNC_JOURNAL_FILENAME).use { it.readBytes() } val bytes = readSystemFileBytesOrEmpty { openReadSystemFile(SYNC_JOURNAL_FILENAME) }
if (bytes.isEmpty()) { if (bytes.isEmpty()) {
return emptyList() return emptyList()
} }
@@ -322,7 +323,7 @@ class EncryptedStorageAccessor(
} }
override suspend fun readSyncLock(): StorageSyncLock? { override suspend fun readSyncLock(): StorageSyncLock? {
val bytes = openReadSystemFile(SYNC_LOCK_FILENAME).use { it.readBytes() } val bytes = readSystemFileBytesOrEmpty { openReadSystemFile(SYNC_LOCK_FILENAME) }
if (bytes.isEmpty()) { if (bytes.isEmpty()) {
return null return null
} }

View File

@@ -1,6 +1,7 @@
package com.github.nullptroma.wallenc.domain.vault.storages.local package com.github.nullptroma.wallenc.domain.vault.storages.local
import com.github.nullptroma.wallenc.domain.errors.WallencException import com.github.nullptroma.wallenc.domain.errors.WallencException
import com.github.nullptroma.wallenc.domain.vault.storages.common.readSystemFileBytesOrEmpty
import com.fasterxml.jackson.core.JacksonException import com.fasterxml.jackson.core.JacksonException
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
@@ -553,11 +554,9 @@ class LocalStorageAccessor(
val dirPath = _filesystemBasePath.resolve(SYSTEM_HIDDEN_DIRNAME) val dirPath = _filesystemBasePath.resolve(SYSTEM_HIDDEN_DIRNAME)
val path = dirPath.resolve(name) val path = dirPath.resolve(name)
val file = path.toFile() val file = path.toFile()
if(!file.exists()) { if (!file.exists()) {
Files.createDirectories(dirPath) throw WallencException.Storage.FileNotFound()
file.createNewFile()
} }
return@withContext file.inputStream() return@withContext file.inputStream()
} }
@@ -574,7 +573,7 @@ class LocalStorageAccessor(
} }
override suspend fun readSyncJournal(): List<StorageSyncJournalEntry> = withContext(ioDispatcher) { override suspend fun readSyncJournal(): List<StorageSyncJournalEntry> = withContext(ioDispatcher) {
val bytes = openReadSystemFile(SYNC_JOURNAL_FILENAME).use { it.readBytes() } val bytes = readSystemFileBytesOrEmpty { openReadSystemFile(SYNC_JOURNAL_FILENAME) }
if (bytes.isEmpty()) { if (bytes.isEmpty()) {
return@withContext emptyList() return@withContext emptyList()
} }
@@ -604,7 +603,7 @@ class LocalStorageAccessor(
} }
override suspend fun readSyncLock(): StorageSyncLock? = withContext(ioDispatcher) { override suspend fun readSyncLock(): StorageSyncLock? = withContext(ioDispatcher) {
val bytes = openReadSystemFile(SYNC_LOCK_FILENAME).use { it.readBytes() } val bytes = readSystemFileBytesOrEmpty { openReadSystemFile(SYNC_LOCK_FILENAME) }
if (bytes.isEmpty()) { if (bytes.isEmpty()) {
return@withContext null return@withContext null
} }

View File

@@ -2,6 +2,7 @@ package com.github.nullptroma.wallenc.domain.vault.storages.yandex
import com.github.nullptroma.wallenc.domain.errors.WallencException import com.github.nullptroma.wallenc.domain.errors.WallencException
import com.github.nullptroma.wallenc.domain.vault.errors.toVaultWallencException import com.github.nullptroma.wallenc.domain.vault.errors.toVaultWallencException
import com.github.nullptroma.wallenc.domain.vault.storages.common.readSystemFileBytesOrEmpty
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.YandexDiskAuthException import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.YandexDiskAuthException
@@ -575,12 +576,11 @@ class YandexStorageAccessor(
override suspend fun openReadSystemFile(name: String): InputStream = withContext(ioDispatcher) { override suspend fun openReadSystemFile(name: String): InputStream = withContext(ioDispatcher) {
ensureSystemDirExists() ensureSystemDirExists()
val rel = "/$SYSTEM_HIDDEN_DIRNAME/$name" val rel = "/$SYSTEM_HIDDEN_DIRNAME/$name"
try { val diskPath = toDiskPath(rel)
guard { repo.openDownloadStream(toDiskPath(rel)) } when (guard { repo.getOrNull(diskPath) }?.type) {
} catch (_: Exception) { "file" -> guard { repo.openDownloadStream(diskPath) }
// как Local: пустой файл если нет null -> throw WallencException.Storage.FileNotFound()
guard { repo.uploadBytes(toDiskPath(rel), ByteArray(0), overwrite = true) } else -> throw WallencException.Storage.FileNotFound()
guard { repo.openDownloadStream(toDiskPath(rel)) }
} }
} }
@@ -598,7 +598,7 @@ class YandexStorageAccessor(
} }
override suspend fun readSyncJournal(): List<StorageSyncJournalEntry> = withContext(ioDispatcher) { override suspend fun readSyncJournal(): List<StorageSyncJournalEntry> = withContext(ioDispatcher) {
val bytes = openReadSystemFile(SYNC_JOURNAL_FILENAME).use { it.readBytes() } val bytes = readSystemFileBytesOrEmpty { openReadSystemFile(SYNC_JOURNAL_FILENAME) }
if (bytes.isEmpty()) { if (bytes.isEmpty()) {
return@withContext emptyList() return@withContext emptyList()
} }
@@ -632,7 +632,7 @@ class YandexStorageAccessor(
} }
override suspend fun readSyncLock(): StorageSyncLock? = withContext(ioDispatcher) { override suspend fun readSyncLock(): StorageSyncLock? = withContext(ioDispatcher) {
val bytes = openReadSystemFile(SYNC_LOCK_FILENAME).use { it.readBytes() } val bytes = readSystemFileBytesOrEmpty { openReadSystemFile(SYNC_LOCK_FILENAME) }
if (bytes.isEmpty()) { if (bytes.isEmpty()) {
return@withContext null return@withContext null
} }

View File

@@ -103,6 +103,17 @@ class LocalVault(
return@withContext storage return@withContext storage
} }
override suspend fun rescanStorages() = withContext(ioDispatcher) {
_storagesScanInProgress.value = true
try {
if (_isAvailable.value) {
readStorages()
}
} finally {
_storagesScanInProgress.value = false
}
}
override suspend fun remove(storage: IStorage) = withContext(ioDispatcher) { override suspend fun remove(storage: IStorage) = withContext(ioDispatcher) {
val path = path.value val path = path.value
if (path == null || !_isAvailable.value) { if (path == null || !_isAvailable.value) {

View File

@@ -4,6 +4,7 @@ import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.YandexDiskA
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.repository.YandexDiskRepository import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.repository.YandexDiskRepository
import com.github.nullptroma.wallenc.domain.vault.storages.yandex.YandexStorage import com.github.nullptroma.wallenc.domain.vault.storages.yandex.YandexStorage
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.datatypes.StorageMetaLoadState
import com.github.nullptroma.wallenc.domain.interfaces.IStorage import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.vault.contract.CloudBrand import com.github.nullptroma.wallenc.vault.contract.CloudBrand
import com.github.nullptroma.wallenc.vault.contract.DescribedVault import com.github.nullptroma.wallenc.vault.contract.DescribedVault
@@ -13,9 +14,12 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.UUID import java.util.UUID
@@ -54,12 +58,20 @@ class YandexVault(
private val _availableSpace = MutableStateFlow<Long?>(null) private val _availableSpace = MutableStateFlow<Long?>(null)
override val availableSpace: StateFlow<Long?> = _availableSpace override val availableSpace: StateFlow<Long?> = _availableSpace
private val refreshMutex = Mutex()
init { init {
parentScope.launch { parentScope.launch {
runCatching { refreshFromDisk() } runCatching { refreshFromDisk() }
} }
} }
override suspend fun rescanStorages() {
refreshMutex.withLock {
refreshFromDisk()
}
}
private suspend fun refreshFromDisk() { private suspend fun refreshFromDisk() {
_storagesScanInProgress.value = true _storagesScanInProgress.value = true
_vaultReachable.value = false _vaultReachable.value = false
@@ -111,13 +123,26 @@ class YandexVault(
if (pending.isEmpty()) return emptyList() if (pending.isEmpty()) return emptyList()
return coroutineScope { return coroutineScope {
pending.map { storage -> pending.map { storage ->
async(ioDispatcher) { async(ioDispatcher) { initStorageWithRetry(storage) }
if (runCatching { storage.init() }.isSuccess) storage else null
}
}.awaitAll().filterNotNull() }.awaitAll().filterNotNull()
} }
} }
private suspend fun initStorageWithRetry(storage: YandexStorage): YandexStorage? {
for (attempt in 0 until STORAGE_INIT_ATTEMPTS) {
if (attempt > 0) {
delay(STORAGE_INIT_RETRY_DELAY_MS * attempt)
}
if (
runCatching { storage.init() }.isSuccess &&
storage.metaLoadState.value == StorageMetaLoadState.Ready
) {
return storage
}
}
return null
}
override suspend fun createStorage(): IStorage = withContext(ioDispatcher) { override suspend fun createStorage(): IStorage = withContext(ioDispatcher) {
val id = UUID.randomUUID() val id = UUID.randomUUID()
repo.createFolder("app:/$id") repo.createFolder("app:/$id")
@@ -150,5 +175,7 @@ class YandexVault(
private companion object { private companion object {
private const val APP_LIST_LIMIT = 1000 private const val APP_LIST_LIMIT = 1000
private const val STORAGE_INIT_ATTEMPTS = 3
private const val STORAGE_INIT_RETRY_DELAY_MS = 400L
} }
} }

View File

@@ -0,0 +1,7 @@
package com.github.nullptroma.wallenc.domain.datatypes
enum class StorageMetaLoadState {
Loading,
Ready,
Unavailable,
}

View File

@@ -1,6 +1,7 @@
package com.github.nullptroma.wallenc.domain.interfaces package com.github.nullptroma.wallenc.domain.interfaces
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.datatypes.StorageMetaLoadState
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -14,6 +15,7 @@ sealed interface IStorageInfo {
val numberOfFiles: StateFlow<Int?> val numberOfFiles: StateFlow<Int?>
val isEmpty: Flow<Boolean?> val isEmpty: Flow<Boolean?>
val metaInfo: StateFlow<IStorageMetaInfo> val metaInfo: StateFlow<IStorageMetaInfo>
val metaLoadState: StateFlow<StorageMetaLoadState>
val isVirtualStorage: Boolean val isVirtualStorage: Boolean
} }

View File

@@ -16,6 +16,8 @@ interface IUnlockManager {
fun getOpenedStorageKey(uuid: UUID): EncryptKey? fun getOpenedStorageKey(uuid: UUID): EncryptKey?
suspend fun open(storage: IStorage, key: EncryptKey, rememberPassword: Boolean = true): IStorage suspend fun open(storage: IStorage, key: EncryptKey, rememberPassword: Boolean = true): IStorage
/** Сохранить ключ для auto-open без открытия виртуального storage. */
suspend fun rememberKey(storage: IStorage, key: EncryptKey)
suspend fun close(storage: IStorage) suspend fun close(storage: IStorage)
suspend fun close(uuid: UUID) suspend fun close(uuid: UUID)
} }

View File

@@ -21,4 +21,7 @@ interface IVault : IVaultInfo {
suspend fun createStorage(): IStorage suspend fun createStorage(): IStorage
suspend fun createStorage(enc: StorageEncryptionInfo): IStorage suspend fun createStorage(enc: StorageEncryptionInfo): IStorage
suspend fun remove(storage: IStorage) suspend fun remove(storage: IStorage)
/** Пересканировать список storages (для удалённых vault — повторный листинг и init). */
suspend fun rescanStorages() {}
} }

View File

@@ -43,6 +43,7 @@ enum class VaultTaskStep {
AddRemoteVault, AddRemoteVault,
RemoveRemoteVault, RemoveRemoteVault,
RetryRemoteVault, RetryRemoteVault,
RescanVaultStorages,
Save2FaToken, Save2FaToken,
Delete2FaToken, Delete2FaToken,
SaveTextSecret, SaveTextSecret,

View File

@@ -1,6 +1,6 @@
#Sat Sep 07 01:04:14 MSK 2024 #Sat Sep 07 01:04:14 MSK 2024
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

View File

@@ -4,17 +4,15 @@ import android.content.Intent
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
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.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.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -22,16 +20,16 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.navDeepLink import androidx.navigation.navDeepLink
import com.github.nullptroma.wallenc.ui.elements.FloatingWallencNavigationBar
import com.github.nullptroma.wallenc.ui.navigation.NavBarItemData import com.github.nullptroma.wallenc.ui.navigation.NavBarItemData
import com.github.nullptroma.wallenc.ui.navigation.WallencDeepLinks import com.github.nullptroma.wallenc.ui.navigation.WallencDeepLinks
import com.github.nullptroma.wallenc.ui.navigation.matchesWallencDeepLink import com.github.nullptroma.wallenc.ui.navigation.matchesWallencDeepLink
import com.github.nullptroma.wallenc.ui.elements.NavigationBarMarqueeText
import com.github.nullptroma.wallenc.ui.navigation.rememberNavigationState import com.github.nullptroma.wallenc.ui.navigation.rememberNavigationState
import com.github.nullptroma.wallenc.ui.screens.main.MainRoute import com.github.nullptroma.wallenc.ui.screens.main.MainRoute
import com.github.nullptroma.wallenc.ui.screens.main.MainScreen import com.github.nullptroma.wallenc.ui.screens.main.MainScreen
@@ -108,57 +106,52 @@ fun WallencNavRoot(
) )
} }
val navBackStackEntry by navState.navHostController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
Scaffold(bottomBar = { Scaffold(
NavigationBar(modifier = Modifier.wrapContentHeight()) { bottomBar = {
val navBackStackEntry by navState.navHostController.currentBackStackEntryAsState() Box(
val currentRoute = navBackStackEntry?.destination?.route modifier = Modifier
topLevelNavBarItems.forEach { .navigationBarsPadding()
val routeClassName = it.key .padding(horizontal = 12.dp)
val navBarItemData = it.value .padding(top = 4.dp, bottom = 6.dp),
NavigationBarItem( ) {
modifier = Modifier.wrapContentHeight(), FloatingWallencNavigationBar(
icon = { items = topLevelNavBarItems,
if (navBarItemData.icon != null) Icon( routes = topLevelRoutes,
navBarItemData.icon, currentRoute = currentRoute,
contentDescription = stringResource(navBarItemData.iconContentDescriptionResourceId), onNavigate = { item ->
) val route = topLevelRoutes[item.screenRouteClass]
}, ?: error("Route ${item.screenRouteClass} not found")
label = { if (currentRoute?.startsWith(item.screenRouteClass) != true) {
NavigationBarMarqueeText( navState.changeTop(route)
text = stringResource(navBarItemData.nameStringResourceId), }
)
}, },
selected = currentRoute?.startsWith(routeClassName) == true,
onClick = {
val route = topLevelRoutes[navBarItemData.screenRouteClass]
if (route == null)
throw NullPointerException("Route $route not found")
if (currentRoute?.startsWith(routeClassName) != true) navState.changeTop(
route
)
}
) )
} }
} },
}) { innerPaddings -> ) { innerPaddings ->
NavHost( NavHost(
navState.navHostController, navState.navHostController,
startDestination = topLevelRoutes[MainRoute::class.qualifiedName]!! startDestination = topLevelRoutes[MainRoute::class.qualifiedName]!!,
modifier = Modifier.padding(innerPaddings),
) { ) {
composable<MainRoute>( composable<MainRoute>(
deepLinks = listOf( deepLinks = listOf(
navDeepLink { uriPattern = WallencDeepLinks.MAIN_URI_PATTERN }, navDeepLink { uriPattern = WallencDeepLinks.MAIN_URI_PATTERN },
), ),
enterTransition = { enterTransition = {
fadeIn(tween(200)) fadeIn(tween(200))
}, exitTransition = { },
fadeOut(tween(200)) exitTransition = {
}) { fadeOut(tween(200))
},
) {
MainScreen( MainScreen(
modifier = Modifier.padding(innerPaddings), modifier = Modifier,
navState = mainNavState, navState = mainNavState,
viewModel = mainViewModel viewModel = mainViewModel,
) )
} }
composable<SettingsRoute>( composable<SettingsRoute>(
@@ -166,23 +159,27 @@ fun WallencNavRoot(
navDeepLink { uriPattern = WallencDeepLinks.SETTINGS_URI_PATTERN }, navDeepLink { uriPattern = WallencDeepLinks.SETTINGS_URI_PATTERN },
), ),
enterTransition = { enterTransition = {
fadeIn(tween(200)) fadeIn(tween(200))
}, exitTransition = { },
fadeOut(tween(200)) exitTransition = {
}) { fadeOut(tween(200))
SettingsScreen(Modifier.padding(innerPaddings), settingsViewModel) },
) {
SettingsScreen(Modifier, settingsViewModel)
} }
composable<StorageSyncRoute>( composable<StorageSyncRoute>(
deepLinks = listOf( deepLinks = listOf(
navDeepLink { uriPattern = WallencDeepLinks.SYNC_URI_PATTERN }, navDeepLink { uriPattern = WallencDeepLinks.SYNC_URI_PATTERN },
), ),
enterTransition = { enterTransition = {
fadeIn(tween(200)) fadeIn(tween(200))
}, exitTransition = { },
fadeOut(tween(200)) exitTransition = {
}) { fadeOut(tween(200))
},
) {
StorageSyncScreen( StorageSyncScreen(
modifier = Modifier.padding(innerPaddings), modifier = Modifier,
viewModel = storageSyncViewModel, viewModel = storageSyncViewModel,
) )
} }
@@ -191,14 +188,14 @@ fun WallencNavRoot(
navDeepLink { uriPattern = WallencDeepLinks.TASKS_URI_PATTERN }, navDeepLink { uriPattern = WallencDeepLinks.TASKS_URI_PATTERN },
), ),
enterTransition = { enterTransition = {
fadeIn(tween(200)) fadeIn(tween(200))
}, exitTransition = { },
fadeOut(tween(200)) exitTransition = {
}) { fadeOut(tween(200))
TaskPipelineScreen( },
modifier = Modifier.padding(innerPaddings) ) {
) TaskPipelineScreen(modifier = Modifier)
} }
} }
} }
} }

View File

@@ -0,0 +1,75 @@
package com.github.nullptroma.wallenc.ui.elements
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.github.nullptroma.wallenc.ui.R
private const val BackButtonAnimMillis = 200
private val backButtonEnter = fadeIn(tween(BackButtonAnimMillis)) +
slideInVertically(
animationSpec = tween(BackButtonAnimMillis),
initialOffsetY = { fullHeight -> fullHeight / 2 },
)
private val backButtonExit = fadeOut(tween(BackButtonAnimMillis)) +
slideOutVertically(
animationSpec = tween(BackButtonAnimMillis),
targetOffsetY = { fullHeight -> -fullHeight / 2 },
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FloatingBackButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
onClick = onClick,
modifier = modifier.size(44.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shadowElevation = 4.dp,
tonalElevation = 2.dp,
contentColor = MaterialTheme.colorScheme.onSurface,
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.nav_cd_back),
modifier = Modifier.padding(10.dp),
)
}
}
@Composable
fun AnimatedFloatingBackButton(
visible: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = visible,
modifier = modifier,
enter = backButtonEnter,
exit = backButtonExit,
) {
FloatingBackButton(onClick = onClick)
}
}

View File

@@ -0,0 +1,180 @@
package com.github.nullptroma.wallenc.ui.elements
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.github.nullptroma.wallenc.ui.navigation.NavBarItemData
/** Вертикальный зазор между вложенной и корневой плавающими панелями навигации. */
val WallencNestedNavBarGap = 2.dp
@Composable
fun FloatingWallencNavigationBar(
items: Map<String, NavBarItemData>,
routes: Map<String, *>,
currentRoute: String?,
onNavigate: (NavBarItemData) -> Unit,
modifier: Modifier = Modifier,
compact: Boolean = false,
) {
val haptic = LocalHapticFeedback.current
val barHeight = if (compact) 48.dp else 56.dp
val barShape = if (compact) RoundedCornerShape(22.dp) else RoundedCornerShape(28.dp)
val barHorizontalPadding = if (compact) 4.dp else 6.dp
val barSurface: @Composable () -> Unit = {
Surface(
modifier = Modifier
.then(
if (compact) {
Modifier
.widthIn(max = 300.dp)
.fillMaxWidth(0.68f)
} else {
Modifier.fillMaxWidth()
},
),
shape = barShape,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shadowElevation = 6.dp,
tonalElevation = 2.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(barHeight)
.padding(horizontal = barHorizontalPadding, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
items.forEach { (routeClassName, navBarItemData) ->
val iconVector = navBarItemData.icon ?: return@forEach
val selected = currentRoute?.startsWith(routeClassName) == true
val enabled = routes[navBarItemData.screenRouteClass] != null
FloatingNavItem(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
icon = iconVector,
label = stringResource(navBarItemData.nameStringResourceId),
contentDescription = stringResource(navBarItemData.iconContentDescriptionResourceId),
selected = selected,
enabled = enabled,
compact = compact,
onClick = {
if (!selected && enabled) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNavigate(navBarItemData)
}
},
)
}
}
}
}
if (compact) {
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
barSurface()
}
} else {
Box(modifier = modifier.fillMaxWidth()) {
barSurface()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FloatingNavItem(
icon: ImageVector,
label: String,
contentDescription: String,
selected: Boolean,
enabled: Boolean,
compact: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val iconSize = if (compact) 22.dp else 24.dp
val itemPaddingH = if (compact) 6.dp else 8.dp
val itemPaddingV = if (compact) 6.dp else 8.dp
val labelStyle = if (compact) {
MaterialTheme.typography.labelSmall
} else {
MaterialTheme.typography.labelMedium
}
val labelVelocity = if (compact) 24.dp else 28.dp
val itemShape = if (compact) RoundedCornerShape(16.dp) else RoundedCornerShape(20.dp)
val containerColor = if (selected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
val contentColor = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(
onClick = onClick,
enabled = enabled,
modifier = modifier
.padding(horizontal = if (compact) 1.dp else 2.dp)
.semantics {
role = Role.Tab
this.contentDescription = contentDescription
},
shape = itemShape,
color = containerColor,
contentColor = contentColor,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = itemPaddingH, vertical = itemPaddingV),
horizontalArrangement = if (selected) Arrangement.Start else Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(iconSize),
)
if (selected) {
NavigationBarMarqueeText(
text = label,
modifier = Modifier
.weight(1f)
.padding(start = if (compact) 4.dp else 6.dp),
style = labelStyle,
velocity = labelVelocity,
)
}
}
}
}

View File

@@ -7,25 +7,33 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/** /**
* Однострочная подпись таба нижней навигации: при нехватке ширины текст * Однострочная подпись таба: при нехватке ширины текст циклически прокручивается.
* прокручивается (marquee), без переноса последних букв на вторую строку.
*/ */
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun NavigationBarMarqueeText( fun NavigationBarMarqueeText(
text: String, text: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
style: TextStyle = LocalTextStyle.current,
velocity: Dp = 28.dp,
) { ) {
Text( Text(
text = text, text = text,
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.basicMarquee(), .basicMarquee(
style = LocalTextStyle.current, iterations = Int.MAX_VALUE,
repeatDelayMillis = 1_200,
velocity = velocity,
),
style = style,
maxLines = 1, maxLines = 1,
softWrap = false, softWrap = false,
overflow = TextOverflow.Clip, overflow = TextOverflow.Clip,

View File

@@ -46,6 +46,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.domain.datatypes.StorageMetaLoadState
import com.github.nullptroma.wallenc.domain.datatypes.Tree import com.github.nullptroma.wallenc.domain.datatypes.Tree
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
@@ -74,7 +75,10 @@ fun StorageTree(
val numOfFiles by cur.numberOfFiles.collectAsStateWithLifecycle() val numOfFiles by cur.numberOfFiles.collectAsStateWithLifecycle()
val size by cur.size.collectAsStateWithLifecycle() val size by cur.size.collectAsStateWithLifecycle()
val metaInfo by cur.metaInfo.collectAsStateWithLifecycle() val metaInfo by cur.metaInfo.collectAsStateWithLifecycle()
val metaLoadState by cur.metaLoadState.collectAsStateWithLifecycle()
val isAvailable by cur.isAvailable.collectAsStateWithLifecycle() val isAvailable by cur.isAvailable.collectAsStateWithLifecycle()
val metaUnavailable = metaLoadState == StorageMetaLoadState.Unavailable
val rowEnabled = isAvailable && !rowBusy && !metaUnavailable
val isEncrypted = metaInfo.encInfo != null val isEncrypted = metaInfo.encInfo != null
val isOpened = isEncryptionOpened(tree) val isOpened = isEncryptionOpened(tree)
val borderColor = val borderColor =
@@ -82,6 +86,7 @@ fun StorageTree(
val yesWord = stringResource(R.string.storage_value_yes) val yesWord = stringResource(R.string.storage_value_yes)
val noWord = stringResource(R.string.storage_value_no) val noWord = stringResource(R.string.storage_value_no)
val unavailableHint = stringResource(R.string.storage_unavailable_hint) val unavailableHint = stringResource(R.string.storage_unavailable_hint)
val metaUnavailableHint = stringResource(R.string.storage_meta_unavailable_hint)
Column(modifier) { Column(modifier) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -112,9 +117,9 @@ fun StorageTree(
elevation = CardDefaults.cardElevation( elevation = CardDefaults.cardElevation(
defaultElevation = 4.dp, defaultElevation = 4.dp,
), ),
enabled = isAvailable && !rowBusy, enabled = rowEnabled,
onClick = debouncedLambda(debounceMs = 500) { onClick = debouncedLambda(debounceMs = 500) {
if (isAvailable && !rowBusy) { if (rowEnabled) {
onClick(tree) onClick(tree)
} }
}, },
@@ -150,7 +155,13 @@ fun StorageTree(
), ),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
if (!isAvailable) { if (metaUnavailable) {
Text(
text = metaUnavailableHint,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
} else if (!isAvailable) {
Text( Text(
text = unavailableHint, text = unavailableHint,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -191,7 +202,7 @@ fun StorageTree(
} }
IconButton( IconButton(
onClick = { expanded = !expanded }, onClick = { expanded = !expanded },
enabled = isAvailable && !rowBusy, enabled = rowEnabled,
) { ) {
Icon( Icon(
Icons.Default.MoreVert, Icons.Default.MoreVert,
@@ -210,10 +221,10 @@ fun StorageTree(
onDismissRequest = { expanded = false }, onDismissRequest = { expanded = false },
) { ) {
DropdownMenuItem( DropdownMenuItem(
enabled = isAvailable && !rowBusy, enabled = rowEnabled,
onClick = { onClick = {
expanded = false expanded = false
if (isAvailable && !rowBusy) showRenameDialog = true if (rowEnabled) showRenameDialog = true
}, },
text = { text = {
Text( Text(
@@ -230,10 +241,10 @@ fun StorageTree(
) )
HorizontalDivider() HorizontalDivider()
DropdownMenuItem( DropdownMenuItem(
enabled = isAvailable && !rowBusy, enabled = rowEnabled,
onClick = { onClick = {
expanded = false expanded = false
if (isAvailable && !rowBusy) showRemoveConfirmDialog = true if (rowEnabled) showRemoveConfirmDialog = true
}, },
text = { text = {
Text( Text(
@@ -251,10 +262,10 @@ fun StorageTree(
if (!isEncrypted) { if (!isEncrypted) {
HorizontalDivider() HorizontalDivider()
DropdownMenuItem( DropdownMenuItem(
enabled = isAvailable && !rowBusy, enabled = rowEnabled,
onClick = { onClick = {
expanded = false expanded = false
if (isAvailable && !rowBusy) showSetupEncryptionDialog = true if (rowEnabled) showSetupEncryptionDialog = true
}, },
text = { text = {
Text( Text(
@@ -361,7 +372,7 @@ fun StorageTree(
if (isEncrypted) { if (isEncrypted) {
IconButton( IconButton(
onClick = { showLockDialog = true }, onClick = { showLockDialog = true },
enabled = isAvailable && !rowBusy, enabled = rowEnabled,
) { ) {
Icon( Icon(
if (isOpened) Icons.Default.LockOpen else Icons.Default.Lock, if (isOpened) Icons.Default.LockOpen else Icons.Default.Lock,

View File

@@ -0,0 +1,49 @@
package com.github.nullptroma.wallenc.ui.elements
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun WallencScreenScaffold(
modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState? = null,
floatingActionButton: @Composable () -> Unit = {},
content: @Composable (PaddingValues) -> Unit,
) {
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0.dp),
snackbarHost = {
if (snackbarHostState != null) {
SnackbarHost(snackbarHostState)
}
},
floatingActionButton = floatingActionButton,
floatingActionButtonPosition = FabPosition.End,
content = content,
)
}
@Composable
fun WallencScreenContentPadding(
innerPadding: PaddingValues,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Box(
modifier = modifier
.padding(innerPadding)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
content()
}
}

View File

@@ -0,0 +1,25 @@
package com.github.nullptroma.wallenc.ui.navigation
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.shared.TextEditRoute
private val mainTopLevelRoutePrefixes: Set<String> = setOf(
LocalVaultRoute::class.qualifiedName!!,
RemoteVaultsRoute::class.qualifiedName!!,
)
fun isMainTopLevelRoute(route: String?): Boolean {
if (route == null) return true
return mainTopLevelRoutePrefixes.any { route.startsWith(it) }
}
fun isTextEditDestination(route: String?): Boolean {
val qualified = TextEditRoute::class.qualifiedName ?: return false
return route?.startsWith(qualified) == true
}
fun shouldShowMainFloatingBack(route: String?): Boolean {
if (route == null) return false
return !isMainTopLevelRoute(route)
}

View File

@@ -24,6 +24,10 @@ class NavigationState(
restoreState = true restoreState = true
} }
} }
fun pop(): Boolean = navHostController.popBackStack()
fun canPop(): Boolean = navHostController.previousBackStackEntry != null
} }
@Composable @Composable

View File

@@ -58,6 +58,7 @@ fun TaskProgressLabel.resolve(resolver: UiStringResolver): String = when (this)
VaultTaskStep.AddRemoteVault -> resolver(R.string.task_progress_add_remote_vault) VaultTaskStep.AddRemoteVault -> resolver(R.string.task_progress_add_remote_vault)
VaultTaskStep.RemoveRemoteVault -> resolver(R.string.task_progress_remove_remote_vault) VaultTaskStep.RemoveRemoteVault -> resolver(R.string.task_progress_remove_remote_vault)
VaultTaskStep.RetryRemoteVault -> resolver(R.string.task_progress_retry_remote_vault) VaultTaskStep.RetryRemoteVault -> resolver(R.string.task_progress_retry_remote_vault)
VaultTaskStep.RescanVaultStorages -> resolver(R.string.task_progress_rescan_vault_storages)
VaultTaskStep.Save2FaToken -> resolver(R.string.task_progress_save_2fa_token) VaultTaskStep.Save2FaToken -> resolver(R.string.task_progress_save_2fa_token)
VaultTaskStep.Delete2FaToken -> resolver(R.string.task_progress_delete_2fa_token) VaultTaskStep.Delete2FaToken -> resolver(R.string.task_progress_delete_2fa_token)
VaultTaskStep.SaveTextSecret -> resolver(R.string.task_progress_save_text_secret) VaultTaskStep.SaveTextSecret -> resolver(R.string.task_progress_save_text_secret)

View File

@@ -3,34 +3,38 @@ package com.github.nullptroma.wallenc.ui.screens.main
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Cloud import androidx.compose.material.icons.outlined.Cloud
import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.toRoute import androidx.navigation.toRoute
import com.github.nullptroma.wallenc.ui.elements.NavigationBarMarqueeText
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.elements.AnimatedFloatingBackButton
import com.github.nullptroma.wallenc.ui.elements.FloatingWallencNavigationBar
import com.github.nullptroma.wallenc.ui.elements.WallencNestedNavBarGap
import com.github.nullptroma.wallenc.ui.navigation.NavBarItemData import com.github.nullptroma.wallenc.ui.navigation.NavBarItemData
import com.github.nullptroma.wallenc.ui.navigation.NavigationState import com.github.nullptroma.wallenc.ui.navigation.NavigationState
import com.github.nullptroma.wallenc.ui.navigation.isMainTopLevelRoute
import com.github.nullptroma.wallenc.ui.navigation.isTextEditDestination
import com.github.nullptroma.wallenc.ui.navigation.shouldShowMainFloatingBack
import com.github.nullptroma.wallenc.ui.navigation.rememberNavigationState import com.github.nullptroma.wallenc.ui.navigation.rememberNavigationState
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.remotes.RemoteVaultsScreen import com.github.nullptroma.wallenc.ui.screens.main.screens.remotes.RemoteVaultsScreen
@@ -54,13 +58,8 @@ import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.VaultBrowserS
import com.github.nullptroma.wallenc.ui.screens.shared.TextEditRoute import com.github.nullptroma.wallenc.ui.screens.shared.TextEditRoute
import com.github.nullptroma.wallenc.ui.screens.shared.TextEditScreen import com.github.nullptroma.wallenc.ui.screens.shared.TextEditScreen
private fun isTextEditDestination(route: String?): Boolean {
val q = TextEditRoute::class.qualifiedName ?: return false
return route?.startsWith(q) == true
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@androidx.compose.runtime.Composable @Composable
fun MainScreen( fun MainScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: MainViewModel = hiltViewModel(), viewModel: MainViewModel = hiltViewModel(),
@@ -72,8 +71,12 @@ fun MainScreen(
val remoteVaultsViewModel: RemoteVaultsViewModel = hiltViewModel() val remoteVaultsViewModel: RemoteVaultsViewModel = hiltViewModel()
val childBackStackEntry by navState.navHostController.currentBackStackEntryAsState() val childBackStackEntry by navState.navHostController.currentBackStackEntryAsState()
val showWorkStatusBar = !isTextEditDestination(childBackStackEntry?.destination?.route) val childRoute = childBackStackEntry?.destination?.route
val showWorkStatusBar = !isTextEditDestination(childRoute)
val showMainBottomNav = isMainTopLevelRoute(childRoute)
val showFloatingBack = shouldShowMainFloatingBack(childRoute) && navState.canPop()
val workStatus = mainUi.workStatus val workStatus = mainUi.workStatus
val onBack: () -> Unit = { navState.pop() }
val topLevelNavBarItems = remember { val topLevelNavBarItems = remember {
mapOf( mapOf(
@@ -101,50 +104,37 @@ fun MainScreen(
} }
}, },
bottomBar = { bottomBar = {
Column { if (showMainBottomNav) {
NavigationBar(windowInsets = WindowInsets(0)) { Box(
val navBackStackEntry by navState.navHostController.currentBackStackEntryAsState() modifier = Modifier
val currentRoute = navBackStackEntry?.destination?.route .padding(horizontal = 16.dp)
topLevelNavBarItems.forEach { .padding(top = WallencNestedNavBarGap, bottom = WallencNestedNavBarGap),
val routeClassName = it.key ) {
val navBarItemData = it.value FloatingWallencNavigationBar(
val iconVector = navBarItemData.icon compact = true,
?: error("Main tab requires icon") items = topLevelNavBarItems,
NavigationBarItem( routes = routes,
modifier = Modifier.weight(1f), currentRoute = childRoute,
icon = { onNavigate = { item ->
Icon( val route = routes[item.screenRouteClass]
imageVector = iconVector, ?: error("Route ${item.screenRouteClass} not found")
contentDescription = stringResource(navBarItemData.iconContentDescriptionResourceId), navState.changeTop(route)
) },
}, )
label = {
NavigationBarMarqueeText(
text = stringResource(navBarItemData.nameStringResourceId),
)
},
selected = currentRoute?.startsWith(routeClassName) == true,
onClick = {
val route = routes[navBarItemData.screenRouteClass]
?: throw NullPointerException("Route ${navBarItemData.screenRouteClass} not found")
if (currentRoute?.startsWith(routeClassName) != true) {
navState.changeTop(route)
}
},
)
}
} }
HorizontalDivider()
} }
}, },
) { innerPaddings -> ) { innerPaddings ->
NavHost( Box(
navController = navState.navHostController,
startDestination = routes[LocalVaultRoute::class.qualifiedName]!!,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPaddings), .padding(innerPaddings),
) { ) {
NavHost(
navController = navState.navHostController,
startDestination = routes[LocalVaultRoute::class.qualifiedName]!!,
modifier = Modifier.fillMaxSize(),
) {
composable<LocalVaultRoute>( composable<LocalVaultRoute>(
enterTransition = { fadeIn(tween(200)) }, enterTransition = { fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(200)) }, exitTransition = { fadeOut(tween(200)) },
@@ -242,9 +232,7 @@ fun MainScreen(
), ),
) )
}, },
onDeleted = { onDeleted = { navState.pop() },
navState.navHostController.popBackStack()
},
) )
} }
composable<TextSecretEditRoute>( composable<TextSecretEditRoute>(
@@ -255,7 +243,7 @@ fun MainScreen(
TextSecretEditScreen( TextSecretEditScreen(
onSaved = { savedSecretId -> onSaved = { savedSecretId ->
val editingExisting = route.secretId != null val editingExisting = route.secretId != null
navState.navHostController.popBackStack() navState.pop()
if (!editingExisting) { if (!editingExisting) {
navState.push( navState.push(
TextSecretDetailsRoute( TextSecretDetailsRoute(
@@ -269,8 +257,18 @@ fun MainScreen(
} }
composable<TextEditRoute> { composable<TextEditRoute> {
val route: TextEditRoute = it.toRoute() val route: TextEditRoute = it.toRoute()
TextEditScreen(route.text) TextEditScreen(text = route.text)
} }
}
AnimatedFloatingBackButton(
visible = showFloatingBack,
onClick = onBack,
modifier = Modifier
.zIndex(1f)
.align(Alignment.BottomStart)
.navigationBarsPadding()
.padding(start = 12.dp, bottom = 12.dp),
)
} }
} }
} }

View File

@@ -1,10 +1,10 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage package com.github.nullptroma.wallenc.ui.screens.main.screens.storage
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -16,10 +16,10 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -27,6 +27,8 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.elements.WallencScreenContentPadding
import com.github.nullptroma.wallenc.ui.elements.WallencScreenScaffold
import com.github.nullptroma.wallenc.ui.resources.resolveText import com.github.nullptroma.wallenc.ui.resources.resolveText
@Composable @Composable
@@ -38,19 +40,19 @@ fun StorageHomeScreen(
) { ) {
val uiState by viewModel.state.collectAsStateWithLifecycle() val uiState by viewModel.state.collectAsStateWithLifecycle()
Scaffold( WallencScreenScaffold(modifier = modifier) { innerPadding ->
modifier = modifier, WallencScreenContentPadding(innerPadding) {
contentWindowInsets = WindowInsets(0.dp),
) { innerPadding ->
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
if (uiState.isLoading) { if (uiState.isLoading) {
CircularProgressIndicator() Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
return@Column return@Column
} }
@@ -110,6 +112,7 @@ fun StorageHomeScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
}
} }
} }

View File

@@ -2,8 +2,11 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.storage
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.datatypes.StorageMetaLoadState
import com.github.nullptroma.wallenc.domain.errors.WallencException import com.github.nullptroma.wallenc.domain.errors.WallencException
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.UserNotification
import com.github.nullptroma.wallenc.ui.resources.toUserNotification import com.github.nullptroma.wallenc.ui.resources.toUserNotification
import com.github.nullptroma.wallenc.usecases.FindStorageUseCase import com.github.nullptroma.wallenc.usecases.FindStorageUseCase
import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase
@@ -43,11 +46,13 @@ class StorageHomeViewModel @Inject constructor(
combine( combine(
storage.isAvailable, storage.isAvailable,
storage.metaInfo, storage.metaInfo,
storage.metaLoadState,
manageTwoFaTokensUseCase.observe(storage), manageTwoFaTokensUseCase.observe(storage),
manageTextSecretsUseCase.observe(storage), manageTextSecretsUseCase.observe(storage),
) { available, meta, twoFa, secrets -> ) { available, meta, metaState, twoFa, secrets ->
val metaUnavailable = metaState == StorageMetaLoadState.Unavailable
val isRawEncrypted = meta.encInfo != null && !storage.isVirtualStorage val isRawEncrypted = meta.encInfo != null && !storage.isVirtualStorage
val canManageDomainData = available && !isRawEncrypted val canManageDomainData = available && !isRawEncrypted && !metaUnavailable
state.value.copy( state.value.copy(
isLoading = false, isLoading = false,
storageUuid = storage.uuid.toString(), storageUuid = storage.uuid.toString(),
@@ -58,10 +63,10 @@ class StorageHomeViewModel @Inject constructor(
twoFaCount = twoFa.size, twoFaCount = twoFa.size,
textSecretsCount = secrets.size, textSecretsCount = secrets.size,
canManageDomainData = canManageDomainData, canManageDomainData = canManageDomainData,
errorNotification = if (isRawEncrypted) { errorNotification = when {
WallencException.Feature.NeedsDecryptedView().toUserNotification() metaUnavailable -> UserNotification.TextRes(R.string.storage_home_meta_unavailable)
} else { isRawEncrypted -> WallencException.Feature.NeedsDecryptedView().toUserNotification()
null else -> null
}, },
) )
}.collect { ui -> }.collect { ui ->

View File

@@ -4,7 +4,6 @@ import android.content.ClipData
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -19,7 +18,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -33,6 +31,8 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.elements.WallencScreenContentPadding
import com.github.nullptroma.wallenc.ui.elements.WallencScreenScaffold
import com.github.nullptroma.wallenc.ui.resources.resolveText import com.github.nullptroma.wallenc.ui.resources.resolveText
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -48,15 +48,10 @@ fun TextSecretDetailsScreen(
val clipboard = LocalClipboard.current val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Scaffold( WallencScreenScaffold(modifier = modifier) { innerPadding ->
modifier = modifier, WallencScreenContentPadding(innerPadding) {
contentWindowInsets = WindowInsets(0.dp),
) { innerPadding ->
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
uiState.errorNotification?.let { notification -> uiState.errorNotification?.let { notification ->
@@ -144,5 +139,6 @@ fun TextSecretDetailsScreen(
Text(stringResource(R.string.remove)) Text(stringResource(R.string.remove))
} }
} }
}
} }
} }

View File

@@ -3,7 +3,6 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -16,7 +15,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -33,6 +31,8 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretEntryRecord import com.github.nullptroma.wallenc.domain.datatypes.TextSecretEntryRecord
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.elements.WallencScreenContentPadding
import com.github.nullptroma.wallenc.ui.elements.WallencScreenScaffold
import com.github.nullptroma.wallenc.ui.resources.resolveText import com.github.nullptroma.wallenc.ui.resources.resolveText
@Composable @Composable
@@ -58,15 +58,10 @@ fun TextSecretEditScreen(
} }
} }
Scaffold( WallencScreenScaffold(modifier = modifier) { innerPadding ->
modifier = modifier, WallencScreenContentPadding(innerPadding) {
contentWindowInsets = WindowInsets(0.dp),
) { innerPadding ->
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Text( Text(
@@ -155,5 +150,6 @@ fun TextSecretEditScreen(
} }
} }
} }
}
} }
} }

View File

@@ -1,7 +1,6 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -10,7 +9,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -21,6 +19,8 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.elements.WallencScreenContentPadding
import com.github.nullptroma.wallenc.ui.elements.WallencScreenScaffold
@Composable @Composable
fun TextSecretsScreen( fun TextSecretsScreen(
@@ -31,9 +31,8 @@ fun TextSecretsScreen(
) { ) {
val uiState by viewModel.state.collectAsStateWithLifecycle() val uiState by viewModel.state.collectAsStateWithLifecycle()
Scaffold( WallencScreenScaffold(
modifier = modifier, modifier = modifier,
contentWindowInsets = WindowInsets(0.dp),
floatingActionButton = { floatingActionButton = {
FloatingActionButton( FloatingActionButton(
onClick = { onClick = {
@@ -47,11 +46,10 @@ fun TextSecretsScreen(
} }
}, },
) { innerPadding -> ) { innerPadding ->
WallencScreenContentPadding(innerPadding) {
TextSecretsScreenContent( TextSecretsScreenContent(
uiState = uiState, uiState = uiState,
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.padding(innerPadding),
) { ) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.items) { secret -> items(uiState.items) { secret ->
@@ -63,5 +61,6 @@ fun TextSecretsScreen(
} }
} }
} }
}
} }
} }

View File

@@ -55,6 +55,7 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameMillis
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
@@ -79,9 +80,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.elements.QrScannerDialog import com.github.nullptroma.wallenc.ui.elements.QrScannerDialog
import com.github.nullptroma.wallenc.ui.elements.WallencScreenContentPadding
import com.github.nullptroma.wallenc.ui.elements.WallencScreenScaffold
import com.github.nullptroma.wallenc.usecases.TwoFaCodeState import com.github.nullptroma.wallenc.usecases.TwoFaCodeState
import com.github.nullptroma.wallenc.usecases.buildTwoFaCodeState import com.github.nullptroma.wallenc.usecases.buildTwoFaCodeState
import kotlinx.coroutines.delay import com.github.nullptroma.wallenc.usecases.totpPeriodProgress
import com.github.nullptroma.wallenc.usecases.totpSecondsUntilRefresh
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -96,16 +100,16 @@ fun TwoFaTokensScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val nowMillis by produceState(initialValue = System.currentTimeMillis()) { val nowMillis by produceState(initialValue = System.currentTimeMillis()) {
while (true) { while (true) {
value = System.currentTimeMillis() withFrameMillis { frameTimeMillis ->
delay(1000) value = frameTimeMillis
}
} }
} }
var editingToken by remember { mutableStateOf<TwoFaTokenRecord?>(null) } var editingToken by remember { mutableStateOf<TwoFaTokenRecord?>(null) }
var creating by remember { mutableStateOf(false) } var creating by remember { mutableStateOf(false) }
Scaffold( WallencScreenScaffold(
modifier = modifier, modifier = modifier,
contentWindowInsets = WindowInsets(0.dp),
floatingActionButton = { floatingActionButton = {
FloatingActionButton( FloatingActionButton(
onClick = { onClick = {
@@ -119,11 +123,10 @@ fun TwoFaTokensScreen(
} }
}, },
) { innerPadding -> ) { innerPadding ->
WallencScreenContentPadding(innerPadding) {
TwoFaTokensScreenContent( TwoFaTokensScreenContent(
uiState = uiState, uiState = uiState,
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.padding(innerPadding),
) { ) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.items) { item -> items(uiState.items) { item ->
@@ -160,12 +163,13 @@ fun TwoFaTokensScreen(
} }
} }
} }
val codeProgress = if (codeState == null) 0f else { val codeProgress = if (codeState == null) {
val period = item.periodSeconds.coerceAtLeast(1) 0f
val elapsed = (period - codeState.secondsUntilRefresh) } else {
.coerceAtLeast(0) totpPeriodProgress(nowMillis, item.periodSeconds)
.coerceAtMost(period) }
elapsed.toFloat() / period.toFloat() val secondsUntilRefresh = codeState?.let {
totpSecondsUntilRefresh(nowMillis, item.periodSeconds)
} }
Row( Row(
modifier = Modifier modifier = Modifier
@@ -183,10 +187,10 @@ fun TwoFaTokensScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Text( Text(
text = if (codeState != null) { text = if (secondsUntilRefresh != null) {
stringResource( stringResource(
R.string.two_fa_code_refresh_seconds, R.string.two_fa_code_refresh_seconds,
codeState.secondsUntilRefresh, secondsUntilRefresh,
) )
} else { } else {
stringResource(R.string.two_fa_code_invalid_secret) stringResource(R.string.two_fa_code_invalid_secret)
@@ -244,6 +248,7 @@ fun TwoFaTokensScreen(
} }
} }
} }
}
} }
} }
@@ -561,7 +566,9 @@ private fun TwoFaTokenEditDialog(
@Composable @Composable
private fun rememberTwoFaCode(token: TwoFaTokenRecord, nowMillis: Long): TwoFaCodeState? { private fun rememberTwoFaCode(token: TwoFaTokenRecord, nowMillis: Long): TwoFaCodeState? {
return remember(token, nowMillis) { val period = token.periodSeconds.coerceAtLeast(1)
val periodSlot = nowMillis / 1000L / period
return remember(token, periodSlot) {
buildTwoFaCodeState(token, nowMillis) buildTwoFaCodeState(token, nowMillis)
} }
} }

View File

@@ -27,9 +27,7 @@ fun TwoFaTokensScreenContent(
tokenList: @Composable () -> Unit = {}, tokenList: @Composable () -> Unit = {},
) { ) {
Column( Column(
modifier = modifier modifier = modifier.fillMaxSize(),
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
if (uiState.isLoading) { if (uiState.isLoading) {

View File

@@ -3,6 +3,7 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
import com.github.nullptroma.wallenc.domain.datatypes.StorageMetaLoadState
import com.github.nullptroma.wallenc.domain.datatypes.Tree import com.github.nullptroma.wallenc.domain.datatypes.Tree
import com.github.nullptroma.wallenc.domain.errors.toWallencException import com.github.nullptroma.wallenc.domain.errors.toWallencException
import com.github.nullptroma.wallenc.domain.interfaces.ILogger import com.github.nullptroma.wallenc.domain.interfaces.ILogger
@@ -30,6 +31,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID import java.util.UUID
@@ -39,6 +41,8 @@ import java.util.UUID
*/ */
abstract class AbstractVaultBrowserViewModel( abstract class AbstractVaultBrowserViewModel(
storagesFlow: Flow<List<IStorage>>, storagesFlow: Flow<List<IStorage>>,
private val storagesScanInProgressFlow: Flow<Boolean> = flowOf(false),
private val vaultHeaderFlow: Flow<VaultBrowserHeader?> = flowOf(null),
private val vaultAvailabilityFlow: Flow<Boolean>, private val vaultAvailabilityFlow: Flow<Boolean>,
private val resolveCreateVaultUuid: () -> UUID?, private val resolveCreateVaultUuid: () -> UUID?,
private val removeStorageUseCase: RemoveStorageUseCase, private val removeStorageUseCase: RemoveStorageUseCase,
@@ -63,8 +67,12 @@ abstract class AbstractVaultBrowserViewModel(
private val _userNotifications = MutableSharedFlow<UserNotification>(extraBufferCapacity = 8) private val _userNotifications = MutableSharedFlow<UserNotification>(extraBufferCapacity = 8)
val userNotifications: SharedFlow<UserNotification> = _userNotifications val userNotifications: SharedFlow<UserNotification> = _userNotifications
/** Удалённый vault: показать кнопку повторного сканирования storages на Диске. */
open val supportsStorageRescan: Boolean = false
init { init {
collectStoragesFlow(storagesFlow) collectStoragesFlow(storagesFlow)
collectVaultHeaderFlow()
collectPipelineBusyFlags() collectPipelineBusyFlags()
viewModelScope.launch { viewModelScope.launch {
vaultAvailabilityFlow vaultAvailabilityFlow
@@ -94,31 +102,41 @@ abstract class AbstractVaultBrowserViewModel(
t.locksVaultStorageList && isPipelineTaskActive(t.state) t.locksVaultStorageList && isPipelineTaskActive(t.state)
} }
private fun collectVaultHeaderFlow() {
viewModelScope.launch {
vaultHeaderFlow.collect { header ->
updateState(state.value.copy(header = header))
}
}
}
private fun collectStoragesFlow(storagesFlow: Flow<List<IStorage>>) { private fun collectStoragesFlow(storagesFlow: Flow<List<IStorage>>) {
viewModelScope.launch { viewModelScope.launch {
combine( combine(
storagesFlow, storagesFlow,
storagesScanInProgressFlow,
getOpenedStoragesUseCase.openedStorages, getOpenedStoragesUseCase.openedStorages,
) { storages, opened -> storages to opened } ) { storages, scanInProgress, opened ->
.collect { (storages, opened) -> Triple(storages, scanInProgress, opened)
val list = mutableListOf<Tree<IStorageInfo>>() }.collect { (storages, scanInProgress, opened) ->
for (storage in storages) { val list = mutableListOf<Tree<IStorageInfo>>()
var tree = Tree<IStorageInfo>(storage) for (storage in storages) {
list.add(tree) var tree = Tree<IStorageInfo>(storage)
while (opened.containsKey(tree.value.uuid)) { list.add(tree)
val child = opened.getValue(tree.value.uuid) while (opened.containsKey(tree.value.uuid)) {
val nextTree = Tree(child) val child = opened.getValue(tree.value.uuid)
tree.children = listOf(nextTree) val nextTree = Tree(child)
tree = nextTree tree.children = listOf(nextTree)
} tree = nextTree
} }
updateState(
state.value.copy(
storagesList = list,
storagesRefreshing = false,
),
)
} }
updateState(
state.value.copy(
storagesList = list,
storagesRefreshing = scanInProgress,
),
)
}
} }
} }
@@ -149,6 +167,35 @@ abstract class AbstractVaultBrowserViewModel(
} }
} }
fun rescanStorages() {
if (!supportsStorageRescan) return
if (state.value.storagesRefreshing) {
notifyUser(R.string.vault_msg_rescan_already_in_progress)
return
}
if (isVaultListMutationActive()) {
notifyUser(R.string.vault_msg_vault_list_mutation_busy)
return
}
val vaultUuid = resolveCreateVaultUuid() ?: return
taskOrchestrator.enqueue(
title = uiStrings(R.string.task_title_rescan_vault_storages),
dispatcher = Dispatchers.IO,
locksVaultStorageList = true,
work = { ctx ->
try {
ctx.reportProgress(null, TaskProgressLabel.VaultTask(VaultTaskStep.RescanVaultStorages))
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_rescanning_vault_storages))
manageVaultUseCase.rescanStorages(vaultUuid)
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_rescan_vault_storages_done))
} catch (e: Exception) {
logger.debug(TAG, "rescanStorages failed: ${e.stackTraceToString()}")
ctx.log(TaskLogLevel.Error, uiStrings(R.string.task_log_rescan_vault_storages_failed))
}
},
)
}
fun createStorage() { fun createStorage() {
if (!state.value.addStorageFabEnabled) { if (!state.value.addStorageFabEnabled) {
logger.debug(TAG, "createStorage ignored (vault unavailable or FAB disabled)") logger.debug(TAG, "createStorage ignored (vault unavailable or FAB disabled)")
@@ -207,9 +254,24 @@ abstract class AbstractVaultBrowserViewModel(
ManageStoragesEncryptionUseCase.CanEncryptResult.Allowed -> { ManageStoragesEncryptionUseCase.CanEncryptResult.Allowed -> {
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_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) if (rememberPassword) {
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_encryption_enabled)) manageStoragesEncryptionUseCase.rememberStorageKey(storage, key)
_userNotifications.emit(UserNotification.TextRes(R.string.msg_encryption_enabled)) }
try {
manageStoragesEncryptionUseCase.openStorage(
storage,
key,
rememberPassword = false,
)
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_encryption_enabled))
_userNotifications.emit(UserNotification.TextRes(R.string.msg_encryption_enabled))
} catch (openError: Exception) {
logger.debug(TAG, "open after encrypt failed: ${openError.stackTraceToString()}")
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_encryption_enabled))
_userNotifications.emit(
UserNotification.TextRes(R.string.msg_encryption_enabled_open_failed),
)
}
} }
ManageStoragesEncryptionUseCase.CanEncryptResult.AlreadyEncrypted -> { ManageStoragesEncryptionUseCase.CanEncryptResult.AlreadyEncrypted -> {
ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_already_encrypted)) ctx.log(TaskLogLevel.Info, uiStrings(R.string.task_log_already_encrypted))
@@ -364,6 +426,9 @@ abstract class AbstractVaultBrowserViewModel(
@StringRes @StringRes
fun getStorageStatusRes(storage: IStorageInfo): Int { fun getStorageStatusRes(storage: IStorageInfo): Int {
if (storage.metaLoadState.value == StorageMetaLoadState.Unavailable) {
return R.string.storage_status_meta_unavailable
}
val encrypted = storage.metaInfo.value.encInfo != null val encrypted = storage.metaInfo.value.encInfo != null
if (!encrypted) return R.string.storage_status_not_encrypted if (!encrypted) return R.string.storage_status_not_encrypted
val opened = isEncryptionSessionOpen(storage) val opened = isEncryptionSessionOpen(storage)

View File

@@ -1,7 +1,9 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.vault package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
import com.github.nullptroma.wallenc.domain.interfaces.ILogger import com.github.nullptroma.wallenc.domain.interfaces.ILogger
import com.github.nullptroma.wallenc.domain.interfaces.IVault
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase
@@ -10,6 +12,8 @@ import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase
import com.github.nullptroma.wallenc.usecases.RemoveStorageUseCase import com.github.nullptroma.wallenc.usecases.RemoveStorageUseCase
import com.github.nullptroma.wallenc.usecases.RenameStorageUseCase import com.github.nullptroma.wallenc.usecases.RenameStorageUseCase
import com.github.nullptroma.wallenc.usecases.StorageFileManagementUseCase import com.github.nullptroma.wallenc.usecases.StorageFileManagementUseCase
import com.github.nullptroma.wallenc.vault.contract.DescribedVault
import com.github.nullptroma.wallenc.vault.contract.VaultDescriptor
import com.github.nullptroma.wallenc.vault.contract.described import com.github.nullptroma.wallenc.vault.contract.described
import com.github.nullptroma.wallenc.vault.contract.locals import com.github.nullptroma.wallenc.vault.contract.locals
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -36,6 +40,8 @@ class LocalVaultViewModel @Inject constructor(
storagesFlow = vaultsManager.vaults storagesFlow = vaultsManager.vaults
.map { vaults -> vaults.described().locals.firstOrNull() } .map { vaults -> vaults.described().locals.firstOrNull() }
.flatMapLatest { v -> v?.storages ?: flowOf(emptyList()) }, .flatMapLatest { v -> v?.storages ?: flowOf(emptyList()) },
vaultHeaderFlow = vaultsManager.vaults
.map { vaults -> vaults.described().locals.firstOrNull().toLocalVaultBrowserHeader() },
vaultAvailabilityFlow = vaultsManager.vaults vaultAvailabilityFlow = vaultsManager.vaults
.map { vaults -> vaults.described().locals.firstOrNull() } .map { vaults -> vaults.described().locals.firstOrNull() }
.flatMapLatest { v -> v?.isAvailable ?: flowOf(false) }, .flatMapLatest { v -> v?.isAvailable ?: flowOf(false) },
@@ -50,3 +56,8 @@ class LocalVaultViewModel @Inject constructor(
uiStrings = uiStrings, uiStrings = uiStrings,
logger = logger, logger = logger,
) )
private fun IVault?.toLocalVaultBrowserHeader(): VaultBrowserHeader? {
if ((this as? DescribedVault)?.descriptor !is VaultDescriptor.LocalDevice) return null
return VaultBrowserHeader(titleResId = R.string.screen_title_local_vault)
}

View File

@@ -2,8 +2,13 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import com.github.nullptroma.wallenc.domain.interfaces.ILogger import com.github.nullptroma.wallenc.domain.interfaces.ILogger
import com.github.nullptroma.wallenc.domain.interfaces.IVault
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import com.github.nullptroma.wallenc.vault.contract.CloudBrand
import com.github.nullptroma.wallenc.vault.contract.DescribedVault
import com.github.nullptroma.wallenc.vault.contract.VaultDescriptor
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
@@ -14,6 +19,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@@ -32,6 +38,11 @@ class RemoteVaultViewModel @Inject constructor(
logger: ILogger, logger: ILogger,
) : AbstractVaultBrowserViewModel( ) : AbstractVaultBrowserViewModel(
storagesFlow = manageVaultUseCase.storagesOf(savedStateHandle.requireVaultUuid()), storagesFlow = manageVaultUseCase.storagesOf(savedStateHandle.requireVaultUuid()),
storagesScanInProgressFlow = manageVaultUseCase.storagesScanInProgressOf(
savedStateHandle.requireVaultUuid(),
),
vaultHeaderFlow = manageVaultUseCase.observe(savedStateHandle.requireVaultUuid())
.map { vault -> vault.toRemoteVaultBrowserHeader() },
vaultAvailabilityFlow = manageVaultUseCase.observe(savedStateHandle.requireVaultUuid()) vaultAvailabilityFlow = manageVaultUseCase.observe(savedStateHandle.requireVaultUuid())
.flatMapLatest { v -> v?.isAvailable ?: flowOf(false) }, .flatMapLatest { v -> v?.isAvailable ?: flowOf(false) },
resolveCreateVaultUuid = { savedStateHandle.requireVaultUuid() }, resolveCreateVaultUuid = { savedStateHandle.requireVaultUuid() },
@@ -44,9 +55,25 @@ class RemoteVaultViewModel @Inject constructor(
taskOrchestrator = taskOrchestrator, taskOrchestrator = taskOrchestrator,
uiStrings = uiStrings, uiStrings = uiStrings,
logger = logger, logger = logger,
) ) {
override val supportsStorageRescan: Boolean = true
}
private fun SavedStateHandle.requireVaultUuid(): UUID { private fun SavedStateHandle.requireVaultUuid(): UUID {
val raw = get<String>("vaultUuid") ?: error("Missing vault UUID in navigation arguments") val raw = get<String>("vaultUuid") ?: error("Missing vault UUID in navigation arguments")
return UUID.fromString(raw) return UUID.fromString(raw)
} }
private fun IVault?.toRemoteVaultBrowserHeader(): VaultBrowserHeader? {
val remote = (this as? DescribedVault)?.descriptor as? VaultDescriptor.LinkedRemote ?: return null
val subtitle = when (remote.brand) {
CloudBrand.YANDEX -> remote.accountDisplayName
}
val titleResId = when (remote.brand) {
CloudBrand.YANDEX -> R.string.screen_title_yandex_vault
}
return VaultBrowserHeader(
titleResId = titleResId,
subtitle = subtitle,
)
}

View File

@@ -0,0 +1,8 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
import androidx.annotation.StringRes
data class VaultBrowserHeader(
@param:StringRes val titleResId: Int,
val subtitle: String? = null,
)

View File

@@ -5,6 +5,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -17,6 +18,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -31,7 +33,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -78,124 +79,174 @@ fun VaultBrowserScreen(
val fabBusy = uiState.vaultListMutationActive val fabBusy = uiState.vaultListMutationActive
val showFullscreenLoader = uiState.storagesList.isEmpty() && uiState.storagesRefreshing val showFullscreenLoader = uiState.storagesList.isEmpty() && uiState.storagesRefreshing
val showEmptyState = uiState.storagesList.isEmpty() && !uiState.storagesRefreshing val showEmptyState = uiState.storagesList.isEmpty() && !uiState.storagesRefreshing
val showRescan = viewModel.supportsStorageRescan
val rescanEnabled = showRescan &&
!uiState.vaultListMutationActive &&
!uiState.storagesRefreshing
val isUuidBusy: (UUID) -> Boolean = { uuid -> uuid in uiState.busyStorageUuids } val isUuidBusy: (UUID) -> Boolean = { uuid -> uuid in uiState.busyStorageUuids }
Box { val addFab: @Composable () -> Unit = {
Scaffold( FloatingActionButton(
modifier = modifier, onClick = {
contentWindowInsets = WindowInsets(0.dp), if (fabEnabled && !fabBusy) {
floatingActionButton = { viewModel.createStorage()
FloatingActionButton(
onClick = {
if (fabEnabled && !fabBusy) {
viewModel.createStorage()
}
},
modifier = Modifier.alpha(if (fabEnabled && !fabBusy) 1f else 0.38f),
) {
Icon(
Icons.Filled.Add,
contentDescription = stringResource(
when {
!fabEnabled -> R.string.vault_fab_add_storage_disabled_cd
fabBusy -> R.string.vault_fab_add_storage_busy_cd
else -> R.string.vault_fab_add_storage_cd
},
),
)
} }
}, },
) { innerPadding -> modifier = Modifier.alpha(if (fabEnabled && !fabBusy) 1f else 0.38f),
Column( ) {
modifier = Modifier Icon(
.padding(innerPadding) Icons.Filled.Add,
.fillMaxSize(), contentDescription = stringResource(
) {
if (!fabEnabled) {
Text(
text = stringResource(R.string.vault_unavailable_banner),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
Box(
modifier = Modifier.fillMaxSize(),
) {
when { when {
showEmptyState -> { !fabEnabled -> R.string.vault_fab_add_storage_disabled_cd
Column( fabBusy -> R.string.vault_fab_add_storage_busy_cd
modifier = Modifier else -> R.string.vault_fab_add_storage_cd
.fillMaxSize() },
.padding(24.dp), ),
verticalArrangement = Arrangement.Center, )
horizontalAlignment = Alignment.CenterHorizontally, }
) { }
Text(
text = stringResource(R.string.vault_empty_list_hint), val vaultContent: @Composable (androidx.compose.foundation.layout.PaddingValues) -> Unit = { innerPadding ->
style = MaterialTheme.typography.bodyLarge, Column(
color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier
textAlign = TextAlign.Center, .padding(innerPadding)
.fillMaxSize(),
) {
uiState.header?.let { header ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(header.titleResId),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
)
header.subtitle?.let { subtitle ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (showRescan) {
FilledTonalButton(
onClick = { viewModel.rescanStorages() },
enabled = rescanEnabled,
) {
Text(stringResource(R.string.vault_rescan_storages_action))
}
}
}
}
if (!fabEnabled) {
Text(
text = stringResource(R.string.vault_unavailable_banner),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
when {
showEmptyState -> {
Text(
text = stringResource(
if (showRescan) {
R.string.vault_empty_list_hint_remote
} else {
R.string.vault_empty_list_hint
},
),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.Center)
.padding(horizontal = 24.dp),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(uiState.storagesList) { listItem ->
StorageTree(
modifier = Modifier.padding(8.dp, 8.dp, 8.dp, 0.dp),
tree = listItem,
isUuidBusy = isUuidBusy,
onClick = { onOpenStorageHome(it.value.uuid.toString()) },
onRename = { tree, newName -> viewModel.rename(tree.value, newName) },
onRemove = { tree -> viewModel.remove(tree.value) },
onEncrypt = { tree, password, encryptPath, rememberPassword ->
viewModel.enableEncryption(
tree.value,
password,
encryptPath,
rememberPassword,
)
},
onOpenEncrypted = { tree, password, remember ->
viewModel.openEncryptedStorage(tree.value, password, remember)
},
onCloseEncrypted = { tree -> viewModel.closeEncryptedStorage(tree.value) },
onDisableEncryption = { tree -> viewModel.disableEncryption(tree.value) },
getStatusTextRes = { tree -> viewModel.getStorageStatusRes(tree.value) },
isEncryptionOpened = { tree -> viewModel.isEncryptionSessionOpen(tree.value) },
isStorageSyncLockHeld = { info -> viewModel.isStorageSyncLockHeld(info) },
onClearStorageSyncLock = { info -> viewModel.clearStorageSyncLock(info) },
) )
} }
} item {
else -> { Spacer(modifier = Modifier.height(8.dp))
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(uiState.storagesList) { listItem ->
StorageTree(
modifier = Modifier.padding(8.dp, 8.dp, 8.dp, 0.dp),
tree = listItem,
isUuidBusy = isUuidBusy,
onClick = { onOpenStorageHome(it.value.uuid.toString()) },
onRename = { tree, newName -> viewModel.rename(tree.value, newName) },
onRemove = { tree -> viewModel.remove(tree.value) },
onEncrypt = { tree, password, encryptPath, rememberPassword ->
viewModel.enableEncryption(
tree.value,
password,
encryptPath,
rememberPassword,
)
},
onOpenEncrypted = { tree, password, remember ->
viewModel.openEncryptedStorage(tree.value, password, remember)
},
onCloseEncrypted = { tree -> viewModel.closeEncryptedStorage(tree.value) },
onDisableEncryption = { tree -> viewModel.disableEncryption(tree.value) },
getStatusTextRes = { tree -> viewModel.getStorageStatusRes(tree.value) },
isEncryptionOpened = { tree -> viewModel.isEncryptionSessionOpen(tree.value) },
isStorageSyncLockHeld = { info -> viewModel.isStorageSyncLockHeld(info) },
onClearStorageSyncLock = { info -> viewModel.clearStorageSyncLock(info) },
)
}
item { Spacer(modifier = Modifier.height(8.dp)) }
} }
} }
} }
} }
} }
} }
}
Box(modifier = modifier) {
Scaffold(
contentWindowInsets = WindowInsets(0.dp),
floatingActionButton = addFab,
content = vaultContent,
)
if (showFullscreenLoader) { if (showFullscreenLoader) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.fillMaxSize().alpha(0.6f).background(Color.Black)) Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim),
)
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(64.dp), modifier = Modifier.size(64.dp),
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant, trackColor = MaterialTheme.colorScheme.surfaceVariant,
) )
Text( Text(
text = stringResource(R.string.vault_loading_storages), text = stringResource(R.string.vault_loading_storages),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onPrimary, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp), modifier = Modifier.padding(horizontal = 24.dp),
) )

View File

@@ -5,6 +5,7 @@ import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
import java.util.UUID import java.util.UUID
data class VaultBrowserScreenState( data class VaultBrowserScreenState(
val header: VaultBrowserHeader? = null,
val storagesList: List<Tree<IStorageInfo>>, val storagesList: List<Tree<IStorageInfo>>,
/** Первый снимок списка storages ещё не получен (удалённый vault). */ /** Первый снимок списка storages ещё не получен (удалённый vault). */
val storagesRefreshing: Boolean, val storagesRefreshing: Boolean,

View File

@@ -1,19 +1,25 @@
package com.github.nullptroma.wallenc.ui.screens.shared package com.github.nullptroma.wallenc.ui.screens.shared
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.elements.WallencScreenContentPadding
import com.github.nullptroma.wallenc.ui.elements.WallencScreenScaffold
@Composable @Composable
fun TextEditScreen(text: String) { fun TextEditScreen(
Text( text: String,
text = stringResource(R.string.text_edit_screen_placeholder, text), modifier: Modifier = Modifier,
style = MaterialTheme.typography.bodyLarge, ) {
modifier = Modifier.padding(16.dp), WallencScreenScaffold(modifier = modifier) { innerPadding ->
) WallencScreenContentPadding(innerPadding) {
Text(
text = stringResource(R.string.text_edit_screen_placeholder, text),
style = MaterialTheme.typography.bodyLarge,
)
}
}
} }

View File

@@ -1,6 +1,7 @@
package com.github.nullptroma.wallenc.ui.screens.sync package com.github.nullptroma.wallenc.ui.screens.sync
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
@@ -39,6 +40,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@@ -46,6 +48,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.elements.AnimatedFloatingBackButton
import com.github.nullptroma.wallenc.ui.resources.UserNotification import com.github.nullptroma.wallenc.ui.resources.UserNotification
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind
import java.util.UUID import java.util.UUID
@@ -441,28 +444,21 @@ private fun StoragePickerScreen(
contentWindowInsets = WindowInsets(0.dp), contentWindowInsets = WindowInsets(0.dp),
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
) { inner -> ) { inner ->
Column( Box(
modifier = Modifier modifier = Modifier
.padding(inner) .padding(inner)
.fillMaxSize() .fillMaxSize(),
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Row( Column(
horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier
verticalAlignment = Alignment.CenterVertically, .fillMaxSize()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.sync_cd_picker_back),
)
}
Text( Text(
text = stringResource(id = R.string.sync_picker_title, groupId), text = stringResource(id = R.string.sync_picker_title, groupId),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleLarge,
) )
}
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -539,6 +535,15 @@ private fun StoragePickerScreen(
} }
} }
} }
}
AnimatedFloatingBackButton(
visible = true,
onClick = onBack,
modifier = Modifier
.align(Alignment.BottomStart)
.navigationBarsPadding()
.padding(start = 12.dp, bottom = 12.dp),
)
} }
} }
} }

View File

@@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@@ -44,16 +45,24 @@ class StorageSyncViewModel @Inject constructor(
observeVaults() observeVaults()
observeStorageSyncPipeline() observeStorageSyncPipeline()
viewModelScope.launch { viewModelScope.launch {
vaultsManager.vaults combine(
.flatMapLatest { vaults -> vaultsManager.vaults,
if (vaults.isEmpty()) { state.map { it.groups },
flowOf(false) ) { vaults, groups ->
} else { val requiredUuids = groups.flatMap { it.storageUuids }.toSet()
combine(vaults.map { it.storagesScanInProgress }) { flags -> if (requiredUuids.isEmpty() || vaults.isEmpty()) {
flags.any { it } false
} } else {
val opened = vaultsManager.unlockManager.openedStorages.value
vaults.any { vault ->
val uuidsInVault = vault.storages.value.flatMap { root ->
flattenStorages(buildStorageTree(root, opened))
}.map { it.uuid }.toSet()
uuidsInVault.any { it in requiredUuids } &&
vault.storagesScanInProgress.value
} }
} }
}
.distinctUntilChanged() .distinctUntilChanged()
.collect { scanning -> .collect { scanning ->
updateState(state.value.copy(anyVaultStoragesScanning = scanning)) updateState(state.value.copy(anyVaultStoragesScanning = scanning))

View File

@@ -7,9 +7,17 @@
<string name="nav_label_main">Главная</string> <string name="nav_label_main">Главная</string>
<string name="nav_label_sync">Синхронизация</string> <string name="nav_label_sync">Синхронизация</string>
<string name="nav_label_settings">Настройки</string> <string name="nav_label_settings">Настройки</string>
<string name="nav_cd_back">Назад</string>
<string name="screen_title_remote_vault">Удалённое хранилище</string>
<string name="screen_title_local_vault">Локальное хранилище</string>
<string name="screen_title_yandex_vault">Хранилище Яндекс.Диска</string>
<string name="screen_title_storage">Хранилище</string>
<string name="screen_title_two_fa">Токены 2FA</string>
<string name="screen_title_text_secrets">Текстовые секреты</string>
<string name="screen_title_text_edit">Текст</string>
<string name="main_work_status_label">Статус:</string> <string name="main_work_status_label">Статус:</string>
<string name="main_status_multiple_tasks">Выполняется задач: %1$d</string> <string name="main_status_multiple_tasks">Выполняется задач: %1$d</string>
<string name="main_status_vault_scanning_storages">Сканирование vault: загрузка списка хранилищ</string> <string name="main_status_vault_scanning_storages">Сканирование vault: загрузка списка хранилищ</string>
<string name="settings_title">Настройки</string> <string name="settings_title">Настройки</string>
<string name="sync_groups_title">Группы синхронизации</string> <string name="sync_groups_title">Группы синхронизации</string>
<string name="sync_progress_section_title">Синхронизация хранилищ</string> <string name="sync_progress_section_title">Синхронизация хранилищ</string>
@@ -75,6 +83,9 @@
<string name="storage_field_size">Размер: %1$s</string> <string name="storage_field_size">Размер: %1$s</string>
<string name="storage_field_virtual">Виртуальное: %1$s</string> <string name="storage_field_virtual">Виртуальное: %1$s</string>
<string name="storage_unavailable_hint">Хранилище недоступно</string> <string name="storage_unavailable_hint">Хранилище недоступно</string>
<string name="storage_meta_unavailable_hint">Метаданные недоступны — переименование, шифрование и открытие отключены</string>
<string name="storage_status_meta_unavailable">Метаданные недоступны</string>
<string name="storage_home_meta_unavailable">Не удалось загрузить метаданные хранилища. 2FA и текстовые секреты недоступны.</string>
<string name="storage_menu_unavailable">Недоступно: %1$s</string> <string name="storage_menu_unavailable">Недоступно: %1$s</string>
<string name="storage_status_not_encrypted">Не зашифровано</string> <string name="storage_status_not_encrypted">Не зашифровано</string>
<string name="storage_status_encrypted_open">Зашифровано (открыто)</string> <string name="storage_status_encrypted_open">Зашифровано (открыто)</string>
@@ -84,9 +95,12 @@
<string name="vault_fab_add_storage_busy_cd">Создание хранилища уже выполняется</string> <string name="vault_fab_add_storage_busy_cd">Создание хранилища уже выполняется</string>
<string name="vault_msg_storage_pipeline_busy">С этим хранилищем уже выполняется операция</string> <string name="vault_msg_storage_pipeline_busy">С этим хранилищем уже выполняется операция</string>
<string name="vault_msg_vault_list_mutation_busy">Список хранилищ сейчас меняется — подождите</string> <string name="vault_msg_vault_list_mutation_busy">Список хранилищ сейчас меняется — подождите</string>
<string name="vault_msg_rescan_already_in_progress">Сканирование хранилищ уже выполняется</string>
<string name="vault_unavailable_banner">Хранилище недоступно. Проверьте сеть, путь или разблокировку.</string> <string name="vault_unavailable_banner">Хранилище недоступно. Проверьте сеть, путь или разблокировку.</string>
<string name="vault_loading_storages">Загрузка списка хранилищ…</string> <string name="vault_loading_storages">Загрузка списка хранилищ…</string>
<string name="vault_empty_list_hint">В этом хранилище пока нет каталогов. Создайте хранилище кнопкой «+», когда оно доступно.</string> <string name="vault_empty_list_hint">В этом хранилище пока нет каталогов. Создайте хранилище кнопкой «+», когда оно доступно.</string>
<string name="vault_empty_list_hint_remote">На удалённом хранилище каталоги не найдены. Если папки уже есть на сервере, нажмите «Обновить список», либо создайте хранилище кнопкой «+», когда оно доступно.</string>
<string name="vault_rescan_storages_action">Обновить список</string>
<string name="task_pipeline_title">Очередь задач</string> <string name="task_pipeline_title">Очередь задач</string>
<string name="task_pipeline_jobs">Задачи</string> <string name="task_pipeline_jobs">Задачи</string>
<string name="task_pipeline_log">Журнал</string> <string name="task_pipeline_log">Журнал</string>
@@ -109,22 +123,23 @@
<string name="task_title_create_storage">Создание хранилища</string> <string name="task_title_create_storage">Создание хранилища</string>
<string name="task_title_enable_encryption">Включение шифрования</string> <string name="task_title_enable_encryption">Включение шифрования</string>
<string name="task_title_open_encrypted_storage">Расшифровка и открытие хранилища</string> <string name="task_title_open_encrypted_storage">Расшифровка и открытие хранилища</string>
<string name="task_progress_decrypt_running">Расшифровка</string> <string name="task_progress_decrypt_running">Расшифровка</string>
<string name="task_progress_dump_storage_log">Сканирование дерева</string> <string name="task_progress_dump_storage_log">Сканирование дерева</string>
<string name="task_progress_create_storage">Создание хранилища</string> <string name="task_progress_create_storage">Создание хранилища</string>
<string name="task_progress_enable_encryption">Шифрование</string> <string name="task_progress_enable_encryption">Шифрование</string>
<string name="task_progress_close_storage">Закрытие хранилища</string> <string name="task_progress_close_storage">Закрытие хранилища</string>
<string name="task_progress_disable_encryption">Очистка содержимого</string> <string name="task_progress_disable_encryption">Очистка содержимого</string>
<string name="task_progress_rename_storage">Переименование</string> <string name="task_progress_rename_storage">Переименование</string>
<string name="task_progress_remove_storage">Удаление</string> <string name="task_progress_remove_storage">Удаление</string>
<string name="task_progress_clear_sync_lock">Снятие блокировки</string> <string name="task_progress_clear_sync_lock">Снятие блокировки</string>
<string name="task_progress_add_remote_vault">Добавление</string> <string name="task_progress_add_remote_vault">Добавление</string>
<string name="task_progress_remove_remote_vault">Удаление</string> <string name="task_progress_remove_remote_vault">Удаление</string>
<string name="task_progress_retry_remote_vault">Подключение</string> <string name="task_progress_retry_remote_vault">Подключение</string>
<string name="task_progress_save_2fa_token">Сохранение…</string> <string name="task_progress_rescan_vault_storages">Сканирование хранилищ</string>
<string name="task_progress_delete_2fa_token">Удаление</string> <string name="task_progress_save_2fa_token">Сохранение</string>
<string name="task_progress_save_text_secret">Сохранение</string> <string name="task_progress_delete_2fa_token">Удаление</string>
<string name="task_progress_delete_text_secret">Удаление</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_close_encrypted_storage">Закрытие зашифрованного хранилища</string>
<string name="task_title_disable_encryption">Отключение шифрования</string> <string name="task_title_disable_encryption">Отключение шифрования</string>
<string name="task_title_rename_storage">Переименование хранилища</string> <string name="task_title_rename_storage">Переименование хранилища</string>
@@ -133,6 +148,7 @@
<string name="task_title_add_remote_vault">Добавление удалённого хранилища</string> <string name="task_title_add_remote_vault">Добавление удалённого хранилища</string>
<string name="task_title_remove_remote_vault">Удаление удалённого хранилища</string> <string name="task_title_remove_remote_vault">Удаление удалённого хранилища</string>
<string name="task_title_retry_remote_vault">Повторное подключение удалённого хранилища</string> <string name="task_title_retry_remote_vault">Повторное подключение удалённого хранилища</string>
<string name="task_title_rescan_vault_storages">Обновление списка хранилищ</string>
<string name="task_title_storage_sync">Синхронизация хранилищ</string> <string name="task_title_storage_sync">Синхронизация хранилищ</string>
<string name="task_title_storage_sync_background">Фоновая синхронизация хранилищ</string> <string name="task_title_storage_sync_background">Фоновая синхронизация хранилищ</string>
<string name="task_title_save_2fa_token">Сохранение 2FA токена</string> <string name="task_title_save_2fa_token">Сохранение 2FA токена</string>
@@ -162,6 +178,7 @@
<string name="vault_link_error_unknown">Не удалось войти</string> <string name="vault_link_error_unknown">Не удалось войти</string>
<string name="vault_link_error_unsupported_brand">Этот провайдер не поддерживается</string> <string name="vault_link_error_unsupported_brand">Этот провайдер не поддерживается</string>
<string name="msg_encryption_enabled">Шифрование включено</string> <string name="msg_encryption_enabled">Шифрование включено</string>
<string name="msg_encryption_enabled_open_failed">Шифрование включено; откройте хранилище вручную для просмотра</string>
<string name="msg_storage_already_encrypted">Хранилище уже зашифровано</string> <string name="msg_storage_already_encrypted">Хранилище уже зашифровано</string>
<string name="msg_storage_not_empty">Хранилище не пустое</string> <string name="msg_storage_not_empty">Хранилище не пустое</string>
<string name="msg_storage_empty_state_unknown">Не удалось определить, пусто ли хранилище</string> <string name="msg_storage_empty_state_unknown">Не удалось определить, пусто ли хранилище</string>
@@ -314,6 +331,9 @@
<string name="task_log_retrying_vault">Повторное подключение…</string> <string name="task_log_retrying_vault">Повторное подключение…</string>
<string name="task_log_retry_requested">Повтор запрошен</string> <string name="task_log_retry_requested">Повтор запрошен</string>
<string name="task_log_retry_vault_failed">Не удалось повторить подключение</string> <string name="task_log_retry_vault_failed">Не удалось повторить подключение</string>
<string name="task_log_rescanning_vault_storages">Повторное сканирование хранилищ на удалённом vault…</string>
<string name="task_log_rescan_vault_storages_done">Список хранилищ обновлён</string>
<string name="task_log_rescan_vault_storages_failed">Не удалось обновить список хранилищ</string>
<string name="task_log_test_started">Тестовая задача запущена на %1$d с</string> <string name="task_log_test_started">Тестовая задача запущена на %1$d с</string>
<string name="task_log_test_finished">Тестовая задача завершена</string> <string name="task_log_test_finished">Тестовая задача завершена</string>
</resources> </resources>

View File

@@ -7,9 +7,17 @@
<string name="nav_label_main">Home</string> <string name="nav_label_main">Home</string>
<string name="nav_label_sync">Sync</string> <string name="nav_label_sync">Sync</string>
<string name="nav_label_settings">Settings</string> <string name="nav_label_settings">Settings</string>
<string name="nav_cd_back">Go back</string>
<string name="screen_title_remote_vault">Remote vault</string>
<string name="screen_title_local_vault">Local vault</string>
<string name="screen_title_yandex_vault">Yandex Disk vault</string>
<string name="screen_title_storage">Storage</string>
<string name="screen_title_two_fa">2FA tokens</string>
<string name="screen_title_text_secrets">Text secrets</string>
<string name="screen_title_text_edit">Text</string>
<string name="main_work_status_label">Status:</string> <string name="main_work_status_label">Status:</string>
<string name="main_status_multiple_tasks">Running tasks: %1$d</string> <string name="main_status_multiple_tasks">Running tasks: %1$d</string>
<string name="main_status_vault_scanning_storages">Scanning vault: loading storage list</string> <string name="main_status_vault_scanning_storages">Scanning vault: loading storage list</string>
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>
<string name="sync_groups_title">Sync groups</string> <string name="sync_groups_title">Sync groups</string>
<string name="sync_progress_section_title">Storage sync</string> <string name="sync_progress_section_title">Storage sync</string>
@@ -75,6 +83,9 @@
<string name="storage_field_size">Size: %1$s</string> <string name="storage_field_size">Size: %1$s</string>
<string name="storage_field_virtual">Virtual: %1$s</string> <string name="storage_field_virtual">Virtual: %1$s</string>
<string name="storage_unavailable_hint">Storage unavailable</string> <string name="storage_unavailable_hint">Storage unavailable</string>
<string name="storage_meta_unavailable_hint">Metadata unavailable — rename, encryption, and open are disabled</string>
<string name="storage_status_meta_unavailable">Metadata unavailable</string>
<string name="storage_home_meta_unavailable">Storage metadata could not be loaded. 2FA and text secrets are unavailable.</string>
<string name="storage_menu_unavailable">Unavailable: %1$s</string> <string name="storage_menu_unavailable">Unavailable: %1$s</string>
<string name="storage_status_not_encrypted">Not encrypted</string> <string name="storage_status_not_encrypted">Not encrypted</string>
<string name="storage_status_encrypted_open">Encrypted (open)</string> <string name="storage_status_encrypted_open">Encrypted (open)</string>
@@ -84,9 +95,12 @@
<string name="vault_fab_add_storage_busy_cd">Storage creation already running</string> <string name="vault_fab_add_storage_busy_cd">Storage creation already running</string>
<string name="vault_msg_storage_pipeline_busy">An operation is already running for this storage</string> <string name="vault_msg_storage_pipeline_busy">An operation is already running for this storage</string>
<string name="vault_msg_vault_list_mutation_busy">Storage list is changing — please wait</string> <string name="vault_msg_vault_list_mutation_busy">Storage list is changing — please wait</string>
<string name="vault_msg_rescan_already_in_progress">Storage scan is already in progress</string>
<string name="vault_unavailable_banner">Vault unavailable. Check network, path, or unlock.</string> <string name="vault_unavailable_banner">Vault unavailable. Check network, path, or unlock.</string>
<string name="vault_loading_storages">Loading storage list…</string> <string name="vault_loading_storages">Loading storage list…</string>
<string name="vault_empty_list_hint">No folders yet. Create storage with "+" when available.</string> <string name="vault_empty_list_hint">No folders yet. Create storage with "+" when available.</string>
<string name="vault_empty_list_hint_remote">No storages found on the remote vault. Tap rescan if folders already exist on the server, or create one with "+" when available.</string>
<string name="vault_rescan_storages_action">Rescan storages</string>
<string name="task_pipeline_title">Task queue</string> <string name="task_pipeline_title">Task queue</string>
<string name="task_pipeline_jobs">Tasks</string> <string name="task_pipeline_jobs">Tasks</string>
<string name="task_pipeline_log">Log</string> <string name="task_pipeline_log">Log</string>
@@ -109,22 +123,23 @@
<string name="task_title_create_storage">Create storage</string> <string name="task_title_create_storage">Create storage</string>
<string name="task_title_enable_encryption">Enable encryption</string> <string name="task_title_enable_encryption">Enable encryption</string>
<string name="task_title_open_encrypted_storage">Decrypt and open storage</string> <string name="task_title_open_encrypted_storage">Decrypt and open storage</string>
<string name="task_progress_decrypt_running">Decrypting</string> <string name="task_progress_decrypt_running">Decrypting</string>
<string name="task_progress_dump_storage_log">Scanning tree</string> <string name="task_progress_dump_storage_log">Scanning tree</string>
<string name="task_progress_create_storage">Creating storage</string> <string name="task_progress_create_storage">Creating storage</string>
<string name="task_progress_enable_encryption">Encrypting</string> <string name="task_progress_enable_encryption">Encrypting</string>
<string name="task_progress_close_storage">Closing storage</string> <string name="task_progress_close_storage">Closing storage</string>
<string name="task_progress_disable_encryption">Clearing content</string> <string name="task_progress_disable_encryption">Clearing content</string>
<string name="task_progress_rename_storage">Renaming</string> <string name="task_progress_rename_storage">Renaming</string>
<string name="task_progress_remove_storage">Removing</string> <string name="task_progress_remove_storage">Removing</string>
<string name="task_progress_clear_sync_lock">Clearing sync lock</string> <string name="task_progress_clear_sync_lock">Clearing sync lock</string>
<string name="task_progress_add_remote_vault">Adding</string> <string name="task_progress_add_remote_vault">Adding</string>
<string name="task_progress_remove_remote_vault">Removing</string> <string name="task_progress_remove_remote_vault">Removing</string>
<string name="task_progress_retry_remote_vault">Connecting</string> <string name="task_progress_retry_remote_vault">Connecting</string>
<string name="task_progress_save_2fa_token">Saving…</string> <string name="task_progress_rescan_vault_storages">Scanning storages</string>
<string name="task_progress_delete_2fa_token">Removing</string> <string name="task_progress_save_2fa_token">Saving</string>
<string name="task_progress_save_text_secret">Saving</string> <string name="task_progress_delete_2fa_token">Removing</string>
<string name="task_progress_delete_text_secret">Removing</string> <string name="task_progress_save_text_secret">Saving</string>
<string name="task_progress_delete_text_secret">Removing</string>
<string name="task_title_close_encrypted_storage">Close encrypted storage</string> <string name="task_title_close_encrypted_storage">Close encrypted storage</string>
<string name="task_title_disable_encryption">Disable encryption</string> <string name="task_title_disable_encryption">Disable encryption</string>
<string name="task_title_rename_storage">Rename storage</string> <string name="task_title_rename_storage">Rename storage</string>
@@ -133,6 +148,7 @@
<string name="task_title_add_remote_vault">Add remote vault</string> <string name="task_title_add_remote_vault">Add remote vault</string>
<string name="task_title_remove_remote_vault">Remove remote vault</string> <string name="task_title_remove_remote_vault">Remove remote vault</string>
<string name="task_title_retry_remote_vault">Retry remote vault connection</string> <string name="task_title_retry_remote_vault">Retry remote vault connection</string>
<string name="task_title_rescan_vault_storages">Rescan vault storages</string>
<string name="task_title_storage_sync">Storage sync</string> <string name="task_title_storage_sync">Storage sync</string>
<string name="task_title_storage_sync_background">Background storage sync</string> <string name="task_title_storage_sync_background">Background storage sync</string>
<string name="task_title_save_2fa_token">Save 2FA token</string> <string name="task_title_save_2fa_token">Save 2FA token</string>
@@ -162,6 +178,7 @@
<string name="vault_link_error_unknown">Sign-in failed</string> <string name="vault_link_error_unknown">Sign-in failed</string>
<string name="vault_link_error_unsupported_brand">This provider is not supported</string> <string name="vault_link_error_unsupported_brand">This provider is not supported</string>
<string name="msg_encryption_enabled">Encryption enabled</string> <string name="msg_encryption_enabled">Encryption enabled</string>
<string name="msg_encryption_enabled_open_failed">Encryption enabled; unlock the storage manually to view contents</string>
<string name="msg_storage_already_encrypted">Storage is already encrypted</string> <string name="msg_storage_already_encrypted">Storage is already encrypted</string>
<string name="msg_storage_not_empty">Storage is not empty</string> <string name="msg_storage_not_empty">Storage is not empty</string>
<string name="msg_storage_empty_state_unknown">Could not determine if storage is empty</string> <string name="msg_storage_empty_state_unknown">Could not determine if storage is empty</string>
@@ -314,6 +331,9 @@
<string name="task_log_retrying_vault">Retrying remote vault connection…</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_requested">Retry requested</string>
<string name="task_log_retry_vault_failed">Failed to retry remote vault</string> <string name="task_log_retry_vault_failed">Failed to retry remote vault</string>
<string name="task_log_rescanning_vault_storages">Rescanning storages on remote vault…</string>
<string name="task_log_rescan_vault_storages_done">Storage list updated</string>
<string name="task_log_rescan_vault_storages_failed">Failed to rescan storages</string>
<string name="task_log_test_started">Test task started for %1$d s</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> <string name="task_log_test_finished">Test task finished</string>
</resources> </resources>

View File

@@ -46,6 +46,14 @@ class ManageStoragesEncryptionUseCase @Inject constructor(
} }
} }
suspend fun rememberStorageKey(storage: IStorageInfo, key: EncryptKey) {
if (storage is IStorage) {
unlockManager.rememberKey(storage, key)
return
}
throw IllegalStateException("Unsupported storage type")
}
suspend fun openStorage(storage: IStorageInfo, key: EncryptKey, rememberPassword: Boolean): IStorageInfo { suspend fun openStorage(storage: IStorageInfo, key: EncryptKey, rememberPassword: Boolean): IStorageInfo {
if (storage is IStorage) return unlockManager.open(storage, key, rememberPassword) if (storage is IStorage) return unlockManager.open(storage, key, rememberPassword)
throw IllegalStateException("Unsupported storage type") throw IllegalStateException("Unsupported storage type")

View File

@@ -30,10 +30,21 @@ class ManageVaultUseCase @Inject constructor(
fun storagesOf(vaultUuid: UUID): Flow<List<IStorage>> = fun storagesOf(vaultUuid: UUID): Flow<List<IStorage>> =
observe(vaultUuid).flatMapLatest { vault -> vault?.storages ?: flowOf(emptyList()) } observe(vaultUuid).flatMapLatest { vault -> vault?.storages ?: flowOf(emptyList()) }
/** Идёт листинг/пересканирование storages vault'а. */
fun storagesScanInProgressOf(vaultUuid: UUID): Flow<Boolean> =
observe(vaultUuid).flatMapLatest { vault -> vault?.storagesScanInProgress ?: flowOf(false) }
/** Создать новое хранилище в указанном vault'е. */ /** Создать новое хранилище в указанном vault'е. */
suspend fun createStorage(vaultUuid: UUID): IStorage { suspend fun createStorage(vaultUuid: UUID): IStorage {
val vault = find(vaultUuid) val vault = find(vaultUuid)
?: throw IllegalStateException("Vault $vaultUuid is not registered") ?: throw IllegalStateException("Vault $vaultUuid is not registered")
return vault.createStorage() return vault.createStorage()
} }
/** Пересканировать storages vault'а (листинг на Диске и повторный init). */
suspend fun rescanStorages(vaultUuid: UUID) {
val vault = find(vaultUuid)
?: throw IllegalStateException("Vault $vaultUuid is not registered")
vault.rescanStorages()
}
} }

View File

@@ -11,9 +11,21 @@ data class TwoFaCodeState(
val secondsUntilRefresh: Int, val secondsUntilRefresh: Int,
) )
/** Доля прошедшего TOTP-периода [0f, 1f] для плавного progress bar. */
fun totpPeriodProgress(nowMillis: Long, periodSeconds: Int): Float {
val period = periodSeconds.coerceAtLeast(1)
val elapsedSec = (nowMillis / 1000.0) % period
return (elapsedSec / period).toFloat().coerceIn(0f, 1f)
}
fun totpSecondsUntilRefresh(nowMillis: Long, periodSeconds: Int): Int {
val period = periodSeconds.coerceAtLeast(1)
return (period - ((nowMillis / 1000L) % period)).toInt().coerceAtLeast(0)
}
fun buildTwoFaCodeState(token: TwoFaTokenRecord, nowMillis: Long): TwoFaCodeState? { fun buildTwoFaCodeState(token: TwoFaTokenRecord, nowMillis: Long): TwoFaCodeState? {
val period = token.periodSeconds.coerceAtLeast(1) val period = token.periodSeconds.coerceAtLeast(1)
val remaining = (period - ((nowMillis / 1000L) % period)).toInt().coerceAtLeast(0) val remaining = totpSecondsUntilRefresh(nowMillis, period)
val code = generateTotpCode( val code = generateTotpCode(
secret = token.secret, secret = token.secret,
nowMillis = nowMillis, nowMillis = nowMillis,

View File

@@ -33,6 +33,24 @@ class TwoFaTotpTest {
assertTrue(state.secondsUntilRefresh in 1..30) assertTrue(state.secondsUntilRefresh in 1..30)
} }
@Test
fun totpPeriodProgressIsContinuousWithinPeriod() {
val period = 30
val periodStartMillis = 1_700_000_100_000L // epoch sec кратна period
assertEquals(0f, totpPeriodProgress(periodStartMillis, period), 0.001f)
assertEquals(0.5f, totpPeriodProgress(periodStartMillis + 15_000L, period), 0.001f)
assertEquals(29f / 30f, totpPeriodProgress(periodStartMillis + 29_000L, period), 0.001f)
}
@Test
fun totpSecondsUntilRefreshCountsDownWithinPeriod() {
val period = 30
val t0 = 1_700_000_100_000L
assertEquals(30, totpSecondsUntilRefresh(t0, period))
assertEquals(15, totpSecondsUntilRefresh(t0 + 15_000L, period))
assertEquals(1, totpSecondsUntilRefresh(t0 + 29_000L, period))
}
@Test @Test
fun buildTwoFaCodeStateReturnsNullForInvalidSecret() { fun buildTwoFaCodeStateReturnsNullForInvalidSecret() {
val token = TwoFaTokenRecord( val token = TwoFaTokenRecord(

View File

@@ -1,6 +1,7 @@
package com.github.nullptroma.wallenc.usecases.fakes package com.github.nullptroma.wallenc.usecases.fakes
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.datatypes.StorageMetaLoadState
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry
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
@@ -23,6 +24,8 @@ class FakeStorage(
override val numberOfFiles: StateFlow<Int?> = MutableStateFlow(0) override val numberOfFiles: StateFlow<Int?> = MutableStateFlow(0)
override val isEmpty: Flow<Boolean?> = flowOf(true) override val isEmpty: Flow<Boolean?> = flowOf(true)
override val metaInfo: StateFlow<IStorageMetaInfo> = MutableStateFlow(meta) override val metaInfo: StateFlow<IStorageMetaInfo> = MutableStateFlow(meta)
override val metaLoadState: StateFlow<StorageMetaLoadState> =
MutableStateFlow(StorageMetaLoadState.Ready)
override val isVirtualStorage: Boolean = false override val isVirtualStorage: Boolean = false
override val accessor: IStorageAccessor = accessorImpl override val accessor: IStorageAccessor = accessorImpl

View File

@@ -24,6 +24,8 @@ class FakeUnlockManager : IUnlockManager {
override suspend fun open(storage: IStorage, key: EncryptKey, rememberPassword: Boolean): IStorage = storage override suspend fun open(storage: IStorage, key: EncryptKey, rememberPassword: Boolean): IStorage = storage
override suspend fun rememberKey(storage: IStorage, key: EncryptKey) = Unit
override suspend fun close(storage: IStorage) = Unit override suspend fun close(storage: IStorage) = Unit
override suspend fun close(uuid: UUID) = Unit override suspend fun close(uuid: UUID) = Unit