# 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/.opus`), не видны другим приложениям. - SQLite хранит метаданные скачанных треков (название, исполнитель, обложка-файл, длительность, features) — раздел «Скачанное» и плеер работают полностью без сети. - Очередь загрузок: не более 2 параллельно, возобновление после обрыва, «только по Wi-Fi» по умолчанию. - Без сети: - доступны «Скачанное» и офлайн-волна (локальный упрощённый алгоритм: перемешивание скачанных с учётом лайков и энергии под выбранный контекст); - события пишутся в SQLite-очередь и отправляются при появлении сети (`POST /events`, идемпотентно); - реакции (лайк / дизлайк) — оптимистично локально + очередь мутаций. - Подписанные ссылки не кэшируются — при восстановлении сети запрашиваются заново. **Реализация** (`src/offline/`): - `downloads.ts` — очередь загрузок: таблица `downloads` (DTO трека JSON + статус), файлы в `documentDirectory/offline//`, обложка 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](ci-cd.md#версионирование)). - Ассеты релиза: `pipu-music-.apk`, `pipu-music-.apk.sha256`. ## Безопасность - Refresh-токен — `expo-secure-store` (Android Keystore). - HTTPS обязателен; для self-signed сертификатов — возможность добавить пользовательский CA через `network_security_config` (собирается в CI по флагу) или пиннинг отпечатка, введённого при подключении к серверу. - `android:allowBackup="false"` для данных с токенами.