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>
12 KiB
12 KiB
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 (
maxCacheSize500 МБ) для недавних стримов. - Аудиофокус: пауза при звонке, приглушение при навигационных подсказках.
Офлайн
- Пользователь скачивает трек, альбом, «все лайки» (опционально — авто-загрузка новых лайков).
- Файлы — во внутреннем хранилище приложения (
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).
Поток:
- При запуске (не чаще раза в 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).
- Если
version_codeновее — диалог с changelog (Markdown из релиза), кнопки «Обновить» / «Позже». Если текущая версия нижеmin_supported— обновление обязательно. - Загрузка APK через
expo-file-systemвcacheDirectoryс прогрессом; проверка SHA-256 (значение из релиза). Хэш считается кусками по 1 МБ (js-sha256), APK целиком в память JS не читается. Загрузка идёт сBearer, сервер отдаёт файл с поддержкойRangeи сам сверяет SHA-256 при кэшировании из Gitea. - Установка:
Intent.ACTION_VIEWсcontent://URI (FileSystem.getContentUriAsync— FileProvider expo-file-system) и флагомFLAG_GRANT_READ_URI_PERMISSIONчерезexpo-intent-launcher(src/lib/update.ts). - Если нет разрешения «установка из неизвестных источников» — открываем
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"для данных с токенами.