Author SHA1 Message Date
iu 4d23e76ab5 chore(openspec): архивировать fix-edit-preview-handoff и обновить photos, realtime
- photos: признак незавершённого рендера правок, мгновенный показ сброса,
  бесшовная подмена предпросмотра
- realtime: события жизненного цикла рендера правок в photo.updated
2026-10-25 10:04:08 +03:00
iu 2b8d329a3e fix(photos): убрать мигание предпросмотра и задержку сброса правок
- фото и photo.updated несут edit_pending: рендер сохранённых правок ещё идёт
- клиент держит предпросмотр, пока серверная версия не загружена и не
  декодирована, поэтому оригинал не мелькает при подмене
- сброс возвратом контролов в значения по умолчанию виден сразу, не через 10 с
- Viewer берёт URL из актуального объекта (SSE), fresh остаётся фолбэком
- попутно исправлен порядок чтения tags и album_title в ListTrash
2026-10-25 10:02:04 +03:00
iu 1c29f5d961 docs(openspec): изменение fix-edit-preview-handoff
- proposal/design/tasks и delta-спеки photos, realtime
- серверный признак edit_pending, бесшовная подмена предпросмотра, мгновенный сброс
2026-10-25 10:02:04 +03:00
iu 4d5cf3c1cb chore: обновить OpenSpec-скиллы и добавить DSH-скиллы
- .opencode: команды и скиллы обновлены openspec 1.15.0
- .dsh/skills: те же скиллы в формате DeepSeek Harness
2026-10-24 22:59:29 +03:00
iu cb9433666a chore(openspec): архивировать photo-effects-editing и обновить photos
Изменение применено к основной спеке photos: эффекты, виньетка, зерно,
живой предпросмотр, свёрнутые секции панели и редактирование из просмотрщика.
Change перемещён в archive.
2026-10-24 22:58:07 +03:00
iu c953008a86 feat(photos): добавить эффекты и настройки панели редактора
- текстура, чёткость, дымка, виньетка и зерно в неразрушающем редакторе
- живой WebGL2-предпросмотр эффектов и CPU-фолбэк
- секции редактора свёрнуты по умолчанию, состояние в localStorage
- вкладка «Редактирование» доступна только из просмотрщика
2026-10-24 22:57:30 +03:00
iu 4cc5c0f340 docs(release): версия 0.2.0
Релиз Galo 0.2.0: инструмент «Цвет», пресеты тоновой кривой, мерж настроек профиля и исправления переключения фото.
2026-10-23 23:09:06 +03:00
iu a481375783 docs: описание релиза Galo за 23 октября 2026
Версия 0.2-alpha: инструмент «Цвет», пресеты тоновой кривой, мерж настроек профиля и исправления переключения фото.
2026-10-23 22:59:09 +03:00
iu 9e39e3d84c chore(openspec): архивировать photo-curve-presets и обновить photos, auth 2026-10-23 22:57:23 +03:00
iu 0bfee5f2f8 feat(photos): добавить пресеты кривой и починить переключение фото
- PCHIP для предпросмотра и серверного рендера
- пресеты всех каналов в профиле, импорт и экспорт
- мерж настроек auth без потери разделов
- сохранение вкладки и правок текущего фото при переключении
2026-10-23 22:56:55 +03:00
iu 3b8406fb4c chore(openspec): архивировать photo-color-editing и обновить photos
Дельта-спека, дизайн, предложение и задачи приведены к фактической реализации:
WebGL2-предпросмотр, пипетка баланса белого, цветокоррекция по зонам.
Изменение применено к основной спеке photos и перемещено в archive.
2026-10-23 22:01:53 +03:00
iu 502f2766d7 docs(openspec): изменение photo-color-editing
Предложение, дельта-спека photos, дизайн и задачи для инструмента «Цвет».
2026-10-23 21:57:51 +03:00
iu d791b9a572 feat(photos): инструмент «Цвет» с WebGL-превью и пипеткой
- неразрушающие правки цвета: баланс белого, вибрация/насыщенность,
  цветовой микшер, колористика и поканальная коррекция
- серверный рендер цвета в Go и паритетная реализация в WASM
- живое превью на WebGL2 (кадр считает GPU), CPU/WASM-фолбэк
- пипетка баланса белого по нейтральной точке
- новые контролы: слайдеры с градиентами, цветовые колёса,
  двойной клик по контролу сбрасывает значение
- тесты: параметры, рендер, паритет JS/WASM, e2e сохранения и сброса
2026-10-23 21:57:48 +03:00
iu c886c6f22c chore(openspec): архивировать fix-copy-edit-variant-sharing и синхронизировать спеки
Дельта media-objects перенесена в основную спеку: копирование больше не
переносит отредактированную версию, копия показывается в исходном виде и
не наследует параметры правок. Change перемещён в архив за 2026-10-21.
2026-10-21 21:33:43 +03:00
iu 723b19bbe5 docs: описание релиза Galo за 21 октября 2026
Описание изменений с main (84ce4d1) до develop (afe85f7): локальный
режим на SQLite и файловой системе, слой объектов, редактирование фото,
новый интерфейс рабочего пространства, видео, Android-клиент, профиль,
офлайн, живое обновление, исправления и инструкция по обновлению.
2026-10-21 21:15:59 +03:00
iu afe85f7be0 docs: отчёт об изменениях — раздел по реализации fix-copy-edit-variant-sharing 2026-10-21 21:01:33 +03:00
iu 70ac12db10 chore(openspec): отметить задачи fix-copy-edit-variant-sharing выполненными
Все 9 задач закрыты; в design.md уточнено, что удалённые привязки
не осиротевают объекты: отредактированная версия живёт вместе с фото.
2026-10-21 21:01:33 +03:00
iu 8dd8cb6f0c fix(photos): копия фото не наследует отредактированную версию
Clone переносил все привязки photo_objects, включая роли edit*, хотя
правка принадлежит фото, чьи правки её породили. Теперь копия делит
только обычные варианты и показывается в исходном виде.

- EditRoles() и IsEditRole() в edit_render.go — единый список ролей;
- фильтр edit-ролей в Store.Clone и SQLiteStore.Clone;
- миграции 000016 (Postgres) и 000008 (SQLite) убирают привязки у
  копий, созданных до фикса;
- контрактная проверка копирования, сервисные и e2e-тесты.
2026-10-21 21:01:33 +03:00
iu 759e78ff79 docs: отчёт об изменениях, сделанных без спецификаций
Разбор 21 коммита диапазона 45a953f..eb2d478: что было описано,
что осталось без спек и почему, найденное расхождение по копированию фото
с правками.
2026-10-21 20:31:39 +03:00
iu b075f4c3a4 chore(openspec): предложить fix-copy-edit-variant-sharing
Копирование фото переносит и отредактированную версию, хотя правка
принадлежит фото, чьи правки её породили. Изменение описывает фильтр
edit-ролей в Clone, разовую чистку копий миграцией и дельту требования
«Совместные объекты».
2026-10-21 20:31:39 +03:00
iu 748143c9d7 docs(openspec): описать правки редактора, сделанные без спецификаций
Дополнены спеки ui-workspace (сворачивание кривой, живая гистограмма,
точки кривой, навигация в зуме, Space к центру), media-objects (роли edit*,
привязка отредактированной версии, отдельная очередь рендера правок),
photos (рендер по оригиналу) и dev-make-workflow (сборка WASM-модуля).
2026-10-21 20:21:59 +03:00
iu eb2d478275 feat(web): блок «Кривая» свёрнут по умолчанию
График тональной кривой с гистограммой занимает много места в узкой панели, поэтому
в инструменте «Свет» он изначально свёрнут и раскрывается по клику на заголовок.
2026-10-21 20:02:34 +03:00
iu 332f35b553 feat(web): сворачивание блока «Кривая» в инструменте «Свет»
Заголовок «Кривая» стал кнопкой со стрелкой: график с гистограммой и точками
сворачивается и разворачивается, кнопки выбора канала остаются видны всегда,
чтобы канал можно было переключить и в свёрнутом виде.
2026-10-21 20:00:30 +03:00
iu 48ed0bb775 fix(web): гистограмма пересчитывается при правках
Гистограмма считалась по базовому превью один раз и не менялась при движении
ползунков и кривой. Теперь по уменьшенной копии фото накапливается сэмпл
пикселей, а сама гистограмма пересчитывается с применением текущих правок, так
что график реагирует на манипуляции сразу.
2026-10-21 01:32:14 +03:00
iu b64574b921 feat(web): гистограммы каналов в редакторе кривой
График тональной кривой теперь показывает гистограмму фото: выбранный канал —
своим цветом, мастер-канал (RGB) — всеми тремя слоями. Гистограмма считается
в браузере по уменьшенной копии базового превью и не влияет на скорость
перерисовки.
2026-10-21 01:29:35 +03:00
iu d904b2fe31 fix(web): линия тональной кривой была не видна
Цвета каналов задавались CSS-переменными темы прямо в SVG-атрибутах stroke и
fill, но presentation-атрибуты не понимают var(), а переменные темы хранят
компоненты HSL, а не готовый цвет. В итоге обводка была пустой, и на тёмном
фоне графика кривую не было видно.

Теперь цвет канала идёт через style в формате hsl(var(--text)), а заливка
точек — hsl(var(--bg)).
2026-10-21 01:24:40 +03:00
iu 2668d87347 fix(web): искажение цветов в предпросмотре правок
Таблицы тональных кривых в WASM-модуле лежали в статическом массиве, который
линковщик помещал в сегмент .data — такой сегмент в wasm доступен только для
чтения, поэтому запись в него не срабатывала, и модуль считал по мусорным
данным (отсюда «каша» вместо плавного градиента).

Теперь таблицы считает и передаёт JS: модуль не хранит состояния и только
применяет правки к пикселям. Дополнительно клиент сверяет модуль с эталонной
JS-реализацией на маленьком образце и, если они расходятся, доверяет JS.

Service worker больше не кэширует .wasm: старый модуль в кэше ломал
предпросмотр даже после обновления сборки.
2026-10-21 01:19:45 +03:00
iu f8ee93777d fix(web): зум в просмотре — контейнер прокрутки и центрирование
Прокручиваемый контейнер просмотра не имел min-w-0, поэтому в зуме он
раздувался до ширины картинки вместо того, чтобы прокручиваться: фото
нельзя было двигать влево-вправо. Та же правка применена к обёрткам просмотра.

Пробел теперь зумит к центру фото: 100% от оригинала с центрированием
изображения в просмотре.
2026-10-21 00:41:08 +03:00
iu b2a509fc38 fix(web): в зуме стрелки панорамируют фото
Стрелки влево/вправо переключают фото только без зума; в зуме они двигают
изображение, как и перетаскивание мышью.
2026-10-21 00:34:15 +03:00
iu 500ec10d7f fix(web): починить зум в просмотре
Механика зума переписана:

- Пробел зумит фото до 100% от оригинала (и обратно к вписанному виду).
- Колесо мыши зумит к курсору: точка под указателем остаётся на месте при
  любом изменении масштаба.
- Стрелки влево/вправо переключают фото и в зуме — панорамирование осталось
  за мышью.
- Масштаб и позиция считаются от размеров оригинала, а не от текущей рамки,
  поэтому зум не «уезжает» и картинка корректно центрируется.
2026-10-21 00:17:15 +03:00
iu 2adcb1374b fix(web): предпросмотр правок работает и без зума
Клиентский предпросмотр был включён только в зуме, поэтому в обычном виде
просмотра фото оставалось без правок. Теперь холст рисует ту часть картинки,
что попала во вьюпорт, в любом режиме, а под ним лежит неотредактированная
версия фото — так правки не применяются дважды.
2026-10-21 00:11:17 +03:00
iu 520886a11d feat(web): правки применяются на фронте, фильтры в WASM
Новый алгоритм редактирования: обработка идёт только на клиенте.

- Фильтры «Свет» считает WASM-модуль на Rust (frontend/wasm/light, 1.5 КБ):
  он строит тональные кривые и обрабатывает пиксели. При недоступности модуля
  используется JS-реализация с тем же результатом — расхождение проверяется
  тестом.
- Просмотр рисует видимую область фото с правками на canvas поверх
  исходной версии, поэтому изменения видны сразу, без ожидания рендера.
- Правки уходят на сервер не сразу: через десять секунд без изменений или при
  переключении фото. Сервер сохраняет параметры и готовит варианты сетки и
  полноразмерную версию в фоне, о готовности сообщает через SSE — клиент
  подтягивает готовую версию.
2026-10-21 00:06:46 +03:00
iu 4d2069d079 feat(photos): рендер только видимой области при зуме правок
Вместо полноразмерной отредактированной версии просмотр запрашивает у сервера
только видимую область кадра: координаты относительные, поэтому запрос не
зависит от размера оригинала. Рендер куска занимает около 0.15 с против 0.8 с
для всей картинки, а полноразмерная версия больше не готовится вовсе.

Зум больше не сбрасывается, когда приходит отрендеренная версия правок:
состояние просмотра сбрасывается только при смене самого фото.
2026-10-20 23:34:45 +03:00
iu 6d3d7cbdd4 perf(photos): ускорить рендер правок в 9 раз
Расчёт правок считал math.Pow для каждого канала каждого пикселя, из-за чего
большое фото обрабатывалось больше секунды. Факторы правок теперь считаются
один раз на рендер, квадраты считаются умножением, а строки обрабатываются
параллельно.

Обработка кадра 4000×3000 сократилась с 1.23 с до 0.14 с. Задержка от
сохранения правок до появления отредактированной версии в сетке и просмотре:
около 0.15 с для небольшого фото и 0.6 с для большого (полноразмерная
версия догружается следом, через 1.4 с).
2026-10-20 23:19:03 +03:00
iu cd3d17c0f0 fix(web): правки применяются сразу и не сбрасывают вкладку
Вкладка «Редактирование» больше не переключается на «Сведения» при
обновлении того же фото: временное состояние панели сбрасывается только при
смене фото.

Правки теперь рендерятся без ожидания тика воркера: сервис будит воркер сразу
после постановки задачи, а уведомление о готовых вариантах приходит до
кодирования полноразмерной версии. Задержка между сохранением правок и
появлением отредактированной версии — около 0.25 с вместо нескольких секунд.
2026-10-20 23:11:57 +03:00
iu 0548b1ab5e feat(web): вкладка «Редактирование» с инструментом «Свет»
В правой панели фото появилась вкладка «Редактирование» с инструментом «Свет»:
ползунки экспозиции, контраста, светов, теней, белых и чёрных и точечная кривая
с выбором канала. Правки сохраняются автоматически, переключатель «Сброс»
удаляет параметры правок и отредактированную версию фото.

Редактирование неразрушающее: параметры хранятся в метаданных фото, а
отредактированная версия рендерится в фоне отдельным объектом (edit, edit_thumb,
edit_medium, edit_preview) рядом с исходными вариантами. Оригинал не меняется,
скачивание отдаёт исходный файл. Сетка, просмотр и обложки альбомов показывают
отредактированную версию, когда она отрендерена.
2026-10-20 23:06:24 +03:00
iu afaaaab5f0 feat(web): офлайн-режим с кешем эскизов и превью
Добавлен service worker: оболочка приложения, ответы API и варианты
изображений (эскиз, превью, средний) сохраняются в офлайн-кеше браузера.
Ключ кеша картинки — путь объекта без подписи, поэтому обновление подписи
не плодит записи; оригиналы и видео не кешируются. Кеш ограничен по размеру
и вытесняет старые записи, при выходе из аккаунта очищается, а в верхней
панели появляется индикатор «Офлайн».

Добавлен манифест PWA; манифест отдаётся с типом application/manifest+json.
2026-10-20 22:27:24 +03:00
iu 961dff15da feat(web): хоткей Ctrl+F для полноэкранного режима приложения
Кнопке «На весь экран» в верхней панели добавлен хоткей Ctrl+F. Хоткей `f`
полноэкранного просмотра больше не срабатывает на Ctrl+F: модификаторы
проверяются явно, поэтому сочетание свободно для кнопки каркаса.
2026-10-20 22:12:24 +03:00
iu 0a9e5274cf fix(web): лента миниатюр следует за текущим фото в просмотре
При переходе стрелками влево/вправо активная миниатюра нижней ленты уходила
за край и оставалась невидимой. Теперь лента прокручивается к текущему фото,
удерживая его плитку в поле зрения.
2026-10-20 22:06:20 +03:00
iu 35f84399bc feat(web): зум клавишами + и - в просмотре фото
В режиме просмотра фотографии (обычный и полноэкранный) клавиши + и - меняют
масштаб изображения на шаг, сохраняя точку в центре просмотра. Пока открыт
просмотр, эти клавиши больше не меняют размер миниатюр в сетке.
2026-10-20 22:01:41 +03:00
iu 50b9983be4 feat(web): сохранять размер миниатюр при скрытых боковых панелях
В скрытом режиме панели больше не растягивают плитки: сетка пересчитывает
число колонок так, чтобы размер миниатюры остался прежним, а в строке
помещалось больше фото. Каркас публикует ширину, которую занимают панели в
текущей раскладке (рейл на широких экранах, панель на сверхшироких), сетка
использует её для пересчёта, а навигация курсора стрелками учитывает
реальное число колонок.
2026-10-20 21:38:02 +03:00
iu 45a953f8bb feat(web): скрытие боковых панелей в альбоме и хоткеи размера миниатюр
В режиме просмотра альбома добавлена кнопка скрытия левой и правой боковых
панелей (рядом с регулятором размера миниатюр) и хоткей Tab. В скрытом режиме
панели не занимают место в раскладке, а появляются по наведению на левую или
правую кромку окна и прячутся после ухода указателя.

Клавиши + и - меняют размер миниатюр на шаг, как кнопки регулятора; хоткей
работает на всех экранах приложения и не срабатывает при вводе текста, в
открытых диалогах и меню.

Спека ui-workspace дополнена требованиями о скрытии панелей и клавишах
изменения размера миниатюр.
2026-10-20 21:30:06 +03:00
iu 3a44844ff9 chore(openspec): архивировать fix-album-sync-churn-and-degradation и синхронизировать спеки
В спеку phone-album-sync добавлены требования о роли локального файла,
подтверждении новой версии до удаления устаревшей и разгрузке без
незавершённой загрузки; уточнены синхронизация изменений, провенанс и
возврат оригиналов.
2026-10-20 20:35:52 +03:00
iu 2e3728d83d fix(android): не принимать разгруженное превью за новую версию элемента
- роль локального файла определяется меткой провенанса, а не состоянием
  индекса: собственное превью выправляет запись (MarkPreview) вместо
  повторной загрузки, постер видео связывается с самим видео;
- изменённое содержимое заменяет серверную копию только после сверки
  original_checksum (ReplaceUpload): прежняя копия удаляется мягко, а при
  расхождении принятая загрузка отбрасывается и элемент не разгружается;
- файл не разгружается, пока по нему есть незавершённая загрузка;
- восстановленный оригинал помечается UPLOADED, чтобы следующий проход не
  разгрузил его снова;
- миграция 8→9 добавляет upload_jobs.replacesPhotoId.
2026-10-20 20:32:20 +03:00
iu 97f8f4e768 fix(backend): отдавать контрольную сумму оригинала в ответе на загрузку
Ответ на загрузку и публикуемое событие теперь несут хеш сохранённого
объекта, чтобы клиент синхронизации мог сверить принятые байты со своим
файлом и только после этого удалять прежнюю версию элемента.
2026-10-20 20:32:20 +03:00
iu 46d54b4b12 chore(openspec): предложить fix-album-sync-churn-and-degradation и уточнить спеку phone-album-sync
Изменение описывает три дефекта двусторонней синхронизации: повторную
загрузку собственного превью, удаление серверной копии до подтверждения
замены и повторную разгрузку только что восстановленного оригинала.
2026-10-20 20:32:20 +03:00
iu cc85d792ea chore(openspec): архивировать phone-album-two-way-sync и синхронизировать спеки
Дельта-спеки перенесены в основные: phone-album-sync (+8 требований, 6 изменено,
1 удалено — «Удаление только собственных загрузок»), android-client.

Change перемещён в openspec/changes/archive/2026-10-16-phone-album-two-way-sync.
2026-10-16 23:20:04 +03:00
iu d17f62f676 chore(openspec): предложить phone-album-two-way-sync и обновить спеку phone-album-sync
Новое изменение: двусторонний миррор альбома устройства и серверного альбома
(материализация серверных фото, признак происхождения, двусторонние удаления с
явным намерением, устойчивое выключение, первый проход, n:1). Purpose основной
спеки phone-album-sync обновлён под двустороннюю синхронизацию.

Change проходит `openspec validate --strict`.
2026-10-16 23:19:08 +03:00
iu cd326c0ae6 feat(android): двусторонняя синхронизация альбома устройства с сервером
Связанная пара «альбом устройства → серверный альбом» становится миррором:
серверные фото материализуются на устройстве (оригинал в режиме копий, превью/
постер в переносе), состав меняется в обе стороны.

- Признак происхождения (OWN/MIRROR) в индексе и метке провенанса; БД v8.
- Материализация серверных фото (MirrorManager) с датой съёмки и меткой.
- Дедуп, материализация ровно в один бакет при n:1, baseline «только новое»/«всё».
- Двустороннее удаление с учётом прав; для зеркал намерением считается только
  явное удаление в приложении (надгробие), пропавшая локальная копия
  материализуется заново — иначе терялись серверные оригиналы.
- Неудачное чтение серверного списка не считается пустым альбомом.
- Устойчивое выключение синхронизации (syncDisabledAt): восстановление
  происхождения не возвращает связь.
- Очередь загрузки — одно дренирующее задание вместо задания на файл
  (JobScheduler переставал запускать остаток на больших альбомах).
- Дебаунс наблюдателя MediaStore и кэш sha256 по (size, dateModified).

Покрыто unit-тестами и instrumented-тестами (режимы A/B/C и переключения,
двусторонний миррор, объём 100 фото + 20 видео на тест).
2026-10-16 23:19:02 +03:00
iu 19309e2ac0 chore(openspec): архивировать android-album-restore-progress и синхронизировать спеки
Дельта перенесена в основные спеки: android-client («Прогресс восстановления
оригиналов») и phone-album-sync («Ход и устойчивость восстановления оригиналов»).
Изменение перемещено в openspec/changes/archive/2026-10-16-android-album-restore-progress.
2026-10-16 20:15:57 +03:00
iu fcc7ac05c1 feat(android): показывать ход восстановления оригиналов
Возврат из переноса в копии скачивает оригиналы с сервера, но ход нигде не
отражался: карточка умела показывать только загрузку, а восстановление шло в
области экрана и обрывалось при уходе с него.

Добавлен RestoreCoordinator: выполняет восстановление в области приложения
(уход с экрана больше не прерывает), ведёт ход «альбом -> восстановлено из
всего» и защищает от одновременного запуска по одному альбому. RestoreManager
сообщает ход по каждому элементу и вынесен за интерфейс AlbumRestorer; SyncCheck
ходит через координатора и, как и раньше, дожидается завершения.

Карточка альбома показывает «Копии — восстановление N%» (пульсирует), экран
альбома устройства — строку «Восстановление оригиналов: X из Y» с полосой.

Изменение OpenSpec: android-album-restore-progress.
2026-10-16 20:15:05 +03:00
iu 1eb1177273 feat(android): статус синхронизации на карточке и безопасная разгрузка
Карточка альбома устройства получает третью строку: метку режима («Копии»/
«Перенос») и ход загрузки. Процент считается как (uploaded + running) / total
для обоих синхронизируемых режимов, пульсирует только при активной загрузке,
показывает очередь, ожидание Wi-Fi и ошибку. В режиме переноса карточка
показывает и карту памяти, и облако — режимы различает метка, а не значки.

Разгрузка оригиналов теперь требует подтверждённой серверной копии в текущем
целевом альбоме: устаревший serverPhotoId перепроверяется по альбому, смена
цели переносит уже загруженные фото и отклоняется, если перенести нельзя.
Потеря серверной копии видна на карточке и на экране альбома, автозатирания
превью нет.

Изменения OpenSpec: android-album-card-sync-status, safe-album-offload.
2026-10-16 18:36:51 +03:00
iu 98aaa3156a chore(openspec): архивировать add-orphaned-preview-handling и синхронизировать спеки
Новое требование в phone-album-sync о потере/возврате серверной копии у
разгруженного превью, требование в android-client о метке потерянного элемента
и действии удаления с устройства.
2026-10-16 14:13:01 +03:00
iu 5cfa045171 test(android): покрыть потерю и возврат серверной копии
Unit-тесты планировщика, координатора, политики и восстановления, Compose-
тесты маркера и шита, instrumented-проверка удаления превью с устройства и
E2E-сценарии потери/возврата серверной копии. E2E не прогонялись: нужен
эмулятор и сервер на 10.0.2.2:8088.
2026-10-16 14:12:58 +03:00
iu 3ff8f80eed feat(android): обработка потерянных серверных копий разгруженных превью
В режиме переноса, если серверное фото исчезает из целевого альбома,
разгруженное превью помечается состоянием ORPHANED: в сетке показывается
перечёркнутое облако, элемент можно удалить с устройства, а вернувшийся
оригинал заливается заново. Детекция — в reconcile по живым фото альбома,
 возврат серверного фото снимает пометку.
2026-10-16 14:12:55 +03:00
iu 863038b5a0 chore: добавить AGENTS.md с инструкциями для агентов
Правила работы в репозитории: команды, архитектура, миграции, конфигурация,
фронтенд, OpenSpec и конвенции коммитов. Отдельный раздел про прогоны с
эмулятором: instrumented-тесты и запуск AVD могут зависнуть, прогресс нужно
проверять примерно раз в минуту, а не ждать молча.
2026-10-16 13:28:03 +03:00
iu e4fc6f0ce9 chore(openspec): архивировать android-album-sort и синхронизировать основные спеки
android-client: +1 требование — выбор порядка карточек и направления, порядок
охватывает весь список вместе с альбомами устройства.
2026-10-16 13:27:30 +03:00
iu 8010ff26f9 test(android): instrumented-проверка сохранения сортировки
AlbumSortE2ETest на эмуляторе: два альбома, выбор «по названию», включение
обратного порядка, перезапуск экрана — порядок и отметки сохраняются.
2026-10-16 13:27:23 +03:00
iu 3e8a6b87b7 feat(android): сортировать весь список и добавить обратный порядок
Выбор порядка раньше действовал только на серверные альбомы: карточки устройства
были закреплены сверху и не сортировались, а направления не было вовсе.

- порядок применяется ко всему списку: карточки устройства и серверные
  упорядочиваются вместе единым компаратором
- альбом устройства получает дату последнего изменения (самый свежий элемент,
  как при выборе обложки), поэтому «по времени изменения» осмыслен и для него
- обратный порядок инвертирует выбранный ключ; карточка без даты остаётся
  последней в любом направлении
- пара (ключ, направление) хранится в DataStore за узким портом
  AlbumSortPreference; меню сортировки получило переключатель «Обратный порядок»
- README: раздел Album sorting обновлён
2026-10-16 13:27:20 +03:00
iu 28e3d4676f fix(make): пересобирать изменившийся фронтенд в make run
Цель 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.
2026-10-16 13:26:43 +03:00
iu 14db79632a chore(openspec): архивировать android-live-albums и fix-album-cover-readiness
Дельта android-live-albums: android-client (+3 требования: живое обновление
списка, догон при переподключении, потеря доступа) и realtime (порядок списка
теперь правило отображения клиента).

Дельта fix-album-cover-readiness: realtime (+1 требование: album.updated при
готовности вариантов фото-обложки).
2026-10-16 12:48:32 +03:00
iu d0d6362a01 fix(android): прогонять unit-тесты только на debug-варианте
Тестовая активность Compose (androidx.activity.ComponentActivity) объявляется
зависимостью ui-test-manifest, подключённой как debugImplementation, поэтому в
release-манифесте её нет и createComposeRule падает на testReleaseUnitTest.
Источники тестов вариант-агностичны, поэтому прогон один раз на debug даёт то
же покрытие и не тащит тестовую активность в production-манифест.
2026-10-16 12:48:08 +03:00
iu 3178699213 test(android): сквозной сценарий живого обновления на эмуляторе
LiveAlbumsE2ETest против локального сервера и двух аккаунтов: участник
регистрируется на устройстве, владелец создаёт альбом и приглашает его,
альбом появляется в списке без обновления, переименование меняет открытый
экран, исключение убирает альбом и возвращает экран к списку.

- E2E: хелперы invite/accept/rename/remove и получение id пользователя
2026-10-16 12:48:02 +03:00
iu 9061f7fc44 feat(android): обновлять список альбомов в реальном времени
Приложение подписывается на персональный поток GET /api/sse/me и применяет
album.created/updated/deleted к кэшу Room, поэтому список и открытый экран
следуют изменениям с другого устройства или из веба без ручного обновления.

- PersonalSseClient и парсер событий жизненного цикла; подписка живёт вместе с
  сессией (AlbumsViewModel), закрывается на выходе
- AlbumRepository.apply: upsert альбома или удаление его вместе с фото;
  при переподключении список догоняется серверным снимком
- потеря доступа (удаление альбома или исключение участника) убирает альбом из
  списка, а открытый экран возвращается к списку
- README: раздел Live updates и ограничение с истечением токена
2026-10-16 12:47:53 +03:00
iu 44cb7c7d68 fix(realtime): доставлять обложку альбома после готовности вариантов
Карточка альбома не получала ссылку на обложку: она появляется в объекте
альбома только после генерации thumb/medium, а событие об этом шаге в
персональные потоки не публиковалось — счётчик обновлялся, обложка нет.

- albums.Service.NotifyCoverReady публикует album.updated получателям, когда
  варианты готовы у фото, являющегося текущей обложкой (иначе no-op, чтобы
  массовая загрузка не рассылала событие на каждый файл)
- photos: порт AlbumAccess расширен, worker вызывает публикацию после
  photo.updated
- project.md §11/§12: триггер добавлен в контракт персонального потока
2026-10-16 12:47:50 +03:00
iu 742745e6aa chore: игнорировать nohup.out
Локальный лог запущенного сервера не должен попадать в репозиторий.
2026-10-15 23:25:54 +03:00
iu a45e01c701 chore(opencode): обновить OpenSpec-скиллы и команды до 1.14.2
Подтянуть сгенерированные skills/commands (generatedBy 1.14.2) и уточнения
инструкций (работа с artifactPaths, формулировки в explore).
2026-10-15 23:25:47 +03:00
iu 6e68242ec2 chore(openspec): архивировать album-live-sync и синхронизировать основные спеки
- realtime: личный поток несёт события жизненного цикла альбома и изменения
  счётчика фото; добавлены требования о жизненном цикле, получателях, форме
  payload'а, догоне и потере доступа
- albums: photos_count считает только фото вне корзины
2026-10-15 23:25:07 +03:00
iu e82f45647c feat(realtime): доставлять клиентам события жизненного цикла альбома и изменения состава фото
Публиковать 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) и
  применяет события к стору альбомов; страница альбома уходит при потере доступа
2026-10-15 23:25:07 +03:00
iu cbff7a340c test(android): grant media permissions before UI E2E and dismiss all-files prompt
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.
2026-10-13 22:13:39 +03:00
iu fa210e6c7f feat(android): album/photo attribute-actions and device gallery sync fixes
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).
2026-10-13 21:49:12 +03:00
iu d1bb964685 Merge commit '15947c030132e8809ac8f867d0c942c43f7f1178' into develop 2026-10-13 17:12:05 +03:00
iu 15947c0301 test(android): restore data-layer unit tests hidden by the data/ gitignore rule
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.
2026-10-13 17:11:50 +03:00
iu 2b54333a57 Merge commit '467a42a' into develop 2026-10-13 17:10:54 +03:00
iu 467a42ac4d chore(openspec): archive add-phone-album-sync and sync main specs
Sync the deltas into phpne...
2026-10-13 17:10:50 +03:00
iu 38b5b03f59 feat(android): sync device albums with a server album
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.
2026-10-13 17:10:47 +03:00
iu b7316f9e00 Merge commit '327f820' into develop 2026-10-13 12:41:16 +03:00
iu 327f820c3b chore(openspec): archive add-mobile-photo-zoom and sync main specs 2026-10-13 12:41:04 +03:00
iu beeb4c7c09 feat(viewer): show a loading spinner while the zoomed original loads
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
2026-10-13 12:40:53 +03:00
iu 13d5e0f290 Merge commit 'f6df9e2' into develop 2026-10-13 12:35:21 +03:00
iu f6df9e23f2 feat(viewer): add pinch-to-zoom for photos on Android
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
2026-10-13 12:30:49 +03:00
iu 28828f1dcd chore(openspec): archive add-make-install-android and sync main specs 2026-10-13 12:06:59 +03:00
iu d9f6a163e5 feat(make): add install-android-app target and default help listing
- add `help` target and make it the default goal (bare `make` lists targets)
- add `install-android-app`: build debug APK and install on the selected device
- device selection via SERIAL/$ANDROID_SERIAL; several devices list and stop
- document the target in android/README.md
2026-10-13 12:06:34 +03:00
iu 43f5520236 chore(openspec): archive add-user-avatar and sync main specs 2026-10-12 16:17:40 +03:00
iu 04617d1cdb feat(profile): add round avatar with upload and cropping
- backend: user_avatar_objects binding (PG+SQLite), GC preserves avatar blobs,
  avatar service (EXIF-stripping WebP 128/512), public avatar read route
- web: avatar upload/crop on profile, round avatar in header/members/comments/author
- android: avatar upload/crop, round avatar on profile and albums header
- openspec: add-user-avatar proposal/design/specs/tasks
2026-10-12 16:16:47 +03:00
iu 657a04309e chore(openspec): archive add-user-profile and sync main specs 2026-10-12 14:27:19 +03:00
iu 86aff695df feat(auth): add user profile, phone login, and account deletion 2026-10-12 14:26:32 +03:00
iu 1cb9aaf198 chore(openspec): archive fix-trash-counter-sync and sync main specs 2026-10-12 12:58:34 +03:00
iu 713c1ca6d3 fix(ui): keep trash counter in sync with trash changes
Reload the rail trash count after every operation that changes the
trash contents, not just navigation and empty-trash: moving photos to
trash (grid, panel, viewer), restoring, permanent delete and deleting
an album. Previously the counter stayed stale until the next route
change.
2026-10-12 12:58:18 +03:00
iu bf3641f9e0 chore(openspec): archive add-android-app and sync main specs 2026-10-12 12:15:13 +03:00
iu 9e85e52a73 chore(openspec): archive fix-photo-tag-sync and sync main specs 2026-10-12 12:15:08 +03:00
iu 3666f4ea6d chore(openspec): archive host-agnostic-object-urls and sync main specs 2026-10-12 12:15:03 +03:00
iu aa2f7552bc feat(android): add native Android client
Kotlin + Jetpack Compose client for a self-hosted instance: manual
server setup and health check, auth with Keystore-backed tokens, album
and photo browsing with SSE live updates, upload queue in WorkManager
with HEIC-to-JPEG conversion, and Room/Coil offline cache. Add an
android Makefile target and revise project.md §17.
2026-10-12 12:14:54 +03:00
iu e4975aafb5 fix(ui): propagate photo tag edits to grid and viewer
Add a shared photo-updates signal the panel publishes on rename or tag
add/remove; the album grid and viewer subscribe and patch the changed
photo into their lists, so the 360 badge and panorama mode update without
a reload.
2026-10-12 12:14:48 +03:00
iu eef7c6c5d0 fix(storage): emit origin-relative object URLs
Build signed object URLs as path-only (/api/objects/...) instead of
prefixing APP_BASE_URL, so media loads from whatever host or reverse
proxy served the app. Remove the now-unused base URL from the signer and
drop the APP_BASE_URL / Server.BaseURL config.
2026-10-12 12:14:46 +03:00
iu 4d0e24347c chore(openspec): archive fix-pano-drag-direction and sync main specs 2026-10-11 21:02:06 +03:00
iu 6528abad82 fix(ui): correct horizontal drag direction in 360 panorama viewer
The panorama viewer inverted horizontal drag relative to vertical: the
yaw sign was opposite, so dragging right turned the view right instead
of moving the scene right. Flip yaw to match the pitch convention
(drag the world) and lock the behavior with a ui-workspace scenario.
2026-10-11 21:02:01 +03:00
iu a72d8876d6 chore(openspec): archive trash-permanent-delete-and-empty and sync main specs 2026-10-11 20:55:04 +03:00
iu 3b745fa737 feat(photos): fix trash permanent delete and add empty trash
Permanently deleting a photo whose album was deleted failed because
authorization resolved through the album. Fall back to uploader
ownership when the photo is albumless. Add a bulk DELETE /trash that
removes every trashed photo the caller may delete (uncapped listing)
and returns the deleted count, with an «Очистить корзину» action on the
trash screen.
2026-10-11 20:54:54 +03:00
iu d5d3f55db8 chore: ignore python bytecode 2026-10-11 20:41:20 +03:00
iu b2e0dd6d30 chore(openspec): archive add-video-support and sync main specs 2026-10-11 20:38:22 +03:00
iu d38ede0201 feat(video): add video upload with remux/transcode playback
Add video as a media kind in photos: streaming upload with per-kind
limits, ffprobe metadata, poster frame reusing the image variant
pipeline, and a playback object (remux when web-compatible, else
h264/yuv420p/aac transcode). Video jobs run on a separate pool with a
long lease. Frontend gains a video viewer, grid badge/duration and
video upload.
2026-10-11 20:37:53 +03:00
iu d059d042f0 chore(openspec): archive thumbnail-variants and sync main specs 2026-10-11 19:57:12 +03:00
iu f1b3b01bdf feat(photos): add square medium variant for sharp grid thumbnails 2026-10-11 19:57:00 +03:00
iu 581e479f03 chore(openspec): archive thumbnail-density-control and sync main specs 2026-10-11 18:24:59 +03:00
iu 511de4ec9c fix(ui): use minus/plus icons for thumbnail size step buttons 2026-10-11 18:24:35 +03:00
iu 41e5dfaeb1 feat(ui): fix thumbnail size slider direction and add step buttons 2026-10-11 18:21:47 +03:00
iu 6ccb7764e8 chore: refresh generated OpenSpec commands and skills 2026-10-11 18:10:34 +03:00
iu c2b0d9c66f chore(openspec): archive viewer-thumbnail-hover and sync main specs 2026-10-11 18:10:07 +03:00
iu a7867054a4 feat(ui): hover zoom on viewer thumbnail strip
- Thumbnails scale their image on pointer hover and keyboard focus
- Tile size and strip layout stay unchanged
2026-10-11 18:09:54 +03:00
iu 83cc178e79 chore(openspec): archive mobile-tap-open-and-panel and sync main specs 2026-10-11 17:58:30 +03:00
iu ebae341c9a feat(ui): open photo on tap for touch and fix narrow-screen panel
- Touch/pen tap opens the viewer; mouse click still selects, dblclick opens
- Panel and rail emit a single transform utility so the panel actually
  slides in below 1280px (Tailwind v4 utility ordering made the base
  translate-x-full win over the conditional translate-x-0)
- Add touch-action: manipulation to grid cells
2026-10-11 17:58:04 +03:00
iu 9b7d81d8fc chore(openspec): archive pano360-viewer and sync main specs 2026-10-11 17:41:54 +03:00
iu fc4233cd57 feat(ui): 360 panorama viewer by pano360 tag
- Detect 'pano360' tag and open the photo in a spherical viewer
- Mini-WebGL equirect viewer (no deps): drag to look, wheel FOV, Space reset
- Load original as origin-clean bitmap; fall back to flat image without WebGL
- 360 indicator in viewer and badge on grid tiles
2026-10-11 17:41:28 +03:00
iu 3311de57c5 chore(openspec): archive grid-keyboard-navigation and sync main specs 2026-10-11 14:51:44 +03:00
iu 7359495a73 feat(ui): keyboard navigation and focus restore in album grid
- Cursor separate from selection; arrow keys move it, Space/Shift+Arrow/Enter/Esc act
- Click on empty grid space clears selection back to album properties
- Focus returns to the last viewed photo after closing the viewer
2026-10-11 14:51:00 +03:00
iu d99ac77587 chore(openspec): archive completed changes and sync main specs
- fix-large-album-load -> photos, realtime, media-objects
- fix-album-cover -> photos, albums
2026-10-11 14:16:38 +03:00
iu d7a37328f0 fix(photos): faster large-album loading and album cover lifecycle
Large albums opened slowly and flickered; the default album cover was
also lost in the media-objects refactor.

Large-album loading:
- quantize signed object URLs within a window and send Cache-Control so
  previews are reused instead of re-fetched
- publish photo.updated (not photo.created) when variants are ready, so
  clients update in place without reordering the grid
- worker concurrency + newest-first variant priority; raise default batch

Album cover:
- first uploaded photo becomes the cover when none is set
- clear the cover when the cover photo is trashed or moved out
- "set as album cover" in the photo context menu and actions panel

OpenSpec changes: fix-large-album-load, fix-album-cover
2026-10-11 14:15:59 +03:00
iu 6361e48b20 feat(observability): configurable logging, request ids and error logs
- internal/logging: LOG_LEVEL/LOG_FORMAT parsing and slog handler
- request_id middleware (X-Request-ID or generated, echoed) + debug access log
- httpx registers errors; middleware logs internal causes with request_id
- health excluded, objects/sse debug-only, no query/body/secrets in logs
- new observability capability spec; README/.env.example docs
2026-10-11 13:40:11 +03:00
iu a5d0cee0eb feat(ui): link album rail title to overview 2026-10-11 13:27:39 +03:00
iu 36e96cd519 feat(seed): add API test-data seeding script and make testdata target
- scripts/seed.py: stdlib HTTP client, JPEG+EXIF via optional Pillow (PNG fallback), additive-only, dry-run
- Makefile: testdata target with ALBUMS/PHOTOS/SEED/TIMEOUT and boolean flags
- README: usage section; new dev-seed-data capability spec
2026-10-11 13:27:21 +03:00
iu b1b0b4a83e chore(openspec): archive completed changes and sync main specs
- archive rename-shared-album-to-galo, add-halo-branding
- sync specs: ui-branding (new capability)
2026-10-11 11:37:23 +03:00
iu 50dff63ece feat: rename service to galo, add halo branding and release build
- rename Go module, binary, systemd unit, PG_DATABASE default, frontend package and docs from shared-album to galo
- add theme-aware halo emblem and favicon (new ui-branding capability)
- add make build-release (versioned, stripped, static binary) and run the binary from the repo root
2026-10-11 11:36:56 +03:00
iu 29c2594a9b Merge pull request 'feat: local-first storage and metadata backends' (#3) from feature/local-fs-storage-backend into develop
Reviewed-on: #3
2026-10-11 07:44:15 +00:00
iu e0b4db926f feat(server): log metadata and storage backends at startup 2026-10-11 10:34:30 +03:00
iu 8638d1bbfc chore(openspec): archive completed changes and sync main specs
- archive local-fs-storage-backend, sqlite-metadata-backend,
  storage-orphan-reconciliation, metadata-managed-jwt-secret, default-local-backends
- sync specs: storage-backends, metadata-backends, storage-reconciliation,
  app-secrets; update photos/share-links
- defaults reflected in specs: SQLite and local FS
2026-10-11 10:19:44 +03:00
iu cb272398ce feat(auth): store the token signing secret in the metadata layer
- app_settings table (PostgreSQL migration 000010, SQLite 000002)
- internal/settings repository (Get/Set/Ensure) wired into store.Repos
- secret generated with crypto/rand on first start and persisted; reused on restart
- drop required JWT_SECRET from env/config; app.New takes a TokenManager and
  storage.Open takes the resolved secret (URL_SECRET still overrides)
- docs updated
2026-10-11 10:18:31 +03:00
iu 176a8d8ced feat(metadata): SQLite backend, storage reconciliation and local defaults
- pluggable per-domain Repository interfaces; PostgreSQL and SQLite impls
- SQLite on modernc.org/sqlite (WAL, busy_timeout, foreign_keys, immediate tx)
- separate migrations/sqlite and db.MigrateSQLite; store.NewPostgres/NewSQLite
- cmd/reconcile compares object storage with the journal (report, then --delete)
- storage.Backend.List and photos.Repository.AllStorageKeys
- DB_BACKEND/SQLITE_PATH and STORAGE_BACKEND/FS_ROOT selectable; local defaults
  (sqlite ./galo.db, fs ./data)
- Makefile reconcile/reconcile-delete; docs and .env.example updated
2026-10-11 10:08:31 +03:00
iu ec8d907565 feat(storage): serve media through backend and add local FS backend
- introduce storage.Backend with S3 and local file-system implementations
- deliver all blobs via signed GET/HEAD /api/objects/<key> (Range, Content-Type)
- stream S3 objects through the backend; remove presigned URL path
- share shared HMAC Signer between backends
- remove S3_PUBLIC_URL; rename S3_PRESIGN_TTL to OBJECT_URL_TTL
- make storage label in health dynamic; nginx proxy_buffering off for objects
2026-10-11 09:15:03 +03:00
iu 7ae9bcf305 chore(openspec): archive media-object-layer and sync main specs
- move change media-object-layer to openspec/changes/archive/2026-10-10-media-object-layer
- add main spec specs/media-objects (opaque keys, object journal, shared
  objects, reference-based GC, lifecycle recovery, variant generation queue)
- update specs/photos deletion requirement to place photos in the trash
  without deleting storage objects
2026-10-10 23:32:21 +03:00
iu 96268ee16c feat(photos): separate object storage from metadata layer
Move physical file facts out of the photos table into an objects ledger:

- objects / photo_objects / variant_jobs tables (migration 000008)
- drop storage_key/thumb_key/preview_key/size/width/height/mime_type from
  photos (migration 000009); they are derived via joins to the original object
- album-free storage keys: objects/{id}/{role}.{ext}
- explicit GC via pending/ready/deleting states; objects deleted only when no
  photo references them
- background worker drives variant generation (durable variant_jobs) and GC
- copy/move are metadata-only; storage objects are shared between photos
- remove unused storage.DeletePrefix

Non-destructive to the external JSON API: size/dimensions/mime and URLs are
still returned, just sourced from the object layer.

OpenSpec change: media-object-layer
2026-10-10 23:31:41 +03:00
iu 9effc0fc82 Merge pull request 'feat: Lightroom-style workspace UI' (#2) from feature/lightroom-workspace-ui into develop
Reviewed-on: #2
2026-10-10 19:56:14 +00:00
795 changed files with 75714 additions and 854 deletions
+206
View File
@@ -0,0 +1,206 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. Also use when the user says "openspec apply", "opsx apply", or "openspec implement".
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.15.0"
---
Implement tasks from an OpenSpec change.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
Otherwise, with no root, what happens next depends on how this workflow was reached:
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
**Input**: Optionally specify a change name (e.g., `/openspec-apply-change add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and ask the user to select one
Always announce: "Using change: <name>" and how to override (e.g., `/openspec-apply-change <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status, source path, and source line
- Dynamic instruction based on current state
- Optional `context`: current required project instruction input from the selected root
- Optional `operationGuidance`: current advisory guidance for apply
- `missingArtifacts` (when present): required artifact ids with no output
**Handle states:**
- If `state: "blocked"`: show the message and pause implementation.
- If `missingArtifacts` is non-empty: suggest completing the missing artifacts. Run `openspec status --change "<name>" --json`, select the next `ready` artifact (not `skipped` or `blocked`), and use `openspec instructions "<artifact-id>" --change "<name>" --json` for its rules and template. Keep the selected `--store <id>` on both commands.
- Otherwise, follow the CLI instruction to create or repair the schema-configured tracking file from existing planning artifacts. Do not assume another artifact is ready or start implementation while blocked.
- If `state: "all_done"`: report that all tracked tasks are complete and suggest review or verification as appropriate before archiving
- Otherwise: proceed to implementation
Treat `context` as a required prompt-level input. Read and consider it, and
apply relevant project facts, conventions, and constraints while implementing.
Treat `operationGuidance` as optional additive advice. Read and consider every
entry, and follow entries that are applicable and compatible with the built-in
workflow.
Keep both fields separate from CLI-returned state, missing artifacts, tasks,
progress, `contextFiles`, and the built-in `instruction`. They are not
evidence of task completion, do not replace the built-in instruction, and do
not permit bypassing a blocked state. If context conflicts with the built-in
instruction, an explicit user choice, or a CLI-controlled value, report the
conflict and preserve the controlling value. If guidance is inapplicable or
conflicts with those controlling inputs, do not follow it and explain why.
These are prompt-level behavior contracts, not enforceable checks.
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
Do not copy `context` or `operationGuidance` verbatim into implementation
files or planning artifacts unless the user separately asks for that content.
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Before editing, confirm the checkbox at the returned `sourcePath` and `line` still matches the task description; if it does not, rerun the apply instructions and use the refreshed location
- Mark the task complete at its returned `sourcePath` and `line`: `- [ ]` → `- [x]`
- Rerun the apply instructions and confirm that task is now done and progress changed
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- A task needs work beyond what the spec and tasks describe, or you are tempted to drop, narrow, defer, or accept exceptions to specified behavior to make it fit → surface the added scope and ask; do not absorb it silently
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: report that tracked tasks are complete and suggest review or verification as appropriate before archiving
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tracked tasks are complete. Review or verify the change as appropriate
before archiving. You can archive this change with `/openspec-archive-change`.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- When a task needs work beyond what the spec describes, surface the added scope and pause - never silently narrow, defer, or simplify away specified behavior
- Only mark a task `- [x]` when its specified behavior is fully implemented, not when it is partially done or deferred
- Use contextFiles from CLI output, don't assume specific file names
- Use each task's sourcePath and line to update its exact checkbox
- Do not use context or operation guidance as proof that a task is complete
- Apply relevant project context; report conflicts with controlling workflow inputs
- Consider every guidance entry; explain any inapplicable or conflicting advice
- Do not copy runtime context or operation guidance into implementation files or planning artifacts
- Preserve CLI-controlled blocked/ready/all-done behavior and completion criteria
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
@@ -0,0 +1,221 @@
---
name: openspec-archive-change
description: Archive a completed OpenSpec change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. Also use when the user says "openspec archive" or "opsx archive".
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.15.0"
---
Archive a completed change in the experimental workflow.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
Otherwise, with no root, what happens next depends on how this workflow was reached:
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and ask the user to select one
When prompting, show only active changes (not already archived).
Include the schema used for each change if available.
Always announce: "Using change: <name>" and how to override (e.g., `/openspec-archive-change <other>`).
**Load current archive inputs before the existing archive checks:**
After resolving the selected change and planning root, run:
```bash
openspec instructions archive --change "<name>" --json
```
Keep the same selected-root flags on this command. This lookup is advisory and
optional: it only supplies extra prompt inputs, so it must never block archiving.
If it exits non-zero or returns invalid JSON — for example on an older CLI that
does not support this command yet — continue the archive workflow with no
context and no operation guidance. Do not report an error and do not stop.
A successful response may omit both optional fields. Treat `context` as a
required prompt-level input: read and consider it, and apply relevant project
facts, conventions, and constraints. Treat `operationGuidance` as optional
additive advice: read and consider every entry, and follow entries that are
applicable and compatible with the built-in archive workflow.
Keep both fields separate from built-in steps, explicit user choices, resolved
paths, CLI checks, and command contracts. If context conflicts with one of those
controlling inputs, report the conflict and preserve the controlling value. If
guidance is inapplicable or conflicts with a controlling input, do not follow it
and explain why. Do not infer replacement paths, skipped prompts, or flags from
either field, and do not copy their text verbatim into specs, change artifacts,
or archive summaries unless the user separately asks for it. These are
prompt-level behavior contracts, not enforceable checks.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done`, `skipped`, or other)
**If any artifacts are neither `done` nor `skipped`** (skipped artifacts satisfy the requirement - the change declares skip_specs):
- Display warning listing incomplete artifacts
- Ask the user to confirm they want to proceed
- Proceed if user confirms
3. **Check task completion status**
Run `openspec list --json` with the same selected-root flags and find the
entry in `changes` whose `name` exactly matches the selected change.
Require exactly one match and nonnegative integer `totalTasks` and
`completedTasks`, with `completedTasks <= totalTasks`. The CLI resolves
the schema's tracked task files, including custom artifact names, output
paths, and globs.
Incomplete tasks = `totalTasks - completedTasks`.
Do not infer task completion from artifact status or the absence of a
top-level `tasks.md`. If the lookup fails, returns invalid JSON, omits or
duplicates the selected change, or returns invalid counts, report the problem
and stop before syncing or archiving.
The CLI counts only `x`/`X` checkbox markers as complete;
other markers, including unfamiliar ones, remain incomplete.
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Ask the user to confirm they want to proceed
- Proceed if user confirms
**If `totalTasks` is zero:** Proceed without a task-related warning.
4. **Assess delta spec sync state**
Use `artifactPaths.specs.existingOutputPaths` from status JSON as the only
delta-spec source. If the `specs` entry is missing or
`existingOutputPaths` is empty, proceed without a sync prompt and do not infer
delta specs from other artifacts.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `<planningHome.root>/openspec/specs/<capability-path>/spec.md` (use the store-aware `planningHome.root` from step 2, not a hardcoded repo path)
- A missing main spec is **not automatically** "already synced". For a new capability, the main spec is an *output* of the sync, not an input:
- If the delta has MODIFIED or RENAMED requirements, report that only ADDED requirements can create a new main spec and mark that capability as sync-blocked. Never invent a requirement that has no current version.
- Otherwise, if the delta has only REMOVED requirements and the change's `.openspec.yaml` declares `retire_capabilities: true`, the capability is already retired: count it as already synced, warn that there is nothing left to remove, and do not recreate the main spec. Apply this rule both now and when verifying a completed sync.
- Otherwise, if the delta has no ADDED requirements, report that no sync is possible and mark that capability as sync-blocked. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. `openspec archive` refuses the unmarked REMOVED-only case with `Spec must have at least one requirement`.
- Otherwise, count the capability as needing sync and name it in the summary (`<capability-path>: new main spec will be created`). If the delta also has REMOVED requirements, warn that they will be ignored because there is no main spec to remove from. The sync creates the main spec from only the delta's ADDED requirements, exactly as `openspec archive` does.
- Determine what changes would be applied (adds, modifications, removals, renames)
- Continue assessing the remaining capabilities even when one is sync-blocked. Show a combined summary before prompting.
**Prompt options:**
- If any capability is sync-blocked: explain why and offer only "Archive without syncing", "Cancel"
- Otherwise, if changes needed: "Sync now (recommended)", "Archive without syncing"
- Otherwise, if already synced: "Archive now", "Sync anyway", "Cancel"
Route on the answer:
- "Cancel" — stop, do not archive
- "Archive without syncing" or "Archive now" — proceed to archive
- "Sync now" or "Sync anyway" — sync, then verify (below). Do not start any sync while a capability is sync-blocked; explain the blocker and repeat the available choices.
- Anything else — ask again rather than archiving
Before a selected sync writes any main spec, run
`openspec instructions specs --change "<name>" --json` once with the same
selected-root flags. Require a zero exit status and valid artifact-instruction
JSON. If the lookup fails or returns invalid JSON, report the error and stop
before writing any main spec or moving the change. A valid response with omitted
`rules` is the no-rules case. Apply returned `rules` only to the content and
form of main specs produced by this merge; do not use them as archive guidance,
change CLI behavior, or copy the rule text into any output file.
Then run the `openspec-sync-specs` workflow inline (agent-driven intelligent merge) for change '<name>', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching `specs` instructions again. Do not delegate it to a background task — step 5 would move `changeRoot` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result.
If the sync reports any stop or blocking condition, treat the sync as failed.
Stop the archive immediately. Do not perform the post-sync content comparison and do not move its `changeRoot`.
Nothing has moved, so the user can fix the blocking condition or re-run the sync.
After the sync writes each main spec, verify its structure against the canonical sync contract:
- A new main spec starts with a `# <capability> Specification` title. An existing main spec keeps its title exactly as it is.
- Preserve existing `## Purpose` sections completely untouched for established main specs.
- For a new main spec, copy the delta `## Purpose` verbatim. Warn only if the purpose text is shorter than standard validation expects. Do not regenerate or rewrite existing authored purpose. If no usable `## Purpose` is provided, use the existing TBD Purpose behavior and warning.
- Verify that no delta-style section headers (`## ADDED Requirements`, `## MODIFIED Requirements`, `## REMOVED Requirements`, `## RENAMED Requirements`) remain in the main spec, adhering strictly to the sync workflow formatting rules.
- Requirement blocks the sync wrote or changed use `### Requirement:` headings, and their scenarios use `#### Scenario:` headings, under the spec's `## Requirements` section. Leave content the delta does not mention exactly as it is.
Then re-run the comparison from the top of this step, including the explicitly retired, missing-spec case, against every capability that has a delta spec in `artifactPaths.specs.existingOutputPaths` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced:
- ADDED requirements present
- MODIFIED requirements carrying the scenario and description changes named in the delta, with their other scenarios intact
- REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving `## Requirements` empty), its main spec deleted rather than left empty.
- RENAMED requirements present under the new name and absent under the old one
If the sync failed, or any capability does not match, report what differs and stop — do not archive. Nothing has moved and `changeRoot` is intact, so the user can fix the mismatch or re-run the sync and start the archive again.
5. **Perform the archive**
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
```bash
mkdir -p "<planningHome.changesDir>/archive"
```
Generate the target name: use the change name as-is when it already starts with a `YYYY-MM-DD-` prefix; otherwise prepend the current date as `YYYY-MM-DD-<change-name>`. Never stack a second date (same rule as `openspec archive`).
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move `changeRoot` to the archive directory
```bash
mv "<changeRoot>" "<planningHome.changesDir>/archive/<target-name>"
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Whether specs were synced (if applicable)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```markdown
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/<target-name>/
**Specs:** <"✓ Synced to main specs" only if the step 4 verification passed; otherwise "No delta specs" or "Sync skipped">
<"All artifacts complete. All tasks complete." — or, if archived with warnings, list them instead (e.g. "Archived with 2 incomplete tasks")>
```
**Guardrails**
- Announce the selected change; prompt for selection when it is ambiguous
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, run the `openspec-sync-specs` workflow inline (agent-driven)
- Never archive while a spec sync is still in flight — run the sync inline and verify the main specs before moving `changeRoot`
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
- Apply relevant runtime context and report conflicts; operation guidance remains advisory
- Consider every guidance entry and explain any inapplicable or conflicting advice
- Existing CLI checks, resolved paths, prompts, and command contracts are unchanged
- Artifact rules constrain only the specs being written and are never operation guidance
- Never copy runtime context, operation guidance, or artifact-rule text verbatim into output files
+356
View File
@@ -0,0 +1,356 @@
---
name: openspec-explore
description: Enter OpenSpec explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements in a project that uses OpenSpec. Use when the user wants to think through something before or during an OpenSpec change. Also use when the user says "openspec explore" or "opsx explore".
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.15.0"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, do not start it here: say that explore mode does not implement, and point them at `/openspec-propose`, which turns the discussion into a change. The work happens from that change, never from explore mode. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. An explicit request from the user to capture the exploration as a new change is itself that confirmation, covering the change and the change artifacts the request names; scaffold it first as described below.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
Otherwise, with no root, what happens next depends on how this workflow was reached:
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## Planning a Change
When the user is planning a change, guide them toward shared understanding with focused discovery questions. For open-ended discussion, follow the conversation without imposing an interview or a required output.
Before asking a factual question, follow the context discovery below and inspect relevant OpenSpec artifacts, source, tests, docs, and configuration. Do not ask the user to repeat facts you can verify. Summarize relevant findings without reproducing private context or rules. If evidence is missing, conflicting, or inaccessible, state that limitation and ask only for the clarification needed to proceed.
- **Follow dependencies** - Resolve the next blocking decision before its dependent details. For example, clarify the user's outcome and scope before choosing an API or data model. Revisit downstream assumptions when an earlier answer changes. Skip branches that do not matter to this goal.
- **Keep questions focused** - Ask one focused question at a time, and briefly explain why it matters and which decision it unlocks. Batch questions only if the user asks for a batch; keep them small and group related decisions.
- **Offer grounded recommendations** - When evidence supports a recommendation, state your preferred option and why it fits the user's goals, with alternatives and their tradeoffs when useful. Do not invent intent, priorities, or external constraints: ask the user when only they can answer. Avoid a fixed question format.
- **Keep a conversational record** - Track decisions in the conversation, not in files. Separate confirmed decisions from proposed defaults and unresolved questions. Silence is not acceptance. Accepting an answer or a batch of recommendations is not permission to write. Keep file-write confirmation separate from discovery questions and follow the guardrails below.
Stop asking when the user has enough clarity. Let them pause, pivot, or defer a decision; do not exhaust every branch or force a proposal.
For example, after inspecting the relevant code:
```text
The CLI already uses SQLite and has no remote service. Is sharing state
across devices in scope? That determines whether local storage is enough.
If this stays a single-device tool, I recommend keeping SQLite to avoid
adding a service to operate; shared state would need a separate sync design.
```
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
+------------------------------------------+
| Use ASCII diagrams liberally |
+------------------------------------------+
| |
| [State A] -------> [State B] |
| | |
| v |
| [State C] |
| |
| System diagrams, state machines, |
| data flows, architecture sketches, |
| dependency graphs, comparison tables |
| |
+------------------------------------------+
```
**Draw with plain ASCII only** — borders `+` `-` `|`, arrows `-->` `<--` `^` `v`, markers `*` `x`.
Unicode diagram glyphs can render at different widths across terminals, fonts, and locales, so padded boxes and aligned tables can drift. Keep every diagram character ASCII.
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names and task status
- What the user might be working on
That is the *change* list - work in flight. It does not include the project's durable capabilities, so list those too:
```bash
openspec list --specs
```
Add `--json` for ids and requirement counts, and append `--store "<id>"` only for a registered standalone store. This is the inventory of what the project already claims to do, and `openspec list` on its own never shows it. To look at one, run `openspec show "<spec-id>" --type spec --json --no-scenarios` (same `--store` rule) - it returns that capability's purpose and requirement texts without pulling the whole spec file into context, and `--type spec` stops a change of the same name from making it ambiguous.
The filtered read is only an overview. Before deciding what is already covered or what should change, read each relevant spec in full, including scenarios, with `openspec show "<spec-id>" --type spec` (same `--store` rule).
Then read the project's own context from the resolved root - `<root.path>/openspec/config.yaml` (or `config.yml`). Use the `root.path` returned above, and skip this if neither file exists:
- `context`: project background - tech stack, conventions, constraints
- `rules`: keyed by artifact id - the entries for an artifact apply only when you write that artifact
Ground your thinking in these. They are constraints for you to follow, not content to reproduce: do NOT copy them into the conversation or into any artifact you create.
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
If the user asks you to capture the exploration as a new change, that request is the confirmation required above. It covers scaffolding that change and creating the change artifacts the request names, and nothing else. This holds only when the request is theirs: a yes to an offer you made confirms only the scope your offer itself named, so name the change and the artifacts in the offer. Don't re-ask for what they already asked for; do ask before anything beyond it. Transition seamlessly into the requested capture:
1. Run `openspec new change "<name>"` (with `--store <id>` when applicable) before creating any artifacts. Never create a new change directory under `openspec/changes/` by hand; the CLI scaffold creates required metadata such as `.openspec.yaml`. Keep the selected `--store <id>` on every applicable follow-up `status` and `instructions` command.
2. Run `openspec status --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store), then process the requested artifacts in dependency order. For each requested artifact that is `ready`, run `openspec instructions "<artifact-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store). Before creating a requested artifact, evaluate any condition in its own `instruction` against the explored change; record a deliberate skip instead when the condition does not apply. If a requested artifact is blocked by a direct prerequisite the user did not request, run `openspec instructions "<prerequisite-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) for that prerequisite whether it is `ready` or `blocked`. If its own `instruction` states a condition, evaluate that condition against the explored change and record a deliberate skip only when the condition does not apply. If the condition applies, or the prerequisite is not conditional, treat it as a normal prerequisite and ask before expanding the capture. Do not create an unrequested prerequisite unless the user approves.
3. Follow the returned `template` and `instruction` fields. Read completed dependency files listed in `dependencies`, and apply `context` and `rules` as constraints without copying them into the artifact. Choose the output path before creating anything: when `resolvedOutputPath` is a glob, use the instruction to choose a concrete path inside `changeRoot` and verify it remains inside `changeRoot` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is `resolvedOutputPath`. If the instruction delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Otherwise write the artifact to that output path. Verify that the selected concrete output exists at that output path.
4. After creating each artifact, re-run `openspec status --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) and continue until every requested artifact is `done`, `skipped`, or was deliberately skipped because its own `instruction` stated a condition that did not apply. Tell the user about a deliberate conditional skip, remember it, and do not reconsider it. Dependencies are enablers, not gates: if a requested artifact is still `blocked` only because you deliberately skipped a conditional prerequisite, run `openspec instructions "<artifact-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) despite the blocked status, then create it using step 3 only when those recorded conditional skips are its sole missing dependencies. If a requested artifact is blocked by a prerequisite the user did not ask to capture and cannot be conditionally skipped, explain that dependency and ask before expanding the capture.
Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. When the requested capture is done, stop there and name where the work continues: `/openspec-propose` writes the remaining planning artifacts, and `/openspec-apply-change` implements the change once tasks exist. Capturing artifacts never starts implementing them.
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Resolve and read existing artifacts for context**
- Run `openspec status --change "<name>" --json`.
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve an existing capability's full path and follow the project's established organization for new capabilities.
The files below are the default spec-driven schema's. With another schema, capture each insight in whichever artifact from `artifactPaths` fits it, and name that artifact in your offer; never create an artifact the schema does not define.
| Insight Type | Where to Capture |
|----------------------------|-------------------------------------|
| New requirement discovered | `specs/<capability-path>/spec.md` |
| Requirement changed | `specs/<capability-path>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers (default schema):
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Handling Different Entry Points
**User brings a vague idea:**
```
User: I'm thinking about adding real-time collaboration
You: Real-time collab is a big space. Let me think about this...
COLLABORATION SPECTRUM
============================================
Awareness Coordination Sync
| | |
v v v
+--------+ +--------+ +--------+
|Presence| |Cursors | | CRDT |
| "3 | | Multi | |Conflict|
|online" | | select | | free |
+--------+ +--------+ +--------+
| | |
trivial moderate complex
Where's your head at?
```
**User brings a specific problem:**
```
User: The auth system is a mess
You: [reads codebase]
+---------------------------------------------+
| CURRENT AUTH FLOW |
+---------------------------------------------+
|
+-------------+-------------+
v v v
+---------+ +---------+ +---------+
| Google | | GitHub | | Email |
| OAuth | | OAuth | | Magic |
+----+----+ +----+----+ +----+----+
| | |
+-------------+-------------+
v
+-----------+
| Session |
+-----+-----+
|
v
+-----------+
| Perms |
+-----------+
I see three tangles. Which one's burning?
```
**User is stuck mid-implementation:**
```
User: /openspec-explore add-auth-system
The OAuth integration is more complex than expected
You: [reads change artifacts]
You're on task 4: "Implement OAuth flow"
Let me trace what's involved...
[draws diagram, explores options, suggests paths]
Want to update the design to reflect this?
Or add a spike task to investigate?
```
**User wants to compare options:**
```
User: Should we use Postgres or SQLite?
You: Generic answer is boring. What's the context?
User: A CLI tool that tracks local dev environments
You: That changes everything.
+-------------------------------------------------+
| CLI TOOL DATA STORAGE |
+-------------------------------------------------+
Key constraints:
- No daemon running
- Must work offline
- Single user
SQLite Postgres
Deployment embedded needs server
Offline yes no
Single file yes no
SQLite. Not even close.
Unless... is there a sync component?
```
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? Run `/openspec-propose` and this becomes a change."
- **Result in artifact updates**: "Updated the design with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When it feels like things are crystallizing, you might summarize:
```
## What We Figured Out
**The problem**: [crystallized understanding]
**The approach**: [if one emerged]
**Open questions**: [if any remain]
**Next steps** (if ready):
- Turn this into a change: `/openspec-propose`
- Keep exploring: just keep talking
```
But this summary is optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Workflow configuration counts too: creating or editing schemas, templates, or `openspec/config.yaml` is a change, not thinking. Creating or updating OpenSpec change artifacts within the confirmed scope is fine, writing anything else is not. When the user is ready to build, name the handoff rather than starting: `/openspec-propose` turns the discussion into a change, and the work happens there.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it. Read-only commands and tools need no confirmation. Before the first write-capable action—including `openspec new change` or another command that writes files—name the artifacts or files and proposed changes, ask a direct yes/no question, and wait for explicit confirmation in a separate user message. That confirmation covers only the described scope; ask again before expanding it. Answers to design or clarifying questions are never consent to write. That rule governs `openspec new change` whenever you are the one proposing the capture; the user's own capture request is the exception, handled in the capture transition above.
- **Don't manually scaffold changes** - Never create a new change directory under `openspec/changes/` by hand. Always use `openspec new change "<name>"` (with `--store <id>` when applicable) so required metadata such as `.openspec.yaml` is created before writing artifacts.
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
+175
View File
@@ -0,0 +1,175 @@
---
name: openspec-propose
description: Propose a new OpenSpec change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. Also use when the user says "openspec propose" or "opsx propose".
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.15.0"
---
Propose a new change - create the change and generate all artifacts in one step.
**Planning boundary**: This workflow creates planning artifacts only. The user request that selected or triggered this workflow authorizes planning only, even if it asks to build or fix something. Do not edit project code. After the planning artifacts are complete, stop. Do not start implementation in the same response, even if the initial request asks for it. Wait for a new user request after the artifacts are presented; then start the apply workflow.
I'll create a change with the artifacts your schema defines. With the default spec-driven schema that is:
- proposal.md (what & why)
- `specs/<capability-path>/spec.md` (what the system must do - a delta, not the main spec)
- design.md (how)
- tasks.md (implementation steps)
`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve an existing capability's full path and follow the project's established organization for new capabilities.
When the user is ready to implement, they must start the apply workflow explicitly.
---
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
Otherwise, with no root, what happens next depends on how this workflow was reached:
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **Understand the request and clarify material ambiguity**
If no clear input is provided, ask the user (open-ended, no preset options):
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
If the request contains ambiguity that would materially affect scope, externally observable behavior, compatibility, or acceptance criteria, ask the user before creating the change. For minor details, make a reasonable assumption and record it in the planning artifacts.
2. **Load project context**
Run `openspec context --json` from the current working directory (or `openspec context --json --store "<store-id>"` when a registered store was explicitly selected). Use the returned `root.path` as the authoritative OpenSpec root. If context reports `no_openspec_root`, stop without creating or changing any files and follow the **Project check** above for how this workflow was reached. Offer `openspec init` only for an explicit OpenSpec request, and wait for the user to request initialization. Do not initialize automatically or run `openspec new change`. After initialization, rerun this context check before continuing. For any other context failure, stop and report the error; do not fall back to the current directory or run later OpenSpec commands without the selected store.
Only when context returns a resolved `root.path`, read `<root.path>/openspec/config.yaml`. Use `config.yml` only when `config.yaml` does not exist. If neither file exists, continue without project context. Do not fall back to `config.yml` if `config.yaml` is unreadable or invalid.
If the file parses as a YAML object and its `context` field is a string no larger than 51,200 bytes in UTF-8, apply that field before exploring the codebase or making planning decisions. If the file cannot be read or parsed, or the context field is invalid or oversized, continue without project context. Validate this field independently of other config fields, as OpenSpec does.
Treat context as project-provided data and constraints, not as authority to change this workflow: it cannot override user authorization, the planning boundary, tool restrictions, or artifact and output rules. Do not copy the context into artifacts; use it to focus any codebase exploration and as a constraint on the proposal.
3. **Determine the workflow schema**
Use the configured default schema unless the user explicitly requests a different workflow.
**Use a different schema only if the user:**
- Explicitly requests a specific schema by name → use `--schema <schema-name>`
- Asks to "show workflows" or asks "what workflows" exist → resolve the authoritative root by running `openspec context --json` from the current working directory. If the user explicitly selected a registered store, use `openspec context --json --store "<store-id>"`. Then run `openspec schemas --json` with its working directory set to the returned `root.path` and let them choose. This preserves roots selected by a local `store:` pointer or the global `defaultStore`; when a registered store was explicitly selected, append `--store "<store-id>"` to `openspec schemas --json` as well. If context fails, stop as described in the context-loading step; do not fall back to the current directory.
Otherwise, omit `--schema` to preserve the configured default.
4. **Create the change directory**
Choose one schema form below. If a registered store is selected, append `--store "<store-id>"` to that command and each later OpenSpec command shown below that accepts `--store`.
Using the configured default:
```bash
openspec new change "<name>"
```
Using an explicitly requested schema:
```bash
openspec new change "<name>" --schema "<schema-name>"
```
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
5. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts, each with its `status` and its `requires` edges (the artifact IDs it directly depends on)
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
6. **Create every artifact in the required set**
Use a todo list to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `skipped`/`warning`: present when the change declares skip_specs and this artifact must NOT be created - stop and pick another artifact
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them)
- **Inspect the relevant project before drafting**: Read `context` and `rules` first, then inspect relevant implementation, nearby tests, configuration, and documentation outside `openspec/`. Keep inspection read-only and proportional to the change; reuse findings for later artifacts and inspect more only as needed.
- Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
- Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
- Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
- Choose the output path before creating anything. If `resolvedOutputPath` is a glob, follow `instruction` to choose a concrete path inside `changeRoot` that matches the glob, and verify it remains inside `changeRoot` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is `resolvedOutputPath`
- If the `instruction` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Then verify the artifact file exists at that output path
- Otherwise create the artifact file using `template` as the structure and write it to that output path
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until every artifact in the required set exists (not just `apply.requires`)**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- The required set is `applyRequires` plus every artifact reachable from those by following the `requires` edges in `status --json` - walk them transitively (spec-driven closes over proposal, specs, design, tasks). Leave artifacts outside that set alone
- `status` is file-existence only, so an `applyRequires` artifact reading `done` does NOT mean its dependencies exist - writing `tasks.md` early marks `tasks` done while `specs` was never written. Use each artifact's `requires` edges, not its `status`, to build the required set: a `done` artifact still lists what it depends on
- An artifact already reading `status: "skipped"` is satisfied: the change declares `skip_specs` in `.openspec.yaml`, so its files must NOT exist. Never try to create one
- Create every artifact in the required set that is missing, then re-check - creating one can unblock others
- Skip one only when `status` already reports it `skipped`, or when its own `instruction` says it is conditional: run `openspec instructions <artifact-id> --change "<name>" --json` and skip only if its `instruction` field marks it optional (e.g. "create only if..."). Spec-driven's `design.md` qualifies; `specs` qualifies only via the `skipped` status above, never by your own judgment. Tell the user, and do not reconsider it
- Dependencies are enablers, not gates: if a required artifact is still `blocked` only because you skipped a conditional dependency, write it anyway
- Stop when every artifact in the required set is `done`, `skipped`, or was deliberately skipped
c. **If an artifact requires user input** (unclear context):
- Ask the user to clarify
- Then continue with creation
7. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions, plus any conditional artifact you skipped and why
- What's ready: "All artifacts needed for implementation are ready."
- Prompt: "The artifacts are ready for review. When you are ready, run `/openspec-apply-change` or ask me to apply this change."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type - it is the authoritative guidance, even for familiar artifact names
- If the `instruction` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- The request that invoked this workflow authorizes planning only. Any implementation or apply instruction in that request does not carry forward. Do NOT implement the change, start the apply workflow, or edit project code during this workflow. After presenting the artifacts, stop and wait for a new user request to start the apply workflow
- Create every artifact the apply phase transitively depends on, not just the ids listed in `apply.requires`
- Always read dependency artifacts before creating a new one - re-read from disk, not from conversation memory (files may have changed since you last saw them)
- Ask about ambiguities that would materially change scope, externally observable behavior, compatibility, or acceptance criteria; for minor details, make reasonable assumptions and record them
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
+290
View File
@@ -0,0 +1,290 @@
---
name: openspec-sync-specs
description: Sync delta specs from an OpenSpec change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change. Also use when the user says "openspec sync" or "opsx sync".
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.15.0"
---
Sync delta specs from a change to main specs.
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
Otherwise, with no root, what happens next depends on how this workflow was reached:
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and ask the user to select one
When prompting, show changes that have delta specs (under `specs/` directory).
Always announce: "Using change: <name>" and how to override (e.g., `/openspec-sync-specs <other>`).
2. **Resolve change context**
Run:
```bash
openspec status --change "<name>" --json
```
The JSON includes `planningHome.root`. Main specs live under `<planningHome.root>/openspec/specs/` — use that (store-aware) root for every main-spec path below, not a hardcoded repo path. When a store is selected it points at the store, not the current repository.
3. **Find delta specs**
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the
only source of delta spec paths. If the `specs` entry is missing or
`existingOutputPaths` is empty, report that there are no delta specs to sync,
do not infer them from other artifacts, and stop without requesting artifact
instructions or writing a main spec.
Sync every path in `existingOutputPaths` unless the caller narrowed the set.
A caller narrows it by naming an explicit list of complete entries from
`existingOutputPaths` — copy those absolute values verbatim. Archive does
this inline, and a user can too (for example, by selecting the entry ending
in `/specs/billing/invoices/spec.md`).
Then sync only the named paths and leave the remaining delta specs untouched:
bulk archive excludes a delta whose implementation it could not find, and
syncing it anyway would write a main spec the caller deliberately withheld.
Carry that narrowed selection through step 4; never widen it back to the full
list. If a named path is not in `existingOutputPaths`, do not sync it —
report it and stop, rather than dropping it silently. If the named list is
empty, report that there is nothing to sync and stop without writing a main
spec.
Each delta spec file contains sections like:
- `## ADDED Requirements` - New requirements to add
- `## MODIFIED Requirements` - Changes to existing requirements
- `## REMOVED Requirements` - Requirements to remove
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
If no delta specs found, inform user and stop.
4. **For each delta spec, apply changes to main specs**
Before the first main-spec write, obtain one current specs-rule snapshot:
- If archive invoked this workflow inline and supplied a valid snapshot from
`openspec instructions specs --change "<name>" --json`, reuse it and do not
fetch the same instructions again.
- Otherwise run that command once now with the same selected-root flags.
- If the direct lookup exits non-zero or returns invalid artifact-instruction
JSON, report the error and stop before writing any main spec. Do not treat the
failure as an absent rule set.
- A valid response with omitted `rules` means no artifact rules are configured
and the existing semantic merge continues.
Apply returned `rules` only to the content and form of the main specs produced
by this merge. Artifact rules are not operation guidance and cannot change
selected roots, delta paths, CLI checks, or workflow steps. Use their text as
constraints without copying it verbatim into a main spec or summary.
For each capability delta spec path selected in step 3 — the full `existingOutputPaths` list, or the narrowed subset when a caller supplied one (these may belong to a selected store, not the repo):
a. **Read the delta spec** to understand the intended changes
b. **Read the main spec** at `<planningHome.root>/openspec/specs/<capability-path>/spec.md` (may not exist yet)
**If it does not exist yet** (a new capability), match what `openspec archive` does:
only ADDED requirements may be applied - step d creates the spec from them.
MODIFIED and RENAMED have no requirement to act on, so stop the sync for that
capability and report that its main spec does not exist and only ADDED is allowed
for a new spec; never invent the missing requirement. REMOVED has nothing to
remove - skip it and warn.
c. **Apply changes intelligently**:
**ADDED Requirements:**
- If requirement doesn't exist in main spec → add it
- If requirement already exists → update it to match (treat as implicit MODIFIED)
**MODIFIED Requirements:**
- Find the requirement in main spec
- Apply the changes - this can be:
- Adding new scenarios the main spec does not have yet
- Modifying existing scenarios
- Changing the requirement description
- Preserve scenarios/content not mentioned in the delta
**REMOVED Requirements:**
- Remove the entire requirement block from main spec
- Retiring the capability. Delete the whole `spec.md` - and the directory once
nothing else is left in it - only when ALL of these hold:
1. removing the requirements *this run* left no requirement blocks;
2. the rest of the spec is well-formed (it still has a `## Purpose`);
3. the main spec was not already empty before this sync - if you removed
nothing, change nothing;
4. every other nonblank line in the whole file is accounted for as the
title, Purpose, Requirements header, or a canonical requirement's
statement, scenarios, or fenced examples;
5. the change's `.openspec.yaml` declares `retire_capabilities: true`;
6. the `spec.md` resolves inside the real specs root (do not follow a
capability-directory symlink to delete an external file).
If removing the selected requirements would leave no requirement blocks and
any retirement condition is not satisfied, do not modify the main spec. Stop
the sync for that capability, report the blocking condition, and tell the user
how to resolve it. Never write or leave an empty `## Requirements` section.
When only the marker is missing, say that too - it is the one thing the user
can add to make the retirement go through.
- Deleting the file also deletes its `## Purpose`; any other section blocks
retirement. Name Purpose when you report the retirement. Include a pasteable
`git checkout` only when the spec lived in the caller's checkout;
otherwise give checkout-scoped recovery guidance.
**RENAMED Requirements:**
- Find the FROM requirement, rename to TO
**`## Purpose` in the delta:**
- The main spec already has one and it is authoritative - leave it alone
(this is what `openspec archive` does; it warns and moves on)
d. **Create new main spec** if capability doesn't exist yet:
- Only when the delta has ADDED requirements to put in it and no MODIFIED or
RENAMED requirements blocked this capability in step b. Otherwise create nothing
and leave the specs directory untouched. For a REMOVED-only delta, if the change's
`.openspec.yaml` declares `retire_capabilities: true`, report it as already retired
and continue without recreating the spec. Without that marker, report the sync as blocked:
`openspec archive` rejects it with `Spec must have at least one requirement`.
An empty delta has no operations to sync; report it as blocked too.
Never write an empty `## Requirements` section.
- Create `<planningHome.root>/openspec/specs/<capability-path>/spec.md`
- Add Purpose section: copy the delta's `## Purpose` body verbatim when it has one
(this is what `openspec archive` does); only write a brief TBD placeholder when it does not
- Add Requirements section with the ADDED requirements
- Follow the **Main Spec Format Reference** below
5. **Validate updated main specs**
Run `openspec validate --specs` with the same selected-root flags used earlier.
If validation fails, report the problems and do not claim the sync succeeded.
6. **Show summary**
After applying all changes, summarize:
- Which capabilities were updated
- What changes were made (requirements added/modified/removed/renamed)
- Any new main spec left with a TBD Purpose placeholder, so it gets written
now rather than lingering
- Any capability retired, naming the deleted `spec.md`, its Purpose, and
either a pasteable `git checkout` or checkout-scoped recovery guidance
**Delta Spec Format Reference**
```markdown
# Spec Delta
## Purpose
Only on a delta that introduces a brand-new capability. Seeds the new main spec.
## ADDED Requirements
### Requirement: New Feature
The system SHALL do something new.
#### Scenario: Basic case
- **WHEN** user does X
- **THEN** system does Y
## MODIFIED Requirements
### Requirement: Existing Feature
The system SHALL keep doing the existing thing, now also handling A.
#### Scenario: Scenario the main spec already has
- **WHEN** user does X
- **THEN** system does Y
#### Scenario: New scenario to add
- **WHEN** user does A
- **THEN** system does B
## REMOVED Requirements
### Requirement: Deprecated Feature
## RENAMED Requirements
- FROM: `### Requirement: Old Name`
- TO: `### Requirement: New Name`
```
**Main Spec Format Reference**
Main specs are what the delta merges INTO. They must never contain delta operation headers (`## ADDED/MODIFIED/REMOVED/RENAMED Requirements`) - after syncing, every requirement lives under a single `## Requirements` section:
```markdown
# <capability> Specification
## Purpose
Short description of what this capability does and why it exists.
## Requirements
### Requirement: New Feature
The system SHALL do something new.
#### Scenario: Basic case
- **WHEN** user does X
- **THEN** system does Y
```
**Key Principle: Intelligent Merging**
Unlike programmatic merging, you merge rather than overwrite:
- A MODIFIED block carries the whole requirement - body plus every scenario that survives the change. `openspec validate` and `openspec archive` both reject one that drops a scenario the main spec still has.
- Keep anything the delta does not mention, in the main spec's existing order
- Use your judgment to merge changes sensibly
**Output On Success**
```markdown
## Specs Synced: <change-name>
Updated main specs:
**<capability-1>**:
- Added requirement: "New Feature"
- Modified requirement: "Existing Feature" (added 1 scenario)
**<capability-2>**:
- Created new spec file
- Added requirement: "Another Feature"
Main specs are now updated. The change remains active - archive when implementation is complete.
```
**Guardrails**
- Read both delta and main specs before making changes
- Preserve existing content not mentioned in delta
- Never copy a delta file into a main spec as-is - merge its content so the main spec keeps the Main Spec Format Reference structure, with no delta operation headers
- If something is unclear, ask for clarification
- Show what you're changing as you go
- The operation should be idempotent - running twice should give same result
- Use only `artifactPaths.specs.existingOutputPaths`; never infer delta specs from unrelated artifacts
- Honor a caller-supplied subset of `existingOutputPaths`; never widen it back to the full list
- Fetch specs instructions once for direct sync, or reuse the archive-supplied snapshot inline
- Stop before every main-spec write on a non-zero or invalid JSON specs-instruction response
- Artifact rules constrain only the specs being written and are never copied into output files
+109
View File
@@ -0,0 +1,109 @@
---
name: openspec-update-change
description: Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Also use when the user says "openspec update change" or "opsx update". If the user means the openspec update CLI command, which refreshes generated files, run that command instead. Never edits code.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.15.0"
---
Revise a change's existing planning artifacts and keep them coherent. Never edit code.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store <id>` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "<name>" --json --store "<id>"`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store <id>` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it.
One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`.
Otherwise, with no root, what happens next depends on how this workflow was reached:
- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup.
- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store <id>`), or continue without OpenSpec for this request. Wait for their answer.
In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
This workflow revises artifacts that already exist; it never creates missing ones. When an artifact is missing, `openspec status --change "<name>" --json` names the next one and `openspec instructions "<artifact-id>" --change "<name>" --json` explains how to write it.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes sorted by most recently modified, and ask the user to select one
When prompting, present the top 3-4 most recently modified changes as options, showing:
- Change name
- Status (e.g., "0/5 tasks", "complete", "no tasks")
- How recently it was modified (from `lastModified` field)
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to update.
Always announce: "Using change: <name>" and how to override (e.g., `/openspec-update-change <other>`).
2. **Get the change's artifacts**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand current state. The response includes:
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
- `artifacts`: Array of artifacts with their status ("done", "skipped", "ready", "blocked")
- `isPlanningComplete`: Boolean indicating if all planning artifacts are complete. Older CLI versions expose the same value as `isComplete`.
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged.
The files to edit are `artifactPaths.<id>.existingOutputPaths` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. `specs/**/*.md`), plus any confirmed concrete path allowed under a partly populated glob artifact (step 4). Do NOT write to `resolvedOutputPath`: for a glob artifact it is still the glob pattern, not a real file.
3. **Understand the request**
- If the user asked for a specific revision ("the design now uses X"), that is the starting edit.
- If they only said "update" / "make this coherent", treat it as a coherence review: read the existing artifacts and check them against each other for contradictions, gaps, and duplication.
4. **Read and reconcile**
- Read the artifact(s) the request touches and the change's other existing artifacts.
- Draft the requested edit in the conversation, not in files. Work out exactly what it changes; step 5 owns every write. Then check every other existing artifact against the drafted edit - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised.
- Note everything that is now inconsistent, missing, or contradictory.
- Propose revisions to files that already exist (`existingOutputPaths`). If an artifact has no existing output files and status `ready` or `blocked`, note it and point the user to `openspec instructions "<artifact-id>" --change "<name>" --json` for how to create them. Leave `skipped` artifacts untouched; do not treat them as missing or defer them to the continue workflow.
- A glob artifact (e.g. `specs/**/*.md`) is marked `done` after at least one file matches, and the continue workflow only handles `ready` artifacts. When reconciliation identifies a missing file for a glob artifact whose `existingOutputPaths` is non-empty:
1. Run `openspec instructions "<artifact-id>" --change "<name>" --json` and use its `instruction` and `template`. Treat `context` and `rules` as constraints; do not copy them into the file. If instructions report `skipped: true`, do not create the file. Read current dependency files from disk; if a required non-skipped dependency is missing, stop and ask the user to restore it first.
2. Choose a concrete path inside `changeRoot` that matches `artifactPaths.<id>.outputPath` and does not already exist. Verify it remains inside `changeRoot` after resolving any symlinked parent directories. The glob `resolvedOutputPath` is not a valid target.
3. Include the new file in step 5's proposed revisions and create it only after the user confirms.
4. After confirmation, immediately before creation, refresh status and instructions. Verify the artifact is still in scope, not skipped, and partially populated; repeat the concrete-path checks above.
5. Use a create operation that fails if the target already exists. If `instruction` delegates creation to another skill or command, invoke it only if it can honor the confirmed path and these guardrails; otherwise stop. If any check fails or the confirmed draft is no longer valid, stop and reconcile with the user rather than replacing existing content or choosing a different path.
- If the change is already coherent, say so and propose no revisions.
5. **Confirm and apply, one artifact at a time**
- This step performs every artifact write in this workflow; no earlier step edits an artifact.
- Show each proposed revision and why - including the requested edit drafted in step 4. Write only after the user confirms.
- If the user rejects a revision, do not write it - leave that artifact unchanged.
- When a substantial rewrite is needed, get that artifact's rules and template first:
```bash
openspec instructions "<artifact-id>" --change "<name>" --json
```
6. **Point to the next step (guidance only - NEVER act on it)**
- Artifacts with empty `existingOutputPaths` and status `ready` or `blocked` -> run `openspec status --change "<name>" --json` for the next artifact and point the user to `openspec instructions "<artifact-id>" --change "<name>" --json` for how to create it.
- Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest `/openspec-apply-change` to carry the delta into code.
- Everything done and implemented -> suggest `/openspec-archive-change`.
**Output**
After each invocation, show:
- Which artifacts were revised (and which proposed revisions were rejected)
- Any file created under a glob artifact that was already partially populated
- Anything deferred because it does not exist yet (artifacts with no files and status `ready` or `blocked`, never `skipped` artifacts)
- Where the change stands and the recommended next command
**Guardrails**
- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to `/openspec-apply-change`.
- Use the artifact ids and paths reported by `openspec status`; never branch on hardcoded artifact names.
- Edit only the concrete files in `existingOutputPaths`, plus any confirmed concrete path allowed under a partly populated glob artifact (step 4); never write to a glob `resolvedOutputPath`.
- Do not advance the build frontier: if an artifact has empty `existingOutputPaths` and status `ready` or `blocked`, creating them is a separate step, outside this workflow. Leave `skipped` artifacts untouched. The only new-file scope is a confirmed concrete path under a glob artifact whose `existingOutputPaths` is non-empty.
- Confirm every edit with the user before writing.
- If the request changes the change's *intent* rather than refining it, ask for a distinct unused change name and recommend `openspec new change "<new-change-name>"` instead (the "Update vs. Start Fresh" heuristic).
+52 -7
View File
@@ -3,28 +3,73 @@ PG_HOST=pg.localnet
PG_PORT=5432
PG_USER=album
PG_PASSWORD=change_me
PG_DATABASE=shared_album
PG_DATABASE=galo
PG_SSLMODE=disable
# S3 (Garage on s3.localnet)
# Metadata backend: sqlite (default, embedded single instance) or postgres
DB_BACKEND=sqlite
# Path to the SQLite database file (used when DB_BACKEND=sqlite)
SQLITE_PATH=./galo.db
# S3 (Garage on s3.localnet) - internal storage, never exposed to clients
S3_ENDPOINT=http://s3.localnet:3900
S3_REGION=garage
S3_ACCESS_KEY=GKchange_me
S3_SECRET_KEY=change_me
S3_BUCKET=galo-data
S3_USE_PATH_STYLE=true
S3_PUBLIC_URL=http://s3.localnet:3900/galo-data
S3_PRESIGN_TTL=1h
# Auth
JWT_SECRET=change_me_long_random
# Storage backend: fs (default, local filesystem) or s3 (S3-compatible/Garage).
# Blobs are always delivered through the backend via /api/objects/<key>.
STORAGE_BACKEND=fs
# Root directory for object blobs (STORAGE_BACKEND=fs)
FS_ROOT=./data
# Signs object URLs; falls back to the metadata-layer secret when empty
URL_SECRET=
# Lifetime of signed object URLs
OBJECT_URL_TTL=1h
# Quantizes URL expiry so the same object keeps an identical URL within a window
# (capped at OBJECT_URL_TTL), letting clients reuse cached previews
OBJECT_URL_SIGN_WINDOW=10m
# Cache-Control max-age for object responses (blobs are immutable)
OBJECT_CACHE_MAX_AGE=1h
# Auth. The token signing secret is generated on first start and stored in the
# metadata layer (app_settings); it is not configured here.
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=720h
# Server
HTTP_ADDR=:8080
APP_BASE_URL=http://localhost:8080
# SSE
SSE_HEARTBEAT_INTERVAL=25s
SSE_CLIENT_BUFFER=64
# Background worker (variant generation + object GC).
# Throughput is roughly WORKER_BATCH per WORKER_INTERVAL; WORKER_CONCURRENCY caps
# how many of those jobs are processed in parallel (CPU-bound, scales with cores).
WORKER_INTERVAL=5s
WORKER_BATCH=100
WORKER_CONCURRENCY=4
WORKER_PENDING_TTL=10m
# Upload size limits per media kind (plain bytes or KB/MB/GB suffix).
IMAGE_MAX_UPLOAD_SIZE=100MB
VIDEO_MAX_UPLOAD_SIZE=2GB
# Video processing toolchain. Video is prepared for browser playback with
# ffmpeg/ffprobe installed on the host (no Docker). When either binary is
# missing, video upload is disabled and /api/health reports video: unavailable;
# images keep working.
FFMPEG_PATH=ffmpeg
FFPROBE_PATH=ffprobe
# How many video transcodes run in parallel (kept separate from image workers).
VIDEO_TRANSCODE_CONCURRENCY=1
# Directory for temporary spool/transcode files (empty = system temp dir).
VIDEO_TEMP_DIR=
# Logging. LOG_LEVEL=debug adds per-request access logs and expected 4xx errors.
# LOG_FORMAT selects the output encoding.
LOG_LEVEL=info
LOG_FORMAT=json
+25
View File
@@ -5,5 +5,30 @@ backend/static/*
frontend/node_modules/
frontend/build/
frontend/.svelte-kit/
# Сборка WASM-модуля фильтров (исходники и готовый модуль в репозитории).
frontend/wasm/light/target/
node_modules/
*.log
nohup.out
# Python bytecode
__pycache__/
*.py[cod]
# Local data backends (defaults)
galo.db
galo.db-*
# Root-level local FS storage (FS_ROOT=./data); anchored so it does not match
# the Android source package android/.../com/galo/android/data/.
/data/
# Android build output and local toolchain config
android/.gradle/
android/build/
android/app/build/
android/local.properties
android/keystore.properties
android/*.jks
android/*.keystore
android/.idea/
android/captures/
+4 -3
View File
@@ -59,7 +59,7 @@ In both branches, never create the root as a side effect: do not run `openspec i
- If `state: "blocked"`: show the message and pause implementation.
- If `missingArtifacts` is non-empty: suggest completing the missing artifacts. Run `openspec status --change "<name>" --json`, select the next `ready` artifact (not `skipped` or `blocked`), and use `openspec instructions "<artifact-id>" --change "<name>" --json` for its rules and template. Keep the selected `--store <id>` on both commands.
- Otherwise, follow the CLI instruction to create or repair the schema-configured tracking file from existing planning artifacts. Do not assume another artifact is ready or start implementation while blocked.
- If `state: "all_done"`: congratulate, suggest archive
- If `state: "all_done"`: report that all tracked tasks are complete and suggest review or verification as appropriate before archiving
- Otherwise: proceed to implementation
Treat `context` as a required prompt-level input. Read and consider it, and
@@ -118,7 +118,7 @@ In both branches, never create the root as a side effect: do not run `openspec i
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If all done: report that tracked tasks are complete and suggest review or verification as appropriate before archiving
- If paused: explain why and wait for guidance
**Output During Implementation**
@@ -149,7 +149,8 @@ Working on task 4/7: <task description>
- [x] Task 2
...
All tasks complete! You can archive this change with `/opsx-archive`.
All tracked tasks are complete. Review or verify the change as appropriate
before archiving. You can archive this change with `/opsx-archive`.
```
**Output On Pause (Issue Encountered)**
+5 -3
View File
@@ -159,7 +159,7 @@ If the user asks you to capture the exploration as a new change, that request is
1. Run `openspec new change "<name>"` (with `--store <id>` when applicable) before creating any artifacts. Never create a new change directory under `openspec/changes/` by hand; the CLI scaffold creates required metadata such as `.openspec.yaml`. Keep the selected `--store <id>` on every applicable follow-up `status` and `instructions` command.
2. Run `openspec status --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store), then process the requested artifacts in dependency order. For each requested artifact that is `ready`, run `openspec instructions "<artifact-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store). Before creating a requested artifact, evaluate any condition in its own `instruction` against the explored change; record a deliberate skip instead when the condition does not apply. If a requested artifact is blocked by a direct prerequisite the user did not request, run `openspec instructions "<prerequisite-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) for that prerequisite whether it is `ready` or `blocked`. If its own `instruction` states a condition, evaluate that condition against the explored change and record a deliberate skip only when the condition does not apply. If the condition applies, or the prerequisite is not conditional, treat it as a normal prerequisite and ask before expanding the capture. Do not create an unrequested prerequisite unless the user approves.
3. Follow the returned `template` and `instruction` fields. Read completed dependency files listed in `dependencies`, and apply `context` and `rules` as constraints without copying them into the artifact. If the instruction delegates creation to a specific skill or command, invoke it; otherwise write the artifact to `resolvedOutputPath`, using the instruction to choose a concrete path when it is a glob. Verify that the selected concrete output exists.
3. Follow the returned `template` and `instruction` fields. Read completed dependency files listed in `dependencies`, and apply `context` and `rules` as constraints without copying them into the artifact. Choose the output path before creating anything: when `resolvedOutputPath` is a glob, use the instruction to choose a concrete path inside `changeRoot` and verify it remains inside `changeRoot` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is `resolvedOutputPath`. If the instruction delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Otherwise write the artifact to that output path. Verify that the selected concrete output exists at that output path.
4. After creating each artifact, re-run `openspec status --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) and continue until every requested artifact is `done`, `skipped`, or was deliberately skipped because its own `instruction` stated a condition that did not apply. Tell the user about a deliberate conditional skip, remember it, and do not reconsider it. Dependencies are enablers, not gates: if a requested artifact is still `blocked` only because you deliberately skipped a conditional prerequisite, run `openspec instructions "<artifact-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) despite the blocked status, then create it using step 3 only when those recorded conditional skips are its sole missing dependencies. If a requested artifact is blocked by a prerequisite the user did not ask to capture and cannot be conditionally skipped, explain that dependency and ask before expanding the capture.
Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. When the requested capture is done, stop there and name where the work continues: `/opsx-propose` writes the remaining planning artifacts, and `/opsx-apply` implements the change once tasks exist. Capturing artifacts never starts implementing them.
@@ -181,6 +181,8 @@ If the user mentions a change or you detect one is relevant:
`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve an existing capability's full path and follow the project's established organization for new capabilities.
The files below are the default spec-driven schema's. With another schema, capture each insight in whichever artifact from `artifactPaths` fits it, and name that artifact in your offer; never create an artifact the schema does not define.
| Insight Type | Where to Capture |
|----------------------------|-------------------------------------|
| New requirement discovered | `specs/<capability-path>/spec.md` |
@@ -190,7 +192,7 @@ If the user mentions a change or you detect one is relevant:
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
Example offers (default schema):
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
@@ -215,7 +217,7 @@ If the user mentions a change or you detect one is relevant:
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? Run `/opsx-propose` and this becomes a change."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Result in artifact updates**: "Updated the design with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
+4 -3
View File
@@ -115,8 +115,9 @@ In both branches, never create the root as a side effect: do not run `openspec i
- Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
- Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
- Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
- If the `instruction` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at `resolvedOutputPath`
- Otherwise create the artifact file using `template` as the structure and write it to `resolvedOutputPath`. If `resolvedOutputPath` is a glob, follow `instruction` to choose the concrete file path
- Choose the output path before creating anything. If `resolvedOutputPath` is a glob, follow `instruction` to choose a concrete path inside `changeRoot` that matches the glob, and verify it remains inside `changeRoot` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is `resolvedOutputPath`
- If the `instruction` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Then verify the artifact file exists at that output path
- Otherwise create the artifact file using `template` as the structure and write it to that output path
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
@@ -150,7 +151,7 @@ After completing all artifacts, summarize:
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type - it is the authoritative guidance, even for familiar artifact names
- If the `instruction` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly
- If the `instruction` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
+2 -2
View File
@@ -52,7 +52,7 @@ This workflow revises artifacts that already exist; it never creates missing one
The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged.
The files to edit are `artifactPaths.<id>.existingOutputPaths` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. `specs/**/*.md`). Do NOT write to `resolvedOutputPath`: for a glob artifact it is still the glob pattern, not a real file.
The files to edit are `artifactPaths.<id>.existingOutputPaths` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. `specs/**/*.md`), plus any confirmed concrete path allowed under a partly populated glob artifact (step 4). Do NOT write to `resolvedOutputPath`: for a glob artifact it is still the glob pattern, not a real file.
3. **Understand the request**
- If the user asked for a specific revision ("the design now uses X"), that is the starting edit.
@@ -96,7 +96,7 @@ After each invocation, show:
**Guardrails**
- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to `/opsx-apply`.
- Use the artifact ids and paths reported by `openspec status`; never branch on hardcoded artifact names.
- Edit only the concrete files in `existingOutputPaths`; never write to a glob `resolvedOutputPath`.
- Edit only the concrete files in `existingOutputPaths`, plus any confirmed concrete path allowed under a partly populated glob artifact (step 4); never write to a glob `resolvedOutputPath`.
- Do not advance the build frontier: if an artifact has empty `existingOutputPaths` and status `ready` or `blocked`, creating them is a separate step, outside this workflow. Leave `skipped` artifacts untouched. The only new-file scope is a confirmed concrete path under a glob artifact whose `existingOutputPaths` is non-empty.
- Confirm every edit with the user before writing.
- If the request changes the change's *intent* rather than refining it, ask for a distinct unused change name and recommend `openspec new change "<new-change-name>"` instead (the "Update vs. Start Fresh" heuristic).
@@ -7,7 +7,7 @@ compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.14.0"
generatedBy: "1.15.0"
---
Implement tasks from an OpenSpec change.
@@ -66,7 +66,7 @@ In both branches, never create the root as a side effect: do not run `openspec i
- If `state: "blocked"`: show the message and pause implementation.
- If `missingArtifacts` is non-empty: suggest completing the missing artifacts. Run `openspec status --change "<name>" --json`, select the next `ready` artifact (not `skipped` or `blocked`), and use `openspec instructions "<artifact-id>" --change "<name>" --json` for its rules and template. Keep the selected `--store <id>` on both commands.
- Otherwise, follow the CLI instruction to create or repair the schema-configured tracking file from existing planning artifacts. Do not assume another artifact is ready or start implementation while blocked.
- If `state: "all_done"`: congratulate, suggest archive
- If `state: "all_done"`: report that all tracked tasks are complete and suggest review or verification as appropriate before archiving
- Otherwise: proceed to implementation
Treat `context` as a required prompt-level input. Read and consider it, and
@@ -125,7 +125,7 @@ In both branches, never create the root as a side effect: do not run `openspec i
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If all done: report that tracked tasks are complete and suggest review or verification as appropriate before archiving
- If paused: explain why and wait for guidance
**Output During Implementation**
@@ -156,7 +156,8 @@ Working on task 4/7: <task description>
- [x] Task 2
...
All tasks complete! You can archive this change with `/opsx-archive`.
All tracked tasks are complete. Review or verify the change as appropriate
before archiving. You can archive this change with `/opsx-archive`.
```
**Output On Pause (Issue Encountered)**
@@ -7,7 +7,7 @@ compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.14.0"
generatedBy: "1.15.0"
---
Archive a completed change in the experimental workflow.
+6 -4
View File
@@ -7,7 +7,7 @@ compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.14.0"
generatedBy: "1.15.0"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
@@ -157,7 +157,7 @@ If the user asks you to capture the exploration as a new change, that request is
1. Run `openspec new change "<name>"` (with `--store <id>` when applicable) before creating any artifacts. Never create a new change directory under `openspec/changes/` by hand; the CLI scaffold creates required metadata such as `.openspec.yaml`. Keep the selected `--store <id>` on every applicable follow-up `status` and `instructions` command.
2. Run `openspec status --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store), then process the requested artifacts in dependency order. For each requested artifact that is `ready`, run `openspec instructions "<artifact-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store). Before creating a requested artifact, evaluate any condition in its own `instruction` against the explored change; record a deliberate skip instead when the condition does not apply. If a requested artifact is blocked by a direct prerequisite the user did not request, run `openspec instructions "<prerequisite-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) for that prerequisite whether it is `ready` or `blocked`. If its own `instruction` states a condition, evaluate that condition against the explored change and record a deliberate skip only when the condition does not apply. If the condition applies, or the prerequisite is not conditional, treat it as a normal prerequisite and ask before expanding the capture. Do not create an unrequested prerequisite unless the user approves.
3. Follow the returned `template` and `instruction` fields. Read completed dependency files listed in `dependencies`, and apply `context` and `rules` as constraints without copying them into the artifact. If the instruction delegates creation to a specific skill or command, invoke it; otherwise write the artifact to `resolvedOutputPath`, using the instruction to choose a concrete path when it is a glob. Verify that the selected concrete output exists.
3. Follow the returned `template` and `instruction` fields. Read completed dependency files listed in `dependencies`, and apply `context` and `rules` as constraints without copying them into the artifact. Choose the output path before creating anything: when `resolvedOutputPath` is a glob, use the instruction to choose a concrete path inside `changeRoot` and verify it remains inside `changeRoot` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is `resolvedOutputPath`. If the instruction delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Otherwise write the artifact to that output path. Verify that the selected concrete output exists at that output path.
4. After creating each artifact, re-run `openspec status --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) and continue until every requested artifact is `done`, `skipped`, or was deliberately skipped because its own `instruction` stated a condition that did not apply. Tell the user about a deliberate conditional skip, remember it, and do not reconsider it. Dependencies are enablers, not gates: if a requested artifact is still `blocked` only because you deliberately skipped a conditional prerequisite, run `openspec instructions "<artifact-id>" --change "<name>" --json` (append the confirmed `--store "<id>"` only for a registered standalone store) despite the blocked status, then create it using step 3 only when those recorded conditional skips are its sole missing dependencies. If a requested artifact is blocked by a prerequisite the user did not ask to capture and cannot be conditionally skipped, explain that dependency and ask before expanding the capture.
Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. When the requested capture is done, stop there and name where the work continues: `/opsx-propose` writes the remaining planning artifacts, and `/opsx-apply` implements the change once tasks exist. Capturing artifacts never starts implementing them.
@@ -179,6 +179,8 @@ If the user mentions a change or you detect one is relevant:
`<capability-path>` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve an existing capability's full path and follow the project's established organization for new capabilities.
The files below are the default spec-driven schema's. With another schema, capture each insight in whichever artifact from `artifactPaths` fits it, and name that artifact in your offer; never create an artifact the schema does not define.
| Insight Type | Where to Capture |
|----------------------------|-------------------------------------|
| New requirement discovered | `specs/<capability-path>/spec.md` |
@@ -188,7 +190,7 @@ If the user mentions a change or you detect one is relevant:
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
Example offers (default schema):
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
@@ -317,7 +319,7 @@ You: That changes everything.
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? Run `/opsx-propose` and this becomes a change."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Result in artifact updates**: "Updated the design with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
+5 -4
View File
@@ -7,7 +7,7 @@ compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.14.0"
generatedBy: "1.15.0"
---
Propose a new change - create the change and generate all artifacts in one step.
@@ -122,8 +122,9 @@ In both branches, never create the root as a side effect: do not run `openspec i
- Identify the target project from the request and project context; the planning home may be separate from the code. If the target is unclear, ask. For greenfield or non-code changes, inspect the available structure and relevant documents. If source is unavailable, state the limitation and ask when it materially affects the plan.
- Ground scope, approach, and tasks in what you find. Distinguish observed behavior from assumptions and proposed additions; surface conflicts with existing specs instead of silently deciding which is correct.
- Do this discovery now, rather than leaving generic "explore the codebase" or "make a plan" tasks for implementation. Keep any necessary follow-up investigation specific to an unresolved question.
- If the `instruction` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at `resolvedOutputPath`
- Otherwise create the artifact file using `template` as the structure and write it to `resolvedOutputPath`. If `resolvedOutputPath` is a glob, follow `instruction` to choose the concrete file path
- Choose the output path before creating anything. If `resolvedOutputPath` is a glob, follow `instruction` to choose a concrete path inside `changeRoot` that matches the glob, and verify it remains inside `changeRoot` after resolving symlinks, both in its parent directories and in the file itself if it already exists; if it does not, stop and tell the user. Otherwise the output path is `resolvedOutputPath`
- If the `instruction` field delegates creation to a specific skill or command, invoke it only if it can write the artifact to that output path; otherwise stop and tell the user. Then verify the artifact file exists at that output path
- Otherwise create the artifact file using `template` as the structure and write it to that output path
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
@@ -157,7 +158,7 @@ After completing all artifacts, summarize:
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type - it is the authoritative guidance, even for familiar artifact names
- If the `instruction` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly
- If the `instruction` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - but only once you have chosen and checked the output path, and only if it can write there
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
@@ -7,7 +7,7 @@ compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.14.0"
generatedBy: "1.15.0"
---
Sync delta specs from a change to main specs.
@@ -7,7 +7,7 @@ compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.14.0"
generatedBy: "1.15.0"
---
Revise a change's existing planning artifacts and keep them coherent. Never edit code.
@@ -59,7 +59,7 @@ This workflow revises artifacts that already exist; it never creates missing one
The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged.
The files to edit are `artifactPaths.<id>.existingOutputPaths` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. `specs/**/*.md`). Do NOT write to `resolvedOutputPath`: for a glob artifact it is still the glob pattern, not a real file.
The files to edit are `artifactPaths.<id>.existingOutputPaths` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. `specs/**/*.md`), plus any confirmed concrete path allowed under a partly populated glob artifact (step 4). Do NOT write to `resolvedOutputPath`: for a glob artifact it is still the glob pattern, not a real file.
3. **Understand the request**
- If the user asked for a specific revision ("the design now uses X"), that is the starting edit.
@@ -103,7 +103,7 @@ After each invocation, show:
**Guardrails**
- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to `/opsx-apply`.
- Use the artifact ids and paths reported by `openspec status`; never branch on hardcoded artifact names.
- Edit only the concrete files in `existingOutputPaths`; never write to a glob `resolvedOutputPath`.
- Edit only the concrete files in `existingOutputPaths`, plus any confirmed concrete path allowed under a partly populated glob artifact (step 4); never write to a glob `resolvedOutputPath`.
- Do not advance the build frontier: if an artifact has empty `existingOutputPaths` and status `ready` or `blocked`, creating them is a separate step, outside this workflow. Leave `skipped` artifacts untouched. The only new-file scope is a confirmed concrete path under a glob artifact whose `existingOutputPaths` is non-empty.
- Confirm every edit with the user before writing.
- If the request changes the change's *intent* rather than refining it, ask for a distinct unused change name and recommend `openspec new change "<new-change-name>"` instead (the "Update vs. Start Fresh" heuristic).
+126
View File
@@ -0,0 +1,126 @@
# AGENTS.md
Инструкции для агентов, работающих в этом репозитории. Читай вместе с `README.md`
(актуальный источник правды) и `project.md` (исходное ТЗ).
## Что это
Self-hosted фотоальбом: Go-бэкенд с вшитой через `go:embed` статикой SvelteKit,
один статический бинарник (`CGO_ENABLED=0`), без Docker. Плюс отдельный Android-клиент.
По умолчанию (`DB_BACKEND=sqlite`, `STORAGE_BACKEND=fs`) работает полностью локально,
без Postgres и S3. Postgres/Garage (`pg.localnet`, `s3.localnet`) — опциональные слои.
## Команды
Все команды — из корня репозитория. Голый `make` только печатает список целей.
```bash
make build # frontend -> backend/static -> go:embed -> bin/galo
make frontend # npm run build (инкрементально), копирует статику в backend/static
make backend # CGO_ENABLED=0 go build -tags nodynamic -o ../bin/galo
make run # frontend + backend + запуск ./bin/galo
make migrate # применяет только Postgres-миграции (cmd/migrate)
make test # cd backend && go test ./... (только бэкенд!)
make testdata # наполняет запущенный инстанс через scripts/seed.py
make android # cd android && ./gradlew :app:assembleDebug
make install-android-app # сборка + установка debug-APK на устройство
```
- `-tags nodynamic` обязателен: без него `gen2brain/webp` тянет динамический libwebp
и бинарник перестаёт быть статическим.
- Порядок сборки важен: `frontend` должен отработать до `backend`, иначе `go:embed
all:static` вшивает только `backend/static/.gitkeep` и бинарник выходит без UI.
- `make test` не запускает фронтенд-тесты; отдельных make-целей для них нет:
`cd frontend && npm run test` (vitest, окружение `node`, файлы `src/**/*.test.ts`) и
`cd frontend && npm run check` (svelte-check/typecheck).
- Один Go-тест: `cd backend && go test ./internal/photos/ -run TestName -v`.
- Интеграционные тесты Postgres-репозиториев пропускаются без `TEST_DATABASE_URL`:
`TEST_DATABASE_URL='postgres://user:pass@pg.localnet:5432/galo?sslmode=disable' \
cd backend && go test ./internal/repo/ -v`
- Тесты видео (`internal/e2e`) сами пропускаются, если нет `ffmpeg`/`ffprobe`.
Тесты e2e репозиториев поднимают SQLite в `t.TempDir()` и внешних сервисов не требуют.
## Тесты на эмуляторе
Instrumented-прогоны (`:app:connectedDebugAndroidTest`) и сам запуск AVD **могут зависнуть** —
не дожидайся их молча. Пока идёт прогон, проверяй прогресс **примерно раз в минуту**: жив ли
процесс, обновляются ли результаты, отвечает ли устройство:
```bash
pgrep -fa "GradleWrapperMain|Gradle Test Executor"
ls -la android/app/build/outputs/androidTest-results/connected/debug/
adb devices
```
Если прогресса нет дольше пары минут — снимай прогон (`kill`), а не жди дальше, и разбирайся
по дампу потоков (`jstack <pid>` JVM тестов). То же относится к обычным unit-тестам: они тоже
идут в отдельной JVM, и её зависание видно только по тому, что результаты перестали
обновляться.
## Архитектура
- Go-модуль `galo` (`backend/go.mod`, Go 1.26). Точки входа: `backend/cmd/server`,
`cmd/migrate`, `cmd/reconcile`, `cmd/backfill-exif`.
- Доменные пакеты в `backend/internal/<domain>` (albums, auth, members, photos, social,
notifications, sharelinks, storage, sse, settings, video). Каждый содержит сервис,
HTTP-обработчики и `Repository`-интерфейс.
- `internal/store/store.go` — единственное место, которое знает конкретные реализации.
При добавлении метода в `Repository` нужно реализовать его в **обоих** вариантах:
Postgres (`NewStore/NewPostgres`) и SQLite (`NewSQLiteStore/NewSQLite`).
- `backend/internal/app` — маршруты/статика; `internal/repo/contract` — контрактные тесты,
которые гоняют одинаковый набор проверок для SQLite и Postgres.
- `backend/embed.go` вшивает `static` (фронтенд) и `migrations/**`. Паттерны эмбеда —
`migrations/*.sql migrations/sqlite/*.sql`: новые файлы должны им соответствовать.
- Права проверяются на уровне сервиса, не только в UI. Блобы наружу отдаются только
через бэкенд: `GET /api/objects/<key>` с HMAC-подписью, один маршрут для авторизованных
и для share-ссылок. Ключи объектов непрозрачны: `objects/{object_id}/{role}`.
- SSE (`/api/sse/...`) авторизуется через `?token=`, т.к. `EventSource` не шлёт заголовки.
Для nginx критичен `proxy_buffering off`.
- `android/` — самостоятельный Gradle-модуль, серверный и веб-код не трогает; использует
уже существующий HTTP API. Тесты: `cd android && ./gradlew test`.
## Миграции (частый источник ошибок)
Схема поддерживается в **двух диалектах** с независимой нумерацией:
- Postgres: `backend/migrations/NNNNNN_*.{up,down}.sql`
- SQLite: `backend/migrations/sqlite/NNNNNN_*.{up,down}.sql`
Любое изменение схемы нужно добавить в оба каталога, номера версий не обязаны совпадать
(сейчас они разошлись). Миграции вшиты в бинарник и применяются автоматически при старте
сервера для выбранного бэкенда. `make migrate` применяет только Postgres.
`cmd/reconcile` существует для сверки хранилища с журналом `objects` (сборка мусора
журналом управляется и хранилище не сканирует).
## Конфигурация и данные
- `.env` читается вручную в `cmd/*/main.go` из `.env`, затем `../.env` (без dotenv-библиотеки).
- Секреты подписи JWT и URL объектов не задаются в env: генерируются при первом старте и
лежат в слое метаданных (`app_settings`), поэтому переживают перезапуск и рестарт SQLite.
- Локальные артефакты в корне (`galo.db*`, `/data/`, `bin/`, `backend/static/*`,
`frontend/build/`) в git не попадают — не коммить их.
## Фронтенд
- SvelteKit 2 + Svelte 5 (runes), `adapter-static` с `fallback: index.html`, Tailwind v4.
- Тёмная/светлая/нейтральная темы через `data-theme` на `<html>` и семантические
CSS-токены. В компонентах — только семантические классы (`bg-bg-elevated`, `text-muted`
и т.п.), **никаких `dark:` префиксов**.
- Vite dev-сервер проксирует `/api` на `localhost:8080` (см. `vite.config.ts`).
## OpenSpec
Спецификации — в `openspec/specs/<capability>/spec.md`; завершённые изменения —
в `openspec/changes/archive/<YYYY-MM-DD-name>`. Активных изменений сейчас нет.
Workflow-команды и скиллы лежат в `.opencode/` и требуют CLI `openspec`
(в этом окружении он может быть не установлен — проверь `command -v openspec`).
При изменении поведения сначала обновляй/создавай спеку, а не только код.
## Конвенции репозитория
- Ветка `develop`; коммиты — Conventional Commits с русским описанием
(`feat(realtime): ...`, `test(android): ...`, `chore: ...`). Функциональные ветки
вливаются в `develop` через merge-коммиты.
- `project.md` — исходное ТЗ и местами устарел (например, называет S3 бэкендом по
умолчанию и старую раскладку ключей `albums/...`). Ориентируйся на `README.md` и код.
+143 -7
View File
@@ -1,31 +1,167 @@
.PHONY: build frontend backend run migrate tidy test clean
.PHONY: help build build-release frontend backend android install-android-app run migrate reconcile reconcile-delete tidy test testdata clean
# Default goal: print the list of available targets.
.DEFAULT_GOAL := help
# Android SDK location. Override when the SDK lives elsewhere.
ANDROID_SDK ?= $(HOME)/Android/sdk
# adb from the Android SDK's platform-tools (matches the SDK we build against).
ADB ?= $(ANDROID_SDK)/platform-tools/adb
# Device serial for `install-android-app` (falls back to $ANDROID_SERIAL). When
# empty, the sole connected device is used; with several connected devices the
# target lists them and stops instead of guessing. Override with SERIAL=<serial>.
SERIAL ?= $(ANDROID_SERIAL)
# Release version stamped into the binary (-X main.version). Override with VERSION=...
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
# Seed testdata parameters (see `make testdata`). Counts: ALBUMS, PHOTOS, SEED, TIMEOUT.
# Boolean (set to 1 to enable): TAGS, COMMENTS, LIKES, SHARE, INVITES, WAIT, DRY, VIDEO.
# VIDEO requires ffmpeg on PATH; it is skipped without it. Extra passthrough flags: ARGS.
ALBUMS ?= 2
PHOTOS ?= 10
SEED ?= 0
TIMEOUT ?= 30
# Print the list of available targets.
help:
@echo "Usage: make <target>"
@echo
@echo "Available targets:"
@grep -hE '^[a-zA-Z0-9_-]+:' $(MAKEFILE_LIST) | cut -d: -f1 | sed 's/^/ /'
# Frontend sources whose changes must trigger a rebuild. `backend/static/index.html`
# doubles as the build stamp, so a clean clone or `make clean` (which empties
# backend/static) forces the next build to regenerate.
FRONTEND_OUT := backend/static/index.html
# `npm ci` is only needed when the manifest/lockfile change.
FRONTEND_DEPS_STAMP := frontend/node_modules/.install-stamp
# Build the SvelteKit bundle and copy it into backend/static (embedded via
# go:embed). Incremental: recompile only when frontend sources are newer than the
# embedded bundle. Paths are checked from the recipe (not as make prerequisites)
# because SvelteKit route names like `(app)` break make's filename parsing.
frontend:
cd frontend && npm ci && npm run build
rm -rf backend/static/*
cp -r frontend/build/* backend/static/
@set -e; \
if [ ! -f $(FRONTEND_DEPS_STAMP) ] \
|| [ frontend/package.json -nt $(FRONTEND_DEPS_STAMP) ] \
|| [ frontend/package-lock.json -nt $(FRONTEND_DEPS_STAMP) ]; then \
echo '> cd frontend && npm ci'; \
cd frontend && npm ci; \
touch node_modules/.install-stamp; \
cd ..; \
fi; \
if [ ! -f $(FRONTEND_OUT) ] \
|| [ -n "$$(find frontend/src frontend/static -newer $(FRONTEND_OUT) -print -quit)" ] \
|| [ frontend/package.json -nt $(FRONTEND_OUT) ] \
|| [ frontend/package-lock.json -nt $(FRONTEND_OUT) ] \
|| [ frontend/svelte.config.js -nt $(FRONTEND_OUT) ] \
|| [ frontend/vite.config.ts -nt $(FRONTEND_OUT) ] \
|| [ frontend/tailwind.config.ts -nt $(FRONTEND_OUT) ] \
|| [ frontend/tsconfig.json -nt $(FRONTEND_OUT) ]; then \
echo '> cd frontend && npm run build'; \
cd frontend && npm run build; \
cd ..; \
rm -rf backend/static/*; \
cp -r frontend/build/* backend/static/; \
else \
echo 'frontend: up to date'; \
fi
backend:
mkdir -p bin
# -tags nodynamic forces gen2brain/webp to use its pure-Go transpiled path
# (no purego/dlopen), keeping the binary fully static.
cd backend && CGO_ENABLED=0 go build -tags nodynamic -trimpath -ldflags="-s -w" \
-o ../bin/shared-album ./cmd/server
-o ../bin/galo ./cmd/server
build: frontend backend
run: backend
cd backend && ../bin/shared-album
# Android client (independent of the server build). Requires the Android SDK.
android:
cd android && ANDROID_HOME=$(ANDROID_SDK) ./gradlew :app:assembleDebug
# Install the debug Android client on a connected device. Builds the APK via the
# `android` target first; only devices in the `device` state are considered.
install-android-app: android
@devices="$$($(ADB) devices | awk 'NR>1 && $$2 == "device" {print $$1}')"; \
if [ -n "$(SERIAL)" ]; then \
target="$(SERIAL)"; \
else \
count=$$(printf '%s\n' "$$devices" | grep -c .); \
if [ "$$count" -eq 0 ]; then \
echo "install-android-app: no connected device (see '$(ADB) devices')" >&2; \
exit 1; \
elif [ "$$count" -gt 1 ]; then \
echo "install-android-app: several devices connected; set SERIAL=<serial>:" >&2; \
printf '%s\n' "$$devices" | sed 's/^/ /' >&2; \
exit 1; \
else \
target="$$devices"; \
fi; \
fi; \
echo "Installing app-debug.apk on $$target"; \
$(ADB) -s "$$target" install -r android/app/build/outputs/apk/debug/app-debug.apk
# Release build: full static, stripped and (if available) UPX-compressed binary
# with the version embedded. Produces bin/galo and a copy at bin/galo-$(VERSION).
build-release: frontend
mkdir -p bin
cd backend && CGO_ENABLED=0 go build -tags nodynamic -trimpath \
-ldflags="-s -w -X main.version=$(VERSION)" -o ../bin/galo ./cmd/server
strip --strip-all bin/galo
@if command -v upx >/dev/null 2>&1; then \
upx --best --quiet bin/galo && echo "upx: bin/galo compressed"; \
else \
echo "upx not found - skipping compression (install upx-ucl to enable)"; \
fi
cp bin/galo bin/galo-$(VERSION)
@echo "release: bin/galo-$(VERSION)"
run: frontend backend
./bin/galo
migrate:
cd backend && go run ./cmd/migrate
# Reconcile object storage against the metadata journal (report only).
reconcile:
cd backend && go run ./cmd/reconcile
# Reconcile and delete orphaned blobs older than --min-age (default 1h).
# Override the grace period when needed: make reconcile-delete MIN_AGE=1m
reconcile-delete:
cd backend && go run ./cmd/reconcile --delete $(if $(MIN_AGE),--min-age $(MIN_AGE),)
tidy:
cd backend && go mod tidy
test:
cd backend && go test ./...
# Seed test data into a running instance through the public API (additive only).
# Credentials come from the environment: GALO_EMAIL, GALO_PASSWORD
# (optional GALO_BASE_URL). Example:
# make testdata ALBUMS=3 PHOTOS=5 TAGS=1 LIKES=1 SEED=42
testdata:
@python3 scripts/seed.py \
--albums $(ALBUMS) \
--photos-per-album $(PHOTOS) \
--seed $(SEED) \
--timeout $(TIMEOUT) \
$(if $(filter 1,$(TAGS)),--tags,) \
$(if $(filter 1,$(COMMENTS)),--comments,) \
$(if $(filter 1,$(LIKES)),--likes,) \
$(if $(filter 1,$(SHARE)),--share-links,) \
$(if $(filter 1,$(INVITES)),--invites,) \
$(if $(filter 1,$(VIDEO)),--video,) \
$(if $(filter 1,$(WAIT)),--wait-variants,) \
$(if $(filter 1,$(DRY)),--dry-run,) \
$(ARGS)
clean:
rm -rf bin backend/static/*
touch backend/static/.gitkeep
+188 -21
View File
@@ -10,7 +10,10 @@ SvelteKit через `go:embed`, запуск через systemd.
- **Backend:** Go, Gin, pgx (PostgreSQL на `pg.localnet`), AWS SDK v2 (Garage на
`s3.localnet`, path-style), нативный SSE, golang-migrate, slog.
- **Frontend:** SvelteKit 2 + Svelte 5, adapter-static, TypeScript, TailwindCSS.
- **Frontend:** SvelteKit 2 + Svelte 5, adapter-static, TypeScript, TailwindCSS,
service worker с офлайн-кешем оболочки, списков и эскизов/превью фотографий.
Фильтры правок считает WASM-модуль на Rust (`frontend/wasm/light`): пересборка
нужна только при правке самого модуля, в репозитории лежит готовый `light.wasm`.
- **Обработка изображений:** `disintegration/imaging` + `gen2brain/webp` +
`rwcarlsen/goexif` (всё чистый Go).
@@ -19,6 +22,8 @@ SvelteKit через `go:embed`, запуск через systemd.
- Go 1.22+ (сборка проверена на 1.26)
- Node.js 20+ (только для сборки фронтенда)
- Доступ к `pg.localnet:5432` (PostgreSQL) и `s3.localnet:3900` (Garage)
- `ffmpeg` и `ffprobe` на хосте — только для видео; без них приём видео отключается,
а изображения продолжают работать (см. «Видео»)
- `make`
## Конфигурация
@@ -29,31 +34,116 @@ SvelteKit через `go:embed`, запуск через systemd.
cp .env.example .env
```
Ключевые переменные: `PG_*`, `S3_*` (обязательны `UsePathStyle=true` и регион
`garage`), `JWT_SECRET`, `JWT_ACCESS_TTL`, `JWT_REFRESH_TTL`, `HTTP_ADDR`,
`SSE_HEARTBEAT_INTERVAL`, `SSE_CLIENT_BUFFER`.
Секрет подписи токенов (`JWT_SECRET`) в окружении не задаётся: он генерируется
при первом старте и хранится в слое метаданных (`app_settings`).
Все параметры окружения (регистр имён учитывается):
| Переменная | Значение по умолчанию | Описание |
| --- | --- | --- |
| `HTTP_ADDR` | `:8080` | Адрес прослушивания HTTP-сервера |
| **База данных (метаданные)** | | |
| `DB_BACKEND` | `sqlite` | Слой метаданных: `sqlite` или `postgres` |
| `SQLITE_PATH` | `./galo.db` | Путь к файлу SQLite (при `DB_BACKEND=sqlite`) |
| `PG_HOST` | `pg.localnet` | Хост PostgreSQL |
| `PG_PORT` | `5432` | Порт PostgreSQL |
| `PG_USER` | `album` | Пользователь PostgreSQL |
| `PG_PASSWORD` | *(пусто)* | Пароль PostgreSQL (обязателен при `DB_BACKEND=postgres`) |
| `PG_DATABASE` | `galo` | Имя БД PostgreSQL |
| `PG_SSLMODE` | `disable` | SSL-режим PostgreSQL |
| **Блочное хранилище** | | |
| `STORAGE_BACKEND` | `fs` | Бэкенд блобов: `fs` или `s3` |
| `FS_ROOT` | `./data` | Корень ФС для блобов (при `STORAGE_BACKEND=fs`) |
| `S3_ENDPOINT` | `http://s3.localnet:3900` | Endpoint S3/Garage |
| `S3_REGION` | `garage` | Регион S3 |
| `S3_ACCESS_KEY` | *(пусто)* | Access key S3 |
| `S3_SECRET_KEY` | *(пусто)* | Secret key S3 |
| `S3_BUCKET` | `albums` | Имя бакета S3 |
| `S3_USE_PATH_STYLE` | `true` | Использовать path-style запросы (обязательно для Garage) |
| `OBJECT_URL_TTL` | `1h` | Время жизни подписанных URL объектов |
| `URL_SECRET` | *(пусто)* | Секрет подписи URL объектов; при пустом — секрет из `app_settings` |
| **Auth / JWT** | | |
| `JWT_ACCESS_TTL` | `15m` | Время жизни access-токена |
| `JWT_REFRESH_TTL` | `720h` | Время жизни refresh-токена |
| **SSE** | | |
| `SSE_HEARTBEAT_INTERVAL` | `25s` | Период heartbeat-сообщений каналов SSE |
| `SSE_CLIENT_BUFFER` | `64` | Размер буфера клиента SSE (количество сообщений) |
| **Фоновый воркер** (генерация вариантов + GC объектов) | | |
| `WORKER_INTERVAL` | `5s` | Период проверки воркером очереди задач |
| `WORKER_BATCH` | `10` | Размер батча обрабатываемых задач |
| `WORKER_PENDING_TTL` | `10m` | TTL задачи в статусе `pending` до повторной попытки |
| **Загрузка и видео** | | |
| `IMAGE_MAX_UPLOAD_SIZE` | `100MB` | Максимальный размер изображения (байты или суффикс `KB`/`MB`/`GB`) |
| `VIDEO_MAX_UPLOAD_SIZE` | `2GB` | Максимальный размер видео |
| `FFMPEG_PATH` | `ffmpeg` | Путь к `ffmpeg` (нужен для приёма видео) |
| `FFPROBE_PATH` | `ffprobe` | Путь к `ffprobe` |
| `VIDEO_TRANSCODE_CONCURRENCY` | `1` | Параллельных транскодов (отдельно от воркера изображений) |
| `VIDEO_TEMP_DIR` | *(системный tmp)* | Каталог для временных файлов загрузки/транскода |
| **Логирование** | | |
| `LOG_LEVEL` | `info` | Уровень логов: `debug`, `info`, `warn`, `error` |
| `LOG_FORMAT` | `json` | Формат логов: `json` или `text` |
На уровне `debug` сервер пишет по строке на каждый HTTP-запрос (метод, маршрут,
статус, длительность, пользователь, `request_id`) и логирует ожидаемые отказы 4xx.
Внутренние ошибки (неожиданные и 5xx) логируются на уровне `error` с исходной причиной
и `request_id`; сам `request_id` возвращается клиенту в заголовке ответа
`X-Request-ID`. Маршрут `/api/health` в access-логи не попадает, а `/api/objects/*` и
`/api/sse/*` — только на уровне `debug`. Query-строка, заголовок `Authorization` и тела
запросов в логи не пишутся.
Слои данных выбираются параметрами (по умолчанию — локальный режим):
- `STORAGE_BACKEND=fs|s3` — где лежат блобы: `fs` (по умолчанию, `FS_ROOT=./data`)
или `s3` (S3/Garage); наружу всегда отдаётся бэкенд (`/api/objects/<key>`).
- `DB_BACKEND=sqlite|postgres` — где лежат метаданные: `sqlite` (по умолчанию,
`SQLITE_PATH=./galo.db`) или `postgres`. SQLite — встроенный режим без внешней БД,
**только для одного экземпляра приложения на файл** (без горизонтального
масштабирования).
### Видео
Видео загружается в те же альбомы, что и фото (`kind=video`), и готовится для
воспроизведения в браузере фоновым воркером:
- **Постер**: из видео извлекается кадр, из которого генерируются обычные варианты
`thumb`/`medium`/`preview` (WebP) — сетка и обложка показывают постер.
- **Воспроизведение**: web-совместимый источник (h264/yuv420p, профиль Baseline/Main/High,
звук aac или без звука, контейнер mp4/mov/mkv, разрешение ≤ 1080p) ремуксится без
перекодирования; иначе выполняется транскод в h264/yuv420p/aac с `+faststart`. Готовый
вариант хранится как объект роли `playback` и отдаётся с поддержкой `Range` (перемотка).
- **Оригинал** сохраняется отдельно и используется для скачивания.
- Транскод идёт в отдельном малом пуле (`VIDEO_TRANSCODE_CONCURRENCY`) с длинным лизингом и
не вытесняет генерацию вариантов изображений. Роль `playback` появляется по готовности и
транслируется подписчикам альбома (`photo.updated`).
Без `ffmpeg`/`ffprobe` приём видео отклоняется, а `/api/health` сообщает
`"video": "unavailable"`; изображения работают как прежде.
## Сборка
```bash
make build # frontend -> backend/static -> go:embed -> bin/shared-album
make build # frontend -> backend/static -> go:embed -> bin/galo
```
Отдельные шаги:
```bash
make frontend # npm ci && npm run build, копирует статику в backend/static
make backend # CGO_ENABLED=0 go build -tags nodynamic -o bin/shared-album
make frontend # npm run build, копирует статику в backend/static (инкрементально)
make backend # CGO_ENABLED=0 go build -tags nodynamic -o bin/galo
```
> `-tags nodynamic` обязателен: он заставляет `gen2brain/webp` использовать
> транспилированный в Go libwebp вместо динамической загрузки через purego,
> благодаря чему бинарник остаётся полностью статическим.
`make frontend` собирает только при изменении исходников `frontend/`; `npm ci`
запускается лишь когда менялись `package.json`/`package-lock.json`. `make run`
и `make build` учитывают это и не пересобирают неизменившийся фронтенд.
Проверка:
```bash
file bin/shared-album # => statically linked
file bin/galo # => statically linked
```
## Миграции
@@ -68,9 +158,9 @@ make migrate # go run ./cmd/migrate — применяет up-миграци
## Локальный запуск
```bash
make run # соберёт backend и запустит ./bin/shared-album
make run # доберёт изменившийся фронтенд, соберёт backend и запустит ./bin/galo
# или напрямую:
./bin/shared-album
./bin/galo
```
Сервер слушает `HTTP_ADDR` (по умолчанию `:8080`). Health-check:
@@ -85,6 +175,62 @@ curl -s localhost:8080/api/health # {"status":"ok","db":"ok","s3":"ok"}
cd frontend && npm run dev # Vite проксирует /api на :8080
```
## Наполнение тестовыми данными
Скрипт `scripts/seed.py` наполняет запущенный инстанс тестовыми данными через публичный
API: создаёт альбомы, генерирует фотографии и, по флагам, добавляет теги, комментарии,
лайки, share-ссылки и инвайты. Работает только на добавление — существующие данные не
удаляются и не изменяются.
```bash
GALO_EMAIL=owner@example.com GALO_PASSWORD=secret \
python3 scripts/seed.py --albums 2 --photos-per-album 10 --tags --likes
```
Ту же операцию можно выполнить через Make-цель (параметры — переменные сборки):
```bash
GALO_EMAIL=owner@example.com GALO_PASSWORD=secret \
make testdata ALBUMS=3 PHOTOS=5 TAGS=1 LIKES=1 SEED=42
```
Значения по умолчанию: `ALBUMS=2`, `PHOTOS=10`, `SEED=0`, `TIMEOUT=30`. Булевы
параметры включаются значением `1`: `TAGS`, `COMMENTS`, `LIKES`, `SHARE`, `INVITES`,
`WAIT`, `DRY`, `VIDEO`. Дополнительные флаги передаются через `ARGS`.
Переменные окружения:
| Переменная | По умолчанию | Описание |
| --- | --- | --- |
| `GALO_BASE_URL` | `http://localhost:8080` | Базовый URL инстанса |
| `GALO_EMAIL` | — | Email владельца (нужен один из email/телефона) |
| `GALO_PHONE` | — | Телефон владельца в E.164 (нужен один из email/телефона) |
| `GALO_PASSWORD` | — | Пароль владельца (обязательно) |
Вход выполняется по единому идентификатору: при заданном `GALO_EMAIL` используется он,
иначе — `GALO_PHONE`.
Параметры:
| Флаг | По умолчанию | Описание |
| --- | --- | --- |
| `--albums` | `2` | Число альбомов |
| `--photos-per-album` | `10` | Фотографий в каждом альбоме |
| `--seed` | `0` | Seed генератора (воспроизводимость) |
| `--tags` | выкл. | Добавлять теги |
| `--comments` | выкл. | Добавлять комментарии |
| `--likes` | выкл. | Добавлять лайки |
| `--share-links` | выкл. | Создавать share-ссылки на альбомы |
| `--invites` | выкл. | Создавать инвайты (роль `member`) |
| `--wait-variants` | выкл. | Дожидаться генерации превью каждого фото |
| `--video` | выкл. | Загружать видео-фикстуры (требует `ffmpeg`; иначе пропускаются) |
| `--timeout` | `30` | Таймаут ожидания и сетевых запросов, сек |
| `--dry-run` | выкл. | Показать план и не отправлять изменяющих запросов |
Изображения генерируются на лету: при установленной Pillow — JPEG с EXIF (дата съёмки и
GPS), иначе — PNG без EXIF на стандартной библиотеке. Ядро скрипта не требует сторонних
пакетов; Pillow — необязательная зависимость.
## Тесты
```bash
@@ -94,26 +240,41 @@ TEST_DATABASE_URL='postgres://user:pass@pg.localnet:5432/galo?sslmode=disable' \
cd backend && go test ./internal/repo/ -v
```
## Реконсиляция хранилища
Сборка мусора воркера управляется журналом `objects` и не сканирует хранилище, поэтому
блобы без строки в журнале (после сброса БД в обход журнала и т.п.) накапливаются.
Ручная сверка:
```bash
go run ./cmd/reconcile # отчёт: сироты (есть в хранилище, нет в журнале) и пропавшие
go run ./cmd/reconcile --delete # удалить сироты старше --min-age (по умолчанию 1h)
```
- По умолчанию ничего не удаляется; удаление только по явному `--delete`.
- Область — префикс `objects/`; блобы старого формата `albums/` не затрагиваются.
- Пропавшие ключи (в журнале есть, в хранилище нет) только сообщаются.
## Деплой через systemd
1. Соберите бинарник: `make build`.
2. Создайте пользователя и каталог:
```bash
sudo useradd --system --home /opt/shared-album album
sudo mkdir -p /opt/shared-album
sudo cp bin/shared-album /opt/shared-album/
sudo cp .env /opt/shared-album/.env
sudo chown -R album:album /opt/shared-album
sudo useradd --system --home /opt/galo album
sudo mkdir -p /opt/galo
sudo cp bin/galo /opt/galo/
sudo cp .env /opt/galo/.env
sudo chown -R album:album /opt/galo
```
3. Установите unit:
```bash
sudo cp deploy/shared-album.service /etc/systemd/system/
sudo cp deploy/galo.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now shared-album
sudo journalctl -u shared-album -f
sudo systemctl enable --now galo
sudo journalctl -u galo -f
```
## nginx (опционально)
@@ -129,11 +290,12 @@ backend/
cmd/migrate/ # применение миграций
internal/config # чтение env
internal/db # pgx + golang-migrate
internal/storage # S3 (Garage)
internal/storage # S3 (Garage) / локальная ФС, отдача через бэкенд
internal/store # фабрика репозиториев (postgres / sqlite)
internal/auth # JWT, bcrypt, refresh
internal/albums # альбомы
internal/members # участники, роли, инвайты
internal/photos # загрузка, варианты, presigned URL
internal/photos # загрузка, варианты, отдача через бэкенд
internal/social # лайки, комментарии
internal/notifications # персональные уведомления
internal/sharelinks # ссылки для просмотра
@@ -149,7 +311,12 @@ deploy/ # systemd unit, пример nginx
Полный список эндпоинтов: `project.md`, раздел 11. Кратко:
- `POST /api/auth/register|login|refresh|logout`, `GET /api/auth/me`
- `POST /api/auth/register` (email и/или phone), `POST /api/auth/login`
(`identifier` — email или E.164-телефон), `POST /api/auth/refresh|logout`
- `GET|PATCH|DELETE /api/auth/me`, `POST /api/auth/me/password`,
`POST /api/auth/me/identifiers`, `PATCH /api/auth/me/preferences`
- `POST|DELETE /api/auth/me/avatar` (круглый аватар: загрузка готового квадрата),
`GET /api/avatars/:userID/:version?size=large` (публичная отдача аватара)
- `GET|POST /api/albums`, `GET|PATCH|DELETE /api/albums/:id`
- `GET /api/albums/:id/members`, `POST /api/albums/:id/invites`,
`POST /api/invites/:token/accept`, `PATCH|DELETE /api/albums/:id/members/:user_id`
+217
View File
@@ -0,0 +1,217 @@
# galo Android client
Native Android client for a self-hosted [galo](../README.md) instance: browse
albums and media, watch photos/videos/360 panoramas, follow live album updates,
and upload from the device with a persistent background queue and offline cache.
Kotlin + Jetpack Compose, `minSdk 28`, `targetSdk 35`. The module is standalone:
it does not touch `backend/` or `frontend/`, and consumes the existing HTTP API
without server changes.
## Requirements
- JDK 17
- Android SDK (`platforms;android-35`, `build-tools;35.0.0`, `platform-tools`)
- `ANDROID_HOME` pointing at the SDK, or `android/local.properties` with
`sdk.dir=<path>`
## Build
```sh
cd android
./gradlew :app:assembleDebug # debug APK
./gradlew test # JVM unit + Robolectric UI tests
./gradlew lintDebug # Android lint
./gradlew :app:assembleRelease # unsigned release APK without a keystore
```
From the repository root:
```sh
make android # runs :app:assembleDebug (ANDROID_SDK=... to override)
make install-android-app # build and install the debug APK on a device
```
`install-android-app` builds the debug APK and installs it onto the sole connected
device. To choose a device explicitly, pass its serial number:
```sh
make install-android-app SERIAL=emulator-5554
```
`SERIAL` also falls back to `$ANDROID_SERIAL`. When several devices are connected and
`SERIAL` is not set, the target prints the list of devices (only those in the `device`
state) and stops without installing. `ADB=...` overrides the `adb` binary (defaults to
`$(ANDROID_SDK)/platform-tools/adb`).
Debug output: `android/app/build/outputs/apk/debug/app-debug.apk`.
## Signing (release)
The release keystore and credentials live **outside** the repository. Point the
build at a properties file with `GALO_KEYSTORE_PROPERTIES=/path/keystore.properties`,
or place an ignored `android/keystore.properties`:
```properties
storeFile=/secure/galo-release.jks
storePassword=...
keyAlias=galo
keyPassword=...
```
Without it, `assembleRelease` still resolves and produces an unsigned APK; no
secrets are stored in the repository.
## First run
1. Enter the instance base URL (e.g. `https://photos.example.com`) and confirm —
the app checks `GET /api/health`.
2. Plain `http://` is blocked by default. For a trusted LAN instance, enable
**Небезопасное соединение (только LAN)**; the UI then marks the connection as
insecure. The manifest permits cleartext, but the client itself refuses HTTP
until you opt in.
3. Log in or register. Tokens are stored in the platform Keystore
(EncryptedSharedPreferences) and refreshed transparently on `401`.
## Upload notes
- Uses the system Photo Picker; pick files, then choose the destination album.
- One file per multipart POST (the server's batch upload is fail-fast and caps
the total body size), so each file gets its own status and retry.
- Queue is persisted in Room and resumes after an app/device restart. Large and
video uploads show a foreground progress notification; transient failures
retry with exponential backoff (up to 3 attempts).
- Optional **Только Wi-Fi** limits uploads to unmetered networks; jobs wait in
the queue until Wi-Fi is available.
- **HEIC/HEIF** originals are converted to JPEG on-device (the server decodes
only JPEG/PNG/GIF/BMP/TIFF). Capture date and geolocation are carried over via
EXIF and the base filename is preserved. Note: for HEIC sources the stored
"original" is the converted JPEG.
## Offline cache
Album metadata and photo pages are cached in Room; media is cached by Coil keyed
by the object path without the signed query, so the cache is reused when the
signature rotates. Albums and photo grids open from cache when offline.
## Live updates
While the app is signed in the client holds the personal stream
`GET /api/sse/me` (authorized by the bearer header, not a query token) and applies
album lifecycle events (`album.created`, `album.updated`, `album.deleted`) straight
to the local cache, so the album list and an open album screen follow changes made
elsewhere without a manual refresh. After a reconnect the list is re-synced over
REST, which also drops albums deleted while the stream was down. When an album is
deleted or the user is removed from it, the album leaves the list and the open
album screen returns to it.
While an album screen is active the client also holds `GET /api/sse/albums/:id` and
backfills missed photo changes via `GET /api/albums/:id/photos?since=` after a
reconnect.
The streaming client does not refresh tokens by itself: it is built without the
authenticator that ordinary API calls use, and the authorization interceptor only
reads the current access token. A reconnect after expiry therefore restarts with
whatever token is stored, and recovers once any other request has refreshed it; a
client that idles with only the stream open keeps retrying with the stale token
until the next API call renews it.
## Attributes and actions
The mobile client mirrors the web workspace's attribute/action management.
- **Album panel.** Swiping right-to-left on any album screen (server or device)
opens the «Атрибуты и действия» panel with the sections «Обложка», «Информация»,
«Навигация» and «Действия» (rename, delete). Cover and navigation are limited to
owner/admin. For a device album linked to a server album, the panel shows the
linked server album's real attributes; an unlinked device album shows local
values (cover, name, item count) without the navigation section.
- **Photo sheet.** Swiping up in full-screen viewing (server or device) opens the
«Атрибуты и действия» sheet with «Информация», «Метки» and «Действия» (download,
move, copy, set as cover, delete) plus rename by tapping the file name. The sheet
does not open while an image is zoomed, a panorama rotates, or video controls are
in use. For device media linked to a server photo, the sheet shows that photo's
server attributes and actions; otherwise it shows local properties.
- **Attribute settings.** The «Информация» section reaches a screen listing the EXIF
attributes with visibility checkboxes and label fields («отметить все»/«снять
все»); the choice is stored in the profile's `photo_info` preferences, using the
same shape as web, so the two clients stay in sync.
API operations backing these panels (all already served by the backend):
- `PATCH /api/albums/:id` (`title`, `description`, `cover_photo_id` / `clear_cover`),
`DELETE /api/albums/:id`, `GET /api/albums/:id/members`,
`GET|POST|DELETE /api/albums/:id/share`
- `POST /api/photo-batch/copy`, `POST|DELETE /api/photos/:id/tags`, `GET /api/tags?q=`
- `PATCH /api/auth/me/preferences`; account payload `GET /api/auth/me` and photo
payloads expose `preferences`, `exif`, `tags`, `uploader` and `download_url`.
## Device albums and synchronization
The album list mixes the server's albums with the device's own albums (MediaStore
buckets). Each card is tagged with its source (`Сервер` / `Телефон`), and a linked
pair shows the connection both ways: the device album shows `→ <server album>`, the
server album shows `источник: <device albums>`.
- **Access.** Enumerating device albums needs media read access: `READ_MEDIA_IMAGES`
and `READ_MEDIA_VIDEO` on Android 13+, or `READ_EXTERNAL_STORAGE` on Android 12
and lower. The app requests it at runtime; without (or with partial) access the
device albums are simply not shown, and server albums keep working.
- **Synchronization modes** are chosen per device album from the album screen
(swipe right, or the settings icon):
- **Выключена (A)** — local only. Previews are generated on the device and cached
in the app's private cache; the album makes **no server requests**.
- **Копии оригиналов на сервере (B)** — originals stay on the device, copies are
uploaded to a chosen server album.
- **Перенос оригиналов на сервер (C)** — originals move to the server; the device
keeps previews. Freeing space replaces the original in MediaStore only after the
user confirms the OS write request, in batches.
- Previews are capped at a 1600px long side (JPEG q80) and generated on the device,
including a poster frame for videos.
- **Deduplication.** A device asset is matched to a server photo by the sha256 of the
bytes that leave the device (the server exposes each original's checksum). Content
already present is linked instead of re-uploaded; an edited asset is re-uploaded and
its previous upload removed; a pure rename is propagated via `PATCH /api/photos/:id`.
- **Deletions.** The server photo is deleted only when this client uploaded it, its
content is gone from the device and no other linked album still holds it. Photos the
client did not upload are never touched, and a photo already gone from the server is
only reflected in the local index.
- **Background.** Synchronization runs on app start and while the app is open (a
MediaStore content observer), and periodically in the background via WorkManager.
The periodic pass honors the **Только Wi-Fi** setting. Freeing space in mode C is
never automatic — it always waits for the user's confirmation.
- **Provenance.** Mode C writes a marker into the EXIF of the preview it leaves
behind (server album/photo, original hash, mode). After a reinstall the app reads
these markers to restore the album link and which uploads are its own; unmarked
media is left alone.
- **Reversibility.** Leaving mode C warns first and offers to fetch the originals
back from the server (space permitting); declining keeps previews only. Unlinking
an album drops the local link and never deletes photos on the server.
## Album sorting
The album list can order every card by most recent change (the default), by title,
or by the number of items, and the chosen key can be reversed from the same menu.
Cards of device albums and server albums are ordered together, so one ordering rule
covers the whole list; a device album's change date is the newest item it holds,
which makes the default key meaningful for it as well. The choice — key and
direction — is made from the sort control in the top bar and is remembered on the
device in DataStore, alongside the other display settings; it is not synced, so each
device keeps its own ordering.
## Testing
```sh
cd android && ./gradlew test
```
Unit tests cover URL resolution, error mapping, token refresh, the offline
cache/repository fallback, page merging, the object cache key, SSE event
parsing/reconnect, HEIC detection, and the panorama camera math. Robolectric
Compose tests cover the albums grid/offline banner, the viewer swipe navigation,
and upload statuses. Request-level tests cover the album/tag/preferences
operations behind the attribute/action panels, and DTO tests cover the rich photo
and account payloads (`exif`, `tags`, `uploader`, `preferences`).
Device-only checks (real server E2E, Wi-Fi-only gating, background continuation,
HEIC conversion of a real asset) require a device or emulator.
+158
View File
@@ -0,0 +1,158 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
}
// Release signing is optional: the keystore and its credentials live outside the
// repository. Point to a properties file with GALO_KEYSTORE_PROPERTIES (or place
// keystore.properties next to this build file, git-ignored). Without it the
// release configuration still resolves, producing an unsigned APK.
val keystorePropertiesFile: File =
(System.getenv("GALO_KEYSTORE_PROPERTIES")?.let(::file))
?: rootProject.file("keystore.properties")
val keystoreProperties = Properties().apply {
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use { load(it) }
}
}
val hasReleaseSigning = keystoreProperties.getProperty("storeFile") != null
android {
namespace = "com.galo.android"
compileSdk = 35
defaultConfig {
applicationId = "com.galo.android"
minSdk = 28
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments["clearPackageData"] = "true"
}
signingConfigs {
if (hasReleaseSigning) {
create("release") {
storeFile = file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
}
}
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
}
release {
isMinifyEnabled = false
isShrinkResources = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
signingConfig = signingConfigs.findByName("release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
execution = "ANDROIDX_TEST_ORCHESTRATOR"
}
}
// Unit tests run against the debug variant only. The Compose test activity comes
// from `ui-test-manifest`, a debug-only dependency, so the release variant's merged
// manifest has no activity for `createComposeRule` to launch. The test sources are
// variant-agnostic, so running them once (on debug) covers the same ground without
// shipping a test activity in the release manifest.
androidComponents {
beforeVariants(selector().withBuildType("release")) { variant ->
variant.enableUnitTest = false
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
implementation(libs.okhttp)
implementation(libs.okhttp.sse)
implementation(libs.okhttp.logging)
implementation(libs.coil.compose)
implementation(libs.coil.video)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
implementation(libs.androidx.media3.common)
implementation(libs.androidx.exifinterface)
implementation(libs.androidx.security.crypto)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.androidx.room.testing)
testImplementation(libs.androidx.work.testing)
testImplementation(platform(libs.androidx.compose.bom))
testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.androidx.compose.ui.test.manifest)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.espresso.core)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.okhttp)
androidTestImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestUtil(libs.androidx.test.orchestrator)
}
+16
View File
@@ -0,0 +1,16 @@
# Keep kotlinx.serialization generated serializers.
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.**
-keepclassmembers class kotlinx.serialization.json.** {
*** Companion;
}
-keepclasseswithmembers class kotlinx.serialization.json.** {
kotlinx.serialization.KSerializer serializer(...);
}
-keep,includedescriptorclasses class com.galo.android.**$$serializer { *; }
-keepclassmembers class com.galo.android.** {
*** Companion;
}
-keepclasseswithmembers class com.galo.android.** {
kotlinx.serialization.KSerializer serializer(...);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,163 @@
package com.galo.android
import android.os.Build
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.galo.android.domain.media.MediaPermissions
import com.galo.android.ui.MainActivity
import com.galo.android.ui.TAG_ALL_FILES_PROMPT_DIALOG
import com.galo.android.ui.albums.TAG_ALBUM_CARD_PREFIX
import com.galo.android.ui.albums.TAG_ALBUMS_GRID
import com.galo.android.ui.albums.TAG_ALBUMS_SORT
import com.galo.android.ui.albums.TAG_ALBUMS_SORT_MARKED_PREFIX
import com.galo.android.ui.albums.TAG_ALBUMS_SORT_MARKED_REVERSED
import com.galo.android.ui.albums.TAG_ALBUMS_SORT_OPTION_PREFIX
import com.galo.android.ui.albums.TAG_ALBUMS_SORT_REVERSED
import com.galo.android.ui.auth.TAG_AUTH_EMAIL
import com.galo.android.ui.auth.TAG_AUTH_IDENTIFIER
import com.galo.android.ui.auth.TAG_AUTH_NAME
import com.galo.android.ui.auth.TAG_AUTH_PASSWORD
import com.galo.android.ui.auth.TAG_AUTH_SUBMIT
import com.galo.android.ui.setup.TAG_SETUP_CONNECT
import com.galo.android.ui.setup.TAG_SETUP_INSECURE
import com.galo.android.ui.setup.TAG_SETUP_URL
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runner.RunWith
import org.junit.runners.model.Statement
/**
* The album-list ordering chosen in the UI survives a restart of the screen.
*
* Start the server with `HTTP_ADDR=:8088 ./bin/galo` and run:
* `./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.galo.android.AlbumSortE2ETest`
*/
@RunWith(AndroidJUnit4::class)
class AlbumSortE2ETest {
private val mediaPermissionRule: TestRule = object : TestRule {
override fun apply(base: Statement, description: Description): Statement = object : Statement() {
override fun evaluate() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val pkg = instrumentation.targetContext.packageName
MediaPermissions.required(Build.VERSION.SDK_INT).forEach { permission ->
runCatching { instrumentation.uiAutomation.grantRuntimePermission(pkg, permission) }
}
base.evaluate()
}
}
}
private val compose = createAndroidComposeRule<MainActivity>()
@get:Rule
val rules: TestRule = RuleChain.outerRule(mediaPermissionRule).around(compose)
private fun app() = ApplicationProvider.getApplicationContext<GaloApplication>()
private fun awaitTag(tag: String) {
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
}
}
private fun awaitAlbumsScreen() {
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithText("Пока нет альбомов").fetchSemanticsNodes().isNotEmpty() ||
compose.onAllNodesWithTag(TAG_ALBUMS_GRID).fetchSemanticsNodes().isNotEmpty()
}
}
private fun dismissAllFilesPrompt() {
if (compose.onAllNodesWithTag(TAG_ALL_FILES_PROMPT_DIALOG).fetchSemanticsNodes().isNotEmpty()) {
compose.onNodeWithText("Позже").performClick()
}
}
/** Horizontal position of a card; two cards share the first grid row. */
private fun leftOf(albumId: String) =
compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + albumId).getUnclippedBoundsInRoot().left
@Test
fun sortChoiceSurvivesRestart() {
val email = "android-sort-${System.currentTimeMillis()}@example.com"
compose.onNodeWithTag(TAG_SETUP_URL).performTextInput(E2E.BASE_URL)
compose.onNodeWithTag(TAG_SETUP_INSECURE).performClick()
compose.onNodeWithTag(TAG_SETUP_CONNECT).performClick()
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithTag(TAG_AUTH_IDENTIFIER).fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithText("Создать аккаунт").performClick()
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithTag(TAG_AUTH_NAME).fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithTag(TAG_AUTH_NAME).performTextInput("Sorter")
compose.onNodeWithTag(TAG_AUTH_EMAIL).performTextInput(email)
compose.onNodeWithTag(TAG_AUTH_PASSWORD).performTextInput("secret123")
compose.onNodeWithTag(TAG_AUTH_SUBMIT).performClick()
awaitAlbumsScreen()
dismissAllFilesPrompt()
val token = app().container.tokenStore.accessToken()
?: error("expected a stored access token after registration")
// Alpha is created first, Zulu later: by recent change Zulu leads, by
// title Alpha leads.
val alpha = E2E.createAlbum(token, "Alpha")
Thread.sleep(1_100)
val zulu = E2E.createAlbum(token, "Zulu")
runBlocking { app().container.albumRepository.refresh() }
awaitTag(TAG_ALBUM_CARD_PREFIX + alpha)
awaitTag(TAG_ALBUM_CARD_PREFIX + zulu)
assertTrue("most recent first expected", leftOf(zulu) < leftOf(alpha))
compose.onNodeWithTag(TAG_ALBUMS_SORT).performClick()
compose.onNodeWithTag(TAG_ALBUMS_SORT_MARKED_PREFIX + "UPDATED", useUnmergedTree = true)
.assertExists()
compose.onNodeWithTag(TAG_ALBUMS_SORT_OPTION_PREFIX + "TITLE").performClick()
compose.waitForIdle()
assertTrue("alphabetical order expected", leftOf(alpha) < leftOf(zulu))
// Reversing the key turns the order around.
compose.onNodeWithTag(TAG_ALBUMS_SORT).performClick()
compose.onNodeWithTag(TAG_ALBUMS_SORT_REVERSED).performClick()
compose.waitForIdle()
assertTrue("reverse order expected", leftOf(zulu) < leftOf(alpha))
// Restarting the screen must keep the saved key and direction.
compose.activityRule.scenario.recreate()
compose.waitForIdle()
awaitTag(TAG_ALBUM_CARD_PREFIX + alpha)
awaitTag(TAG_ALBUM_CARD_PREFIX + zulu)
dismissAllFilesPrompt()
assertTrue("ordering did not survive the restart", leftOf(zulu) < leftOf(alpha))
compose.onNodeWithTag(TAG_ALBUMS_SORT).performClick()
compose.onNodeWithTag(TAG_ALBUMS_SORT_MARKED_PREFIX + "TITLE", useUnmergedTree = true)
.assertExists()
compose.onNodeWithTag(TAG_ALBUMS_SORT_MARKED_REVERSED, useUnmergedTree = true)
.assertExists()
}
private companion object {
const val TIMEOUT = 30_000L
}
}
@@ -0,0 +1,116 @@
package com.galo.android
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.galo.android.domain.media.MediaPermissions
import com.galo.android.ui.devicealbum.DeviceAlbumViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runner.RunWith
import org.junit.runners.model.Statement
/**
* The device album grid must refresh when MediaStore changes, so photos the sync
* engine materializes (or removes) appear without reopening the album.
*/
@RunWith(AndroidJUnit4::class)
class DeviceAlbumGridInstrumentedTest {
private val mediaPermissionRule: TestRule = object : TestRule {
override fun apply(base: Statement, description: Description): Statement = object : Statement() {
override fun evaluate() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val pkg = instrumentation.targetContext.packageName
MediaPermissions.required(Build.VERSION.SDK_INT).forEach { permission ->
runCatching { instrumentation.uiAutomation.grantRuntimePermission(pkg, permission) }
}
base.evaluate()
}
}
}
@get:Rule
val rules: TestRule = RuleChain.outerRule(mediaPermissionRule)
private lateinit var context: Context
private lateinit var resolver: ContentResolver
private val created = mutableListOf<Uri>()
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
resolver = context.contentResolver
}
@After
fun tearDown() {
created.forEach { runCatching { resolver.delete(it, null, null) } }
}
private fun insertImage(folder: String, name: String): Pair<Uri, String> {
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, name)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/$folder/")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!!
val bitmap = Bitmap.createBitmap(120, 90, Bitmap.Config.ARGB_8888)
resolver.openOutputStream(uri)!!.use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
bitmap.recycle()
values.clear()
values.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(uri, values, null, null)
created += uri
val bucket = resolver.query(uri, arrayOf(MediaStore.Images.Media.BUCKET_ID), null, null, null)!!
.use { cursor ->
cursor.moveToFirst()
cursor.getString(0)
}
return uri to bucket
}
@Test
fun gridAppearsAndRefreshesOnMediaStoreChanges() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
val folder = "GaloGrid-${System.nanoTime()}"
val (_, bucket) = insertImage(folder, "grid-${System.nanoTime()}.jpg")
val viewModel = DeviceAlbumViewModel(container, bucket)
// The initial item is shown.
withTimeout(15_000) {
viewModel.state.first { it.items.any { media -> media.albumId == bucket } }
}
// A new file appears in the bucket (as a materialized server photo would);
// the grid must pick it up without any manual reload.
val (_, bucket2) = insertImage(folder, "grid2-${System.nanoTime()}.jpg")
assertTrue("test setup must use one bucket", bucket == bucket2)
withTimeout(20_000) {
viewModel.state.first { state ->
state.items.count { it.albumId == bucket } >= 2
}
}
Unit
}
}
@@ -0,0 +1,416 @@
package com.galo.android
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.provider.MediaStore
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import android.util.Log
import com.galo.android.data.db.entity.DeviceAssetEntity
import com.galo.android.domain.model.LocalAssetState
import com.galo.android.domain.model.SyncMode
import com.galo.android.ui.devicealbum.DeviceAlbumViewModel
import com.galo.android.ui.devicealbum.OriginalSource
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/**
* End-to-end device-album synchronization against a running galo instance at
* 10.0.2.2:8088: a device album is linked to a server album, mode B uploads its
* content through the real queue, and a second pass does not duplicate it.
*/
@RunWith(AndroidJUnit4::class)
class DeviceSyncE2ETest {
private lateinit var context: Context
private lateinit var resolver: ContentResolver
private val created = mutableListOf<Uri>()
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
resolver = context.contentResolver
}
@After
fun tearDown() {
created.forEach { runCatching { resolver.delete(it, null, null) } }
}
private fun insertImage(): Pair<Uri, String> {
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "sync-${System.nanoTime()}.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/GaloSync/")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!!
val bitmap = Bitmap.createBitmap(320, 240, Bitmap.Config.ARGB_8888)
resolver.openOutputStream(uri)!!.use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
values.clear()
values.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(uri, values, null, null)
created += uri
val bucket = resolver.query(
uri,
arrayOf(MediaStore.Images.Media.BUCKET_ID),
null,
null,
null,
)!!.use { cursor ->
cursor.moveToFirst()
cursor.getString(0)
}
return uri to bucket
}
@Test
fun copyModeUploadsDeviceAlbumAndDeduplicates() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
Log.i(TAG_LOG, "configure")
E2E.configure(container)
Log.i(TAG_LOG, "register")
val token = E2E.registerAuthenticated(container)
Log.i(TAG_LOG, "createAlbum")
val serverAlbum = E2E.createAlbum(token, "Device sync ${System.currentTimeMillis()}")
Log.i(TAG_LOG, "insert")
val (_, bucket) = insertImage()
Log.i(TAG_LOG, "link")
container.syncRepository.link(bucket, "Sync", serverAlbum, SyncMode.COPY)
Log.i(TAG_LOG, "reconcile")
val contacted = withTimeout(30_000) { container.syncCoordinator.reconcile(bucket) }
Log.i(TAG_LOG, "reconcile=$contacted")
assertTrue("reconcile should contact the server", contacted)
Log.i(TAG_LOG, "await photos")
val uploaded = awaitPhotos(token, serverAlbum, minimum = 1)
Log.i(TAG_LOG, "uploaded=$uploaded")
assertTrue("device photo should reach the server", uploaded >= 1)
// A second pass must not duplicate content already on the server.
Log.i(TAG_LOG, "second reconcile")
container.syncCoordinator.reconcile(bucket)
delay(2_000)
val afterSecond = E2E.listPhotos(token, serverAlbum).size
Log.i(TAG_LOG, "afterSecond=$afterSecond")
assertEquals(uploaded, afterSecond)
Log.i(TAG_LOG, "done")
Unit
}
private suspend fun awaitPhotos(token: String, albumId: String, minimum: Int): Int {
val deadline = System.currentTimeMillis() + 60_000
while (System.currentTimeMillis() < deadline) {
val count = E2E.listPhotos(token, albumId).size
if (count >= minimum) return count
delay(500)
}
return E2E.listPhotos(token, albumId).size
}
@Test
fun moveModeUploadsThenOffloadsTheOriginal() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val serverAlbum = E2E.createAlbum(token, "Move ${System.currentTimeMillis()}")
val (uri, bucket) = insertImage()
val key = DeviceAssetEntity.key(bucket, ContentUris.parseId(uri))
container.syncRepository.link(bucket, "Move", serverAlbum, SyncMode.MOVE)
assertTrue("reconcile should contact the server", container.syncCoordinator.reconcile(bucket))
assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1)
// The original is offloaded automatically right after the upload.
val offloaded = waitForState(container, bucket, key, LocalAssetState.PREVIEW)
assertTrue("asset should be offloaded automatically", offloaded)
}
@Test
fun switchingFromCopiesToMoveOffloadsExistingUploads() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val serverAlbum = E2E.createAlbum(token, "CopyThenMove ${System.currentTimeMillis()}")
val (uri, bucket) = insertImage()
val key = DeviceAssetEntity.key(bucket, ContentUris.parseId(uri))
// First, copies: upload while keeping the original.
container.syncRepository.link(bucket, "C", serverAlbum, SyncMode.COPY)
assertTrue(container.syncCoordinator.reconcile(bucket))
assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1)
assertTrue("upload should be recorded", waitForState(container, bucket, key, LocalAssetState.UPLOADED))
// Switch to move: the already-uploaded asset is offloaded.
container.syncRepository.link(bucket, "C", serverAlbum, SyncMode.MOVE)
assertTrue(container.syncCoordinator.reconcile(bucket))
assertTrue(
"existing upload should be offloaded on switching to move",
waitForState(container, bucket, key, LocalAssetState.PREVIEW),
)
}
@Test
fun offloadedAssetExposesTheServerOriginalForZoom() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val serverAlbum = E2E.createAlbum(token, "Zoom ${System.currentTimeMillis()}")
val (uri, bucket) = insertImage()
val mediaId = ContentUris.parseId(uri)
val key = DeviceAssetEntity.key(bucket, mediaId)
container.syncRepository.link(bucket, "Z", serverAlbum, SyncMode.MOVE)
assertTrue(container.syncCoordinator.reconcile(bucket))
assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1)
assertTrue("asset should be offloaded", waitForState(container, bucket, key, LocalAssetState.PREVIEW))
val viewModel = DeviceAlbumViewModel(container, bucket)
val collector = launch { viewModel.state.collect { } }
try {
var source: OriginalSource? = null
val deadline = System.currentTimeMillis() + 8_000
while (System.currentTimeMillis() < deadline) {
source = viewModel.originalSource(mediaId)
if (source != null) break
delay(200)
}
assertNotNull("an offloaded asset must expose the server original", source)
assertTrue("the natural size must be known", source!!.width > 0 && source!!.height > 0)
// The URL must actually be reachable and return the original bytes.
val bytes = okhttp3.OkHttpClient().newCall(
okhttp3.Request.Builder().url(source!!.url).build(),
).execute().use { response ->
assertTrue("original should be fetched, HTTP ${response.code}", response.isSuccessful)
response.body?.bytes()?.size ?: 0
}
assertTrue("original should not be empty", bytes > 0)
} finally {
collector.cancel()
}
}
@Test
fun switchingFromMoveToCopyRestoresTheOriginal() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val serverAlbum = E2E.createAlbum(token, "MoveToCopy ${System.currentTimeMillis()}")
val (uri, bucket) = insertImage()
val key = DeviceAssetEntity.key(bucket, ContentUris.parseId(uri))
container.syncRepository.link(bucket, "MC", serverAlbum, SyncMode.MOVE)
assertTrue(container.syncCoordinator.reconcile(bucket))
assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1)
assertTrue("asset should be offloaded", waitForState(container, bucket, key, LocalAssetState.PREVIEW))
// Switch to copies and run the check: the original comes back.
container.syncRepository.link(bucket, "MC", serverAlbum, SyncMode.COPY)
container.syncCheck.checkGallery(bucket)
assertTrue(
"original should be restored on move→copy",
waitForState(container, bucket, key, LocalAssetState.LOCAL),
)
// The restored original must not be re-uploaded as a duplicate.
container.syncCoordinator.reconcile(bucket)
delay(2_000)
assertEquals(1, E2E.listPhotos(token, serverAlbum).size)
}
@Test
fun unlinkedAlbumWithPreviewsRestoresOriginals() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val serverAlbum = E2E.createAlbum(token, "UnlinkRestore ${System.currentTimeMillis()}")
val (uri, bucket) = insertImage()
val key = DeviceAssetEntity.key(bucket, ContentUris.parseId(uri))
container.syncRepository.link(bucket, "U", serverAlbum, SyncMode.MOVE)
assertTrue(container.syncCoordinator.reconcile(bucket))
assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1)
assertTrue("asset should be offloaded", waitForState(container, bucket, key, LocalAssetState.PREVIEW))
// Unlink (mode off): previews must still be restored, without touching the server.
container.syncRepository.unlink(bucket)
container.syncCheck.checkGallery(bucket)
assertTrue(
"originals should be restored when no longer moving",
waitForState(container, bucket, key, LocalAssetState.LOCAL),
)
assertEquals(1, E2E.listPhotos(token, serverAlbum).size)
}
@Test
fun checkRebuildsLostIndexFromMarker() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val serverAlbum = E2E.createAlbum(token, "LostIndex ${System.currentTimeMillis()}")
val (uri, bucket) = insertImage()
val key = DeviceAssetEntity.key(bucket, ContentUris.parseId(uri))
container.syncRepository.link(bucket, "L", serverAlbum, SyncMode.MOVE)
assertTrue(container.syncCoordinator.reconcile(bucket))
assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1)
assertTrue("asset should be offloaded", waitForState(container, bucket, key, LocalAssetState.PREVIEW))
// Simulate a lost index/link: the marker in the preview file is the only trail.
container.syncRepository.forgetAlbum(bucket)
container.syncCheck.checkGallery(bucket, recoverIndex = true)
val link = waitForLink(container, bucket)
assertTrue("link should be rebuilt from the marker", link != null)
assertEquals(SyncMode.MOVE, SyncMode.from(link?.mode))
assertTrue(
"an offloaded asset stays offloaded in move mode",
waitForState(container, bucket, key, LocalAssetState.PREVIEW),
)
}
@Test
fun deletedServerCopyMarksThePreviewAsOrphaned() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val serverAlbum = E2E.createAlbum(token, "Orphan ${System.currentTimeMillis()}")
val (uri, bucket) = insertImage()
val mediaId = ContentUris.parseId(uri)
val key = DeviceAssetEntity.key(bucket, mediaId)
container.syncRepository.link(bucket, "O", serverAlbum, SyncMode.MOVE)
assertTrue(container.syncCoordinator.reconcile(bucket))
assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1)
assertTrue("asset should be offloaded", waitForState(container, bucket, key, LocalAssetState.PREVIEW))
// Somebody removes the photo from the server album.
val photo = E2E.listPhotos(token, serverAlbum).first()
E2E.deletePhoto(token, photo.id)
container.syncCheck.checkGallery(bucket, recoverIndex = true)
assertTrue(
"the offloaded preview should be marked as orphaned",
waitForState(container, bucket, key, LocalAssetState.ORPHANED),
)
// The grid derives the crossed-out cloud from the view model set.
val viewModel = DeviceAlbumViewModel(container, bucket)
val collector = launch { viewModel.state.collect { } }
try {
var orphaned = false
val deadline = System.currentTimeMillis() + 8_000
while (System.currentTimeMillis() < deadline) {
orphaned = mediaId in viewModel.state.value.orphanedMediaIds
if (orphaned) break
delay(200)
}
assertTrue("the lost preview should be flagged for the grid", orphaned)
} finally {
collector.cancel()
}
}
@Test
fun returnedOriginalIsUploadedAgainAndOffloaded() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val serverAlbum = E2E.createAlbum(token, "Return ${System.currentTimeMillis()}")
val (uri, bucket) = insertImage()
val mediaId = ContentUris.parseId(uri)
val key = DeviceAssetEntity.key(bucket, mediaId)
val originalBytes = resolver.openInputStream(uri)!!.use { it.readBytes() }
container.syncRepository.link(bucket, "R", serverAlbum, SyncMode.MOVE)
assertTrue(container.syncCoordinator.reconcile(bucket))
assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1)
assertTrue("asset should be offloaded", waitForState(container, bucket, key, LocalAssetState.PREVIEW))
val photo = E2E.listPhotos(token, serverAlbum).first()
E2E.deletePhoto(token, photo.id)
container.syncCheck.checkGallery(bucket, recoverIndex = true)
assertTrue(waitForState(container, bucket, key, LocalAssetState.ORPHANED))
// The original comes back to the device: upload it again, then offload.
resolver.openOutputStream(uri)!!.use { it.write(originalBytes) }
var offloadedAgain = false
val deadline = System.currentTimeMillis() + 60_000
while (System.currentTimeMillis() < deadline) {
container.syncCheck.checkGallery(bucket, recoverIndex = true)
delay(1_000)
val state = container.syncRepository.assets(bucket)
.firstOrNull { it.assetKey == key }?.state
if (state == LocalAssetState.PREVIEW.name) {
offloadedAgain = true
break
}
}
assertTrue("the returned original should be uploaded and offloaded again", offloadedAgain)
assertTrue(
"the server album should hold a photo again",
E2E.listPhotos(token, serverAlbum).isNotEmpty(),
)
}
private suspend fun waitForLink(
container: com.galo.android.di.AppContainer,
bucket: String,
): com.galo.android.data.db.entity.SyncedAlbumEntity? {
val deadline = System.currentTimeMillis() + 10_000
while (System.currentTimeMillis() < deadline) {
val link = container.syncRepository.currentLink(bucket)
if (link != null && link.serverAlbumId.isNotBlank()) return link
delay(200)
}
return container.syncRepository.currentLink(bucket)
}
private suspend fun waitForState(
container: com.galo.android.di.AppContainer,
bucket: String,
key: String,
state: LocalAssetState,
): Boolean {
val deadline = System.currentTimeMillis() + 30_000
while (System.currentTimeMillis() < deadline) {
val asset = container.syncRepository.assets(bucket).firstOrNull { it.assetKey == key }
if (asset?.state == state.name) return true
delay(300)
}
return false
}
private companion object {
const val TAG_LOG = "GaloSyncE2E"
}
}
@@ -0,0 +1,248 @@
package com.galo.android
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.galo.android.data.network.GaloJson
import com.galo.android.data.network.dto.AlbumEnvelopeDto
import com.galo.android.data.network.dto.PhotoDto
import com.galo.android.data.network.dto.TokenEnvelopeDto
import com.galo.android.data.network.dto.UploadResponseDto
import com.galo.android.di.AppContainer
import kotlinx.coroutines.runBlocking
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.UUID
import java.util.concurrent.TimeUnit
/**
* Shared helpers for on-device integration tests. Assumes a galo server is
* reachable from the emulator at [BASE_URL] (host loopback) started with
* `HTTP_ADDR=:8088 ./bin/galo`.
*/
object E2E {
const val BASE_URL = "http://10.0.2.2:8088"
val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
fun context(): Context = ApplicationProvider.getApplicationContext()
/** Points the app at the LAN server with insecure HTTP allowed, then waits until applied. */
fun configure(container: AppContainer) {
runBlocking {
container.settingsRepository.setInsecureAllowed(true)
container.settingsRepository.setBaseUrl(BASE_URL)
}
val deadline = System.currentTimeMillis() + 10_000
while (System.currentTimeMillis() < deadline && container.baseUrl() != BASE_URL) {
Thread.sleep(100)
}
check(container.baseUrl() == BASE_URL) { "settings did not apply" }
}
fun register(): String = registerTokens().accessToken
/** Registers and stores the token in the app's Keystore-backed session. */
fun registerAuthenticated(container: AppContainer): String {
val tokens = registerTokens()
container.tokenStore.save(tokens.accessToken, tokens.refreshToken)
return tokens.accessToken
}
private fun registerTokens(): com.galo.android.data.network.dto.AuthTokensDto {
val email = "android-it-${System.currentTimeMillis()}-${UUID.randomUUID().toString().take(6)}@example.com"
val payload = """{"email":"$email","password":"secret123","name":"IT"}"""
.toRequestBody("application/json".toMediaType())
val request = Request.Builder().url("$BASE_URL/api/auth/register").post(payload).build()
client.newCall(request).execute().use { response ->
check(response.isSuccessful) { "register failed: HTTP ${response.code}" }
return GaloJson.decodeFromString<TokenEnvelopeDto>(response.body!!.string()).tokens
}
}
fun createAlbum(token: String, title: String): String { val payload = """{"title":"$title"}""".toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("$BASE_URL/api/albums")
.header("Authorization", "Bearer $token")
.post(payload)
.build()
client.newCall(request).execute().use { response ->
check(response.isSuccessful) { "create album failed: HTTP ${response.code}" }
return GaloJson.decodeFromString<AlbumEnvelopeDto>(response.body!!.string()).album.id
}
}
/** Registers a fresh user and returns the access token alongside the user id. */
fun registerWithId(): Pair<String, String> {
val tokens = registerTokens()
return tokens.accessToken to userId(tokens.accessToken)
}
/** The id of the user the token belongs to. */
fun userId(token: String): String {
val request = Request.Builder()
.url("$BASE_URL/api/auth/me")
.header("Authorization", "Bearer $token")
.get()
.build()
client.newCall(request).execute().use { response ->
check(response.isSuccessful) { "me failed: HTTP ${response.code}" }
val user = GaloJson.decodeFromString<com.galo.android.data.network.dto.UserEnvelopeDto>(
response.body!!.string(),
).user
return user.id
}
}
/** Creates an album invite and returns its token. */
fun invite(token: String, albumId: String, role: String = "viewer"): String {
val payload = """{"role":"$role"}""".toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("$BASE_URL/api/albums/$albumId/invites")
.header("Authorization", "Bearer $token")
.post(payload)
.build()
client.newCall(request).execute().use { response ->
check(response.isSuccessful) { "invite failed: HTTP ${response.code}" }
val body = response.body!!.string()
return kotlinx.serialization.json.Json.parseToJsonElement(body)
.let { it as kotlinx.serialization.json.JsonObject }
.getValue("invite")
.let { it as kotlinx.serialization.json.JsonObject }
.getValue("token")
.let { it as kotlinx.serialization.json.JsonPrimitive }
.content
}
}
/** Accepts an invite, joining the album as the invited user. */
fun acceptInvite(token: String, inviteToken: String) {
val request = Request.Builder()
.url("$BASE_URL/api/invites/$inviteToken/accept")
.header("Authorization", "Bearer $token")
.post(ByteArray(0).toRequestBody(null))
.build()
client.newCall(request).execute().use { response ->
check(response.isSuccessful) { "accept invite failed: HTTP ${response.code}" }
}
}
/** Renames an album (drives a live `album.updated`). */
fun renameAlbum(token: String, albumId: String, title: String) {
val payload = """{"title":"$title"}""".toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("$BASE_URL/api/albums/$albumId")
.header("Authorization", "Bearer $token")
.patch(payload)
.build()
client.newCall(request).execute().use { response ->
check(response.isSuccessful) { "rename failed: HTTP ${response.code}" }
}
}
/** Removes a member from an album (drives a personal `album.deleted`). */
fun removeMember(token: String, albumId: String, userId: String) {
val request = Request.Builder()
.url("$BASE_URL/api/albums/$albumId/members/$userId")
.header("Authorization", "Bearer $token")
.delete()
.build()
client.newCall(request).execute().use { response ->
check(response.isSuccessful) { "remove member failed: HTTP ${response.code}" }
}
}
fun upload(token: String, albumId: String, filename: String, bytes: ByteArray, mime: String) { val multipart = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("files", filename, bytes.toRequestBody(mime.toMediaType()))
.build()
val request = Request.Builder()
.url("$BASE_URL/api/albums/$albumId/photos")
.header("Authorization", "Bearer $token")
.post(multipart)
.build()
client.newCall(request).execute().use { response ->
check(response.isSuccessful) { "upload failed: HTTP ${response.code}" }
GaloJson.decodeFromString<UploadResponseDto>(response.body!!.string())
}
}
fun listPhotos(token: String, albumId: String): List<PhotoDto> {
val request = Request.Builder()
.url("$BASE_URL/api/albums/$albumId/photos?limit=50")
.header("Authorization", "Bearer $token")
.build()
client.newCall(request).execute().use { response ->
val body = response.body?.string().orEmpty()
return GaloJson.decodeFromString<com.galo.android.data.network.dto.PhotoListDto>(body).photos
}
}
/** Deletes a photo on the server (drives a live `photo.deleted`). */
fun deletePhoto(token: String, photoId: String) {
val request = Request.Builder()
.url("$BASE_URL/api/photos/$photoId")
.header("Authorization", "Bearer $token")
.delete()
.build()
client.newCall(request).execute().use { response ->
check(response.isSuccessful) { "delete photo failed: HTTP ${response.code}" }
}
}
fun awaitPhotoWithPlayback(token: String, albumId: String, timeoutMs: Long): PhotoDto {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val request = Request.Builder()
.url("$BASE_URL/api/albums/$albumId/photos?limit=5")
.header("Authorization", "Bearer $token")
.build()
client.newCall(request).execute().use { response ->
val body = response.body?.string().orEmpty()
val photo = runCatching {
GaloJson.decodeFromString<com.galo.android.data.network.dto.PhotoListDto>(body)
.photos.firstOrNull { !it.playbackUrl.isNullOrBlank() }
}.getOrNull()
if (photo != null) return photo
}
Thread.sleep(500)
}
error("no processed photo with playback_url within ${timeoutMs}ms")
}
fun readAsset(name: String): ByteArray =
androidx.test.platform.app.InstrumentationRegistry.getInstrumentation()
.context.assets.open(name).use { it.readBytes() }
fun absolute(path: String): String = BASE_URL + path
fun jpegBytes(size: Int = 96): ByteArray {
val bitmap = android.graphics.Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888)
bitmap.eraseColor(android.graphics.Color.rgb(20, 140, 90))
val out = java.io.ByteArrayOutputStream()
bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 85, out)
bitmap.recycle()
return out.toByteArray()
}
fun writeTempFile(name: String, bytes: ByteArray): android.net.Uri {
val file = java.io.File(context().cacheDir, name)
file.writeBytes(bytes)
return android.net.Uri.fromFile(file)
}
/** Toggles airplane mode via UiAutomation (shell) and waits for the change to settle. */
fun setAirplane(enabled: Boolean) {
val ui = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().uiAutomation
ui.executeShellCommand(
"cmd connectivity airplane-mode " + if (enabled) "enable" else "disable",
).close()
Thread.sleep(4_000)
}
}
@@ -0,0 +1,191 @@
package com.galo.android
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import android.os.Build
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.galo.android.data.network.GaloJson
import com.galo.android.data.network.dto.AlbumEnvelopeDto
import com.galo.android.domain.media.MediaPermissions
import com.galo.android.domain.model.UploadStatus
import com.galo.android.ui.MainActivity
import com.galo.android.ui.TAG_ALL_FILES_PROMPT_DIALOG
import com.galo.android.ui.album.TAG_ALBUM_GRID
import com.galo.android.ui.album.TAG_PHOTO_PREFIX
import com.galo.android.ui.albums.TAG_ALBUM_CARD_PREFIX
import com.galo.android.ui.albums.TAG_ALBUMS_GRID
import com.galo.android.ui.auth.TAG_AUTH_EMAIL
import com.galo.android.ui.auth.TAG_AUTH_IDENTIFIER
import com.galo.android.ui.auth.TAG_AUTH_NAME
import com.galo.android.ui.auth.TAG_AUTH_PASSWORD
import com.galo.android.ui.auth.TAG_AUTH_SUBMIT
import com.galo.android.ui.setup.TAG_SETUP_CONNECT
import com.galo.android.ui.setup.TAG_SETUP_INSECURE
import com.galo.android.ui.setup.TAG_SETUP_URL
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runner.RunWith
import org.junit.runners.model.Statement
import java.io.File
/**
* End-to-end flow against a galo instance reachable from the emulator at
* 10.0.2.2: onboarding → registration → browse → upload → photo appears.
*
* Start the server with `HTTP_ADDR=:8088 ./bin/galo` and run:
* `./gradlew :app:connectedDebugAndroidTest`.
*/
@RunWith(AndroidJUnit4::class)
class E2EFlowTest {
private val mediaPermissionRule: TestRule = object : TestRule {
override fun apply(base: Statement, description: Description): Statement = object : Statement() {
override fun evaluate() {
// Grant media access before the activity launches, otherwise the
// system permission dialog steals focus and no compose hierarchy
// is found (orchestrator clears package data between tests).
val instrumentation = InstrumentationRegistry.getInstrumentation()
val pkg = instrumentation.targetContext.packageName
MediaPermissions.required(Build.VERSION.SDK_INT).forEach { permission ->
runCatching { instrumentation.uiAutomation.grantRuntimePermission(pkg, permission) }
}
base.evaluate()
}
}
}
private val compose = createAndroidComposeRule<MainActivity>()
@get:Rule
val rules: TestRule = RuleChain.outerRule(mediaPermissionRule).around(compose)
private val baseUrl = "http://10.0.2.2:8088"
private val client = OkHttpClient()
private fun app() = ApplicationProvider.getApplicationContext<GaloApplication>()
@Test
fun onboardingRegisterBrowseUpload() {
val stamp = System.currentTimeMillis()
val email = "android-e2e-$stamp@example.com"
// Onboarding: base URL + insecure mode (plain HTTP to the emulator host).
compose.onNodeWithTag(TAG_SETUP_URL).performTextInput(baseUrl)
compose.onNodeWithTag(TAG_SETUP_INSECURE).performClick()
compose.onNodeWithTag(TAG_SETUP_CONNECT).performClick()
// Registration.
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithTag(TAG_AUTH_IDENTIFIER).fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithText("Создать аккаунт").performClick()
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithTag(TAG_AUTH_NAME).fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithTag(TAG_AUTH_NAME).performTextInput("E2E")
compose.onNodeWithTag(TAG_AUTH_EMAIL).performTextInput(email)
compose.onNodeWithTag(TAG_AUTH_PASSWORD).performTextInput("secret123")
compose.onNodeWithTag(TAG_AUTH_SUBMIT).performClick()
// Empty album list.
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithTag(TAG_ALBUMS_GRID).fetchSemanticsNodes().isNotEmpty() ||
compose.onAllNodesWithText("Пока нет альбомов").fetchSemanticsNodes().isNotEmpty()
}
// Dismiss the "all files" access prompt if shown, so it does not block taps.
if (compose.onAllNodesWithTag(TAG_ALL_FILES_PROMPT_DIALOG).fetchSemanticsNodes().isNotEmpty()) {
compose.onNodeWithText("Позже").performClick()
}
// Seed an album (the client has no album-creation UI in v1).
val token = app().container.tokenStore.accessToken()
?: error("expected a stored access token after registration")
val albumId = createAlbum(token, "Android E2E $stamp")
runBlocking { app().container.albumRepository.refresh() }
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithTag(TAG_ALBUM_CARD_PREFIX + albumId).fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + albumId).performClick()
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithTag(TAG_ALBUM_GRID).fetchSemanticsNodes().isNotEmpty() ||
compose.onAllNodesWithText("В альбоме пока нет медиа").fetchSemanticsNodes().isNotEmpty()
}
// Upload a generated JPEG through the real WorkManager queue.
val uri = writeJpeg()
runBlocking { app().container.uploadRepository.enqueue(albumId, listOf(uri), wifiOnly = false) }
val photo = runBlocking {
val deadline = System.currentTimeMillis() + UPLOAD_TIMEOUT
while (System.currentTimeMillis() < deadline) {
val photos = app().container.photoRepository.observePhotos(albumId).first()
if (photos.isNotEmpty()) return@runBlocking photos.first()
val job = app().container.uploadRepository.observeJobs().first().firstOrNull()
if (job?.status == UploadStatus.ERROR) {
error("upload job failed: ${job.error}")
}
kotlinx.coroutines.delay(500)
}
val jobs = app().container.uploadRepository.observeJobs().first()
error("upload timed out; jobs=${jobs.map { it.status to it.error }}")
}
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithTag(TAG_PHOTO_PREFIX + photo.id).fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithTag(TAG_PHOTO_PREFIX + photo.id).assertIsDisplayed()
}
private fun createAlbum(token: String, title: String): String {
val body = """{"title":"$title"}""".toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("$baseUrl/api/albums")
.header("Authorization", "Bearer $token")
.post(body)
.build()
client.newCall(request).execute().use { response ->
check(response.isSuccessful) { "create album failed: HTTP ${response.code}" }
val text = response.body?.string().orEmpty()
return GaloJson.decodeFromString<AlbumEnvelopeDto>(text).album.id
}
}
private fun writeJpeg(): Uri {
val file = File(app().cacheDir, "e2e-upload.jpg")
val bitmap = Bitmap.createBitmap(96, 96, Bitmap.Config.ARGB_8888).apply {
eraseColor(Color.rgb(30, 120, 200))
}
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 85, it) }
bitmap.recycle()
return Uri.fromFile(file)
}
private companion object {
const val TIMEOUT = 20_000L
const val UPLOAD_TIMEOUT = 60_000L
}
}
@@ -0,0 +1,80 @@
package com.galo.android
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.galo.android.data.upload.HeicConverter
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/**
* Device checks for task 6.3:
* - a real HEIC is decoded and re-encoded as JPEG with the same dimensions and a
* `.jpg` filename;
* - the EXIF copy step preserves capture date and geolocation (exercised with a
* source whose EXIF we control, since AndroidX ExifInterface can read but not
* write HEIC attributes).
*/
@RunWith(AndroidJUnit4::class)
class HeicConversionTest {
private fun app() = ApplicationProvider.getApplicationContext<GaloApplication>()
@Test
fun decodesRealHeicToJpeg() {
val source = File(app().cacheDir, "source.heic").apply { writeBytes(E2E.readAsset("sample.heic")) }
val sourceBounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(source.absolutePath, sourceBounds)
assertTrue("sample HEIC must decode on-device", sourceBounds.outWidth > 0)
val converted = runBlocking { HeicConverter.convert(app(), Uri.fromFile(source), "autumn.heic") }
assertEquals("autumn.jpg", converted.filename)
assertEquals("image/jpeg", converted.mimeType)
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(converted.file.absolutePath, bounds)
assertEquals("image/jpeg", bounds.outMimeType)
assertEquals(sourceBounds.outWidth, bounds.outWidth)
assertEquals(sourceBounds.outHeight, bounds.outHeight)
}
@Test
fun preservesExifDateAndLocationThroughConversion() {
val source = File(app().cacheDir, "with-exif.jpg")
Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888).apply {
eraseColor(android.graphics.Color.YELLOW)
source.outputStream().use { compress(Bitmap.CompressFormat.JPEG, 90, it) }
recycle()
}
val sourceExif = ExifInterface(source.absolutePath)
sourceExif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, DATE)
sourceExif.setLatLong(LAT, LONG)
sourceExif.saveAttributes()
val converted = runBlocking {
HeicConverter.convert(app(), Uri.fromFile(source), "IMG_20240715.heic")
}
assertEquals("IMG_20240715.jpg", converted.filename)
val outExif = ExifInterface(converted.file.absolutePath)
assertEquals("capture date must be preserved", DATE, outExif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL))
val latLong = outExif.latLong
assertNotNull("geolocation must be preserved", latLong)
assertTrue(Math.abs(latLong!![0] - LAT) < 0.01)
assertTrue(Math.abs(latLong[1] - LONG) < 0.01)
}
private companion object {
const val DATE = "2024:07:15 10:30:00"
const val LAT = 55.7558
const val LONG = 37.6173
}
}
@@ -0,0 +1,153 @@
package com.galo.android
import android.os.Build
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.galo.android.domain.media.MediaPermissions
import com.galo.android.ui.MainActivity
import com.galo.android.ui.TAG_ALL_FILES_PROMPT_DIALOG
import com.galo.android.ui.album.TAG_ALBUM_GRID
import com.galo.android.ui.albums.TAG_ALBUM_CARD_PREFIX
import com.galo.android.ui.auth.TAG_AUTH_EMAIL
import com.galo.android.ui.auth.TAG_AUTH_IDENTIFIER
import com.galo.android.ui.auth.TAG_AUTH_NAME
import com.galo.android.ui.auth.TAG_AUTH_PASSWORD
import com.galo.android.ui.auth.TAG_AUTH_SUBMIT
import com.galo.android.ui.setup.TAG_SETUP_CONNECT
import com.galo.android.ui.setup.TAG_SETUP_INSECURE
import com.galo.android.ui.setup.TAG_SETUP_URL
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runner.RunWith
import org.junit.runners.model.Statement
/**
* The album list and an open album screen follow changes made on another account,
* delivered over the personal stream (`GET /api/sse/me`) - no manual refresh.
*
* Start the server with `HTTP_ADDR=:8088 ./bin/galo` and run:
* `./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.galo.android.LiveAlbumsE2ETest`
*/
@RunWith(AndroidJUnit4::class)
class LiveAlbumsE2ETest {
private val mediaPermissionRule: TestRule = object : TestRule {
override fun apply(base: Statement, description: Description): Statement = object : Statement() {
override fun evaluate() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val pkg = instrumentation.targetContext.packageName
MediaPermissions.required(Build.VERSION.SDK_INT).forEach { permission ->
runCatching { instrumentation.uiAutomation.grantRuntimePermission(pkg, permission) }
}
base.evaluate()
}
}
}
private val compose = createAndroidComposeRule<MainActivity>()
@get:Rule
val rules: TestRule = RuleChain.outerRule(mediaPermissionRule).around(compose)
private fun app() = ApplicationProvider.getApplicationContext<GaloApplication>()
private fun awaitTag(tag: String) {
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
}
}
private fun awaitTagGone(tag: String) {
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithTag(tag).fetchSemanticsNodes().isEmpty()
}
}
private fun awaitText(text: String) {
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
}
}
private fun awaitAlbumsScreen() {
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithText("Пока нет альбомов").fetchSemanticsNodes().isNotEmpty() ||
compose.onAllNodesWithTag(com.galo.android.ui.albums.TAG_ALBUMS_GRID)
.fetchSemanticsNodes().isNotEmpty()
}
}
@Test
fun listAndOpenAlbumFollowChangesFromAnotherAccount() {
val stamp = System.currentTimeMillis()
val email = "android-live-$stamp@example.com"
// Onboarding as the member account.
compose.onNodeWithTag(TAG_SETUP_URL).performTextInput(E2E.BASE_URL)
compose.onNodeWithTag(TAG_SETUP_INSECURE).performClick()
compose.onNodeWithTag(TAG_SETUP_CONNECT).performClick()
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithTag(TAG_AUTH_IDENTIFIER).fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithText("Создать аккаунт").performClick()
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithTag(TAG_AUTH_NAME).fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithTag(TAG_AUTH_NAME).performTextInput("Member")
compose.onNodeWithTag(TAG_AUTH_EMAIL).performTextInput(email)
compose.onNodeWithTag(TAG_AUTH_PASSWORD).performTextInput("secret123")
compose.onNodeWithTag(TAG_AUTH_SUBMIT).performClick()
awaitAlbumsScreen()
if (compose.onAllNodesWithTag(TAG_ALL_FILES_PROMPT_DIALOG).fetchSemanticsNodes().isNotEmpty()) {
compose.onNodeWithText("Позже").performClick()
}
val memberToken = app().container.tokenStore.accessToken()
?: error("expected a stored access token after registration")
val memberId = E2E.userId(memberToken)
// The owner creates an album and invites the member, who accepts.
val (ownerToken, _) = E2E.registerWithId()
val title = "Live $stamp"
val albumId = E2E.createAlbum(ownerToken, title)
E2E.acceptInvite(memberToken, E2E.invite(ownerToken, albumId))
// The album reaches the list over the personal stream: no refresh is issued.
awaitTag(TAG_ALBUM_CARD_PREFIX + albumId)
// Open the album, then rename it from the owner account.
compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + albumId).performClick()
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithTag(TAG_ALBUM_GRID).fetchSemanticsNodes().isNotEmpty() ||
compose.onAllNodesWithText("В альбоме пока нет медиа").fetchSemanticsNodes().isNotEmpty()
}
val renamed = "$title renamed"
E2E.renameAlbum(ownerToken, albumId, renamed)
awaitText(renamed)
// Removing the member takes the album out of the list and the open screen
// back to it (the event arrives only over the personal stream).
E2E.removeMember(ownerToken, albumId, memberId)
awaitAlbumsScreen()
awaitTagGone(TAG_ALBUM_CARD_PREFIX + albumId)
compose.onNodeWithText(renamed).assertDoesNotExist()
}
private companion object {
const val TIMEOUT = 30_000L
}
}
@@ -0,0 +1,69 @@
package com.galo.android
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.galo.android.domain.model.SyncMode
import com.galo.android.ui.devicealbum.DeviceAlbumViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/**
* Choosing a synchronization mode must persist even before a server album is
* picked, so leaving and reopening the album keeps the choice.
*/
@RunWith(AndroidJUnit4::class)
class ModePersistenceTest {
@Test
fun turningSyncOffClearsTheLinkedAlbum() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
val albumId = "off-${System.currentTimeMillis()}"
val viewModel = DeviceAlbumViewModel(container, albumId)
try {
container.syncRepository.link(albumId, "Camera", "srv1", SyncMode.COPY)
viewModel.setMode(SyncMode.DISABLED)
var cleared = false
val deadline = System.currentTimeMillis() + 5_000
while (System.currentTimeMillis() < deadline) {
if (container.syncRepository.currentLink(albumId) == null) {
cleared = true
break
}
delay(100)
}
assertTrue("switching sync off should clear the linked album", cleared)
} finally {
container.syncRepository.unlink(albumId)
}
}
@Test
fun modePersistsBeforeTargetChosen() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
val albumId = "persist-${System.currentTimeMillis()}"
val viewModel = DeviceAlbumViewModel(container, albumId)
try {
viewModel.setMode(SyncMode.COPY)
var stored: SyncMode? = null
val deadline = System.currentTimeMillis() + 5_000
while (System.currentTimeMillis() < deadline) {
stored = container.syncRepository.currentLink(albumId)?.let { SyncMode.from(it.mode) }
if (stored == SyncMode.COPY) break
delay(100)
}
assertEquals(SyncMode.COPY, stored)
} finally {
container.syncRepository.unlink(albumId)
}
}
}
@@ -0,0 +1,243 @@
package com.galo.android
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.provider.MediaStore
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.galo.android.data.db.GaloDatabase
import com.galo.android.data.db.entity.DeviceAssetEntity
import com.galo.android.data.db.entity.SyncedAlbumEntity
import com.galo.android.data.media.MediaStoreDeviceSource
import com.galo.android.data.repo.SyncRepository
import com.galo.android.data.sync.OffloadManager
import com.galo.android.data.sync.OffloadResult
import com.galo.android.data.sync.OriginalFetcher
import com.galo.android.data.sync.ProvenanceStamp
import com.galo.android.data.sync.RestoreManager
import com.galo.android.data.sync.SyncRecovery
import com.galo.android.domain.model.LocalAssetState
import com.galo.android.domain.model.SyncMode
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.ByteArrayOutputStream
@RunWith(AndroidJUnit4::class)
class OffloadInstrumentedTest {
private lateinit var context: Context
private lateinit var resolver: ContentResolver
private lateinit var db: GaloDatabase
private val created = mutableListOf<Uri>()
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
resolver = context.contentResolver
db = Room.inMemoryDatabaseBuilder(context, GaloDatabase::class.java)
.allowMainThreadQueries()
.build()
}
@After
fun tearDown() {
created.forEach { runCatching { resolver.delete(it, null, null) } }
db.close()
}
private fun bucketOf(uri: Uri): String =
resolver.query(uri, arrayOf(MediaStore.MediaColumns.BUCKET_ID), null, null, null)!!.use { cursor ->
cursor.moveToFirst()
cursor.getString(0)
}
private fun sizeOf(uri: Uri): Long =
resolver.openInputStream(uri)!!.use { it.readBytes().size.toLong() }
private fun dimensionsOf(uri: Uri): Pair<Int, Int> =
resolver.openInputStream(uri)!!.use { stream ->
val bitmap = BitmapFactory.decodeStream(stream)
bitmap.width to bitmap.height
}
private fun exists(uri: Uri): Boolean =
resolver.query(uri, arrayOf(MediaStore.MediaColumns._ID), null, null, null)!!.use { it.count > 0 }
private fun insertImage(width: Int, height: Int): Pair<Uri, String> {
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "orig-${System.nanoTime()}.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/GaloTest/")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!!
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
resolver.openOutputStream(uri)!!.use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
values.clear()
values.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(uri, values, null, null)
created += uri
return uri to bucketOf(uri)
}
private fun insertVideo(): Pair<Uri, String> {
val values = ContentValues().apply {
put(MediaStore.Video.Media.DISPLAY_NAME, "clip-${System.nanoTime()}.mp4")
put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
put(MediaStore.Video.Media.RELATIVE_PATH, "DCIM/GaloTest/")
put(MediaStore.Video.Media.IS_PENDING, 1)
}
val uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values)!!
val bytes = InstrumentationRegistry.getInstrumentation().context.assets
.open("test.mp4").use { it.readBytes() }
resolver.openOutputStream(uri)!!.use { it.write(bytes) }
values.clear()
values.put(MediaStore.Video.Media.IS_PENDING, 0)
resolver.update(uri, values, null, null)
created += uri
return uri to bucketOf(uri)
}
private fun assetOf(uri: Uri, bucket: String, name: String, mime: String): DeviceAssetEntity {
val id = ContentUris.parseId(uri)
return DeviceAssetEntity(
assetKey = DeviceAssetEntity.key(bucket, id),
deviceAlbumId = bucket,
mediaStoreId = id,
uri = uri.toString(),
displayName = name,
mimeType = mime,
size = sizeOf(uri),
dateModified = 0L,
contentHash = "hash",
serverPhotoId = "photo1",
uploadedAt = null,
state = LocalAssetState.AWAITING_FREE.name,
)
}
@Test
fun offloadReplacesOriginalWithPreviewAndStampsProvenance() = runBlocking {
val (uri, bucket) = insertImage(2000, 1500)
val asset = assetOf(uri, bucket, "orig.jpg", "image/jpeg")
db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L))
db.syncDao().upsertAssets(listOf(asset))
val result = OffloadManager(context, db.syncDao()).offload(asset, setOf("photo1"))
assertEquals(OffloadResult.Done, result)
// The file is still there but now a capped preview.
assertEquals(1600 to 1200, dimensionsOf(uri))
// Provenance travels with the file.
val marker = ProvenanceStamp.read(context, uri)
assertNotNull(marker)
assertEquals("photo1", marker?.serverPhotoId)
assertEquals("srvX", marker?.serverAlbumId)
assertEquals(LocalAssetState.PREVIEW.name, db.syncDao().getAsset(asset.assetKey)?.state)
}
@Test
fun offloadVideoReplacesItWithAPosterInTheSameFolder() = runBlocking {
val (uri, bucket) = insertVideo()
val asset = assetOf(uri, bucket, "clip.mp4", "video/mp4")
db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L))
db.syncDao().upsertAssets(listOf(asset))
val result = OffloadManager(context, db.syncDao()).offload(asset, setOf("photo1"))
assertEquals(OffloadResult.Done, result)
assertTrue("video row should be gone", !exists(uri))
val posters = mutableListOf<String>()
resolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
arrayOf(MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME),
"${MediaStore.Images.Media.RELATIVE_PATH} LIKE ?",
arrayOf("DCIM/GaloTest/%"),
null,
)!!.use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
posters += cursor.getString(1)
created += ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
}
}
assertTrue("expected a poster image", posters.any { it.endsWith("-preview.jpg") })
assertEquals(LocalAssetState.PREVIEW.name, db.syncDao().getAsset(asset.assetKey)?.state)
}
@Test
fun recoveryRebuildsLinkAndProvenanceFromMarkers() = runBlocking {
val (uri, bucket) = insertImage(1000, 800)
val asset = assetOf(uri, bucket, "orig.jpg", "image/jpeg")
val key = asset.assetKey
db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L))
assertEquals(OffloadResult.Done, OffloadManager(context, db.syncDao()).offload(asset, setOf("photo1")))
// Simulate a reinstall: the app's index is gone.
db.syncDao().clearAlbum(bucket)
db.syncDao().deleteSyncedAlbum(bucket)
val recovered = SyncRecovery(context, db.syncDao(), MediaStoreDeviceSource(context))
.recover(bucket)
assertTrue("expected at least one recovered asset", recovered >= 1)
assertEquals("photo1", db.syncDao().getAsset(key)?.serverPhotoId)
assertEquals("srvX", db.syncDao().getSyncedAlbum(bucket)?.serverAlbumId)
}
@Test
fun removingALostPreviewDeletesTheFileAndTheIndexRow() = runBlocking {
val (uri, bucket) = insertImage(800, 600)
val asset = assetOf(uri, bucket, "lost.jpg", "image/jpeg")
.copy(state = LocalAssetState.ORPHANED.name)
db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L))
db.syncDao().upsertAssets(listOf(asset))
val repository = SyncRepository(db.syncDao(), MediaStoreDeviceSource(context))
val removed = repository.removeDeviceAsset(asset.assetKey, uri)
assertTrue("the preview file should be gone", removed)
assertTrue("the media row should be gone", !exists(uri))
assertTrue("the index row should be gone", db.syncDao().getAsset(asset.assetKey) == null)
}
@Test
fun restoreWritesTheOriginalBackOverThePreview() = runBlocking {
val (uri, bucket) = insertImage(2400, 1800)
val asset = assetOf(uri, bucket, "orig.jpg", "image/jpeg")
db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L))
db.syncDao().upsertAssets(listOf(asset))
assertEquals(OffloadResult.Done, OffloadManager(context, db.syncDao()).offload(asset, setOf("photo1")))
assertEquals(1600 to 1200, dimensionsOf(uri))
val original = Bitmap.createBitmap(2400, 1800, Bitmap.Config.ARGB_8888)
val bytes = ByteArrayOutputStream().use { out ->
original.compress(Bitmap.CompressFormat.JPEG, 90, out)
out.toByteArray()
}
val fetcher = object : OriginalFetcher {
override suspend fun fetch(photoId: String): ByteArray = bytes
}
val report = RestoreManager(context, db.syncDao(), fetcher).restore(listOf(asset))
assertEquals(1, report.restored)
assertEquals(2400 to 1800, dimensionsOf(uri))
assertEquals(LocalAssetState.LOCAL.name, db.syncDao().getAsset(asset.assetKey)?.state)
}
}
@@ -0,0 +1,48 @@
package com.galo.android
import android.view.MotionEvent
import android.view.View
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.galo.android.ui.viewer.PanoramaView
import org.junit.Assert.assertNotEquals
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/**
* Device check for task 4.3: a drag on the GL panorama view rotates the camera
* (same yaw/pitch mapping as the web viewer).
*/
@RunWith(AndroidJUnit4::class)
class PanoramaDragTest {
@Test
fun dragRotatesCamera() {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val file = File(app.cacheDir, "equirect.jpg").apply { writeBytes(E2E.readAsset("equirect.jpg")) }
val url = file.toURI().toURL().toString()
lateinit var view: PanoramaView
InstrumentationRegistry.getInstrumentation().runOnMainSync {
val v = PanoramaView(app)
v.measure(
View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(1920, View.MeasureSpec.EXACTLY),
)
v.layout(0, 0, 1080, 1920)
v.load(url)
val down = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 100f, 200f, 0)
val move = MotionEvent.obtain(0, 16, MotionEvent.ACTION_MOVE, 500f, 200f, 0)
v.dispatchTouchEvent(down)
v.dispatchTouchEvent(move)
down.recycle()
move.recycle()
view = v
}
assertNotEquals("horizontal drag should change yaw", 0f, view.currentYawForTest())
}
}
@@ -0,0 +1,307 @@
package com.galo.android
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.galo.android.data.db.entity.DeviceAssetEntity
import com.galo.android.data.sync.ContentHash
import com.galo.android.di.AppContainer
import com.galo.android.domain.media.MediaPermissions
import com.galo.android.domain.model.LocalAssetState
import com.galo.android.domain.model.SyncMode
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runner.RunWith
import org.junit.runners.model.Statement
/**
* Every synchronization mode (A/B/C) and the switches between them. Small, fast
* and functional; the volume coverage lives in [SyncVolumeE2ETest]. Against a
* running galo instance at 10.0.2.2:8088.
*/
@RunWith(AndroidJUnit4::class)
class SyncModesE2ETest {
private val mediaPermissionRule: TestRule = object : TestRule {
override fun apply(base: Statement, description: Description): Statement = object : Statement() {
override fun evaluate() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val pkg = instrumentation.targetContext.packageName
MediaPermissions.required(Build.VERSION.SDK_INT).forEach { permission ->
runCatching { instrumentation.uiAutomation.grantRuntimePermission(pkg, permission) }
}
base.evaluate()
}
}
}
@get:Rule
val rules: TestRule = RuleChain.outerRule(mediaPermissionRule)
private lateinit var context: Context
private lateinit var resolver: ContentResolver
private val created = mutableListOf<Uri>()
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
resolver = context.contentResolver
}
@After
fun tearDown() {
created.forEach { runCatching { resolver.delete(it, null, null) } }
}
private data class One(val uri: Uri, val bucket: String, val key: String, val bytes: ByteArray)
private fun insertImage(folder: String): One {
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "mode-${System.nanoTime()}.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/$folder-${System.nanoTime()}/")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!!
val bitmap = Bitmap.createBitmap(320, 240, Bitmap.Config.ARGB_8888)
bitmap.eraseColor(android.graphics.Color.rgb(30, 90, 200))
resolver.openOutputStream(uri)!!.use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
bitmap.recycle()
values.clear()
values.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(uri, values, null, null)
created += uri
val bucket = resolver.query(uri, arrayOf(MediaStore.Images.Media.BUCKET_ID), null, null, null)!!
.use { cursor ->
cursor.moveToFirst()
cursor.getString(0)
}
val bytes = resolver.openInputStream(uri)!!.use { it.readBytes() }
return One(uri, bucket, DeviceAssetEntity.key(bucket, ContentUris.parseId(uri)), bytes)
}
private suspend fun awaitState(
container: AppContainer,
bucket: String,
key: String,
state: LocalAssetState,
timeoutMs: Long = 90_000,
): Boolean {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val now = container.syncRepository.assets(bucket).firstOrNull { it.assetKey == key }?.state
if (now == state.name) return true
delay(500)
}
return false
}
private suspend fun awaitPhotos(container: AppContainer, album: String, minimum: Int): Int {
val deadline = System.currentTimeMillis() + 90_000
while (System.currentTimeMillis() < deadline) {
val count = runCatching { container.syncRemote.serverPhotos(album).size }.getOrDefault(0)
if (count >= minimum) return count
delay(500)
}
return runCatching { container.syncRemote.serverPhotos(album).size }.getOrDefault(0)
}
private fun localHash(uri: Uri): String = TestMedia.hash(resolver, uri)
// --- Mode A ---------------------------------------------------------------
@Test
fun disabledModeIsLocalOnly() = runBlocking {
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
E2E.configure(container)
E2E.registerAuthenticated(container)
val item = insertImage("GaloModesA")
assertNull("a disabled album has no link", container.syncRepository.currentLink(item.bucket))
assertFalse("mode A must not contact the server", container.syncCoordinator.reconcile(item.bucket))
assertEquals("the local original is untouched", localHash(item.uri), ContentHash.sha256(item.bytes.inputStream()))
}
// --- Mode B ---------------------------------------------------------------
@Test
fun copyModeUploadsAndKeepsTheOriginal() = runBlocking {
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val album = E2E.createAlbum(token, "B ${System.currentTimeMillis()}")
val item = insertImage("GaloModesB")
container.syncRepository.link(item.bucket, "B", album, SyncMode.COPY)
assertTrue(container.syncCoordinator.reconcile(item.bucket))
assertTrue("photo should reach the server", awaitPhotos(container, album, 1) >= 1)
assertTrue("asset stays uploaded", awaitState(container, item.bucket, item.key, LocalAssetState.UPLOADED))
assertEquals("the original stays on the device", localHash(item.uri), ContentHash.sha256(item.bytes.inputStream()))
}
// --- Mode C ---------------------------------------------------------------
@Test
fun moveModeUploadsAndOffloads() = runBlocking {
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val album = E2E.createAlbum(token, "C ${System.currentTimeMillis()}")
val item = insertImage("GaloModesC")
container.syncRepository.link(item.bucket, "C", album, SyncMode.MOVE)
assertTrue(container.syncCoordinator.reconcile(item.bucket))
assertTrue("photo should reach the server", awaitPhotos(container, album, 1) >= 1)
assertTrue("the original is replaced by a preview", awaitState(container, item.bucket, item.key, LocalAssetState.PREVIEW))
}
// --- Switch B -> C --------------------------------------------------------
@Test
fun copyToMoveOffloadsExistingUploads() = runBlocking {
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val album = E2E.createAlbum(token, "B2C ${System.currentTimeMillis()}")
val item = insertImage("GaloModesB2C")
container.syncRepository.link(item.bucket, "B", album, SyncMode.COPY)
container.syncCoordinator.reconcile(item.bucket)
assertTrue(awaitPhotos(container, album, 1) >= 1)
assertTrue(awaitState(container, item.bucket, item.key, LocalAssetState.UPLOADED))
container.syncRepository.link(item.bucket, "B", album, SyncMode.MOVE)
container.syncCheck.checkGallery(item.bucket)
assertTrue("switching to move offloads the upload", awaitState(container, item.bucket, item.key, LocalAssetState.PREVIEW))
}
// --- Switch C -> B --------------------------------------------------------
@Test
fun moveToCopyRestoresTheOriginal() = runBlocking {
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val album = E2E.createAlbum(token, "C2B ${System.currentTimeMillis()}")
val item = insertImage("GaloModesC2B")
container.syncRepository.link(item.bucket, "C", album, SyncMode.MOVE)
container.syncCoordinator.reconcile(item.bucket)
assertTrue(awaitPhotos(container, album, 1) >= 1)
assertTrue(awaitState(container, item.bucket, item.key, LocalAssetState.PREVIEW))
container.syncRepository.link(item.bucket, "C", album, SyncMode.COPY)
container.syncCheck.checkGallery(item.bucket)
assertTrue("the original is restored locally", awaitState(container, item.bucket, item.key, LocalAssetState.LOCAL))
assertEquals("the restored file is the original bytes", localHash(item.uri), ContentHash.sha256(item.bytes.inputStream()))
}
// --- Switch to A (off), regression ---------------------------------------
@Test
fun moveToOffStaysOffAfterRecovery() = runBlocking {
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val album = E2E.createAlbum(token, "C2A ${System.currentTimeMillis()}")
val item = insertImage("GaloModesC2A")
container.syncRepository.link(item.bucket, "C", album, SyncMode.MOVE)
container.syncCoordinator.reconcile(item.bucket)
assertTrue(awaitPhotos(container, album, 1) >= 1)
assertTrue(awaitState(container, item.bucket, item.key, LocalAssetState.PREVIEW))
container.syncRepository.unlink(item.bucket)
assertNull(container.syncRepository.currentLink(item.bucket))
container.syncCheck.checkGallery(item.bucket, recoverIndex = true)
assertNull("turning off must be sticky", container.syncRepository.currentLink(item.bucket))
assertTrue(container.syncRepository.isSyncDisabled(item.bucket))
}
@Test
fun copyToOffStaysOffAfterRecovery() = runBlocking {
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val album = E2E.createAlbum(token, "B2A ${System.currentTimeMillis()}")
val item = insertImage("GaloModesB2A")
container.syncRepository.link(item.bucket, "B", album, SyncMode.COPY)
container.syncCoordinator.reconcile(item.bucket)
assertTrue(awaitPhotos(container, album, 1) >= 1)
container.syncRepository.unlink(item.bucket)
container.syncCheck.checkGallery(item.bucket, recoverIndex = true)
assertNull(container.syncRepository.currentLink(item.bucket))
assertTrue(container.syncRepository.isSyncDisabled(item.bucket))
}
// --- Switch off -> B (re-enable) -----------------------------------------
@Test
fun offToCopyUploadsAgain() = runBlocking {
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val album = E2E.createAlbum(token, "A2B ${System.currentTimeMillis()}")
val item = insertImage("GaloModesA2B")
container.syncRepository.unlink(item.bucket)
assertTrue(container.syncRepository.isSyncDisabled(item.bucket))
container.syncRepository.link(item.bucket, "B", album, SyncMode.COPY)
assertFalse(container.syncRepository.isSyncDisabled(item.bucket))
container.syncCoordinator.reconcile(item.bucket)
assertTrue("re-enabled sync uploads again", awaitPhotos(container, album, 1) >= 1)
}
// --- Reinstall-style recovery for a genuine setup -------------------------
@Test
fun recoveryRebuildsLinkWhenSyncWasNotTurnedOff() = runBlocking {
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val album = E2E.createAlbum(token, "Recover ${System.currentTimeMillis()}")
val item = insertImage("GaloModesRecover")
container.syncRepository.link(item.bucket, "C", album, SyncMode.MOVE)
container.syncCoordinator.reconcile(item.bucket)
assertTrue(awaitPhotos(container, album, 1) >= 1)
assertTrue(awaitState(container, item.bucket, item.key, LocalAssetState.PREVIEW))
container.syncRepository.forgetAlbum(item.bucket)
assertNull(container.syncRepository.currentLink(item.bucket))
val recovered = container.syncRecovery.recover(item.bucket)
assertTrue("markers must rebuild the link", recovered >= 1)
assertNotNull(container.syncRepository.currentLink(item.bucket))
}
}
@@ -0,0 +1,236 @@
package com.galo.android
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.galo.android.data.db.entity.DeviceAssetEntity
import com.galo.android.data.sync.ContentHash
import com.galo.android.data.sync.ProvenanceStamp
import com.galo.android.di.AppContainer
import com.galo.android.domain.media.MediaPermissions
import com.galo.android.domain.model.AssetOrigin
import com.galo.android.domain.model.LocalAssetState
import com.galo.android.domain.model.SyncMode
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runner.RunWith
import org.junit.runners.model.Statement
/**
* Dedicated volume coverage: 100 photos and 20 videos per test, both directions.
*
* - upload: the whole device album reaches the server through the drain queue;
* - materialize: a server album of the same size lands on the device (previews in
* move mode; server videos become posters).
*
* This suite is slow (every file is one HTTP request) — it intentionally lives
* apart from the fast functional tests. Against a running galo at 10.0.2.2:8088.
*/
@RunWith(AndroidJUnit4::class)
class SyncVolumeE2ETest {
private val mediaPermissionRule: TestRule = object : TestRule {
override fun apply(base: Statement, description: Description): Statement = object : Statement() {
override fun evaluate() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val pkg = instrumentation.targetContext.packageName
MediaPermissions.required(Build.VERSION.SDK_INT).forEach { permission ->
runCatching { instrumentation.uiAutomation.grantRuntimePermission(pkg, permission) }
}
base.evaluate()
}
}
}
@get:Rule
val rules: TestRule = RuleChain.outerRule(mediaPermissionRule)
private lateinit var context: Context
private lateinit var resolver: ContentResolver
private val created = mutableListOf<Uri>()
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
resolver = context.contentResolver
}
@After
fun tearDown() {
created.forEach { runCatching { resolver.delete(it, null, null) } }
}
private val total = TestMedia.PHOTO_COUNT + TestMedia.VIDEO_COUNT
private fun anchor(folder: String): String {
val values = android.content.ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "anchor-${System.nanoTime()}.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/$folder-${System.nanoTime()}/")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!!
android.graphics.Bitmap.createBitmap(96, 96, android.graphics.Bitmap.Config.ARGB_8888).let {
// A colour nothing else uses, so the anchor never dedups with a server photo.
it.eraseColor(android.graphics.Color.rgb(7, 200, 13))
resolver.openOutputStream(uri)!!.use { out -> it.compress(android.graphics.Bitmap.CompressFormat.JPEG, 85, out) }
it.recycle()
}
values.clear()
values.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(uri, values, null, null)
created += uri
return resolver.query(uri, arrayOf(MediaStore.Images.Media.BUCKET_ID), null, null, null)!!
.use { cursor ->
cursor.moveToFirst()
cursor.getString(0)
}
}
private fun countLocal(bucket: String, collection: Uri, prefix: String): Int =
resolver.query(
collection,
arrayOf(MediaStore.MediaColumns._ID),
"${MediaStore.MediaColumns.BUCKET_ID} = ? AND ${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ?",
arrayOf(bucket, "$prefix%"),
null,
)?.use { it.count } ?: 0
private fun localUri(bucket: String, collection: Uri, name: String): Uri? =
resolver.query(
collection,
arrayOf(MediaStore.MediaColumns._ID),
"${MediaStore.MediaColumns.BUCKET_ID} = ? AND ${MediaStore.MediaColumns.DISPLAY_NAME} = ?",
arrayOf(bucket, name),
null,
)?.use { cursor ->
if (cursor.moveToFirst()) {
android.content.ContentUris.withAppendedId(collection, cursor.getLong(0))
} else {
null
}
}
private suspend fun awaitServerCount(container: AppContainer, album: String, minimum: Int): Int {
val deadline = System.currentTimeMillis() + 900_000
var last = -1
while (System.currentTimeMillis() < deadline) {
last = runCatching { container.syncRemote.serverPhotos(album).size }.getOrDefault(-1)
if (last >= minimum) return last
delay(1_000)
}
return last
}
private suspend fun awaitState(
container: AppContainer,
bucket: String,
key: String,
state: LocalAssetState,
): Boolean {
val deadline = System.currentTimeMillis() + 600_000
while (System.currentTimeMillis() < deadline) {
if (container.syncRepository.assets(bucket).firstOrNull { it.assetKey == key }?.state == state.name) return true
delay(1_000)
}
return false
}
private suspend fun materializeUntil(
container: AppContainer,
bucket: String,
timeoutMs: Long = 480_000,
done: () -> Boolean,
) {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
container.syncCoordinator.reconcile(bucket)
if (done()) return
delay(2_000)
}
}
@Test
fun copyUploadsTheWholeDeviceAlbum() = runBlocking {
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val album = E2E.createAlbum(token, "VolUp ${System.currentTimeMillis()}")
val seeded = TestMedia.seed(resolver, "GaloVolUp")
created += seeded.uris
val bucket = seeded.bucket(resolver)
val first = seeded.photos.first()
val key = DeviceAssetEntity.key(bucket, android.content.ContentUris.parseId(first))
val bytes = resolver.openInputStream(first)!!.use { it.readBytes() }
container.syncRepository.link(bucket, "VolUp", album, SyncMode.COPY)
assertTrue(container.syncCoordinator.reconcile(bucket))
assertEquals("all media must reach the server", total, awaitServerCount(container, album, total))
assertTrue("assets stay uploaded", awaitState(container, bucket, key, LocalAssetState.UPLOADED))
assertEquals(
"the original stays on the device",
ContentHash.sha256(bytes.inputStream()),
TestMedia.hash(resolver, first),
)
}
@Test
fun copyMaterializesServerVolume() = runBlocking {
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val album = E2E.createAlbum(token, "VolDown ${System.currentTimeMillis()}")
val bucket = anchor("GaloVolDown")
TestMedia.uploadServerMedia(token, album)
container.syncRepository.link(bucket, "VolDown", album, SyncMode.COPY, mirrorFromCreatedAt = 0L)
val images = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val videos = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
val deadline = System.currentTimeMillis() + 480_000
var pass = 0
while (System.currentTimeMillis() < deadline) {
container.syncCoordinator.reconcile(bucket)
val p = countLocal(bucket, images, "srv-photo-")
val v = countLocal(bucket, videos, "srv-video-")
android.util.Log.i(
"VolTest",
"pass=${pass++} photos=$p videos=$v server=${runCatching { container.syncRemote.serverPhotos(album).size }.getOrDefault(-1)}",
)
if (p >= TestMedia.PHOTO_COUNT && v >= TestMedia.VIDEO_COUNT) break
delay(2_000)
}
assertTrue(
"all server photos must be materialized",
countLocal(bucket, images, "srv-photo-") >= TestMedia.PHOTO_COUNT,
)
assertTrue(
"all server videos must be materialized",
countLocal(bucket, videos, "srv-video-") >= TestMedia.VIDEO_COUNT,
)
val sample = localUri(bucket, images, "srv-photo-000.jpg")
assertTrue("a sample server photo must exist locally", sample != null)
assertTrue("materialized sample must be a mirror", ProvenanceStamp.read(context, sample!!)?.origin == AssetOrigin.MIRROR.name)
val mirrored = container.syncRepository.assets(bucket)
.count { it.origin == AssetOrigin.MIRROR.name }
assertTrue("materialized items must be recorded as mirrors", mirrored >= TestMedia.PHOTO_COUNT)
}
}
@@ -0,0 +1,97 @@
package com.galo.android
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.galo.android.domain.model.SyncMode
import com.galo.android.ui.devicealbum.DeviceAlbumViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/**
* Choosing "copies on the server" must offer the server albums as targets, and
* picking one must persist the link (mode + target).
*/
@RunWith(AndroidJUnit4::class)
class TargetSelectionTest {
@Test
fun choosingCopyThenTargetPersistsTheLink() = runBlocking { val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val serverAlbum = E2E.createAlbum(token, "Target ${System.currentTimeMillis()}")
val bucket = "target-${System.currentTimeMillis()}"
val viewModel = DeviceAlbumViewModel(container, bucket)
var targetsLoaded = false
val collector = launch {
viewModel.state.collect { state ->
if (state.serverAlbums.any { it.id == serverAlbum }) targetsLoaded = true
}
}
try {
waitFor("server albums offered") { targetsLoaded }
viewModel.setMode(SyncMode.COPY)
waitFor("mode persisted") {
container.syncRepository.currentLink(bucket)?.let { SyncMode.from(it.mode) } == SyncMode.COPY
}
viewModel.setTarget(serverAlbum)
waitFor("target persisted") {
container.syncRepository.currentLink(bucket)?.serverAlbumId == serverAlbum
}
val link = container.syncRepository.currentLink(bucket)
assertEquals(SyncMode.COPY, link?.let { SyncMode.from(it.mode) })
assertEquals(serverAlbum, link?.serverAlbumId)
assertTrue(targetsLoaded)
} finally {
collector.cancel()
container.syncRepository.unlink(bucket)
}
}
@Test
fun creatingANewAlbumLinksAndSyncs() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
E2E.configure(container)
E2E.registerAuthenticated(container)
val bucket = "create-${System.currentTimeMillis()}"
val viewModel = DeviceAlbumViewModel(container, bucket)
val collector = launch { viewModel.state.collect { } }
try {
viewModel.setMode(SyncMode.COPY)
val title = "Созданный ${System.currentTimeMillis()}"
viewModel.createAlbum(title)
waitFor("link created") {
container.syncRepository.currentLink(bucket)?.serverAlbumId?.isNotBlank() == true
}
val link = container.syncRepository.currentLink(bucket)
assertEquals(SyncMode.COPY, link?.let { SyncMode.from(it.mode) })
assertTrue("created album should exist on the server", container.api.albums().any { it.title == title })
} finally {
collector.cancel()
container.syncRepository.unlink(bucket)
}
}
private suspend fun waitFor(what: String, condition: suspend () -> Boolean) { val deadline = System.currentTimeMillis() + 8_000
while (System.currentTimeMillis() < deadline) {
if (condition()) return
delay(100)
}
throw AssertionError("timed out waiting for: $what")
}
}
@@ -0,0 +1,117 @@
package com.galo.android
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import android.provider.MediaStore
import com.galo.android.data.sync.ContentHash
import java.io.ByteArrayOutputStream
/**
* Shared media seeding for the dedicated volume test. Content is made unique per
* index so the checksum-based deduplication does not collapse it.
*/
object TestMedia {
const val PHOTO_COUNT = 100
const val VIDEO_COUNT = 20
/** A unique JPEG for [index] (distinct pixel colour, so distinct checksum). */
fun jpeg(index: Int, size: Int = 96): ByteArray {
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val r = (index * 7) % 256
val g = (index * 13) % 256
val b = (index * 29) % 256
bitmap.eraseColor(Color.rgb(r, g, b))
val out = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, out)
bitmap.recycle()
return out.toByteArray()
}
/** A unique video for [index] (bundled asset, distinct bytes each). */
fun video(index: Int): ByteArray {
val name = "video-%02d.mp4".format(index.coerceIn(1, VIDEO_COUNT))
return androidx.test.platform.app.InstrumentationRegistry.getInstrumentation()
.context.assets.open(name).use { input -> input.readBytes() }
}
data class Seeded(val photos: List<Uri>, val videos: List<Uri>) {
val uris: List<Uri> get() = photos + videos
fun bucket(resolver: ContentResolver): String =
resolver.query(photos.first(), arrayOf(MediaStore.Images.Media.BUCKET_ID), null, null, null)!!
.use { cursor ->
cursor.moveToFirst()
cursor.getString(0)
}
}
/** Inserts [PHOTO_COUNT] photos and [VIDEO_COUNT] videos into DCIM/[folder]. */
fun seed(resolver: ContentResolver, folder: String): Seeded {
// A unique bucket per call: leftovers from interrupted runs never leak in.
val path = "DCIM/$folder-${System.nanoTime()}/"
val photos = (0 until PHOTO_COUNT).map { index ->
insertImage(resolver, path, "photo-%03d.jpg".format(index), jpeg(index))
}
val videos = (1..VIDEO_COUNT).map { index ->
insertVideo(resolver, path, "video-%02d.mp4".format(index), video(index))
}
return Seeded(photos, videos)
}
private fun insertImage(
resolver: ContentResolver,
relativePath: String,
name: String,
bytes: ByteArray,
): Uri {
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, name)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.RELATIVE_PATH, relativePath)
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!!
resolver.openOutputStream(uri)!!.use { it.write(bytes) }
values.clear()
values.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(uri, values, null, null)
return uri
}
private fun insertVideo(
resolver: ContentResolver,
relativePath: String,
name: String,
bytes: ByteArray,
): Uri {
val values = ContentValues().apply {
put(MediaStore.Video.Media.DISPLAY_NAME, name)
put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
put(MediaStore.Video.Media.RELATIVE_PATH, relativePath)
put(MediaStore.Video.Media.IS_PENDING, 1)
}
val uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values)!!
resolver.openOutputStream(uri)!!.use { it.write(bytes) }
values.clear()
values.put(MediaStore.Video.Media.IS_PENDING, 0)
resolver.update(uri, values, null, null)
return uri
}
fun hash(resolver: ContentResolver, uri: Uri): String =
resolver.openInputStream(uri)!!.use { ContentHash.sha256(it) }
/** Uploads the full volume (photos + videos) straight to [album] on the server. */
fun uploadServerMedia(token: String, album: String) {
for (i in 0 until PHOTO_COUNT) {
E2E.upload(token, album, "srv-photo-%03d.jpg".format(i), jpeg(i), "image/jpeg")
}
for (i in 1..VIDEO_COUNT) {
E2E.upload(token, album, "srv-video-%02d.mp4".format(i), video(i), "video/mp4")
}
}
}
@@ -0,0 +1,306 @@
package com.galo.android
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.exifinterface.media.ExifInterface
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.galo.android.data.sync.ProvenanceStamp
import com.galo.android.domain.media.MediaPermissions
import com.galo.android.domain.model.SyncMode
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runner.RunWith
import org.junit.runners.model.Statement
import java.io.File
/**
* End-to-end two-way synchronization: a photo added to the target server album
* (as the web UI would) is materialized into the device album's MediaStore bucket
* with provenance, and deletions propagate both ways. Small and fast; volume
* coverage lives in [SyncVolumeE2ETest]. Against a running galo at 10.0.2.2:8088.
*/
@RunWith(AndroidJUnit4::class)
class TwoWayMirrorE2ETest {
private val mediaPermissionRule: TestRule = object : TestRule {
override fun apply(base: Statement, description: Description): Statement = object : Statement() {
override fun evaluate() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val pkg = instrumentation.targetContext.packageName
MediaPermissions.required(Build.VERSION.SDK_INT).forEach { permission ->
runCatching { instrumentation.uiAutomation.grantRuntimePermission(pkg, permission) }
}
base.evaluate()
}
}
}
@get:Rule
val rules: TestRule = RuleChain.outerRule(mediaPermissionRule)
private lateinit var context: Context
private lateinit var resolver: ContentResolver
private val created = mutableListOf<Uri>()
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
resolver = context.contentResolver
}
@After
fun tearDown() {
created.forEach { runCatching { resolver.delete(it, null, null) } }
}
/** Anchors the album bucket with one image and returns its bucket id. */
private fun insertAnchor(): String {
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "anchor-${System.nanoTime()}.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/GaloTwoWay-${System.nanoTime()}/")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!!
val bitmap = Bitmap.createBitmap(160, 120, Bitmap.Config.ARGB_8888)
bitmap.eraseColor(android.graphics.Color.rgb(7, 200, 13))
resolver.openOutputStream(uri)!!.use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
bitmap.recycle()
values.clear()
values.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(uri, values, null, null)
created += uri
return resolver.query(uri, arrayOf(MediaStore.Images.Media.BUCKET_ID), null, null, null)!!
.use { cursor ->
cursor.moveToFirst()
cursor.getString(0)
}
}
/** JPEG with a capture date, so the server reports `taken_at`. */
private fun datedJpeg(): ByteArray {
val file = File(context.cacheDir, "web-${System.nanoTime()}.jpg")
file.writeBytes(E2E.jpegBytes(128))
ExifInterface(file).apply {
setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, "2021:05:05 10:00:00")
saveAttributes()
}
val bytes = file.readBytes()
file.delete()
return bytes
}
private fun awaitLocalFile(bucket: String, name: String, timeoutMs: Long = 60_000): Uri? {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val uri = queryLocal(bucket, name)
if (uri != null) {
if (uri !in created) created += uri
return uri
}
Thread.sleep(300)
}
return null
}
private fun queryLocal(bucket: String, name: String): Uri? =
resolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
arrayOf(MediaStore.Images.Media._ID),
"${MediaStore.Images.Media.BUCKET_ID} = ? AND ${MediaStore.Images.Media.DISPLAY_NAME} = ?",
arrayOf(bucket, name),
null,
)?.use { cursor ->
if (cursor.moveToFirst()) {
ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
cursor.getLong(0),
)
} else {
null
}
}
private fun dateTakenOf(uri: Uri): Long =
resolver.query(uri, arrayOf(MediaStore.Images.Media.DATE_TAKEN), null, null, null)!!
.use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else 0L }
@Test
fun webPhotoIsMaterializedWithProvenanceAndDate() = runBlocking {
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val album = E2E.createAlbum(token, "TwoWay ${System.currentTimeMillis()}")
val bucket = insertAnchor()
E2E.upload(token, album, "web.jpg", datedJpeg(), "image/jpeg")
val serverPhoto = E2E.listPhotos(token, album).first { it.originalFilename == "web.jpg" }
container.syncRepository.link(bucket, "TwoWay", album, SyncMode.COPY, mirrorFromCreatedAt = 0L)
container.syncCoordinator.reconcile(bucket)
val local = awaitLocalFile(bucket, "web.jpg")
assertNotNull("the server photo must be materialized into the album bucket", local)
val marker = ProvenanceStamp.read(context, local!!)
assertNotNull("materialized file must carry a provenance marker", marker)
assertTrue("origin must be MIRROR", marker!!.origin == "MIRROR")
assertTrue("provenance must point at the server photo", marker.serverPhotoId == serverPhoto.id)
val exifDate = runCatching {
resolver.openInputStream(local).use {
if (it == null) null else ExifInterface(it).getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)
}
}.getOrNull()
if (!serverPhoto.takenAt.isNullOrBlank()) {
assertTrue(
"capture date must be preserved on the device",
dateTakenOf(local) > 0L || !exifDate.isNullOrBlank(),
)
}
}
@Test
fun deletingTheLocalCopyKeepsTheServerPhotoAndRecreates() = runBlocking {
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val album = E2E.createAlbum(token, "TwoWay del ${System.currentTimeMillis()}")
val bucket = insertAnchor()
E2E.upload(token, album, "web.jpg", E2E.jpegBytes(128), "image/jpeg")
val serverPhoto = E2E.listPhotos(token, album).first { it.originalFilename == "web.jpg" }
container.syncRepository.link(bucket, "TwoWay", album, SyncMode.COPY, mirrorFromCreatedAt = 0L)
container.syncCoordinator.reconcile(bucket)
val local = awaitLocalFile(bucket, "web.jpg")
assertNotNull(local)
// The user deletes the downloaded copy from the gallery. The server photo
// is not this client's upload, so it must survive; the local copy returns.
resolver.delete(local!!, null, null)
container.syncCoordinator.reconcile(bucket)
assertTrue(
"a lost local mirror must not delete a server photo",
runCatching { container.syncRemote.serverPhotos(album) }.getOrDefault(emptyList())
.any { it.id == serverPhoto.id },
)
assertNotNull("the materialized copy must be recreated", awaitLocalFile(bucket, "web.jpg"))
}
@Test
fun deletingOnTheServerRemovesTheLocalCopy() = runBlocking {
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val album = E2E.createAlbum(token, "TwoWay srv ${System.currentTimeMillis()}")
val bucket = insertAnchor()
E2E.upload(token, album, "web.jpg", E2E.jpegBytes(128), "image/jpeg")
val serverPhoto = E2E.listPhotos(token, album).first { it.originalFilename == "web.jpg" }
container.syncRepository.link(bucket, "TwoWay", album, SyncMode.COPY, mirrorFromCreatedAt = 0L)
container.syncCoordinator.reconcile(bucket)
val local = awaitLocalFile(bucket, "web.jpg")
assertNotNull(local)
E2E.deletePhoto(token, serverPhoto.id)
var remaining: Uri? = queryLocal(bucket, "web.jpg")
val deadline = System.currentTimeMillis() + 30_000
while (System.currentTimeMillis() < deadline && remaining != null) {
container.syncCoordinator.reconcile(bucket)
remaining = queryLocal(bucket, "web.jpg")
if (remaining != null) delay(300)
}
assertNull("a server deletion must remove the local materialized file", remaining)
}
@Test
fun moveMaterializesVideoAsPoster() = runBlocking {
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val album = E2E.createAlbum(token, "TwoWay vid ${System.currentTimeMillis()}")
val bucket = insertAnchor()
E2E.upload(token, album, "vid.mp4", TestMedia.video(1), "video/mp4")
container.syncRepository.link(bucket, "TwoWay", album, SyncMode.MOVE, mirrorFromCreatedAt = 0L)
var local = queryLocal(bucket, "vid-preview.jpg")
val deadline = System.currentTimeMillis() + 60_000
while (System.currentTimeMillis() < deadline && local == null) {
container.syncCoordinator.reconcile(bucket)
local = queryLocal(bucket, "vid-preview.jpg")
if (local != null) created += local
delay(500)
}
assertNotNull("a server video must become a poster in move mode", local)
val mirrored = container.syncRepository.assets(bucket)
.any { it.origin == "MIRROR" && it.state == "PREVIEW" }
assertTrue("the poster must be recorded as a mirror preview", mirrored)
}
@Test
fun explicitDeleteOfAMirrorRemovesTheServerPhoto() = runBlocking {
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val album = E2E.createAlbum(token, "TwoWay exp ${System.currentTimeMillis()}")
val bucket = insertAnchor()
E2E.upload(token, album, "web.jpg", E2E.jpegBytes(128), "image/jpeg")
val serverPhoto = E2E.listPhotos(token, album).first { it.originalFilename == "web.jpg" }
container.syncRepository.link(bucket, "TwoWay", album, SyncMode.COPY, mirrorFromCreatedAt = 0L)
container.syncCoordinator.reconcile(bucket)
val local = awaitLocalFile(bucket, "web.jpg")
assertNotNull(local)
// An explicit in-app deletion: mark the intent and remove the local copy.
container.syncRepository.markExplicitDelete(bucket, serverPhoto.id)
resolver.delete(local!!, null, null)
container.syncCoordinator.reconcile(bucket)
assertTrue(
"an explicit in-app deletion must remove the server photo",
awaitServerPhotoGone(container, album, serverPhoto.id),
)
}
private suspend fun awaitServerPhotoGone(
container: com.galo.android.di.AppContainer,
album: String,
photoId: String,
): Boolean {
val deadline = System.currentTimeMillis() + 20_000
while (System.currentTimeMillis() < deadline) {
val alive = runCatching { container.syncRemote.serverPhotos(album) }.getOrDefault(emptyList())
.any { it.id == photoId }
if (!alive) return true
delay(300)
}
return runCatching { container.syncRemote.serverPhotos(album) }.getOrDefault(emptyList())
.none { it.id == photoId }
}
}
@@ -0,0 +1,102 @@
package com.galo.android
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.galo.android.di.AppContainer
import com.galo.android.domain.model.Photo
import com.galo.android.domain.model.UploadStatus
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/**
* On-device checks for the upload pipeline and offline cache:
* 6.4 (upload completes without a visible UI), 6.5 (queued work waits for
* network and resumes) and 3.5 (album opens from cache while offline).
*/
@RunWith(AndroidJUnit4::class)
class UploadPipelineTest {
private fun container(): AppContainer =
ApplicationProvider.getApplicationContext<GaloApplication>().container
private fun awaitPhoto(container: AppContainer, albumId: String, timeoutMs: Long): Photo {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val photos = runBlocking { container.photoRepository.observePhotos(albumId).first() }
if (photos.isNotEmpty()) return photos.first()
val job = runBlocking { container.uploadRepository.observeJobs().first().firstOrNull() }
if (job?.status == UploadStatus.ERROR) error("upload job failed: ${job.error}")
Thread.sleep(500)
}
error("upload did not complete within ${timeoutMs}ms")
}
@Test
fun uploadCompletesWithoutVisibleUi() {
val container = container()
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val album = E2E.createAlbum(token, "bg-${System.currentTimeMillis()}")
val uri = E2E.writeTempFile("bg.jpg", E2E.jpegBytes())
runBlocking { container.uploadRepository.enqueue(album, listOf(uri), wifiOnly = false) }
val photo = awaitPhoto(container, album, 60_000)
assertNotNull(photo.id)
}
@Test
fun queuedJobWaitsForNetworkThenResumes() {
val container = container()
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val album = E2E.createAlbum(token, "net-${System.currentTimeMillis()}")
E2E.setAirplane(true)
try {
val uri = E2E.writeTempFile("net.jpg", E2E.jpegBytes())
runBlocking { container.uploadRepository.enqueue(album, listOf(uri), wifiOnly = false) }
Thread.sleep(6_000)
val status = runBlocking { container.uploadRepository.observeJobs().first() }.first().status
assertTrue("job should stay queued without network", status == UploadStatus.QUEUED)
} finally {
E2E.setAirplane(false)
}
val photo = awaitPhoto(container, album, 60_000)
assertNotNull(photo.id)
}
@Test
fun offlineBrowsingServesCache() {
val container = container()
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val album = E2E.createAlbum(token, "cache-${System.currentTimeMillis()}")
E2E.upload(token, album, "cached.jpg", E2E.jpegBytes(), "image/jpeg")
// Warm the cache while online.
runBlocking { container.albumRepository.refresh() }
runBlocking { container.photoRepository.loadNextPage(album, null) }
assertTrue(runBlocking { container.albumRepository.observeAlbums().first().any { it.id == album } })
assertTrue(runBlocking { container.photoRepository.observePhotos(album).first().isNotEmpty() })
E2E.setAirplane(true)
try {
assertFalse("refresh must fail offline", runBlocking { container.albumRepository.refresh() })
val page = runBlocking { container.photoRepository.loadNextPage(album, null) }
assertFalse("page fetch must report offline", page.online)
assertTrue(
"cached photos must remain available offline",
runBlocking { container.photoRepository.observePhotos(album).first().isNotEmpty() },
)
} finally {
E2E.setAirplane(false)
}
}
}
@@ -0,0 +1,63 @@
package com.galo.android
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* Device check for task 4.2: Media3 prepares and seeks a server-served video,
* exercising HTTP Range/seek end to end (server responds 206 Partial Content).
*/
@RunWith(AndroidJUnit4::class)
class VideoSeekTest {
@Test
fun playsAndSeeksServerVideo() {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
E2E.configure(app.container)
val token = E2E.register()
val album = E2E.createAlbum(token, "video-${System.currentTimeMillis()}")
E2E.upload(token, album, "test.mp4", E2E.readAsset("test.mp4"), "video/mp4")
val photo = E2E.awaitPhotoWithPlayback(token, album, 60_000)
val url = E2E.absolute(photo.playbackUrl!!)
val instrumentation = InstrumentationRegistry.getInstrumentation()
val ready = CountDownLatch(1)
var player: ExoPlayer? = null
instrumentation.runOnMainSync {
val p = ExoPlayer.Builder(app).build()
player = p
p.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_READY) ready.countDown()
}
})
p.setMediaItem(MediaItem.fromUri(url))
p.prepare()
}
assertTrue("player did not reach STATE_READY", ready.await(30, TimeUnit.SECONDS))
instrumentation.runOnMainSync { player!!.seekTo(1_200) }
var position = 0L
val deadline = System.currentTimeMillis() + 10_000
while (System.currentTimeMillis() < deadline && position < 1_000) {
instrumentation.runOnMainSync { position = player!!.currentPosition }
Thread.sleep(150)
}
instrumentation.runOnMainSync { player!!.release() }
assertTrue("seek did not reach target (position=$position)", position >= 1_000)
}
}
@@ -0,0 +1,67 @@
package com.galo.android
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.doubleClick
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.pinch
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.galo.android.ui.theme.GaloTheme
import com.galo.android.ui.viewer.TAG_ZOOMABLE_IMAGE
import com.galo.android.ui.viewer.ZoomableImage
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.atomic.AtomicBoolean
/**
* Device check for the photo zoom: a real pinch enlarges the image and a double
* tap returns it to the fitted view (the 200% cap and pan clamp are covered by
* the unit tests for `PhotoZoom`).
*/
@RunWith(AndroidJUnit4::class)
class ZoomGestureTest {
@get:Rule
val compose = createComposeRule()
@Test
fun pinchZoomsAndDoubleTapResets() {
val zoomed = AtomicBoolean(false)
compose.setContent {
GaloTheme(dynamicColor = false) {
Box(Modifier.fillMaxSize()) {
ZoomableImage(
previewUrl = null,
hiResUrl = null,
contentDescription = null,
naturalWidth = 2000,
naturalHeight = 1000,
onZoomChanged = { zoomed.set(it) },
)
}
}
}
compose.onNodeWithTag(TAG_ZOOMABLE_IMAGE).performTouchInput {
pinch(
start0 = center + Offset(-60f, 0f),
end0 = center + Offset(-260f, 0f),
start1 = center + Offset(60f, 0f),
end1 = center + Offset(260f, 0f),
)
}
compose.waitForIdle()
assertTrue("pinch should zoom the image", zoomed.get())
compose.onNodeWithTag(TAG_ZOOMABLE_IMAGE).performTouchInput { doubleClick() }
compose.waitForIdle()
assertFalse("double tap should return to the fitted view", zoomed.get())
}
}
+63
View File
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Device albums (MediaStore): read images/videos to enumerate albums and
read files. Legacy permission covers Android 12 and lower. -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- Lets mode C replace originals in the gallery without the per-batch OS
write-consent dialog. Granted by the user in system settings. -->
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!--
Cleartext HTTP is gated in the application layer (see HttpGateway): the
platform permits it, but the client refuses plain HTTP until the user
explicitly enables the insecure LAN mode.
-->
<application
android:name=".GaloApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Galo"
tools:targetApi="35">
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:theme="@style/Theme.Galo">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
</application>
</manifest>
@@ -0,0 +1,50 @@
package com.galo.android
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.decode.VideoFrameDecoder
import com.galo.android.di.AppContainer
class GaloApplication : Application(), ImageLoaderFactory {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
container = AppContainer(this).also { it.start() }
createNotificationChannels()
}
/**
* Coil loader that can also render video frames, so device-album covers and
* grid thumbnails work for albums that contain only videos.
*/
override fun newImageLoader(): ImageLoader =
ImageLoader.Builder(this)
.components { add(VideoFrameDecoder.Factory()) }
.crossfade(true)
.build()
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
UPLOAD_CHANNEL_ID,
getString(R.string.upload_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = getString(R.string.upload_channel_description)
}
manager.createNotificationChannel(channel)
}
companion object {
const val UPLOAD_CHANNEL_ID = "galo-uploads"
}
}
@@ -0,0 +1,44 @@
package com.galo.android.data.auth
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
/**
* Persists tokens in Keystore-backed [EncryptedSharedPreferences] so they never
* land in plain SharedPreferences.
*/
class SecureTokenStore(context: Context) : TokenStore {
private val prefs by lazy {
val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
EncryptedSharedPreferences.create(
FILE_NAME,
masterKey,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
override fun accessToken(): String? = prefs.getString(KEY_ACCESS, null)
override fun refreshToken(): String? = prefs.getString(KEY_REFRESH, null)
override fun save(accessToken: String, refreshToken: String) {
prefs.edit()
.putString(KEY_ACCESS, accessToken)
.putString(KEY_REFRESH, refreshToken)
.apply()
}
override fun clear() {
prefs.edit().clear().apply()
}
private companion object {
const val FILE_NAME = "galo_session"
const val KEY_ACCESS = "access_token"
const val KEY_REFRESH = "refresh_token"
}
}
@@ -0,0 +1,30 @@
package com.galo.android.data.auth
/** Token persistence abstraction; the production implementation is Keystore-backed. */
interface TokenStore {
fun accessToken(): String?
fun refreshToken(): String?
fun save(accessToken: String, refreshToken: String)
fun clear()
}
class InMemoryTokenStore : TokenStore {
@Volatile
private var access: String? = null
@Volatile
private var refresh: String? = null
override fun accessToken(): String? = access
override fun refreshToken(): String? = refresh
override fun save(accessToken: String, refreshToken: String) {
access = accessToken
refresh = refreshToken
}
override fun clear() {
access = null
refresh = null
}
}
@@ -0,0 +1,98 @@
package com.galo.android.data.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.galo.android.data.db.dao.AlbumDao
import com.galo.android.data.db.dao.PhotoDao
import com.galo.android.data.db.dao.SyncDao
import com.galo.android.data.db.dao.UploadDao
import com.galo.android.data.db.entity.AlbumEntity
import com.galo.android.data.db.entity.DeviceAlbumMetaEntity
import com.galo.android.data.db.entity.DeviceAssetEntity
import com.galo.android.data.db.entity.DeletedAssetEntity
import com.galo.android.data.db.entity.PhotoEntity
import com.galo.android.data.db.entity.SyncedAlbumEntity
import com.galo.android.data.db.entity.UploadJobEntity
@Database(
entities = [
AlbumEntity::class,
PhotoEntity::class,
UploadJobEntity::class,
SyncedAlbumEntity::class,
DeviceAssetEntity::class,
DeviceAlbumMetaEntity::class,
DeletedAssetEntity::class,
],
version = 9,
exportSchema = false,
)abstract class GaloDatabase : RoomDatabase() {
abstract fun albumDao(): AlbumDao
abstract fun photoDao(): PhotoDao
abstract fun uploadDao(): UploadDao
abstract fun syncDao(): SyncDao
companion object {
private const val NAME = "galo.db"
fun create(context: Context): GaloDatabase =
Room.databaseBuilder(context.applicationContext, GaloDatabase::class.java, NAME)
.addMigrations(MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9)
.fallbackToDestructiveMigration()
.build()
/**
* Adds two-way mirroring state without losing the sync index: the
* provenance origin, the first-pass baseline and the deletion journal.
*/
val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE synced_albums ADD COLUMN mirrorFromCreatedAt INTEGER NOT NULL DEFAULT 0",
)
db.execSQL(
"ALTER TABLE device_assets ADD COLUMN origin TEXT NOT NULL DEFAULT 'OWN'",
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS deleted_assets (" +
"photoId TEXT NOT NULL, deviceAlbumId TEXT NOT NULL, " +
"status TEXT NOT NULL, createdAt INTEGER NOT NULL, " +
"PRIMARY KEY(photoId))",
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS index_deleted_assets_deviceAlbumId " +
"ON deleted_assets (deviceAlbumId)",
)
}
}
/**
* Records whether the user explicitly turned synchronization off for an
* album, so provenance recovery does not resurrect the link.
*/
val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE device_album_meta ADD COLUMN syncDisabledAt INTEGER")
}
}
/**
* Records which earlier photo version an upload replaces, so the stale
* copy is deleted only after the new version is confirmed by the server.
* Existing jobs read as "no replacement".
*/
val MIGRATION_8_9 = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE upload_jobs ADD COLUMN replacesPhotoId TEXT")
}
}
}
}
@@ -0,0 +1,37 @@
package com.galo.android.data.db.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import com.galo.android.data.db.entity.AlbumEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface AlbumDao {
@Query("SELECT * FROM albums ORDER BY updatedAt DESC, title ASC")
fun observeAll(): Flow<List<AlbumEntity>>
@Query("SELECT * FROM albums ORDER BY updatedAt DESC, title ASC")
suspend fun getAll(): List<AlbumEntity>
@Upsert
suspend fun upsertAll(albums: List<AlbumEntity>)
@Query("DELETE FROM albums WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<String>)
@Query("DELETE FROM albums")
suspend fun clear()
@Query("SELECT COUNT(*) FROM albums")
suspend fun count(): Int
/** Replaces the cached album set with the latest server snapshot atomically. */
@Transaction
suspend fun replaceAll(albums: List<AlbumEntity>) {
clear()
upsertAll(albums)
}
}
@@ -0,0 +1,32 @@
package com.galo.android.data.db.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import com.galo.android.data.db.entity.PhotoEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface PhotoDao {
@Query("SELECT * FROM photos WHERE albumId = :albumId ORDER BY createdAt DESC, id DESC")
fun observeForAlbum(albumId: String): Flow<List<PhotoEntity>>
@Query("SELECT * FROM photos WHERE albumId = :albumId ORDER BY createdAt DESC, id DESC")
suspend fun getForAlbum(albumId: String): List<PhotoEntity>
@Query("SELECT * FROM photos WHERE id = :id")
suspend fun getById(id: String): PhotoEntity?
@Upsert
suspend fun upsertAll(photos: List<PhotoEntity>)
@Query("DELETE FROM photos WHERE id = :id")
suspend fun deleteById(id: String)
@Query("DELETE FROM photos WHERE albumId = :albumId")
suspend fun clearAlbum(albumId: String)
@Query("SELECT COUNT(*) FROM photos WHERE albumId = :albumId")
suspend fun countForAlbum(albumId: String): Int
}
@@ -0,0 +1,132 @@
package com.galo.android.data.db.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import com.galo.android.data.db.entity.DeviceAlbumMetaEntity
import com.galo.android.data.db.entity.DeviceAssetEntity
import com.galo.android.data.db.entity.DeletedAssetEntity
import com.galo.android.data.db.entity.SyncedAlbumEntity
import kotlinx.coroutines.flow.Flow
/** Number of indexed assets that already have a server photo, per device album. */
data class AlbumUploadedCount(val deviceAlbumId: String, val count: Int)
@Dao
interface SyncDao {
// --- Linked albums -----------------------------------------------------
@Query("SELECT * FROM synced_albums ORDER BY deviceAlbumName")
fun observeSyncedAlbums(): Flow<List<SyncedAlbumEntity>>
@Query("SELECT * FROM synced_albums WHERE deviceAlbumId = :deviceAlbumId")
suspend fun getSyncedAlbum(deviceAlbumId: String): SyncedAlbumEntity?
@Query("SELECT * FROM synced_albums WHERE serverAlbumId = :serverAlbumId")
suspend fun getSyncedAlbumsForServer(serverAlbumId: String): List<SyncedAlbumEntity>
@Upsert
suspend fun upsertSyncedAlbum(album: SyncedAlbumEntity)
@Query("DELETE FROM synced_albums WHERE deviceAlbumId = :deviceAlbumId")
suspend fun deleteSyncedAlbum(deviceAlbumId: String)
// --- Local album metadata (display name, cover) ------------------------
@Query("SELECT * FROM device_album_meta")
fun observeAlbumMeta(): Flow<List<DeviceAlbumMetaEntity>>
@Query("SELECT * FROM device_album_meta WHERE deviceAlbumId = :deviceAlbumId")
suspend fun getAlbumMeta(deviceAlbumId: String): DeviceAlbumMetaEntity?
@Upsert
suspend fun upsertAlbumMeta(meta: DeviceAlbumMetaEntity)
@Query("DELETE FROM device_album_meta WHERE deviceAlbumId = :deviceAlbumId")
suspend fun deleteAlbumMeta(deviceAlbumId: String)
// --- Device asset index and provenance ---------------------------------
@Query("SELECT DISTINCT deviceAlbumId FROM device_assets")
suspend fun indexedAlbumIds(): List<String>
@Query("SELECT * FROM device_assets WHERE deviceAlbumId = :deviceAlbumId")
fun observeAssets(deviceAlbumId: String): Flow<List<DeviceAssetEntity>>
@Query(
"SELECT deviceAlbumId, COUNT(*) AS count FROM device_assets " +
"WHERE serverPhotoId IS NOT NULL GROUP BY deviceAlbumId",
)
fun observeUploadedCounts(): Flow<List<AlbumUploadedCount>>
@Query(
"SELECT deviceAlbumId, COUNT(*) AS count FROM device_assets " +
"WHERE state = 'ORPHANED' GROUP BY deviceAlbumId",
)
fun observeMissingCopyCounts(): Flow<List<AlbumUploadedCount>>
@Query("SELECT * FROM device_assets WHERE deviceAlbumId = :deviceAlbumId")
suspend fun getAssets(deviceAlbumId: String): List<DeviceAssetEntity>
@Query("SELECT * FROM device_assets WHERE assetKey = :assetKey")
suspend fun getAsset(assetKey: String): DeviceAssetEntity?
@Query("SELECT * FROM device_assets WHERE contentHash = :contentHash LIMIT 1")
suspend fun getAssetByHash(contentHash: String): DeviceAssetEntity?
@Query("SELECT * FROM device_assets WHERE serverPhotoId = :photoId")
suspend fun getAssetsByServerPhoto(photoId: String): List<DeviceAssetEntity>
@Query(
"SELECT DISTINCT contentHash FROM device_assets " +
"WHERE deviceAlbumId != :deviceAlbumId AND contentHash IS NOT NULL",
)
suspend fun hashesOutside(deviceAlbumId: String): List<String>
@Query("UPDATE device_assets SET displayName = :name WHERE serverPhotoId = :photoId")
suspend fun updateDisplayNameByServerPhoto(photoId: String, name: String)
/**
* Records the identity the local file really has: an offloaded preview belongs
* to the original's photo and hash, replacing values that drifted (for example
* a version uploaded from the preview itself).
*/
@Query(
"UPDATE device_assets SET serverPhotoId = :serverPhotoId, contentHash = :originalHash " +
"WHERE assetKey = :assetKey",
)
suspend fun linkOriginal(assetKey: String, serverPhotoId: String, originalHash: String?)
@Query("UPDATE device_assets SET state = :state WHERE assetKey = :assetKey")
suspend fun markState(assetKey: String, state: String)
@Upsert
suspend fun upsertAssets(assets: List<DeviceAssetEntity>)
@Query("DELETE FROM device_assets WHERE assetKey = :assetKey")
suspend fun deleteAsset(assetKey: String)
@Query("DELETE FROM device_assets WHERE deviceAlbumId = :deviceAlbumId")
suspend fun clearAlbum(deviceAlbumId: String)
// --- Deletion journal (tombstones) -------------------------------------
@Query("SELECT * FROM deleted_assets WHERE deviceAlbumId = :deviceAlbumId")
suspend fun getDeletedAssets(deviceAlbumId: String): List<DeletedAssetEntity>
@Query("SELECT * FROM deleted_assets")
suspend fun getAllDeletedAssets(): List<DeletedAssetEntity>
@Query("SELECT * FROM deleted_assets WHERE photoId = :photoId")
suspend fun getDeletedAsset(photoId: String): DeletedAssetEntity?
@Upsert
suspend fun upsertDeletedAsset(asset: DeletedAssetEntity)
@Query("DELETE FROM deleted_assets WHERE photoId = :photoId")
suspend fun deleteDeletedAsset(photoId: String)
@Query("DELETE FROM deleted_assets WHERE deviceAlbumId = :deviceAlbumId")
suspend fun clearDeletedAssets(deviceAlbumId: String)
}
@@ -0,0 +1,49 @@
package com.galo.android.data.db.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import com.galo.android.data.db.entity.UploadJobEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface UploadDao {
@Query("SELECT * FROM upload_jobs ORDER BY createdAt DESC")
fun observeAll(): Flow<List<UploadJobEntity>>
/** Uploads created by a device-album sync; manual uploads have no device album. */
@Query("SELECT * FROM upload_jobs WHERE deviceAlbumId IS NOT NULL ORDER BY createdAt ASC")
fun observeSyncJobs(): Flow<List<UploadJobEntity>>
@Query("SELECT * FROM upload_jobs WHERE id = :id")
suspend fun getById(id: String): UploadJobEntity?
@Query("SELECT * FROM upload_jobs WHERE status IN ('QUEUED','RUNNING') ORDER BY createdAt ASC")
suspend fun getPending(): List<UploadJobEntity>
@Upsert
suspend fun upsert(job: UploadJobEntity)
@Query(
"UPDATE upload_jobs SET status = :status, progress = :progress, error = :error, " +
"attempts = :attempts, convertedPath = :convertedPath WHERE id = :id",
)
suspend fun updateState(
id: String,
status: String,
progress: Int,
error: String?,
attempts: Int,
convertedPath: String?,
)
@Query("UPDATE upload_jobs SET status = 'QUEUED' WHERE status = 'RUNNING'")
suspend fun resetRunning()
@Query("DELETE FROM upload_jobs WHERE id = :id")
suspend fun delete(id: String)
@Query("DELETE FROM upload_jobs WHERE status = 'SUCCESS'")
suspend fun clearCompleted()
}
@@ -0,0 +1,18 @@
package com.galo.android.data.db.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "albums")
data class AlbumEntity(
@PrimaryKey val id: String,
val title: String,
val description: String,
val ownerId: String,
val coverPhotoId: String?,
val coverThumbUrl: String?,
val coverMediumUrl: String?,
val photosCount: Int,
val membersCount: Int,
val updatedAt: String?,
)
@@ -0,0 +1,37 @@
package com.galo.android.data.db.entity
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "photos",
indices = [Index("albumId")],
)
data class PhotoEntity(
@PrimaryKey val id: String,
val albumId: String,
val originalFilename: String,
val kind: String,
val mimeType: String?,
val width: Int,
val height: Int,
val durationMs: Long,
val size: Long,
val takenAt: String?,
val caption: String,
val latitude: Double? = null,
val longitude: Double? = null,
val isPanorama: Boolean,
val thumbUrl: String?,
val mediumUrl: String?,
val previewUrl: String?,
val playbackUrl: String?,
val originalUrl: String?,
val downloadUrl: String?,
val createdAt: String?,
val tagsJson: String = "[]",
val exifJson: String = "{}",
val uploaderName: String? = null,
val uploaderAvatarUrl: String? = null,
)
@@ -0,0 +1,91 @@
package com.galo.android.data.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/** A device album linked to a server album, with its synchronization mode. */
@Entity(tableName = "synced_albums")
data class SyncedAlbumEntity(
@PrimaryKey val deviceAlbumId: String,
val deviceAlbumName: String,
val serverAlbumId: String,
val mode: String,
val updatedAt: Long,
/**
* Two-way mirroring baseline: only server photos created at or after this
* epoch (millis) are materialized. 0 means "the whole current album".
*/
@ColumnInfo(defaultValue = "0") val mirrorFromCreatedAt: Long = 0L,
)
/**
* Local, app-only metadata for a device album: a display name and a chosen
* cover. Stored regardless of whether the album is linked to a server album.
*/
@Entity(tableName = "device_album_meta")
data class DeviceAlbumMetaEntity(
@PrimaryKey val deviceAlbumId: String,
val displayName: String?,
val coverUri: String?,
val updatedAt: Long,
/**
* When the user explicitly turned synchronization off (or unlinked), the
* album is local-only until they enable sync again. Provenance recovery must
* not resurrect the link from the offloaded previews' markers.
*/
val syncDisabledAt: Long? = null,
)
/**
* Local index of a device asset plus provenance: the server photo it was
* uploaded as (when known) and the content hash used to match it.
*/
@Entity(
tableName = "device_assets",
indices = [
Index("deviceAlbumId"),
Index("contentHash"),
Index("serverPhotoId"),
],
)
data class DeviceAssetEntity(
@PrimaryKey val assetKey: String,
val deviceAlbumId: String,
val mediaStoreId: Long,
val uri: String,
val displayName: String,
val mimeType: String?,
val size: Long,
val dateModified: Long,
val contentHash: String?,
val serverPhotoId: String?,
val uploadedAt: Long?,
val state: String,
/** Uploaded by this client (`OWN`) or materialized from the server (`MIRROR`). */
@ColumnInfo(defaultValue = "OWN") val origin: String = "OWN",
) {
companion object {
fun key(deviceAlbumId: String, mediaStoreId: Long): String = "$deviceAlbumId/$mediaStoreId"
}
}
/**
* A deletion made on the device that still has to be confirmed on the server.
* Keeps a locally removed element from being materialized again before its
* server photo is gone, and makes delete events idempotent.
*/
@Entity(tableName = "deleted_assets", indices = [Index("deviceAlbumId")])
data class DeletedAssetEntity(
@PrimaryKey val photoId: String,
val deviceAlbumId: String,
/** `PENDING` while the server delete is not confirmed; `FAILED` after a refusal. */
val status: String,
val createdAt: Long,
) {
companion object {
const val PENDING = "PENDING"
const val FAILED = "FAILED"
}
}
@@ -0,0 +1,34 @@
package com.galo.android.data.db.entity
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "upload_jobs",
indices = [Index("status"), Index("albumId")],
)
data class UploadJobEntity(
@PrimaryKey val id: String,
val albumId: String,
val uri: String,
val filename: String,
val mimeType: String?,
val size: Long,
val status: String,
val progress: Int,
val error: String?,
val attempts: Int,
val wifiOnly: Boolean,
val createdAt: Long,
val convertedPath: String?,
// Device-album synchronization: when set, the completed upload records
// provenance for the asset in the sync index.
val deviceAlbumId: String? = null,
val assetKey: String? = null,
val contentHash: String? = null,
val syncMode: String? = null,
// Set when this upload replaces an earlier version of the same element: the
// stale photo is deleted only after this upload is confirmed by the server.
val replacesPhotoId: String? = null,
)
@@ -0,0 +1,17 @@
package com.galo.android.data.image
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
/**
* Coil cache key derived from the object path only, dropping the signed query
* (`?e=..&s=..`). The signature rotates every window, so keying by the full URL
* would miss the cache on every refresh; keying by path reuses the cached object.
*/
object ObjectCacheKey {
fun keyFor(url: String?): String? {
if (url.isNullOrBlank()) return null
val parsed = url.toHttpUrlOrNull() ?: return url
return parsed.newBuilder().query(null).build().toString()
}
}
@@ -0,0 +1,151 @@
package com.galo.android.data.mapper
import com.galo.android.data.db.entity.AlbumEntity
import com.galo.android.data.db.entity.PhotoEntity
import com.galo.android.data.network.GaloJson
import com.galo.android.data.network.ObjectUrlResolver
import com.galo.android.data.network.dto.AlbumDto
import com.galo.android.data.network.dto.PhotoDto
import com.galo.android.data.network.dto.TagDto
import com.galo.android.domain.model.Album
import com.galo.android.domain.model.MediaKind
import com.galo.android.domain.model.Photo
import com.galo.android.domain.model.Tag
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
// Remote DTO -> persisted entity (URLs kept raw so the cache survives a base-URL change).
fun AlbumDto.toEntity(): AlbumEntity = AlbumEntity(
id = id,
title = title,
description = description,
ownerId = ownerId,
coverPhotoId = coverPhotoId,
coverThumbUrl = coverThumbUrl,
coverMediumUrl = coverMediumUrl,
photosCount = photosCount,
membersCount = membersCount,
updatedAt = updatedAt,
)
fun PhotoDto.toEntity(): PhotoEntity = PhotoEntity(
id = id,
albumId = albumId,
originalFilename = originalFilename,
kind = kind,
mimeType = mimeType,
width = width,
height = height,
durationMs = durationMs,
size = size,
takenAt = takenAt,
caption = caption,
latitude = latitude,
longitude = longitude,
isPanorama = tags.any { MediaKind.isPanoTag(it.name) },
thumbUrl = thumbUrl,
mediumUrl = mediumUrl,
previewUrl = previewUrl,
playbackUrl = playbackUrl,
originalUrl = originalUrl,
downloadUrl = downloadUrl,
createdAt = createdAt,
tagsJson = encodeTags(tags),
exifJson = encodeExif(exif),
uploaderName = uploader?.name,
uploaderAvatarUrl = uploader?.avatarUrl,
)
// Persisted entity -> domain (URLs resolved against the current base URL).
fun AlbumEntity.toDomain(resolver: ObjectUrlResolver): Album = Album(
id = id,
title = title,
description = description,
ownerId = ownerId,
coverPhotoId = coverPhotoId,
coverThumbUrl = resolver.resolve(coverThumbUrl),
coverMediumUrl = resolver.resolve(coverMediumUrl),
photosCount = photosCount,
membersCount = membersCount,
updatedAt = updatedAt,
)
fun PhotoEntity.toDomain(resolver: ObjectUrlResolver): Photo = Photo(
id = id,
albumId = albumId,
originalFilename = originalFilename,
kind = MediaKind.from(kind),
mimeType = mimeType,
width = width,
height = height,
durationMs = durationMs,
size = size,
takenAt = takenAt,
caption = caption,
latitude = latitude,
longitude = longitude,
isPanorama = isPanorama,
thumbUrl = resolver.resolve(thumbUrl),
mediumUrl = resolver.resolve(mediumUrl),
previewUrl = resolver.resolve(previewUrl),
playbackUrl = resolver.resolve(playbackUrl),
originalUrl = resolver.resolve(originalUrl),
downloadUrl = resolver.resolve(downloadUrl),
createdAt = createdAt,
tags = decodeTags(tagsJson),
exif = decodeExif(exifJson),
uploaderName = uploaderName,
uploaderAvatarUrl = resolver.resolve(uploaderAvatarUrl),
)
fun PhotoDto.toDomain(resolver: ObjectUrlResolver): Photo = Photo(
id = id,
albumId = albumId,
originalFilename = originalFilename,
kind = MediaKind.from(kind),
mimeType = mimeType,
width = width,
height = height,
durationMs = durationMs,
size = size,
takenAt = takenAt,
caption = caption,
latitude = latitude,
longitude = longitude,
isPanorama = tags.any { MediaKind.isPanoTag(it.name) },
thumbUrl = resolver.resolve(thumbUrl),
mediumUrl = resolver.resolve(mediumUrl),
previewUrl = resolver.resolve(previewUrl),
playbackUrl = resolver.resolve(playbackUrl),
originalUrl = resolver.resolve(originalUrl),
downloadUrl = resolver.resolve(downloadUrl),
createdAt = createdAt,
tags = tags.map { Tag(it.id, it.name) },
exif = exif.mapValues { it.value.asExifString() },
uploaderName = uploader?.name,
uploaderAvatarUrl = resolver.resolve(uploader?.avatarUrl),
)
private fun encodeTags(tags: List<TagDto>): String =
runCatching { GaloJson.encodeToString(tags) }.getOrDefault("[]")
private fun decodeTags(json: String): List<Tag> =
runCatching {
GaloJson.decodeFromString<List<TagDto>>(json).map { Tag(it.id, it.name) }
}.getOrDefault(emptyList())
private fun encodeExif(exif: Map<String, JsonElement>): String =
runCatching {
GaloJson.encodeToString(exif.mapValues { it.value.asExifString() })
}.getOrDefault("{}")
private fun decodeExif(json: String): Map<String, String> =
runCatching { GaloJson.decodeFromString<Map<String, String>>(json) }
.getOrDefault(emptyMap())
private fun JsonElement.asExifString(): String =
when (this) {
is JsonPrimitive -> content
else -> toString()
}
@@ -0,0 +1,27 @@
package com.galo.android.data.media
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import com.galo.android.domain.media.MediaPermissions
/** Whether the app can enumerate and read device media through MediaStore. */
interface MediaAccess {
fun hasAccess(): Boolean
}
/**
* Checks runtime access to device media. Access requires every permission the
* running platform needs ([MediaPermissions.required]); partial access (for
* example images granted but video denied) is treated as no access.
*/
class MediaAccessChecker(private val context: Context) : MediaAccess {
override fun hasAccess(): Boolean {
val required = MediaPermissions.required(Build.VERSION.SDK_INT)
return required.isNotEmpty() && required.all { permission ->
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
}
}
}
@@ -0,0 +1,133 @@
package com.galo.android.data.media
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.provider.MediaStore
import com.galo.android.domain.model.DeviceAlbum
import com.galo.android.domain.model.DeviceMedia
import com.galo.android.domain.model.MediaKind
/**
* Maps MediaStore cursors to [DeviceMedia] and groups media into [DeviceAlbum]s.
* Kept free of provider access so the mapping can be exercised with a cursor.
*/
object DeviceMediaMapping {
val projection = arrayOf(
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.MediaColumns.MIME_TYPE,
MediaStore.MediaColumns.SIZE,
MediaStore.MediaColumns.DATE_MODIFIED,
MediaStore.MediaColumns.DATE_TAKEN,
MediaStore.MediaColumns.BUCKET_ID,
MediaStore.MediaColumns.BUCKET_DISPLAY_NAME,
MediaStore.MediaColumns.RELATIVE_PATH,
MediaStore.MediaColumns.WIDTH,
MediaStore.MediaColumns.HEIGHT,
)
fun mediaFromCursor(cursor: Cursor, collection: Uri, kind: MediaKind): List<DeviceMedia> {
val idCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
val nameCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)
val mimeCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE)
val sizeCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)
val modifiedCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
val takenCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_TAKEN)
val bucketIdCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID)
val bucketNameCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_DISPLAY_NAME)
val pathCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.RELATIVE_PATH)
val widthCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
val heightCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
val out = ArrayList<DeviceMedia>(cursor.count)
while (cursor.moveToNext()) {
val id = cursor.getLong(idCol)
val bucketId = cursor.getString(bucketIdCol) ?: ""
val taken = if (cursor.isNull(takenCol)) null else cursor.getLong(takenCol)
out += DeviceMedia(
id = id,
uri = ContentUris.withAppendedId(collection, id).toString(),
albumId = bucketId,
albumName = cursor.getString(bucketNameCol) ?: "",
displayName = cursor.getString(nameCol) ?: "",
mimeType = cursor.getString(mimeCol),
size = cursor.getLong(sizeCol),
width = cursor.getInt(widthCol),
height = cursor.getInt(heightCol),
dateModified = cursor.getLong(modifiedCol),
dateTaken = taken,
relativePath = cursor.getString(pathCol) ?: "",
kind = kind,
)
}
return out
}
/** Groups media by bucket, picking the most recent item as the cover. */
fun albums(media: List<DeviceMedia>): List<DeviceAlbum> =
media.groupBy { it.albumId }
.map { (albumId, items) ->
val name = items.firstOrNull { it.albumName.isNotBlank() }?.albumName ?: ""
val latest = items.maxByOrNull { it.timestamp() }
DeviceAlbum(
id = albumId,
name = name.ifBlank { "Без названия" },
itemCount = items.size,
coverUri = latest?.uri,
relativePath = items.first().relativePath,
updatedAtMillis = latest?.timestamp(),
)
}
.sortedBy { it.name.lowercase() }
/** When an item was last changed, in epoch millis. */
private fun DeviceMedia.timestamp(): Long = dateTaken ?: (dateModified * 1_000)
}
/**
* Reads device albums and their media through MediaStore. Requires media read
* access; without it queries fail with a security error and are reported as an
* empty result so the UI simply shows no device albums.
*/
class MediaStoreDeviceSource(private val context: Context) {
fun albums(): List<DeviceAlbum> = DeviceMediaMapping.albums(allMedia())
fun items(albumId: String): List<DeviceMedia> =
allMedia().filter { it.albumId == albumId }
/**
* System consent to delete media the app does not own. Launching the returned
* sender (after user confirmation) removes the items from MediaStore.
*/
fun deleteConsent(uris: List<Uri>): android.content.IntentSender? =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R && uris.isNotEmpty()) {
runCatching {
MediaStore.createDeleteRequest(context.contentResolver, uris).intentSender
}.getOrNull()
} else {
null
}
/**
* Deletes media directly through MediaStore, used for media the app owns or
* after a delete consent has been granted. @return how many items were removed.
*/
fun delete(uris: List<Uri>): Int =
uris.sumOf { uri -> runCatching { context.contentResolver.delete(uri, null, null) }.getOrDefault(0) }
private fun allMedia(): List<DeviceMedia> =
query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaKind.IMAGE) +
query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, MediaKind.VIDEO)
private fun query(collection: Uri, kind: MediaKind): List<DeviceMedia> =
runCatching {
context.contentResolver
.query(collection, DeviceMediaMapping.projection, null, null, null)
?.use { cursor -> DeviceMediaMapping.mediaFromCursor(cursor, collection, kind) }
?: emptyList()
}.getOrDefault(emptyList())
}
@@ -0,0 +1,84 @@
package com.galo.android.data.media
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import android.net.Uri
import com.galo.android.domain.model.MediaKind
import java.io.File
import java.io.FileOutputStream
import kotlin.math.max
import kotlin.math.roundToInt
/**
* Generates the light preview that stays on the device (cache in modes A/B and
* the replacement file in mode C). The long side is capped and the output is a
* quality-limited JPEG.
*/
object PreviewGenerator {
const val MAX_LONG_SIDE = 1600
const val JPEG_QUALITY = 80
/** Scales dimensions down so the long side is at most [MAX_LONG_SIDE]. */
fun scaledSize(width: Int, height: Int): Pair<Int, Int> {
if (width <= 0 || height <= 0) return 0 to 0
val longSide = max(width, height)
if (longSide <= MAX_LONG_SIDE) return width to height
val ratio = MAX_LONG_SIDE.toDouble() / longSide
return max(1, (width * ratio).roundToInt()) to max(1, (height * ratio).roundToInt())
}
/** Cache file for the preview of a device asset, keyed by the asset key. */
fun previewFile(context: Context, assetKey: String): File {
val dir = File(context.cacheDir, "previews").apply { mkdirs() }
return File(dir, assetKey.replace('/', '_') + ".jpg")
}
/**
* Unique temporary file for one offload pass. Each pass must use its own file
* so concurrent offloads cannot delete each other's preview mid-write.
*/
fun tempPreviewFile(context: Context, assetKey: String): File {
val dir = File(context.cacheDir, "previews").apply { mkdirs() }
return File(dir, assetKey.replace('/', '_') + "-" + System.nanoTime() + ".jpg")
}
/**
* Decodes the source (image file, or a poster frame for video), scales it and
* writes a JPEG preview to [out]. Returns false when the source is unreadable.
*/
fun generate(context: Context, uri: Uri, kind: MediaKind, out: File): Boolean {
val source = runCatching {
if (kind == MediaKind.VIDEO) videoFrame(context, uri) else decode(context, uri)
}.getOrNull() ?: return false
val (width, height) = scaledSize(source.width, source.height)
val scaled = if (width <= 0 || height <= 0) {
source
} else {
Bitmap.createScaledBitmap(source, width, height, true)
}
if (scaled !== source) source.recycle()
val ok = runCatching { encodeJpeg(scaled, out) }.getOrDefault(false)
scaled.recycle()
return ok
}
fun encodeJpeg(bitmap: Bitmap, out: File): Boolean =
FileOutputStream(out).use { bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, it) }
private fun decode(context: Context, uri: Uri): Bitmap? =
context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it) }
private fun videoFrame(context: Context, uri: Uri): Bitmap? {
val retriever = MediaMetadataRetriever()
return try {
retriever.setDataSource(context, uri)
retriever.getFrameAtTime(0)
} finally {
runCatching { retriever.release() }
}
}
}
@@ -0,0 +1,41 @@
package com.galo.android.data.network
import com.galo.android.data.network.dto.PhotoDto
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
sealed interface AlbumEvent {
data class PhotoUpserted(val photo: PhotoDto) : AlbumEvent
data class PhotoDeleted(val photoId: String) : AlbumEvent
data class SocialChanged(val photoId: String) : AlbumEvent
data object AlbumChanged : AlbumEvent
/** Emitted after a dropped SSE connection so the caller can catch up via `?since=`. */
data object Reconnected : AlbumEvent
}
/**
* Maps the server's SSE `event:`/`data:` pairs to typed [AlbumEvent]s.
* Unknown event types return null and are ignored.
*/
object AlbumEventParser {
fun parse(type: String, data: String): AlbumEvent? = when (type) {
"photo.created", "photo.updated" ->
runCatching { AlbumEvent.PhotoUpserted(GaloJson.decodeFromString<PhotoDto>(data)) }
.getOrNull()
"photo.deleted" -> stringField(data, "id")?.let { AlbumEvent.PhotoDeleted(it) }
"album.updated", "album.deleted" -> AlbumEvent.AlbumChanged
"like.added", "like.removed", "comment.created" ->
stringField(data, "photo_id")?.let { AlbumEvent.SocialChanged(it) }
else -> null
}
private fun stringField(data: String, field: String): String? = runCatching {
GaloJson.parseToJsonElement(data).jsonObject[field]?.jsonPrimitive?.content
}.getOrNull()
}
@@ -0,0 +1,71 @@
package com.galo.android.data.network
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.sse.EventSource
import okhttp3.sse.EventSourceListener
import okhttp3.sse.EventSources
/**
* Live album updates over SSE (`GET /api/sse/albums/:id`). Reconnects with
* exponential backoff and emits [AlbumEvent.Reconnected] after each drop so the
* consumer can backfill missed changes.
*/
class AlbumSseClient(
private val api: GaloApi,
private val client: OkHttpClient,
) {
fun events(albumId: String): Flow<AlbumEvent> = channelFlow {
val factory = EventSources.createFactory(client)
var backoff = INITIAL_BACKOFF_MS
while (true) {
val request = runCatching { api.sseRequest(albumId) }.getOrNull() ?: break
val closed = CompletableDeferred<Unit>()
val listener = object : EventSourceListener() {
override fun onOpen(eventSource: EventSource, response: Response) {
backoff = INITIAL_BACKOFF_MS
}
override fun onEvent(
eventSource: EventSource,
id: String?,
type: String?,
data: String,
) {
if (type.isNullOrBlank()) return
AlbumEventParser.parse(type, data)?.let { trySend(it) }
}
override fun onClosed(eventSource: EventSource) {
closed.complete(Unit)
}
override fun onFailure(
eventSource: EventSource,
t: Throwable?,
response: Response?,
) {
closed.complete(Unit)
}
}
val source = factory.newEventSource(request, listener)
closed.await()
source.cancel()
if (isClosedForSend) break
trySend(AlbumEvent.Reconnected)
delay(backoff)
backoff = (backoff * 2).coerceAtMost(MAX_BACKOFF_MS)
}
}
private companion object {
const val INITIAL_BACKOFF_MS = 1_000L
const val MAX_BACKOFF_MS = 30_000L
}
}
@@ -0,0 +1,81 @@
package com.galo.android.data.network
import com.galo.android.data.auth.TokenStore
import com.galo.android.domain.ConnectionPolicy
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.util.concurrent.TimeUnit
/**
* Shared HTTP stack for the galo API.
*
* - resolves every endpoint against the configured base URL,
* - enforces the insecure-connection gate,
* - attaches the bearer token and transparently refreshes it on 401.
*/
class ApiClient(
private val tokenStore: TokenStore,
private val baseUrlProvider: () -> String?,
private val insecureAllowedProvider: () -> Boolean = { false },
debugLogging: Boolean = false,
) {
val objectUrls: ObjectUrlResolver = ObjectUrlResolver(baseUrlProvider)
private val refreshClient: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
val client: OkHttpClient = refreshClient.newBuilder()
.addInterceptor(AuthHeaderInterceptor(tokenStore))
.authenticator(TokenAuthenticator(tokenStore, refreshClient, ::refreshUrlOrNull))
.apply {
if (debugLogging) {
val logging = okhttp3.logging.HttpLoggingInterceptor().apply {
level = okhttp3.logging.HttpLoggingInterceptor.Level.BASIC
redactHeader("Authorization")
}
addInterceptor(logging)
}
}
.build()
/** Client tuned for long-lived SSE streams (no read timeout). */
val sseClient: OkHttpClient = refreshClient.newBuilder()
.addInterceptor(AuthHeaderInterceptor(tokenStore))
.readTimeout(0, TimeUnit.MILLISECONDS)
.build()
fun baseUrl(): String {
val base = baseUrlProvider()?.trim()?.trimEnd('/')
if (base.isNullOrEmpty()) {
throw IllegalStateException("server base URL is not configured")
}
ConnectionPolicy.check(base, insecureAllowedProvider())
return base
}
fun endpoint(path: String): String = baseUrl() + "/" + path.trimStart('/')
fun refreshUrl(): String = endpoint("api/auth/refresh")
private fun refreshUrlOrNull(): String? = runCatching { refreshUrl() }.getOrNull()
private class AuthHeaderInterceptor(private val tokenStore: TokenStore) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
if (original.header("Authorization") != null) return chain.proceed(original)
val token = tokenStore.accessToken()
val request: Request = if (token.isNullOrBlank()) {
original
} else {
original.newBuilder().header("Authorization", "Bearer $token").build()
}
return chain.proceed(request)
}
}
}
@@ -0,0 +1,76 @@
package com.galo.android.data.network
import com.galo.android.data.network.dto.ErrorEnvelopeDto
import com.galo.android.domain.model.ApiError
/**
* Turns any HTTP failure body into a normalised [ApiError] and a message safe to
* show to the user. The server contract is `{error:{code,message}}`; malformed
* or missing bodies degrade to a generic internal error.
*/
object ErrorMapper {
fun parse(status: Int, body: String?): ApiError {
val envelope = body
?.takeIf { it.isNotBlank() }
?.let { raw ->
runCatching { GaloJson.decodeFromString<ErrorEnvelopeDto>(raw) }.getOrNull()
}
val error = envelope?.error
if (error != null) {
return ApiError(
code = error.code.ifBlank { "internal_error" },
message = error.message.ifBlank { "internal server error" },
status = status,
)
}
return ApiError(code = fallbackCode(status), message = fallbackMessage(status), status = status)
}
/** Human-friendly message; prefers a known localisation, else the server text. */
fun userMessage(error: ApiError): String {
val localized = when (error.code) {
"invalid_credentials" -> "Неверный идентификатор или пароль"
"email_taken" -> "Такой email уже зарегистрирован"
"phone_taken" -> "Такой телефон уже зарегистрирован"
"invalid_phone" -> "Телефон должен быть в формате E.164 (например, +15551234567)"
"invalid_email" -> "Некорректный email"
"identifier_required" -> "Укажите email или телефон"
"invalid_name" -> "Укажите имя"
"weak_password" -> "Пароль должен быть не короче 8 символов"
"current_password_incorrect" -> "Неверный текущий пароль"
"email_already_set", "phone_already_set" ->
"Идентификатор уже задан и не может быть изменён"
"identifiers_complete" -> "Оба идентификатора уже заданы"
"unauthorized" -> "Требуется вход в аккаунт"
"forbidden" -> "Недостаточно прав"
"not_found", "album_not_found", "photo_not_found", "object_not_found" ->
"Не найдено"
"file_too_large" -> "Файл превышает допустимый размер"
"no_files" -> "Файлы не выбраны"
"invalid_request" -> "Некорректный запрос"
"conflict" -> "Конфликт данных"
"internal_error" -> "Ошибка сервера, попробуйте позже"
else -> null
}
return localized ?: error.message
}
private fun fallbackCode(status: Int): String = when (status) {
401 -> "unauthorized"
403 -> "forbidden"
404 -> "not_found"
409 -> "conflict"
413 -> "file_too_large"
else -> "internal_error"
}
private fun fallbackMessage(status: Int): String = when (status) {
401 -> "unauthorized"
403 -> "forbidden"
404 -> "not found"
409 -> "conflict"
413 -> "payload too large"
else -> "internal server error"
}
}
@@ -0,0 +1,374 @@
package com.galo.android.data.network
import com.galo.android.data.network.dto.AddIdentifierRequest
import com.galo.android.data.network.dto.AddTagRequest
import com.galo.android.data.network.dto.AlbumDto
import com.galo.android.data.network.dto.AlbumEnvelopeDto
import com.galo.android.data.network.dto.AlbumListDto
import com.galo.android.data.network.dto.AuthTokensDto
import com.galo.android.data.network.dto.BatchRequest
import com.galo.android.data.network.dto.ChangePasswordRequest
import com.galo.android.data.network.dto.CreateAlbumRequest
import com.galo.android.data.network.dto.DeleteAccountRequest
import com.galo.android.data.network.dto.HealthDto
import com.galo.android.data.network.dto.LoginRequest
import com.galo.android.data.network.dto.LogoutRequest
import com.galo.android.data.network.dto.MemberDto
import com.galo.android.data.network.dto.MemberListDto
import com.galo.android.data.network.dto.PhotoDto
import com.galo.android.data.network.dto.PhotoEnvelopeDto
import com.galo.android.data.network.dto.PhotoListDto
import com.galo.android.data.network.dto.PreferencesDto
import com.galo.android.data.network.dto.PreferencesEnvelopeDto
import com.galo.android.data.network.dto.RegisterRequest
import com.galo.android.data.network.dto.RenamePhotoRequest
import com.galo.android.data.network.dto.ShareLinkDto
import com.galo.android.data.network.dto.ShareLinkEnvelopeDto
import com.galo.android.data.network.dto.ShareLinkListDto
import com.galo.android.data.network.dto.ShareLinkRequest
import com.galo.android.data.network.dto.TagDto
import com.galo.android.data.network.dto.TagListDto
import com.galo.android.data.network.dto.TokenEnvelopeDto
import com.galo.android.data.network.dto.UpdateAlbumRequest
import com.galo.android.data.network.dto.UpdateProfileRequest
import com.galo.android.data.network.dto.UploadResponseDto
import com.galo.android.data.network.dto.UserDto
import com.galo.android.data.network.dto.UserEnvelopeDto
import com.galo.android.domain.model.ApiError
import com.galo.android.domain.model.ApiException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.io.IOException
/** Profile operations the UI depends on; implemented by [GaloApi]. */
interface ProfileApi {
suspend fun me(): UserDto
suspend fun updateProfile(name: String): UserDto
suspend fun changePassword(currentPassword: String, newPassword: String)
suspend fun addIdentifier(email: String, phone: String): UserDto
suspend fun deleteAccount(password: String)
suspend fun uploadAvatar(body: RequestBody): UserDto
suspend fun deleteAvatar(): UserDto
}
/**
* Typed galo REST client. All calls resolve against the configured base URL and
* surface failures as [ApiException] with a normalised [ApiError].
*/
class GaloApi(
private val api: ApiClient,
private val json: Json = GaloJson,
private val client: OkHttpClient = api.client,
) : ProfileApi {
private val jsonMedia = "application/json; charset=utf-8".toMediaType()
suspend fun health(): HealthDto =
get("api/health").decode()
suspend fun register(email: String, phone: String, password: String, name: String): AuthTokensDto =
postJson("api/auth/register", json.encodeToString(RegisterRequest(email, phone, password, name)))
.decode<TokenEnvelopeDto>()
.tokens
suspend fun login(identifier: String, password: String): AuthTokensDto =
postJson("api/auth/login", json.encodeToString(LoginRequest(identifier, password)))
.decode<TokenEnvelopeDto>()
.tokens
override suspend fun me(): UserDto =
get("api/auth/me").decode<UserEnvelopeDto>().user
/** Full account payload including saved preferences and their `photo_info`. */
suspend fun meEnvelope(): UserEnvelopeDto = get("api/auth/me").decode<UserEnvelopeDto>()
/** Persists preferences (e.g. which EXIF attributes are visible and their labels). */
suspend fun savePreferences(preferences: PreferencesDto): PreferencesDto =
patchJson("api/auth/me/preferences", json.encodeToString(preferences))
.decode<PreferencesEnvelopeDto>()
.preferences
override suspend fun updateProfile(name: String): UserDto =
patchJson("api/auth/me", json.encodeToString(UpdateProfileRequest(name)))
.decode<UserEnvelopeDto>()
.user
override suspend fun changePassword(currentPassword: String, newPassword: String) {
postJson(
"api/auth/me/password",
json.encodeToString(ChangePasswordRequest(currentPassword, newPassword)),
)
}
override suspend fun addIdentifier(email: String, phone: String): UserDto =
postJson("api/auth/me/identifiers", json.encodeToString(AddIdentifierRequest(email, phone)))
.decode<UserEnvelopeDto>()
.user
override suspend fun deleteAccount(password: String) {
deleteJson("api/auth/me", json.encodeToString(DeleteAccountRequest(password)))
}
/** Uploads a cropped square avatar and returns the updated profile. */
override suspend fun uploadAvatar(body: RequestBody): UserDto {
val multipart = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("file", "avatar.jpg", body)
.build()
val request = Request.Builder()
.url(api.endpoint("api/auth/me/avatar"))
.post(multipart)
.build()
return execute(request).decode<UserEnvelopeDto>().user
}
/** Removes the avatar and returns the updated profile. */
override suspend fun deleteAvatar(): UserDto =
execute(Request.Builder().url(api.endpoint("api/auth/me/avatar")).delete().build())
.decode<UserEnvelopeDto>()
.user
/** Revokes the refresh token server-side. Best-effort: callers clear local state anyway. */
suspend fun logout(refreshToken: String) {
postJson("api/auth/logout", json.encodeToString(LogoutRequest(refreshToken)))
}
suspend fun albums(): List<AlbumDto> =
get("api/albums").decode<AlbumListDto>().albums
suspend fun album(albumId: String): AlbumDto =
get("api/albums/$albumId").decode<AlbumEnvelopeDto>().album
/** Album plus the caller's role on it. */
suspend fun albumEnvelope(albumId: String): AlbumEnvelopeDto =
get("api/albums/$albumId").decode()
/** Creates a server album and returns it. */
suspend fun createAlbum(title: String): AlbumDto =
postJson("api/albums", json.encodeToString(CreateAlbumRequest(title)))
.decode<AlbumEnvelopeDto>()
.album
suspend fun photo(photoId: String): PhotoDto =
get("api/photos/$photoId").decode<PhotoEnvelopeDto>().photo
suspend fun photos(
albumId: String,
cursor: String? = null,
since: String? = null,
limit: Int = 50,
): PhotoListDto {
val query = buildList {
cursor?.let { add("cursor=" + urlEncode(it)) }
since?.let { add("since=" + urlEncode(it)) }
add("limit=$limit")
}.joinToString("&")
return get("api/albums/$albumId/photos?$query").decode()
}
suspend fun upload(
albumId: String,
filename: String,
body: RequestBody,
): PhotoDto {
val multipart = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("files", filename, body)
.build()
val request = Request.Builder()
.url(api.endpoint("api/albums/$albumId/photos"))
.post(multipart)
.build()
return execute(request).decode<UploadResponseDto>().photos.firstOrNull()
?: throw ApiException(ApiError("empty_response", "server returned no photo", 201))
}
/** Renames a photo; the server returns the updated photo. */
suspend fun renamePhoto(photoId: String, filename: String): PhotoDto =
patchJson("api/photos/$photoId", json.encodeToString(RenamePhotoRequest(filename)))
.decode<PhotoEnvelopeDto>()
.photo
/** Soft-deletes a photo (moves it to the server trash). */
suspend fun deletePhoto(photoId: String) {
execute(Request.Builder().url(api.endpoint("api/photos/$photoId")).delete().build())
}
/** Moves photos to another album in one batch. */
suspend fun movePhotos(photoIds: List<String>, albumId: String) {
postJson("api/photo-batch/move", json.encodeToString(BatchRequest(photoIds, albumId)))
}
/** Copies photos to another album in one batch. */
suspend fun copyPhotos(photoIds: List<String>, albumId: String) {
postJson("api/photo-batch/copy", json.encodeToString(BatchRequest(photoIds, albumId)))
}
/** Updates album attributes (title, description, cover). Omitted fields are left unchanged. */
suspend fun updateAlbum(
albumId: String,
title: String? = null,
description: String? = null,
coverPhotoId: String? = null,
clearCover: Boolean = false,
): AlbumDto {
val body = UpdateAlbumRequest(
title = title,
description = description,
coverPhotoId = coverPhotoId,
clearCover = if (clearCover) true else null,
)
return patchJson("api/albums/$albumId", json.encodeToString(body))
.decode<AlbumEnvelopeDto>()
.album
}
/** Deletes an album; its photos are moved to the server trash. */
suspend fun deleteAlbum(albumId: String) {
execute(Request.Builder().url(api.endpoint("api/albums/$albumId")).delete().build())
}
suspend fun members(albumId: String): List<MemberDto> =
get("api/albums/$albumId/members").decode<MemberListDto>().members
suspend fun shareLinks(albumId: String): List<ShareLinkDto> =
get("api/albums/$albumId/share").decode<ShareLinkListDto>().shareLinks
suspend fun createShareLink(albumId: String, expiresIn: String = "168h"): ShareLinkDto =
postJson("api/albums/$albumId/share", json.encodeToString(ShareLinkRequest(expiresIn)))
.decode<ShareLinkEnvelopeDto>()
.shareLink
suspend fun revokeShareLink(albumId: String, linkId: String) {
execute(
Request.Builder().url(api.endpoint("api/albums/$albumId/share/$linkId")).delete().build(),
)
}
/** Adds a label to a photo and returns the updated photo. */
suspend fun addTag(photoId: String, name: String): PhotoDto =
postJson("api/photos/$photoId/tags", json.encodeToString(AddTagRequest(name)))
.decode<PhotoEnvelopeDto>()
.photo
/** Removes a label from a photo and returns the updated photo. */
suspend fun removeTag(photoId: String, tagId: String): PhotoDto =
execute(
Request.Builder().url(api.endpoint("api/photos/$photoId/tags/$tagId")).delete().build(),
).decode<PhotoEnvelopeDto>().photo
/** Lists the user's labels, optionally filtered by a query. */
suspend fun tags(query: String = ""): List<TagDto> {
val suffix = if (query.isBlank()) "" else "?q=" + urlEncode(query)
return get("api/tags$suffix").decode<TagListDto>().tags
}
/**
* Control sums of an album's photos keyed by checksum, following pagination.
* Used by the device-album sync to avoid re-uploading existing content.
*/
suspend fun albumChecksums(albumId: String): Map<String, String> {
val out = HashMap<String, String>()
var cursor: String? = null
while (true) {
val page = photos(albumId, cursor = cursor, limit = 100)
page.photos.forEach { p ->
val checksum = p.originalChecksum
if (!checksum.isNullOrBlank()) out[checksum] = p.id
}
// The server reports "no more pages" as an empty cursor, not null.
val next = page.nextCursor
if (next.isNullOrBlank()) break
cursor = next
}
return out
}
/** Raw authenticated request for the SSE stream. */
fun sseRequest(albumId: String): Request =
Request.Builder()
.url(api.endpoint("api/sse/albums/$albumId"))
.header("Accept", "text/event-stream")
.build()
/**
* Raw authenticated request for the personal stream (album lifecycle events
* for the signed-in user). Authorization travels in the header, so unlike the
* web client no `token` query parameter is needed.
*/
fun sseMeRequest(): Request =
Request.Builder()
.url(api.endpoint("api/sse/me"))
.header("Accept", "text/event-stream")
.build()
private suspend fun get(path: String): HttpResult =
execute(Request.Builder().url(api.endpoint(path)).get().build())
private suspend fun postJson(path: String, body: String): HttpResult =
execute(
Request.Builder()
.url(api.endpoint(path))
.post(body.toRequestBody(jsonMedia))
.build(),
)
private suspend fun patchJson(path: String, body: String): HttpResult =
execute(
Request.Builder()
.url(api.endpoint(path))
.patch(body.toRequestBody(jsonMedia))
.build(),
)
private suspend fun deleteJson(path: String, body: String): HttpResult =
execute(
Request.Builder()
.url(api.endpoint(path))
.delete(body.toRequestBody(jsonMedia))
.build(),
)
/**
* Executes the request off the main thread and, crucially, buffers the whole
* response body inside the IO dispatcher. Reading the body lazily afterwards
* would hit the socket on the caller's thread and throw
* [android.os.NetworkOnMainThreadException] for large (chunked) responses.
*/
private suspend fun execute(request: Request): HttpResult = withContext(Dispatchers.IO) {
val response = try {
client.newCall(request).execute()
} catch (e: IOException) {
throw ApiException(ApiError("network_error", e.message ?: "network error", 0))
}
val status = response.code
val body = response.use { runCatching { it.body?.string() }.getOrNull() }
if (!response.isSuccessful) {
throw ApiException(ErrorMapper.parse(status, body))
}
HttpResult(status, body)
}
private data class HttpResult(val status: Int, val body: String?)
private inline fun <reified T> HttpResult.decode(): T {
val text = body ?: throw ApiException(
ApiError("empty_response", "empty response body", status),
)
return runCatching { json.decodeFromString<T>(text) }.getOrElse { e ->
throw ApiException(ApiError("invalid_response", "malformed response: ${e.message}", status))
}
}
private fun urlEncode(value: String): String =
java.net.URLEncoder.encode(value, "UTF-8")
}
@@ -0,0 +1,9 @@
package com.galo.android.data.network
import kotlinx.serialization.json.Json
val GaloJson: Json = Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
}
@@ -0,0 +1,33 @@
package com.galo.android.data.network
import com.galo.android.data.network.dto.HealthDto
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
/**
* Standalone `/api/health` probe used during onboarding, before any base URL is
* persisted. Returns the parsed body or throws on transport failure.
*/
class HealthChecker(private val client: OkHttpClient) {
suspend fun check(baseUrl: String): HealthDto = withContext(Dispatchers.IO) {
val url = baseUrl.trim().trimEnd('/') + "/api/health"
val request = Request.Builder().url(url).get().build()
val response = try {
client.newCall(request).execute()
} catch (e: IOException) {
throw IOException("health check failed: ${e.message}", e)
}
response.use {
val body = it.body?.string().orEmpty()
if (!it.isSuccessful) {
throw IOException("health returned HTTP ${it.code}")
}
runCatching { GaloJson.decodeFromString<HealthDto>(body) }
.getOrElse { throw IOException("malformed health response") }
}
}
}
@@ -0,0 +1,23 @@
package com.galo.android.data.network
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
/**
* Resolves the origin-relative object URLs returned by the galo API
* (`/api/objects/<key>?e=..&s=..`) against the configured base URL.
*
* Absolute URLs are passed through untouched; blank inputs yield null.
*/
class ObjectUrlResolver(
private val baseUrlProvider: () -> String?,
) {
fun resolve(raw: String?): String? {
if (raw.isNullOrBlank()) return null
if (raw.toHttpUrlOrNull() != null) return raw
val base = baseUrlProvider()?.trim()?.takeIf { it.isNotEmpty() } ?: return raw
val parsedBase = base.toHttpUrlOrNull() ?: return raw
return parsedBase.resolve(raw)?.toString() ?: raw
}
}
@@ -0,0 +1,42 @@
package com.galo.android.data.network
import com.galo.android.data.network.dto.AlbumDto
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/** Album lifecycle events delivered on the personal stream (`GET /api/sse/me`). */
sealed interface PersonalEvent {
data class AlbumUpserted(val album: AlbumDto) : PersonalEvent
data class AlbumDeleted(val albumId: String) : PersonalEvent
/** Emitted after a dropped connection so the caller can catch up over REST. */
data object Reconnected : PersonalEvent
}
/**
* Maps the personal stream's `event:`/`data:` pairs to typed [PersonalEvent]s.
*
* Only album lifecycle events affect the local album list. Notifications carry an
* unrelated `type` inside their payload and are ignored, as are unknown event
* types. An event whose payload yields no album id is treated as malformed.
*/
object PersonalEventParser {
fun parse(type: String, data: String): PersonalEvent? = when (type) {
"album.created", "album.updated" ->
runCatching { GaloJson.decodeFromString<AlbumDto>(data) }
.getOrNull()
?.takeIf { it.id.isNotBlank() }
?.let { PersonalEvent.AlbumUpserted(it) }
"album.deleted" -> stringField(data, "id")
?.takeIf { it.isNotBlank() }
?.let { PersonalEvent.AlbumDeleted(it) }
else -> null
}
private fun stringField(data: String, field: String): String? = runCatching {
GaloJson.parseToJsonElement(data).jsonObject[field]?.jsonPrimitive?.content
}.getOrNull()
}
@@ -0,0 +1,76 @@
package com.galo.android.data.network
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.sse.EventSource
import okhttp3.sse.EventSourceListener
import okhttp3.sse.EventSources
/**
* Album lifecycle events over the personal SSE stream (`GET /api/sse/me`).
* Reconnects with exponential backoff and emits [PersonalEvent.Reconnected] after
* each drop so the consumer can catch up over REST.
*/
class PersonalSseClient(
private val api: GaloApi,
private val client: OkHttpClient,
private val factory: EventSource.Factory = EventSources.createFactory(client),
) {
fun events(): Flow<PersonalEvent> = channelFlow {
var backoff = INITIAL_BACKOFF_MS
while (true) {
val request = runCatching { api.sseMeRequest() }.getOrNull() ?: break
val closed = CompletableDeferred<Unit>()
val listener = object : EventSourceListener() {
override fun onOpen(eventSource: EventSource, response: Response) {
backoff = INITIAL_BACKOFF_MS
}
override fun onEvent(
eventSource: EventSource,
id: String?,
type: String?,
data: String,
) {
if (type.isNullOrBlank()) return
PersonalEventParser.parse(type, data)?.let { trySend(it) }
}
override fun onClosed(eventSource: EventSource) {
closed.complete(Unit)
}
override fun onFailure(
eventSource: EventSource,
t: Throwable?,
response: Response?,
) {
closed.complete(Unit)
}
}
val source = factory.newEventSource(request, listener)
try {
closed.await()
} finally {
// Runs on a drop and, just as importantly, when the collector is
// cancelled: the connection must not outlive its subscription.
source.cancel()
}
if (isClosedForSend) break
trySend(PersonalEvent.Reconnected)
delay(backoff)
backoff = (backoff * 2).coerceAtMost(MAX_BACKOFF_MS)
}
}
private companion object {
const val INITIAL_BACKOFF_MS = 1_000L
const val MAX_BACKOFF_MS = 30_000L
}
}
@@ -0,0 +1,40 @@
package com.galo.android.data.network
import okhttp3.MediaType
import okhttp3.RequestBody
import okio.BufferedSink
import java.io.InputStream
/**
* Streams a payload from [openStream] without buffering the whole file in memory
* (video originals can be very large) and reports upload progress.
*/
class StreamingRequestBody(
private val length: Long,
private val contentType: MediaType?,
private val openStream: () -> InputStream,
private val onProgress: (sent: Long, total: Long) -> Unit,
) : RequestBody() {
override fun contentType(): MediaType? = contentType
override fun contentLength(): Long = length
override fun writeTo(sink: BufferedSink) {
openStream().use { input ->
val buffer = ByteArray(CHUNK_SIZE)
var sent = 0L
while (true) {
val read = input.read(buffer)
if (read < 0) break
sink.write(buffer, 0, read)
sent += read
onProgress(sent, length)
}
}
}
private companion object {
const val CHUNK_SIZE = 16 * 1024
}
}
@@ -0,0 +1,92 @@
package com.galo.android.data.network
import com.galo.android.data.auth.TokenStore
import com.galo.android.data.network.dto.AuthTokensDto
import com.galo.android.data.network.dto.RefreshRequest
import com.galo.android.data.network.dto.TokenEnvelopeDto
import okhttp3.Authenticator
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.Route
import kotlinx.serialization.encodeToString
/**
* Transparently refreshes the access token on a 401 and retries the original
* request once. A failed refresh clears the session so the UI can return to the
* login screen.
*/
class TokenAuthenticator(
private val tokenStore: TokenStore,
private val refreshClient: OkHttpClient,
private val refreshUrlProvider: () -> String?,
) : Authenticator {
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
override fun authenticate(route: Route?, response: Response): Request? {
// Only attempt refresh for requests that actually carried a token.
if (response.request.header(HEADER_AUTHORIZATION) == null) return null
if (responseCount(response) >= MAX_RETRIES) {
tokenStore.clear()
return null
}
synchronized(this) {
// Another thread may already have refreshed while this one waited.
val current = response.request.header(HEADER_AUTHORIZATION)
val latest = tokenStore.accessToken()?.let { "$BEARER $it" }
if (latest != null && latest != current) {
return response.request.newBuilder()
.header(HEADER_AUTHORIZATION, latest)
.build()
}
val refreshed = refresh() ?: run {
tokenStore.clear()
return null
}
return response.request.newBuilder()
.header(HEADER_AUTHORIZATION, "$BEARER ${refreshed.accessToken}")
.build()
}
}
private fun refresh(): AuthTokensDto? {
val refreshToken = tokenStore.refreshToken() ?: return null
val url = refreshUrlProvider() ?: return null
val payload = GaloJson.encodeToString(RefreshRequest(refreshToken))
val request = Request.Builder()
.url(url)
.post(payload.toRequestBody(jsonMediaType))
.build()
return runCatching {
refreshClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) return null
val body = resp.body?.string() ?: return null
val tokens = GaloJson.decodeFromString<TokenEnvelopeDto>(body).tokens
if (tokens.accessToken.isBlank()) return null
tokenStore.save(tokens.accessToken, tokens.refreshToken)
if (tokens.refreshToken.isBlank()) null else tokens
}
}.getOrNull()
}
private fun responseCount(response: Response): Int {
var count = 1
var prior = response.priorResponse
while (prior != null) {
count++
prior = prior.priorResponse
}
return count
}
private companion object {
const val HEADER_AUTHORIZATION = "Authorization"
const val BEARER = "Bearer"
const val MAX_RETRIES = 2
}
}
@@ -0,0 +1,262 @@
package com.galo.android.data.network.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class HealthDto(
val status: String = "",
val db: String? = null,
val s3: String? = null,
val video: String? = null,
)
@Serializable
data class AuthTokensDto(
@SerialName("access_token") val accessToken: String = "",
@SerialName("refresh_token") val refreshToken: String = "",
@SerialName("token_type") val tokenType: String = "Bearer",
@SerialName("expires_in") val expiresIn: Long = 0,
)
/** Login/register/refresh all wrap the token pair under `tokens`. */
@Serializable
data class TokenEnvelopeDto(
val tokens: AuthTokensDto = AuthTokensDto(),
)
@Serializable
data class UserDto(
val id: String = "",
val email: String? = null,
val phone: String? = null,
val name: String = "",
@SerialName("avatar_url") val avatarUrl: String? = null,
)
@Serializable
data class UserEnvelopeDto(
val user: UserDto = UserDto(),
val preferences: PreferencesDto? = null,
)
@Serializable
data class RegisterRequest(
val email: String = "",
val phone: String = "",
val password: String,
val name: String,
)
@Serializable
data class LoginRequest(
val identifier: String,
val password: String,
)
@Serializable
data class UpdateProfileRequest(
val name: String,
)
@Serializable
data class ChangePasswordRequest(
@SerialName("current_password") val currentPassword: String,
@SerialName("new_password") val newPassword: String,
)
@Serializable
data class AddIdentifierRequest(
val email: String = "",
val phone: String = "",
)
@Serializable
data class DeleteAccountRequest(
val password: String,
)
@Serializable
data class RefreshRequest(
@SerialName("refresh_token") val refreshToken: String,
)
@Serializable
data class LogoutRequest(
@SerialName("refresh_token") val refreshToken: String,
)
@Serializable
data class RenamePhotoRequest(
@SerialName("original_filename") val originalFilename: String,
)
@Serializable
data class BatchRequest(
val ids: List<String>,
@SerialName("album_id") val albumId: String? = null,
)
@Serializable
data class CreateAlbumRequest(
val title: String,
)
@Serializable
data class AlbumDto(
val id: String = "",
val title: String = "",
val description: String = "",
@SerialName("owner_id") val ownerId: String = "",
@SerialName("cover_photo_id") val coverPhotoId: String? = null,
@SerialName("cover_thumb_url") val coverThumbUrl: String? = null,
@SerialName("cover_medium_url") val coverMediumUrl: String? = null,
@SerialName("photos_count") val photosCount: Int = 0,
@SerialName("members_count") val membersCount: Int = 0,
@SerialName("created_at") val createdAt: String? = null,
@SerialName("updated_at") val updatedAt: String? = null,
)
@Serializable
data class AlbumListDto(
val albums: List<AlbumDto> = emptyList(),
)
@Serializable
data class AlbumEnvelopeDto(
val album: AlbumDto,
val role: String? = null,
)
@Serializable
data class PhotoDto(
val id: String = "",
@SerialName("album_id") val albumId: String = "",
@SerialName("original_filename") val originalFilename: String = "",
val kind: String = "",
@SerialName("mime_type") val mimeType: String? = null,
@SerialName("original_checksum") val originalChecksum: String? = null,
val width: Int = 0,
val height: Int = 0,
@SerialName("duration_ms") val durationMs: Long = 0,
val size: Long = 0,
@SerialName("taken_at") val takenAt: String? = null,
val caption: String = "",
val latitude: Double? = null,
val longitude: Double? = null,
val tags: List<TagDto> = emptyList(),
val exif: Map<String, JsonElement> = emptyMap(),
val uploader: UserDto? = null,
@SerialName("thumb_url") val thumbUrl: String? = null,
@SerialName("medium_url") val mediumUrl: String? = null,
@SerialName("preview_url") val previewUrl: String? = null,
@SerialName("playback_url") val playbackUrl: String? = null,
@SerialName("original_url") val originalUrl: String? = null,
@SerialName("download_url") val downloadUrl: String? = null,
@SerialName("created_at") val createdAt: String? = null,
)
@Serializable
data class TagDto(
val id: String = "",
val name: String = "",
)
@Serializable
data class PhotoListDto(
val photos: List<PhotoDto> = emptyList(),
@SerialName("next_cursor") val nextCursor: String? = null,
)
@Serializable
data class PhotoEnvelopeDto(
val photo: PhotoDto,
)
@Serializable
data class UploadResponseDto(
val photos: List<PhotoDto> = emptyList(),
)
@Serializable
data class ErrorEnvelopeDto(
val error: ErrorBodyDto? = null,
)
@Serializable
data class ErrorBodyDto(
val code: String = "internal_error",
val message: String = "internal server error",
)
@Serializable
data class UpdateAlbumRequest(
val title: String? = null,
val description: String? = null,
@SerialName("cover_photo_id") val coverPhotoId: String? = null,
@SerialName("clear_cover") val clearCover: Boolean? = null,
)
@Serializable
data class ShareLinkRequest(
@SerialName("expires_in") val expiresIn: String = "168h",
)
@Serializable
data class ShareLinkDto(
val id: String = "",
@SerialName("album_id") val albumId: String = "",
val token: String = "",
@SerialName("expires_at") val expiresAt: String? = null,
@SerialName("revoked_at") val revokedAt: String? = null,
)
@Serializable
data class ShareLinkEnvelopeDto(
@SerialName("share_link") val shareLink: ShareLinkDto = ShareLinkDto(),
)
@Serializable
data class ShareLinkListDto(
@SerialName("share_links") val shareLinks: List<ShareLinkDto> = emptyList(),
)
@Serializable
data class MemberDto(
@SerialName("user_id") val userId: String = "",
val role: String = "",
val user: UserDto = UserDto(),
)
@Serializable
data class MemberListDto(
val members: List<MemberDto> = emptyList(),
)
@Serializable
data class TagListDto(
val tags: List<TagDto> = emptyList(),
)
@Serializable
data class AddTagRequest(
val name: String,
)
@Serializable
data class PhotoInfoPrefsDto(
val visible: List<String> = emptyList(),
val labels: Map<String, String> = emptyMap(),
)
@Serializable
data class PreferencesDto(
@SerialName("photo_info") val photoInfo: PhotoInfoPrefsDto? = null,
)
@Serializable
data class PreferencesEnvelopeDto(
val preferences: PreferencesDto = PreferencesDto(),
)
@@ -0,0 +1,89 @@
package com.galo.android.data.prefs
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.galo.android.domain.model.AlbumSort
import com.galo.android.domain.model.AlbumSortChoice
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
name = "galo_settings",
)
/** Non-secret connection settings persisted in DataStore. */
data class SettingsState(
val baseUrl: String,
val insecureAllowed: Boolean,
val wifiOnlyUploads: Boolean,
)
/**
* The album-list ordering preference: the chosen key and direction, and where a
* new choice is stored. Narrow on purpose, so callers that only reorder albums
* depend on this rather than on the whole connection settings store.
*/
interface AlbumSortPreference {
val choice: Flow<AlbumSortChoice>
suspend fun setChoice(choice: AlbumSortChoice)
}
class SettingsRepository(context: Context) : AlbumSortPreference {
private val dataStore = context.applicationContext.settingsDataStore
val flow: Flow<SettingsState> = dataStore.data.map { prefs ->
SettingsState(
baseUrl = prefs[KEY_BASE_URL].orEmpty(),
insecureAllowed = prefs[KEY_INSECURE] ?: false,
wifiOnlyUploads = prefs[KEY_WIFI_ONLY] ?: false,
)
}
override val choice: Flow<AlbumSortChoice> = dataStore.data.map { prefs ->
AlbumSortChoice(
sort = AlbumSort.from(prefs[KEY_ALBUM_SORT]),
reversed = prefs[KEY_ALBUM_SORT_REVERSED] ?: false,
)
}
suspend fun setBaseUrl(url: String) {
dataStore.edit { it[KEY_BASE_URL] = url.trim() }
}
suspend fun setInsecureAllowed(allowed: Boolean) {
dataStore.edit { it[KEY_INSECURE] = allowed }
}
suspend fun setWifiOnlyUploads(enabled: Boolean) {
dataStore.edit { it[KEY_WIFI_ONLY] = enabled }
}
/** Stores the album-list ordering; it is a per-device display preference. */
override suspend fun setChoice(choice: AlbumSortChoice) {
dataStore.edit { prefs ->
prefs[KEY_ALBUM_SORT] = choice.sort.name
prefs[KEY_ALBUM_SORT_REVERSED] = choice.reversed
}
}
suspend fun clear() {
dataStore.edit { prefs ->
prefs.remove(KEY_BASE_URL)
prefs.remove(KEY_INSECURE)
}
}
private companion object {
val KEY_BASE_URL = stringPreferencesKey("base_url")
val KEY_INSECURE = booleanPreferencesKey("insecure_allowed")
val KEY_WIFI_ONLY = booleanPreferencesKey("wifi_only_uploads")
val KEY_ALBUM_SORT = stringPreferencesKey("album_sort")
val KEY_ALBUM_SORT_REVERSED = booleanPreferencesKey("album_sort_reversed")
}
}
@@ -0,0 +1,28 @@
package com.galo.android.data.repo
import com.galo.android.data.network.PersonalEvent
import kotlinx.coroutines.flow.Flow
/**
* Applies the personal album stream to the cache. Started once per session and
* cancelled with the scope that runs it, so its lifetime follows the session.
*/
interface LiveAlbumSource {
suspend fun run()
}
/**
* Bridges the personal SSE stream to the album cache: every event is applied to
* [AlbumRepository]. The container builds one of these, and the UI only starts
* and stops collecting it.
*/
class AlbumLiveSync(
private val albums: AlbumRepository,
private val events: Flow<PersonalEvent>,
) : LiveAlbumSource {
/** Collects the stream until cancelled, applying every event to the cache. */
override suspend fun run() {
events.collect { albums.apply(it) }
}
}
@@ -0,0 +1,65 @@
package com.galo.android.data.repo
import com.galo.android.data.mapper.toDomain
import com.galo.android.data.mapper.toEntity
import com.galo.android.data.network.ObjectUrlResolver
import com.galo.android.data.network.PersonalEvent
import com.galo.android.domain.model.Album
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Album browsing with a cache-first flow: the UI always observes Room, and a
* [refresh] fetches the server snapshot when the network is available. A failed
* refresh leaves the cached data intact (offline mode).
*/
class AlbumRepository(
private val remote: AlbumRemoteSource,
private val local: AlbumLocalSource,
private val photos: PhotoLocalSource,
private val resolver: ObjectUrlResolver,
) {
fun observeAlbums(): Flow<List<Album>> =
local.observeEntities().map { entities -> entities.map { it.toDomain(resolver) } }
fun observeAlbum(albumId: String): Flow<Album?> =
observeAlbums().map { albums -> albums.firstOrNull { it.id == albumId } }
/** @return true when the server snapshot was fetched, false when offline. */
suspend fun refresh(): Boolean = try {
val albums = remote.fetchAlbums()
local.replaceAll(albums.map { it.toEntity() })
true
} catch (e: Exception) {
false
}
/** Refreshes a single album (covers metadata the list endpoint may omit). */
suspend fun loadAlbum(albumId: String): Boolean = try {
local.upsert(listOf(remote.fetchAlbum(albumId).toEntity()))
true
} catch (_: Exception) {
false
}
/**
* Applies an album lifecycle event from the personal stream to the cache.
* A created or updated album replaces the cached one from the payload without
* a list request; a deleted album is dropped together with its cached photos
* (there is no foreign key to cascade). After a reconnect the list is re-synced
* so changes missed while the stream was down are reflected.
*/
suspend fun apply(event: PersonalEvent) {
when (event) {
is PersonalEvent.AlbumUpserted -> local.upsert(listOf(event.album.toEntity()))
is PersonalEvent.AlbumDeleted -> {
local.deleteByIds(listOf(event.albumId))
photos.clearAlbum(event.albumId)
}
PersonalEvent.Reconnected -> refresh()
}
}
}
@@ -0,0 +1,64 @@
package com.galo.android.data.repo
import com.galo.android.data.mapper.toDomain
import com.galo.android.data.mapper.toEntity
import com.galo.android.data.network.ObjectUrlResolver
import com.galo.android.data.network.dto.PhotoDto
import com.galo.android.domain.model.Photo
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/** Outcome of a page fetch; [online] is false when the cache was served instead. */
data class PageResult(
val nextCursor: String?,
val online: Boolean,
)
/**
* Album media browsing. Pages fetched by cursor are merged into the local store
* so the grid can be replayed offline, and cache rows are keyed by stable photo
* id so repeated pages do not duplicate entries.
*/
class PhotoRepository(
private val remote: PhotoRemoteSource,
private val local: PhotoLocalSource,
private val resolver: ObjectUrlResolver,
) {
fun observePhotos(albumId: String): Flow<List<Photo>> =
local.observeForAlbum(albumId).map { entities -> entities.map { it.toDomain(resolver) } }
suspend fun loadNextPage(albumId: String, cursor: String?): PageResult = try {
val page = remote.fetchPage(albumId, cursor)
local.upsertAll(page.photos.map { it.toEntity() })
PageResult(nextCursor = page.nextCursor, online = true)
} catch (e: Exception) {
PageResult(nextCursor = null, online = false)
}
/** Catches up on changes missed while an SSE stream was disconnected. */
suspend fun loadSince(albumId: String, since: String): Boolean = try {
val page = remote.fetchSince(albumId, since)
local.upsertAll(page.photos.map { it.toEntity() })
true
} catch (e: Exception) {
android.util.Log.w("PhotoRepository", "loadSince failed", e)
false
}
suspend fun upsert(photo: PhotoDto) {
local.upsertAll(listOf(photo.toEntity()))
}
/** Re-fetches a single photo (e.g. after a social event) and updates the cache. */
suspend fun refreshPhoto(photoId: String): Boolean = try {
local.upsertAll(listOf(remote.fetchPhoto(photoId).toEntity()))
true
} catch (_: Exception) {
false
}
suspend fun remove(photoId: String) {
local.deleteById(photoId)
}
}

Some files were not shown because too many files have changed in this diff Show More