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
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.
136 lines
15 KiB
Markdown
136 lines
15 KiB
Markdown
# 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](design.md#android).
|
||
|
||
- **Подключение и вход**: адрес сервера (проверка `GET /server-info`: версия сервера, HTTPS, поддерживается ли эта версия
|
||
приложения), список сохранённых серверов, затем логин, пароль, язык. Далее те же setup-экраны, что и в вебе.
|
||
- **Нижняя навигация**: Главная · Поиск · Коллекция · Профиль. Мини-плеер над навигацией; свайп вверх — полноэкранный
|
||
плеер в одном из двух видов (выбор в профиле): `cover` — крупная обложка (по умолчанию) или `wave` — визуализация волны;
|
||
прогресс, лайк / дизлайк, скачать, поделиться ссылкой на `/track/{id}`, очередь (нижний лист).
|
||
- **Поиск**: недавние запросы (хранятся локально), подсказка про опечатки и раскладку, значок «скачано» у треков.
|
||
- **Главная**: кнопка «Моя волна» (`orb` или `liquid` по настройке, тот же алгоритм, что в вебе, на Skia; пульс в такт BPM / energy из `features` трека — на Android
|
||
спектр недоступен из ExoPlayer без нативного модуля, поэтому анимация управляется метаданными и позицией трека),
|
||
выбор занятия / настроения.
|
||
- **Коллекция**: кнопка **Плейлисты**, затем История, Лайки, Дизлайки, **Скачанное**. Плейлисты — [playlists.md](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"` для данных с токенами.
|