Files
pipu-music/docs/mobile.md
T
mrleo1nidandClaude Opus 5.5 756e915ff9 feat(mobile): queue track reactions made offline and sync on reconnect (M5)
Likes and dislikes without a network are kept in a pending_reactions
table (last reaction per track wins) and sent when the connection
returns, instead of being rolled back. Downloaded track DTOs pick up the
new reaction so the offline wave and Downloads reflect it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:08:36 +03:00

12 KiB
Raw Blame History

Android-приложение (apps/mobile)

Стек

Задача Выбор
Фреймворк React Native + Expo SDK, prebuild / dev client (нативный код под контролем). New Architecture выключена, пока react-native-track-player её не поддерживает
Навигация expo-router (файловые маршруты)
Аудио react-native-track-player v4 — ExoPlayer, MediaSession, уведомление, экран блокировки, Android Auto-совместимость
Анимация волны Сейчас — Animated (пульс по BPM / energy, палитра обложки); дальше — @shopify/react-native-skia (порт wave.js)
Серверное состояние TanStack Query (+ persist в MMKV)
Клиентское состояние Zustand (общие сторы из packages/core)
Хранилище expo-secure-store (refresh-токены, список серверов, недавние запросы); expo-sqlite — офлайн-каталог и очередь событий (вместе с загрузками)
Загрузки expo-file-system (фоновые загрузки, resumable)
Сеть @react-native-community/netinfo (Wi-Fi / сотовая → качество)
i18n i18next, словари из packages/i18n
Минимальная версия Android 8.0 (API 26), target — актуальный API

Экраны

Макет — Pipu Android.dc.html, сводка решений — design.md.

  • Подключение и вход: адрес сервера (проверка GET /server-info: версия сервера, HTTPS, поддерживается ли эта версия приложения), список сохранённых серверов, затем логин, пароль, язык. Далее те же setup-экраны, что и в вебе.
  • Нижняя навигация: Главная · Поиск · Коллекция · Профиль. Мини-плеер над навигацией; свайп вверх — полноэкранный плеер в одном из двух видов (выбор в профиле): cover — крупная обложка (по умолчанию) или wave — визуализация волны; прогресс, лайк / дизлайк, скачать, очередь (нижний лист).
  • Поиск: недавние запросы (хранятся локально), подсказка про опечатки и раскладку, значок «скачано» у треков.
  • Главная: кнопка «Моя волна» (orb или liquid по настройке, тот же алгоритм, что в вебе, на Skia; пульс в такт BPM / energy из features трека — на Android спектр недоступен из ExoPlayer без нативного модуля, поэтому анимация управляется метаданными и позицией трека), выбор занятия / настроения.
  • Коллекция: История, Лайки, Дизлайки, Скачанное.
  • Коллекция → Скачанное: занято на устройстве / свободно в телефоне, «Скачивать новые лайки», «Скачать все лайки», очередь загрузок с пометкой «по Wi-Fi».
  • Профиль: смена пароля (лист), язык, тема, вид волны, вид плеера, качество звука (лист: Wi-Fi / сотовая / загрузки), «только Wi-Fi для загрузок», кэш недавних треков (размер + «Очистить»), проверка обновлений (точка, если есть новая версия), текущий сервер, «Админка — откроется в браузере» (для admin / moderator), выход.
  • Офлайн-баннер вверху, когда нет связи с сервером.
  • Админку в мобильном не делаем на первом этапе (ссылка «открыть в браузере»).

Воспроизведение

  • packages/core содержит общую логику очереди, событий и сохранения позиции; мобильный реализует адаптер AudioEngine поверх react-native-track-player.
  • Фоновый сервис плеера (PlaybackService) обрабатывает кнопки уведомления / гарнитуры и шлёт события, даже когда UI выгружен.
  • Если трек скачан — играется локальный файл, иначе стрим с качеством по типу сети.
  • Встроенный кэш ExoPlayer (maxCacheSize 500 МБ) для недавних стримов.
  • Аудиофокус: пауза при звонке, приглушение при навигационных подсказках.

Офлайн

  • Пользователь скачивает трек, альбом, «все лайки» (опционально — авто-загрузка новых лайков).
  • Файлы — во внутреннем хранилище приложения (documentDirectory/offline/<trackId>.opus), не видны другим приложениям.
  • SQLite хранит метаданные скачанных треков (название, исполнитель, обложка-файл, длительность, features) — раздел «Скачанное» и плеер работают полностью без сети.
  • Очередь загрузок: не более 2 параллельно, возобновление после обрыва, «только по Wi-Fi» по умолчанию.
  • Без сети:
    • доступны «Скачанное» и офлайн-волна (локальный упрощённый алгоритм: перемешивание скачанных с учётом лайков и энергии под выбранный контекст);
    • события пишутся в SQLite-очередь и отправляются при появлении сети (POST /events, идемпотентно);
    • реакции (лайк / дизлайк) — оптимистично локально + очередь мутаций.
  • Подписанные ссылки не кэшируются — при восстановлении сети запрашиваются заново.

Реализация (src/offline/):

  • downloads.ts — очередь загрузок: таблица downloads (DTO трека JSON + статус), файлы в documentDirectory/offline/<owner>/, обложка 300 px рядом; owner — хэш «сервер + пользователь». Перед загрузкой берётся свежий DTO (POST /tracks/batch), т.к. подписанная ссылка живёт 12 ч.
  • events.ts — sink для EventBuffer: при сетевой ошибке пачка уходит в pending_events и досылается при появлении сети. Реакции так же: без сети — в pending_reactions (по треку хранится последняя), UI и DTO скачанного обновляются сразу; ответ сервера 4xx откатывает реакцию в UI, при досылке — отбрасывает отложенную.
  • Запуск без сети: AuthSession.restore() берёт последнего пользователя из RefreshStore.getUser (состояние signed_in с offline: true); access-токен получается перед первым запросом, когда сеть вернётся.
  • Офлайн-волна: если POST /wave/sessions не удался — перемешанные скачанные треки без дизлайков, лайки ближе к началу.

Самообновление

Приложение распространяется только через Gitea Releases (не Google Play).

Поток:

  1. При запуске (не чаще раза в 6 ч) и вручную в профиле: GET /api/v1/app/android/latest на сервере Pipu. Сервер сам опрашивает Gitea API (GET /api/v1/repos/{owner}/{repo}/releases/latest) с серверным токеном, кэширует ответ на 10 мин и APK на диске. Так:
    • мобильному приложению не нужен доступ к Gitea и токен (репозиторий может быть приватным);
    • версия приложения согласована с версией сервера (min_supported из server-info).
  2. Если version_code новее — диалог с changelog (Markdown из релиза), кнопки «Обновить» / «Позже». Если текущая версия ниже min_supported — обновление обязательно.
  3. Загрузка APK через expo-file-system в cacheDirectory с прогрессом; проверка SHA-256 (значение из релиза). Хэш считается кусками по 1 МБ (js-sha256), APK целиком в память JS не читается. Загрузка идёт с Bearer, сервер отдаёт файл с поддержкой Range и сам сверяет SHA-256 при кэшировании из Gitea.
  4. Установка: Intent.ACTION_VIEW с content:// URI (FileSystem.getContentUriAsync — FileProvider expo-file-system) и флагом FLAG_GRANT_READ_URI_PERMISSION через expo-intent-launcher (src/lib/update.ts).
  5. Если нет разрешения «установка из неизвестных источников» — открываем Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES для нашего пакета с пояснением.

Требования:

  • В манифесте: REQUEST_INSTALL_PACKAGES (FileProvider добавляет expo-file-system).
  • Сервер: GITEA_URL, GITEA_REPO, GITEA_TOKEN, кэш APK — UPDATES_DIR (/updates). Без них — 404 app.update_unavailable, приложение молча пропускает проверку.
  • Все релизы подписываются одним и тем же ключом (keystore в секретах CI) — иначе Android откажет в обновлении.
  • versionCode монотонно растёт: major·1 000 000 + minor·10 000 + patch·100 + (99 | rc) (см. ci-cd.md).
  • Ассеты релиза: pipu-music-<version>.apk, pipu-music-<version>.apk.sha256.

Безопасность

  • Refresh-токен — expo-secure-store (Android Keystore).
  • HTTPS обязателен; для self-signed сертификатов — возможность добавить пользовательский CA через network_security_config (собирается в CI по флагу) или пиннинг отпечатка, введённого при подключении к серверу.
  • android:allowBackup="false" для данных с токенами.