Приложение для сервера видеонаблюдения: выбор сервера, онлайн-просмотр камер и архив записей.
| Экран | Что делает |
|---|---|
| Серверы | Список сохранённых серверов, добавление по 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-коде лежат адрес сервера, код и логин — адрес берётся из адресной строки панели, поэтому если администратор открыл её по внешнему имени, телефон пойдёт туда же.
- Приложение меняет код на обычный токен сессии и сразу входит. Пароль не нужен: код уже означает, что администратор разрешил подключение.
Если камера не читает код с экрана, его можно ввести руками: в форме
Добавить сервер вручную есть поле Код подключения. Код нечувствителен
к регистру и к разделителю: abcd-2345, ABCD2345 и abcd 2345 — одно
и то же. Рядом всё равно нужно указать адрес сервера: сам по себе код его не
содержит.
Новый код отменяет прежний неиспользованный — если открыть окно повторно, старый QR перестанет работать. Это сделано намеренно: иначе действительных приглашений накопилось бы столько, сколько раз открывали окно.
Чего в QR-коде нет: пароля учётной записи и долгоживущего секрета. Поэтому снимок экрана или чужой взгляд код не превращает в постоянный доступ — истёк или использован, и он бесполезен.
- явно указанная схема в поле ввода всегда важнее переключателя
Кнопка Проверить связь обращается к публичному /health сервера — это
можно сделать до сохранения, чтобы поймать ошибку в адресе или порту сразу.
Порт 8080 — это API сервера. Обмен SDP идёт через API, но само видео по WebRTC приходит напрямую от медиасервера, поэтому для работы вне домашней сети на роутере нужны два порта:
Порт Протокол Зачем 8080TCP API: камеры, архив, PTZ, СКУД, обмен SDP 8555TCP и UDP Видео и звук по WebRTC Если
8555закрыт, приложение само перейдёт на HLS: видео будет, но с большей задержкой и без звука.
Токен доступа живёт сутки. Обновить его на сервере нечем (эндпоинта refresh нет), поэтому приложение сохраняет логин и пароль и входит автоматически.
Как это работает:
- При входе логин и пароль сохраняются на устройстве — рядом с токеном.
- При запуске и при любом ответе
401приложение само повторяет вход сохранёнными данными и повторяет запрос: оператор ничего не замечает. - Если сохранённые данные больше не подходят (сменили пароль или сняли права), открывается экран входа с уже выбранным сервером и подставленным логином, а сверху — пояснение «Срок сессии истёк». Удалять сервер и добавлять заново не нужно: вводить нужно только пароль.
- Осознанный выход (кнопка выхода) забывает сохранённые данные: иначе приложение вошло бы обратно само и выход ничего не значил бы.
Пароль лежит в том же хранилище, что и токен (
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.
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-toolscd 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-сборка 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.
| Сборка | Размер | Архитектуры |
|---|---|---|
| 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"
}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.
Ключ нужен не только для уведомлений о тревогах: им же сервер «будит» замороженное приложение перед входящим вызовом домофона. Без ключа телефон в фоне не получает вызов — трубка звонит, а приложение молчит.
- Приложение при входе спрашивает разрешение на уведомления, получает у
Firebase токен устройства и отправляет его на сервер
(
POST /api/v1/notifications/register). Токен привязан к человеку, а не к телефону: у одного сотрудника может быть несколько устройств. - Сервер отправляет тревоги и пропущенные звонки через FCM HTTP v1
(
backend/internal/notify). Настройки канала — на странице Уведомления → Мобильное приложение: какие события присылать, с каких камер, порог уверенности, тихие часы. - В закрытом и свёрнутом приложении уведомление показывает система — это
её работа, приложению для этого ничего делать не нужно. Когда приложение
открыто, система уведомления не показывает (иначе оно всплывало бы
поверх него), и его показывает собственный модуль
NvrPushNotify(PushNotifyModule.kt) — тем же текстом, что прислал сервер. - Нажатие на уведомление о пропущенном звонке открывает экран
Пропущенные; список берётся с сервера (
GET /api/v1/sip/calls), потому что журнал звонков общий для всех устройств, а не хранится в телефоне. - При выходе из аккаунта приложение снимает регистрацию устройства: иначе тревоги продолжали бы приходить на телефон человека, который уже вышел.
Для входящего звонка путь другой, и это важно знать: сообщение приходит без уведомления, только с данными и с высоким приоритетом. Причина простая — уведомление система показывает сама и приложению не отдаёт, пока оно свёрнуто, а нам нужно, чтобы приложение проснулось: в блокировке прошивка замораживает процесс, 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.
- Включите на телефоне Параметры → О телефоне → 7 раз нажать на «Номер сборки» — появятся параметры разработчика.
- В параметрах разработчика включите Отладка по USB.
- Подключите телефон кабелем и проверьте, что он виден:
adb devices- Установите приложение:
adb install -r mobile/android/app/build/outputs/apk/debug/app-debug.apkФлаг -r переустанавливает поверх предыдущей версии, сохраняя настройки.
Либо просто перенесите APK на телефон и откройте его файловым менеджером — потребуется разрешить установку из неизвестных источников.
Для разработки удобнее 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
- Светлая тема для работы днём на улице