Вместо полноразмерной отредактированной версии просмотр запрашивает у сервера
только видимую область кадра: координаты относительные, поэтому запрос не
зависит от размера оригинала. Рендер куска занимает около 0.15 с против 0.8 с
для всей картинки, а полноразмерная версия больше не готовится вовсе.
Зум больше не сбрасывается, когда приходит отрендеренная версия правок:
состояние просмотра сбрасывается только при смене самого фото.
Расчёт правок считал math.Pow для каждого канала каждого пикселя, из-за чего
большое фото обрабатывалось больше секунды. Факторы правок теперь считаются
один раз на рендер, квадраты считаются умножением, а строки обрабатываются
параллельно.
Обработка кадра 4000×3000 сократилась с 1.23 с до 0.14 с. Задержка от
сохранения правок до появления отредактированной версии в сетке и просмотре:
около 0.15 с для небольшого фото и 0.6 с для большого (полноразмерная
версия догружается следом, через 1.4 с).
Вкладка «Редактирование» больше не переключается на «Сведения» при
обновлении того же фото: временное состояние панели сбрасывается только при
смене фото.
Правки теперь рендерятся без ожидания тика воркера: сервис будит воркер сразу
после постановки задачи, а уведомление о готовых вариантах приходит до
кодирования полноразмерной версии. Задержка между сохранением правок и
появлением отредактированной версии — около 0.25 с вместо нескольких секунд.
В правой панели фото появилась вкладка «Редактирование» с инструментом «Свет»:
ползунки экспозиции, контраста, светов, теней, белых и чёрных и точечная кривая
с выбором канала. Правки сохраняются автоматически, переключатель «Сброс»
удаляет параметры правок и отредактированную версию фото.
Редактирование неразрушающее: параметры хранятся в метаданных фото, а
отредактированная версия рендерится в фоне отдельным объектом (edit, edit_thumb,
edit_medium, edit_preview) рядом с исходными вариантами. Оригинал не меняется,
скачивание отдаёт исходный файл. Сетка, просмотр и обложки альбомов показывают
отредактированную версию, когда она отрендерена.
Добавлен service worker: оболочка приложения, ответы API и варианты
изображений (эскиз, превью, средний) сохраняются в офлайн-кеше браузера.
Ключ кеша картинки — путь объекта без подписи, поэтому обновление подписи
не плодит записи; оригиналы и видео не кешируются. Кеш ограничен по размеру
и вытесняет старые записи, при выходе из аккаунта очищается, а в верхней
панели появляется индикатор «Офлайн».
Добавлен манифест PWA; манифест отдаётся с типом application/manifest+json.
Кнопке «На весь экран» в верхней панели добавлен хоткей Ctrl+F. Хоткей `f`
полноэкранного просмотра больше не срабатывает на Ctrl+F: модификаторы
проверяются явно, поэтому сочетание свободно для кнопки каркаса.
При переходе стрелками влево/вправо активная миниатюра нижней ленты уходила
за край и оставалась невидимой. Теперь лента прокручивается к текущему фото,
удерживая его плитку в поле зрения.
В режиме просмотра фотографии (обычный и полноэкранный) клавиши + и - меняют
масштаб изображения на шаг, сохраняя точку в центре просмотра. Пока открыт
просмотр, эти клавиши больше не меняют размер миниатюр в сетке.
В скрытом режиме панели больше не растягивают плитки: сетка пересчитывает
число колонок так, чтобы размер миниатюры остался прежним, а в строке
помещалось больше фото. Каркас публикует ширину, которую занимают панели в
текущей раскладке (рейл на широких экранах, панель на сверхшироких), сетка
использует её для пересчёта, а навигация курсора стрелками учитывает
реальное число колонок.
В режиме просмотра альбома добавлена кнопка скрытия левой и правой боковых
панелей (рядом с регулятором размера миниатюр) и хоткей Tab. В скрытом режиме
панели не занимают место в раскладке, а появляются по наведению на левую или
правую кромку окна и прячутся после ухода указателя.
Клавиши + и - меняют размер миниатюр на шаг, как кнопки регулятора; хоткей
работает на всех экранах приложения и не срабатывает при вводе текста, в
открытых диалогах и меню.
Спека ui-workspace дополнена требованиями о скрытии панелей и клавишах
изменения размера миниатюр.
В спеку phone-album-sync добавлены требования о роли локального файла,
подтверждении новой версии до удаления устаревшей и разгрузке без
незавершённой загрузки; уточнены синхронизация изменений, провенанс и
возврат оригиналов.
- роль локального файла определяется меткой провенанса, а не состоянием
индекса: собственное превью выправляет запись (MarkPreview) вместо
повторной загрузки, постер видео связывается с самим видео;
- изменённое содержимое заменяет серверную копию только после сверки
original_checksum (ReplaceUpload): прежняя копия удаляется мягко, а при
расхождении принятая загрузка отбрасывается и элемент не разгружается;
- файл не разгружается, пока по нему есть незавершённая загрузка;
- восстановленный оригинал помечается UPLOADED, чтобы следующий проход не
разгрузил его снова;
- миграция 8→9 добавляет upload_jobs.replacesPhotoId.
Ответ на загрузку и публикуемое событие теперь несут хеш сохранённого
объекта, чтобы клиент синхронизации мог сверить принятые байты со своим
файлом и только после этого удалять прежнюю версию элемента.
Изменение описывает три дефекта двусторонней синхронизации: повторную
загрузку собственного превью, удаление серверной копии до подтверждения
замены и повторную разгрузку только что восстановленного оригинала.
Новое изменение: двусторонний миррор альбома устройства и серверного альбома
(материализация серверных фото, признак происхождения, двусторонние удаления с
явным намерением, устойчивое выключение, первый проход, n:1). Purpose основной
спеки phone-album-sync обновлён под двустороннюю синхронизацию.
Change проходит `openspec validate --strict`.
Связанная пара «альбом устройства → серверный альбом» становится миррором:
серверные фото материализуются на устройстве (оригинал в режиме копий, превью/
постер в переносе), состав меняется в обе стороны.
- Признак происхождения (OWN/MIRROR) в индексе и метке провенанса; БД v8.
- Материализация серверных фото (MirrorManager) с датой съёмки и меткой.
- Дедуп, материализация ровно в один бакет при n:1, baseline «только новое»/«всё».
- Двустороннее удаление с учётом прав; для зеркал намерением считается только
явное удаление в приложении (надгробие), пропавшая локальная копия
материализуется заново — иначе терялись серверные оригиналы.
- Неудачное чтение серверного списка не считается пустым альбомом.
- Устойчивое выключение синхронизации (syncDisabledAt): восстановление
происхождения не возвращает связь.
- Очередь загрузки — одно дренирующее задание вместо задания на файл
(JobScheduler переставал запускать остаток на больших альбомах).
- Дебаунс наблюдателя MediaStore и кэш sha256 по (size, dateModified).
Покрыто unit-тестами и instrumented-тестами (режимы A/B/C и переключения,
двусторонний миррор, объём 100 фото + 20 видео на тест).
Дельта перенесена в основные спеки: android-client («Прогресс восстановления
оригиналов») и phone-album-sync («Ход и устойчивость восстановления оригиналов»).
Изменение перемещено в openspec/changes/archive/2026-10-16-android-album-restore-progress.
Возврат из переноса в копии скачивает оригиналы с сервера, но ход нигде не
отражался: карточка умела показывать только загрузку, а восстановление шло в
области экрана и обрывалось при уходе с него.
Добавлен RestoreCoordinator: выполняет восстановление в области приложения
(уход с экрана больше не прерывает), ведёт ход «альбом -> восстановлено из
всего» и защищает от одновременного запуска по одному альбому. RestoreManager
сообщает ход по каждому элементу и вынесен за интерфейс AlbumRestorer; SyncCheck
ходит через координатора и, как и раньше, дожидается завершения.
Карточка альбома показывает «Копии — восстановление N%» (пульсирует), экран
альбома устройства — строку «Восстановление оригиналов: X из Y» с полосой.
Изменение OpenSpec: android-album-restore-progress.
Карточка альбома устройства получает третью строку: метку режима («Копии»/
«Перенос») и ход загрузки. Процент считается как (uploaded + running) / total
для обоих синхронизируемых режимов, пульсирует только при активной загрузке,
показывает очередь, ожидание Wi-Fi и ошибку. В режиме переноса карточка
показывает и карту памяти, и облако — режимы различает метка, а не значки.
Разгрузка оригиналов теперь требует подтверждённой серверной копии в текущем
целевом альбоме: устаревший serverPhotoId перепроверяется по альбому, смена
цели переносит уже загруженные фото и отклоняется, если перенести нельзя.
Потеря серверной копии видна на карточке и на экране альбома, автозатирания
превью нет.
Изменения OpenSpec: android-album-card-sync-status, safe-album-offload.
Новое требование в phone-album-sync о потере/возврате серверной копии у
разгруженного превью, требование в android-client о метке потерянного элемента
и действии удаления с устройства.
Unit-тесты планировщика, координатора, политики и восстановления, Compose-
тесты маркера и шита, instrumented-проверка удаления превью с устройства и
E2E-сценарии потери/возврата серверной копии. E2E не прогонялись: нужен
эмулятор и сервер на 10.0.2.2:8088.
В режиме переноса, если серверное фото исчезает из целевого альбома,
разгруженное превью помечается состоянием ORPHANED: в сетке показывается
перечёркнутое облако, элемент можно удалить с устройства, а вернувшийся
оригинал заливается заново. Детекция — в reconcile по живым фото альбома,
возврат серверного фото снимает пометку.
Правила работы в репозитории: команды, архитектура, миграции, конфигурация,
фронтенд, OpenSpec и конвенции коммитов. Отдельный раздел про прогоны с
эмулятором: instrumented-тесты и запуск AVD могут зависнуть, прогресс нужно
проверять примерно раз в минуту, а не ждать молча.
Выбор порядка раньше действовал только на серверные альбомы: карточки устройства
были закреплены сверху и не сортировались, а направления не было вовсе.
- порядок применяется ко всему списку: карточки устройства и серверные
упорядочиваются вместе единым компаратором
- альбом устройства получает дату последнего изменения (самый свежий элемент,
как при выборе обложки), поэтому «по времени изменения» осмыслен и для него
- обратный порядок инвертирует выбранный ключ; карточка без даты остаётся
последней в любом направлении
- пара (ключ, направление) хранится в DataStore за узким портом
AlbumSortPreference; меню сортировки получило переключатель «Обратный порядок»
- README: раздел Album sorting обновлён
Цель run зависела только от backend, а статика фронтенда попадает в бинарник
через go:embed из backend/static, который обновляла лишь цель frontend. Поэтому
make run пересобирал Go-код со старым UI.
- run теперь зависит от frontend backend;
- frontend стала инкрементальной: npm ci только при изменении package.json/
package-lock.json, npm run build и копирование в backend/static — только когда
исходники или конфиги новее, чем backend/static/index.html (он же stamp);
- проверки вынесены в shell-recipe, т.к. имена SvelteKit-роутов вида (app)
ломают разбор имён файлов в make.
Тестовая активность Compose (androidx.activity.ComponentActivity) объявляется
зависимостью ui-test-manifest, подключённой как debugImplementation, поэтому в
release-манифесте её нет и createComposeRule падает на testReleaseUnitTest.
Источники тестов вариант-агностичны, поэтому прогон один раз на debug даёт то
же покрытие и не тащит тестовую активность в production-манифест.
LiveAlbumsE2ETest против локального сервера и двух аккаунтов: участник
регистрируется на устройстве, владелец создаёт альбом и приглашает его,
альбом появляется в списке без обновления, переименование меняет открытый
экран, исключение убирает альбом и возвращает экран к списку.
- E2E: хелперы invite/accept/rename/remove и получение id пользователя
Приложение подписывается на персональный поток GET /api/sse/me и применяет
album.created/updated/deleted к кэшу Room, поэтому список и открытый экран
следуют изменениям с другого устройства или из веба без ручного обновления.
- PersonalSseClient и парсер событий жизненного цикла; подписка живёт вместе с
сессией (AlbumsViewModel), закрывается на выходе
- AlbumRepository.apply: upsert альбома или удаление его вместе с фото;
при переподключении список догоняется серверным снимком
- потеря доступа (удаление альбома или исключение участника) убирает альбом из
списка, а открытый экран возвращается к списку
- README: раздел Live updates и ограничение с истечением токена
Карточка альбома не получала ссылку на обложку: она появляется в объекте
альбома только после генерации thumb/medium, а событие об этом шаге в
персональные потоки не публиковалось — счётчик обновлялся, обложка нет.
- albums.Service.NotifyCoverReady публикует album.updated получателям, когда
варианты готовы у фото, являющегося текущей обложкой (иначе no-op, чтобы
массовая загрузка не рассылала событие на каждый файл)
- photos: порт AlbumAccess расширен, worker вызывает публикацию после
photo.updated
- project.md §11/§12: триггер добавлен в контракт персонального потока
- realtime: личный поток несёт события жизненного цикла альбома и изменения
счётчика фото; добавлены требования о жизненном цикле, получателях, форме
payload'а, догоне и потере доступа
- albums: photos_count считает только фото вне корзины
Публиковать album.created/updated/deleted и изменения состава фото в личный поток
SSE, а также photo.created при загрузке, чтобы списки альбомов, счётчики и открытые
страницы альбома обновлялись без перезагрузки.
- members.Repository получил Audience (владелец + участники) для дешёвого веера
- albums.Service рассылает события в личные потоки получателей, перечитывая альбом
через GetByID и attachCover, чтобы payload совпадал с GET /api/albums
- members.Service публикует при принятии инвайта, смене роли и исключении
- photos.Service публикует photo.created при загрузке и album.updated при
добавлении/копировании/перемещении/восстановлении/корзине/окончательном удалении
- photos_count теперь считает фото вне корзины
- web-клиент подписывается на /api/sse/me один раз на уровне layout'а (app) и
применяет события к стору альбомов; страница альбома уходит при потере доступа
E2EFlowTest failed because the system media-permission dialog stole focus and
left no compose hierarchy (orchestrator clears package data between tests).
Grant media access in an outer rule before the activity launches, and dismiss the
all-files access prompt so it does not block taps.
Album panel and photo sheet with attributes/actions for every album (server and
device), local album rename/cover/delete, EXIF attribute settings, and a unified
device-gallery sync check:
- add repository "Атрибуты и действия" panel (swipe left) on server and device
albums: cover, info, navigation, rename/delete; album settings screen; photo
sheet (swipe up) with info, tags and actions; EXIF attribute settings backed by
profile preferences
- local albums use local metadata and actions: app-only display name and cover
(new device_album_meta table), delete removes photos from the phone via the
system delete consent
- unified mode x state policy (GallerySyncPolicy/SyncCheck) runs on start and on
album open: upload/offload in move, restore originals in copy/off
- fix move->copy restore, offload of any linked-but-local original, provenance
recovery that no longer overwrites the album mode, serialized reconcile and
duplicate-upload suppression; prompt for "all files" access on start
- album card caption: name (with linked server name when different) and storage
icons (memory card / cloud) with item count
Sync delta specs into android-client and archive the change
add-mobile-album-photo-actions.
Verified: unit/Robolectric suite green, :app:assembleDebug, :app:lint, and the
DeviceSyncE2ETest instrumented suite on the emulator (7/7).
These network, repo, upload and image tests existed only in the main checkout because the old unanchored data/ rule ignored them. With the rule anchored to /data/ they now belong to the tracked data layer.
Add phone album sync to the Android client: MediaStore source (device
albums), a Room index with upload provenance, on-device previews, and a
mixed server + device album list where a linked pair collapses into a
single device card with a link indicator and mode label.
Three modes over one pipeline: A (off), B (upload copies, originals stay
local), C (upload originals and offload them locally to previews). The
sync engine tracks device changes (add/remove/rename/move/content), matches
assets to server photos by sha256 content hash, deletes only this client's
uploads, and reflects server changes without touching local originals.
Offload is automatic; the zoomed viewer pulls the server original.
Backend: expose the original content checksum in photo data (Postgres and
SQLite), additive with no migration.
Also anchor the `.gitignore` `data/` rule to `/data/` so the Android
`com.galo.android.data` package is tracked, and add the halo launcher icon.
Show a spinner in the top-right of the photo viewer while the original is
being loaded on zoom, so the wait is visible. Only the original triggers it;
the fitted preview and a re-shown preview never do.
Cover the visibility gate with a unit test.
OpenSpec change: add-mobile-photo-zoom
Add pinch/pan/double-tap zoom to the full-screen photo viewer, mirroring the
web viewer semantics:
- scale is a multiplier of the natural size, capped at 200%; the fitted view
is the lower bound
- double tap toggles fitted view <-> 100% natural anchored at the tap
- the original loads on zoom, with the preview kept as a placeholder so
entering zoom never flashes a black surface
- the pager is disabled while zoomed (drags pan the image) and the zoom resets
when leaving a photo
- video and 360 panoramas are unaffected
Implement PhotoZoom (zoom math) and ZoomableImage (gestures), wire it into
ViewerContent/MediaPage, and cover it with unit, Compose and device tests.
OpenSpec change: add-mobile-photo-zoom