diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..c1eb9c94 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: OpenIPC Telegram channels + url: https://openipc.org/our-channels + about: Questions and discussion diff --git a/.github/ISSUE_TEMPLATE/device_report.yml b/.github/ISSUE_TEMPLATE/device_report.yml new file mode 100644 index 00000000..78ff2150 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/device_report.yml @@ -0,0 +1,101 @@ +name: Device report +description: Report how OpenIPC Decoder works on your phone, tablet, TV, set-top box or car device +title: "[Device]: " +labels: ["device-report"] +body: + - type: markdown + attributes: + value: | + Thanks for testing! Reports are collected into the **Verified devices** table in the README. + The app version and build hash are shown in the menu next to **Exit**, for example `v1.28, a9ac8df`. + + - type: dropdown + id: device_type + attributes: + label: Device type + options: + - Phone + - Tablet + - TV + - Set-top box + - Car device / mirror + - Other + validations: + required: true + + - type: input + id: model + attributes: + label: Manufacturer and model + placeholder: "Samsung Galaxy A51 (SM-A515F)" + validations: + required: true + + - type: input + id: android + attributes: + label: Android version / firmware + description: Android version, plus the vendor firmware if it is not stock (ColorOS, SlimBOXtv, ...) + placeholder: "Android 13 / One UI 5.1" + validations: + required: true + + - type: input + id: soc + attributes: + label: SoC and kernel version + description: Settings → About → Kernel version. SoC if known (Amlogic S905X3, Allwinner H313, ...) + placeholder: "Exynos 9611, kernel 4.14.113" + + - type: input + id: app_version + attributes: + label: App version + description: Shown in the menu next to Exit + placeholder: "v1.28, a9ac8df" + validations: + required: true + + - type: dropdown + id: result + attributes: + label: Overall result + options: + - Works + - Works with issues + - Does not work + validations: + required: true + + - type: checkboxes + id: works + attributes: + label: What works + options: + - label: H.264 video + - label: H.265 video + - label: Audio + - label: Quad mode + - label: Remote control / D-pad + - label: WebUI + - label: Screenshot (long press) + + - type: textarea + id: stream + attributes: + label: Camera and stream + description: Camera firmware (OpenIPC or other), resolution, FPS, bitrate, audio codec + placeholder: "OpenIPC on gk7205v300, H.265 1920x1080@25, 2 Mbps, AAC 16 kHz" + + - type: textarea + id: notes + attributes: + label: Problems and notes + description: What did not work or looked wrong (cropped picture, no sound, delay, crash, ...) + + - type: textarea + id: logs + attributes: + label: Log (optional) + description: "With USB/network debugging enabled: `adb logcat -d -s OpenIPCDecoder`" + render: text diff --git a/README.md b/README.md index bc4c0cd5..210809ba 100644 --- a/README.md +++ b/README.md @@ -6,142 +6,213 @@ # OpenIPC Decoder +**English** | [Русский](README.ru.md) | [简体中文](README.zh-CN.md) + [![Build](https://github.com/OpenIPC/decoder/actions/workflows/build.yml/badge.svg)](https://github.com/OpenIPC/decoder/actions/workflows/build.yml) +[![Release](https://img.shields.io/badge/download-latest-blue)](https://github.com/OpenIPC/decoder/releases/tag/latest) [![License](https://img.shields.io/badge/license-Prosperity%203.0-blue)](LICENSE.md) [![Platform](https://img.shields.io/badge/platform-Android%205.0%2B-brightgreen)](https://developer.android.com/about/versions/lollipop) -[![Telegram](https://img.shields.io/badge/Telegram-OpenIPC-blue)](https://openipc.org/our-channels) -Android application for viewing RTSP streams from IP cameras with hardware-accelerated H.264/H.265 decoding. +A small Android app for watching RTSP streams from IP cameras, with hardware H.264/H.265 decoding. It is built for cameras running [OpenIPC](https://openipc.org) firmware and works on phones, tablets, TVs, set-top boxes and car devices.

Application screenshot

+## Download + +Ready-to-install builds are in the [**latest** release](https://github.com/OpenIPC/decoder/releases/tag/latest): + +| File | What it is | +|------------------|-------------------------------------------------------------| +| `decoder-v2.apk` | **Current version**, recommended | +| `decoder.apk` | Legacy version from the `basic` branch | +| `*.aab` | Android App Bundles for Google Play; not for manual install | + +A beta channel is available on [Google Play](https://play.google.com/apps/internaltest/4701231141824898735). To join it, send your e-mail through the [OpenIPC Telegram channels](https://openipc.org/our-channels). + ## Features -- **Video**: RTSP playback, H.264/H.265 hardware decoding via MediaCodec -- **Audio**: PCM, AAC (ADTS), G.711 -- **Display modes**: Single camera, Quad (4 cameras simultaneously) -- **Controls**: Pinch-to-zoom, pan, double-tap reset, Android TV remote (Leanback) -- **Network**: TCP, automatic reconnection with exponential backoff, Basic auth -- **WebUI**: Built-in browser for camera web interface access -- **Additional**: Long-press screenshot, status indicators, object pooling, 4 camera slots +- **Video:** RTSP playback, hardware H.264/H.265 decoding via MediaCodec +- **Audio:** AAC (RFC 3640 `mpeg4-generic`, ADTS), G.711 μ-law/A-law, L16 PCM (mono/stereo) +- **Display:** single camera, or Quad mode with 4 cameras at once +- **Controls:** pinch-to-zoom, pan, double-tap to reset, Android TV remote (Leanback launcher) +- **Network:** automatic reconnection with exponential back-off, connection-quality indicator +- **WebUI:** built-in browser for the camera's web interface +- **More:** long-press screenshot, 4 camera slots, URLs can be shared from other apps ## Requirements - Android 5.0 (API 21) or higher -- IP camera with RTSP support +- An IP camera with RTSP over TCP (see [Known limitations](#known-limitations)) -## Quick Start +## Quick start -1. Install the application on your Android device -2. Launch the application -3. Tap the screen to open the menu -4. Configure camera URL in the format: +1. Install the APK from the [latest release](https://github.com/OpenIPC/decoder/releases/tag/latest) and launch the app. +2. Tap the screen, or press **OK** on a remote, to open the menu. +3. Choose a camera number (**1**–**4**), then press **Settings** and enter the camera URL: ``` - rtsp://username:password@ip_address:port/stream + rtsp://username:password@ip_address:554/stream=0 ``` -5. Select transport (TCP only) -6. Tap camera number to activate +4. Press **Save**. The app connects straight away and reconnects automatically if the stream drops. -## Building +## Controls -```bash -cd android && ./gradlew assembleRelease -``` +### Menu -### Signing +| Item | Action | +|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Q** | Turn Quad mode on or off | +| **1**–**4** | Switch to a camera slot. The active slot's number shows the connection quality: green under 100 ms of jitter, yellow 100–300 ms, red above 300 ms. A grey number is an empty slot. | +| **Settings** | Edit the RTSP URL of the active slot (single-camera mode only) | +| **WebUI** | Open the camera's web interface; credentials come from the RTSP URL (single-camera mode only) | +| **Exit** | Close the app. The small text next to it is the app version and build hash. | -Set environment variables for release signing: +### Gestures and keys -```bash -export KEYSTORE_PASS=your_password -export KEY_ALIAS=your_alias -export KEY_PASS=your_key_password -``` +| Input | Action | +|--------------------------|-----------------------------------------| +| Tap / **OK** / **Enter** | Open or close the menu | +| **Back** / **Esc** | Close the menu | +| Pinch, then drag | Zoom (up to 5×) and pan | +| Double tap | Reset zoom | +| Long press | Save a screenshot to `Pictures/OpenIPC` | -## Settings +### Opening a camera from another app -### Camera Menu +The app accepts plain-text shares (`ACTION_SEND`, `text/plain`). Sharing an `rtsp://` URL from another app, for example OpenIPC Network, puts it into the active slot. -- **1-8** — camera slot selection -- **Settings** — advanced settings -- **Transport** — set RTSP URL -- **Quad** — enable/disable quad mode -- **WebUI** — open camera web interface -- **Long-press** — take screenshot -- **Exit** — exit application +### Quad mode -### Quad Mode +Shows all configured slots in a 2×2 grid. Each cell has its own connection and decoder. Audio is off in this mode. Tap any cell to open the menu. -Simultaneous viewing of up to 4 cameras with individual settings per cell. TCP transport only. Audio is disabled to reduce resource usage. +## Known limitations -## Architecture +> [!IMPORTANT] +> These are known gaps that we plan to address. Each one has an issue — comments, test results and pull requests are welcome. +> +> - **RTSP over TCP only.** No UDP or multicast — [#7](https://github.com/OpenIPC/decoder/issues/7) +> - **Basic authentication only.** Cameras that require Digest auth will not connect — [#8](https://github.com/OpenIPC/decoder/issues/8) +> - **Video codec is detected by payload type number** (96 = H.264, 97 = H.265), not from the SDP. H.265 sent on PT 96 will not play — [#9](https://github.com/OpenIPC/decoder/issues/9) +> - **Simplified RTP handling for video:** no STAP-A/AP aggregation, packets with CSRC or header extensions are dropped, no packet-loss recovery — [#10](https://github.com/OpenIPC/decoder/issues/10) +> - **No RTSP keepalive.** Servers that enforce a session timeout may drop the stream periodically — [#11](https://github.com/OpenIPC/decoder/issues/11) +> - **No audio in Quad mode** — [#12](https://github.com/OpenIPC/decoder/issues/12) +> - **Only 4 camera slots** — [#13](https://github.com/OpenIPC/decoder/issues/13) +> - **Audio formats not supported:** MP4A-LATM, Opus, G.726, and AAC frames split across RTP packets — [#14](https://github.com/OpenIPC/decoder/issues/14) +> - **Possible dependency on Google services (GAPPS)** on some devices; under investigation, so disable GAPPS with care — [#15](https://github.com/OpenIPC/decoder/issues/15) +> - **Credentials are stored and sent in clear text** (plain RTSP/HTTP with Basic auth) — [#16](https://github.com/OpenIPC/decoder/issues/16) +> +> Known bugs are tracked separately under the [`bug` label](https://github.com/OpenIPC/decoder/issues?q=is%3Aissue+is%3Aopen+label%3Abug). -Multi-threaded processing on a fixed thread pool (5 threads): network I/O, watchdog, video decoding, audio playback, AAC decoding. Hardware video decoding via MediaCodec. Object pooling (`FramePool`) minimizes GC pressure. Quad mode creates dedicated per-cell pipelines (3 threads each). +## Branches -## Security +| Branch | Status | +|---------|----------------------------------------------------------------------------------------------------------------| +| `v2` | **Main development branch** (default). Modular code in `android/`. Builds are published as `decoder-v2.apk`. | +| `basic` | Legacy single-file version (1.0x), kept for reference and older setups. Builds are published as `decoder.apk`. | -- Credentials stripped from RTSP request URIs, sent only in `Authorization: Basic` header -- Resource cleanup on pause/stop: sockets closed, executor threads shut down, codecs stopped/released -- Global `UncaughtExceptionHandler` prevents silent crashes +Please base pull requests on `v2`. -## Device Compatibility +## Building -**Please send information about devices where the program was tested only in this format:** +You need JDK 17 and the Android SDK (platform 36). Point the build at the SDK with `ANDROID_HOME` or `android/local.properties`. +```bash +cd android +./gradlew assembleDebug # debug build, no signing keys needed +# → app/build/outputs/apk/debug/app-debug.apk ``` -Device type, Manufacturer and Model, Android Version, Kernel Version + +A release build needs a keystore at `android/key.jks` and these environment variables: + +```bash +export KEYSTORE_PASS=your_password +export KEY_ALIAS=your_alias +export KEY_PASS=your_key_password +./gradlew assembleRelease # → app/build/outputs/apk/release/app-release.apk ``` -### Verified Devices - -- Phones - - Asus ZC553KL, Android 8.1, Kernel 3.18.71 - - Blackview BV4900Pro, Android 12, Kernel 4.19.191 - - Oppo 5X, ColorOS 15.0, Kernel 5.15.149 - - Oppo A17, ColorOS 12.1, Kernel 4.19.191 - - Samsung Galaxy A51, Android 13, Kernel 4.14.113 - - Samsung Galaxy M21 (SM-M215F/DSN), Android 12, Kernel 4.14.113 - - Samsung Galaxy S25, Android 15, Kernel 6.6.30 - - Samsung A55 5G, Android 14, Kernel 6.1.93 - - Samsung Galaxy J7, Android 10, Kernel 3.18.150 - - Samsung S8, Android 9, Kernel 4.4.153 - - Samsung S23 Ultra, Android 15, Kernel 5.15.153 - - Xiaomi Redmi 7A, Android 10, Kernel 4.9.261 - - Xiaomi Redmi Note 7 (M1901F7G), Android 10, Kernel 4.4.192 - - Xiaomi Redmi Note 13 4G, Android 13, Kernel 5.15.94 -- Tablets - - Lenovo TB-X304L, Android 8.1.0, Kernel 3.18.71 (image cropped at edges) - - Lenovo TB-X606F, Android 10, Kernel unknown (image cropped at edges) - - Lenovo Tab P11, Android 11, Kernel 4.19.157-perf+ (image cropped at edges) - - Samsung Galaxy Tab A, Android 7.1.1, Kernel 3.10.49 - - Samsung Galaxy Tab A, Android 11, Kernel 4.9.227 - - Samsung Tab A7 (SM-T505), Android 12, Kernel 4.19.157-perf- -- TVs and Set-top Boxes - - A95X F3 AIR, Android 9, Kernel 4.9.113, SlimBOXtv AOSP 9.14 without GAPPS - - IE X3 Air, Android 9, Kernel 4.9.113, SlimBOXtv AOSP 9.14 without GAPPS - - Haier Candy Android TV 2K, Android 11, Kernel 4.9.243+ (no icon in device apps) - - SberBox SBDV-00001, Custom ?, Kernel 4.9.228 - - TV Box X96Q, Android 10, Kernel 4.9.170 (original H313 SoC) - - Yandex TV Novex NVX-55U169TSY, Android 7.1.1, Kernel 4.4.3 -- Car Devices - - Media Center JCAC10003, Android 12, Kernel 3.18.79+ (SoC ac8227/ac8229) - - Mirror Z55, Android 8.1.0, Kernel 4.4.83 - -## GAPPS Compatibility Note - -It has been noticed that the Decoder application may sometimes use some GAPPS components/libraries, so do not disable them or do it consciously. Research in this area is highly encouraged! +Debug and release builds are signed with different keys. To switch from one to the other, uninstall the app first; its saved camera URLs are removed with it. + +**CI:** every push is built by GitHub Actions. Pushes to `v2` and `basic` update the [latest](https://github.com/OpenIPC/decoder/releases/tag/latest) release and notify the OpenIPC Telegram channel. Other branches only attach the APK as a workflow artifact on the run page. + +## Architecture + +| Class | Role | +|-------------------------------|-------------------------------------------------------------------------| +| `Decoder` | Main activity: UI, lifecycle, settings, video decode thread | +| `RtspClient` | RTSP handshake, RTP-over-TCP receive loop, watchdog, SDP parsing | +| `NalAssembler` | Rebuilds H.264/H.265 NAL units from RTP fragmentation units | +| `MediaCodecManager` | Hardware video decoder lifecycle | +| `AudioManager`, `AudioConfig` | Audio pipeline: G.711/L16 conversion, AAC decoding, AudioTrack playback | +| `QuadCell` | A self-contained stream (network, watchdog, decoder) for one Quad cell | +| `MenuManager` | Popup menu, URL editor, WebUI browser | +| `FramePool` | Buffer pool that reduces garbage-collector load | + +Network, video decoding and audio run on separate threads, so a slow decoder never blocks the socket. + +## Security notes + +- Credentials are taken from the URL and sent as an HTTP/RTSP `Authorization: Basic` header. They are not included in the RTSP request line. +- Plain RTSP and HTTP are not encrypted. Use the app only on trusted networks, see [#16](https://github.com/OpenIPC/decoder/issues/16). +- Camera URLs, including passwords, are kept in the app's private storage. Backup is disabled (`allowBackup="false"`). + +## Device compatibility + +Tested the app on your device? Please [**submit a Device report**](https://github.com/OpenIPC/decoder/issues/new?template=device_report.yml). It takes a minute and helps other users. + +### Verified devices + +The devices below were reported with the legacy 1.0x version (`basic` branch). New reports should include the app version. + +| Type | Device | Android | Kernel | App | Notes | +|-------------|----------------------------------------|-----------------------------------|----------------|------|----------------------------------------------------------------------| +| Phone | Asus ZC553KL | 8.1 | 3.18.71 | 1.0x | | +| Phone | Blackview BV4900Pro | 12 | 4.19.191 | 1.0x | | +| Phone | Oppo 5X | ColorOS 15.0 | 5.15.149 | 1.0x | | +| Phone | Oppo A17 | ColorOS 12.1 | 4.19.191 | 1.0x | | +| Phone | Samsung Galaxy A51 | 13 | 4.14.113 | 1.0x | | +| Phone | Samsung Galaxy M21 (SM-M215F/DSN) | 12 | 4.14.113 | 1.0x | | +| Phone | Samsung Galaxy S25 | 15 | 6.6.30 | 1.0x | | +| Phone | Samsung A55 5G | 14 | 6.1.93 | 1.0x | | +| Phone | Samsung Galaxy J7 | 10 | 3.18.150 | 1.0x | | +| Phone | Samsung S8 | 9 | 4.4.153 | 1.0x | | +| Phone | Samsung S23 Ultra | 15 | 5.15.153 | 1.0x | | +| Phone | Xiaomi Redmi 7A | 10 | 4.9.261 | 1.0x | | +| Phone | Xiaomi Redmi Note 7 (M1901F7G) | 10 | 4.4.192 | 1.0x | | +| Phone | Xiaomi Redmi Note 13 4G | 13 | 5.15.94 | 1.0x | | +| Tablet | Lenovo TB-X304L | 8.1.0 | 3.18.71 | 1.0x | Picture cropped, [#17](https://github.com/OpenIPC/decoder/issues/17) | +| Tablet | Lenovo TB-X606F | 10 | — | 1.0x | Picture cropped, [#17](https://github.com/OpenIPC/decoder/issues/17) | +| Tablet | Lenovo Tab P11 | 11 | 4.19.157-perf+ | 1.0x | Picture cropped, [#17](https://github.com/OpenIPC/decoder/issues/17) | +| Tablet | Samsung Galaxy Tab A | 7.1.1 | 3.10.49 | 1.0x | | +| Tablet | Samsung Galaxy Tab A | 11 | 4.9.227 | 1.0x | | +| Tablet | Samsung Tab A7 (SM-T505) | 12 | 4.19.157-perf- | 1.0x | | +| Set-top box | A95X F3 AIR | 9 (SlimBOXtv AOSP 9.14, no GAPPS) | 4.9.113 | 1.0x | | +| Set-top box | IE X3 Air | 9 (SlimBOXtv AOSP 9.14, no GAPPS) | 4.9.113 | 1.0x | | +| Set-top box | SberBox SBDV-00001 | custom | 4.9.228 | 1.0x | | +| Set-top box | X96Q (Allwinner H313) | 10 | 4.9.170 | 1.0x | | +| TV | Haier Candy Android TV 2K | 11 | 4.9.243+ | 1.0x | No icon in the device's app list | +| TV | Yandex TV Novex NVX-55U169TSY | 7.1.1 | 4.4.3 | 1.0x | | +| Car | Media Center JCAC10003 (ac8227/ac8229) | 12 | 3.18.79+ | 1.0x | | +| Car | Mirror Z55 | 8.1.0 | 4.4.83 | 1.0x | | + +### Not working + +| Type | Device | Android | Kernel | App | Notes | +|-------------|----------------|---------|---------|------|----------| +| Set-top box | Unnamed TV box | 5.1.1 | 3.14.29 | 1.0x | No video | + +## Version history + +Changes are listed on the [releases page](https://github.com/OpenIPC/decoder/releases) and in the [commit history](https://github.com/OpenIPC/decoder/commits/v2). The history of 1.0x versions is in the [`basic` branch README](https://github.com/OpenIPC/decoder/tree/basic#version-history). ## License -[The Prosperity Public License 3.0.0](LICENSE.md). See [LICENSE.md](LICENSE.md) for details. +[The Prosperity Public License 3.0.0](LICENSE.md): free for noncommercial use, with a 30-day trial for commercial use. See [LICENSE.md](LICENSE.md) for the full terms. ## Support +- Questions and bug reports: [GitHub Issues](https://github.com/OpenIPC/decoder/issues) - Documentation: [openipc.org](https://openipc.org) -- Source code: [github.com/OpenIPC/decoder](https://github.com/OpenIPC/decoder) -- Questions and suggestions: via [GitHub Issues](https://github.com/OpenIPC/decoder/issues) -- Telegram: [OpenIPC Channels](https://openipc.org/our-channels) [![Telegram](https://openipc.org/images/telegram_button.svg)](https://openipc.org/our-channels) diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 00000000..dc57b62f --- /dev/null +++ b/README.ru.md @@ -0,0 +1,218 @@ + + + + OpenIPC logo + + +# OpenIPC Decoder + +[English](README.md) | **Русский** | [简体中文](README.zh-CN.md) + +[![Build](https://github.com/OpenIPC/decoder/actions/workflows/build.yml/badge.svg)](https://github.com/OpenIPC/decoder/actions/workflows/build.yml) +[![Release](https://img.shields.io/badge/download-latest-blue)](https://github.com/OpenIPC/decoder/releases/tag/latest) +[![License](https://img.shields.io/badge/license-Prosperity%203.0-blue)](LICENSE.md) +[![Platform](https://img.shields.io/badge/platform-Android%205.0%2B-brightgreen)](https://developer.android.com/about/versions/lollipop) + +Компактное приложение для Android: показывает RTSP-потоки с IP-камер и декодирует H.264/H.265 аппаратно. Создано для камер с прошивкой [OpenIPC](https://openipc.org). Работает на телефонах, планшетах, телевизорах, ТВ-приставках и автомобильных устройствах. + +

+ Снимок экрана приложения +

+ +## Скачать + +Готовые сборки лежат в [релизе **latest**](https://github.com/OpenIPC/decoder/releases/tag/latest): + +| Файл | Что это | +|------------------|----------------------------------------------------| +| `decoder-v2.apk` | **Текущая версия**, рекомендуется | +| `decoder.apk` | Старая версия из ветки `basic` | +| `*.aab` | Пакеты для Google Play; вручную не устанавливаются | + +Есть бета-канал в [Google Play](https://play.google.com/apps/internaltest/4701231141824898735). Чтобы попасть в него, пришлите свой e-mail через [Telegram-каналы OpenIPC](https://openipc.org/our-channels). + +## Возможности + +- **Видео:** воспроизведение RTSP, аппаратное декодирование H.264/H.265 через MediaCodec +- **Звук:** AAC (RFC 3640 `mpeg4-generic`, ADTS), G.711 μ-law/A-law, L16 PCM (моно и стерео) +- **Режимы:** одна камера или Quad — 4 камеры на одном экране +- **Управление:** масштаб двумя пальцами, перемещение, сброс двойным касанием, пульт Android TV (Leanback) +- **Сеть:** автоматическое переподключение с растущей задержкой, индикатор качества связи +- **WebUI:** встроенный браузер для веб-интерфейса камеры +- **Прочее:** скриншот долгим нажатием, 4 слота для камер, приём ссылок из других приложений + +## Требования + +- Android 5.0 (API 21) или новее +- IP-камера с RTSP поверх TCP (см. [Известные ограничения](#известные-ограничения)) + +## Быстрый старт + +1. Установите APK из [релиза latest](https://github.com/OpenIPC/decoder/releases/tag/latest) и запустите приложение. +2. Коснитесь экрана или нажмите **OK** на пульте, чтобы открыть меню. +3. Выберите номер камеры (**1**–**4**), нажмите **Settings** и введите адрес камеры: + ``` + rtsp://username:password@ip_address:554/stream=0 + ``` +4. Нажмите **Save**. Приложение сразу подключится и само переподключится, если поток прервётся. + +## Управление + +### Меню + +| Пункт | Действие | +|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Q** | Включить или выключить режим Quad | +| **1**–**4** | Переключиться на слот камеры. Цвет номера активного слота показывает качество связи: зелёный — джиттер меньше 100 мс, жёлтый — 100–300 мс, красный — больше 300 мс. Серый номер — пустой слот. | +| **Settings** | Изменить RTSP-адрес активного слота (только в режиме одной камеры) | +| **WebUI** | Открыть веб-интерфейс камеры; логин и пароль берутся из RTSP-адреса (только в режиме одной камеры) | +| **Exit** | Закрыть приложение. Мелкий текст рядом — версия приложения и хеш сборки. | + +### Жесты и кнопки + +| Ввод | Действие | +|------------------------------|-----------------------------------------| +| Касание / **OK** / **Enter** | Открыть или закрыть меню | +| **Back** / **Esc** | Закрыть меню | +| Щипок, затем перетаскивание | Увеличение (до 5×) и перемещение | +| Двойное касание | Сбросить масштаб | +| Долгое нажатие | Сохранить скриншот в `Pictures/OpenIPC` | + +### Открытие камеры из другого приложения + +Приложение принимает текст через «Поделиться» (`ACTION_SEND`, `text/plain`). Если поделиться ссылкой `rtsp://` из другого приложения, например из OpenIPC Network, она запишется в активный слот. + +### Режим Quad + +Показывает все настроенные слоты сеткой 2×2. У каждой ячейки своё подключение и свой декодер. Звук в этом режиме выключен. Коснитесь любой ячейки, чтобы открыть меню. + +## Известные ограничения + +> [!IMPORTANT] +> Это известные пробелы, которые мы планируем закрыть. На каждый заведён issue — комментарии, результаты проверок и pull request приветствуются. +> +> - **Только RTSP поверх TCP.** UDP и multicast не поддерживаются — [#7](https://github.com/OpenIPC/decoder/issues/7) +> - **Только Basic-авторизация.** Камеры, которым нужен Digest, не подключатся — [#8](https://github.com/OpenIPC/decoder/issues/8) +> - **Кодек видео определяется по номеру payload type** (96 = H.264, 97 = H.265), а не по SDP. H.265 на PT 96 не воспроизводится — [#9](https://github.com/OpenIPC/decoder/issues/9) +> - **Упрощённая обработка RTP для видео:** нет агрегации STAP-A/AP, пакеты с CSRC или расширением заголовка отбрасываются, потери пакетов не обрабатываются — [#10](https://github.com/OpenIPC/decoder/issues/10) +> - **Нет RTSP keepalive.** Серверы с таймаутом сессии могут периодически обрывать поток — [#11](https://github.com/OpenIPC/decoder/issues/11) +> - **В режиме Quad нет звука** — [#12](https://github.com/OpenIPC/decoder/issues/12) +> - **Только 4 слота для камер** — [#13](https://github.com/OpenIPC/decoder/issues/13) +> - **Не поддерживаются форматы звука** MP4A-LATM, Opus, G.726, а также AAC-кадры, разбитые на несколько RTP-пакетов — [#14](https://github.com/OpenIPC/decoder/issues/14) +> - **Возможная зависимость от сервисов Google (GAPPS)** на некоторых устройствах; изучается, поэтому отключайте GAPPS осознанно — [#15](https://github.com/OpenIPC/decoder/issues/15) +> - **Логин и пароль хранятся и передаются в открытом виде** (RTSP/HTTP без шифрования, Basic-авторизация) — [#16](https://github.com/OpenIPC/decoder/issues/16) +> +> Известные ошибки собраны отдельно, под [меткой `bug`](https://github.com/OpenIPC/decoder/issues?q=is%3Aissue+is%3Aopen+label%3Abug). + +## Ветки + +| Ветка | Назначение | +|---------|-----------------------------------------------------------------------------------------------------------------------| +| `v2` | **Основная ветка разработки** (по умолчанию). Модульный код в `android/`. Сборки публикуются как `decoder-v2.apk`. | +| `basic` | Старая версия 1.0x в одном файле, сохранена для справки и для старых установок. Сборки публикуются как `decoder.apk`. | + +Pull request делайте от ветки `v2`. + +## Сборка + +Нужны JDK 17 и Android SDK (platform 36). Путь к SDK задаётся через `ANDROID_HOME` или `android/local.properties`. + +```bash +cd android +./gradlew assembleDebug # отладочная сборка, ключи подписи не нужны +# → app/build/outputs/apk/debug/app-debug.apk +``` + +Для релизной сборки нужно хранилище ключей `android/key.jks` и переменные окружения: + +```bash +export KEYSTORE_PASS=your_password +export KEY_ALIAS=your_alias +export KEY_PASS=your_key_password +./gradlew assembleRelease # → app/build/outputs/apk/release/app-release.apk +``` + +Отладочная и релизная сборки подписаны разными ключами. Чтобы перейти с одной на другую, сначала удалите приложение; сохранённые адреса камер удалятся вместе с ним. + +**CI:** GitHub Actions собирает каждый push. Push в `v2` и `basic` обновляет релиз [latest](https://github.com/OpenIPC/decoder/releases/tag/latest) и отправляет уведомление в Telegram-канал OpenIPC. Для остальных веток APK прикладывается только как артефакт на странице запуска. + +## Архитектура + +| Класс | Назначение | +|-------------------------------|-----------------------------------------------------------------------------------| +| `Decoder` | Главная activity: интерфейс, жизненный цикл, настройки, поток декодирования видео | +| `RtspClient` | RTSP-рукопожатие, приём RTP поверх TCP, сторожевой таймер, разбор SDP | +| `NalAssembler` | Сборка NAL-блоков H.264/H.265 из RTP-фрагментов | +| `MediaCodecManager` | Жизненный цикл аппаратного видеодекодера | +| `AudioManager`, `AudioConfig` | Звук: преобразование G.711/L16, декодирование AAC, вывод через AudioTrack | +| `QuadCell` | Независимый поток (сеть, сторожевой таймер, декодер) для одной ячейки Quad | +| `MenuManager` | Всплывающее меню, редактор адреса, браузер WebUI | +| `FramePool` | Пул буферов, снижает нагрузку на сборщик мусора | + +Сеть, декодирование видео и звук работают в отдельных потоках, поэтому медленный декодер не блокирует сокет. + +## Безопасность + +- Логин и пароль берутся из адреса и передаются в заголовке `Authorization: Basic`. В строку RTSP-запроса они не попадают. +- RTSP и HTTP без шифрования. Используйте приложение только в доверенных сетях, см. [#16](https://github.com/OpenIPC/decoder/issues/16). +- Адреса камер вместе с паролями хранятся в закрытом хранилище приложения. Резервное копирование отключено (`allowBackup="false"`). + +## Совместимость с устройствами + +Проверили приложение на своём устройстве? [**Отправьте Device report**](https://github.com/OpenIPC/decoder/issues/new?template=device_report.yml) — это займёт минуту и поможет другим пользователям. + +### Проверенные устройства + +Отчёты ниже получены для старой версии 1.0x (ветка `basic`). В новых отчётах указывайте версию приложения. + +| Тип | Устройство | Android | Ядро | Версия | Примечание | +|--------------|--------------------------------------|------------------------------------|----------------|--------|---------------------------------------------------------------------------| +| Телефон | Asus ZC553KL | 8.1 | 3.18.71 | 1.0x | | +| Телефон | Blackview BV4900Pro | 12 | 4.19.191 | 1.0x | | +| Телефон | Oppo 5X | ColorOS 15.0 | 5.15.149 | 1.0x | | +| Телефон | Oppo A17 | ColorOS 12.1 | 4.19.191 | 1.0x | | +| Телефон | Samsung Galaxy A51 | 13 | 4.14.113 | 1.0x | | +| Телефон | Samsung Galaxy M21 (SM-M215F/DSN) | 12 | 4.14.113 | 1.0x | | +| Телефон | Samsung Galaxy S25 | 15 | 6.6.30 | 1.0x | | +| Телефон | Samsung A55 5G | 14 | 6.1.93 | 1.0x | | +| Телефон | Samsung Galaxy J7 | 10 | 3.18.150 | 1.0x | | +| Телефон | Samsung S8 | 9 | 4.4.153 | 1.0x | | +| Телефон | Samsung S23 Ultra | 15 | 5.15.153 | 1.0x | | +| Телефон | Xiaomi Redmi 7A | 10 | 4.9.261 | 1.0x | | +| Телефон | Xiaomi Redmi Note 7 (M1901F7G) | 10 | 4.4.192 | 1.0x | | +| Телефон | Xiaomi Redmi Note 13 4G | 13 | 5.15.94 | 1.0x | | +| Планшет | Lenovo TB-X304L | 8.1.0 | 3.18.71 | 1.0x | Изображение обрезано, [#17](https://github.com/OpenIPC/decoder/issues/17) | +| Планшет | Lenovo TB-X606F | 10 | — | 1.0x | Изображение обрезано, [#17](https://github.com/OpenIPC/decoder/issues/17) | +| Планшет | Lenovo Tab P11 | 11 | 4.19.157-perf+ | 1.0x | Изображение обрезано, [#17](https://github.com/OpenIPC/decoder/issues/17) | +| Планшет | Samsung Galaxy Tab A | 7.1.1 | 3.10.49 | 1.0x | | +| Планшет | Samsung Galaxy Tab A | 11 | 4.9.227 | 1.0x | | +| Планшет | Samsung Tab A7 (SM-T505) | 12 | 4.19.157-perf- | 1.0x | | +| ТВ-приставка | A95X F3 AIR | 9 (SlimBOXtv AOSP 9.14, без GAPPS) | 4.9.113 | 1.0x | | +| ТВ-приставка | IE X3 Air | 9 (SlimBOXtv AOSP 9.14, без GAPPS) | 4.9.113 | 1.0x | | +| ТВ-приставка | SberBox SBDV-00001 | своя | 4.9.228 | 1.0x | | +| ТВ-приставка | X96Q (Allwinner H313) | 10 | 4.9.170 | 1.0x | | +| Телевизор | Haier Candy Android TV 2K | 11 | 4.9.243+ | 1.0x | Нет значка в списке приложений | +| Телевизор | Yandex TV Novex NVX-55U169TSY | 7.1.1 | 4.4.3 | 1.0x | | +| Авто | Медиацентр JCAC10003 (ac8227/ac8229) | 12 | 3.18.79+ | 1.0x | | +| Авто | Зеркало Z55 | 8.1.0 | 4.4.83 | 1.0x | | + +### Не работает + +| Тип | Устройство | Android | Ядро | Версия | Примечание | +|--------------|---------------------------|---------|---------|--------|------------| +| ТВ-приставка | ТВ-приставка без названия | 5.1.1 | 3.14.29 | 1.0x | Нет видео | + +## История версий + +Изменения описаны на [странице релизов](https://github.com/OpenIPC/decoder/releases) и в [истории коммитов](https://github.com/OpenIPC/decoder/commits/v2). История версий 1.0x — в [README ветки `basic`](https://github.com/OpenIPC/decoder/tree/basic#version-history). + +## Лицензия + +[The Prosperity Public License 3.0.0](LICENSE.md): бесплатно для некоммерческого использования; для коммерческого — пробный период 30 дней. Полный текст — в [LICENSE.md](LICENSE.md). + +## Поддержка + +- Вопросы и сообщения об ошибках: [GitHub Issues](https://github.com/OpenIPC/decoder/issues) +- Документация: [openipc.org](https://openipc.org) + +[![Telegram](https://openipc.org/images/telegram_button.svg)](https://openipc.org/our-channels) diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 00000000..87f73038 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,218 @@ + + + + OpenIPC logo + + +# OpenIPC Decoder + +[English](README.md) | [Русский](README.ru.md) | **简体中文** + +[![Build](https://github.com/OpenIPC/decoder/actions/workflows/build.yml/badge.svg)](https://github.com/OpenIPC/decoder/actions/workflows/build.yml) +[![Release](https://img.shields.io/badge/download-latest-blue)](https://github.com/OpenIPC/decoder/releases/tag/latest) +[![License](https://img.shields.io/badge/license-Prosperity%203.0-blue)](LICENSE.md) +[![Platform](https://img.shields.io/badge/platform-Android%205.0%2B-brightgreen)](https://developer.android.com/about/versions/lollipop) + +一款小巧的 Android 应用,用于观看 IP 摄像机的 RTSP 视频流,支持 H.264/H.265 硬件解码。专为运行 [OpenIPC](https://openipc.org) 固件的摄像机设计,可在手机、平板、电视、电视盒子和车载设备上使用。 + +

+ 应用截图 +

+ +## 下载 + +可直接安装的版本位于 [**latest** 发布页](https://github.com/OpenIPC/decoder/releases/tag/latest): + +| 文件 | 说明 | +|------------------|-----------------------------------------| +| `decoder-v2.apk` | **当前版本**,推荐使用 | +| `decoder.apk` | `basic` 分支的旧版本 | +| `*.aab` | 用于 Google Play 的应用包,不能手动安装 | + +[Google Play](https://play.google.com/apps/internaltest/4701231141824898735) 上提供测试渠道。如需加入,请通过 [OpenIPC Telegram 频道](https://openipc.org/our-channels)发送您的电子邮箱。 + +## 功能 + +- **视频:** RTSP 播放,通过 MediaCodec 进行 H.264/H.265 硬件解码 +- **音频:** AAC(RFC 3640 `mpeg4-generic`、ADTS)、G.711 μ-law/A-law、L16 PCM(单声道/立体声) +- **显示:** 单路画面,或 Quad 四分屏(同时显示 4 路摄像机) +- **操作:** 双指缩放、拖动平移、双击复位,支持 Android TV 遥控器(Leanback 启动器) +- **网络:** 断线后自动重连(指数退避),连接质量指示 +- **WebUI:** 内置浏览器,可打开摄像机的网页管理界面 +- **其他:** 长按截图,4 个摄像机槽位,可接收其他应用分享的地址 + +## 系统要求 + +- Android 5.0(API 21)或更高版本 +- 支持 RTSP over TCP 的 IP 摄像机(参见[已知限制](#已知限制)) + +## 快速上手 + +1. 从 [latest 发布页](https://github.com/OpenIPC/decoder/releases/tag/latest)安装 APK 并启动应用。 +2. 点击屏幕或按遥控器上的 **OK** 键打开菜单。 +3. 选择摄像机编号(**1**–**4**),点击 **Settings** 并输入摄像机地址: + ``` + rtsp://username:password@ip_address:554/stream=0 + ``` +4. 点击 **Save**。应用会立即连接;视频流中断时会自动重连。 + +## 操作说明 + +### 菜单 + +| 菜单项 | 功能 | +|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------| +| **Q** | 开启或关闭 Quad 四分屏模式 | +| **1**–**4** | 切换摄像机槽位。当前槽位编号的颜色表示连接质量:绿色为抖动低于 100 毫秒,黄色为 100–300 毫秒,红色为高于 300 毫秒。灰色编号表示该槽位未配置。 | +| **Settings** | 编辑当前槽位的 RTSP 地址(仅单路模式) | +| **WebUI** | 打开摄像机网页界面,用户名和密码取自 RTSP 地址(仅单路模式) | +| **Exit** | 退出应用。旁边的小字是应用版本号和构建哈希。 | + +### 手势与按键 + +| 输入 | 功能 | +|---------------------------|---------------------------------| +| 点击 / **OK** / **Enter** | 打开或关闭菜单 | +| **Back** / **Esc** | 关闭菜单 | +| 双指捏合后拖动 | 缩放(最大 5 倍)并平移 | +| 双击 | 恢复原始大小 | +| 长按 | 截图并保存到 `Pictures/OpenIPC` | + +### 从其他应用打开摄像机 + +应用可接收纯文本分享(`ACTION_SEND`,`text/plain`)。从其他应用(例如 OpenIPC Network)分享 `rtsp://` 地址后,该地址会写入当前槽位。 + +### Quad 四分屏模式 + +以 2×2 网格显示所有已配置的槽位。每个画面都有独立的连接和解码器。此模式下没有声音。点击任意画面即可打开菜单。 + +## 已知限制 + +> [!IMPORTANT] +> 以下是我们计划解决的已知不足,每一项都有对应的 issue。欢迎留言、提交测试结果和 pull request。 +> +> - **仅支持 RTSP over TCP**,不支持 UDP 和组播 — [#7](https://github.com/OpenIPC/decoder/issues/7) +> - **仅支持 Basic 认证**,需要 Digest 认证的摄像机无法连接 — [#8](https://github.com/OpenIPC/decoder/issues/8) +> - **视频编码按 payload type 编号判断**(96 = H.264,97 = H.265),而不是读取 SDP。使用 PT 96 传输的 H.265 无法播放 — [#9](https://github.com/OpenIPC/decoder/issues/9) +> - **视频的 RTP 处理较为简单:** 不支持 STAP-A/AP 聚合包,带 CSRC 或扩展头的包会被丢弃,也不处理丢包 — [#10](https://github.com/OpenIPC/decoder/issues/10) +> - **没有 RTSP 保活**,设置了会话超时的服务器可能会周期性断开视频流 — [#11](https://github.com/OpenIPC/decoder/issues/11) +> - **Quad 模式下没有声音** — [#12](https://github.com/OpenIPC/decoder/issues/12) +> - **仅有 4 个摄像机槽位** — [#13](https://github.com/OpenIPC/decoder/issues/13) +> - **不支持的音频格式:** MP4A-LATM、Opus、G.726,以及跨多个 RTP 包分片的 AAC 帧 — [#14](https://github.com/OpenIPC/decoder/issues/14) +> - **部分设备上可能依赖 Google 服务(GAPPS)**,仍在调查中,请谨慎禁用 GAPPS — [#15](https://github.com/OpenIPC/decoder/issues/15) +> - **用户名和密码以明文存储和传输**(未加密的 RTSP/HTTP,Basic 认证) — [#16](https://github.com/OpenIPC/decoder/issues/16) +> +> 已知的程序错误另行收录在 [`bug` 标签](https://github.com/OpenIPC/decoder/issues?q=is%3Aissue+is%3Aopen+label%3Abug)下。 + +## 分支 + +| 分支 | 说明 | +|---------|--------------------------------------------------------------------------------------| +| `v2` | **主开发分支**(默认)。模块化代码位于 `android/`,构建产物发布为 `decoder-v2.apk`。 | +| `basic` | 旧版 1.0x,单文件实现,保留作参考并供旧环境使用。构建产物发布为 `decoder.apk`。 | + +请基于 `v2` 分支提交 pull request。 + +## 构建 + +需要 JDK 17 和 Android SDK(platform 36)。通过 `ANDROID_HOME` 或 `android/local.properties` 指定 SDK 路径。 + +```bash +cd android +./gradlew assembleDebug # 调试版本,无需签名密钥 +# → app/build/outputs/apk/debug/app-debug.apk +``` + +发布版本需要密钥库 `android/key.jks` 以及以下环境变量: + +```bash +export KEYSTORE_PASS=your_password +export KEY_ALIAS=your_alias +export KEY_PASS=your_key_password +./gradlew assembleRelease # → app/build/outputs/apk/release/app-release.apk +``` + +调试版本和发布版本使用不同的密钥签名。在两者之间切换前需先卸载应用,已保存的摄像机地址会一并删除。 + +**CI:** GitHub Actions 会构建每一次 push。推送到 `v2` 和 `basic` 时会更新 [latest](https://github.com/OpenIPC/decoder/releases/tag/latest) 发布页并通知 OpenIPC Telegram 频道。其他分支的 APK 只作为工作流产物附在运行页面上。 + +## 架构 + +| 类 | 作用 | +|-------------------------------|----------------------------------------------------------| +| `Decoder` | 主 Activity:界面、生命周期、设置、视频解码线程 | +| `RtspClient` | RTSP 握手、RTP over TCP 接收循环、看门狗、SDP 解析 | +| `NalAssembler` | 将 RTP 分片重组为 H.264/H.265 NAL 单元 | +| `MediaCodecManager` | 硬件视频解码器的生命周期管理 | +| `AudioManager`、`AudioConfig` | 音频处理:G.711/L16 转换、AAC 解码、通过 AudioTrack 播放 | +| `QuadCell` | Quad 模式中单个画面的独立数据流(网络、看门狗、解码器) | +| `MenuManager` | 弹出菜单、地址编辑器、WebUI 浏览器 | +| `FramePool` | 缓冲区池,减轻垃圾回收压力 | + +网络、视频解码和音频分别运行在独立线程中,解码器较慢时也不会阻塞网络读取。 + +## 安全说明 + +- 用户名和密码从地址中提取,通过 `Authorization: Basic` 头发送,不会出现在 RTSP 请求行中。 +- RTSP 和 HTTP 均未加密,请仅在可信网络中使用,参见 [#16](https://github.com/OpenIPC/decoder/issues/16)。 +- 摄像机地址(含密码)保存在应用私有存储中,并已禁用备份(`allowBackup="false"`)。 + +## 设备兼容性 + +在您的设备上测试过本应用?请[**提交 Device report**](https://github.com/OpenIPC/decoder/issues/new?template=device_report.yml),只需一分钟,可以帮助其他用户。 + +### 已验证设备 + +以下设备的报告基于旧版 1.0x(`basic` 分支)。新报告请注明应用版本。 + +| 类型 | 设备 | Android | 内核 | 版本 | 备注 | +|----------|---------------------------------------|------------------------------------|----------------|------|---------------------------------------------------------------------| +| 手机 | Asus ZC553KL | 8.1 | 3.18.71 | 1.0x | | +| 手机 | Blackview BV4900Pro | 12 | 4.19.191 | 1.0x | | +| 手机 | Oppo 5X | ColorOS 15.0 | 5.15.149 | 1.0x | | +| 手机 | Oppo A17 | ColorOS 12.1 | 4.19.191 | 1.0x | | +| 手机 | Samsung Galaxy A51 | 13 | 4.14.113 | 1.0x | | +| 手机 | Samsung Galaxy M21 (SM-M215F/DSN) | 12 | 4.14.113 | 1.0x | | +| 手机 | Samsung Galaxy S25 | 15 | 6.6.30 | 1.0x | | +| 手机 | Samsung A55 5G | 14 | 6.1.93 | 1.0x | | +| 手机 | Samsung Galaxy J7 | 10 | 3.18.150 | 1.0x | | +| 手机 | Samsung S8 | 9 | 4.4.153 | 1.0x | | +| 手机 | Samsung S23 Ultra | 15 | 5.15.153 | 1.0x | | +| 手机 | Xiaomi Redmi 7A | 10 | 4.9.261 | 1.0x | | +| 手机 | Xiaomi Redmi Note 7 (M1901F7G) | 10 | 4.4.192 | 1.0x | | +| 手机 | Xiaomi Redmi Note 13 4G | 13 | 5.15.94 | 1.0x | | +| 平板 | Lenovo TB-X304L | 8.1.0 | 3.18.71 | 1.0x | 画面边缘被裁切,[#17](https://github.com/OpenIPC/decoder/issues/17) | +| 平板 | Lenovo TB-X606F | 10 | — | 1.0x | 画面边缘被裁切,[#17](https://github.com/OpenIPC/decoder/issues/17) | +| 平板 | Lenovo Tab P11 | 11 | 4.19.157-perf+ | 1.0x | 画面边缘被裁切,[#17](https://github.com/OpenIPC/decoder/issues/17) | +| 平板 | Samsung Galaxy Tab A | 7.1.1 | 3.10.49 | 1.0x | | +| 平板 | Samsung Galaxy Tab A | 11 | 4.9.227 | 1.0x | | +| 平板 | Samsung Tab A7 (SM-T505) | 12 | 4.19.157-perf- | 1.0x | | +| 电视盒子 | A95X F3 AIR | 9(SlimBOXtv AOSP 9.14,无 GAPPS) | 4.9.113 | 1.0x | | +| 电视盒子 | IE X3 Air | 9(SlimBOXtv AOSP 9.14,无 GAPPS) | 4.9.113 | 1.0x | | +| 电视盒子 | SberBox SBDV-00001 | 定制系统 | 4.9.228 | 1.0x | | +| 电视盒子 | X96Q(全志 H313) | 10 | 4.9.170 | 1.0x | | +| 电视 | Haier Candy Android TV 2K | 11 | 4.9.243+ | 1.0x | 应用列表中没有图标 | +| 电视 | Yandex TV Novex NVX-55U169TSY | 7.1.1 | 4.4.3 | 1.0x | | +| 车载 | 多媒体主机 JCAC10003(ac8227/ac8229) | 12 | 3.18.79+ | 1.0x | | +| 车载 | 后视镜 Z55 | 8.1.0 | 4.4.83 | 1.0x | | + +### 无法使用 + +| 类型 | 设备 | Android | 内核 | 版本 | 备注 | +|----------|------------------|---------|---------|------|--------| +| 电视盒子 | 未知型号电视盒子 | 5.1.1 | 3.14.29 | 1.0x | 无视频 | + +## 版本历史 + +更新内容请参见[发布页](https://github.com/OpenIPC/decoder/releases)和[提交历史](https://github.com/OpenIPC/decoder/commits/v2)。1.0x 版本的历史记录见 [`basic` 分支的 README](https://github.com/OpenIPC/decoder/tree/basic#version-history)。 + +## 许可证 + +[The Prosperity Public License 3.0.0](LICENSE.md):非商业用途可免费使用;商业用途可试用 30 天。完整条款见 [LICENSE.md](LICENSE.md)。 + +## 支持 + +- 问题与错误报告:[GitHub Issues](https://github.com/OpenIPC/decoder/issues) +- 文档:[openipc.org](https://openipc.org) + +[![Telegram](https://openipc.org/images/telegram_button.svg)](https://openipc.org/our-channels) diff --git a/android/app/build.gradle b/android/app/build.gradle index d75060ca..889bf11e 100755 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,6 +1,6 @@ /* * - * Copyright (c) OpenIPC https://openipc.org MIT License + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 * * build.gradle — Android application module build configuration * diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index a371efe9..5d775509 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -1,5 +1,5 @@ # -# Copyright (c) OpenIPC https://openipc.org MIT License +# Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 # # proguard-rules.pro — R8/ProGuard keep rules for the Decoder application # diff --git a/android/app/src/main/java/com/openipc/decoder/App.java b/android/app/src/main/java/com/openipc/decoder/App.java index 9e20bf7e..69c743e2 100644 --- a/android/app/src/main/java/com/openipc/decoder/App.java +++ b/android/app/src/main/java/com/openipc/decoder/App.java @@ -1,6 +1,6 @@ /* * - * Copyright (c) OpenIPC https://openipc.org MIT License + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 * * App.java — Application subclass for global uncaught exception handling * diff --git a/android/app/src/main/java/com/openipc/decoder/AudioConfig.java b/android/app/src/main/java/com/openipc/decoder/AudioConfig.java new file mode 100644 index 00000000..51a24ec3 --- /dev/null +++ b/android/app/src/main/java/com/openipc/decoder/AudioConfig.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * AudioConfig.java — audio track parameters parsed from the SDP + * + */ + +package com.openipc.decoder; + +class AudioConfig { + static final String NONE = "NONE"; // no audio track or unsupported codec + static final String PCM = "PCM"; // L16 + static final String PCMU = "PCMU"; // G.711 mu-law + static final String PCMA = "PCMA"; // G.711 A-law + static final String AAC = "AAC"; + + String codec = NONE; + int pt = -1; + int sampleRate = 8000; + int channels = 1; + boolean bigEndian; + + // AAC: AudioSpecificConfig from fmtp "config=", null when absent + byte[] aacConfig; + // AAC RFC 3640 (mpeg4-generic) AU-header layout; sizeLength == 0 means no AU headers + int sizeLength; + int indexLength; + int indexDeltaLength; + + /** Fill defaults for static RTP payload types (RFC 3551), which may come without rtpmap. */ + void applyStaticPayloadType() { + switch (pt) { + case 0: codec = PCMU; sampleRate = 8000; channels = 1; break; + case 8: codec = PCMA; sampleRate = 8000; channels = 1; break; + case 10: codec = PCM; sampleRate = 44100; channels = 2; bigEndian = true; break; + case 11: codec = PCM; sampleRate = 44100; channels = 1; bigEndian = true; break; + default: break; + } + } + + /** Apply "a=rtpmap: /[/]". */ + void applyRtpmap(String encoding) { + String[] parts = encoding.trim().split("/"); + String name = parts[0].toUpperCase(java.util.Locale.ROOT); + + bigEndian = false; + if (name.equals("MPEG4-GENERIC") || name.equals("AAC") || name.equals("MP4A")) { + codec = AAC; + } else if (name.equals("PCMU")) { + codec = PCMU; + } else if (name.equals("PCMA")) { + codec = PCMA; + } else if (name.equals("L16")) { + codec = PCM; + bigEndian = true; + } else { + // MP4A-LATM, OPUS, G726, ... are not supported + codec = NONE; + } + + if (parts.length > 1) { + try { + int rate = Integer.parseInt(parts[1].trim()); + if (rate > 0 && rate <= 192000) sampleRate = rate; + } catch (NumberFormatException ignored) {} + } + channels = 1; + if (parts.length > 2) { + try { + int ch = Integer.parseInt(parts[2].trim()); + if (ch == 1 || ch == 2) channels = ch; + } catch (NumberFormatException ignored) {} + } + } + + /** Apply "a=fmtp: key=value; key=value". */ + void applyFmtp(String params) { + for (String param : params.split(";")) { + int eq = param.indexOf('='); + if (eq <= 0) continue; + String key = param.substring(0, eq).trim().toLowerCase(java.util.Locale.ROOT); + String value = param.substring(eq + 1).trim(); + try { + switch (key) { + case "config": aacConfig = parseHex(value); break; + case "sizelength": sizeLength = Integer.parseInt(value); break; + case "indexlength": indexLength = Integer.parseInt(value); break; + case "indexdeltalength": indexDeltaLength = Integer.parseInt(value); break; + default: break; + } + } catch (NumberFormatException ignored) {} + } + } + + /** + * Validate after all SDP attributes are applied (rtpmap and fmtp may come in any order). + * AAC with an impossible AU-header layout is disabled rather than misparsed. + */ + void finish() { + if (AAC.equals(codec) && (sizeLength < 0 || sizeLength > 16 + || indexLength < 0 || indexLength > 16 + || indexDeltaLength < 0 || indexDeltaLength > 16)) { + codec = NONE; + } + } + + private static byte[] parseHex(String hex) { + if (hex.isEmpty() || hex.length() % 2 != 0) return null; + byte[] out = new byte[hex.length() / 2]; + for (int i = 0; i < out.length; i++) { + int hi = Character.digit(hex.charAt(2 * i), 16); + int lo = Character.digit(hex.charAt(2 * i + 1), 16); + if (hi < 0 || lo < 0) return null; + out[i] = (byte) ((hi << 4) | lo); + } + return out; + } +} diff --git a/android/app/src/main/java/com/openipc/decoder/AudioManager.java b/android/app/src/main/java/com/openipc/decoder/AudioManager.java index a98db48c..943a1017 100644 --- a/android/app/src/main/java/com/openipc/decoder/AudioManager.java +++ b/android/app/src/main/java/com/openipc/decoder/AudioManager.java @@ -1,5 +1,5 @@ /* - * Copyright (c) OpenIPC https://openipc.org MIT License + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 * * AudioManager.java — audio playback and AAC decoding * @@ -16,6 +16,7 @@ import android.util.Log; import java.nio.ByteBuffer; +import java.util.Arrays; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ExecutorService; @@ -23,52 +24,105 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +/** + * Audio pipeline: RTP payload → (G.711 / L16 conversion | AAC MediaCodec) → pcmQueue → + * AudioTrack. The AudioTrack is written only from the playback thread via playPcmFrame(). + */ class AudioManager { private static final String TAG = "OpenIPCDecoder"; + private static final int[] AAC_SAMPLE_RATES = { + 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, + 16000, 12000, 11025, 8000, 7350 + }; + + private static final short[] ULAW_TABLE = new short[256]; + private static final short[] ALAW_TABLE = new short[256]; + + static { + for (int i = 0; i < 256; i++) { + ULAW_TABLE[i] = ulawToLinear(i); + ALAW_TABLE[i] = alawToLinear(i); + } + } + private final AtomicReference audioTrackRef = new AtomicReference<>(); + // Parameters of the current AudioTrack; touched only by the playback thread + private int trackSampleRate; + private int trackChannels; + // Parameters of the PCM currently being queued + private volatile int pcmSampleRate = 8000; + private volatile int pcmChannels = 1; + private volatile MediaCodec aacDecoder; private final Object aacDecoderLock = new Object(); private volatile boolean aacRunning; + private ExecutorService aacExecutor; volatile boolean audioFailed; private final BlockingQueue pcmQueue = new ArrayBlockingQueue<>(32); private final BlockingQueue aacQueue = new ArrayBlockingQueue<>(32); - private final ExecutorService executor; - - // Audio params set by the RTSP client via configure() - private volatile int audioSampleRate = 8000; - private volatile int audioPt; - private volatile boolean audioBigEndian = true; - private volatile String audioCodec = "PCM"; // "PCM", "AAC", "G711" - - AudioManager() { - this.executor = Executors.newSingleThreadExecutor(r -> { - Thread t = new Thread(r, "rtsp-audio-playback"); - return t; - }); - } + + private volatile AudioConfig config = new AudioConfig(); + private String lastUnsupported; void shutdown() { closeAudio(); - executor.shutdownNow(); + synchronized (aacDecoderLock) { + if (aacExecutor != null) { + aacExecutor.shutdownNow(); + aacExecutor = null; + } + } } - void configure(int sampleRate, int pt, boolean bigEndian, String codec) { - this.audioSampleRate = sampleRate; - this.audioPt = pt; - this.audioBigEndian = bigEndian; - this.audioCodec = codec; + /** Called on every RTSP (re)connect, before PLAY. */ + void configure(AudioConfig audio) { + config = audio; + // Parameters may differ after a reconnect: the AAC decoder is recreated on the next packet + releaseAacDecoder(); + aacQueue.clear(); + if (!AudioConfig.AAC.equals(audio.codec)) { + pcmSampleRate = audio.sampleRate; + pcmChannels = audio.channels; + } } - /** Called from the network thread to deliver an audio RTP frame. */ + /** Called from the network thread to deliver an audio RTP packet. */ void onAudioFrame(Frame frame) { - processAudio(frame); + byte[] data = frame.data(); + int start = RtspClient.rtpHeaderLength(data, frame.length()); + if (start < 0) return; + int end = RtspClient.rtpPayloadEnd(data, frame.length()); + if (end <= start) return; + + AudioConfig c = config; + switch (c.codec) { + case AudioConfig.AAC: + processAac(c, data, start, end); + break; + case AudioConfig.PCMU: + offerPcm(decodeG711(data, start, end, ULAW_TABLE)); + break; + case AudioConfig.PCMA: + offerPcm(decodeG711(data, start, end, ALAW_TABLE)); + break; + case AudioConfig.PCM: + offerPcm(decodeL16(data, start, end, c.bigEndian)); + break; + default: + if (!c.codec.equals(lastUnsupported)) { + lastUnsupported = c.codec; + Log.w(TAG, "Audio codec not supported, audio disabled (pt " + c.pt + ")"); + } + break; + } } void closeAudio() { aacRunning = false; aacQueue.clear(); + pcmQueue.clear(); AudioTrack track = audioTrackRef.getAndSet(null); if (track != null) { Log.i(TAG, "Close audio decoder"); @@ -79,230 +133,339 @@ void closeAudio() { Log.e(TAG, "Audio close exception", e); } } - synchronized (aacDecoderLock) { - MediaCodec aac = aacDecoder; - if (aac != null) { - aacDecoder = null; - try { aac.stop(); } catch (Exception ignored) {} - try { aac.release(); } catch (Exception ignored) {} - } - } - aacQueue.clear(); + releaseAacDecoder(); + lastUnsupported = null; audioFailed = false; - if (executor != null) { - executor.shutdownNow(); + } + + /** Called from Decoder's audio playback thread. */ + void playPcmFrame() { + try { + Frame buffer = pcmQueue.poll(5, TimeUnit.MILLISECONDS); + if (buffer != null) playAudio(buffer); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); } } - private void processAudio(Frame frame) { - int header = 12; - int length = frame.length() - header; - if (length <= 0) return; + // ====================== PCM ====================== - byte[] audioData = new byte[length]; - System.arraycopy(frame.data(), header, audioData, 0, length); + private void offerPcm(byte[] pcm) { + if (pcm.length == 0) return; + if (!pcmQueue.offer(new Frame(pcm, pcm.length))) { + Log.w(TAG, "Audio queue full, frame dropped"); + } + } - if ("AAC".equals(audioCodec)) { - processAacFrame(audioData); - } else if ("G711".equals(audioCodec)) { - Log.d(TAG, "G.711 audio not yet implemented"); - } else { - // PCM - if (audioBigEndian) { - for (int i = 0; i + 1 < length; i += 2) { - byte tmp = audioData[i]; - audioData[i] = audioData[i + 1]; - audioData[i + 1] = tmp; - } - } - if (!pcmQueue.offer(new Frame(audioData, length))) { - Log.w(TAG, "Audio queue full, frame dropped"); + static byte[] decodeG711(byte[] data, int start, int end, short[] table) { + byte[] pcm = new byte[(end - start) * 2]; + for (int i = start, o = 0; i < end; i++, o += 2) { + short s = table[data[i] & 0xFF]; + pcm[o] = (byte) s; + pcm[o + 1] = (byte) (s >> 8); + } + return pcm; + } + + static byte[] decodeL16(byte[] data, int start, int end, boolean bigEndian) { + int len = (end - start) & ~1; + byte[] pcm = Arrays.copyOfRange(data, start, start + len); + if (bigEndian) { + for (int i = 0; i + 1 < len; i += 2) { + byte tmp = pcm[i]; + pcm[i] = pcm[i + 1]; + pcm[i + 1] = tmp; } } + return pcm; } - private void processAacFrame(byte[] aacData) { - if (!aacQueue.offer(aacData)) { - Log.w(TAG, "AAC audio queue full, frame dropped"); + static short ulawToLinear(int u) { + u = ~u & 0xFF; + int t = ((u & 0x0F) << 3) + 0x84; + t <<= (u & 0x70) >> 4; + return (short) ((u & 0x80) != 0 ? 0x84 - t : t - 0x84); + } + + static short alawToLinear(int a) { + a ^= 0x55; + int t = (a & 0x0F) << 4; + int seg = (a & 0x70) >> 4; + if (seg == 0) { + t += 8; + } else { + t += 0x108; + if (seg > 1) t <<= seg - 1; } + return (short) ((a & 0x80) != 0 ? t : -t); } private void playAudio(Frame data) { + int rate = pcmSampleRate; + int channels = pcmChannels; AudioTrack track = audioTrackRef.get(); - if (track == null) { - if (!audioFailed) { - synchronized (audioTrackRef) { - if (audioTrackRef.get() == null && !audioFailed) { - createAudio(); - } - } - } - } else { - byte[] buf = data.data(); - int offset = 0; - int remaining = data.length(); - while (remaining > 0) { - AudioTrack t = audioTrackRef.get(); - if (t == null) break; - int written = t.write(buf, offset, remaining); - if (written < 0) { - Log.e(TAG, "AudioTrack.write() error: " + written); - break; - } - offset += written; - remaining -= written; - } + if (track == null || rate != trackSampleRate || channels != trackChannels) { + if (audioFailed) return; + track = createPcmAudio(rate, channels); + if (track == null) return; } - } - private void createAudio() { - if ("AAC".equals(audioCodec)) { - createAacDecoder(); - } else { - createPcmAudio(); + byte[] buf = data.data(); + int offset = 0; + int remaining = data.length(); + while (remaining > 0) { + if (audioTrackRef.get() != track) break; // closed concurrently + int written = track.write(buf, offset, remaining); + if (written < 0) { + Log.e(TAG, "AudioTrack.write() error: " + written); + break; + } + offset += written; + remaining -= written; } } - private void createPcmAudio() { - int sample = audioSampleRate; - int format = AudioFormat.CHANNEL_OUT_MONO; + private AudioTrack createPcmAudio(int sample, int channels) { + int mask = channels == 2 ? AudioFormat.CHANNEL_OUT_STEREO : AudioFormat.CHANNEL_OUT_MONO; - Log.d(TAG, "Create PCM audio (" + sample + "hz)"); - int size = AudioTrack.getMinBufferSize(sample, format, AudioFormat.ENCODING_PCM_16BIT); + Log.d(TAG, "Create PCM audio (" + sample + "hz, " + channels + "ch)"); + int size = AudioTrack.getMinBufferSize(sample, mask, AudioFormat.ENCODING_PCM_16BIT); if (size <= 0) { Log.e(TAG, "Invalid PCM audio parameters: sample=" + sample + " bufSize=" + size); audioFailed = true; - return; + return null; } - AudioTrack track = new AudioTrack.Builder() - .setAudioAttributes(new AudioAttributes.Builder() + // AudioTrack.Builder needs API 23; this constructor works from API 21 (minSdk) + AudioTrack track = new AudioTrack( + new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .build()) - .setAudioFormat(new AudioFormat.Builder() + .build(), + new AudioFormat.Builder() .setEncoding(AudioFormat.ENCODING_PCM_16BIT) .setSampleRate(sample) - .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) - .build()) - .setBufferSizeInBytes(size) - .setTransferMode(AudioTrack.MODE_STREAM) - .build(); + .setChannelMask(mask) + .build(), + size, AudioTrack.MODE_STREAM, + android.media.AudioManager.AUDIO_SESSION_ID_GENERATE); if (track.getState() != AudioTrack.STATE_INITIALIZED) { Log.e(TAG, "AudioTrack failed to initialize, releasing"); track.release(); audioFailed = true; - return; + return null; } - audioTrackRef.set(track); try { track.play(); } catch (Exception e) { - audioTrackRef.compareAndSet(track, null); track.release(); Log.e(TAG, "AudioTrack.play() failed", e); audioFailed = true; + return null; } + + AudioTrack old = audioTrackRef.getAndSet(track); + if (old != null) { + try { old.stop(); } catch (Exception ignored) {} + old.release(); + } + trackSampleRate = sample; + trackChannels = channels; + return track; } - private void createAacDecoder() { - Log.d(TAG, "Creating AAC decoder"); - MediaFormat fmt = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, - audioSampleRate, 1); - fmt.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC); - fmt.setInteger(MediaFormat.KEY_IS_ADTS, 1); + // ====================== AAC ====================== - MediaCodec codec; - try { - codec = MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_AAC); - try { - codec.configure(fmt, null, null, 0); - codec.start(); - } catch (Exception e) { - codec.release(); - throw e; + private void releaseAacDecoder() { + synchronized (aacDecoderLock) { + MediaCodec aac = aacDecoder; + if (aac != null) { + aacDecoder = null; + try { aac.stop(); } catch (Exception ignored) {} + try { aac.release(); } catch (Exception ignored) {} } - } catch (Exception e) { - Log.e(TAG, "Cannot create AAC decoder, falling back to PCM", e); - createPcmAudio(); - return; } + } - synchronized (aacDecoderLock) { - MediaCodec old = aacDecoder; - if (old != null) { - try { old.stop(); } catch (Exception ignored) {} - old.release(); + private void processAac(AudioConfig c, byte[] data, int start, int end) { + if (c.sizeLength > 0) { + // RFC 3640 mpeg4-generic: AU-headers-length (bits), AU headers, then AU data + if (end - start < 2) return; + int headerBits = ((data[start] & 0xFF) << 8) | (data[start + 1] & 0xFF); + int bitPos = (start + 2) * 8; + int bitEnd = bitPos + headerBits; + int pos = start + 2 + (headerBits + 7) / 8; + if (pos > end) return; + ensureAacDecoder(c, false); + boolean first = true; + while (true) { + int headerLen = c.sizeLength + (first ? c.indexLength : c.indexDeltaLength); + if (bitPos + headerLen > bitEnd) break; + int size = readBits(data, bitPos, c.sizeLength); + bitPos += headerLen; + first = false; + // AUs fragmented across packets are not supported + if (size <= 0 || pos + size > end) break; + offerAac(Arrays.copyOfRange(data, pos, pos + size)); + pos += size; } - aacDecoder = codec; + } else if (end - start >= 7 && isAdtsSync(data, start)) { + ensureAacDecoder(c, true); + int pos = start; + while (end - pos >= 7 && isAdtsSync(data, pos)) { + int frameLen = ((data[pos + 3] & 0x03) << 11) + | ((data[pos + 4] & 0xFF) << 3) + | ((data[pos + 5] & 0xE0) >>> 5); + if (frameLen < 7 || pos + frameLen > end) break; + offerAac(Arrays.copyOfRange(data, pos, pos + frameLen)); + pos += frameLen; + } + } else { + // Raw access unit without AU headers + ensureAacDecoder(c, false); + offerAac(Arrays.copyOfRange(data, start, end)); } + } - aacRunning = true; - executor.execute(() -> { - Thread.currentThread().setName("rtsp-aac-decode"); - MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); + private static boolean isAdtsSync(byte[] data, int pos) { + return (data[pos] & 0xFF) == 0xFF && (data[pos + 1] & 0xF6) == 0xF0; + } - while (aacRunning && !Thread.currentThread().isInterrupted()) { - try { - byte[] aacData = aacQueue.poll(100, TimeUnit.MILLISECONDS); - if (aacData == null) continue; + static int readBits(byte[] data, int bitPos, int count) { + int value = 0; + for (int i = 0; i < count; i++, bitPos++) { + int bit = (data[bitPos >> 3] >> (7 - (bitPos & 7))) & 1; + value = (value << 1) | bit; + } + return value; + } - synchronized (aacDecoderLock) { - if (aacDecoder != codec) break; - } + private void offerAac(byte[] au) { + if (!aacQueue.offer(au)) { + Log.w(TAG, "AAC audio queue full, frame dropped"); + } + } - boolean hasAdts = (aacData.length >= 2 - && (aacData[0] & 0xFF) == 0xFF - && (aacData[1] & 0xF0) == 0xF0); + /** AAC-LC AudioSpecificConfig built from the sample rate and channel count. */ + static byte[] buildAacConfig(int sampleRate, int channels) { + int index = 4; // 44100 + for (int i = 0; i < AAC_SAMPLE_RATES.length; i++) { + if (AAC_SAMPLE_RATES[i] == sampleRate) { index = i; break; } + } + int objectType = 2; // AAC LC + return new byte[] { + (byte) ((objectType << 3) | (index >> 1)), + (byte) (((index & 1) << 7) | (channels << 3)) + }; + } - int inputId = codec.dequeueInputBuffer(10_000); - if (inputId < 0) continue; + private void ensureAacDecoder(AudioConfig c, boolean adts) { + if (aacDecoder != null || audioFailed) return; + synchronized (aacDecoderLock) { + if (aacDecoder != null || audioFailed) return; + + Log.d(TAG, "Creating AAC decoder (" + c.sampleRate + "hz, " + c.channels + "ch, " + + (adts ? "ADTS" : "raw") + ")"); + MediaFormat fmt = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, + c.sampleRate, c.channels); + if (adts) { + fmt.setInteger(MediaFormat.KEY_IS_ADTS, 1); + } else { + byte[] asc = c.aacConfig != null ? c.aacConfig + : buildAacConfig(c.sampleRate, c.channels); + fmt.setByteBuffer("csd-0", ByteBuffer.wrap(asc)); + fmt.setInteger(MediaFormat.KEY_AAC_PROFILE, + MediaCodecInfo.CodecProfileLevel.AACObjectLC); + } - ByteBuffer inBuf = codec.getInputBuffer(inputId); - if (inBuf == null) continue; - inBuf.clear(); - if (hasAdts) { - inBuf.put(aacData, 0, aacData.length); - } else { - inBuf.put(aacData); - } - codec.queueInputBuffer(inputId, 0, aacData.length, - System.nanoTime() / 1000, 0); - - int outId; - while ((outId = codec.dequeueOutputBuffer(info, 0)) >= 0) { - if (outId >= 0) { - ByteBuffer outBuf = codec.getOutputBuffer(outId); - int size = info.size; - if (outBuf != null && size > 0) { - byte[] pcm = new byte[size]; - outBuf.get(pcm); - codec.releaseOutputBuffer(outId, false); - Frame data = new Frame(pcm, pcm.length); - playAudio(data); - } else { - codec.releaseOutputBuffer(outId, false); - } - } - } + MediaCodec codec; + try { + codec = MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_AAC); + try { + codec.configure(fmt, null, null, 0); + codec.start(); + } catch (Exception e) { + codec.release(); + throw e; + } + } catch (Exception e) { + Log.e(TAG, "Cannot create AAC decoder, audio disabled", e); + audioFailed = true; + return; + } + + aacDecoder = codec; + pcmSampleRate = c.sampleRate; + pcmChannels = c.channels; + aacRunning = true; + if (aacExecutor == null) { + aacExecutor = Executors.newSingleThreadExecutor( + r -> new Thread(r, "rtsp-aac-decode")); + } + aacExecutor.execute(() -> aacLoop(codec)); + } + } + + private void aacLoop(MediaCodec codec) { + MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); + byte[] pending = null; + while (aacRunning && !Thread.currentThread().isInterrupted()) { + if (pending == null) { + try { + pending = aacQueue.poll(20, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; + } + } + + synchronized (aacDecoderLock) { + if (aacDecoder != codec) break; + try { + if (pending != null) { + int inputId = codec.dequeueInputBuffer(10_000); + // No free input buffer: keep the access unit and retry after draining + if (inputId >= 0) { + ByteBuffer inBuf = codec.getInputBuffer(inputId); + int len = 0; + if (inBuf != null) { + inBuf.clear(); + inBuf.put(pending); + len = pending.length; + } + codec.queueInputBuffer(inputId, 0, len, System.nanoTime() / 1000, 0); + pending = null; + } + } + // Drain on idle iterations too, so the last decoded frame is not held back + drainAacOutput(codec, info); } catch (Exception e) { Log.e(TAG, "AAC decode error", e); + pending = null; } } - }); + } } - /** Called from Decoder's audio playback thread for PCM data. */ - void playPcmFrame() { - try { - Frame buffer = pcmQueue.poll(5, TimeUnit.MILLISECONDS); - if (buffer != null) playAudio(buffer); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + private void drainAacOutput(MediaCodec codec, MediaCodec.BufferInfo info) { + int outId; + while ((outId = codec.dequeueOutputBuffer(info, 0)) != MediaCodec.INFO_TRY_AGAIN_LATER) { + if (outId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + MediaFormat f = codec.getOutputFormat(); + pcmSampleRate = f.getInteger(MediaFormat.KEY_SAMPLE_RATE); + pcmChannels = f.getInteger(MediaFormat.KEY_CHANNEL_COUNT); + continue; + } + if (outId < 0) continue; + ByteBuffer outBuf = codec.getOutputBuffer(outId); + if (outBuf != null && info.size > 0) { + byte[] pcm = new byte[info.size]; + outBuf.position(info.offset); + outBuf.get(pcm); + offerPcm(pcm); + } + codec.releaseOutputBuffer(outId, false); } } } diff --git a/android/app/src/main/java/com/openipc/decoder/Crash.java b/android/app/src/main/java/com/openipc/decoder/Crash.java index 6f43c5dc..3182f406 100644 --- a/android/app/src/main/java/com/openipc/decoder/Crash.java +++ b/android/app/src/main/java/com/openipc/decoder/Crash.java @@ -1,6 +1,6 @@ /* * - * Copyright (c) OpenIPC https://openipc.org MIT License + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 * * Crash.java — crash report activity displaying error details * diff --git a/android/app/src/main/java/com/openipc/decoder/Decoder.java b/android/app/src/main/java/com/openipc/decoder/Decoder.java index 8b618296..15e2dce5 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -1,5 +1,5 @@ /* - * Copyright (c) OpenIPC https://openipc.org MIT License + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 * * Decoder.java — main activity for H.264/H.265 hardware video decoding * @@ -111,7 +111,7 @@ public class Decoder extends Activity // Codec/bitrate state for status overlay (pulled from RtspClient) private volatile boolean codecH265; - private volatile String audioCodec = "PCM"; + private volatile String audioCodec = AudioConfig.NONE; // Bitrate tracking (polled from RtspClient on "connected") private long statusStreamBytes; @@ -148,10 +148,10 @@ protected void onCreate(Bundle savedInstanceState) { rtspClient.setListener(new RtspClient.Listener() { @Override public void onStatusChanged(String s) { updateStatus(s); } @Override - public void onCodecChanged(boolean h265, String aCodec, int sr, boolean be, int pt) { + public void onCodecChanged(boolean h265, AudioConfig audio) { codecH265 = h265; - audioCodec = aCodec; - audioManager.configure(sr, pt, be, aCodec); + audioCodec = audio.codec; + audioManager.configure(audio); } @Override public void onJitterSample(int avg) { menuManager.updateQuality(avg); @@ -410,8 +410,14 @@ private void updateStatus(String status) { if (statusText == null || statusText.getVisibility() != View.VISIBLE) return; String videoCodec = codecH265 ? "H.265" : "H.264"; - String aCodec = "AAC".equals(audioCodec) ? "AAC" - : "G711".equals(audioCodec) ? "G.711" : "PCM"; + String aCodec; + switch (audioCodec) { + case AudioConfig.AAC: aCodec = " • AAC"; break; + case AudioConfig.PCMU: + case AudioConfig.PCMA: aCodec = " • G.711"; break; + case AudioConfig.PCM: aCodec = " • PCM"; break; + default: aCodec = ""; break; + } long elapsedNs = System.nanoTime() - statusBitrateNs; String bps = ""; long bytes = rtspClient != null ? rtspClient.getStreamBytes() : 0; @@ -420,7 +426,7 @@ private void updateStatus(String status) { / (elapsedNs / 1_000_000_000.0); bps = String.format(Locale.US, " • %.1f Mbps", mbps); } - statusText.setText("Connected • " + videoCodec + " • " + aCodec + bps); + statusText.setText("Connected • " + videoCodec + aCodec + bps); }, 1000); statusText.postDelayed(() -> { if (statusText != null) { @@ -733,6 +739,12 @@ protected void onPause() { nalQueue.clear(); } + @Override + protected void onDestroy() { + super.onDestroy(); + audioManager.shutdown(); + } + @Override protected void onResume() { super.onResume(); diff --git a/android/app/src/main/java/com/openipc/decoder/Frame.java b/android/app/src/main/java/com/openipc/decoder/Frame.java index 2ff8cb52..3ed8b7c9 100644 --- a/android/app/src/main/java/com/openipc/decoder/Frame.java +++ b/android/app/src/main/java/com/openipc/decoder/Frame.java @@ -1,5 +1,5 @@ /* - * Copyright (c) OpenIPC https://openipc.org MIT License + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 * * Frame.java — lightweight byte-array wrapper for decoded/raw media data * diff --git a/android/app/src/main/java/com/openipc/decoder/FramePool.java b/android/app/src/main/java/com/openipc/decoder/FramePool.java index 88ed80bf..28f144bf 100644 --- a/android/app/src/main/java/com/openipc/decoder/FramePool.java +++ b/android/app/src/main/java/com/openipc/decoder/FramePool.java @@ -1,5 +1,5 @@ /* - * Copyright (c) OpenIPC https://openipc.org MIT License + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 * * FramePool.java — simple object pool for Frame objects to reduce GC pressure * diff --git a/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java b/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java index 26f567cf..734c9f5c 100644 --- a/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java +++ b/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java @@ -1,5 +1,5 @@ /* - * Copyright (c) OpenIPC https://openipc.org MIT License + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 * * MediaCodecManager.java — video decoder lifecycle management * diff --git a/android/app/src/main/java/com/openipc/decoder/MenuManager.java b/android/app/src/main/java/com/openipc/decoder/MenuManager.java index 92e08019..fa85657c 100644 --- a/android/app/src/main/java/com/openipc/decoder/MenuManager.java +++ b/android/app/src/main/java/com/openipc/decoder/MenuManager.java @@ -1,5 +1,5 @@ /* - * Copyright (c) OpenIPC https://openipc.org MIT License + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 * * MenuManager.java — popup menu, URL editor, WebUI browser * diff --git a/android/app/src/main/java/com/openipc/decoder/NalAssembler.java b/android/app/src/main/java/com/openipc/decoder/NalAssembler.java index bbb2a6c5..b9a30c22 100644 --- a/android/app/src/main/java/com/openipc/decoder/NalAssembler.java +++ b/android/app/src/main/java/com/openipc/decoder/NalAssembler.java @@ -1,5 +1,5 @@ /* - * Copyright (c) OpenIPC https://openipc.org MIT License + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 * * NalAssembler.java — reusable NAL unit reassembler from RTP fragmentation units * diff --git a/android/app/src/main/java/com/openipc/decoder/QuadCell.java b/android/app/src/main/java/com/openipc/decoder/QuadCell.java index a05034cb..0e1b0ef8 100644 --- a/android/app/src/main/java/com/openipc/decoder/QuadCell.java +++ b/android/app/src/main/java/com/openipc/decoder/QuadCell.java @@ -1,5 +1,5 @@ /* - * Copyright (c) OpenIPC https://openipc.org MIT License + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 * * QuadCell.java — single camera stream in quad mode * @@ -302,11 +302,8 @@ private void connect() throws Exception { // Parse SDP for video track only — reuse RtspClient's parser boolean[] h265Out = new boolean[1]; - int[] audioPtOut = new int[1]; - audioPtOut[0] = -1; - String[] audioInfo = new String[3]; String[] trackUrls = RtspClient.parseSdp(sdpBuf.toString(), rtspUrl, - h265Out, audioPtOut, audioInfo); + h265Out, null); codecH265 = h265Out[0]; String videoControl = trackUrls[0]; diff --git a/android/app/src/main/java/com/openipc/decoder/RtspClient.java b/android/app/src/main/java/com/openipc/decoder/RtspClient.java index 3749b188..6ad019fc 100644 --- a/android/app/src/main/java/com/openipc/decoder/RtspClient.java +++ b/android/app/src/main/java/com/openipc/decoder/RtspClient.java @@ -1,5 +1,5 @@ /* - * Copyright (c) OpenIPC https://openipc.org MIT License + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 * * RtspClient.java — RTSP handshake and RTP-over-TCP streaming client * @@ -31,7 +31,6 @@ class RtspClient { static final int RTP_PT_H265 = 97; static final int RTP_PT_H264 = 96; - static final int RTP_PT_PCMU_DEFAULT = 100; private static final long WATCHDOG_MS = 8000; @@ -50,10 +49,7 @@ class RtspClient { // Decoder state private volatile boolean codecH265; - private volatile String audioCodec = "PCM"; - private volatile int audioSampleRate = 8000; - private volatile boolean audioBigEndian = true; - private volatile int audioPt = RTP_PT_PCMU_DEFAULT; + private volatile AudioConfig audioConfig = new AudioConfig(); private volatile String videoTrackUrl; private volatile String audioTrackUrl; @@ -77,8 +73,7 @@ class RtspClient { interface Listener { void onStatusChanged(String status); - void onCodecChanged(boolean codecH265, String audioCodec, int audioSampleRate, - boolean audioBigEndian, int audioPt); + void onCodecChanged(boolean codecH265, AudioConfig audio); void onJitterSample(int avgJitter); } @@ -400,7 +395,7 @@ private void processPacket(Frame frame) { if ((data[0] & 0x0F) != 0 || (data[0] & 0x10) != 0) { framePool.recycle(frame); return; } int payload = (data[1] & 0x7F); - if (payload == audioPt) { + if (payload == audioConfig.pt) { if (audioManager != null) { audioManager.onAudioFrame(frame); } @@ -463,37 +458,29 @@ private void processPacket(Frame frame) { private void parseSdpAndNotify(String sdp, String baseUrl) { boolean[] h265Out = new boolean[1]; - int[] audioPtOut = new int[1]; - audioPtOut[0] = -1; - String[] audioInfo = new String[3]; // codec, bigEndianStr, rateStr - String[] controls = parseSdp(sdp, baseUrl, h265Out, audioPtOut, audioInfo); + AudioConfig audio = new AudioConfig(); + String[] controls = parseSdp(sdp, baseUrl, h265Out, audio); codecH265 = h265Out[0]; - audioPt = audioPtOut[0]; + audioConfig = audio; videoTrackUrl = controls[0]; audioTrackUrl = controls[1]; - audioCodec = audioInfo[0]; - audioBigEndian = "true".equals(audioInfo[1]); - if (audioInfo[2] != null) { - try { audioSampleRate = Integer.parseInt(audioInfo[2]); } - catch (NumberFormatException ignored) {} - } if (listenerCb != null) { - listenerCb.onCodecChanged(codecH265, audioCodec, audioSampleRate, - audioBigEndian, audioPt); + listenerCb.onCodecChanged(codecH265, audio); } } + /** + * Parse the SDP: returns { videoControlUrl, audioControlUrl }, stores the video codec + * into {@code codecH265Out[0]} and the audio track parameters into {@code audioOut} + * (both may be null). + */ static String[] parseSdp(String sdp, String baseUrl, - boolean[] codecH265Out, int[] audioPtOut, - String[] audioInfoOut) { + boolean[] codecH265Out, AudioConfig audioOut) { String base = baseUrl; String[] controls = { null, null }; int track = -1; boolean codecH265 = false; - int audioPt = -1; - String audioCodec = "PCM"; - boolean audioBigEndian = false; - int audioSampleRate = 0; + AudioConfig audio = audioOut != null ? audioOut : new AudioConfig(); for (String line : sdp.split("[\r\n]+")) { if (line.startsWith("m=video")) { @@ -510,62 +497,57 @@ static String[] parseSdp(String sdp, String baseUrl, String[] parts = line.split("\\s+"); if (parts.length >= 4) { try { - audioPt = Integer.parseInt(parts[3]); + audio.pt = Integer.parseInt(parts[3]); + audio.applyStaticPayloadType(); } catch (NumberFormatException ignored) {} } + } else if (line.startsWith("m=")) { + track = 2; // other media, ignored } else if (line.startsWith("a=control:")) { String ctrl = line.substring("a=control:".length()).trim(); if (track == -1) { base = ctrl.startsWith("rtsp://") ? ctrl : baseUrl + "/" + ctrl; - } else { + } else if (track < 2) { controls[track] = ctrl.startsWith("rtsp://") ? ctrl : base + "/" + ctrl; } - } else if (audioPt >= 0 && line.startsWith("a=rtpmap:" + audioPt + " ")) { - String encoding = line.substring(("a=rtpmap:" + audioPt + " ").length()); - String codecName = encoding.split("/")[0].toUpperCase(Locale.ROOT); - - if (codecName.contains("MP4A") || codecName.contains("AAC")) { - audioCodec = "AAC"; - audioBigEndian = false; - } else if (codecName.contains("PCMA") || codecName.contains("PCMU")) { - audioCodec = "G711"; - audioBigEndian = false; - } else if (codecName.startsWith("L16")) { - audioCodec = "PCM"; - audioBigEndian = true; - } else { - audioCodec = "PCM"; - audioBigEndian = false; - } - - int slash = encoding.indexOf('/'); - if (slash >= 0) { - int end = encoding.indexOf('/', slash + 1); - String rateStr = (end >= 0 - ? encoding.substring(slash + 1, end) - : encoding.substring(slash + 1)).trim(); - try { - int rate = Integer.parseInt(rateStr); - if (rate > 0 && rate <= 192000) { - audioSampleRate = rate; - } - } catch (NumberFormatException ignored) {} - } + } else if (track == 1 && audio.pt >= 0 + && line.startsWith("a=rtpmap:" + audio.pt + " ")) { + audio.applyRtpmap(line.substring(("a=rtpmap:" + audio.pt + " ").length())); + } else if (track == 1 && audio.pt >= 0 + && line.startsWith("a=fmtp:" + audio.pt + " ")) { + audio.applyFmtp(line.substring(("a=fmtp:" + audio.pt + " ").length())); } } + audio.finish(); if (controls[0] == null) controls[0] = base + "/trackID=0"; if (controls[1] == null) controls[1] = base + "/trackID=1"; if (codecH265Out != null && codecH265Out.length > 0) codecH265Out[0] = codecH265; - if (audioPtOut != null && audioPtOut.length > 0) audioPtOut[0] = audioPt; - if (audioInfoOut != null && audioInfoOut.length >= 3) { - audioInfoOut[0] = audioCodec; - audioInfoOut[1] = String.valueOf(audioBigEndian); - audioInfoOut[2] = audioSampleRate > 0 ? String.valueOf(audioSampleRate) : null; - } return controls; } + /** + * Length of the RTP header including CSRC list and header extension, + * or -1 when the packet is malformed. + */ + static int rtpHeaderLength(byte[] data, int length) { + if (length < 12 || (data[0] & 0xC0) != 0x80) return -1; + int len = 12 + 4 * (data[0] & 0x0F); + if ((data[0] & 0x10) != 0) { + if (len + 4 > length) return -1; + int extWords = ((data[len + 2] & 0xFF) << 8) | (data[len + 3] & 0xFF); + len += 4 + 4 * extWords; + } + return len <= length ? len : -1; + } + + /** Payload end offset: packet length minus RTP padding, or -1 when malformed. */ + static int rtpPayloadEnd(byte[] data, int length) { + if ((data[0] & 0x20) == 0) return length; + int pad = data[length - 1] & 0xFF; + return pad <= length ? length - pad : -1; + } + static String readLine(InputStream in) throws IOException { StringBuilder sb = new StringBuilder(128); int c; diff --git a/android/gradle.properties b/android/gradle.properties index 513bfbff..4b9cb92a 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,5 +1,5 @@ # -# Copyright (c) OpenIPC https://openipc.org MIT License +# Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 # # gradle.properties — project-wide Gradle and Android build properties # diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 66be3fdf..ba018ae3 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,5 +1,5 @@ # -# Copyright (c) OpenIPC https://openipc.org MIT License +# Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 # # libs.versions.toml — Gradle version catalog for dependency and plugin versions # diff --git a/android/settings.gradle b/android/settings.gradle index 9c593aff..df444465 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,6 +1,6 @@ /* * - * Copyright (c) OpenIPC https://openipc.org MIT License + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 * * settings.gradle — Gradle project settings and repository configuration *