Files
pipu-music/docs/mobile.md
T
mrleo1nidandClaude Opus 5.5 fda6ed48ab
ci / api / api (push) Successful in 37s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 46s
ci / db / db (push) Failing after 14s
ci / recommender / recommender (push) Successful in 51s
ci / ts / ts (packages + web) (push) Successful in 57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m2s
feat: Android download page, analysis retry and a collapsible search
- /android is a public page with the latest APK, the server address to copy
  and three install steps; linked from the sign-in screen and the profile menu
- failed audio analysis can be retried from the library page, which also shows
  the most common error; the analyzer no longer burns attempts on files that
  are missing because the disk is not mounted
- the top bar search folds into a round button and slides open on click, "/"
  or Ctrl+K

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:55:51 +03:00

119 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |
## Экраны
Макет — `Pipu Android.dc.html`, сводка решений — [design.md](design.md#android).
- **Подключение и вход**: адрес сервера (проверка `GET /server-info`: версия сервера, HTTPS, поддерживается ли эта версия
приложения), список сохранённых серверов, затем логин, пароль, язык. Далее те же setup-экраны, что и в вебе.
- **Нижняя навигация**: Главная · Поиск · Коллекция · Профиль. Мини-плеер над навигацией; свайп вверх — полноэкранный
плеер в одном из двух видов (выбор в профиле): `cover` — крупная обложка (по умолчанию) или `wave` — визуализация волны;
прогресс, лайк / дизлайк, скачать, очередь (нижний лист).
- **Поиск**: недавние запросы (хранятся локально), подсказка про опечатки и раскладку, значок «скачано» у треков.
- **Главная**: кнопка «Моя волна» (`orb` или `liquid` по настройке, тот же алгоритм, что в вебе, на Skia; пульс в такт BPM / energy из `features` трека — на Android
спектр недоступен из ExoPlayer без нативного модуля, поэтому анимация управляется метаданными и позицией трека),
выбор занятия / настроения.
- **Коллекция**: История, Лайки, Дизлайки, **Скачанное**.
- **Коллекция → Скачанное**: занято на устройстве / свободно в телефоне, «Скачивать новые лайки», «Скачать все лайки»,
очередь загрузок с пометкой «по 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`.
Путь каталога взят из KotlinAudio — проверить на устройстве.
- **Офлайн-баннер** вверху, когда нет связи с сервером.
- Админку в мобильном **не делаем** на первом этапе (ссылка «открыть в браузере»).
## Воспроизведение
- `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` не удался — перемешанные скачанные треки без дизлайков, лайки ближе к началу.
## Самообновление
Первая установка — публичная страница `/android` веб-клиента: последний APK, адрес сервера и инструкция.
Приложение распространяется только через 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](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"` для данных с токенами.