Skip to content

Latest commit

 

History

History
574 lines (447 loc) · 36.4 KB

MOBILE.md

File metadata and controls

574 lines (447 loc) · 36.4 KB

Мобильное приложение (Android)

Приложение для сервера видеонаблюдения: выбор сервера, онлайн-просмотр камер и архив записей.

Возможности

Экран Что делает
Серверы Список сохранённых серверов, добавление по IP-адресу, порту или DNS-имени, подключение по QR-коду из панели, проверка связи
Вход Вход по логину и паролю, токен сохраняется для каждого сервера отдельно
Камеры Плитки с превью-кадрами, статус онлайн/офлайн, обновление кадров раз в 10 секунд
Просмотр Онлайн-видео по WebRTC (при неудаче — по HLS), переключение основного и дополнительного потока, звук камеры, двусторонняя связь на камерах с динамиком
PTZ Пульт поворотной камеры: стрелки, зум, стоп и сохранённые положения
Доступ Контроллеры СКУД, открытие двери с подтверждением, журнал проходов
Архив Список записей с фильтром по камере, встроенный плеер, подгрузка при прокрутке
Пропущенные Журнал недозвонённых вызовов с сервера: кто звонил, куда, когда и чем закончилось
Уведомления Push о тревогах и пропущенных звонках: приходят, когда приложение закрыто; нажатие открывает журнал пропущенных

Состояние

Что Состояние
Сборка APK Работает
Установка на телефон Работает
Выбор сервера, проверка связи Работает
Вход, сохранение токена Работает
Список камер с превью Работает
Онлайн-просмотр Работает
Онлайн по WebRTC с откатом на HLS Реализовано, нужна проверка на телефоне
Двусторонняя связь Реализовано для камер с обратным каналом, нужна проверка на телефоне
PTZ Работает на поворотных камерах
Контроль доступа (СКУД) Работает
Архив записей Работает
iOS Не собирался
Тесты 18 тестов на разбор адреса, нативные модули не покрыты

Проверено на реальном телефоне с работающим сервером: подключение к серверу, вход, список камер, просмотр потока и воспроизведение записей.

Чего в приложении нет

Приложение — инструмент просмотра. Для управления нужен веб-интерфейс.

Не реализовано Где это есть
Лента событий детекции Веб-интерфейс, раздел «События»
СКУД: двери, карты, журнал проходов Веб-интерфейс, раздел СКУД
Управление PTZ Веб-интерфейс, пульт в карточке камеры
Настройки камеры (видео, OSD, ночной режим) Веб-интерфейс, карточка камеры
Добавление и правка камер Веб-интерфейс, раздел «Камеры»
Push-уведомления о событиях —
Просмотр архива по таймлайну —
Экспорт и скачивание записей —
Светлая тема —

Подключение к серверу

На экране «Серверы» нажмите Добавить сервер и укажите адрес в любом виде:

Ввод Что получится
192.168.1.10 http://192.168.1.10:8080
192.168.1.10:9000 http://192.168.1.10:9000
nvr.local http://nvr.local:8080
https://cam.example.com https://cam.example.com (порт 443)

Правила разбора:

  • схема не указана → подставляется http
  • порт не указан → подставляется 8080 (порт сервера NVR)
  • переключатель HTTPS добавляет схему, если её не написали вручную

Подключение по QR-коду

Самый короткий путь: ничего не набирать на телефоне. На странице Пользователи в панели нажмите в строке сотрудника Подключить приложение — откроется окно с QR-кодом. В приложении выберите Серверы → Подключить по QR-коду и наведите камеру на этот код.

Что при этом происходит:

  1. Панель просит сервер выдать одноразовый код: он живёт десять минут, срабатывает один раз и привязан к конкретному сотруднику.
  2. В QR-коде лежат адрес сервера, код и логин — адрес берётся из адресной строки панели, поэтому если администратор открыл её по внешнему имени, телефон пойдёт туда же.
  3. Приложение меняет код на обычный токен сессии и сразу входит. Пароль не нужен: код уже означает, что администратор разрешил подключение.

Если камера не читает код с экрана, его можно ввести руками: в форме Добавить сервер вручную есть поле Код подключения. Код нечувствителен к регистру и к разделителю: abcd-2345, ABCD2345 и abcd 2345 — одно и то же. Рядом всё равно нужно указать адрес сервера: сам по себе код его не содержит.

Новый код отменяет прежний неиспользованный — если открыть окно повторно, старый QR перестанет работать. Это сделано намеренно: иначе действительных приглашений накопилось бы столько, сколько раз открывали окно.

Чего в QR-коде нет: пароля учётной записи и долгоживущего секрета. Поэтому снимок экрана или чужой взгляд код не превращает в постоянный доступ — истёк или использован, и он бесполезен.

  • явно указанная схема в поле ввода всегда важнее переключателя

Кнопка Проверить связь обращается к публичному /health сервера — это можно сделать до сохранения, чтобы поймать ошибку в адресе или порту сразу.

Порт 8080 — это API сервера. Обмен SDP идёт через API, но само видео по WebRTC приходит напрямую от медиасервера, поэтому для работы вне домашней сети на роутере нужны два порта:

Порт Протокол Зачем
8080 TCP API: камеры, архив, PTZ, СКУД, обмен SDP
8555 TCP и UDP Видео и звук по WebRTC

Если 8555 закрыт, приложение само перейдёт на HLS: видео будет, но с большей задержкой и без звука.

Сессия и повторный вход

Токен доступа живёт сутки. Обновить его на сервере нечем (эндпоинта refresh нет), поэтому приложение сохраняет логин и пароль и входит автоматически.

Как это работает:

  1. При входе логин и пароль сохраняются на устройстве — рядом с токеном.
  2. При запуске и при любом ответе 401 приложение само повторяет вход сохранёнными данными и повторяет запрос: оператор ничего не замечает.
  3. Если сохранённые данные больше не подходят (сменили пароль или сняли права), открывается экран входа с уже выбранным сервером и подставленным логином, а сверху — пояснение «Срок сессии истёк». Удалять сервер и добавлять заново не нужно: вводить нужно только пароль.
  4. Осознанный выход (кнопка выхода) забывает сохранённые данные: иначе приложение вошло бы обратно само и выход ничего не значил бы.

Пароль лежит в том же хранилище, что и токен (AsyncStorage), а не в системном хранилище ключей: проект обходится без лишних нативных зависимостей. На устройстве без root файлы приложения недоступны другим программам; при работе через интернет хранилище стоит заменить на react-native-keychain.

Как воспроизводится видео

Онлайн. Основной транспорт — WebRTC: задержка меньше секунды и звук приходит внутри потока. Приложение создаёт SDP-оффер и меняет его на ответ через API сервера: POST /api/v1/cameras/{id}/webrtc?stream=sub с заголовком Authorization. Параметр mic=pcmu включает передачу голоса в камеру.

Две тонкости, без которых соединение не поднимается:

  • оффер отправляется только после полного сбора ICE-кандидатов — go2rtc не поддерживает trickle ICE, и без кандидатов телефона ответ придёт успешный, но медиа не пойдёт;
  • кодек аудио задаётся параметром mic=pcmu: камеры OpenIPC принимают G.711.

Если WebRTC не пробился (например, мобильный оператор режет нестандартные порты), плеер сам переходит на HLS: /api/v1/cameras/{id}/hls/index.m3u8?token=<jwt> — токен в query, потому что нативный плеер не умеет отправлять заголовок Authorization. В резервном режиме звука нет: в HLS идёт только видео.

MSE в приложении невозможен: это браузерный API (MediaSource), в Android его нет — поэтому выбор стоит между WebRTC и HLS.

По умолчанию включается дополнительный поток (обычно 704×576): он заметно экономичнее по трафику и заряду батареи. Кнопкой можно переключиться на основной (1920×1080).

Архив. Записи хранятся готовыми файлами MP4, плеер играет их с поддержкой перемотки: сервер отдаёт файл через /api/v1/recordings/file?path=....

HEVC (H.265). Если камера пишет в H.265, часть телефонов такие записи не воспроизводит. Приложение сообщает об этом понятным текстом. Решение — переключить камеру на H.264 в её настройках.

Что приложение НЕ использует

Сервер отдаёт в ответе /cameras/{id}/stream поля, которые с телефона не работают, и приложение их игнорирует:

Поле Почему не используется
webrtc_url Содержит внутренний адрес медиасервера; WebRTC-сессию приложение открывает через API сервера
rtsp_url Содержит localhost:8554, плюс RTSP требует отдельного плеера
snapshot_url Адрес камеры в её локальной сети, недоступен с телефона
mse_url MSE — браузерная технология, в нативном приложении её нет

Ссылки hls_url, main_hls_url, sub_hls_url и url записей сервер отдаёт относительными — приложение приклеивает к ним адрес выбранного сервера.

Сборка

Что нужно

Компонент Версия Проверка
Node.js 18 или новее node -v
JDK 17 (не 21!) java -version
Android SDK платформа 34, build-tools 34 sdkmanager --list_installed

JDK 17 обязателен. На JDK 21 сборка падает с ошибкой jlink executable ... does not exist: Android Gradle Plugin вызывает jlink из JDK 21, а тот несовместим с core-for-system-modules.jar платформы 34.

Установка Android SDK

mkdir -p ~/Android/Sdk/cmdline-tools && cd ~/Android/Sdk/cmdline-tools
curl -sSL -o tools.zip \
  "https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"
unzip -q tools.zip && mv cmdline-tools latest && rm tools.zip

export ANDROID_HOME=$HOME/Android/Sdk
export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools

yes | sdkmanager --licenses
sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0"

Добавьте в ~/.bashrc:

export ANDROID_HOME=$HOME/Android/Sdk
export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools

Сборка APK

cd mobile
npm install

# Путь к SDK — один раз на проект.
echo "sdk.dir=$HOME/Android/Sdk" > android/local.properties

cd android
JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 ./gradlew assembleDebug

Готовый файл: mobile/android/app/build/outputs/apk/debug/app-debug.apk (около 51 МБ).

Почему debug-APK работает без Metro

Обычно debug-сборка React Native берёт JS-скрипт из Metro по сети. Если установить её на телефон без запущенного Metro, приложение падает с ошибкой:

Unable to load script. Make sure you're either running Metro
(run 'npx react-native start') or that your bundle
'index.android.bundle' is packaged correctly for release.

В этом проекте поведение изменено: в android/app/build.gradle список debuggable-вариантов оставлен пустым

react {
    debuggableVariants = []
}

Плагин React Native создаёт задачу сборки бандла только для вариантов, которых нет в этом списке. Пустой список означает, что бандл собирается и для debug, и assets/index.android.bundle попадает в APK. Ошибка не возникает.

Для разработки с hot reload верните строку debuggableVariants = ["debug"] и запускайте npm start плюс adb reverse tcp:8081 tcp:8081.

Размер APK

Сборка Размер Архитектуры
debug ~93 МБ arm64-v8a, armeabi-v7a
release ~54 МБ arm64-v8a, armeabi-v7a

В APK две архитектуры: arm64-v8a — современные телефоны, и armeabi-v7a — 32-битные устройства, на которые иначе обновление не поставить. Каждая архитектура несёт свои копии нативных библиотек, поэтому размер почти удваивается: сборка только под arm64-v8a занимает около 24 МБ. Именно из-за этого пары архитектур размер release вырос выше прежних 24 МБ.

Ограничение задано в android/app/build.gradle:

ndk {
    abiFilters "arm64-v8a", "armeabi-v7a"
}

Свойство reactNativeArchitectures в gradle.properties не помогает: оно управляет только сборкой C++ из исходников. Готовые библиотеки .so из зависимостей попадают в APK для всех архитектур, и отсечь их можно только через abiFilters.

Для запуска на эмуляторе добавьте x86_64:

ndk {
    abiFilters "arm64-v8a", "x86_64"
}

Файл google-services.json

Release-сборка требует файл настроек Firebase: из него приложение берёт идентификаторы проекта для push-уведомлений. В репозитории его нет — он у каждой сборки свой (в .gitignore).

Где взять: console.firebase.google.com → ваш проект → «Добавить приложение» → Android → имя пакета указать точно такое, как applicationId в mobile/android/app/build.gradle (сейчас com.open_nvr). После создания скачается файл, его надо положить в mobile/android/app/google-services.json.

С пакетом нужно определиться один раз: маркеты требуют уникальный идентификатор навсегда, а в Firebase приложение регистрируется под конкретный пакет. Смена пакета означает новое приложение в Firebase и новый файл настроек.

Ключ для отправки push с сервера — другой файл: консоль Firebase → «Настройки проекта» → «Сервисные аккаунты» → «Создать новый закрытый ключ». Он кладётся в secrets/fcm-service-account.json — каталог secrets/ есть в .gitignore, в репозиторий ключ не попадает. Путь к файлу задаётся переменной FCM_CREDENTIALS_FILE в .env (по умолчанию — /secrets/fcm-service-account.json внутри контейнера, каталог монтируется туда из docker-compose.yml). Это секрет: им можно отправлять уведомления от имени проекта.

Вместо старой «серверной пары ключей» используется именно сервисный аккаунт — прежний способ Google отключил.

Как проверить, что сервер увидел ключ:

# при старте в журнале появляется строка с проектом из ключа
docker logs nvr-backend-1 2>&1 | grep -i "push-уведомления"
# → push-уведомления в мобильное приложение включены project=open-nvr

# тестовая отправка на все зарегистрированные устройства
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
  -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' | jq -r .token)
curl -s -X POST http://localhost:8080/api/v1/settings/notifications/push/test \
  -H "Authorization: Bearer $TOKEN"
# → {"ok":true,"chat_name":"отправлено на 1 устройств"}

Если вместо этого в интерфейсе видно «ключ Firebase не настроен», значит файла нет по пути из FCM_CREDENTIALS_FILE (проверьте docker exec nvr-backend-1 ls -la /secrets/) — после копирования ключа контейнер нужно пересоздать, ключ читается один раз при старте. Права на файл: 600, владелец root — читает его процесс в контейнере от root.

Ключ нужен не только для уведомлений о тревогах: им же сервер «будит» замороженное приложение перед входящим вызовом домофона. Без ключа телефон в фоне не получает вызов — трубка звонит, а приложение молчит.

Как работают push-уведомления

  1. Приложение при входе спрашивает разрешение на уведомления, получает у Firebase токен устройства и отправляет его на сервер (POST /api/v1/notifications/register). Токен привязан к человеку, а не к телефону: у одного сотрудника может быть несколько устройств.
  2. Сервер отправляет тревоги и пропущенные звонки через FCM HTTP v1 (backend/internal/notify). Настройки канала — на странице Уведомления → Мобильное приложение: какие события присылать, с каких камер, порог уверенности, тихие часы.
  3. В закрытом и свёрнутом приложении уведомление показывает система — это её работа, приложению для этого ничего делать не нужно. Когда приложение открыто, система уведомления не показывает (иначе оно всплывало бы поверх него), и его показывает собственный модуль NvrPushNotify (PushNotifyModule.kt) — тем же текстом, что прислал сервер.
  4. Нажатие на уведомление о пропущенном звонке открывает экран Пропущенные; список берётся с сервера (GET /api/v1/sip/calls), потому что журнал звонков общий для всех устройств, а не хранится в телефоне.
  5. При выходе из аккаунта приложение снимает регистрацию устройства: иначе тревоги продолжали бы приходить на телефон человека, который уже вышел.

Звонок в домофон будит телефон

Для входящего звонка путь другой, и это важно знать: сообщение приходит без уведомления, только с данными и с высоким приоритетом. Причина простая — уведомление система показывает сама и приложению не отдаёт, пока оно свёрнуто, а нам нужно, чтобы приложение проснулось: в блокировке прошивка замораживает процесс, SIP-соединение закрывается, и звонок не доходит вовсе.

Проснувшись, приложение показывает вызов на весь экран (снова поверх блокировки), поднимает линию и принимает звонок. Станция в это время повторяет набор несколько секунд, поэтому вызов не теряется, пока телефон регистрируется. Обработчик живёт в src/net/pushBackground.ts и регистрируется в index.js — до запуска интерфейса, потому что сообщение может прийти и тогда, когда интерфейса нет вовсе.

Предпросмотр: видео до ответа

Панель присылает картинку в INVITE, но станция соединяет медиа сторон только после ответа вызываемого — до этого видео физически не доходит. Поэтому приложение отвечает на вызов сразу и молча: картинка с калитки видна сразу, микрофон и своя камера выключены до нажатия «Ответить». В этом состоянии доступна кнопка «Открыть дверь» — впустить гостя можно, не вступая в разговор.

Автоответ включается только тогда, когда вызывающий действительно предложил видео: иначе панель считала бы вызов принятым и перестала звонить дальше по группе. Что решило приложение, видно в журнале телефона: [sip] предложение вызова: видео=да, источник=INVITE.

Кнопка «Сохранить и проверить» на странице настроек отправляет пробное уведомление на все зарегистрированные телефоны — это самая быстрая проверка после установки приложения.

Значок приложения

Иконка собирается из логотипа скриптом:

python3 scripts/make_app_icon.py

Исходник — mobile/assets/app-logo.jpeg. Из него берётся только значок камеры: подпись «OPEN-NVR» на иконке не читается (в лентчере значок показывают 60–80 px), поэтому текст отрезан. Скрипт пишет:

  • res/mipmap-*/ic_launcher.png и ic_launcher_round.png — значки для старых версий Android;
  • res/mipmap-*/ic_launcher_foreground.png и res/mipmap-anydpi-v26/*.xml — адаптивная иконка для Android 8+ (система сама обрезает её под свою форму);
  • webui/public/favicon.png — значок вкладки браузера.

После сборки значков нужно пересобрать APK: ./gradlew assembleRelease. Значок уведомлений берётся из того же файла — в манифесте указано com.google.firebase.messaging.default_notification_icon = @mipmap/ic_launcher.

Установка на телефон

  1. Включите на телефоне Параметры → О телефоне → 7 раз нажать на «Номер сборки» — появятся параметры разработчика.
  2. В параметрах разработчика включите Отладка по USB.
  3. Подключите телефон кабелем и проверьте, что он виден:
adb devices
  1. Установите приложение:
adb install -r mobile/android/app/build/outputs/apk/debug/app-debug.apk

Флаг -r переустанавливает поверх предыдущей версии, сохраняя настройки.

Либо просто перенесите APK на телефон и откройте его файловым менеджером — потребуется разрешить установку из неизвестных источников.

Запуск с live-reload

Для разработки удобнее Metro, а не сборка APK на каждое изменение:

cd mobile
npm start                          # в одном терминале

# во втором — при подключённом телефоне
cd android
JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 ./gradlew installDebug
adb reverse tcp:8081 tcp:8081      # Metro доступен телефону

Не забудьте про debuggableVariants = ["debug"] — иначе приложение использует встроенный бандл и правки на лету не подхватываются.

Структура

mobile/
  App.tsx                    навигация между экранами
  src/
    theme.ts                 цвета и типографика
    types.ts                 типы ответов API
    net/
      address.ts             разбор адреса сервера, сборка ссылок
      api.ts                 клиент REST API, перевод ошибок
    storage/
      servers.ts             сохранение серверов и токенов
    state/
      AppContext.tsx         выбранный сервер, токен, клиент
    screens/
      ServersScreen.tsx      выбор и добавление сервера
      LoginScreen.tsx        вход
      CamerasScreen.tsx      список камер с превью
      LiveScreen.tsx         онлайн-просмотр
      ArchiveScreen.tsx      архив записей

Проверка кода

cd mobile
npm test                             # тесты разбора адреса
npx tsc --noEmit                     # типы
npx eslint . --ext .ts,.tsx          # стиль

Тесты покрывают разбор адреса сервера и сборку ссылок — это самая чувствительная к ошибкам часть: неправильный адрес ломает всё приложение, а проверить его на телефоне глазами трудно. Нативные модули подменены заглушками в jest.setup.js, чтобы тесты шли без Android.

Решения, о которых стоит знать

Адрес сервера нормализуется. Пользователь вводит адрес как удобно, приложение приводит его к виду http://хост:порт. Так в базе не появляется одновременно 192.168.1.10 и http://192.168.1.10:8080.

Класс URL не используется. В Hermes (движок JavaScript в React Native) он реализован частично: обращение к hostname и другим полям падает с ошибкой URL.hostname is not implemented. Разбор адреса сделан регулярными выражениями — они работают одинаково и в браузере, и в Hermes. При правках в src/net/address.ts не заменяйте их на new URL().

Токен хранится отдельно для каждого сервера. Переключение между серверами не требует повторного входа, а выход с одного не затрагивает другие.

Токены лежат в AsyncStorage. Это осознанный выбор ради отсутствия лишних зависимостей: без root файлы приложения недоступны другим программам. При работе через публичные сети стоит заменить хранилище на react-native-keychain.

Превью кадров идут через очередь. Одновременно запрашивается не больше трёх кадров: камеры OpenIPC отказывают при большом числе параллельных запросов, и в списке появлялись бы пустые плитки. Место освобождается только после получения картинки или ошибки — иначе очередь не ограничивает нагрузку.

JS-бандл встраивается и в debug-сборку. По умолчанию debug-сборка React Native берёт скрипт из Metro по сети и падает с ошибкой «Unable to load script», если Metro не запущен. Список debuggableVariants оставлен пустым, поэтому бандл собирается для всех вариантов и APK работает на телефоне сам по себе. Подробности — в разделе сборки.

В APK только arm64-v8a. Отсекается через abiFilters: свойство reactNativeArchitectures управляет лишь сборкой C++ из исходников и на упаковку готовых .so не влияет.

Навигация собрана вручную. Экранов пять, переходы линейные, поэтому библиотека навигации не нужна: меньше зависимостей и предсказуемое поведение кнопки «назад».

Разрешён незашифрованный трафик (usesCleartextTraffic). Серверы видеонаблюдения обычно работают по HTTP без сертификата, и без этого разрешения Android блокирует запросы к ним.

Плеер встроен в приложение. Так запись смотрится без выхода из приложения, с перемоткой и на весь экран. Плата — зависимость от того, что телефон умеет декодировать: записи в HEVC (H.265) часть устройств не воспроизводит, о чём приложение сообщает понятным текстом. Лечение — переключить камеру на H.264 в её настройках.

Что можно добавить дальше

  • Push-уведомления о событиях детекции — самое полезное для телефона
  • Лента событий с фильтрами и снимками
  • Просмотр архива по таймлайну, а не списком
  • Сохранение записи в галерею телефона
  • Поддержка iOS: код общий, нужна настройка сборки под Xcode
  • Светлая тема для работы днём на улице