Files
Wallenc/Report/includes/ch04.typ

161 lines
15 KiB
Typst
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#import "common.typ": pz-fig, pz-table
= Программная реализация
== Разработка программных модулей
Архитектура реализации следует принятой на этапе проектирования схеме MVVM + Clean Architecture. Модули Gradle разделены по ответственности: контракты vault, доменная логика, сценарии use case, Android-инфраструктура (Room, OAuth, файловые адаптеры), UI и точка входа `:app`. Такое разбиение позволило параллельно развивать локальный и удалённый контуры и изолировать криптографию от представления данных.
=== Модуль криптографической защиты данных
Класс `Encryptor` формирует `StorageEncryptionInfo`, проверяет ключ и выполняет шифрование/дешифрование на клиенте. Unit-тесты подтверждают корректность для верного и неверного ключа (гл. 5).
=== Модуль управления storage и шифрованием
Use case `ManageStoragesEncryptionUseCase` инкапсулирует проверку `canEncrypt`, включение шифрования и открытие хранилища. ViewModel предотвращает повторный запуск шифрования для занятого storage.
Фрагмент логики включения шифрования:
```kotlin
fun enableEncryption(storage: IStorageInfo, password: String, encryptPath: Boolean) {
val key = EncryptKey(password)
viewModelScope.launch {
when (manageStoragesEncryptionUseCase.canEncrypt(storage)) {
ManageStoragesEncryptionUseCase.CanEncryptResult.Allowed -> {
manageStoragesEncryptionUseCase.enableEncryption(storage, key, encryptPath)
manageStoragesEncryptionUseCase.openStorage(storage, key, true)
}
ManageStoragesEncryptionUseCase.CanEncryptResult.AlreadyEncrypted -> { /* сообщение */ }
else -> { /* неподдерживаемая операция */ }
}
}
}
```
Блок-схема сценария рис. @fig-21.
#pz-fig("fig_21_encrypt_flow.png", [Блок-схема: enableEncryption checkKey openStorage], "fig-21")
=== Модуль адаптеров хранилищ
`VaultsManager` агрегирует один `LocalVault` и удалённые vault; адаптеры реализуют доступ к файлам внутри каждого `IStorage`. Регистрация удалённых vault через модуль `:vault-contracts`.
=== Модуль синхронизации хранилищ
В Room хранится сущность `DbStorageSyncGroup` набор UUID `Storage`, которые должны иметь согласованное состояние. Запуск синхронизации выполняется через `RunStorageSyncUseCase` / `WorkManager` и debounce при изменении файлов (рис. @fig-01@fig-03). Движок `StorageSyncEngine` (модуль `:usecases`) реализует согласование журналов изменений; доработка касается в основном политики фонового расписания и UX отображения прогресса.
Для каждого `Storage` ведётся журнал изменений по относительным путям пользовательских файлов. Запись журнала (`StorageSyncJournalEntry`) содержит операцию (`UPSERT`, `TRASH`, `DELETE`) и ревизию (`sequence`, `actorId`, `createdAt`). Ключи шифрования в обмен не включаются провайдер видит только зашифрованные объекты. Модуль `:task-runtime` обслуживает длительные задачи шифрования и синхронизации без блокировки UI.
=== Алгоритм согласования журналов синхронизации
Синхронизация одной группы выполняется в несколько этапов (рис. @fig-35).
#pz-fig("fig_35_sync_merge_algorithm.png", [Алгоритм согласования журналов (StorageSyncEngine)], "fig-35")
*Подготовка.* По UUID из `DbStorageSyncGroup` загружаются объекты `IStorage`. Если в группе меньше двух хранилищ или несовместимы параметры шифрования, синхронизация пропускается. На каждом accessor запрашивается блокировка sync (lease на удалённом диске best-effort; внутри процесса группа сериализуется `Mutex`). Параллельно для каждого storage вызываются `flushPendingSyncJournal()` и `readSyncJournal()`; служебные пути отфильтровываются (`StorageSyncPaths.isSyncableUserPath`).
*Слияние журналов.* Объект `StorageSyncJournalMerge` объединяет журналы в отображение «путь победитель». Для каждого пути остаётся запись с наибольшей ревизией. Сравнение реализовано функцией `compareEntries`: сначала `revision.sequence`, при равенстве `actorId`, затем `createdAt`. Такой порядок обеспечивает детерминированный выбор одной записи при конкурентных изменениях на разных устройствах.
*Выбор источника для пути.* После слияния для каждой пары (путь, `winnerEntry`) вызывается `findSourceStorage`:
- при `UPSERT` первый `Storage`, у которого запись по этому пути *совпадает* с победителем (`compareEntries == 0`): оттуда читаются байты для копирования;
- при `DELETE` или `TRASH` первый `Storage`, где путь ещё присутствует в журнале (или любой storage группы, если записей нет): оттуда инициируется удаление на целях.
Если для `UPSERT` источник не найден, путь пропускается (файл уже отсутствует на всех носителях).
*Распространение на цели.* Для каждого `target` в группе, отличного от источника, сравнивается ревизия записи на цели с `winnerEntry`. Если цель уже не слабее победителя (`compareEntries(target, winner) >= 0`), шаг пропускается. Иначе вызывается `applyEntry`: для `UPSERT` потоковое копирование `openRead` `openWrite`; для `DELETE` / `TRASH` `delete` или `moveToTrash`. Все операции выполняются с `recordSyncJournal = false`, чтобы не порождать цикл повторной синхронизации. Ошибки отдельных путей учитываются счётчиком `applyFailures`, отмена кооперативная (проверка `syncGeneration`, снятие блокировок в `finally`).
Фрагмент сравнения ревизий и выбора источника:
```kotlin
private fun compareEntries(a: StorageSyncJournalEntry, b: StorageSyncJournalEntry): Int {
val seqCmp = a.revision.sequence.compareTo(b.revision.sequence)
if (seqCmp != 0) return seqCmp
val actorCmp = a.revision.actorId.compareTo(b.revision.actorId)
if (actorCmp != 0) return actorCmp
return a.revision.createdAt.compareTo(b.revision.createdAt)
}
private fun findSourceStorage(..., winnerEntry: StorageSyncJournalEntry): IStorage? {
if (winnerEntry.operation == DELETE || winnerEntry.operation == TRASH) {
return storages.firstOrNull { entriesByStorage[it.uuid]?.get(path) != null }
?: storages.firstOrNull()
}
return storages.firstOrNull { storage ->
val entry = entriesByStorage[storage.uuid]?.get(path) ?: return@firstOrNull false
compareEntries(entry, winnerEntry) == 0
}
}
```
*Ограничения модели.* Механизм не является полноценным CRDT: конфликты снимаются фиксированным порядком ревизий, а не автоматическим слиянием содержимого. Содержимое файла при расхождении версий без роста `sequence` на одном пути не анализируется побайтно. Шифротекст передаётся как есть; расшифровка на стороне провайдера не предполагается. Корректность алгоритма проверена unit-тестами `StorageSyncEngineTest` (гл. 5): слияние одной записи на путь, пропуск цели с актуальной ревизией, копирование и удаление, cooperative cancellation.
=== Использование средств ИИ при разработке
Разработка Wallenc велась в два этапа. На первом этапе исполнитель самостоятельно спроектировал доменную модель (иерархия vault storage файлы, единый `VaultsManager`), навигацию между экранами, визуальный стиль UI на Jetpack Compose @smith-jetpack-compose, границы Gradle-модулей и принципы сопровождаемого кода @martin-clean-code и каркас use case-слоя. Криптографический контур (`Encryptor`, привязка ключей к storage), журнал синхронизации и сценарии OAuth проектировались и проверялись вручную. При проектировании модуля `:domain` была составлена диаграмма классов (рис. @fig-24), фиксирующая первоначальные интерфейсы и связи сущностей до этапа разработки с ИИ-ассистентом.
#pz-fig("fig_24_domain_class_manual.png", [Диаграмма классов модуля domain (самостоятельное проектирование)], "fig-24")
На втором этапе, после готовности архитектурного каркаса, наращивание функционала выполнялось с помощью среды Cursor (модели семейства Composer): адаптеры Yandex Disk и локального storage, движок `StorageSyncEngine`, экраны 2FA и текстовых секретов, unit-тесты. После каждой генерации код просматривался в diff, запускались модульные тесты (`./gradlew :usecases:test` и смежные модули), критичные сценарии проверялись на устройстве.
#pz-table(
[Роли при разработке (фрагмент)],
3,
table.header([Этап], [Исполнитель], [Инструмент / метод]),
[Домен, навигация, UI-концепция], [Исполнитель ВКР], [Ручное проектирование, Compose],
[Адаптеры, sync, тесты, доработка UI], [Исполнитель ВКР + ревью], [Cursor, Gradle test],
[Шифрование, OAuth, ревизии журнала], [Исполнитель ВКР], [Ручная ревизия, без автогенерации «вслепую»],
) <tbl-ai-dev>
ИИ использовался как ускоритель шаблонного и повторяющегося кода, а не как замена проектных решений. Риски (неверные сигнатуры API, лишние зависимости, утечки в логи) снижались обязательной проверкой сборки, отсутствием секретов в репозитории и правилами `.gitignore` для локальных конфигураций.
== Разработка мобильного приложения на Kotlin (Android)
=== Слой domain
Модуль `:domain` содержит интерфейсы хранилищ и use case. Модуль `:usecases` связывает сценарии приложения.
=== Слой data
Модуль `:infrastructure-android` реализует Room `AppDb` (версия 5) с сущностями `DbStorageKeyMap`, `DbStorageMetaInfo`, `DbYandexAccount`, `DbStorageSyncGroup`:
```kotlin
@Database(
entities = [
DbStorageKeyMap::class,
DbStorageMetaInfo::class,
DbYandexAccount::class,
DbStorageSyncGroup::class,
],
version = 5,
exportSchema = false,
)
abstract class AppDb : IAppDb, RoomDatabase()
```
=== Слой presentation
Модуль `:ui` и `:app` содержат Compose-экраны, ViewModel и навигацию. OAuth Яндекс запускается из UI удалённых vault:
```kotlin
remoteVaultAuthenticator.beginLink(CloudBrand.YANDEX) { outcome ->
when (outcome) {
is VaultLinkOutcome.Success ->
viewModel.onVaultLinked(outcome.registration)
is VaultLinkOutcome.Failed -> { /* ошибка */ }
VaultLinkOutcome.Cancelled -> { }
}
}
```
== Взаимодействие подсистем и итоговая архитектура
Зависимости модулей Gradle показаны на рисунке @fig-23. Полный исходный код модулей сборки приведён в приложении А.
#pz-fig("fig_23_module_deps.png", [Зависимости модулей Gradle], "fig-23")
В основном тексте приведены показательные фрагменты; полные листинги в приложении А.
#include "ch04-expand.typ"
#include "ch04-modules.typ"