master 0c0dd61e20eb cached
66 files
354.9 KB
85.0k tokens
141 symbols
1 requests
Download .txt
Showing preview only (379K chars total). Download the full file or copy to clipboard to get everything.
Repository: alexeyvasilyev/rtsp-client-android
Branch: master
Commit: 0c0dd61e20eb
Files: 66
Total size: 354.9 KB

Directory structure:
gitextract_xa9itofy/

├── .github/
│   └── FUNDING.yml
├── .gitignore
├── LICENSE
├── README.md
├── app/
│   ├── .gitignore
│   ├── build.gradle
│   ├── proguard-rules.pro
│   └── src/
│       └── main/
│           ├── AndroidManifest.xml
│           ├── java/
│           │   └── com/
│           │       └── alexvas/
│           │           └── rtsp/
│           │               └── demo/
│           │                   ├── MainActivity.kt
│           │                   └── live/
│           │                       ├── LiveFragment.kt
│           │                       ├── LiveViewModel.kt
│           │                       └── RawFragment.kt
│           └── res/
│               ├── drawable/
│               │   ├── ic_camera_black_24dp.xml
│               │   ├── ic_cctv_black_24dp.xml
│               │   ├── ic_launcher_background.xml
│               │   ├── ic_launcher_foreground.xml
│               │   └── ic_text_subject_black_24dp.xml
│               ├── layout/
│               │   ├── activity_main.xml
│               │   ├── fragment_live.xml
│               │   ├── fragment_logs.xml
│               │   ├── fragment_raw.xml
│               │   └── layout_rtsp_params.xml
│               ├── menu/
│               │   └── bottom_nav_menu.xml
│               ├── mipmap-anydpi-v26/
│               │   ├── ic_launcher.xml
│               │   └── ic_launcher_round.xml
│               ├── navigation/
│               │   └── mobile_navigation.xml
│               └── values/
│                   ├── colors.xml
│                   ├── dimens.xml
│                   ├── strings.xml
│                   └── styles.xml
├── build.gradle
├── gradle/
│   └── wrapper/
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
├── jitpack.yml
├── library-client-rtsp/
│   ├── .gitignore
│   ├── build.gradle
│   ├── proguard-rules.txt
│   └── src/
│       └── main/
│           ├── AndroidManifest.xml
│           └── java/
│               └── com/
│                   ├── alexvas/
│                   │   ├── rtsp/
│                   │   │   ├── RtspClient.java
│                   │   │   ├── codec/
│                   │   │   │   ├── AudioDecodeThread.kt
│                   │   │   │   ├── FrameQueue.kt
│                   │   │   │   ├── VideoDecodeThread.kt
│                   │   │   │   ├── VideoDecoderBitmapThread.kt
│                   │   │   │   ├── VideoDecoderSurfaceThread.kt
│                   │   │   │   └── color/
│                   │   │   │       ├── ColorConverter.kt
│                   │   │   │       └── ColorConverterImage.kt
│                   │   │   ├── parser/
│                   │   │   │   ├── AacParser.java
│                   │   │   │   ├── AudioParser.kt
│                   │   │   │   ├── G711Parser.kt
│                   │   │   │   ├── RtpH264Parser.kt
│                   │   │   │   ├── RtpH265Parser.kt
│                   │   │   │   ├── RtpHeaderParser.java
│                   │   │   │   └── RtpParser.kt
│                   │   │   └── widget/
│                   │   │       ├── RtspImageView.kt
│                   │   │       ├── RtspListeners.kt
│                   │   │       ├── RtspProcessor.kt
│                   │   │       └── RtspSurfaceView.kt
│                   │   └── utils/
│                   │       ├── ByteUtils.java
│                   │       ├── MediaCodecUtils.kt
│                   │       ├── NetUtils.java
│                   │       └── VideoCodecUtils.kt
│                   └── limelight/
│                       └── binding/
│                           └── video/
│                               └── MediaCodecHelper.java
└── settings.gradle

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/FUNDING.yml
================================================
github: alexeyvasilyev

================================================
FILE: .gitignore
================================================
*.iml
.gradle
/local.properties
/.idea
/build
.DS_Store


================================================
FILE: LICENSE
================================================
                                 Apache License
                           Version 2.0, January 2004
                        http://www.apache.org/licenses/

   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

   1. Definitions.

      "License" shall mean the terms and conditions for use, reproduction,
      and distribution as defined by Sections 1 through 9 of this document.

      "Licensor" shall mean the copyright owner or entity authorized by
      the copyright owner that is granting the License.

      "Legal Entity" shall mean the union of the acting entity and all
      other entities that control, are controlled by, or are under common
      control with that entity. For the purposes of this definition,
      "control" means (i) the power, direct or indirect, to cause the
      direction or management of such entity, whether by contract or
      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      outstanding shares, or (iii) beneficial ownership of such entity.

      "You" (or "Your") shall mean an individual or Legal Entity
      exercising permissions granted by this License.

      "Source" form shall mean the preferred form for making modifications,
      including but not limited to software source code, documentation
      source, and configuration files.

      "Object" form shall mean any form resulting from mechanical
      transformation or translation of a Source form, including but
      not limited to compiled object code, generated documentation,
      and conversions to other media types.

      "Work" shall mean the work of authorship, whether in Source or
      Object form, made available under the License, as indicated by a
      copyright notice that is included in or attached to the work
      (an example is provided in the Appendix below).

      "Derivative Works" shall mean any work, whether in Source or Object
      form, that is based on (or derived from) the Work and for which the
      editorial revisions, annotations, elaborations, or other modifications
      represent, as a whole, an original work of authorship. For the purposes
      of this License, Derivative Works shall not include works that remain
      separable from, or merely link (or bind by name) to the interfaces of,
      the Work and Derivative Works thereof.

      "Contribution" shall mean any work of authorship, including
      the original version of the Work and any modifications or additions
      to that Work or Derivative Works thereof, that is intentionally
      submitted to Licensor for inclusion in the Work by the copyright owner
      or by an individual or Legal Entity authorized to submit on behalf of
      the copyright owner. For the purposes of this definition, "submitted"
      means any form of electronic, verbal, or written communication sent
      to the Licensor or its representatives, including but not limited to
      communication on electronic mailing lists, source code control systems,
      and issue tracking systems that are managed by, or on behalf of, the
      Licensor for the purpose of discussing and improving the Work, but
      excluding communication that is conspicuously marked or otherwise
      designated in writing by the copyright owner as "Not a Contribution."

      "Contributor" shall mean Licensor and any individual or Legal Entity
      on behalf of whom a Contribution has been received by Licensor and
      subsequently incorporated within the Work.

   2. Grant of Copyright License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      copyright license to reproduce, prepare Derivative Works of,
      publicly display, publicly perform, sublicense, and distribute the
      Work and such Derivative Works in Source or Object form.

   3. Grant of Patent License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      (except as stated in this section) patent license to make, have made,
      use, offer to sell, sell, import, and otherwise transfer the Work,
      where such license applies only to those patent claims licensable
      by such Contributor that are necessarily infringed by their
      Contribution(s) alone or by combination of their Contribution(s)
      with the Work to which such Contribution(s) was submitted. If You
      institute patent litigation against any entity (including a
      cross-claim or counterclaim in a lawsuit) alleging that the Work
      or a Contribution incorporated within the Work constitutes direct
      or contributory patent infringement, then any patent licenses
      granted to You under this License for that Work shall terminate
      as of the date such litigation is filed.

   4. Redistribution. You may reproduce and distribute copies of the
      Work or Derivative Works thereof in any medium, with or without
      modifications, and in Source or Object form, provided that You
      meet the following conditions:

      (a) You must give any other recipients of the Work or
          Derivative Works a copy of this License; and

      (b) You must cause any modified files to carry prominent notices
          stating that You changed the files; and

      (c) You must retain, in the Source form of any Derivative Works
          that You distribute, all copyright, patent, trademark, and
          attribution notices from the Source form of the Work,
          excluding those notices that do not pertain to any part of
          the Derivative Works; and

      (d) If the Work includes a "NOTICE" text file as part of its
          distribution, then any Derivative Works that You distribute must
          include a readable copy of the attribution notices contained
          within such NOTICE file, excluding those notices that do not
          pertain to any part of the Derivative Works, in at least one
          of the following places: within a NOTICE text file distributed
          as part of the Derivative Works; within the Source form or
          documentation, if provided along with the Derivative Works; or,
          within a display generated by the Derivative Works, if and
          wherever such third-party notices normally appear. The contents
          of the NOTICE file are for informational purposes only and
          do not modify the License. You may add Your own attribution
          notices within Derivative Works that You distribute, alongside
          or as an addendum to the NOTICE text from the Work, provided
          that such additional attribution notices cannot be construed
          as modifying the License.

      You may add Your own copyright statement to Your modifications and
      may provide additional or different license terms and conditions
      for use, reproduction, or distribution of Your modifications, or
      for any such Derivative Works as a whole, provided Your use,
      reproduction, and distribution of the Work otherwise complies with
      the conditions stated in this License.

   5. Submission of Contributions. Unless You explicitly state otherwise,
      any Contribution intentionally submitted for inclusion in the Work
      by You to the Licensor shall be under the terms and conditions of
      this License, without any additional terms or conditions.
      Notwithstanding the above, nothing herein shall supersede or modify
      the terms of any separate license agreement you may have executed
      with Licensor regarding such Contributions.

   6. Trademarks. This License does not grant permission to use the trade
      names, trademarks, service marks, or product names of the Licensor,
      except as required for reasonable and customary use in describing the
      origin of the Work and reproducing the content of the NOTICE file.

   7. Disclaimer of Warranty. Unless required by applicable law or
      agreed to in writing, Licensor provides the Work (and each
      Contributor provides its Contributions) on an "AS IS" BASIS,
      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      implied, including, without limitation, any warranties or conditions
      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      PARTICULAR PURPOSE. You are solely responsible for determining the
      appropriateness of using or redistributing the Work and assume any
      risks associated with Your exercise of permissions under this License.

   8. Limitation of Liability. In no event and under no legal theory,
      whether in tort (including negligence), contract, or otherwise,
      unless required by applicable law (such as deliberate and grossly
      negligent acts) or agreed to in writing, shall any Contributor be
      liable to You for damages, including any direct, indirect, special,
      incidental, or consequential damages of any character arising as a
      result of this License or out of the use or inability to use the
      Work (including but not limited to damages for loss of goodwill,
      work stoppage, computer failure or malfunction, or any and all
      other commercial damages or losses), even if such Contributor
      has been advised of the possibility of such damages.

   9. Accepting Warranty or Additional Liability. While redistributing
      the Work or Derivative Works thereof, You may choose to offer,
      and charge a fee for, acceptance of support, warranty, indemnity,
      or other liability obligations and/or rights consistent with this
      License. However, in accepting such obligations, You may act only
      on Your own behalf and on Your sole responsibility, not on behalf
      of any other Contributor, and only if You agree to indemnify,
      defend, and hold each Contributor harmless for any liability
      incurred by, or claims asserted against, such Contributor by reason
      of your accepting any such warranty or additional liability.

   END OF TERMS AND CONDITIONS

   APPENDIX: How to apply the Apache License to your work.

      To apply the Apache License to your work, attach the following
      boilerplate notice, with the fields enclosed by brackets "[]"
      replaced with your own identifying information. (Don't include
      the brackets!)  The text should be enclosed in the appropriate
      comment syntax for the file format. We also recommend that a
      file or class name and description of purpose be included on the
      same "printed page" as the copyright notice for easier
      identification within third-party archives.

   Copyright [yyyy] [name of copyright owner]

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.


================================================
FILE: README.md
================================================
# rtsp-client-android
<b>Lightweight RTSP client library for Android</b> with almost zero lag video decoding (achieved 20 msec video decoding latency on some RTSP streams). Designed for lag criticial applications (e.g. video surveillance from drones, car rear view cameras, etc.).

Unlike [AndroidX Media ExoPlayer](https://github.com/androidx/media) which also supports RTSP, this library does not make any video buffering. Video frames are shown immidiately when they arrive.

[![Release](https://jitpack.io/v/alexeyvasilyev/rtsp-client-android.svg)](https://jitpack.io/#alexeyvasilyev/rtsp-client-android)

![Screenshot](docs/images/rtsp-demo-app.webp?raw=true "Screenshot")

## Features:
- RTSP/RTSPS over TCP.
- Supports majority of RTSP IP cameras.
- Video H.264/H.265.
- Audio AAC LC, G.711 uLaw, G.711 aLaw.
- Support for application specific data sent via RTP, e.g. GPS data (`m=application`, see [RFC 4566 sec.5.14](https://datatracker.ietf.org/doc/html/rfc4566#section-5.14))
- Basic/Digest authentication.
- Uses Android's [Low-Latency MediaCodec](https://source.android.com/docs/core/media/low-latency-media) by default if available.
- Ability to select hardware or software video decoder.
- Ability to [rewrite SPS frame](https://github.com/alexeyvasilyev/rtsp-client-android/blob/dbea741548307b1b0e1ead0ccc6294e811fbf6fd/library-client-rtsp/src/main/java/com/alexvas/rtsp/widget/RtspProcessor.kt#L106C9-L106C55) with low-latency parameters (EXPERIMENTAL).
- Video rotation (90, 180, 270 degrees). 
- Android min API 24.

## Upcoming features:
- 2-w talk.

## Permissions:

```xml
<uses-permission android:name="android.permission.INTERNET" />
```

## Compile

To use this library in your project add this to your build.gradle:
```gradle
allprojects {
  repositories {
    maven { url 'https://jitpack.io' }
  }
}
dependencies {
  implementation 'com.github.alexeyvasilyev:rtsp-client-android:x.x.x'
}
```

## How to use:
Easiest way is just to use `RtspSurfaceView` (recommended) or `RtspImageView` classes for showing video stream in UI.

Use [RtspSurfaceView](https://github.com/alexeyvasilyev/rtsp-client-android/blob/master/library-client-rtsp/src/main/java/com/alexvas/rtsp/widget/RtspSurfaceView.kt) if you need best performance and less battery usage. To get bitmap from SurfaceView use [PixelCopy.request](https://developer.android.com/reference/android/view/PixelCopy) (on Pixel 8 Pro with 1440p @ 20 fps video stream, you can get 12 fps only via PixelCopy)

Use [RtspImageView](https://github.com/alexeyvasilyev/rtsp-client-android/blob/master/library-client-rtsp/src/main/java/com/alexvas/rtsp/widget/RtspImageView.kt) if you need better performance than PixelCopy for getting bitmaps for further processing (e.g. for AI).

```xml
<com.alexvas.rtsp.widget.RtspSurfaceView
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:id="@+id/svVideo" />

<com.alexvas.rtsp.widget.RtspImageView
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:id="@+id/ivVideo" />
```

Then in code use:
```kotlin
val uri = Uri.parse("rtsps://10.0.1.3/test.sdp")
val username = "admin"
val password = "secret"
svVideo.init(uri, username, password)
svVideo.start(
    requestVideo = true,
    requestAudio = true,
    requestApplication = false)
// ...
svVideo.stop()
```

You can still use library without any decoding (just for obtaining raw frames from RTSP source), e.g. for writing video stream into MP4 via muxer.

```kotlin
val rtspClientListener = object: RtspClient.RtspClientListener {
    override fun onRtspConnecting() {}
    override fun onRtspConnected(sdpInfo: SdpInfo) {}
    override fun onRtspVideoNalUnitReceived(data: ByteArray, offset: Int, length: Int, timestamp: Long) {
        // Send raw H264/H265 NAL unit to decoder
    }
    override fun onRtspAudioSampleReceived(data: ByteArray, offset: Int, length: Int, timestamp: Long) {
        // Send raw audio to decoder
    }
    override fun onRtspApplicationDataReceived(data: ByteArray, offset: Int, length: Int, timestamp: Long) {
        // Send raw application data to app specific parser
    }
    override fun onRtspDisconnected() {}
    override fun onRtspFailedUnauthorized() {
        Log.e(TAG, "RTSP failed unauthorized");
    }
    override fun onRtspFailed(message: String?) {
        Log.e(TAG, "RTSP failed with message '$message'")
    }
}

val uri = Uri.parse("rtsps://10.0.1.3/test.sdp")
val username = "admin"
val password = "secret"
val stopped = new AtomicBoolean(false)
val sslSocket = NetUtils.createSslSocketAndConnect(uri.getHost(), uri.getPort(), 5000)

val rtspClient = RtspClient.Builder(sslSocket, uri.toString(), stopped, rtspClientListener)
    .requestVideo(true)
    .requestAudio(true)
    .withDebug(false)
    .withUserAgent("RTSP client")
    .withCredentials(username, password)
    .build()
// Blocking call until stopped variable is true or connection failed
rtspClient.execute()

NetUtils.closeSocket(sslSocket)
```

## How to get lowest possible latency:
There are two types of latencies:

### Network latency
If you want the lowest possible network latency, be sure that both Android device and RTSP camera are connected to the same network by the Ethernet cable (not WiFi).

Another option to try is to decrease stream bitrate on RTSP camera. Less frame size leads to less time needed for frame transfer.

### Video decoder latency
Video decoder latency can vary significantly on different Android devices and on different RTSP camera streams.

For the same profile/level and resolution (but different cameras) the latency in best cases can can be 20 msec, in worst cases 1200 msec.

To decrease latency be sure you use the lowest possible H.264 video stream profile and level (enable `debug` in the library and check SPS frame params `profile_idc` and `level_idc` in the log). `Baseline profile` should have the lowest possible decoder latency.
Check `max_num_reorder_frames` param as well. For best latency it's value should be `0`.

You can also try to use [experimentalUpdateSpsFrameWithLowLatencyParams](https://github.com/alexeyvasilyev/rtsp-client-android/blob/master/library-client-rtsp/src/main/java/com/alexvas/rtsp/widget/RtspProcessor.kt#L106) library feature which rewrites config frame on runtime with low-latency parameters.


================================================
FILE: app/.gitignore
================================================
# Created by https://www.gitignore.io/api/android,java,intellij

### Android ###
# Built application files
*.apk
*.ap_

# Files for the Dalvik VM
*.dex

# Java class files
*.class

# Generated files
bin/
gen/

# Gradle files
.gradle/
build/

# Local configuration file (sdk path, etc)
local.properties

# Proguard folder generated by Eclipse
proguard/

xactmobile/class_files.txt
xactmobile/mapping.txt
xactmobile/seeds.txt

# Log Files
*.log

# Android Studio Navigation editor temp files
.navigation/

### Android Patch ###
gen-external-apklibs


### Java ###
*.class

# Mobile Tools for Java (J2ME)
.mtj.tmp/

# Package Files #
#*.jar
*.war
*.ear

# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*


### Intellij ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio

*.iml

## Directory-based project format:
.idea/
# if you remove the above rule, at least ignore the following:

# User-specific stuff:
.idea/workspace.xml
.idea/tasks.xml
.idea/dictionaries

# Sensitive or high-churn files:
.idea/dataSources.ids
.idea/dataSources.xml
.idea/sqlDataSources.xml
.idea/dynamic.xml
.idea/uiDesigner.xml

# Gradle:
.idea/gradle.xml
.idea/libraries

# Mongo Explorer plugin:
.idea/mongoSettings.xml

## File-based project format:
*.ipr
*.iws

## Plugin-specific files:

# IntelliJ
/out/

# mpeltonen/sbt-idea plugin
.idea_modules/

# JIRA plugin
atlassian-ide-plugin.xml

# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties

xactmobile/.DS_Store~64be78fe3602626c61b52bcbfd09e09a6107b50a
xactmobile/.DS_Store~HEAD
oslab-viewpager/._.DS_Store
oslab-viewpager/src/main/.DS_Store
oslab-viewpager/src/main/._.DS_Store
oslab-viewpager/src/main/res/.DS_Store
oslab-viewpager/src/main/res/._.DS_Store
oslab-viewpager/.gitignore
oslab-materialdesign/.DS_Store
oslab-materialdesign/._.DS_Store
oslab-materialdesign/src/.DS_Store
oslab-materialdesign/src/._.DS_Store
oslab-materialdesign/src/main/.DS_Store
oslab-materialdesign/src/main/._.DS_Store
oslab-materialdesign/src/main/res/.DS_Store
oslab-materialdesign/src/main/res/._.DS_Store


================================================
FILE: app/build.gradle
================================================
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'

android {

    compileSdkVersion 36

    defaultConfig {
        applicationId "com.alexvas.rtsp.demo"
        minSdk 24
        targetSdk 34
        versionCode 1
        versionName "1.0"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }

// To inline the bytecode built with JVM target 1.8 into
// bytecode that is being built with JVM target 1.6. (e.g. navArgs)


    compileOptions {
        sourceCompatibility JavaVersion.VERSION_17
        targetCompatibility JavaVersion.VERSION_17
    }
    kotlinOptions {
        jvmTarget = JavaVersion.VERSION_17.toString()
    }
    buildFeatures {
        viewBinding true
    }
    namespace 'com.alexvas.rtsp.demo'
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation 'androidx.appcompat:appcompat:1.7.1'
    implementation 'androidx.core:core-ktx:1.18.0'
    implementation 'com.google.android.material:material:1.13.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
    implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'

    def androidx_navigation_version = '2.9.7'
    implementation "androidx.navigation:navigation-fragment-ktx:$androidx_navigation_version"
    implementation "androidx.navigation:navigation-ui-ktx:$androidx_navigation_version"
    implementation "androidx.navigation:navigation-fragment-ktx:$androidx_navigation_version"
    implementation "androidx.navigation:navigation-ui-ktx:$androidx_navigation_version"

    def logcat_core_version = '3.4'
    api "com.github.AppDevNext.Logcat:LogcatCoreLib:$logcat_core_version"
    api "com.github.AppDevNext.Logcat:LogcatCoreUI:$logcat_core_version"

    implementation project(':library-client-rtsp')
}


================================================
FILE: app/proguard-rules.pro
================================================
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
#   http://developer.android.com/guide/developing/tools/proguard.html

# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
#   public *;
#}

# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable

# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile


================================================
FILE: app/src/main/AndroidManifest.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:label="@string/app_name">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

================================================
FILE: app/src/main/java/com/alexvas/rtsp/demo/MainActivity.kt
================================================
package com.alexvas.rtsp.demo

import android.os.Bundle
import com.google.android.material.bottomnavigation.BottomNavigationView
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.findNavController
import androidx.navigation.ui.setupWithNavController

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val navView: BottomNavigationView = findViewById(R.id.nav_view)

        val navController = findNavController(R.id.nav_host_fragment)
        // Passing each menu ID as a set of Ids because each
        // menu should be considered as top level destinations.
//        val appBarConfiguration = AppBarConfiguration(setOf(
//                R.id.navigation_live, R.id.navigation_logs))
//        setupActionBarWithNavController(navController, appBarConfiguration)
        navView.setupWithNavController(navController)
    }
}


================================================
FILE: app/src/main/java/com/alexvas/rtsp/demo/live/LiveFragment.kt
================================================
package com.alexvas.rtsp.demo.live

import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.os.Bundle
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import android.view.LayoutInflater
import android.view.PixelCopy
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.Toast
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.net.toUri
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import com.alexvas.rtsp.codec.VideoDecodeThread
import com.alexvas.rtsp.demo.databinding.FragmentLiveBinding
import com.alexvas.rtsp.widget.RtspDataListener
import com.alexvas.rtsp.widget.RtspImageView
import com.alexvas.rtsp.widget.RtspStatusListener
import com.alexvas.rtsp.widget.toHexString
import java.util.Timer
import java.util.TimerTask
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.min

@SuppressLint("LogNotTimber")
class LiveFragment : Fragment() {

    private lateinit var binding: FragmentLiveBinding
    private lateinit var liveViewModel: LiveViewModel

    private var statisticsTimer: Timer? = null
    private var svVideoSurfaceResolution = Pair(0, 0)

    private val rtspStatusSurfaceListener = object: RtspStatusListener {
        override fun onRtspStatusConnecting() {
            if (DEBUG) Log.v(TAG, "onRtspStatusConnecting()")
            binding.apply {
                tvStatusSurface.text = "RTSP connecting"
                pbLoadingSurface.visibility = View.VISIBLE
                vShutterSurface.visibility = View.VISIBLE
                llRtspParams.apply {
                    etRtspRequest.isEnabled = false
                    etRtspUsername.isEnabled = false
                    etRtspPassword.isEnabled = false
                    cbVideo.isEnabled = false
                    cbAudio.isEnabled = false
                    cbApplication.isEnabled = false
                    cbDebug.isEnabled = false
                }
                tgRotation.isEnabled = false
            }
        }

        override fun onRtspStatusConnected() {
            if (DEBUG) Log.v(TAG, "onRtspStatusConnected()")
            binding.apply {
                tvStatusSurface.text = "RTSP connected"
                bnStartStopSurface.text = "Stop RTSP"
            }
            setKeepScreenOn(true)
        }

        override fun onRtspStatusDisconnecting() {
            if (DEBUG) Log.v(TAG, "onRtspStatusDisconnecting()")
            binding.apply {
                tvStatusSurface.text = "RTSP disconnecting"
            }
        }

        override fun onRtspStatusDisconnected() {
            if (DEBUG) Log.v(TAG, "onRtspStatusDisconnected()")
            binding.apply {
                tvStatusSurface.text = "RTSP disconnected"
                bnStartStopSurface.text = "Start RTSP"
                pbLoadingSurface.visibility = View.GONE
                vShutterSurface.visibility = View.VISIBLE
                pbLoadingSurface.isEnabled = false
                llRtspParams.apply {
                    cbVideo.isEnabled = true
                    cbAudio.isEnabled = true
                    cbApplication.isEnabled = true
                    cbDebug.isEnabled = true
                    etRtspRequest.isEnabled = true
                    etRtspUsername.isEnabled = true
                    etRtspPassword.isEnabled = true
                }
                tgRotation.isEnabled = true
            }
            setKeepScreenOn(false)
        }

        override fun onRtspStatusFailedUnauthorized() {
            if (DEBUG) Log.e(TAG, "onRtspStatusFailedUnauthorized()")
            if (context == null) return
            onRtspStatusDisconnected()
            binding.apply {
                tvStatusSurface.text = "RTSP username or password invalid"
                pbLoadingSurface.visibility = View.GONE
            }
        }

        override fun onRtspStatusFailed(message: String?) {
            if (DEBUG) Log.e(TAG, "onRtspStatusFailed(message='$message')")
            if (context == null) return
            onRtspStatusDisconnected()
            binding.apply {
                tvStatusSurface.text = "Error: $message"
                pbLoadingSurface.visibility = View.GONE
            }
        }

        override fun onRtspFirstFrameRendered() {
            if (DEBUG) Log.v(TAG, "onRtspFirstFrameRendered()")
            Log.i(TAG, "First frame rendered")
            binding.apply {
                pbLoadingSurface.visibility = View.GONE
                vShutterSurface.visibility = View.GONE
                bnSnapshotSurface.isEnabled = true
            }
        }

        override fun onRtspFrameSizeChanged(width: Int, height: Int) {
            if (DEBUG) Log.v(TAG, "onRtspFrameSizeChanged(width=$width, height=$height)")
            Log.i(TAG, "Video resolution changed to ${width}x${height}")
            svVideoSurfaceResolution = Pair(width, height)
            ConstraintSet().apply {
                clone(binding.csVideoSurface)
                setDimensionRatio(binding.svVideoSurface.id, "$width:$height")
                applyTo(binding.csVideoSurface)
            }
        }
    }

    private val rtspDataListener = object: RtspDataListener {
        override fun onRtspDataApplicationDataReceived(data: ByteArray, offset: Int, length: Int, timestamp: Long) {
            val numBytesDump = min(length, 25) // dump max 25 bytes
            Log.i(TAG, "RTSP app data ($length bytes): ${data.toHexString(offset, offset + numBytesDump)}")
        }
    }

    private val rtspStatusImageListener = object: RtspStatusListener {
        override fun onRtspStatusConnecting() {
            if (DEBUG) Log.v(TAG, "onRtspStatusConnecting()")
            binding.apply {
                tvStatusImage.text = "RTSP connecting"
                pbLoadingImage.visibility = View.VISIBLE
                vShutterImage.visibility = View.VISIBLE
            }
        }

        override fun onRtspStatusConnected() {
            if (DEBUG) Log.v(TAG, "onRtspStatusConnected()")
            binding.apply {
                tvStatusImage.text = "RTSP connected"
                bnStartStopImage.text = "Stop RTSP"
            }
            setKeepScreenOn(true)
        }

        override fun onRtspStatusDisconnecting() {
            if (DEBUG) Log.v(TAG, "onRtspStatusDisconnecting()")
            binding.apply {
                tvStatusImage.text = "RTSP disconnecting"
            }
        }

        override fun onRtspStatusDisconnected() {
            if (DEBUG) Log.v(TAG, "onRtspStatusDisconnected()")
            binding.apply {
                tvStatusImage.text = "RTSP disconnected"
                bnStartStopImage.text = "Start RTSP"
                pbLoadingImage.visibility = View.GONE
                vShutterImage.visibility = View.VISIBLE
                pbLoadingImage.isEnabled = false
            }
            setKeepScreenOn(false)
        }

        override fun onRtspStatusFailedUnauthorized() {
            if (DEBUG) Log.e(TAG, "onRtspStatusFailedUnauthorized()")
            if (context == null) return
            onRtspStatusDisconnected()
            binding.apply {
                tvStatusImage.text = "RTSP username or password invalid"
                pbLoadingImage.visibility = View.GONE
            }
        }

        override fun onRtspStatusFailed(message: String?) {
            if (DEBUG) Log.e(TAG, "onRtspStatusFailed(message='$message')")
            if (context == null) return
            onRtspStatusDisconnected()
            binding.apply {
                tvStatusImage.text = "Error: $message"
                pbLoadingImage.visibility = View.GONE
            }
        }

        override fun onRtspFirstFrameRendered() {
            if (DEBUG) Log.v(TAG, "onRtspFirstFrameRendered()")
            Log.i(TAG, "First frame rendered")
            binding.apply {
                vShutterImage.visibility = View.GONE
                pbLoadingImage.visibility = View.GONE
            }
        }

        override fun onRtspFrameSizeChanged(width: Int, height: Int) {
            if (DEBUG) Log.v(TAG, "onRtspFrameSizeChanged(width=$width, height=$height)")
            Log.i(TAG, "Video resolution changed to ${width}x${height}")
            ConstraintSet().apply {
                clone(binding.csVideoImage)
                setDimensionRatio(binding.ivVideoImage.id, "$width:$height")
                applyTo(binding.csVideoImage)
            }
        }
    }

    private fun getSnapshot(): Bitmap? {
        if (DEBUG) Log.v(TAG, "getSnapshot()")
        val surfaceBitmap = Bitmap.createBitmap(
            svVideoSurfaceResolution.first,
            svVideoSurfaceResolution.second,
            Bitmap.Config.ARGB_8888
        )
        val lock = Object()
        val success = AtomicBoolean(false)
        val thread = HandlerThread("PixelCopyHelper")
        thread.start()
        val sHandler = Handler(thread.looper)
        val listener = PixelCopy.OnPixelCopyFinishedListener { copyResult ->
            success.set(copyResult == PixelCopy.SUCCESS)
            synchronized (lock) {
                lock.notify()
            }
        }
        synchronized (lock) {
            PixelCopy.request(binding.svVideoSurface.holder.surface, surfaceBitmap, listener, sHandler)
            lock.wait()
        }
        thread.quitSafely()
        return if (success.get()) surfaceBitmap else null
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        if (DEBUG) Log.v(TAG, "onCreateView()")

        liveViewModel = ViewModelProvider(this)[LiveViewModel::class.java]
        binding = FragmentLiveBinding.inflate(inflater, container, false)

        binding.bnVideoDecoderGroup.check(binding.bnVideoDecoderHardware.id)

        binding.svVideoSurface.setStatusListener(rtspStatusSurfaceListener)
        binding.svVideoSurface.setDataListener(rtspDataListener)
        binding.ivVideoImage.setStatusListener(rtspStatusImageListener)
        binding.ivVideoImage.setDataListener(rtspDataListener)

        liveViewModel.initEditTexts(
            binding.llRtspParams.etRtspRequest,
            binding.llRtspParams.etRtspUsername,
            binding.llRtspParams.etRtspPassword
        )

        liveViewModel.rtspRequest.observe(viewLifecycleOwner) {
            if (binding.llRtspParams.etRtspRequest.text.toString() != it)
                binding.llRtspParams.etRtspRequest.setText(it)
        }
        liveViewModel.rtspUsername.observe(viewLifecycleOwner) {
            if (binding.llRtspParams.etRtspUsername.text.toString() != it)
                binding.llRtspParams.etRtspUsername.setText(it)
        }
        liveViewModel.rtspPassword.observe(viewLifecycleOwner) {
            if (binding.llRtspParams.etRtspPassword.text.toString() != it)
                binding.llRtspParams.etRtspPassword.setText(it)
        }

        binding.cbVideoFpsStabilization.setOnCheckedChangeListener { _, isChecked ->
            binding.svVideoSurface.videoFrameRateStabilization = isChecked
        }

        binding.cbExperimentalRewriteSps.setOnCheckedChangeListener { _, isChecked ->
            binding.svVideoSurface.experimentalUpdateSpsFrameWithLowLatencyParams = isChecked
        }

        binding.bnRotate0.setOnClickListener {
            binding.svVideoSurface.videoRotation = 0
            binding.ivVideoImage.videoRotation = 0
        }

        binding.bnRotate90.setOnClickListener {
            binding.svVideoSurface.videoRotation = 90
            binding.ivVideoImage.videoRotation = 90
        }

        binding.bnRotate180.setOnClickListener {
            binding.svVideoSurface.videoRotation = 180
            binding.ivVideoImage.videoRotation = 180
        }

        binding.bnRotate270.setOnClickListener {
            binding.svVideoSurface.videoRotation = 270
            binding.ivVideoImage.videoRotation = 270
        }

        binding.bnRotate0.performClick()

        binding.bnVideoDecoderHardware.setOnClickListener {
            binding.svVideoSurface.videoDecoderType = VideoDecodeThread.DecoderType.HARDWARE
            binding.ivVideoImage.videoDecoderType = VideoDecodeThread.DecoderType.HARDWARE
        }

        binding.bnVideoDecoderSoftware.setOnClickListener {
            binding.svVideoSurface.videoDecoderType = VideoDecodeThread.DecoderType.SOFTWARE
            binding.ivVideoImage.videoDecoderType = VideoDecodeThread.DecoderType.SOFTWARE
        }

        binding.bnStartStopSurface.setOnClickListener {
            if (binding.svVideoSurface.isStarted()) {
                binding.svVideoSurface.stop()
                stopStatistics()
            } else {
                val uri = liveViewModel.rtspRequest.value!!.toUri()
                binding.svVideoSurface.apply {
                    init(
                        uri,
                        username = liveViewModel.rtspUsername.value,
                        password = liveViewModel.rtspPassword.value,
                        userAgent = "rtsp-client-android"
                    )
                    debug = binding.llRtspParams.cbDebug.isChecked
                    videoFrameRateStabilization = binding.cbVideoFpsStabilization.isChecked
                    start(
                        requestVideo = binding.llRtspParams.cbVideo.isChecked,
                        requestAudio = binding.llRtspParams.cbAudio.isChecked,
                        requestApplication = binding.llRtspParams.cbApplication.isChecked
                    )
                }
                startStatistics()
            }
        }

        binding.bnStartStopImage.setOnClickListener {
            if (binding.ivVideoImage.isStarted()) {
                binding.ivVideoImage.stop()
                stopStatistics()
            } else {
                val uri = liveViewModel.rtspRequest.value!!.toUri()
                binding.ivVideoImage.apply {
                    init(
                        uri,
                        username = liveViewModel.rtspUsername.value,
                        password = liveViewModel.rtspPassword.value,
                        userAgent = "rtsp-client-android"
                    )
                    debug = binding.llRtspParams.cbDebug.isChecked
                    onRtspImageBitmapListener = object : RtspImageView.RtspImageBitmapListener {
                        override fun onRtspImageBitmapObtained(bitmap: Bitmap) {
                            // TODO: You can send bitmap for processing
                        }
                    }
                    start(
                        requestVideo = binding.llRtspParams.cbVideo.isChecked,
                        requestAudio = binding.llRtspParams.cbAudio.isChecked,
                        requestApplication = binding.llRtspParams.cbApplication.isChecked
                    )
                }
                startStatistics()
            }
        }

        binding.bnSnapshotSurface.setOnClickListener {
            val bitmap = getSnapshot()
            // TODO Save snapshot to DCIM folder
            if (bitmap != null) {
                Toast.makeText(requireContext(), "Snapshot succeeded ${bitmap.width}x${bitmap.height}", Toast.LENGTH_LONG).show()
            } else {
                Toast.makeText(requireContext(), "Snapshot failed", Toast.LENGTH_LONG).show()
            }
        }
        return binding.root
    }

    override fun onResume() {
        if (DEBUG) Log.v(TAG, "onResume()")
        super.onResume()
        liveViewModel.loadParams(requireContext())
    }

    override fun onPause() {
        val started = binding.svVideoSurface.isStarted()
        if (DEBUG) Log.v(TAG, "onPause(), started:$started")
        super.onPause()
        liveViewModel.saveParams(requireContext())

        if (started) {
            binding.svVideoSurface.stop()
            stopStatistics()
        }
    }

    private fun startStatistics() {
        if (DEBUG) Log.v(TAG, "startStatistics()")
        Log.i(TAG, "Start statistics")
        if (statisticsTimer == null) {
            val task: TimerTask = object : TimerTask() {
                override fun run() {
                    val statistics = binding.svVideoSurface.statistics
                    val text =
                        "Video decoder: ${statistics.videoDecoderType.toString().lowercase()} ${if (statistics.videoDecoderName.isNullOrEmpty()) "" else "(${statistics.videoDecoderName})"}" +
                        "\nVideo decoder latency: ${statistics.videoDecoderLatencyMsec} ms" +
                        "\nResolution: ${svVideoSurfaceResolution.first}x${svVideoSurfaceResolution.second}"
//                        "\nNetwork latency: "

//                    // Assume that difference between current Android time and camera time cannot be more than 5 sec.
//                    // Otherwise time need to be synchronized on both devices.
//                    text += if (statistics.networkLatencyMsec == -1) {
//                        "-"
//                    } else if (statistics.networkLatencyMsec < 0 || statistics.networkLatencyMsec > TimeUnit.SECONDS.toMillis(5)) {
//                        "[time out of sync]"
//                    } else {
//                        "${statistics.networkLatencyMsec} ms"
//                    }

                    binding.tvStatistics.post {
                        binding.tvStatistics.text = text
                    }
                }
            }
            statisticsTimer = Timer("${TAG}::Statistics").apply {
                schedule(task, 0, 1000)
            }
        }
    }

    private fun stopStatistics() {
        if (DEBUG) Log.v(TAG, "stopStatistics()")
        statisticsTimer?.apply {
            Log.i(TAG, "Stop statistics")
            cancel()
        }
        statisticsTimer = null
    }

    private fun setKeepScreenOn(enable: Boolean) {
        if (DEBUG) Log.v(TAG, "setKeepScreenOn(enable=$enable)")
        if (enable) {
            activity?.apply {
                window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
                Log.i(TAG, "Enabled keep screen on")
            }
        } else {
            activity?.apply {
                window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
                Log.i(TAG, "Disabled keep screen on")
            }
        }
    }
    companion object {
        private val TAG: String = LiveFragment::class.java.simpleName
        private const val DEBUG = true
    }

}


================================================
FILE: app/src/main/java/com/alexvas/rtsp/demo/live/LiveViewModel.kt
================================================
package com.alexvas.rtsp.demo.live

import android.annotation.SuppressLint
import android.content.Context
import android.text.Editable
import android.text.TextWatcher
import android.util.Log
import android.widget.EditText
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel

private const val RTSP_REQUEST_KEY = "rtsp_request"
private const val RTSP_USERNAME_KEY = "rtsp_username"
private const val RTSP_PASSWORD_KEY = "rtsp_password"

private const val DEFAULT_RTSP_REQUEST = "rtsp://10.0.1.3:554/axis-media/media.amp"
private const val DEFAULT_RTSP_USERNAME = ""
private const val DEFAULT_RTSP_PASSWORD = ""

private const val LIVE_PARAMS_FILENAME = "live_params"

@SuppressLint("LogNotTimber")
class LiveViewModel : ViewModel() {

    val rtspRequest = MutableLiveData<String>().apply {
        value = DEFAULT_RTSP_REQUEST
    }
    val rtspUsername = MutableLiveData<String>().apply {
        value = DEFAULT_RTSP_USERNAME
    }
    val rtspPassword = MutableLiveData<String>().apply {
        value = DEFAULT_RTSP_PASSWORD
    }

//    private val _text = MutableLiveData<String>().apply {
//        value = "This is live Fragment"
//    }
//    val text: LiveData<String> = _text

//    init {
//        // Here you could use the ID to get the user info from the DB or remote server
//        rtspRequest.value = "rtsp://10.0.1.3:554/axis-media/media.amp"
//    }

    fun loadParams(context: Context) {
        if (DEBUG) Log.v(TAG, "loadParams()")
        val pref = context.getSharedPreferences(LIVE_PARAMS_FILENAME, Context.MODE_PRIVATE)
        try {
            rtspRequest.setValue(pref.getString(RTSP_REQUEST_KEY, DEFAULT_RTSP_REQUEST))
        } catch (e: ClassCastException) {
            e.printStackTrace()
        }
        try {
            rtspUsername.setValue(pref.getString(RTSP_USERNAME_KEY, DEFAULT_RTSP_USERNAME))
        } catch (e: ClassCastException) {
            e.printStackTrace()
        }
        try {
            rtspPassword.setValue(pref.getString(RTSP_PASSWORD_KEY, DEFAULT_RTSP_PASSWORD))
        } catch (e: ClassCastException) {
            e.printStackTrace()
        }
    }

    fun saveParams(context: Context) {
        if (DEBUG) Log.v(TAG, "saveParams()")
        context.getSharedPreferences(LIVE_PARAMS_FILENAME, Context.MODE_PRIVATE).edit().apply {
            putString(RTSP_REQUEST_KEY, rtspRequest.value)
            putString(RTSP_USERNAME_KEY, rtspUsername.value)
            putString(RTSP_PASSWORD_KEY, rtspPassword.value)
            apply()
        }
    }

    fun initEditTexts(etRtspRequest: EditText, etRtspUsername: EditText, etRtspPassword: EditText) {
        if (DEBUG) Log.v(TAG, "initEditTexts()")
        etRtspRequest.addTextChangedListener(object : TextWatcher {
            override fun afterTextChanged(s: Editable?) {
            }
            override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
            }
            override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
                val text = s.toString()
                if (text != rtspRequest.value) {
                    rtspRequest.value = text
                }
            }
        })
        etRtspUsername.addTextChangedListener(object : TextWatcher {
            override fun afterTextChanged(s: Editable?) {
            }
            override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
            }
            override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
                val text = s.toString()
                if (text != rtspUsername.value) {
                    rtspUsername.value = text
                }
            }
        })
        etRtspPassword.addTextChangedListener(object : TextWatcher {
            override fun afterTextChanged(s: Editable?) {
            }
            override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
            }
            override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
                val text = s.toString()
                if (text != rtspPassword.value) {
                    rtspPassword.value = text
                }
            }
        })
    }

    companion object {
        private val TAG: String = LiveViewModel::class.java.simpleName
        private const val DEBUG = false


    }

}


================================================
FILE: app/src/main/java/com/alexvas/rtsp/demo/live/RawFragment.kt
================================================
package com.alexvas.rtsp.demo.live

import android.annotation.SuppressLint
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import com.alexvas.rtsp.RtspClient
import com.alexvas.rtsp.demo.databinding.FragmentRawBinding
import com.alexvas.rtsp.widget.toHexString
import com.alexvas.utils.NetUtils
import kotlinx.coroutines.Runnable
import java.net.Socket
import java.util.Timer
import java.util.TimerTask
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.min

@SuppressLint("LogNotTimber")
class RawFragment : Fragment() {

    private lateinit var binding: FragmentRawBinding
    private lateinit var liveViewModel: LiveViewModel

    private var statisticsTimer: Timer? = null
    private val rtspStopped = AtomicBoolean(true)

    private var rtspVideoBytesReceived: Long = 0
    private var rtspVideoFramesReceived: Long = 0
    private var rtspAudioBytesReceived: Long = 0
    private var rtspAudioSamplesReceived: Long = 0
    private var rtspApplicationBytesReceived: Long = 0
    private var rtspApplicationSamplesReceived: Long = 0

    private val rtspClientListener = object: RtspClient.RtspClientListener {
        override fun onRtspConnecting() {
            if (DEBUG) Log.v(TAG, "onRtspConnecting()")
            rtspVideoBytesReceived = 0
            rtspVideoFramesReceived = 0
            rtspAudioBytesReceived = 0
            rtspAudioSamplesReceived = 0
            rtspApplicationBytesReceived = 0
            rtspApplicationSamplesReceived = 0

            binding.apply {
                root.post {
                    updateStatistics()
                    llRtspParams.etRtspRequest.isEnabled = false
                    llRtspParams.etRtspUsername.isEnabled = false
                    llRtspParams.etRtspPassword.isEnabled = false
                    llRtspParams.cbVideo.isEnabled = false
                    llRtspParams.cbAudio.isEnabled = false
                    llRtspParams.cbApplication.isEnabled = false
                    llRtspParams.cbDebug.isEnabled = false
                    tvStatusSurface.text = "RTSP connecting"
                    bnStartStop.text = "Stop RTSP"
                }
            }
        }

        override fun onRtspConnected(sdpInfo: RtspClient.SdpInfo) {
            if (DEBUG) Log.v(TAG, "onRtspConnected()")
            binding.apply {
                root.post {
                    tvStatusSurface.text = "RTSP connected"
                }
            }
            startStatistics()
        }

        override fun onRtspVideoNalUnitReceived(data: ByteArray, offset: Int, length: Int, timestamp: Long) {
            val numBytesDump = min(length, 25) // dump max 25 bytes
            Log.i(TAG, "RTSP video data ($length bytes): ${data.toHexString(offset, offset + numBytesDump)}")
            rtspVideoBytesReceived += length
            rtspVideoFramesReceived++
        }

        override fun onRtspAudioSampleReceived(data: ByteArray, offset: Int, length: Int, timestamp: Long) {
            val numBytesDump = min(length, 25) // dump max 25 bytes
            Log.i(TAG, "RTSP audio data ($length bytes): ${data.toHexString(offset, offset + numBytesDump)}")
            rtspAudioBytesReceived += length
            rtspAudioSamplesReceived++
        }

        override fun onRtspApplicationDataReceived(data: ByteArray, offset: Int, length: Int, timestamp: Long) {
            val numBytesDump = min(length, 25) // dump max 25 bytes
            Log.i(TAG, "RTSP app data ($length bytes): ${data.toHexString(offset, offset + numBytesDump)}")
            rtspApplicationBytesReceived += length
            rtspApplicationSamplesReceived++
        }

        override fun onRtspDisconnecting() {
            if (DEBUG) Log.v(TAG, "onRtspDisconnecting()")
            binding.apply {
                root.post {
                    tvStatusSurface.text = "RTSP disconnecting"
                }
            }
            stopStatistics()
        }

        override fun onRtspDisconnected() {
            if (DEBUG) Log.v(TAG, "onRtspDisconnected()")
            binding.apply {
                root.post {
                    tvStatusSurface.text = "RTSP disconnected"
                    bnStartStop.text = "Start RTSP"
                    llRtspParams.cbVideo.isEnabled = true
                    llRtspParams.cbAudio.isEnabled = true
                    llRtspParams.cbApplication.isEnabled = true
                    llRtspParams.cbDebug.isEnabled = true
                    llRtspParams.etRtspRequest.isEnabled = true
                    llRtspParams.etRtspUsername.isEnabled = true
                    llRtspParams.etRtspPassword.isEnabled = true
                }
            }
        }

        override fun onRtspFailedUnauthorized() {
            if (DEBUG) Log.e(TAG, "onRtspFailedUnauthorized()")
            Log.e(TAG, "RTSP failed unauthorized")
            if (context == null) return
            onRtspDisconnected()
            binding.apply {
                root.post {
                    tvStatusSurface.text = "RTSP username or password invalid"
                }
            }
        }

        override fun onRtspFailed(message: String?) {
            if (DEBUG) Log.e(TAG, "onRtspFailed(message='$message')")
            Log.e(TAG, "RTSP failed with message '$message'")
            if (context == null) return
            onRtspDisconnected()
            binding.apply {
                root.post {
                    tvStatusSurface.text = "Error: $message"
                }
            }
        }
    }

    private val threadRunnable = Runnable {
        Log.i(TAG, "Thread started")
        var socket: Socket? = null
        try {
            val uri = Uri.parse(liveViewModel.rtspRequest.value)
            val port = if (uri.port == -1) DEFAULT_RTSP_PORT else uri.port
            socket = NetUtils.createSocketAndConnect(uri.host!!, port, 5000)

            val rtspClient =
                RtspClient.Builder(
                    socket,
                    uri.toString(),
                    rtspStopped,
                    rtspClientListener
                )
                    .requestVideo(binding.llRtspParams.cbVideo.isChecked)
                    .requestAudio(binding.llRtspParams.cbAudio.isChecked)
                    .requestApplication(binding.llRtspParams.cbApplication.isChecked)
                    .withDebug(binding.llRtspParams.cbDebug.isChecked)
                    .withUserAgent("rtsp-client-android")
                    .withCredentials(
                        binding.llRtspParams.etRtspUsername.text.toString(),
                        binding.llRtspParams.etRtspPassword.text.toString())
                    .build()

            rtspClient.execute()
        } catch (e: Exception) {
            e.printStackTrace()
            binding.root.post { rtspClientListener.onRtspFailed(e.message) }
        } finally {
            NetUtils.closeSocket(socket)
        }
        Log.i(TAG, "Thread stopped")
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        if (DEBUG) Log.v(TAG, "onCreateView()")

        liveViewModel = ViewModelProvider(this)[LiveViewModel::class.java]
        binding = FragmentRawBinding.inflate(inflater, container, false)

        liveViewModel.initEditTexts(
            binding.llRtspParams.etRtspRequest,
            binding.llRtspParams.etRtspUsername,
            binding.llRtspParams.etRtspPassword
        )
        liveViewModel.rtspRequest.observe(viewLifecycleOwner) {
            if (binding.llRtspParams.etRtspRequest.text.toString() != it)
                binding.llRtspParams.etRtspRequest.setText(it)
        }
        liveViewModel.rtspUsername.observe(viewLifecycleOwner) {
            if (binding.llRtspParams.etRtspUsername.text.toString() != it)
                binding.llRtspParams.etRtspUsername.setText(it)
        }
        liveViewModel.rtspPassword.observe(viewLifecycleOwner) {
            if (binding.llRtspParams.etRtspPassword.text.toString() != it)
                binding.llRtspParams.etRtspPassword.setText(it)
        }

        binding.bnStartStop.setOnClickListener {
            if (DEBUG) Log.v(TAG, "onClick() rtspStopped=${rtspStopped.get()}")
            if (rtspStopped.get()) {
                rtspStopped.set(false)
                Log.i(TAG, "Thread starting...")
                Thread(threadRunnable).apply {
                    name = "RTSP raw thread"
                    start()
                }
            } else {
                Log.i(TAG, "Thread stopping...")
                rtspStopped.set(true)
            }
        }
        return binding.root
    }

    override fun onResume() {
        if (DEBUG) Log.v(TAG, "onResume()")
        super.onResume()
        liveViewModel.loadParams(requireContext())
    }

    override fun onPause() {
        if (DEBUG) Log.v(TAG, "onPause()")
        super.onPause()
        liveViewModel.saveParams(requireContext())

        stopStatistics()
        rtspStopped.set(true)
    }

    private fun updateStatistics() {
//      if (DEBUG) Log.v(TAG, "updateStatistics()")
        binding.apply {
            tvStatisticsVideo.text = "Video: $rtspVideoBytesReceived bytes, $rtspVideoFramesReceived frames"
            tvStatisticsAudio.text = "Audio: $rtspAudioBytesReceived bytes, $rtspAudioSamplesReceived samples"
            tvStatisticsApplication.text = "Application: $rtspApplicationBytesReceived bytes, $rtspApplicationSamplesReceived samples"
        }
    }

    private fun startStatistics() {
        if (DEBUG) Log.v(TAG, "startStatistics()")
        Log.i(TAG, "Start statistics")
        if (statisticsTimer == null) {
            val task: TimerTask = object : TimerTask() {
                override fun run() {
                    binding.root.post {
                        updateStatistics()
                    }
                }
            }
            statisticsTimer = Timer("${TAG}::Statistics").apply {
                schedule(task, 0, 1000)
            }
        }
    }

    private fun stopStatistics() {
        if (DEBUG) Log.v(TAG, "stopStatistics()")
        statisticsTimer?.apply {
            Log.i(TAG, "Stop statistics")
            cancel()
        }
        statisticsTimer = null
    }

    companion object {
        private val TAG: String = RawFragment::class.java.simpleName
        private const val DEBUG = true

        private const val DEFAULT_RTSP_PORT = 554
    }

}


================================================
FILE: app/src/main/res/drawable/ic_camera_black_24dp.xml
================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:height="24dp"
    android:width="24dp"
    android:viewportWidth="24"
    android:viewportHeight="24">
    <path android:fillColor="#000" android:pathData="M4,4H7L9,2H15L17,4H20A2,2 0 0,1 22,6V18A2,2 0 0,1 20,20H4A2,2 0 0,1 2,18V6A2,2 0 0,1 4,4M12,7A5,5 0 0,0 7,12A5,5 0 0,0 12,17A5,5 0 0,0 17,12A5,5 0 0,0 12,7M12,9A3,3 0 0,1 15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9Z" />
</vector>

================================================
FILE: app/src/main/res/drawable/ic_cctv_black_24dp.xml
================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:height="24dp"
    android:width="24dp"
    android:viewportWidth="24"
    android:viewportHeight="24">
    <path android:fillColor="#000" android:pathData="M18.15,4.94C17.77,4.91 17.37,5 17,5.2L8.35,10.2C7.39,10.76 7.07,12 7.62,12.94L9.12,15.53C9.67,16.5 10.89,16.82 11.85,16.27L13.65,15.23C13.92,15.69 14.32,16.06 14.81,16.27V18.04C14.81,19.13 15.7,20 16.81,20H22V18.04H16.81V16.27C17.72,15.87 18.31,14.97 18.31,14C18.31,13.54 18.19,13.11 17.97,12.73L20.5,11.27C21.47,10.71 21.8,9.5 21.24,8.53L19.74,5.94C19.4,5.34 18.79,5 18.15,4.94M6.22,13.17L2,13.87L2.75,15.17L4.75,18.63L5.5,19.93L8.22,16.63L6.22,13.17Z" />
</vector>

================================================
FILE: app/src/main/res/drawable/ic_launcher_background.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="108dp"
    android:height="108dp"
    android:viewportWidth="108"
    android:viewportHeight="108">
    <path
        android:fillColor="#3DDC84"
        android:pathData="M0,0h108v108h-108z" />
    <path
        android:fillColor="#00000000"
        android:pathData="M9,0L9,108"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M19,0L19,108"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M29,0L29,108"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M39,0L39,108"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M49,0L49,108"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M59,0L59,108"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M69,0L69,108"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M79,0L79,108"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M89,0L89,108"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M99,0L99,108"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M0,9L108,9"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M0,19L108,19"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M0,29L108,29"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M0,39L108,39"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M0,49L108,49"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M0,59L108,59"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M0,69L108,69"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M0,79L108,79"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M0,89L108,89"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M0,99L108,99"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M19,29L89,29"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M19,39L89,39"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M19,49L89,49"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M19,59L89,59"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M19,69L89,69"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M19,79L89,79"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M29,19L29,89"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M39,19L39,89"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M49,19L49,89"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M59,19L59,89"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M69,19L69,89"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
    <path
        android:fillColor="#00000000"
        android:pathData="M79,19L79,89"
        android:strokeWidth="0.8"
        android:strokeColor="#33FFFFFF" />
</vector>


================================================
FILE: app/src/main/res/drawable/ic_launcher_foreground.xml
================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:height="16dp"
    android:width="16dp"
    android:viewportWidth="24"
    android:viewportHeight="24">
    <path android:fillColor="#ffffff" android:pathData="M18.15,4.94C17.77,4.91 17.37,5 17,5.2L8.35,10.2C7.39,10.76 7.07,12 7.62,12.94L9.12,15.53C9.67,16.5 10.89,16.82 11.85,16.27L13.65,15.23C13.92,15.69 14.32,16.06 14.81,16.27V18.04C14.81,19.13 15.7,20 16.81,20H22V18.04H16.81V16.27C17.72,15.87 18.31,14.97 18.31,14C18.31,13.54 18.19,13.11 17.97,12.73L20.5,11.27C21.47,10.71 21.8,9.5 21.24,8.53L19.74,5.94C19.4,5.34 18.79,5 18.15,4.94M6.22,13.17L2,13.87L2.75,15.17L4.75,18.63L5.5,19.93L8.22,16.63L6.22,13.17Z" />
</vector>

================================================
FILE: app/src/main/res/drawable/ic_text_subject_black_24dp.xml
================================================
<!-- drawable/text_subject.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:height="24dp"
    android:width="24dp"
    android:viewportWidth="24"
    android:viewportHeight="24">
    <path android:fillColor="#000" android:pathData="M4,5H20V7H4V5M4,9H20V11H4V9M4,13H20V15H4V13M4,17H14V19H4V17Z" />
</vector>

================================================
FILE: app/src/main/res/layout/activity_main.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <fragment
        android:layout_above="@+id/nav_view"
        android:id="@+id/nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:defaultNavHost="true"
        app:navGraph="@navigation/mobile_navigation" />

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/nav_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="?android:attr/windowBackground"
        android:layout_alignParentBottom="true"
        app:menu="@menu/bottom_nav_menu" />

</RelativeLayout>

================================================
FILE: app/src/main/res/layout/fragment_live.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".live.LiveFragment">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:padding="20dp">

        <include
            android:id="@+id/llRtspParams"
            layout="@layout/layout_rtsp_params"/>

        <CheckBox
            android:id="@+id/cbVideoFpsStabilization"
            android:text="Video frame rate stabilization.\nAdd delay up to 100ms for smoother playback. RtspSurfaceView only."
            android:layout_marginStart="5dp"
            android:checked="false"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />

        <CheckBox
            android:id="@+id/cbExperimentalRewriteSps"
            android:text="Rewrite SPS frames w/ low-latency params (EXPERIMENTAL)"
            android:checked="false"
            android:layout_marginStart="5dp"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />

        <Button
            android:layout_marginTop="40dp"
            android:id="@+id/bnStartStopSurface"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:text="Start" />


        <!-- RtspSurfaceView -->

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="match_parent"
            android:paddingBottom="5dp"
            android:text="RtspSurfaceView:"/>

        <androidx.constraintlayout.widget.ConstraintLayout
            android:id="@+id/csVideoSurface"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:animateLayoutChanges="true">
            <com.alexvas.rtsp.widget.RtspSurfaceView
                android:layout_width="0dp"
                android:layout_height="0dp"
                android:id="@+id/svVideoSurface"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintDimensionRatio="16:9"/>
            <View
                android:layout_width="0dp"
                android:layout_height="0dp"
                android:background="@android:color/black"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                android:id="@+id/vShutterSurface" />
            <ProgressBar
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="center"
                android:visibility="gone"
                android:id="@+id/pbLoadingSurface"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintEnd_toEndOf="parent"/>
        </androidx.constraintlayout.widget.ConstraintLayout>

        <!-- Debug statistics -->
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="2dp"
            android:id="@+id/tvStatistics"
            android:textSize="12sp"/>

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:paddingStart="5dp"
            android:paddingEnd="5dp"
            android:orientation="horizontal"
            android:gravity="center_vertical">
            <Button
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                style="@style/Widget.Material3.Button.TextButton.Icon"
                android:id="@+id/bnSnapshotSurface"
                android:enabled="false"
                android:text="Snapshot"
                app:icon="@drawable/ic_camera_black_24dp"/>
            <TextView
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:id="@+id/tvStatusSurface"
                android:gravity="end"/>
        </LinearLayout>


        <!-- RtspImageView -->

        <Button
            android:layout_marginTop="30dp"
            android:id="@+id/bnStartStopImage"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:text="Start" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="match_parent"
            android:paddingBottom="5dp"
            android:text="RtspImageView:"/>

        <androidx.constraintlayout.widget.ConstraintLayout
            android:id="@+id/csVideoImage"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:animateLayoutChanges="true">
            <com.alexvas.rtsp.widget.RtspImageView
                android:layout_width="0dp"
                android:layout_height="0dp"
                android:scaleType="fitXY"
                android:id="@+id/ivVideoImage"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintDimensionRatio="16:9"/>
            <View
                android:layout_width="0dp"
                android:layout_height="0dp"
                android:background="@android:color/black"
                android:id="@+id/vShutterImage"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintEnd_toEndOf="parent" />
            <ProgressBar
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="center"
                android:visibility="gone"
                android:id="@+id/pbLoadingImage"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintEnd_toEndOf="parent" />
        </androidx.constraintlayout.widget.ConstraintLayout>

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:padding="5dp"
            android:orientation="horizontal"
            android:gravity="center_vertical">
            <TextView
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:id="@+id/tvStatusImage"
                android:gravity="end"/>
        </LinearLayout>

        <LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="10dp"
            android:layout_gravity="center"
            android:orientation="vertical">

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Video decoder" />

            <com.google.android.material.button.MaterialButtonToggleGroup
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:id="@+id/bnVideoDecoderGroup"
                android:layout_gravity="center"
                app:singleSelection="true">

                <Button
                    android:id="@+id/bnVideoDecoderHardware"
                    style="?attr/materialButtonOutlinedStyle"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="Hardware" />

                <Button
                    android:id="@+id/bnVideoDecoderSoftware"
                    style="?attr/materialButtonOutlinedStyle"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="Software" />
            </com.google.android.material.button.MaterialButtonToggleGroup>
        </LinearLayout>

        <LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:layout_marginTop="10dp"
            android:orientation="vertical">

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Rotation" />

            <com.google.android.material.button.MaterialButtonToggleGroup
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="center"
                android:id="@+id/tgRotation"
                app:singleSelection="true">

                <Button
                    android:id="@+id/bnRotate0"
                    style="?attr/materialButtonOutlinedStyle"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="0" />

                <Button
                    android:id="@+id/bnRotate90"
                    style="?attr/materialButtonOutlinedStyle"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="90" />

                <Button
                    android:id="@+id/bnRotate180"
                    style="?attr/materialButtonOutlinedStyle"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="180" />

                <Button
                    android:id="@+id/bnRotate270"
                    style="?attr/materialButtonOutlinedStyle"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="270" />
            </com.google.android.material.button.MaterialButtonToggleGroup>
        </LinearLayout>

    </LinearLayout>

</ScrollView>

================================================
FILE: app/src/main/res/layout/fragment_logs.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<HorizontalScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_margin="8dp"
    android:layout_weight="1">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/log_recycler"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:scrollbars="vertical" />

</HorizontalScrollView>


================================================
FILE: app/src/main/res/layout/fragment_raw.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".live.LiveFragment">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:padding="20dp">

        <include
            android:id="@+id/llRtspParams"
            layout="@layout/layout_rtsp_params" />

        <Button
            android:layout_marginTop="10dp"
            android:id="@+id/bnStartStop"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:layout_marginBottom="10dp"
            android:text="Start" />

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:id="@+id/tvStatusSurface"
            android:gravity="end"/>

        <!-- Debug statistics -->
        <TextView
            android:id="@+id/tvStatisticsVideo"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="30dp"
            android:padding="5dp"
            android:textStyle="normal|bold"
            android:textSize="16sp"/>
        <TextView
            android:id="@+id/tvStatisticsAudio"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:padding="5dp"
            android:textStyle="normal|bold"
            android:textSize="16sp"/>
        <TextView
            android:id="@+id/tvStatisticsApplication"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:padding="5dp"
            android:textStyle="normal|bold"
            android:textSize="16sp"/>

    </LinearLayout>

</ScrollView>


================================================
FILE: app/src/main/res/layout/layout_rtsp_params.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <com.google.android.material.textfield.TextInputLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp">
        <EditText
            android:id="@+id/etRtspRequest"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:hint="RTSP request"
            android:inputType="textUri"/>
    </com.google.android.material.textfield.TextInputLayout>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:orientation="horizontal">

        <com.google.android.material.textfield.TextInputLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:layout_marginEnd="5dp">
            <EditText
                android:id="@+id/etRtspUsername"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:hint="RTSP username"/>
        </com.google.android.material.textfield.TextInputLayout>

        <com.google.android.material.textfield.TextInputLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            app:passwordToggleEnabled="true">
            <EditText
                android:id="@+id/etRtspPassword"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:inputType="textPassword"
                android:hint="RTSP password"/>
        </com.google.android.material.textfield.TextInputLayout>

    </LinearLayout>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:orientation="horizontal">

        <CheckBox
            android:id="@+id/cbVideo"
            android:text="Video"
            android:checked="true"
            android:layout_margin="5dp"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />

        <CheckBox
            android:id="@+id/cbAudio"
            android:text="Audio"
            android:checked="false"
            android:layout_margin="5dp"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />

        <CheckBox
            android:id="@+id/cbApplication"
            android:text="Application"
            android:checked="false"
            android:layout_margin="5dp"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />

        <CheckBox
            android:id="@+id/cbDebug"
            android:text="Debug"
            android:checked="false"
            android:layout_marginStart="20dp"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />
    </LinearLayout>

</LinearLayout>

================================================
FILE: app/src/main/res/menu/bottom_nav_menu.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">

    <item
        android:id="@+id/navigation_live"
        android:icon="@drawable/ic_cctv_black_24dp"
        android:title="@string/title_live" />

    <item
        android:id="@+id/navigation_raw"
        android:icon="@drawable/ic_cctv_black_24dp"
        android:title="@string/title_raw" />

    <item
        android:id="@+id/navigation_logs"
        android:icon="@drawable/ic_text_subject_black_24dp"
        android:title="@string/title_logs" />

</menu>


================================================
FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
    <background android:drawable="@drawable/ic_launcher_background" />
    <foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

================================================
FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
    <background android:drawable="@drawable/ic_launcher_background" />
    <foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

================================================
FILE: app/src/main/res/navigation/mobile_navigation.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/mobile_navigation"
    app:startDestination="@+id/navigation_live">

    <fragment
        android:id="@+id/navigation_live"
        android:name="com.alexvas.rtsp.demo.live.LiveFragment"
        android:label="@string/title_live"
        tools:layout="@layout/fragment_live" />

    <fragment
        android:id="@+id/navigation_raw"
        android:name="com.alexvas.rtsp.demo.live.RawFragment"
        android:label="@string/title_live"
        tools:layout="@layout/fragment_live" />

    <fragment
        android:id="@+id/navigation_logs"
        android:name="info.hannes.logcat.ui.LogcatFragment"
        android:label="@string/title_logs"
        tools:layout="@layout/fragment_log" />

</navigation>


================================================
FILE: app/src/main/res/values/colors.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="colorPrimary">#40747A</color>
    <color name="colorPrimaryDark">#00BCD4</color>
    <color name="colorAccent">#03DAC5</color>
</resources>


================================================
FILE: app/src/main/res/values/dimens.xml
================================================
<resources>
    <!-- Default screen margins, per the Android Design guidelines. -->
    <dimen name="activity_horizontal_margin">16dp</dimen>
    <dimen name="activity_vertical_margin">16dp</dimen>
</resources>


================================================
FILE: app/src/main/res/values/strings.xml
================================================
<resources>
    <string name="app_name">Rtsp demo</string>
    <string name="title_live">Live</string>
    <string name="title_raw">Raw</string>
    <string name="title_logs">Logs</string>
</resources>


================================================
FILE: app/src/main/res/values/styles.xml
================================================
<resources>

    <!-- Base application theme. -->
    <style name="AppTheme" parent="Theme.Material3.DayNight">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimaryDark</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
    </style>

</resources>


================================================
FILE: build.gradle
================================================
buildscript {

  ext.kotlin_version = '2.2.21'
  ext.compile_sdk_version = 36
  ext.min_sdk_version = 24
  ext.target_sdk_version = 35
  ext.project_version_code = 564
  ext.project_version_name = '5.6.4'

  repositories {
    google()
    mavenCentral()
  }
  dependencies {
    classpath 'com.android.tools.build:gradle:8.13.2'
    classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
  }
}

allprojects {
  repositories {
    google()
    mavenCentral()
    maven { url 'https://jitpack.io' }
  }
}

tasks.register('clean', Delete) {
  delete rootProject.layout.buildDirectory
}


================================================
FILE: gradle/wrapper/gradle-wrapper.properties
================================================
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists


================================================
FILE: gradle.properties
================================================
org.gradle.jvmargs=-Xmx1g
android.useAndroidX=true


================================================
FILE: gradlew
================================================
#!/bin/sh

#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#

##############################################################################
#
#   Gradle start up script for POSIX generated by Gradle.
#
#   Important for running:
#
#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
#       noncompliant, but you have some other compliant shell such as ksh or
#       bash, then to run this script, type that shell name before the whole
#       command line, like:
#
#           ksh Gradle
#
#       Busybox and similar reduced shells will NOT work, because this script
#       requires all of these POSIX shell features:
#         * functions;
#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
#         * compound commands having a testable exit status, especially «case»;
#         * various built-in commands including «command», «set», and «ulimit».
#
#   Important for patching:
#
#   (2) This script targets any POSIX shell, so it avoids extensions provided
#       by Bash, Ksh, etc; in particular arrays are avoided.
#
#       The "traditional" practice of packing multiple parameters into a
#       space-separated string is a well documented source of bugs and security
#       problems, so this is (mostly) avoided, by progressively accumulating
#       options in "$@", and eventually passing that to Java.
#
#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
#       see the in-line comments for details.
#
#       There are tweaks for specific operating systems such as AIX, CygWin,
#       Darwin, MinGW, and NonStop.
#
#   (3) This script is generated from the Groovy template
#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
#       within the Gradle project.
#
#       You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################

# Attempt to set APP_HOME

# Resolve links: $0 may be a link
app_path=$0

# Need this for daisy-chained symlinks.
while
    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
    [ -h "$app_path" ]
do
    ls=$( ls -ld "$app_path" )
    link=${ls#*' -> '}
    case $link in             #(
      /*)   app_path=$link ;; #(
      *)    app_path=$APP_HOME$link ;;
    esac
done

# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit

# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum

warn () {
    echo "$*"
} >&2

die () {
    echo
    echo "$*"
    echo
    exit 1
} >&2

# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in                #(
  CYGWIN* )         cygwin=true  ;; #(
  Darwin* )         darwin=true  ;; #(
  MSYS* | MINGW* )  msys=true    ;; #(
  NONSTOP* )        nonstop=true ;;
esac

CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar


# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
        # IBM's JDK on AIX uses strange locations for the executables
        JAVACMD=$JAVA_HOME/jre/sh/java
    else
        JAVACMD=$JAVA_HOME/bin/java
    fi
    if [ ! -x "$JAVACMD" ] ; then
        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME

Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
    fi
else
    JAVACMD=java
    if ! command -v java >/dev/null 2>&1
    then
        die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.

Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
    fi
fi

# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
    case $MAX_FD in #(
      max*)
        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
        # shellcheck disable=SC2039,SC3045
        MAX_FD=$( ulimit -H -n ) ||
            warn "Could not query maximum file descriptor limit"
    esac
    case $MAX_FD in  #(
      '' | soft) :;; #(
      *)
        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
        # shellcheck disable=SC2039,SC3045
        ulimit -n "$MAX_FD" ||
            warn "Could not set maximum file descriptor limit to $MAX_FD"
    esac
fi

# Collect all arguments for the java command, stacking in reverse order:
#   * args from the command line
#   * the main class name
#   * -classpath
#   * -D...appname settings
#   * --module-path (only if needed)
#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.

# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
    CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )

    JAVACMD=$( cygpath --unix "$JAVACMD" )

    # Now convert the arguments - kludge to limit ourselves to /bin/sh
    for arg do
        if
            case $arg in                                #(
              -*)   false ;;                            # don't mess with options #(
              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
                    [ -e "$t" ] ;;                      #(
              *)    false ;;
            esac
        then
            arg=$( cygpath --path --ignore --mixed "$arg" )
        fi
        # Roll the args list around exactly as many times as the number of
        # args, so each arg winds up back in the position where it started, but
        # possibly modified.
        #
        # NB: a `for` loop captures its iteration list before it begins, so
        # changing the positional parameters here affects neither the number of
        # iterations, nor the values presented in `arg`.
        shift                   # remove old arg
        set -- "$@" "$arg"      # push replacement arg
    done
fi


# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'

# Collect all arguments for the java command:
#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
#     and any embedded shellness will be escaped.
#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
#     treated as '${Hostname}' itself on the command line.

set -- \
        "-Dorg.gradle.appname=$APP_BASE_NAME" \
        -classpath "$CLASSPATH" \
        org.gradle.wrapper.GradleWrapperMain \
        "$@"

# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
    die "xargs is not available"
fi

# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
#   set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#

eval "set -- $(
        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
        xargs -n1 |
        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
        tr '\n' ' '
    )" '"$@"'

exec "$JAVACMD" "$@"


================================================
FILE: gradlew.bat
================================================
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem      https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem

@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem  Gradle startup script for Windows
@rem
@rem ##########################################################################

@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal

set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%

@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi

@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"

@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome

set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute

echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2

goto fail

:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe

if exist "%JAVA_EXE%" goto execute

echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2

goto fail

:execute
@rem Setup the command line

set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar


@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*

:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd

:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%

:mainEnd
if "%OS%"=="Windows_NT" endlocal

:omega


================================================
FILE: jitpack.yml
================================================
jdk:
  - openjdk17

install:
  - ./gradlew build :library-client-rtsp:publishToMavenLocal

================================================
FILE: library-client-rtsp/.gitignore
================================================
# Created by https://www.gitignore.io/api/android,java,intellij

### Android ###
# Built application files
*.apk
*.ap_

# Files for the Dalvik VM
*.dex

# Java class files
*.class

# Generated files
bin/
gen/

# Gradle files
.gradle/
build/

# Local configuration file (sdk path, etc)
local.properties

# Proguard folder generated by Eclipse
proguard/

xactmobile/class_files.txt
xactmobile/mapping.txt
xactmobile/seeds.txt

# Log Files
*.log

# Android Studio Navigation editor temp files
.navigation/

### Android Patch ###
gen-external-apklibs


### Java ###
*.class

# Mobile Tools for Java (J2ME)
.mtj.tmp/

# Package Files #
#*.jar
*.war
*.ear

# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*


### Intellij ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio

*.iml

## Directory-based project format:
.idea/
# if you remove the above rule, at least ignore the following:

# User-specific stuff:
.idea/workspace.xml
.idea/tasks.xml
.idea/dictionaries

# Sensitive or high-churn files:
.idea/dataSources.ids
.idea/dataSources.xml
.idea/sqlDataSources.xml
.idea/dynamic.xml
.idea/uiDesigner.xml

# Gradle:
.idea/gradle.xml
.idea/libraries

# Mongo Explorer plugin:
.idea/mongoSettings.xml

## File-based project format:
*.ipr
*.iws

## Plugin-specific files:

# IntelliJ
/out/

# mpeltonen/sbt-idea plugin
.idea_modules/

# JIRA plugin
atlassian-ide-plugin.xml

# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties

xactmobile/.DS_Store~64be78fe3602626c61b52bcbfd09e09a6107b50a
xactmobile/.DS_Store~HEAD
oslab-viewpager/._.DS_Store
oslab-viewpager/src/main/.DS_Store
oslab-viewpager/src/main/._.DS_Store
oslab-viewpager/src/main/res/.DS_Store
oslab-viewpager/src/main/res/._.DS_Store
oslab-viewpager/.gitignore
oslab-materialdesign/.DS_Store
oslab-materialdesign/._.DS_Store
oslab-materialdesign/src/.DS_Store
oslab-materialdesign/src/._.DS_Store
oslab-materialdesign/src/main/.DS_Store
oslab-materialdesign/src/main/._.DS_Store
oslab-materialdesign/src/main/res/.DS_Store
oslab-materialdesign/src/main/res/._.DS_Store


================================================
FILE: library-client-rtsp/build.gradle
================================================
plugins {
    id 'com.android.library'
    id 'kotlin-android'
    id 'maven-publish'
}

apply plugin: 'com.android.library'

project.afterEvaluate {
    publishing {
        publications {
            release(MavenPublication) {
                from components.release
            }
        }
    }
}

android {

    compileSdkVersion compile_sdk_version

    defaultConfig {
        minSdk min_sdk_version
        targetSdk target_sdk_version
    }

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_17
        targetCompatibility JavaVersion.VERSION_17
    }

    kotlinOptions {
        jvmTarget = JavaVersion.VERSION_17.toString()
    }

    namespace 'com.alexvas.rtsp'
}

dependencies {
    implementation 'androidx.annotation:annotation:1.9.1'
    implementation 'androidx.media3:media3-exoplayer:1.9.3'
    implementation 'androidx.camera:camera-core:1.5.3' // YUV -> BMP conversion
    implementation 'org.jcodec:jcodec:0.2.5' // SPS frame modification
}


================================================
FILE: library-client-rtsp/proguard-rules.txt
================================================
# Proguard rules.



================================================
FILE: library-client-rtsp/src/main/AndroidManifest.xml
================================================
<manifest
    xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET" />
</manifest>


================================================
FILE: library-client-rtsp/src/main/java/com/alexvas/rtsp/RtspClient.java
================================================
package com.alexvas.rtsp;

import android.text.TextUtils;
import android.util.Base64;
import android.util.Log;
import android.util.Pair;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import com.alexvas.rtsp.parser.AacParser;
import com.alexvas.rtsp.parser.G711Parser;
import com.alexvas.rtsp.parser.AudioParser;
import com.alexvas.rtsp.parser.RtpH264Parser;
import com.alexvas.rtsp.parser.RtpH265Parser;
import com.alexvas.rtsp.parser.RtpHeaderParser;
import com.alexvas.rtsp.parser.RtpParser;
import com.alexvas.utils.NetUtils;
import com.alexvas.utils.VideoCodecUtils;

import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Serial;
import java.math.BigInteger;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;

//OPTIONS rtsp://10.0.1.145:88/videoSub RTSP/1.0
//CSeq: 1
//User-Agent: Lavf58.29.100
//
//RTSP/1.0 200 OK
//CSeq: 1
//Date: Fri, Jan 03 2020 22:03:07 GMT
//Public: OPTIONS, DESCRIBE, SETUP, TEARDOWN, PLAY, PAUSE, GET_PARAMETER, SET_PARAMETER

//DESCRIBE rtsp://10.0.1.145:88/videoSub RTSP/1.0
//Accept: application/sdp
//CSeq: 2
//User-Agent: Lavf58.29.100
//
//RTSP/1.0 401 Unauthorized
//CSeq: 2
//Date: Fri, Jan 03 2020 22:03:07 GMT
//WWW-Authenticate: Digest realm="Foscam IPCam Living Video", nonce="3c889dbf8371d3660aa2496789a5d130"

//DESCRIBE rtsp://10.0.1.145:88/videoSub RTSP/1.0
//Accept: application/sdp
//CSeq: 3
//User-Agent: Lavf58.29.100
//Authorization: Digest username="admin", realm="Foscam IPCam Living Video", nonce="3c889dbf8371d3660aa2496789a5d130", uri="rtsp://10.0.1.145:88/videoSub", response="4f062baec1c813ae3db15e3a14111d3d"
//
//RTSP/1.0 200 OK
//CSeq: 3
//Date: Fri, Jan 03 2020 22:03:07 GMT
//Content-Base: rtsp://10.0.1.145:65534/videoSub/
//Content-Type: application/sdp
//Content-Length: 495
//
//v=0
//o=- 1578088972261172 1 IN IP4 10.0.1.145
//s=IP Camera Video
//i=videoSub
//t=0 0
//a=tool:LIVE555 Streaming Media v2014.02.10
//a=type:broadcast
//a=control:*
//a=range:npt=0-
//a=x-qt-text-nam:IP Camera Video
//a=x-qt-text-inf:videoSub
//m=video 0 RTP/AVP 96
//c=IN IP4 0.0.0.0
//b=AS:96
//a=rtpmap:96 H264/90000
//a=fmtp:96 packetization-mode=1;profile-level-id=420020;sprop-parameter-sets=Z0IAIJWoFAHmQA==,aM48gA==
//a=control:track1
//m=audio 0 RTP/AVP 0
//c=IN IP4 0.0.0.0
//b=AS:64
//a=control:track2
//SETUP rtsp://10.0.1.145:65534/videoSub/track1 RTSP/1.0
//Transport: RTP/AVP/UDP;unicast;client_port=27452-27453
//CSeq: 4
//User-Agent: Lavf58.29.100
//Authorization: Digest username="admin", realm="Foscam IPCam Living Video", nonce="3c889dbf8371d3660aa2496789a5d130", uri="rtsp://10.0.1.145:65534/videoSub/track1", response="1fbc50b24d582c9331dd5e89f3102a06"
//
//RTSP/1.0 200 OK
//CSeq: 4
//Date: Fri, Jan 03 2020 22:03:07 GMT
//Transport: RTP/AVP;unicast;destination=10.0.1.53;source=10.0.1.145;client_port=27452-27453;server_port=6972-6973
//Session: 1F91B1B6;timeout=65

//SETUP rtsp://10.0.1.145:65534/videoSub/track2 RTSP/1.0
//Transport: RTP/AVP/UDP;unicast;client_port=27454-27455
//CSeq: 5
//User-Agent: Lavf58.29.100
//Session: 1F91B1B6
//Authorization: Digest username="admin", realm="Foscam IPCam Living Video", nonce="3c889dbf8371d3660aa2496789a5d130", uri="rtsp://10.0.1.145:65534/videoSub/track2", response="ad779abe070c096eff1012e7c70c986a"
//
//RTSP/1.0 200 OK
//CSeq: 5
//Date: Fri, Jan 03 2020 22:03:07 GMT
//Transport: RTP/AVP;unicast;destination=10.0.1.53;source=10.0.1.145;client_port=27454-27455;server_port=6974-6975
//Session: 1F91B1B6;timeout=65

//PLAY rtsp://10.0.1.145:65534/videoSub/ RTSP/1.0
//Range: npt=0.000-
//CSeq: 6
//User-Agent: Lavf58.29.100
//Session: 1F91B1B6
//Authorization: Digest username="admin", realm="Foscam IPCam Living Video", nonce="3c889dbf8371d3660aa2496789a5d130", uri="rtsp://10.0.1.145:65534/videoSub/", response="bb52eb6938dd4e50c4fac50363ffded0"
//
//RTSP/1.0 200 OK
//CSeq: 6
//Date: Fri, Jan 03 2020 22:03:07 GMT
//Range: npt=0.000-
//Session: 1F91B1B6
//RTP-Info: url=rtsp://10.0.1.145:65534/videoSub/track1;seq=42731;rtptime=2690581590,url=rtsp://10.0.1.145:65534/videoSub/track2;seq=34051;rtptime=3328043318

// https://www.ietf.org/rfc/rfc2326.txt
public class RtspClient {

    private static final String TAG = RtspClient.class.getSimpleName();
            static final String TAG_DEBUG = TAG + " DBG";
    private static final boolean DEBUG = false;
    private static final byte[] EMPTY_ARRAY = new byte[0];

    public final static int RTSP_CAPABILITY_NONE          = 0;
    public final static int RTSP_CAPABILITY_OPTIONS       = 1 << 1;
    public final static int RTSP_CAPABILITY_DESCRIBE      = 1 << 2;
    public final static int RTSP_CAPABILITY_ANNOUNCE      = 1 << 3;
    public final static int RTSP_CAPABILITY_SETUP         = 1 << 4;
    public final static int RTSP_CAPABILITY_PLAY          = 1 << 5;
    public final static int RTSP_CAPABILITY_RECORD        = 1 << 6;
    public final static int RTSP_CAPABILITY_PAUSE         = 1 << 7;
    public final static int RTSP_CAPABILITY_TEARDOWN      = 1 << 8;
    public final static int RTSP_CAPABILITY_SET_PARAMETER = 1 << 9;
    public final static int RTSP_CAPABILITY_GET_PARAMETER = 1 << 10;
    public final static int RTSP_CAPABILITY_REDIRECT      = 1 << 11;

    public static boolean hasCapability(int capability, int capabilitiesMask) {
        return (capabilitiesMask & capability) != 0;
    }

    public interface RtspClientListener {
        void onRtspConnecting();
        void onRtspConnected(@NonNull SdpInfo sdpInfo);
        void onRtspVideoNalUnitReceived(@NonNull byte[] data, int offset, int length, long timestamp);
        void onRtspAudioSampleReceived(@NonNull byte[] data, int offset, int length, long timestamp);
        void onRtspApplicationDataReceived(@NonNull byte[] data, int offset, int length, long timestamp);
        void onRtspDisconnecting();
        void onRtspDisconnected();
        void onRtspFailedUnauthorized();
        void onRtspFailed(@Nullable String message);
    }

    private interface RtspClientKeepAliveListener {
        void onRtspKeepAliveRequested();
    }

    public static class SdpInfo {
        /**
         * Session name (RFC 2327). In most cases RTSP server name.
         */
        public @Nullable String sessionName;

        /**
         * Session description (RFC 2327).
         */
        public @Nullable String sessionDescription;

        public @Nullable VideoTrack videoTrack;
        public @Nullable AudioTrack audioTrack;
        public @Nullable ApplicationTrack applicationTrack;
    }

    public abstract static class Track {
        public String request;
        public int payloadType;

        @NonNull
        @Override
        public String toString() {
            return "Track{request='" + request + "', payloadType=" + payloadType + '}';
        }
    }

    public static final int VIDEO_CODEC_H264 = 0;
    public static final int VIDEO_CODEC_H265 = 1;

    public static class VideoTrack extends Track {
        public int videoCodec = VIDEO_CODEC_H264;
        public @Nullable byte[] sps; // Both H.264 and H.265
        public @Nullable byte[] pps; // Both H.264 and H.265
        public @Nullable byte[] vps; // H.265 only
    }

    public static final int AUDIO_CODEC_UNKNOWN = -1;
    public static final int AUDIO_CODEC_AAC = 0;
    public static final int AUDIO_CODEC_OPUS = 1;
    public static final int AUDIO_CODEC_G711_ULAW = 2;
    public static final int AUDIO_CODEC_G711_ALAW = 3;

    @NonNull
    private static String getAudioCodecName(int codec) {
        return switch (codec) {
            case AUDIO_CODEC_AAC -> "AAC";
            case AUDIO_CODEC_OPUS -> "Opus";
            case AUDIO_CODEC_G711_ULAW -> "G.711 uLaw";
            case AUDIO_CODEC_G711_ALAW -> "G.711 aLaw";
            default -> "Unknown";
        };
    }

    public static class AudioTrack extends Track {
        public int audioCodec = AUDIO_CODEC_UNKNOWN;
        public int sampleRateHz; // 16000, 8000
        public int channels; // 1 - mono, 2 - stereo
        public String mode; // AAC-lbr, AAC-hbr
        public @Nullable byte[] config; // config=1210fff15081ffdffc
    }

    public static class ApplicationTrack extends Track {
    }

    private static final String CRLF = "\r\n";

    // Size of buffer for reading from the connection
    private final static int MAX_LINE_SIZE = 4098;

    private static class UnauthorizedException extends IOException {
        UnauthorizedException() {
            super("Unauthorized");
        }
    }

    private final static class NoResponseHeadersException extends IOException {
        @Serial
        private static final long serialVersionUID = 1L;
    }

    private final @NonNull Socket rtspSocket;
    private @NonNull String uriRtsp;
    private final @NonNull AtomicBoolean exitFlag;
    private final @NonNull RtspClientListener listener;

//  private boolean sendOptionsCommand;
    private final boolean requestVideo;
    private final boolean requestAudio;
    private final boolean requestApplication;
    private final boolean debug;
    private final @Nullable String username;
    private final @Nullable String password;
    private final @Nullable String userAgent;

    private RtspClient(@NonNull RtspClient.Builder builder) {
        rtspSocket = builder.rtspSocket;
        uriRtsp = builder.uriRtsp;
        exitFlag = builder.exitFlag;
        listener = builder.listener;
//      sendOptionsCommand = builder.sendOptionsCommand;
        requestVideo = builder.requestVideo;
        requestAudio = builder.requestAudio;
        requestApplication = builder.requestApplication;
        username = builder.username;
        password = builder.password;
        debug = builder.debug;
        userAgent = builder.userAgent;
    }

    public void execute() {
        if (DEBUG) Log.v(TAG, "execute()");
        listener.onRtspConnecting();
        try {
            final InputStream inputStream = rtspSocket.getInputStream();
            final OutputStream outputStream = debug ?
                    new LoggerOutputStream(rtspSocket.getOutputStream()) :
                    new BufferedOutputStream(rtspSocket.getOutputStream());

            SdpInfo sdpInfo = new SdpInfo();
            final AtomicInteger cSeq = new AtomicInteger(0);
            ArrayList<Pair<String, String>> headers;
            int status;

            String authToken = null;
            Pair<String, String> digestRealmNonce = null;

// OPTIONS rtsp://10.0.1.78:8080/video/h264 RTSP/1.0
// CSeq: 1
// User-Agent: Lavf58.29.100

// RTSP/1.0 200 OK
// CSeq: 1
// Public: OPTIONS, DESCRIBE, SETUP, PLAY, GET_PARAMETER, SET_PARAMETER, TEARDOWN
//          if (sendOptionsCommand) {
            checkExitFlag(exitFlag);
            sendOptionsCommand(outputStream, uriRtsp, cSeq.addAndGet(1), userAgent, null);
            status = readResponseStatusCode(inputStream);
            headers = readResponseHeaders(inputStream);
            dumpHeaders(headers);
            // Try once again with credentials
            if (status == 401) {
                digestRealmNonce = getHeaderWwwAuthenticateDigestRealmAndNonce(headers);
                if (digestRealmNonce == null) {
                    String basicRealm = getHeaderWwwAuthenticateBasicRealm(headers);
                    if (TextUtils.isEmpty(basicRealm)) {
                        throw new IOException("Unknown authentication type");
                    }
                    // Basic auth
                    authToken = getBasicAuthHeader(username, password);
                } else {
                    // Digest auth
                    authToken = getDigestAuthHeader(username, password, "OPTIONS", uriRtsp, digestRealmNonce.first, digestRealmNonce.second);
                }
                checkExitFlag(exitFlag);
                sendOptionsCommand(outputStream, uriRtsp, cSeq.addAndGet(1), userAgent, authToken);
                status = readResponseStatusCode(inputStream);
                headers = readResponseHeaders(inputStream);
                dumpHeaders(headers);
            }
            if (DEBUG)
                Log.i(TAG, "OPTIONS status: " + status);
            checkStatusCode(status);
            final int capabilities = getSupportedCapabilities(headers);


// DESCRIBE rtsp://10.0.1.78:8080/video/h264 RTSP/1.0
// Accept: application/sdp
// CSeq: 2
// User-Agent: Lavf58.29.100

// RTSP/1.0 200 OK
// CSeq: 2
// Content-Type: application/sdp
// Content-Length: 364
//
// v=0
// t=0 0
// a=range:npt=now-
// m=video 0 RTP/AVP 96
// a=rtpmap:96 H264/90000
// a=fmtp:96 packetization-mode=1;sprop-parameter-sets=Z0KAH9oBABhpSCgwMDaFCag=,aM4G4g==
// a=control:trackID=1
// m=audio 0 RTP/AVP 96
// a=rtpmap:96 mpeg4-generic/48000/1
// a=fmtp:96 profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1188
// a=control:trackID=2
            checkExitFlag(exitFlag);

            if (digestRealmNonce != null) {
                authToken = getDigestAuthHeader(username, password, "DESCRIBE", uriRtsp, digestRealmNonce.first, digestRealmNonce.second);
            }
            sendDescribeCommand(outputStream, uriRtsp, cSeq.addAndGet(1), userAgent, authToken);
            status = readResponseStatusCode(inputStream);
            headers = readResponseHeaders(inputStream);
            dumpHeaders(headers);
            // Try once again with credentials. OPTIONS command can be accepted without authentication.
            if (status == 401) {
                digestRealmNonce = getHeaderWwwAuthenticateDigestRealmAndNonce(headers);
                if (digestRealmNonce == null) {
                    String basicRealm = getHeaderWwwAuthenticateBasicRealm(headers);
                    if (TextUtils.isEmpty(basicRealm)) {
                        throw new IOException("Unknown authentication type");
                    }
                    // Basic auth
                    authToken = getBasicAuthHeader(username, password);
                } else {
                    // Digest auth
                    authToken = getDigestAuthHeader(username, password, "DESCRIBE", uriRtsp, digestRealmNonce.first, digestRealmNonce.second);
                }
                checkExitFlag(exitFlag);
                sendDescribeCommand(outputStream, uriRtsp, cSeq.addAndGet(1), userAgent, authToken);
                status = readResponseStatusCode(inputStream);
                headers = readResponseHeaders(inputStream);
                dumpHeaders(headers);
            }
            if (DEBUG)
                Log.i(TAG, "DESCRIBE status: " + status);
            checkStatusCode(status);

            String contentBaseUri = getHeaderContentBase(headers);
            if (contentBaseUri != null) {
                if (debug)
                    Log.i(TAG_DEBUG, "RTSP URI changed to '" + uriRtsp + "'");
                uriRtsp = contentBaseUri;
            }

            int contentLength = getHeaderContentLength(headers);
            if (contentLength > 0) {
                String content = readContentAsText(inputStream, contentLength);
                if (debug)
                    Log.i(TAG_DEBUG, "" + content);
                try {
                    List<Pair<String, String>> params = getDescribeParams(content);
                    sdpInfo = getSdpInfoFromDescribeParams(params);
                    if (!requestVideo)
                        sdpInfo.videoTrack = null;
                    if (!requestAudio)
                        sdpInfo.audioTrack = null;
                    if (!requestApplication)
                        sdpInfo.applicationTrack = null;
                    // Only AAC supported
                    if (sdpInfo.audioTrack != null && sdpInfo.audioTrack.audioCodec == AUDIO_CODEC_UNKNOWN) {
                        Log.e(TAG_DEBUG, "Unknown RTSP audio codec (" + sdpInfo.audioTrack.audioCodec + ") specified in SDP");
                        sdpInfo.audioTrack = null;
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }


// SETUP rtsp://10.0.1.78:8080/video/h264/trackID=1 RTSP/1.0
// Transport: RTP/AVP/TCP;unicast;interleaved=0-1
// CSeq: 3
// User-Agent: Lavf58.29.100

// RTSP/1.0 200 OK
// CSeq: 3
// Transport: RTP/AVP/TCP;unicast;interleaved=0-1
// Session: Mzk5MzY2MzUwMTg3NTc2Mzc5NQ;timeout=30
            String session = null;
            int sessionTimeout = 0;
            for (int i = 0; i < 3; i++) {
                // 0 - video track, 1 - audio track, 2 - application track
                checkExitFlag(exitFlag);
                Track track;
                switch (i) {
                    case 0 -> track = requestVideo ? sdpInfo.videoTrack : null;
                    case 1 -> track = requestAudio ? sdpInfo.audioTrack : null;
                    default -> track = requestApplication ? sdpInfo.applicationTrack : null;
                }
                if (track != null) {
                    String uriRtspSetup = getUriForSetup(uriRtsp, track);
                    if (uriRtspSetup == null) {
                        Log.e(TAG, "Failed to get RTSP URI for SETUP");
                        continue;
                    }
                    if (digestRealmNonce != null)
                        authToken = getDigestAuthHeader(
                                username,
                                password,
                                "SETUP",
                                uriRtspSetup,
                                digestRealmNonce.first,
                                digestRealmNonce.second);
                    sendSetupCommand(
                            outputStream,
                            uriRtspSetup,
                            cSeq.addAndGet(1),
                            userAgent,
                            authToken,
                            session,
                            (i == 0 ? "0-1" /*video*/ : "2-3" /*audio*/));
                    status = readResponseStatusCode(inputStream);
                    if (DEBUG)
                        Log.i(TAG, "SETUP status: " + status);
                    checkStatusCode(status);
                    headers = readResponseHeaders(inputStream);
                    dumpHeaders(headers);
                    session = getHeader(headers, "Session");
                    if (!TextUtils.isEmpty(session)) {
                        // ODgyODg3MjQ1MDczODk3NDk4Nw;timeout=30
                        String[] params = TextUtils.split(session, ";");
                        session = params[0];
                        // Getting session timeout
                        if (params.length > 1) {
                            params = TextUtils.split(params[1], "=");
                            if (params.length > 1) {
                                try {
                                    sessionTimeout = Integer.parseInt(params[1]);
                                } catch (NumberFormatException e) {
                                    Log.e(TAG, "Failed to parse RTSP session timeout");
                                }
                            }
                        }
                    }
                    if (DEBUG)
                        Log.d(TAG, "SETUP session: " + session + ", timeout: " + sessionTimeout);
                    if (TextUtils.isEmpty(session))
                        throw new IOException("Failed to get RTSP session");
                }
            }

            if (TextUtils.isEmpty(session))
                throw new IOException("Failed to get any media track");

// PLAY rtsp://10.0.1.78:8080/video/h264 RTSP/1.0
// Range: npt=0.000-
// CSeq: 5
// User-Agent: Lavf58.29.100
// Session: Mzk5MzY2MzUwMTg3NTc2Mzc5NQ

// RTSP/1.0 200 OK
// CSeq: 5
// RTP-Info: url=/video/h264;seq=56
// Session: Mzk5MzY2MzUwMTg3NTc2Mzc5NQ;timeout=30
            checkExitFlag(exitFlag);
            if (digestRealmNonce != null)
                authToken = getDigestAuthHeader(username, password, "PLAY", uriRtsp /*?*/, digestRealmNonce.first, digestRealmNonce.second);
            sendPlayCommand(outputStream, uriRtsp, cSeq.addAndGet(1), userAgent, authToken, session);
            status = readResponseStatusCode(inputStream);
            if (DEBUG)
                Log.i(TAG, "PLAY status: " + status);
            checkStatusCode(status);
            headers = readResponseHeaders(inputStream);
            dumpHeaders(headers);

            listener.onRtspConnected(sdpInfo);

            if (sdpInfo.videoTrack != null ||  sdpInfo.audioTrack != null || sdpInfo.applicationTrack != null) {
                if (digestRealmNonce != null)
                    authToken = getDigestAuthHeader(username, password, hasCapability(RTSP_CAPABILITY_GET_PARAMETER, capabilities) ? "GET_PARAMETER" : "OPTIONS", uriRtsp, digestRealmNonce.first, digestRealmNonce.second);
                final String authTokenFinal = authToken;
                final String sessionFinal = session;
                RtspClientKeepAliveListener keepAliveListener = () -> {
                    try {
                        //GET_PARAMETER rtsp://10.0.1.155:554/cam/realmonitor?channel=1&subtype=1/ RTSP/1.0
                        //CSeq: 6
                        //User-Agent: Lavf58.45.100
                        //Session: 4066342621205
                        //Authorization: Digest username="admin", realm="Login to cam", nonce="8fb58500489d60f99a40b43f3c8574ef", uri="rtsp://10.0.1.155:554/cam/realmonitor?channel=1&subtype=1/", response="692a26124a1ee9562135785ace33a23b"

                        //RTSP/1.0 200 OK
                        //CSeq: 6
                        //Session: 4066342621205
                        if (debug)
                            Log.d(TAG_DEBUG, "Sending keep-alive");
                        if (hasCapability(RTSP_CAPABILITY_GET_PARAMETER, capabilities))
                            sendGetParameterCommand(outputStream, uriRtsp, cSeq.addAndGet(1), userAgent, sessionFinal, authTokenFinal);
                        else
                            sendOptionsCommand(outputStream, uriRtsp, cSeq.addAndGet(1), userAgent, authTokenFinal);

                        // Do not read response right now, since it may contain unread RTP frames.
                        // RtpHeader.searchForNextRtpHeader will handle that.
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                };

                // Blocking call unless exitFlag set to true, thread.interrupt() called or connection closed.
                try {
                    readRtpData(
                            inputStream,
                            sdpInfo,
                            exitFlag,
                            listener,
                            sessionTimeout / 2 * 1000,
                            keepAliveListener);
                } finally {
                    // Cleanup resources on server side
                    if (hasCapability(RTSP_CAPABILITY_TEARDOWN, capabilities)) {
                        if (digestRealmNonce != null)
                            authToken = getDigestAuthHeader(username, password, "TEARDOWN", uriRtsp, digestRealmNonce.first, digestRealmNonce.second);
                        sendTeardownCommand(outputStream, uriRtsp, cSeq.addAndGet(1), userAgent, authToken, sessionFinal);
                    }
                }

            } else {
                listener.onRtspFailed("No tracks found. RTSP server issue.");
            }

            listener.onRtspDisconnecting();
            listener.onRtspDisconnected();
        } catch (UnauthorizedException e) {
            e.printStackTrace();
            listener.onRtspFailedUnauthorized();
        } catch (InterruptedException e) {
            // Thread interrupted. Expected behavior.
            listener.onRtspDisconnecting();
            listener.onRtspDisconnected();
        } catch (Exception e) {
            e.printStackTrace();
            listener.onRtspFailed(e.getMessage());
        }
        try {
            rtspSocket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    @Nullable
    private static String getUriForSetup(@NonNull String uriRtsp, @Nullable Track track) {
        if (DEBUG) Log.v(TAG, "getUriForSetup(uriRtsp='" + uriRtsp + "', track=" + track + ")");
        if (track == null)
            return null;
        if (track.request == null) {
            // a=control:trackID=1 is missed
            Log.w(TAG, "Track request is empty. Skipping it.");
            track.request = uriRtsp;
        }
        String uriRtspSetup = uriRtsp;
        // Absolute URL
        if (track.request.startsWith("rtsp://") || track.request.startsWith("rtsps://")) {
            uriRtspSetup = track.request;
        // Relative URL
        } else {
            if (!track.request.startsWith("/") && !uriRtspSetup.endsWith("/")) {
                track.request = "/" + track.request;
            }
            uriRtspSetup += track.request;
        }
        return uriRtspSetup.trim();
    }

    private static void checkExitFlag(@NonNull AtomicBoolean exitFlag) throws InterruptedException {
        if (exitFlag.get())
            throw new InterruptedException();
    }

    private static void checkStatusCode(int code) throws IOException {
        switch (code) {
            case 200:
                break;
            case 401:
                throw new UnauthorizedException();
            default:
                throw new IOException("Invalid status code " + code);
        }
    }

    private static void readRtpData(
            @NonNull InputStream inputStream,
            @NonNull SdpInfo sdpInfo,
            @NonNull AtomicBoolean exitFlag,
            @NonNull RtspClientListener listener,
            int keepAliveTimeout,
            @NonNull RtspClientKeepAliveListener keepAliveListener)
    throws IOException {
        byte[] data = EMPTY_ARRAY; // Usually not bigger than MTU = 15KB

        final RtpParser videoParser = (sdpInfo.videoTrack != null && sdpInfo.videoTrack.videoCodec == VIDEO_CODEC_H265 ?
                new RtpH265Parser() :
                new RtpH264Parser());
        final AudioParser audioParser = sdpInfo.audioTrack != null
                ? switch (sdpInfo.audioTrack.audioCodec) {
                    case AUDIO_CODEC_AAC -> new AacParser(sdpInfo.audioTrack.mode);
                    case AUDIO_CODEC_G711_ULAW,
                         AUDIO_CODEC_G711_ALAW -> new G711Parser();
                    default -> null;
                }
                : null;

        byte[] nalUnitSps = (sdpInfo.videoTrack != null ? sdpInfo.videoTrack.sps : null);
        byte[] nalUnitPps = (sdpInfo.videoTrack != null ? sdpInfo.videoTrack.pps : null);
        byte[] nalUnitSei = EMPTY_ARRAY;
        byte[] nalUnitAud = EMPTY_ARRAY;
        int videoSeqNum = 0;

        long keepAliveSent = System.currentTimeMillis();

        while (!exitFlag.get()) {
            RtpHeaderParser.RtpHeader header = RtpHeaderParser.readHeader(inputStream);
            if (header == null) {
                continue;
//                throw new IOException("No RTP frames found");
            }
//          header.dumpHeader();
            if (header.payloadSize > data.length)
                data = new byte[header.payloadSize];

            NetUtils.readData(inputStream, data, 0, header.payloadSize);

            // Check if keep-alive should be sent
            long l = System.currentTimeMillis();
            if (keepAliveTimeout > 0 && l - keepAliveSent > keepAliveTimeout) {
                keepAliveSent = l;
                keepAliveListener.onRtspKeepAliveRequested();
            }

            // Video
            if (sdpInfo.videoTrack != null && header.payloadType == sdpInfo.videoTrack.payloadType) {
                if (videoSeqNum > header.sequenceNumber)
                    Log.w(TAG, "Invalid video seq num " + videoSeqNum + "/" + header.sequenceNumber);
                videoSeqNum = header.sequenceNumber;

                byte[] nalUnit;
                // If extendion bit set in header, skip extension data
                if (header.extension == 1) {
                    int skipBytes = ((data[2] & 0xFF) << 8 | (data[3] & 0xFF)) * 4 + 4;
                    nalUnit = videoParser.processRtpPacketAndGetNalUnit(Arrays.copyOfRange(data, skipBytes, data.length),
                            header.payloadSize - skipBytes, header.marker == 1);
                } else {
                    nalUnit = videoParser.processRtpPacketAndGetNalUnit(data, header.payloadSize, header.marker == 1);
                }

                if (nalUnit != null) {
                    boolean isH265 = sdpInfo.videoTrack.videoCodec == VIDEO_CODEC_H265;
                    byte type = VideoCodecUtils.INSTANCE.getNalUnitType(nalUnit, 0, nalUnit.length, isH265);
//                  Log.i(TAG, "NAL u: " + VideoCodecUtils.INSTANCE.getH265NalUnitTypeString(type));
                    switch (type) {
                        case VideoCodecUtils.NAL_SPS:
                            nalUnitSps = nalUnit;
                            // Looks like there is NAL_IDR_SLICE as well. Send it now.
                            if (nalUnit.length > VideoCodecUtils.MAX_NAL_SPS_SIZE)
                                listener.onRtspVideoNalUnitReceived(nalUnit, 0, nalUnit.length, header.getTimestampMsec());
                            break;

                        case VideoCodecUtils.NAL_PPS:
                            nalUnitPps = nalUnit;
                            // Looks like there is NAL_IDR_SLICE as well. Send it now.
                            if (nalUnit.length > VideoCodecUtils.MAX_NAL_SPS_SIZE)
                                listener.onRtspVideoNalUnitReceived(nalUnit, 0, nalUnit.length, header.getTimestampMsec());
                            break;

                        case VideoCodecUtils.NAL_AUD:
                            nalUnitAud = nalUnit;
                            break;

                        case VideoCodecUtils.NAL_SEI:
                            nalUnitSei = nalUnit;
                            break;

                        case VideoCodecUtils.NAL_IDR_SLICE:
                            // Combine IDR with SPS/PPS
                            if (nalUnitSps != null && nalUnitPps != null) {
                                byte[] nalUnitSpsPpsIdr = new byte[nalUnitAud.length + nalUnitSps.length + nalUnitPps.length + nalUnitSei.length + nalUnit.length];
                                int offset = 0;
                                System.arraycopy(nalUnitSps, 0, nalUnitSpsPpsIdr, offset, nalUnitSps.length);
                                offset += nalUnitSps.length;
                                System.arraycopy(nalUnitPps, 0, nalUnitSpsPpsIdr, offset, nalUnitPps.length);
                                offset += nalUnitPps.length;
                                System.arraycopy(nalUnitAud, 0, nalUnitSpsPpsIdr, offset, nalUnitAud.length);
                                offset += nalUnitAud.length;
                                System.arraycopy(nalUnitSei, 0, nalUnitSpsPpsIdr, offset, nalUnitSei.length);
                                offset += nalUnitSei.length;
                                System.arraycopy(nalUnit, 0, nalUnitSpsPpsIdr, offset, nalUnit.length);
                                listener.onRtspVideoNalUnitReceived(nalUnitSpsPpsIdr, 0, nalUnitSpsPpsIdr.length, header.getTimestampMsec());
//                              listener.onRtspVideoNalUnitReceived(nalUnitSppPpsIdr, 0, nalUnitSppPpsIdr.length, System.currentTimeMillis());
                                // Send it only once
                                nalUnitSps = null;
                                nalUnitPps = null;
                                nalUnitSei = EMPTY_ARRAY;
                                nalUnitAud = EMPTY_ARRAY;
                                break;
                            }

                        default:
                            if (nalUnitSei.length == 0 && nalUnitAud.length == 0) {
                                listener.onRtspVideoNalUnitReceived(nalUnit, 0, nalUnit.length, header.getTimestampMsec());
                            } else {
                                byte[] nalUnitAudSeiSlice = new byte[nalUnitAud.length + nalUnitSei.length + nalUnit.length];
                                int offset = 0;
                                System.arraycopy(nalUnitAud, 0, nalUnitAudSeiSlice, offset, nalUnitAud.length);
                                offset += nalUnitAud.length;
                                System.arraycopy(nalUnitSei, 0, nalUnitAudSeiSlice, offset, nalUnitSei.length);
                                offset += nalUnitSei.length;
                                System.arraycopy(nalUnit, 0, nalUnitAudSeiSlice, offset, nalUnit.length);
                                listener.onRtspVideoNalUnitReceived(nalUnitAudSeiSlice, 0, nalUnitAudSeiSlice.length, header.getTimestampMsec());
                                nalUnitSei = EMPTY_ARRAY;
                                nalUnitAud = EMPTY_ARRAY;
                            }
                    }
                }

            // Audio
            } else if (sdpInfo.audioTrack != null && header.payloadType == sdpInfo.audioTrack.payloadType) {
                if (audioParser != null) {
                    byte[] sample = audioParser.processRtpPacketAndGetSample(data, header.payloadSize);
                    if (sample != null)
                        listener.onRtspAudioSampleReceived(sample, 0, sample.length, header.getTimestampMsec());
                }

            // Application
            } else if (sdpInfo.applicationTrack != null && header.payloadType == sdpInfo.applicationTrack.payloadType) {
                listener.onRtspApplicationDataReceived(data, 0, header.payloadSize, header.getTimestampMsec());

            // Unknown
            } else {
                // https://www.iana.org/assignments/rtp-parameters/rtp-parameters.xhtml
                if (DEBUG && header.payloadType >= 96 && header.payloadType <= 127)
                    Log.w(TAG, "Invalid RTP payload type " + header.payloadType);
            }
        }
    }

    private static void sendSimpleCommand(
            @NonNull String command,
            @NonNull OutputStream outputStream,
            @NonNull String request,
            int cSeq,
            @Nullable String userAgent,
            @Nullable String session,
            @Nullable String authToken)
    throws IOException {
        outputStream.write((command + " " + request + " RTSP/1.0" + CRLF).getBytes());
        if (authToken != null)
            outputStream.write(("Authorization: " + authToken + CRLF).getBytes());
        outputStream.write(("CSeq: " + cSeq + CRLF).getBytes());
        if (userAgent != null)
            outputStream.write(("User-Agent: " + userAgent + CRLF).getBytes());
        if (session != null)
            outputStream.write(("Session: " + session + CRLF).getBytes());
        outputStream.write(CRLF.getBytes());
        outputStream.flush();
    }

    private static void sendOptionsCommand(
            @NonNull OutputStream outputStream,
            @NonNull String request,
            int cSeq,
            @Nullable String userAgent,
            @Nullable String authToken)
    throws IOException {
        if (DEBUG) Log.v(TAG, "sendOptionsCommand(request=\"" + request + "\", cSeq=" + cSeq + ")");
        sendSimpleCommand("OPTIONS", outputStream, request, cSeq, userAgent, null, authToken);
    }

    private static void sendGetParameterCommand(
            @NonNull OutputStream outputStream,
            @NonNull String request,
            int cSeq,
            @Nullable String userAgent,
            @Nullable String session,
            @Nullable String authToken)
    throws IOException {
        if (DEBUG) Log.v(TAG, "sendGetParameterCommand(request=\"" + request + "\", cSeq=" + cSeq + ")");
        sendSimpleCommand("GET_PARAMETER", outputStream, request, cSeq, userAgent, session, authToken);
    }

    private static void sendDescribeCommand(
            @NonNull OutputStream outputStream,
            @NonNull String request,
            int cSeq,
            @Nullable String userAgent,
            @Nullable String authToken)
    throws IOException {
        if (DEBUG) Log.v(TAG, "sendDescribeCommand(request=\"" + request + "\", cSeq=" + cSeq + ")");
        outputStream.write(("DESCRIBE " + request + " RTSP/1.0" + CRLF).getBytes());
        outputStream.write(("Accept: application/sdp" + CRLF).getBytes());
        if (authToken != null)
            outputStream.write(("Authorization: " + authToken + CRLF).getBytes());
        outputStream.write(("CSeq: " + cSeq + CRLF).getBytes());
        if (userAgent != null)
            outputStream.write(("User-Agent: " + userAgent + CRLF).getBytes());
        outputStream.write(CRLF.getBytes());
        outputStream.flush();
    }

    private static void sendTeardownCommand(
            @NonNull OutputStream outputStream,
            @NonNull String request,
            int cSeq,
            @Nullable String userAgent,
            @Nullable String authToken,
            @Nullable String session)
    throws IOException {
        if (DEBUG) Log.v(TAG, "sendTeardownCommand(request=\"" + request + "\", cSeq=" + cSeq + ")");
        outputStream.write(("TEARDOWN " + request + " RTSP/1.0" + CRLF).getBytes());
        if (authToken != null)
            outputStream.write(("Authorization: " + authToken + CRLF).getBytes());
        outputStream.write(("CSeq: " + cSeq + CRLF).getBytes());
        if (userAgent != null)
            outputStream.write(("User-Agent: " + userAgent + CRLF).getBytes());
        if (session != null)
            outputStream.write(("Session: " + session + CRLF).getBytes());
        outputStream.write(CRLF.getBytes());
        outputStream.flush();
    }

    private static void sendSetupCommand(
            @NonNull OutputStream outputStream,
            @NonNull String request,
            int cSeq,
            @Nullable String userAgent,
            @Nullable String authToken,
            @Nullable String session,
            @NonNull String interleaved)
    throws IOException {
        if (DEBUG) Log.v(TAG, "sendSetupCommand(request=\"" + request + "\", cSeq=" + cSeq + ")");
        outputStream.write(("SETUP " + request + " RTSP/1.0" + CRLF).getBytes());
        outputStream.write(("Transport: RTP/AVP/TCP;unicast;interleaved=" + interleaved + CRLF).getBytes());
        if (authToken != null)
            outputStream.write(("Authorization: " + authToken + CRLF).getBytes());
        outputStream.write(("CSeq: " + cSeq + CRLF).getBytes());
        if (userAgent != null)
            outputStream.write(("User-Agent: " + userAgent + CRLF).getBytes());
        if (session != null)
            outputStream.write(("Session: " + session + CRLF).getBytes());
        outputStream.write(CRLF.getBytes());
        outputStream.flush();
    }

    private static void sendPlayCommand(
            @NonNull OutputStream outputStream,
            @NonNull String request,
            int cSeq,
            @Nullable String userAgent,
            @Nullable String authToken,
            @NonNull String session)
    throws IOException {
        if (DEBUG) Log.v(TAG, "sendPlayCommand(request=\"" + request + "\", cSeq=" + cSeq + ")");
        outputStream.write(("PLAY " + request + " RTSP/1.0" + CRLF).getBytes());
        outputStream.write(("Range: npt=0.000-" + CRLF).getBytes());
        if (authToken != null)
            outputStream.write(("Authorization: " + authToken + CRLF).getBytes());
        outputStream.write(("CSeq: " + cSeq + CRLF).getBytes());
        if (userAgent != null)
            outputStream.write(("User-Agent: " + userAgent + CRLF).getBytes());
        outputStream.write(("Session: " + session + CRLF).getBytes());
        outputStream.write(CRLF.getBytes());
        outputStream.flush();
    }

    private int readResponseStatusCode(@NonNull InputStream inputStream) throws IOException {
//        String line = readLine(inputStream);
//        if (debug)
//            Log.d(TAG_DEBUG, "" + line);
        String line;
        byte[] rtspHeader = "RTSP/1.0 ".getBytes();
        // Search fpr "RTSP/1.0 "
        while (!exitFlag.get() && readUntilBytesFound(inputStream, rtspHeader) && (line = readLine(inputStream)) != null) {
            if (debug)
                Log.d(TAG_DEBUG, "" + line);
//            int indexRtsp = line.indexOf("TSP/1.0 "); // 8 characters, 'R' already found
//            if (indexRtsp >= 0) {
            int indexCode = line.indexOf(' ');
            String code = line.substring(0, indexCode);
            try {
                int statusCode = Integer.parseInt(code);
//                if (debug)
//                    Log.d(TAG_DEBUG, "Status code: " + statusCode);
                return statusCode;
            } catch (NumberFormatException e) {
                // Does not fulfill standard "RTSP/1.1 200 OK" token
                // Continue search for
            }
//            }
        }
        if (debug)
            Log.w(TAG_DEBUG, "Could not obtain status code");
        return -1;
    }

    @NonNull
    private ArrayList<Pair<String, String>> readResponseHeaders(@NonNull InputStream inputStream) throws IOException {
        ArrayList<Pair<String, String>> headers = new ArrayList<>();
        String line;
        while (!exitFlag.get() && !TextUtils.isEmpty(line = readLine(inputStream))) {
            if (debug)
                Log.d(TAG_DEBUG, "" + line);
            if (CRLF.equals(line)) {
                return headers;
            } else {
                String[] pairs = line.split(":", 2);
                if (pairs.length == 2) {
                    headers.add(Pair.create(pairs[0].trim(), pairs[1].trim()));
                }
            }
        }
        return headers;
    }

    /**
     * Get a list of tracks from SDP. Usually contains video and audio track only.
     * @return array of 3 tracks. First is video track, second audio track, third application track.
     */
    @NonNull
    private static Track[] getTracksFromDescribeParams(@NonNull List<Pair<String, String>> params) {
        Track[] tracks = new Track[3];
        Track currentTrack = null;
        for (Pair<String, String> param: params) {
            switch (param.first) {
                case "m":
                    // m=video 0 RTP/AVP 96
                    if (param.second.startsWith("video")) {
                        currentTrack = new VideoTrack();
                        tracks[0] = currentTrack;

                    // m=audio 0 RTP/AVP 97
                    // m=audio 0 RTP/AVP 0 8
                    } else if (param.second.startsWith("audio")) {
                        currentTrack = new AudioTrack();
                        tracks[1] = currentTrack;

                    // m=application 0 RTP/AVP 99
                    // a=rtpmap:99 com.my/90000
                    } else if (param.second.startsWith("application")) {
                        currentTrack = new ApplicationTrack();
                        tracks[2] = currentTrack;

                    } else if (param.second.startsWith("text")) {
                        Log.w(TAG, "Media track 'text' is not supported");

                    } else if (param.second.startsWith("message")) {
                        Log.w(TAG, "Media track 'message' is not supported");

                    } else {
                        currentTrack = null;
                    }

                    if (currentTrack != null) {
                        // m=<media> <port>/<number of ports> <proto> <fmt> ...
                        String[] values = TextUtils.split(param.second, " ");
                        try {
                            currentTrack.payloadType = (values.length > 3 ? Integer.parseInt(values[3]) : -1);
                            // Handle static PT that comes with no rtpmap
                            if (currentTrack instanceof AudioTrack track) {
                                switch (currentTrack.payloadType) {
                                    case 0 -> { // uLaw
                                        track.audioCodec = AUDIO_CODEC_G711_ULAW;
                                        track.sampleRateHz = 8000;
                                        track.channels = 1;
                                    }
                                    case 8 -> { // aLaw
                                        track.audioCodec = AUDIO_CODEC_G711_ALAW;
                                        track.sampleRateHz = 8000;
                                        track.channels = 1;
                                    }
                                }
                            }
                        } catch (Exception e) {
                            currentTrack.payloadType = -1;
                        }
                        if (currentTrack.payloadType == -1)
                            Log.e(TAG, "Failed to get payload type from \"m=" + param.second + "\"");
                    }
                    break;

                case "a":
                    // a=control:trackID=1
                    if (currentTrack != null) {
                        if (param.second.startsWith("control:")) {
                            currentTrack.request = param.second.substring(8);

                        // a=fmtp:96 packetization-mode=1; profile-level-id=4D4029; sprop-parameter-sets=Z01AKZpmBkCb8uAtQEBAQXpw,aO48gA==
                        // a=fmtp:97 streamtype=5; profile-level-id=15; mode=AAC-hbr; config=1408; sizeLength=13; indexLength=3; indexDeltaLength=3; profile=1; bitrate=32000;
                        // a=fmtp:97 streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1408
                        // a=fmtp:96 streamtype=5; profile-level-id=14; mode=AAC-lbr; config=1388; sizeLength=6; indexLength=2; indexDeltaLength=2; constantDuration=1024; maxDisplacement=5
                        // a=fmtp:96 profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1210fff15081ffdffc
                        // a=fmtp:96
                        } else if (param.second.startsWith("fmtp:")) {
                            // Video
                            if (currentTrack instanceof VideoTrack) {
                                updateVideoTrackFromDescribeParam((VideoTrack)tracks[0], param);
                            // Audio
                            } else if (currentTrack instanceof AudioTrack) {
                                updateAudioTrackFromDescribeParam((AudioTrack)tracks[1], param);
                            }

                        // a=rtpmap:96 H264/90000
                        // a=rtpmap:97 mpeg4-generic/16000/1
                        // a=rtpmap:97 MPEG4-GENERIC/16000
                        // a=rtpmap:97 G726-32/8000
                        // a=rtpmap:96 mpeg4-generic/44100/2
                        } else if (param.second.startsWith("rtpmap:")) {
                            // Video
                            String[] values = TextUtils.split(param.second, " ");
                            if (currentTrack instanceof VideoTrack) {
                                if (values.length > 1) {
                                    values = TextUtils.split(values[1], "/");
                                    if (values.length > 0) {
                                        switch (values[0].toLowerCase()) {
                                            case "h264" -> ((VideoTrack) tracks[0]).videoCodec = VIDEO_CODEC_H264;
                                            case "h265" -> ((VideoTrack) tracks[0]).videoCodec = VIDEO_CODEC_H265;
                                            default -> Log.w(TAG, "Unknown video codec \"" + values[0] + "\"");
                                        }
                                        Log.i(TAG, "Video: " + values[0]);
                                    }
                                }

                            // Audio
                            } else if (currentTrack instanceof AudioTrack) {
                                if (values.length > 1) {
                                    AudioTrack track = ((AudioTrack) tracks[1]);
                                    values = TextUtils.split(values[1], "/");
                                    if (values.length > 1) {
                                        switch (values[0].toLowerCase()) {
                                            case "mpeg4-generic" -> track.audioCodec = AUDIO_CODEC_AAC;
                                            case "opus" -> track.audioCodec = AUDIO_CODEC_OPUS;
                                            case "pcmu" -> track.audioCodec = AUDIO_CODEC_G711_ULAW;
                                            case "pcma" -> track.audioCodec = AUDIO_CODEC_G711_ALAW;
                                            default -> {
                                                Log.w(TAG, "Unknown audio codec \"" + values[0] + "\"");
                                                track.audioCodec = AUDIO_CODEC_UNKNOWN;
                                            }
                                        }
                                        track.sampleRateHz = Integer.parseInt(values[1]);
                                        // If no channels specified, use mono, e.g. "a=rtpmap:97 MPEG4-GENERIC/8000"
                                        track.channels = values.length > 2 ? Integer.parseInt(values[2]) : 1;
                                        Log.i(TAG, "Audio: " + getAudioCodecName(track.audioCodec) + ", sample rate: " + track.sampleRateHz + " Hz, channels: " + track.channels);
                                    }
                                }

                            // Application
                            } else {
                                // Do nothing
                            }
                        }
                    }
                    break;
            }
        }
        return tracks;
    }

//v=0
//o=- 1542237507365806 1542237507365806 IN IP4 10.0.1.111
//s=Media Presentation
//e=NONE
//b=AS:50032
//t=0 0
//a=control:*
//a=range:npt=0.000000-
//m=video 0 RTP/AVP 96
//c=IN IP4 0.0.0.0
//b=AS:50000
//a=framerate:25.0
//a=transform:1.000000,0.000000,0.000000;0.000000,1.000000,0.000000;0.000000,0.000000,1.000000
//a=control:trackID=1
//a=rtpmap:96 H264/90000
//a=fmtp:96 packetization-mode=1; profile-level-id=4D4029; sprop-parameter-sets=Z01AKZpmBkCb8uAtQEBAQXpw,aO48gA==
//m=audio 0 RTP/AVP 97
//c=IN IP4 0.0.0.0
//b=AS:32
//a=control:trackID=2
//a=rtpmap:97 G726-32/8000

// v=0
// o=- 14190294250618174561 14190294250618174561 IN IP4 127.0.0.1
// s=IP Webcam
// c=IN IP4 0.0.0.0
// t=0 0
// a=range:npt=now-
// a=control:*
// m=video 0 RTP/AVP 96
// a=rtpmap:96 H264/90000
// a=control:h264
// a=fmtp:96 packetization-mode=1;profile-level-id=42C028;sprop-parameter-sets=Z0LAKIyNQDwBEvLAPCIRqA==,aM48gA==;
// a=cliprect:0,0,1920,1080
// a=framerate:30.0
// a=framesize:96 1080-1920

    // Pair first - name, e.g. "a"; second - value, e.g "cliprect:0,0,1920,1080"
    @NonNull
    private static List<Pair<String, String>> getDescribeParams(@NonNull String text) {
        ArrayList<Pair<String, String>> list = new ArrayList<>();
        String[] params = TextUtils.split(text, "\r\n");
        for (String param : params) {
            int i = param.indexOf('=');
            if (i > 0) {
                String name = param.substring(0, i).trim();
                String value = param.substring(i + 1);
                list.add(Pair.create(name, value));
            }
        }
        return list;
    }

    @NonNull
    private static SdpInfo getSdpInfoFromDescribeParams(@NonNull List<Pair<String, String>> params) {
        SdpInfo sdpInfo = new SdpInfo();

        Track[] tracks = getTracksFromDescribeParams(params);
        sdpInfo.videoTrack = ((VideoTrack)tracks[0]);
        sdpInfo.audioTrack = ((AudioTrack)tracks[1]);
        sdpInfo.applicationTrack = ((ApplicationTrack)tracks[2]);

        for (Pair<String, String> param : params) {
            switch (param.first) {
                case "s" -> sdpInfo.sessionName = param.second;
                case "i" -> sdpInfo.sessionDescription = param.second;
            }
        }
        return sdpInfo;
    }

    // a=fmtp:97 streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1408
    @Nullable
    private static List<Pair<String, String>> getSdpAParams(@NonNull Pair<String, String> param) {
        if (param.first.equals("a") && param.second.startsWith("fmtp:") && param.second.length() > 8) { //
            String value = param.second.substring(8).trim(); // fmtp can be '96' (2 chars) and '127' (3 chars)
            String[] paramsA = TextUtils.split(value, ";");
            // streamtype=5
            // profile-level-id=1
            // mode=AAC-hbr
            ArrayList<Pair<String, String>> retParams = new ArrayList<>();
            for (String paramA: paramsA) {
                paramA = paramA.trim();
                // sprop-parameter-sets=Z0LAKIyNQDwBEvLAPCIRqA==,aM48gA==
                int i = paramA.indexOf("=");
                if (i != -1)
                    retParams.add(
                            Pair.create(
                                    paramA.substring(0, i),
                                    paramA.substring(i + 1)));
            }
            return retParams;
        } else {
            Log.w(TAG, "Not a valid fmtp");
        }
        return null;
    }

    @NonNull
    private static byte[] getNalUnitFromSprop(String nalBase64) {
        byte[] nal = Base64.decode(nalBase64, Base64.NO_WRAP);
        byte[] nalWithStart = new byte[nal.length + 4];
        // Add 00 00 00 01 NAL unit header
        nalWithStart[0] = 0;
        nalWithStart[1] = 0;
        nalWithStart[2] = 0;
        nalWithStart[3] = 1;
        System.arraycopy(nal, 0, nalWithStart, 4, nal.length);
        return nalWithStart;
    }

    private static void updateVideoTrackFromDescribeParam(@NonNull VideoTrack videoTrack, @NonNull Pair<String, String> param) {
        // a=fmtp:96 packetization-mode=1;profile-level-id=42C028;sprop-parameter-sets=Z0LAKIyNQDwBEvLAPCIRqA==,aM48gA==;
        // a=fmtp:96 packetization-mode=1; profile-level-id=4D4029; sprop-parameter-sets=Z01AKZpmBkCb8uAtQEBAQXpw,aO48gA==
        // a=fmtp:99 sprop-parameter-sets=Z0LgKdoBQBbpuAgIMBA=,aM4ySA==;packetization-mode=1;profile-level-id=42e029
        // a=fmtp:98 profile-id=1;sprop-sps=QgEBAWAAAAMAgAAAAwAAAwB4oAWCAJB/ja7tTd3Jdf+ACAAFtwUFBQQAAA+gAAGGoch3uUQD6AARlAB9AAIygg==;sprop-pps=RAHBcrAiQA==;sprop-vps=QAEMAf//AWAAAAMAgAAAAwAAAwB4rAk=
        List<Pair<String, String>> params = getSdpAParams(param);
        if (params != null) {
            for (Pair<String, String> pair: params) {
                switch (pair.first.toLowerCase()) {
                    case "sprop-sps" -> {
                        videoTrack.sps = getNalUnitFromSprop(pair.second);
                    }
                    case "sprop-pps" -> {
                        videoTrack.pps = getNalUnitFromSprop(pair.second);
                    }
                    case "sprop-vps" -> {
                        videoTrack.vps = getNalUnitFromSprop(pair.second);
                    }
                    case "sprop-parameter-sets" -> {
                        String[] paramsSpsPps = TextUtils.split(pair.second, ",");
                        if (paramsSpsPps.length > 1) {
                            videoTrack.sps = getNalUnitFromSprop(paramsSpsPps[0]);
                            videoTrack.pps = getNalUnitFromSprop(paramsSpsPps[1]);
//                            Base64.decode(paramsSpsPps[0], Base64.NO_WRAP);
//                            byte[] pps = Base64.decode(paramsSpsPps[1], Base64.NO_WRAP);
//                            byte[] nalSps = new byte[sps.length + 4];
//                            byte[] nalPps = new byte[pps.length + 4];
//                            // Add 00 00 00 01 NAL unit header
//                            nalSps[0] = 0;
//                            nalSps[1] = 0;
//                            nalSps[2] = 0;
//                            nalSps[3] = 1;
//                            System.arraycopy(sps, 0, nalSps, 4, sps.length);
//                            nalPps[0] = 0;
//                            nalPps[1] = 0;
//                            nalPps[2] = 0;
//                            nalPps[3] = 1;
//                            System.arraycopy(pps, 0, nalPps, 4, pps.length);
//                            videoTrack.sps = nalSps;
//                            videoTrack.pps = nalPps;
                        }
                    }
                    // packetization-mode=1
                    case "packetization-mode" -> {
                        // 0 - single NAL unit (default)
                        // 1 - non-interleaved mode (STAP-A and FU-A NAL units)
                        // 2 - interleaved mode
                        try {
                            int mode = Integer.parseInt(pair.second);
                            if (mode == 2)
                                Log.e(TAG, "Interleaved packetization mode is not supported");
                        } catch (NumberFormatException ignored) {
                        }
                    }
                }
            }
        }
    }

    @NonNull
    private static byte[] getBytesFromHexString(@NonNull String config) {
        // "1210fff1" -> [12, 10, ff, f1]
        return new BigInteger(config ,16).toByteArray();
    }

    private static void updateAudioTrackFromDescribeParam(@NonNull AudioTrack audioTrack, @NonNull Pair<String, String> param) {
        // a=fmtp:96 streamtype=5; profile-level-id=14; mode=AAC-lbr; config=1388; sizeLength=6; indexLength=2; indexDeltaLength=2; constantDuration=1024; maxDisplacement=5
        // a=fmtp:97 streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1408
        // a=fmtp:96 profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1210fff15081ffdffc
        List<Pair<String, String>> params = getSdpAParams(param);
        if (params != null) {
            for (Pair<String, String> pair: params) {
                switch (pair.first.toLowerCase()) {
                    case "mode" -> audioTrack.mode = pair.second;
                    case "config" -> audioTrack.config = getBytesFromHexString(pair.second);
                }
            }
        }
    }

    /**
     * Search for header "Content-Base: rtsp://example.com/stream/"
     * and return "rtsp://example.com/stream/"
     */
    @Nullable
    private static String getHeaderContentBase(@NonNull ArrayList<Pair<String, String>> headers) {
        String contentBase = getHeader(headers, "content-base");
        if (!TextUtils.isEmpty(contentBase)) {
            return contentBase;
        }
        return null;
    }

    private static int getHeaderContentLength(@NonNull ArrayList<Pair<String, String>> headers) {
        String length = getHeader(headers, "content-length");
        if (!TextUtils.isEmpty(length)) {
            try {
                return Integer.parseInt(length);
            } catch (NumberFormatException ignored) {
            }
        }
        return -1;
    }

    private static int getSupportedCapabilities(@NonNull ArrayList<Pair<String, String>> headers) {
        for (Pair<String, String> head: headers) {
            String h = head.first.toLowerCase();
            // Public: OPTIONS, DESCRIBE, SETUP, PLAY, GET_PARAMETER, SET_PARAMETER, TEARDOWN
            if ("public".equals(h)) {
                int mask = 0;
                String[] tokens = TextUtils.split(head.second.toLowerCase(), ",");
                for (String token: tokens) {
                    switch (token.trim()) {
                        case "options" -> mask |= RTSP_CAPABILITY_OPTIONS;
                        case "describe" -> mask |= RTSP_CAPABILITY_DESCRIBE;
                        case "announce" -> mask |= RTSP_CAPABILITY_ANNOUNCE;
                        case "setup" -> mask |= RTSP_CAPABILITY_SETUP;
                        case "play" -> mask |= RTSP_CAPABILITY_PLAY;
                        case "record" -> mask |= RTSP_CAPABILITY_RECORD;
                        case "pause" -> mask |= RTSP_CAPABILITY_PAUSE;
                        case "teardown" -> mask |= RTSP_CAPABILITY_TEARDOWN;
                        case "set_parameter" -> mask |= RTSP_CAPABILITY_SET_PARAMETER;
                        case "get_parameter" -> mask |= RTSP_CAPABILITY_GET_PARAMETER;
                        case "redirect" -> mask |= RTSP_CAPABILITY_REDIRECT;
                    }
                }
                return mask;
            }
        }
        return RTSP_CAPABILITY_NONE;
    }

    @Nullable
    private static Pair<String, String> getHeaderWwwAuthenticateDigestRealmAndNonce(@NonNull ArrayList<Pair<String, String>> headers) {
        for (Pair<String, String> head: headers) {
            String h = head.first.toLowerCase();
            // WWW-Authenticate: Digest realm="AXIS_00408CEF081C", nonce="00054cecY7165349339ae05f7017797d6b0aaad38f6ff45", stale=FALSE
            // WWW-Authenticate: Basic realm="AXIS_00408CEF081C"
            // WWW-Authenticate: Digest realm="Login to 4K049EBPAG1D7E7", nonce="de4ccb15804565dc8a4fa5b115695f4f"
            if ("www-authenticate".equals(h) && head.second.toLowerCase().startsWith("digest")) {
                String v = head.second.substring(7).trim();
                int begin, end;

                begin = v.indexOf("realm=");
                begin = v.indexOf('"', begin) + 1;
                end = v.indexOf('"', begin);
                String digestRealm = v.substring(begin, end);

                begin = v.indexOf("nonce=");
                begin = v.indexOf('"', begin)+1;
                end = v.indexOf('"', begin);
                String digestNonce = v.substring(begin, end);

                return Pair.create(digestRealm, digestNonce);
            }
        }
        return null;
    }

    @Nullable
    private static String getHeaderWwwAuthenticateBasicRealm(@NonNull ArrayList<Pair<String, String>> headers) {
        for (Pair<String, String> head: headers) {
            // Session: ODgyODg3MjQ1MDczODk3NDk4Nw
            String h = head.first.toLowerCase();
            String v = head.second.toLowerCase();
            // WWW-Authenticate: Digest realm="AXIS_00408CEF081C", nonce="00054cecY7165349339ae05f7017797d6b0aaad38f6ff45", stale=FALSE
            // WWW-Authenticate: Basic realm="AXIS_00408CEF081C"
            if ("www-authenticate".equals(h) && v.startsWith("basic")) {
                v = v.substring(6).trim();
                // realm=
                // AXIS_00408CEF081C
                String[] tokens = TextUtils.split(v, "\"");
                if (tokens.length > 2)
                    return tokens[1];
            }
        }
        return null;
    }

    // Basic authentication
    @NonNull
    private static String getBasicAuthHeader(@Nullable String username, @Nullable String password) {
        String auth = (username == null ? "" : username) + ":" + (password == null ? "" : password);
        return "Basic " + new String(Base64.encode(auth.getBytes(StandardCharsets.ISO_8859_1), Base64.NO_WRAP));
    }

    // Digest authentication
    @Nullable
    private static String getDigestAuthHeader(
            @Nullable String username,
            @Nullable String password,
            @NonNull String method,
            @NonNull String digestUri,
            @NonNull String realm,
            @NonNull String nonce) {
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            byte[] ha1;

            if (username == null)
                username = "";
            if (password == null)
                password = "";

            // calc A1 digest
            md.update(username.getBytes(StandardCharsets.ISO_8859_1));
            md.update((byte) ':');
            md.update(realm.getBytes(StandardCharsets.ISO_8859_1));
            md.update((byte) ':');
            md.update(password.getBytes(StandardCharsets.ISO_8859_1));
            ha1 = md.digest();

            // calc A2 digest
            md.reset();
            md.update(method.getBytes(StandardCharsets.ISO_8859_1));
            md.update((byte) ':');
            md.update(digestUri.getBytes(StandardCharsets.ISO_8859_1));
            byte[] ha2 = md.digest();

            // calc response
            md.update(getHexStringFromBytes(ha1).getBytes(StandardCharsets.ISO_8859_1));
            md.update((byte) ':');
            md.update(nonce.getBytes(StandardCharsets.ISO_8859_1));
            md.update((byte) ':');
            // TODO add support for more secure version of digest auth
            //md.update(nc.getBytes(StandardCharsets.ISO_8859_1));
            //md.update((byte) ':');
            //md.update(cnonce.getBytes(StandardCharsets.ISO_8859_1));
            //md.update((byte) ':');
            //md.update(qop.getBytes(StandardCharsets.ISO_8859_1));
            //md.update((byte) ':');
            md.update(getHexStringFromBytes(ha2).getBytes(StandardCharsets.ISO_8859_1));
            String response = getHexStringFromBytes(md.digest());

//            log.trace("username=\"{}\", realm=\"{}\", nonce=\"{}\", uri=\"{}\", response=\"{}\"",
//                    userName, digestRealm, digestNonce, digestUri, response);

            return "Digest username=\"" + username + "\", realm=\"" + realm + "\", nonce=\"" + nonce + "\", uri=\"" + digestUri + "\", response=\"" + response + "\"";
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

    @NonNull
    private static String getHexStringFromBytes(@NonNull byte[] bytes) {
        StringBuilder buf = new StringBuilder();
        for (byte b : bytes)
            buf.append(String.format("%02x", b));
        return buf.toString();
    }

    @NonNull
    private static String readContentAsText(@NonNull InputStream inputStream, int length) throws IOException {
        if (length <= 0)
            return "";
        byte[] b = new byte[length];
        int read = readData(inputStream, b, 0, length);
        return new String(b, 0, read);
    }

    // int memcmp ( const void * ptr1, const void * ptr2, size_t num );
    public static boolean memcmp(
            @NonNull byte[] source1,
            int offsetSource1,
            @NonNull byte[] source2,
            int offsetSource2,
            int num) {
        if (source1.length - offsetSource1 < num)
            return false;
        if (source2.length - offsetSource2 < num)
            return false;

        for (int i = 0; i < num; i++) {
            if (source1[offsetSource1 + i] != source2[offsetSource2 + i])
                return false;
        }
        return true;
    }

    private static void shiftLeftArray(@NonNull byte[] array, int num) {
        // ABCDEF -> BCDEF
        if (num - 1 >= 0)
            System.arraycopy(array, 1, array, 0, num - 1);
    }

    private boolean readUntilBytesFound(@NonNull InputStream inputStream, @NonNull byte[] array) throws IOException {
        byte[] buffer = new byte[array.length];

        // Fill in buffer
        if (NetUtils.readData(inputStream, buffer, 0, buffer.length) != buffer.length)
            return false; // EOF

        while (!exitFlag.get()) {
            // Check if buffer is the same one
            if (memcmp(buffer, 0, array, 0, buffer.length)) {
                return true;
            }
            // ABCDEF -> BCDEFF
            shiftLeftArray(buffer, buffer.length);
            // Read 1 byte into last buffer item
            if (NetUtils.readData(inputStream, buffer, buffer.length - 1, 1) != 1) {
                return false; // EOF
            }
        }
        return false;
    }

//    private boolean readUntilByteFound(@NonNull InputStream inputStream, byte bt) throws IOException {
//        byte[] buffer = new byte[1];
//        int readBytes;
//        while (!exitFlag.get()) {
//            readBytes = inputStream.read(buffer, 0, 1);
//            if (readBytes == -1) // EOF
//                return false;
//            if (readBytes == 1 && buffer[0] == bt) {
//                return true;
//            }
//        }
//        return false;
//    }

    @Nullable
    private String readLine(@NonNull InputStream inputStream) throws IOException {
        byte[] bufferLine = new byte[MAX_LINE_SIZE];
        int offset = 0;
        int readBytes;
        do {
            // Didn't find "\r\n" within 4K bytes
            if (offset >= MAX_LINE_SIZE) {
                throw new NoResponseHeadersException();
            }

            // Read 1 byte
            readBytes = inputStream.read(bufferLine, offset, 1);
            if (readBytes == 1) {
                // Check for EOL
                // Some cameras like Linksys WVC200 do not send \n instead of \r\n
                if (offset > 0 && /*bufferLine[offset-1] == '\r' &&*/ bufferLine[offset] == '\n') {
                    // Found empty EOL. End of header section
                    if (offset == 1)
                        return "";//break;

                    // Found EOL. Add to array.
                    return new String(bufferLine, 0, offset-1);
                } else {
                    offset++;
                }
            }
        } while (readBytes > 0 && !exitFlag.get());
        return null;
    }

    private static int readData(@NonNull InputStream inputStream, @NonNull byte[] buffer, int offset, int length) throws IOException {
        if (DEBUG) Log.v(TAG, "readData(offset=" + offset + ", length=" + length + ")");
        int readBytes;
        int totalReadBytes = 0;
        do {
            readBytes = inputStream.read(buffer, offset + totalReadBytes, length - totalReadBytes);
            if (readBytes > 0)
                totalReadBytes += readBytes;
        } while (readBytes >= 0 && totalReadBytes < length);
        return totalReadBytes;
    }

    private static void dumpHeaders(@NonNull ArrayList<Pair<String, String>> headers) {
        if (DEBUG) {
            for (Pair<String, String> head : headers) {
                Log.d(TAG, head.first + ": " + head.second);
            }
        }
    }

    @Nullable
    private static String getHeader(@NonNull ArrayList<Pair<String, String>> headers, @NonNull String header) {
        for (Pair<String, String> head: headers) {
            // Session: ODgyODg3MjQ1MDczODk3NDk4Nw
            String h = head.first.toLowerCase();
            if (header.toLowerCase().equals(h)) {
                return head.second;
            }
        }
        // Not found
        return null;
    }

    public static class Builder {

        private static final String DEFAULT_USER_AGENT = "Lavf58.29.100";

        private final @NonNull Socket rtspSocket;
        private final @NonNull String uriRtsp;
        private final @NonNull AtomicBoolean exitFlag;
        private final @NonNull RtspClientListener listener;
//      private boolean sendOptionsCommand = true;
        private boolean requestVideo = true;
        private boolean requestAudio = true;
        private boolean requestApplication = true;
        private boolean debug = false;
        private @Nullable String username = null;
        private @Nullable String password = null;
        private @Nullable String userAgent = DEFAULT_USER_AGENT;

        public Builder(
                @NonNull Socket rtspSocket,
                @NonNull String uriRtsp,
                @NonNull AtomicBoolean exitFlag,
                @NonNull RtspClientListener listener) {
            this.rtspSocket = rtspSocket;
            this.uriRtsp = uriRtsp;
            this.exitFlag = exitFlag;
            this.listener = listener;
        }

        @NonNull
        public Builder withDebug(boolean debug) {
            this.debug = debug;
            return this;
        }

        @NonNull
        public Builder withCredentials(@Nullable String username, @Nullable String password) {
            this.username = username;
            this.password = password;
            return this;
        }

        @NonNull
        public Builder withUserAgent(@Nullable String userAgent) {
            this.userAgent = userAgent;
            return this;
        }

//        @NonNull
//        public Builder sendOptionsCommand(boolean sendOptionsCommand) {
//            this.sendOptionsCommand = sendOptionsCommand;
//            return this;
//        }

        @NonNull
        public Builder requestVideo(boolean requestVideo) {
            this.requestVideo = requestVideo;
            return this;
        }

        @NonNull
        public Builder requestAudio(boolean requestAudio) {
            this.requestAudio = requestAudio;
            return this;
        }

        @NonNull
        public Builder requestApplication(boolean requestApplication) {
            this.requestApplication = requestApplication;
            return this;
        }

        @NonNull
        public RtspClient build() {
            return new RtspClient(this);
        }
    }
}

class LoggerOutputStream extends BufferedOutputStream {
    private boolean logging = true;

    public LoggerOutputStream(@NonNull OutputStream out) {
        super(out);
    }

    public synchronized void setLogging(boolean logging) {
        this.logging = logging;
    }

    @Override
    public synchronized void write(byte[] b, int off, int len) throws IOException {
        super.write(b, off, len);
        if (logging)
            Log.i(RtspClient.TAG_DEBUG, new String(b, off, len));
    }
}


================================================
FILE: library-client-rtsp/src/main/java/com/alexvas/rtsp/codec/AudioDecodeThread.kt
================================================
package com.alexvas.rtsp.codec

import android.media.*
import android.os.Process
import android.util.Log
import java.nio.ByteBuffer


class AudioDecodeThread (
        private val mimeType: String,
        private val sampleRate: Int,
        private val channelCount: Int,
        private val codecConfig: ByteArray?,
        private val audioFrameQueue: AudioFrameQueue) : Thread() {

    private var isRunning = true

    fun stopAsync() {
        if (DEBUG) Log.v(TAG, "stopAsync()")
        isRunning = false
        // Wake up sleep() code
        interrupt()
    }

    override fun run() {
        if (DEBUG) Log.d(TAG, "$name started")

        Process.setThreadPriority(Process.THREAD_PRIORITY_AUDIO)

        // Creating audio decoder
        val decoder = MediaCodec.createDecoderByType(mimeType)
        val format = MediaFormat.createAudioFormat(mimeType, sampleRate, channelCount)

        if (mimeType == MediaFormat.MIMETYPE_AUDIO_AAC) {
            val csd0 = codecConfig ?: getAacDecoderConfigData(MediaCodecInfo.CodecProfileLevel.AACObjectLC, sampleRate, channelCount)
            format.setByteBuffer("csd-0", ByteBuffer.wrap(csd0))
            format.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
        } else if (mimeType == MediaFormat.MIMETYPE_AUDIO_OPUS) {
            // TODO: Add Opus support

//            val OPUS_IDENTIFICATION_HEADER = "OpusHead".toByteArray()
//            val OPUS_PRE_SKIP_NSEC = ByteBuffer.allocate(8).putLong(11971).array()
//            val OPUS_SEEK_PRE_ROLL_NSEC = ByteBuffer.allocate(8).putLong(80000000).array()

//            val csd0 = ByteBuffer.allocate(8+1+1+2+4+2+1)
//            csd0.put("OpusHead".toByteArray())
//            // Version
//            csd0.put(1)
//            // Number of channels
//            csd0.put(2)
//            // Pre-skip
//            csd0.putShort(0)
//            csd0.putInt(sampleRate)
//            // Output Gain
//            csd0.putShort(0)
//            // Channel Mapping Family
//            csd0.put(0)
            // Buffer buf = new Buffer();
//                // Magic Signature:固定头,占8个字节,为字符串OpusHead
//                buf.write("OpusHead".getBytes(StandardCharsets.UTF_8));
//                // Version:版本号,占1字节,固定为0x01
//                buf.writeByte(1);
//                // Channel Count:通道数,占1字节,根据音频流通道自行设置,如0x02
//                buf.writeByte(1);
//                // Pre-skip:回放的时候从解码器中丢弃的samples数量,占2字节,为小端模式,默认设置0x00,
//                buf.writeShortLe(0);
//                // Input Sample Rate (Hz):音频流的Sample Rate,占4字节,为小端模式,根据实际情况自行设置
//                buf.writeIntLe(currentFormat.HZ);
//                //Output Gain:输出增益,占2字节,为小端模式,没有用到默认设置0x00, 0x00就好
//                buf.writeShortLe(0);
//                // Channel Mapping Family:通道映射系列,占1字节,默认设置0x00就好
//                buf.writeByte(0);
//                //Channel Mapping Table:可选参数,上面的Family默认设置0x00的时候可忽略
//            format.setByteBuffer("csd-0", ByteBuffer.wrap(OPUS_IDENTIFICATION_HEADER).order(ByteOrder.BIG_ENDIAN))
//            format.setByteBuffer("csd-1", ByteBuffer.wrap(OPUS_PRE_SKIP_NSEC).order(ByteOrder.BIG_ENDIAN))
//            format.setByteBuffer("csd-2", ByteBuffer.wrap(OPUS_SEEK_PRE_ROLL_NSEC).order(ByteOrder.LITTLE_ENDIAN))

            val csd0 = byteArrayOf(
                0x4f, 0x70, 0x75, 0x73, // "Opus"
                0x48, 0x65, 0x61, 0x64, // "Head"
                0x01,  // Version
                0x02,  // Channel Count
                0x00, 0x00,  // Pre skip
                0x80.toByte(), 0xbb.toByte(), 0x00, 0x00, // Sample rate 48000
                0x00, 0x00,  // Output Gain (Q7.8 in dB)
                0x00,  // Mapping Family
            )
            val csd1 = byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
            val csd2 = byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
            format.setByteBuffer("csd-0", ByteBuffer.wrap(csd0))
            format.setByteBuffer("csd-1", ByteBuffer.wrap(csd1))
            format.setByteBuffer("csd-2", ByteBuffer.wrap(csd2))
        }

        decoder.configure(format, null, null, 0)
        decoder.start()

        // Creating audio playback device
        val outChannel = if (channelCount > 1) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO
        val outAudio = AudioFormat.ENCODING_PCM_16BIT
        val bufferSize = AudioTrack.getMinBufferSize(sampleRate, outChannel, outAudio)
//      Log.i(TAG, "sampleRate: $sampleRate, bufferSize: $bufferSize".format(sampleRate, bufferSize))
        val audioTrack = AudioTrack(
                AudioAttributes.Builder()
                        .setUsage(AudioAttributes.USAGE_MEDIA)
                        .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
                        .build(),
                AudioFormat.Builder()
                        .setEncoding(outAudio)
                        .setChannelMask(outChannel)
                        .setSampleRate(sampleRate)
                        .build(),
                bufferSize,
                AudioTrack.MODE_STREAM,
                0)
        audioTrack.play()

        val bufferInfo = MediaCodec.BufferInfo()
        while (isRunning) {
            val inIndex: Int = decoder.dequeueInputBuffer(10000L)
            if (inIndex >= 0) {
                // fill inputBuffers[inputBufferIndex] with valid data
                var byteBuffer: ByteBuffer?
                try {
                    byteBuffer = decoder.getInputBuffer(inIndex)
                } catch (e: Exception) {
                    e.printStackTrace()
                    break
                }
                byteBuffer?.rewind()

                // Preventing BufferOverflowException
//              if (length > byteBuffer.limit()) throw DecoderFatalException("Error")

                val audioFrame: FrameQueue.Frame?
                try {
                    audioFrame = audioFrameQueue.pop()
                    if (audioFrame == null) {
                        Log.d(TAG, "Empty audio frame")
                        // Release input buffer
                        decoder.queueInputBuffer(inIndex, 0, 0, 0L, 0)
                    } else {
                        byteBuffer?.put(audioFrame.data, audioFrame.offset, audioFrame.length)
                        decoder.queueInputBuffer(inIndex, audioFrame.offset, audioFrame.length, audioFrame.timestampMs, 0)
                    }
                } catch (e: Exception) {
                    e.printStackTrace()
                }
            }
//            Log.i(TAG, "inIndex: ${inIndex}")

            try {
//                Log.w(TAG, "outIndex: ${outIndex}")
                if (!isRunning) break
                when (val outIndex = decoder.dequeueOutputBuffer(bufferInfo, 10000L)) {
                    MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> Log.d(TAG, "Decoder format changed: ${decoder.outputFormat}")
                    MediaCodec.INFO_TRY_AGAIN_LATER -> if (DEBUG) Log.d(TAG, "No output from decoder available")
                    else -> {
                        if (outIndex >= 0) {
                            val byteBuffer: ByteBuffer? = decoder.getOutputBuffer(outIndex)

                            val chunk = ByteArray(bufferInfo.size)
                            byteBuffer?.get(chunk)
                            byteBuffer?.clear()

                            if (chunk.isNotEmpty()) {
                                audioTrack.write(chunk, 0, chunk.size)
                            }
                            decoder.releaseOutputBuffer(outIndex, false)
                        }
                    }
                }
            } catch (e: Exception) {
                e.printStackTrace()
            }

            // All decoded frames have been rendered, we can stop playing now
            if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
                Log.d(TAG, "OutputBuffer BUFFER_FLAG_END_OF_STREAM")
                break
            }
        }
        audioTrack.flush()
        audioTrack.release()

        try {
            decoder.stop()
            decoder.release()
        } catch (_: InterruptedException) {
        } catch (e: Exception) {
            e.printStackTrace()
        }
        audioFrameQueue.clear()
        if (DEBUG) Log.d(TAG, "$name stopped")
    }

    companion object {
        private val TAG: String = AudioDecodeThread::class.java.simpleName
        private const val DEBUG = false

        fun getAacDecoderConfigData(audioProfile: Int, sampleRate: Int, channels: Int): ByteArray {
            // AOT_LC = 2
            // 0001 0000 0000 0000
            var extraDataAac = audioProfile shl 11
            // Sample rate
            when (sampleRate) {
                7350 -> extraDataAac = extraDataAac or (0xC shl 7)
                8000 -> extraDataAac = extraDataAac or (0xB shl 7)
                11025 -> extraDataAac = extraDataAac or (0xA shl 7)
                12000 -> extraDataAac = extraDataAac or (0x9 shl 7)
                16000 -> extraDataAac = extraDataAac or (0x8 shl 7)
                22050 -> extraDataAac = extraDataAac or (0x7 shl 7)
                24000 -> extraDataAac = extraDataAac or (0x6 shl 7)
                32000 -> extraDataAac = extraDataAac or (0x5 shl 7)
                44100 -> extraDataAac = extraDataAac or (0x4 shl 7)
                48000 -> extraDataAac = extraDataAac or (0x3 shl 7)
                64000 -> extraDataAac = extraDataAac or (0x2 shl 7)
                88200 -> extraDataAac = extraDataAac or (0x1 shl 7)
                96000 -> extraDataAac = extraDataAac or (0x0 shl 7)
            }
            // Channels
            extraDataAac = extraDataAac or (channels shl 3)
            val extraData = ByteArray(2)
            extraData[0] = (extraDataAac and 0xff00 shr 8).toByte() // high byte
            extraData[1] = (extraDataAac and 0xff).toByte()         // low byte
            return extraData
        }
    }

}



================================================
FILE: library-client-rtsp/src/main/java/com/alexvas/rtsp/codec/FrameQueue.kt
================================================
package com.alexvas.rtsp.codec

import java.util.concurrent.ArrayBlockingQueue
import java.util.concurrent.TimeUnit

enum class VideoCodecType {
    H264, H265, UNKNOWN
}

enum class AudioCodecType {
    AAC_LC, G711_ALAW, G711_MLAW, UNKNOWN
}

class VideoFrameQueue(frameQueueCapacity: Int): FrameQueue<FrameQueue.VideoFrame>(frameQueueCapacity)
class AudioFrameQueue(frameQueueCapacity: Int): FrameQueue<FrameQueue.AudioFrame>(frameQueueCapacity)

/**
 * Queue for concurrent adding/removing audio/video frames.
 */
open class FrameQueue<T>(private val frameQueueCapacity: Int) {

    interface Frame {
        val data: ByteArray
        val offset: Int
        val length: Int
        val timestampMs: Long  // presentation time in msec
    }

    data class VideoFrame(
        /** Only H264 codec supported */
        val codecType: VideoCodecType,
        /** Indicates whether it is a keyframe or not */
        val isKeyframe: Boolean,
        override val data: ByteArray,
        override val offset: Int,
        override val length: Int,
        /** Video frame timestamp (msec) generated by camera */
        override val timestampMs: Long,
        /** Captured (received) video frame timestamp (msec). If -1, not supported. */
        val capturedTimestampMs: Long = -1
    ) : Frame

    data class AudioFrame(
        val codecType: AudioCodecType,
//      val sampleRate: Int,
        override val data: ByteArray,
        override val offset: Int,
        override val length: Int,
        override val timestampMs: Long,
    ) : Frame

    private val queue = ArrayBlockingQueue<T>(frameQueueCapacity)

    val size: Int
        get() = queue.size

    val capacity: Int
        get() = frameQueueCapacity

    @Throws(InterruptedException::class)
    fun push(frame: T): Boolean {
        if (queue.offer(frame, 5, TimeUnit.MILLISECONDS)) {
            return true
        }
//        Log.w(TAG, "Cannot add frame, queue is full")
        return false
    }

    @Throws(InterruptedException::class)
    open fun pop(timeout: Long = 1000): T? {
        try {
            val frame: T? = queue.poll(timeout, TimeUnit.MILLISECONDS)
//            if (frame == null) {
//                Log.w(TAG, "Cannot get frame within 1 sec, queue is empty")
//            }
            return frame
        } catch (e: InterruptedException) {
            Thread.currentThread().interrupt()
        }
        return null
    }

    fun clear() {
        queue.clear()
    }

    fun copyInto(dstFrameQueue: FrameQueue<T>) {
        dstFrameQueue.queue.addAll(queue)
    }

    companion object {
        private val TAG: String = FrameQueue::class.java.simpleName
    }

}


================================================
FILE: library-client-rtsp/src/main/java/com/alexvas/rtsp/codec/VideoDecodeThread.kt
================================================
package com.alexvas.rtsp.codec

import android.annotation.SuppressLint
import android.media.MediaCodec
import android.media.MediaCodec.OnFrameRenderedListener
import android.media.MediaFormat
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.Process
import android.util.Log
import com.alexvas.utils.MediaCodecUtils
import com.alexvas.utils.capabilitiesToString
import androidx.media3.common.util.Util
import com.alexvas.utils.VideoCodecUtils
import com.limelight.binding.video.MediaCodecHelper
import java.lang.Integer.min
import java.nio.ByteBuffer
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean

abstract class VideoDecodeThread (
    protected val mimeType: String,
    protected val width: Int,
    protected val height: Int,
    protected val rotation: Int, // 0, 90, 180, 270
    protected val videoFrameQueue: VideoFrameQueue,
    protected val videoDecoderListener: VideoDecoderListener,
    protected var videoDecoderType: DecoderType
) : Thread() {

    enum class DecoderType {
        HARDWARE,
        SOFTWARE // fallback
    }

    interface VideoDecoderListener {
        /** Video decoder successfully started */
        fun onVideoDecoderStarted() {}
        /** Video decoder successfully stopped */
        fun onVideoDecoderStopped() {}
        /** Fatal error occurred */
        fun onVideoDecoderFailed(message: String?) {}
        /** Resolution changed */
        fun onVideoDecoderFormatChanged(width: Int, height: Int) {}
        /** First video frame rendered */
        fun onVideoDecoderFirstFrameRendered() {}
    }

    protected val uiHandler = Handler(Looper.getMainLooper())
    protected var exitFlag = AtomicBoolean(false)
    protected var firstFrameRendered = false

    /** Decoder latency used for statistics */
    @Volatile private var decoderLatency = -1
    /** Flag for allowing calculating latency */
    private var decoderLatencyRequested = false
    /** Network latency used for statistics */
    @Volatile private var networkLatency = -1
    private var videoDecoderName: String? = null
    private var firstFrameDecoded = false
    @Volatile private var videoFrameRateStabilization = false

    fun stopAsync() {
        if (DEBUG) Log.v(TAG, "stopAsync()")
        exitFlag.set(true)
        // Wake up sleep() code
        interrupt()
    }

    /**
     * Currently used video decoder. Video decoder can be changed on runtime.
     * If videoDecoderType set to HARDWARE, it can be switched to SOFTWARE in case of decoding issue
     * (e.g. hardware decoder does not support the stream resolution).
     * If videoDecoderType set to SOFTWARE, it will always remain SOFTWARE (no any changes).
     */
    fun getCurrentVideoDecoderType(): DecoderType {
        return videoDecoderType
    }

    fun getCurrentVideoDecoderName(): String? {
        return videoDecoderName
    }

    /**
     * Get frames de
Download .txt
gitextract_xa9itofy/

├── .github/
│   └── FUNDING.yml
├── .gitignore
├── LICENSE
├── README.md
├── app/
│   ├── .gitignore
│   ├── build.gradle
│   ├── proguard-rules.pro
│   └── src/
│       └── main/
│           ├── AndroidManifest.xml
│           ├── java/
│           │   └── com/
│           │       └── alexvas/
│           │           └── rtsp/
│           │               └── demo/
│           │                   ├── MainActivity.kt
│           │                   └── live/
│           │                       ├── LiveFragment.kt
│           │                       ├── LiveViewModel.kt
│           │                       └── RawFragment.kt
│           └── res/
│               ├── drawable/
│               │   ├── ic_camera_black_24dp.xml
│               │   ├── ic_cctv_black_24dp.xml
│               │   ├── ic_launcher_background.xml
│               │   ├── ic_launcher_foreground.xml
│               │   └── ic_text_subject_black_24dp.xml
│               ├── layout/
│               │   ├── activity_main.xml
│               │   ├── fragment_live.xml
│               │   ├── fragment_logs.xml
│               │   ├── fragment_raw.xml
│               │   └── layout_rtsp_params.xml
│               ├── menu/
│               │   └── bottom_nav_menu.xml
│               ├── mipmap-anydpi-v26/
│               │   ├── ic_launcher.xml
│               │   └── ic_launcher_round.xml
│               ├── navigation/
│               │   └── mobile_navigation.xml
│               └── values/
│                   ├── colors.xml
│                   ├── dimens.xml
│                   ├── strings.xml
│                   └── styles.xml
├── build.gradle
├── gradle/
│   └── wrapper/
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
├── jitpack.yml
├── library-client-rtsp/
│   ├── .gitignore
│   ├── build.gradle
│   ├── proguard-rules.txt
│   └── src/
│       └── main/
│           ├── AndroidManifest.xml
│           └── java/
│               └── com/
│                   ├── alexvas/
│                   │   ├── rtsp/
│                   │   │   ├── RtspClient.java
│                   │   │   ├── codec/
│                   │   │   │   ├── AudioDecodeThread.kt
│                   │   │   │   ├── FrameQueue.kt
│                   │   │   │   ├── VideoDecodeThread.kt
│                   │   │   │   ├── VideoDecoderBitmapThread.kt
│                   │   │   │   ├── VideoDecoderSurfaceThread.kt
│                   │   │   │   └── color/
│                   │   │   │       ├── ColorConverter.kt
│                   │   │   │       └── ColorConverterImage.kt
│                   │   │   ├── parser/
│                   │   │   │   ├── AacParser.java
│                   │   │   │   ├── AudioParser.kt
│                   │   │   │   ├── G711Parser.kt
│                   │   │   │   ├── RtpH264Parser.kt
│                   │   │   │   ├── RtpH265Parser.kt
│                   │   │   │   ├── RtpHeaderParser.java
│                   │   │   │   └── RtpParser.kt
│                   │   │   └── widget/
│                   │   │       ├── RtspImageView.kt
│                   │   │       ├── RtspListeners.kt
│                   │   │       ├── RtspProcessor.kt
│                   │   │       └── RtspSurfaceView.kt
│                   │   └── utils/
│                   │       ├── ByteUtils.java
│                   │       ├── MediaCodecUtils.kt
│                   │       ├── NetUtils.java
│                   │       └── VideoCodecUtils.kt
│                   └── limelight/
│                       └── binding/
│                           └── video/
│                               └── MediaCodecHelper.java
└── settings.gradle
Download .txt
SYMBOL INDEX (141 symbols across 6 files)

FILE: library-client-rtsp/src/main/java/com/alexvas/rtsp/RtspClient.java
  class RtspClient (line 129) | public class RtspClient {
    method hasCapability (line 149) | public static boolean hasCapability(int capability, int capabilitiesMa...
    type RtspClientListener (line 153) | public interface RtspClientListener {
      method onRtspConnecting (line 154) | void onRtspConnecting();
      method onRtspConnected (line 155) | void onRtspConnected(@NonNull SdpInfo sdpInfo);
      method onRtspVideoNalUnitReceived (line 156) | void onRtspVideoNalUnitReceived(@NonNull byte[] data, int offset, in...
      method onRtspAudioSampleReceived (line 157) | void onRtspAudioSampleReceived(@NonNull byte[] data, int offset, int...
      method onRtspApplicationDataReceived (line 158) | void onRtspApplicationDataReceived(@NonNull byte[] data, int offset,...
      method onRtspDisconnecting (line 159) | void onRtspDisconnecting();
      method onRtspDisconnected (line 160) | void onRtspDisconnected();
      method onRtspFailedUnauthorized (line 161) | void onRtspFailedUnauthorized();
      method onRtspFailed (line 162) | void onRtspFailed(@Nullable String message);
    type RtspClientKeepAliveListener (line 165) | private interface RtspClientKeepAliveListener {
      method onRtspKeepAliveRequested (line 166) | void onRtspKeepAliveRequested();
    class SdpInfo (line 169) | public static class SdpInfo {
    class Track (line 185) | public abstract static class Track {
      method toString (line 189) | @NonNull
    class VideoTrack (line 199) | public static class VideoTrack extends Track {
    method getAudioCodecName (line 212) | @NonNull
    class AudioTrack (line 223) | public static class AudioTrack extends Track {
    class ApplicationTrack (line 231) | public static class ApplicationTrack extends Track {
    class UnauthorizedException (line 239) | private static class UnauthorizedException extends IOException {
      method UnauthorizedException (line 240) | UnauthorizedException() {
    class NoResponseHeadersException (line 245) | private final static class NoResponseHeadersException extends IOExcept...
    method RtspClient (line 264) | private RtspClient(@NonNull RtspClient.Builder builder) {
    method execute (line 279) | public void execute() {
    method getUriForSetup (line 591) | @Nullable
    method checkExitFlag (line 615) | private static void checkExitFlag(@NonNull AtomicBoolean exitFlag) thr...
    method checkStatusCode (line 620) | private static void checkStatusCode(int code) throws IOException {
    method readRtpData (line 631) | private static void readRtpData(
    method sendSimpleCommand (line 786) | private static void sendSimpleCommand(
    method sendOptionsCommand (line 807) | private static void sendOptionsCommand(
    method sendGetParameterCommand (line 818) | private static void sendGetParameterCommand(
    method sendDescribeCommand (line 830) | private static void sendDescribeCommand(
    method sendTeardownCommand (line 849) | private static void sendTeardownCommand(
    method sendSetupCommand (line 870) | private static void sendSetupCommand(
    method sendPlayCommand (line 893) | private static void sendPlayCommand(
    method readResponseStatusCode (line 914) | private int readResponseStatusCode(@NonNull InputStream inputStream) t...
    method readResponseHeaders (line 944) | @NonNull
    method getTracksFromDescribeParams (line 967) | @NonNull
    method getDescribeParams (line 1144) | @NonNull
    method getSdpInfoFromDescribeParams (line 1159) | @NonNull
    method getSdpAParams (line 1178) | @Nullable
    method getNalUnitFromSprop (line 1204) | @NonNull
    method updateVideoTrackFromDescribeParam (line 1217) | private static void updateVideoTrackFromDescribeParam(@NonNull VideoTr...
    method getBytesFromHexString (line 1276) | @NonNull
    method updateAudioTrackFromDescribeParam (line 1282) | private static void updateAudioTrackFromDescribeParam(@NonNull AudioTr...
    method getHeaderContentBase (line 1301) | @Nullable
    method getHeaderContentLength (line 1310) | private static int getHeaderContentLength(@NonNull ArrayList<Pair<Stri...
    method getSupportedCapabilities (line 1321) | private static int getSupportedCapabilities(@NonNull ArrayList<Pair<St...
    method getHeaderWwwAuthenticateDigestRealmAndNonce (line 1349) | @Nullable
    method getHeaderWwwAuthenticateBasicRealm (line 1376) | @Nullable
    method getBasicAuthHeader (line 1397) | @NonNull
    method getDigestAuthHeader (line 1404) | @Nullable
    method getHexStringFromBytes (line 1461) | @NonNull
    method readContentAsText (line 1469) | @NonNull
    method memcmp (line 1479) | public static boolean memcmp(
    method shiftLeftArray (line 1497) | private static void shiftLeftArray(@NonNull byte[] array, int num) {
    method readUntilBytesFound (line 1503) | private boolean readUntilBytesFound(@NonNull InputStream inputStream, ...
    method readLine (line 1539) | @Nullable
    method readData (line 1570) | private static int readData(@NonNull InputStream inputStream, @NonNull...
    method dumpHeaders (line 1582) | private static void dumpHeaders(@NonNull ArrayList<Pair<String, String...
    method getHeader (line 1590) | @Nullable
    class Builder (line 1603) | public static class Builder {
      method Builder (line 1620) | public Builder(
      method withDebug (line 1631) | @NonNull
      method withCredentials (line 1637) | @NonNull
      method withUserAgent (line 1644) | @NonNull
      method requestVideo (line 1656) | @NonNull
      method requestAudio (line 1662) | @NonNull
      method requestApplication (line 1668) | @NonNull
      method build (line 1674) | @NonNull
  class LoggerOutputStream (line 1681) | class LoggerOutputStream extends BufferedOutputStream {
    method LoggerOutputStream (line 1684) | public LoggerOutputStream(@NonNull OutputStream out) {
    method setLogging (line 1688) | public synchronized void setLogging(boolean logging) {
    method write (line 1692) | @Override

FILE: library-client-rtsp/src/main/java/com/alexvas/rtsp/parser/AacParser.java
  class AacParser (line 19) | @SuppressLint("UnsafeOptInUsageError")
    method AacParser (line 43) | public AacParser(@NonNull String aacMode) {
    method processRtpPacketAndGetSample (line 50) | @Override
    method handleSingleAacFrame (line 108) | private byte[] handleSingleAacFrame(ParsableByteArray packet) {

FILE: library-client-rtsp/src/main/java/com/alexvas/rtsp/parser/RtpHeaderParser.java
  class RtpHeaderParser (line 13) | public class RtpHeaderParser {
    class RtpHeader (line 20) | public static class RtpHeader {
      method getTimestampMsec (line 32) | public long getTimestampMsec() {
      method searchForNextRtpHeader (line 37) | private static boolean searchForNextRtpHeader(@NonNull InputStream i...
      method parseData (line 70) | @Nullable
      method getPacketSize (line 93) | private static int getPacketSize(@NonNull byte[] header) {
      method dumpHeader (line 100) | public void dumpHeader() {
    method readHeader (line 114) | @Nullable

FILE: library-client-rtsp/src/main/java/com/alexvas/utils/ByteUtils.java
  class ByteUtils (line 8) | public class ByteUtils {
    method memcmp (line 11) | public static boolean memcmp(
    method copy (line 29) | public static byte[] copy(@NonNull byte[] src) {

FILE: library-client-rtsp/src/main/java/com/alexvas/utils/NetUtils.java
  class NetUtils (line 24) | public class NetUtils {
    class FakeX509TrustManager (line 30) | public static final class FakeX509TrustManager implements X509TrustMan...
      method FakeX509TrustManager (line 40) | public FakeX509TrustManager() {
      method checkClientTrusted (line 46) | public void checkClientTrusted(X509Certificate[] certificates, Strin...
      method checkServerTrusted (line 53) | public void checkServerTrusted(X509Certificate[] certificates, Strin...
      method checkServerTrusted (line 59) | @SuppressWarnings("unused")
      method getAcceptedIssuers (line 67) | public X509Certificate[] getAcceptedIssuers() {
    method createSslSocketAndConnect (line 72) | @NonNull
    method createSocketAndConnect (line 94) | @NonNull
    method createSocket (line 105) | @NonNull
    method closeSocket (line 113) | public static void closeSocket(@Nullable Socket socket) throws IOExcep...
    method readResponseHeaders (line 129) | @NonNull
    method readLine (line 148) | @Nullable
    method getResponseStatusCode (line 180) | public static int getResponseStatusCode(@NonNull ArrayList<String> hea...
    method readContentAsText (line 216) | @NonNull
    method readData (line 226) | public static int readData(@NonNull InputStream inputStream, @NonNull ...

FILE: library-client-rtsp/src/main/java/com/limelight/binding/video/MediaCodecHelper.java
  class MediaCodecHelper (line 27) | public class MediaCodecHelper {
    method isPowerVR (line 255) | private static boolean isPowerVR(String glRenderer) {
    method getAdrenoVersionString (line 259) | private static String getAdrenoVersionString(String glRenderer) {
    method isLowEndSnapdragonRenderer (line 278) | private static boolean isLowEndSnapdragonRenderer(String glRenderer) {
    method getAdrenoRendererModelNumber (line 289) | private static int getAdrenoRendererModelNumber(String glRenderer) {
    method isGLES31SnapdragonRenderer (line 303) | private static boolean isGLES31SnapdragonRenderer(String glRenderer) {
    method initialize (line 308) | public static void initialize(Context context, String glRenderer) {
    method isDecoderInList (line 419) | private static boolean isDecoderInList(List<String> decoderList, Strin...
    method decoderSupportsAndroidRLowLatency (line 436) | private static boolean decoderSupportsAndroidRLowLatency(MediaCodecInf...
    method decoderSupportsKnownVendorLowLatencyOption (line 452) | private static boolean decoderSupportsKnownVendorLowLatencyOption(Stri...
    method decoderSupportsMaxOperatingRate (line 481) | private static boolean decoderSupportsMaxOperatingRate(String decoderN...
    method setDecoderLowLatencyOptions (line 496) | public static boolean setDecoderLowLatencyOptions(MediaFormat videoFor...
    method decoderSupportsFusedIdrFrame (line 598) | public static boolean decoderSupportsFusedIdrFrame(MediaCodecInfo deco...
    method decoderSupportsAdaptivePlayback (line 614) | public static boolean decoderSupportsAdaptivePlayback(MediaCodecInfo d...
    method decoderNeedsConstrainedHighProfile (line 636) | public static boolean decoderNeedsConstrainedHighProfile(String decode...
    method decoderCanDirectSubmit (line 640) | public static boolean decoderCanDirectSubmit(String decoderName) {
    method decoderNeedsSpsBitstreamRestrictions (line 644) | public static boolean decoderNeedsSpsBitstreamRestrictions(String deco...
    method decoderNeedsBaselineSpsHack (line 648) | public static boolean decoderNeedsBaselineSpsHack(String decoderName) {
    method getDecoderOptimalSlicesPerFrame (line 652) | public static byte getDecoderOptimalSlicesPerFrame(String decoderName) {
    method decoderSupportsRefFrameInvalidationAvc (line 663) | public static boolean decoderSupportsRefFrameInvalidationAvc(String de...
    method decoderSupportsRefFrameInvalidationHevc (line 678) | public static boolean decoderSupportsRefFrameInvalidationHevc(MediaCod...
    method decoderSupportsRefFrameInvalidationAv1 (line 695) | public static boolean decoderSupportsRefFrameInvalidationAv1(MediaCode...
    method decoderIsWhitelistedForHevc (line 706) | public static boolean decoderIsWhitelistedForHevc(MediaCodecInfo decod...
    method isDecoderWhitelistedForAv1 (line 745) | public static boolean isDecoderWhitelistedForAv1(MediaCodecInfo decode...
    method getMediaCodecList (line 771) | @SuppressWarnings("deprecation")
    method dumpDecoders (line 782) | @SuppressWarnings("RedundantThrows")
    method findPreferredDecoder (line 804) | private static MediaCodecInfo findPreferredDecoder() {
    method isCodecBlacklisted (line 831) | private static boolean isCodecBlacklisted(MediaCodecInfo codecInfo) {
    method findFirstDecoder (line 849) | public static MediaCodecInfo findFirstDecoder(String mimeType) {
    method findProbableSafeDecoder (line 880) | public static MediaCodecInfo findProbableSafeDecoder(String mimeType, ...
    method findKnownSafeDecoder (line 902) | @SuppressWarnings("RedundantThrows")
    method readCpuinfo (line 963) | public static String readCpuinfo() throws Exception {
    method stringContainsIgnoreCase (line 977) | private static boolean stringContainsIgnoreCase(String string, String ...
    method isExynos4Device (line 981) | public static boolean isExynos4Device() {
Condensed preview — 66 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (382K chars).
[
  {
    "path": ".github/FUNDING.yml",
    "chars": 22,
    "preview": "github: alexeyvasilyev"
  },
  {
    "path": ".gitignore",
    "chars": 56,
    "preview": "*.iml\n.gradle\n/local.properties\n/.idea\n/build\n.DS_Store\n"
  },
  {
    "path": "LICENSE",
    "chars": 11357,
    "preview": "                                 Apache License\n                           Version 2.0, January 2004\n                   "
  },
  {
    "path": "README.md",
    "chars": 6343,
    "preview": "# rtsp-client-android\n<b>Lightweight RTSP client library for Android</b> with almost zero lag video decoding (achieved 2"
  },
  {
    "path": "app/.gitignore",
    "chars": 2333,
    "preview": "# Created by https://www.gitignore.io/api/android,java,intellij\r\n\r\n### Android ###\r\n# Built application files\r\n*.apk\r\n*."
  },
  {
    "path": "app/build.gradle",
    "chars": 1996,
    "preview": "apply plugin: 'com.android.application'\napply plugin: 'kotlin-android'\n\nandroid {\n\n    compileSdkVersion 36\n\n    default"
  },
  {
    "path": "app/proguard-rules.pro",
    "chars": 751,
    "preview": "# Add project specific ProGuard rules here.\n# You can control the set of applied configuration files using the\n# proguar"
  },
  {
    "path": "app/src/main/AndroidManifest.xml",
    "chars": 771,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\n    <appli"
  },
  {
    "path": "app/src/main/java/com/alexvas/rtsp/demo/MainActivity.kt",
    "chars": 1001,
    "preview": "package com.alexvas.rtsp.demo\n\nimport android.os.Bundle\nimport com.google.android.material.bottomnavigation.BottomNaviga"
  },
  {
    "path": "app/src/main/java/com/alexvas/rtsp/demo/live/LiveFragment.kt",
    "chars": 18637,
    "preview": "package com.alexvas.rtsp.demo.live\n\nimport android.annotation.SuppressLint\nimport android.graphics.Bitmap\nimport android"
  },
  {
    "path": "app/src/main/java/com/alexvas/rtsp/demo/live/LiveViewModel.kt",
    "chars": 4434,
    "preview": "package com.alexvas.rtsp.demo.live\n\nimport android.annotation.SuppressLint\nimport android.content.Context\nimport android"
  },
  {
    "path": "app/src/main/java/com/alexvas/rtsp/demo/live/RawFragment.kt",
    "chars": 10679,
    "preview": "package com.alexvas.rtsp.demo.live\n\nimport android.annotation.SuppressLint\nimport android.net.Uri\nimport android.os.Bund"
  },
  {
    "path": "app/src/main/res/drawable/ic_camera_black_24dp.xml",
    "chars": 472,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:height=\"24dp\"\n    android:width=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_cctv_black_24dp.xml",
    "chars": 701,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:height=\"24dp\"\n    android:width=\"24dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_launcher_background.xml",
    "chars": 5606,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:wi"
  },
  {
    "path": "app/src/main/res/drawable/ic_launcher_foreground.xml",
    "chars": 704,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:height=\"16dp\"\n    android:width=\"16dp\"\n  "
  },
  {
    "path": "app/src/main/res/drawable/ic_text_subject_black_24dp.xml",
    "chars": 351,
    "preview": "<!-- drawable/text_subject.xml -->\r\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\r\n    android:heig"
  },
  {
    "path": "app/src/main/res/layout/activity_main.xml",
    "chars": 1005,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<RelativeLayout\n    xmlns:android=\"http://schemas.android.com/apk/res/android\"\n  "
  },
  {
    "path": "app/src/main/res/layout/fragment_live.xml",
    "chars": 11163,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<ScrollView\n    xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    xm"
  },
  {
    "path": "app/src/main/res/layout/fragment_logs.xml",
    "chars": 506,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<HorizontalScrollView xmlns:android=\"http://schemas.android.com/apk/res/android\"\n"
  },
  {
    "path": "app/src/main/res/layout/fragment_raw.xml",
    "chars": 2060,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<ScrollView\n    xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    xm"
  },
  {
    "path": "app/src/main/res/layout/layout_rtsp_params.xml",
    "chars": 3405,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<LinearLayout\n    xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    "
  },
  {
    "path": "app/src/main/res/menu/bottom_nav_menu.xml",
    "chars": 573,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<menu xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\n    <item\n    "
  },
  {
    "path": "app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml",
    "chars": 272,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <b"
  },
  {
    "path": "app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml",
    "chars": 272,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <b"
  },
  {
    "path": "app/src/main/res/navigation/mobile_navigation.xml",
    "chars": 948,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<navigation xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    xmlns:"
  },
  {
    "path": "app/src/main/res/values/colors.xml",
    "chars": 208,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n    <color name=\"colorPrimary\">#40747A</color>\n    <color name=\"color"
  },
  {
    "path": "app/src/main/res/values/dimens.xml",
    "chars": 211,
    "preview": "<resources>\n    <!-- Default screen margins, per the Android Design guidelines. -->\n    <dimen name=\"activity_horizontal"
  },
  {
    "path": "app/src/main/res/values/strings.xml",
    "chars": 202,
    "preview": "<resources>\n    <string name=\"app_name\">Rtsp demo</string>\n    <string name=\"title_live\">Live</string>\n    <string name="
  },
  {
    "path": "app/src/main/res/values/styles.xml",
    "chars": 376,
    "preview": "<resources>\n\n    <!-- Base application theme. -->\n    <style name=\"AppTheme\" parent=\"Theme.Material3.DayNight\">\n        "
  },
  {
    "path": "build.gradle",
    "chars": 599,
    "preview": "buildscript {\n\n  ext.kotlin_version = '2.2.21'\n  ext.compile_sdk_version = 36\n  ext.min_sdk_version = 24\n  ext.target_sd"
  },
  {
    "path": "gradle/wrapper/gradle-wrapper.properties",
    "chars": 253,
    "preview": "distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributi"
  },
  {
    "path": "gradle.properties",
    "chars": 51,
    "preview": "org.gradle.jvmargs=-Xmx1g\nandroid.useAndroidX=true\n"
  },
  {
    "path": "gradlew",
    "chars": 8729,
    "preview": "#!/bin/sh\n\n#\n# Copyright © 2015-2021 the original authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"Lice"
  },
  {
    "path": "gradlew.bat",
    "chars": 2966,
    "preview": "@rem\r\n@rem Copyright 2015 the original author or authors.\r\n@rem\r\n@rem Licensed under the Apache License, Version 2.0 (th"
  },
  {
    "path": "jitpack.yml",
    "chars": 89,
    "preview": "jdk:\n  - openjdk17\n\ninstall:\n  - ./gradlew build :library-client-rtsp:publishToMavenLocal"
  },
  {
    "path": "library-client-rtsp/.gitignore",
    "chars": 2333,
    "preview": "# Created by https://www.gitignore.io/api/android,java,intellij\r\n\r\n### Android ###\r\n# Built application files\r\n*.apk\r\n*."
  },
  {
    "path": "library-client-rtsp/build.gradle",
    "chars": 985,
    "preview": "plugins {\n    id 'com.android.library'\n    id 'kotlin-android'\n    id 'maven-publish'\n}\n\napply plugin: 'com.android.libr"
  },
  {
    "path": "library-client-rtsp/proguard-rules.txt",
    "chars": 19,
    "preview": "# Proguard rules.\n\n"
  },
  {
    "path": "library-client-rtsp/src/main/AndroidManifest.xml",
    "chars": 153,
    "preview": "<manifest\n    xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <uses-permission android:name=\"android.per"
  },
  {
    "path": "library-client-rtsp/src/main/java/com/alexvas/rtsp/RtspClient.java",
    "chars": 74359,
    "preview": "package com.alexvas.rtsp;\n\nimport android.text.TextUtils;\nimport android.util.Base64;\nimport android.util.Log;\nimport an"
  },
  {
    "path": "library-client-rtsp/src/main/java/com/alexvas/rtsp/codec/AudioDecodeThread.kt",
    "chars": 10021,
    "preview": "package com.alexvas.rtsp.codec\n\nimport android.media.*\nimport android.os.Process\nimport android.util.Log\nimport java.nio"
  },
  {
    "path": "library-client-rtsp/src/main/java/com/alexvas/rtsp/codec/FrameQueue.kt",
    "chars": 2678,
    "preview": "package com.alexvas.rtsp.codec\n\nimport java.util.concurrent.ArrayBlockingQueue\nimport java.util.concurrent.TimeUnit\n\nenu"
  },
  {
    "path": "library-client-rtsp/src/main/java/com/alexvas/rtsp/codec/VideoDecodeThread.kt",
    "chars": 27021,
    "preview": "package com.alexvas.rtsp.codec\n\nimport android.annotation.SuppressLint\nimport android.media.MediaCodec\nimport android.me"
  },
  {
    "path": "library-client-rtsp/src/main/java/com/alexvas/rtsp/codec/VideoDecoderBitmapThread.kt",
    "chars": 3015,
    "preview": "package com.alexvas.rtsp.codec\n\nimport android.graphics.Bitmap\nimport android.graphics.Matrix\nimport android.media.Media"
  },
  {
    "path": "library-client-rtsp/src/main/java/com/alexvas/rtsp/codec/VideoDecoderSurfaceThread.kt",
    "chars": 5919,
    "preview": "package com.alexvas.rtsp.codec\n\nimport android.media.MediaCodec\nimport android.media.MediaFormat\nimport android.util.Log"
  },
  {
    "path": "library-client-rtsp/src/main/java/com/alexvas/rtsp/codec/color/ColorConverter.kt",
    "chars": 2573,
    "preview": "package com.alexvas.rtsp.codec.color\n\nimport android.annotation.SuppressLint\nimport android.graphics.Bitmap\nimport andro"
  },
  {
    "path": "library-client-rtsp/src/main/java/com/alexvas/rtsp/codec/color/ColorConverterImage.kt",
    "chars": 278,
    "preview": "package com.alexvas.rtsp.codec.color\n\nimport android.graphics.Bitmap\nimport android.media.Image\n\nabstract class ColorCon"
  },
  {
    "path": "library-client-rtsp/src/main/java/com/alexvas/rtsp/parser/AacParser.java",
    "chars": 6637,
    "preview": "package com.alexvas.rtsp.parser;\n\nimport android.annotation.SuppressLint;\nimport android.util.Log;\n\nimport androidx.anno"
  },
  {
    "path": "library-client-rtsp/src/main/java/com/alexvas/rtsp/parser/AudioParser.kt",
    "chars": 173,
    "preview": "package com.alexvas.rtsp.parser\n\nabstract class AudioParser {\n    abstract fun processRtpPacketAndGetSample(\n        dat"
  },
  {
    "path": "library-client-rtsp/src/main/java/com/alexvas/rtsp/parser/G711Parser.kt",
    "chars": 271,
    "preview": "package com.alexvas.rtsp.parser\n\nclass G711Parser() : AudioParser() {\n    override fun processRtpPacketAndGetSample(\n   "
  },
  {
    "path": "library-client-rtsp/src/main/java/com/alexvas/rtsp/parser/RtpH264Parser.kt",
    "chars": 4868,
    "preview": "package com.alexvas.rtsp.parser\n\nimport android.util.Log\nimport com.alexvas.utils.VideoCodecUtils\nimport com.alexvas.uti"
  },
  {
    "path": "library-client-rtsp/src/main/java/com/alexvas/rtsp/parser/RtpH265Parser.kt",
    "chars": 5304,
    "preview": "package com.alexvas.rtsp.parser\n\nimport android.util.Log\n\nclass RtpH265Parser: RtpParser() {\n\n    override fun processRt"
  },
  {
    "path": "library-client-rtsp/src/main/java/com/alexvas/rtsp/parser/RtpHeaderParser.java",
    "chars": 5736,
    "preview": "package com.alexvas.rtsp.parser;\n\nimport android.util.Log;\n\nimport androidx.annotation.NonNull;\nimport androidx.annotati"
  },
  {
    "path": "library-client-rtsp/src/main/java/com/alexvas/rtsp/parser/RtpParser.kt",
    "chars": 841,
    "preview": "package com.alexvas.rtsp.parser\n\nabstract class RtpParser {\n\n    abstract fun processRtpPacketAndGetNalUnit(data: ByteAr"
  },
  {
    "path": "library-client-rtsp/src/main/java/com/alexvas/rtsp/widget/RtspImageView.kt",
    "chars": 4375,
    "preview": "package com.alexvas.rtsp.widget\n\nimport android.content.Context\nimport android.graphics.Bitmap\nimport android.net.Uri\nim"
  },
  {
    "path": "library-client-rtsp/src/main/java/com/alexvas/rtsp/widget/RtspListeners.kt",
    "chars": 862,
    "preview": "package com.alexvas.rtsp.widget\n\n/**\n * Listener for getting RTSP status update.\n */\ninterface RtspStatusListener {\n    "
  },
  {
    "path": "library-client-rtsp/src/main/java/com/alexvas/rtsp/widget/RtspProcessor.kt",
    "chars": 24939,
    "preview": "package com.alexvas.rtsp.widget\n\nimport android.annotation.SuppressLint\nimport android.media.MediaFormat\nimport android."
  },
  {
    "path": "library-client-rtsp/src/main/java/com/alexvas/rtsp/widget/RtspSurfaceView.kt",
    "chars": 6523,
    "preview": "package com.alexvas.rtsp.widget\n\nimport android.content.Context\nimport android.net.Uri\nimport android.util.AttributeSet\n"
  },
  {
    "path": "library-client-rtsp/src/main/java/com/alexvas/utils/ByteUtils.java",
    "chars": 927,
    "preview": "package com.alexvas.utils;\n\nimport androidx.annotation.NonNull;\n\nimport java.io.File;\nimport java.io.FileOutputStream;\n\n"
  },
  {
    "path": "library-client-rtsp/src/main/java/com/alexvas/utils/MediaCodecUtils.kt",
    "chars": 3360,
    "preview": "package com.alexvas.utils\n\nimport android.annotation.SuppressLint\nimport android.util.Log\nimport android.util.Range\nimpo"
  },
  {
    "path": "library-client-rtsp/src/main/java/com/alexvas/utils/NetUtils.java",
    "chars": 8958,
    "preview": "package com.alexvas.utils;\n\nimport android.util.Log;\n\nimport androidx.annotation.NonNull;\nimport androidx.annotation.Nul"
  },
  {
    "path": "library-client-rtsp/src/main/java/com/alexvas/utils/VideoCodecUtils.kt",
    "chars": 14687,
    "preview": "package com.alexvas.utils\n\nimport android.annotation.SuppressLint\nimport android.util.Log\nimport androidx.media3.contain"
  },
  {
    "path": "library-client-rtsp/src/main/java/com/limelight/binding/video/MediaCodecHelper.java",
    "chars": 45505,
    "preview": "package com.limelight.binding.video;\n\nimport java.io.BufferedReader;\nimport java.io.File;\nimport java.io.FileReader;\nimp"
  },
  {
    "path": "settings.gradle",
    "chars": 45,
    "preview": "include ':library-client-rtsp'\ninclude ':app'"
  }
]

// ... and 1 more files (download for full content)

About this extraction

This page contains the full source code of the alexeyvasilyev/rtsp-client-android GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 66 files (354.9 KB), approximately 85.0k tokens, and a symbol index with 141 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!