Files
pipu-music/docs/mobile.md
T
mrleo1nid ab49e22357
ci / module boundaries / boundaries (push) Successful in 7s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m2s
ci / ts / ts (packages + web) (push) Successful in 3m8s
ci / desktop / rust (desktop proxy) (push) Successful in 4m21s
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 4m0s
release / desktop (Linux и Windows в релиз) (push) Successful in 11m49s
release / prune (старые файлы и прогоны) (push) Successful in 5s
feat(desktop): add offline cache and verified app updates
Clean release notes in Android and desktop update dialogs. Persist downloaded audio and offline playback events per server and user; add desktop update checks, checksum verification and installer launch.
2026-10-22 22:37:35 +03:00

15 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-совместимость
Анимация волны @shopify/react-native-skia + Reanimated: кадр собирается в UI-потоке (createPicture в useDerivedValue), формулы — общие с вебом (packages/core/src/wave-anim.ts, worklet'ы), отрисовка — src/ui/wave/draw.ts; вне экрана и в фоне кадры не считаются
Серверное состояние 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

Сборка APK: ABI и 16 КБ страницы

  • APK только под arm64-v8a (plugins/withArm64Only.js): нативные модули собираются под одну ABI, а без фильтра в APK попадали неполные каталоги x86, x86_64, armeabi-v7a из библиотек React Native — эмулятор x86 и 32-битные устройства выбирали их и падали при старте (couldn't find DSO to load: libexpo-modules-core.so).
  • Все .so выровнены по 16 КБ: устройства с Android 15+ и 16 КБ страницами памяти не загружают библиотеки с выравниванием 4 КБ, приложение закрывается сразу после запуска.
  • expo-build-properties.android.useLegacyPackaging=true: библиотеки сжаты в APK и распаковываются при установке. Иначе SoLoader на x86_64-эмуляторе с ARM-трансляцией ищет lib/x86_64 внутри APK вместо arm64-v8a и падает с couldn't find DSO to load: libreactnative.so.
  • Доступ к экранам ограничен через Stack.Protected до их отрисовки. Перенаправление из useEffect выполнялось слишком поздно: первый запуск без сервера открывал главную и падал с Error: no session. Регрессия первого запуска, повторного открытия и защищённой ссылки: .maestro/startup.yaml.
  • События прослушивания используют UUIDv7 со случайными байтами из нативного expo.uuidv4(). Web Crypto (crypto.getRandomValues) в Hermes отсутствует: браузерный генератор ронял приложение при восстановлении непустой очереди с компьютера, когда TrackPlayer сообщал о выбранном треке. Генератор передаётся в общий EventBuffer; веб продолжает использовать Web Crypto.

Экраны

Сводка внешнего вида — design.md.

  • Подключение и вход: адрес сервера (проверка GET /server-info: версия сервера, HTTPS, поддерживается ли эта версия приложения), список сохранённых серверов, затем логин, пароль, язык. Далее те же setup-экраны, что и в вебе.
  • Нижняя навигация: Главная · Поиск · Коллекция · Профиль. Мини-плеер над навигацией; свайп вверх — полноэкранный плеер в одном из двух видов (выбор в профиле): cover — крупная обложка (по умолчанию) или wave — визуализация волны; прогресс, лайк / дизлайк, скачать, поделиться ссылкой на /track/{id}, очередь (нижний лист).
  • Поиск: недавние запросы (хранятся локально), подсказка про опечатки и раскладку, значок «скачано» у треков.
  • Главная: кнопка «Моя волна» (orb или liquid по настройке, тот же алгоритм, что в вебе, на Skia; пульс в такт BPM / energy из features трека — на Android спектр недоступен из ExoPlayer без нативного модуля, поэтому анимация управляется метаданными и позицией трека), выбор занятия / настроения.
  • Коллекция: кнопка Плейлисты, затем История, Лайки, Дизлайки, Скачанное. Плейлисты — playlists.md.
  • Коллекция → Скачанное: занято на устройстве / свободно в телефоне, «Скачивать новые лайки», «Скачать все лайки», очередь загрузок с пометкой «по Wi-Fi».
  • Профиль: смена пароля (лист), язык, тема, вид волны, вид плеера, качество звука (лист: Wi-Fi / сотовая / загрузки), «только Wi-Fi для загрузок», кэш недавних треков (размер + «Очистить»), проверка обновлений (точка, если есть новая версия), текущий сервер, «Админка — откроется в браузере» (для admin / moderator), выход.
  • Тема: ui_prefs.theme (dark / light / system); цвета берутся через useColors() из контекста темы (src/theme.ts), до входа — по настройке телефона.
  • Качество звука хранится на устройстве (src/lib/prefs.ts, SecureStore): Wi-Fi (по умолчанию high), сотовая (medium), загрузки (high) → параметр q стрима.
  • Кэш недавних треков — кэш ExoPlayer (cacheDir/TrackPlayer, 500 МБ). Пока сервис плеера жив, SimpleCache держит индекс в памяти, поэтому «Очистить» помечает кэш, а удаляется он при следующем холодном запуске до setupPlayer.
  • Офлайн-баннер вверху, когда нет связи с сервером.
  • Админка открывается в браузере (ссылка в профиле у персонала).

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

  • 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 не удался — перемешанные скачанные треки без дизлайков, лайки ближе к началу.

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

Первая установка — публичная страница /download веб-клиента (/android ведёт туда же): APK, десктопные сборки по ОС, предыдущие версии (до 8 последних релизов, сколько есть), адрес сервера и инструкция.

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

Поток:

  1. При запуске (не чаще раза в 6 ч) и вручную в профиле: GET /api/v1/app/android/latest на сервере Pipu. Сервер сам опрашивает Gitea API (список релизов, до 8 с файлами; fallback — /releases/latest) с серверным токеном, кэширует ответ на 10 мин и APK на диске. Так:
    • мобильному приложению не нужен доступ к Gitea и токен (репозиторий может быть приватным);
    • версия приложения согласована с версией сервера (min_supported из server-info).
  2. Если version_code новее — диалог с changelog (текст из Markdown релиза без служебных CI-блоков сборок), кнопки «Обновить» / «Позже». Если текущая версия ниже 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, приложение молча пропускает проверку.
  • Все версии подписываются одним и тем же ключом — иначе Android откажет в обновлении.
  • versionCode монотонно растёт: major·1 000 000 + minor·10 000 + patch·100 + (99 для выпуска, N для -rc.N).
  • Ассеты релиза: pipu-music-<version>.apk, pipu-music-<version>.apk.sha256.

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

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