Have fun
commit
a2a499efb8
@ -0,0 +1,8 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea/workspace.xml
|
||||||
|
/.idea/libraries
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/captures
|
@ -0,0 +1 @@
|
|||||||
|
Sea
|
@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="CompilerConfiguration">
|
||||||
|
<resourceExtensions />
|
||||||
|
<wildcardResourcePatterns>
|
||||||
|
<entry name="!?*.java" />
|
||||||
|
<entry name="!?*.form" />
|
||||||
|
<entry name="!?*.class" />
|
||||||
|
<entry name="!?*.groovy" />
|
||||||
|
<entry name="!?*.scala" />
|
||||||
|
<entry name="!?*.flex" />
|
||||||
|
<entry name="!?*.kt" />
|
||||||
|
<entry name="!?*.clj" />
|
||||||
|
<entry name="!?*.aj" />
|
||||||
|
</wildcardResourcePatterns>
|
||||||
|
<annotationProcessing>
|
||||||
|
<profile default="true" name="Default" enabled="false">
|
||||||
|
<processorPath useClasspath="true" />
|
||||||
|
</profile>
|
||||||
|
</annotationProcessing>
|
||||||
|
</component>
|
||||||
|
</project>
|
@ -0,0 +1,3 @@
|
|||||||
|
<component name="CopyrightManager">
|
||||||
|
<settings default="" />
|
||||||
|
</component>
|
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Encoding">
|
||||||
|
<file url="PROJECT" charset="UTF-8" />
|
||||||
|
</component>
|
||||||
|
</project>
|
@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GradleSettings">
|
||||||
|
<option name="linkedExternalProjectsSettings">
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
<option value="$PROJECT_DIR$/app" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
<option name="myModules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
<option value="$PROJECT_DIR$/app" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
@ -0,0 +1,62 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="EntryPointsManager">
|
||||||
|
<entry_points version="2.0" />
|
||||||
|
</component>
|
||||||
|
<component name="NullableNotNullManager">
|
||||||
|
<option name="myDefaultNullable" value="android.support.annotation.Nullable" />
|
||||||
|
<option name="myDefaultNotNull" value="android.support.annotation.NonNull" />
|
||||||
|
<option name="myNullables">
|
||||||
|
<value>
|
||||||
|
<list size="4">
|
||||||
|
<item index="0" class="java.lang.String" itemvalue="org.jetbrains.annotations.Nullable" />
|
||||||
|
<item index="1" class="java.lang.String" itemvalue="javax.annotation.Nullable" />
|
||||||
|
<item index="2" class="java.lang.String" itemvalue="edu.umd.cs.findbugs.annotations.Nullable" />
|
||||||
|
<item index="3" class="java.lang.String" itemvalue="android.support.annotation.Nullable" />
|
||||||
|
</list>
|
||||||
|
</value>
|
||||||
|
</option>
|
||||||
|
<option name="myNotNulls">
|
||||||
|
<value>
|
||||||
|
<list size="4">
|
||||||
|
<item index="0" class="java.lang.String" itemvalue="org.jetbrains.annotations.NotNull" />
|
||||||
|
<item index="1" class="java.lang.String" itemvalue="javax.annotation.Nonnull" />
|
||||||
|
<item index="2" class="java.lang.String" itemvalue="edu.umd.cs.findbugs.annotations.NonNull" />
|
||||||
|
<item index="3" class="java.lang.String" itemvalue="android.support.annotation.NonNull" />
|
||||||
|
</list>
|
||||||
|
</value>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
<component name="ProjectLevelVcsManager" settingsEditedManually="false">
|
||||||
|
<OptionsSetting value="true" id="Add" />
|
||||||
|
<OptionsSetting value="true" id="Remove" />
|
||||||
|
<OptionsSetting value="true" id="Checkout" />
|
||||||
|
<OptionsSetting value="true" id="Update" />
|
||||||
|
<OptionsSetting value="true" id="Status" />
|
||||||
|
<OptionsSetting value="true" id="Edit" />
|
||||||
|
<ConfirmationsSetting value="0" id="Add" />
|
||||||
|
<ConfirmationsSetting value="0" id="Remove" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="true" assert-keyword="true" jdk-15="true" project-jdk-name="1.8" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectType">
|
||||||
|
<option name="id" value="Android" />
|
||||||
|
</component>
|
||||||
|
<component name="masterDetails">
|
||||||
|
<states>
|
||||||
|
<state key="ProjectJDKs.UI">
|
||||||
|
<settings>
|
||||||
|
<last-edited>1.8</last-edited>
|
||||||
|
<splitter-proportions>
|
||||||
|
<option name="proportions">
|
||||||
|
<list>
|
||||||
|
<option value="0.2" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</splitter-proportions>
|
||||||
|
</settings>
|
||||||
|
</state>
|
||||||
|
</states>
|
||||||
|
</component>
|
||||||
|
</project>
|
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/Sea.iml" filepath="$PROJECT_DIR$/Sea.iml" />
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/app/app.iml" filepath="$PROJECT_DIR$/app/app.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="RunConfigurationProducerService">
|
||||||
|
<option name="ignoredProducers">
|
||||||
|
<set>
|
||||||
|
<option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
@ -0,0 +1,17 @@
|
|||||||
|
Yet Another Stream Encoder for Android
|
||||||
|
======================================
|
||||||
|
|
||||||
|
**yasea** is an RTMP streaming client in pure Java for Android for those who
|
||||||
|
hate JNI development. It combines the source code of both [!srs-sea](https://github.com/ossrs/srs-sea)
|
||||||
|
and [!SimpleRtmp](https://github.com/faucamp/SimpleRtmp) to encode video in
|
||||||
|
H.264 and audio in AAC by hardware and upload packets to server over RTMP.
|
||||||
|
Moreover, hardware encoding produces less CPU overhead than software does. And
|
||||||
|
the code does not depend on any native library.
|
||||||
|
|
||||||
|
Help
|
||||||
|
----
|
||||||
|
|
||||||
|
The project now can sample both video from camera and audio from microphone of
|
||||||
|
Android mobile and connect and handshake with the remote. Unfortunately it has
|
||||||
|
some problems with the correct format of RTMP packets which still can not be
|
||||||
|
identified by the server and Wireshark. Any help is welcome.
|
@ -0,0 +1 @@
|
|||||||
|
/build
|
@ -0,0 +1,26 @@
|
|||||||
|
apply plugin: 'com.android.application'
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdkVersion 23
|
||||||
|
buildToolsVersion "23.0.2"
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "net.ossrs.sea"
|
||||||
|
minSdkVersion 16
|
||||||
|
targetSdkVersion 23
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0"
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
compile fileTree(dir: 'libs', include: ['*.jar'])
|
||||||
|
testCompile 'junit:junit:4.12'
|
||||||
|
compile 'com.android.support:appcompat-v7:23.2.0'
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
package net.ossrs.sea;
|
||||||
|
|
||||||
|
import android.app.Application;
|
||||||
|
import android.test.ApplicationTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <a href="http://d.android.com/tools/testing/testing_android.html">Testing Fundamentals</a>
|
||||||
|
*/
|
||||||
|
public class ApplicationTest extends ApplicationTestCase<Application> {
|
||||||
|
public ApplicationTest() {
|
||||||
|
super(Application.class);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="net.ossrs.sea">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme">
|
||||||
|
<activity android:name=".MainActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
@ -0,0 +1,388 @@
|
|||||||
|
package net.ossrs.sea;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.hardware.Camera;
|
||||||
|
import android.hardware.Camera.Size;
|
||||||
|
import android.media.AudioRecord;
|
||||||
|
import android.media.MediaRecorder;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.text.Editable;
|
||||||
|
import android.text.TextWatcher;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.Menu;
|
||||||
|
import android.view.MenuItem;
|
||||||
|
import android.view.SurfaceHolder;
|
||||||
|
import android.view.SurfaceView;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.WindowManager;
|
||||||
|
import android.widget.Button;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class MainActivity extends Activity implements SurfaceHolder.Callback, Camera.PreviewCallback {
|
||||||
|
private static final String TAG = "SrsPublisher";
|
||||||
|
|
||||||
|
private AudioRecord mic = null;
|
||||||
|
private boolean aloop = false;
|
||||||
|
private Thread aworker = null;
|
||||||
|
|
||||||
|
private SurfaceView mCameraView = null;
|
||||||
|
private Camera mCamera = null;
|
||||||
|
|
||||||
|
private int mPreviewRotation = 90;
|
||||||
|
private int mDisplayRotation = 90;
|
||||||
|
private int mCamId = Camera.getNumberOfCameras() - 1; // default camera
|
||||||
|
private byte[] mYuvFrameBuffer;
|
||||||
|
|
||||||
|
private SrsEncoder mEncoder;
|
||||||
|
|
||||||
|
// settings storage
|
||||||
|
private SharedPreferences sp;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
|
||||||
|
sp = getSharedPreferences("SrsPublisher", MODE_PRIVATE);
|
||||||
|
|
||||||
|
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||||
|
setContentView(R.layout.activity_main);
|
||||||
|
|
||||||
|
mEncoder = new SrsEncoder();
|
||||||
|
mYuvFrameBuffer = new byte[SrsEncoder.VWIDTH * SrsEncoder.VHEIGHT * 3 / 2];
|
||||||
|
|
||||||
|
// restore data.
|
||||||
|
SrsEncoder.rtmpUrl = sp.getString("SrsEncoder.rtmpUrl", SrsEncoder.rtmpUrl);
|
||||||
|
SrsEncoder.vbitrate = sp.getInt("VBITRATE", SrsEncoder.vbitrate);
|
||||||
|
Log.i(TAG, String.format("initialize rtmp url to %s, vbitrate=%dkbps", SrsEncoder.rtmpUrl, SrsEncoder.vbitrate));
|
||||||
|
|
||||||
|
// initialize url.
|
||||||
|
final EditText efu = (EditText) findViewById(R.id.url);
|
||||||
|
efu.setText(SrsEncoder.rtmpUrl);
|
||||||
|
efu.addTextChangedListener(new TextWatcher() {
|
||||||
|
@Override
|
||||||
|
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterTextChanged(Editable s) {
|
||||||
|
String fu = efu.getText().toString();
|
||||||
|
if (fu == SrsEncoder.rtmpUrl || fu.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SrsEncoder.rtmpUrl = fu;
|
||||||
|
Log.i(TAG, String.format("flv url changed to %s", SrsEncoder.rtmpUrl));
|
||||||
|
|
||||||
|
SharedPreferences.Editor editor = sp.edit();
|
||||||
|
editor.putString("SrsEncoder.rtmpUrl", SrsEncoder.rtmpUrl);
|
||||||
|
editor.commit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final EditText evb = (EditText) findViewById(R.id.vbitrate);
|
||||||
|
evb.setText(String.format("%dkbps", SrsEncoder.vbitrate / 1000));
|
||||||
|
evb.addTextChangedListener(new TextWatcher() {
|
||||||
|
@Override
|
||||||
|
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterTextChanged(Editable s) {
|
||||||
|
int vb = Integer.parseInt(evb.getText().toString().replaceAll("kbps", ""));
|
||||||
|
if (vb * 1000 != SrsEncoder.vbitrate) {
|
||||||
|
SrsEncoder.vbitrate = vb * 1000;
|
||||||
|
SharedPreferences.Editor editor = sp.edit();
|
||||||
|
editor.putInt("VBITRATE", SrsEncoder.vbitrate);
|
||||||
|
editor.commit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// for camera, @see https://developer.android.com/reference/android/hardware/Camera.html
|
||||||
|
final Button btnPublish = (Button) findViewById(R.id.publish);
|
||||||
|
final Button btnStop = (Button) findViewById(R.id.stop);
|
||||||
|
final Button btnSwitch = (Button) findViewById(R.id.swCam);
|
||||||
|
final Button btnRotate = (Button) findViewById(R.id.rotate);
|
||||||
|
mCameraView = (SurfaceView) findViewById(R.id.preview);
|
||||||
|
mCameraView.getHolder().addCallback(this);
|
||||||
|
// mCameraView.getHolder().setFormat(SurfaceHolder.SURFACE_TYPE_HARDWARE);
|
||||||
|
btnPublish.setEnabled(true);
|
||||||
|
btnStop.setEnabled(false);
|
||||||
|
|
||||||
|
btnPublish.setOnClickListener(new View.OnClickListener() {
|
||||||
|
@Override
|
||||||
|
public void onClick(View v) {
|
||||||
|
startPublish();
|
||||||
|
btnPublish.setEnabled(false);
|
||||||
|
btnStop.setEnabled(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnStop.setOnClickListener(new View.OnClickListener() {
|
||||||
|
@Override
|
||||||
|
public void onClick(View v) {
|
||||||
|
stopPublish();
|
||||||
|
btnPublish.setEnabled(true);
|
||||||
|
btnStop.setEnabled(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnSwitch.setOnClickListener(new View.OnClickListener() {
|
||||||
|
@Override
|
||||||
|
public void onClick(View v) {
|
||||||
|
if (mCamera != null && mEncoder != null) {
|
||||||
|
mCamId = (mCamId + 1) % Camera.getNumberOfCameras();
|
||||||
|
stopCamera();
|
||||||
|
mEncoder.swithCameraFace();
|
||||||
|
startCamera();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnRotate.setOnClickListener(new View.OnClickListener() {
|
||||||
|
@Override
|
||||||
|
public void onClick(View v) {
|
||||||
|
if (mCamera != null) {
|
||||||
|
mPreviewRotation = (mPreviewRotation + 90) % 360;
|
||||||
|
mCamera.setDisplayOrientation(mPreviewRotation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
|
||||||
|
final Button btn = (Button) findViewById(R.id.publish);
|
||||||
|
btn.setEnabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onPause() {
|
||||||
|
super.onPause();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCreateOptionsMenu(Menu menu) {
|
||||||
|
// Inflate the menu; this adds items to the action bar if it is present.
|
||||||
|
getMenuInflater().inflate(R.menu.menu_main, menu);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onOptionsItemSelected(MenuItem item) {
|
||||||
|
// Handle action bar item clicks here. The action bar will
|
||||||
|
// automatically handle clicks on the Home/Up button, so long
|
||||||
|
// as you specify a parent activity in AndroidManifest.xml.
|
||||||
|
int id = item.getItemId();
|
||||||
|
|
||||||
|
//noinspection SimplifiableIfStatement
|
||||||
|
if (id == R.id.action_settings) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.onOptionsItemSelected(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startCamera() {
|
||||||
|
if (mCamera != null) {
|
||||||
|
Log.d(TAG, "start camera, already started. return");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mCamId > (Camera.getNumberOfCameras() - 1) || mCamId < 0) {
|
||||||
|
Log.e(TAG, "####### start camera failed, inviald params, camera No.="+ mCamId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mCamera = Camera.open(mCamId);
|
||||||
|
|
||||||
|
Camera.CameraInfo info = new Camera.CameraInfo();
|
||||||
|
Camera.getCameraInfo(mCamId, info);
|
||||||
|
if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT){
|
||||||
|
mDisplayRotation = (mPreviewRotation + 180) % 360;
|
||||||
|
mDisplayRotation = (360 - mDisplayRotation) % 360;
|
||||||
|
} else {
|
||||||
|
mDisplayRotation = mPreviewRotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
Camera.Parameters params = mCamera.getParameters();
|
||||||
|
|
||||||
|
/* supported preview fps range */
|
||||||
|
// List<int[]> spfr = params.getSupportedPreviewFpsRange();
|
||||||
|
// Log.i("Cam", "! Supported Preview Fps Range:");
|
||||||
|
// int rn = 0;
|
||||||
|
// for (int[] r : spfr) {
|
||||||
|
// Log.i("Cam", "\tRange [" + rn++ + "]: " + r[0] + "~" + r[1]);
|
||||||
|
// }
|
||||||
|
// /* preview size */
|
||||||
|
List<Size> sizes = params.getSupportedPreviewSizes();
|
||||||
|
Log.i("Cam", "! Supported Preview Size:");
|
||||||
|
for (int i = 0; i < sizes.size(); i++) {
|
||||||
|
Log.i("Cam", "\tSize [" + i + "]: " + sizes.get(i).width + "x" + sizes.get(i).height);
|
||||||
|
}
|
||||||
|
/* picture size */
|
||||||
|
sizes = params.getSupportedPictureSizes();
|
||||||
|
Log.i("Cam", "! Supported Picture Size:");
|
||||||
|
for (int i = 0; i < sizes.size(); i++) {
|
||||||
|
Log.i("Cam", "\tSize [" + i + "]: " + sizes.get(i).width + "x" + sizes.get(i).height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/***** set parameters *****/
|
||||||
|
//params.set("orientation", "portrait");
|
||||||
|
//params.set("orientation", "landscape");
|
||||||
|
//params.setRotation(90);
|
||||||
|
params.setPictureSize(SrsEncoder.VWIDTH, SrsEncoder.VHEIGHT);
|
||||||
|
params.setPreviewSize(SrsEncoder.VWIDTH, SrsEncoder.VHEIGHT);
|
||||||
|
int[] range = findClosestFpsRange(SrsEncoder.VFPS, params.getSupportedPreviewFpsRange());
|
||||||
|
params.setPreviewFpsRange(range[0], range[1]);
|
||||||
|
params.setPreviewFormat(SrsEncoder.VFORMAT);
|
||||||
|
params.setFlashMode(Camera.Parameters.FLASH_MODE_OFF);
|
||||||
|
params.setWhiteBalance(Camera.Parameters.WHITE_BALANCE_AUTO);
|
||||||
|
params.setSceneMode(Camera.Parameters.SCENE_MODE_AUTO);
|
||||||
|
mCamera.setParameters(params);
|
||||||
|
|
||||||
|
mCamera.setDisplayOrientation(mPreviewRotation);
|
||||||
|
|
||||||
|
mCamera.addCallbackBuffer(mYuvFrameBuffer);
|
||||||
|
mCamera.setPreviewCallbackWithBuffer(this);
|
||||||
|
try {
|
||||||
|
mCamera.setPreviewDisplay(mCameraView.getHolder());
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
mCamera.startPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopCamera() {
|
||||||
|
if (mCamera != null) {
|
||||||
|
// need to SET NULL CB before stop preview!!!
|
||||||
|
mCamera.setPreviewCallback(null);
|
||||||
|
mCamera.stopPreview();
|
||||||
|
mCamera.release();
|
||||||
|
mCamera = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onGetYuvFrame(byte[] data) {
|
||||||
|
mEncoder.onGetYuvFrame(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPreviewFrame(byte[] data, Camera c) {
|
||||||
|
onGetYuvFrame(data);
|
||||||
|
c.addCallbackBuffer(mYuvFrameBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onGetPcmFrame(byte[] pcmBuffer, int size) {
|
||||||
|
mEncoder.onGetPcmFrame(pcmBuffer, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startAudio() {
|
||||||
|
if (mic != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int bufferSize = 2 * AudioRecord.getMinBufferSize(SrsEncoder.ASAMPLERATE, SrsEncoder.ACHANNEL, SrsEncoder.AFORMAT);
|
||||||
|
mic = new AudioRecord(MediaRecorder.AudioSource.MIC, SrsEncoder.ASAMPLERATE, SrsEncoder.ACHANNEL, SrsEncoder.AFORMAT, bufferSize);
|
||||||
|
mic.startRecording();
|
||||||
|
|
||||||
|
byte pcmBuffer[] = new byte[4096];
|
||||||
|
while (aloop && !Thread.interrupted()) {
|
||||||
|
int size = mic.read(pcmBuffer, 0, pcmBuffer.length);
|
||||||
|
if (size <= 0) {
|
||||||
|
Log.e(TAG, "***** audio ignored, no data to read.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
onGetPcmFrame(pcmBuffer, size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopAudio() {
|
||||||
|
aloop = false;
|
||||||
|
if (aworker != null) {
|
||||||
|
Log.i(TAG, "stop audio worker thread");
|
||||||
|
aworker.interrupt();
|
||||||
|
try {
|
||||||
|
aworker.join();
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
aworker = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mic != null) {
|
||||||
|
mic.setRecordPositionUpdateListener(null);
|
||||||
|
mic.stop();
|
||||||
|
mic.release();
|
||||||
|
mic = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startPublish() {
|
||||||
|
int ret = mEncoder.start();
|
||||||
|
if (ret < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startCamera();
|
||||||
|
|
||||||
|
aworker = new Thread(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_AUDIO);
|
||||||
|
startAudio();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
aloop = true;
|
||||||
|
aworker.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopPublish() {
|
||||||
|
stopAudio();
|
||||||
|
stopCamera();
|
||||||
|
mEncoder.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int[] findClosestFpsRange(int expectedFps, List<int[]> fpsRanges) {
|
||||||
|
expectedFps *= 1000;
|
||||||
|
int[] closestRange = fpsRanges.get(0);
|
||||||
|
int measure = Math.abs(closestRange[0] - expectedFps) + Math.abs(closestRange[1] - expectedFps);
|
||||||
|
for (int[] range : fpsRanges) {
|
||||||
|
if (range[0] <= expectedFps && range[1] >= expectedFps) {
|
||||||
|
int curMeasure = Math.abs(range[0] - expectedFps) + Math.abs(range[1] - expectedFps);
|
||||||
|
if (curMeasure < measure) {
|
||||||
|
closestRange = range;
|
||||||
|
measure = curMeasure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return closestRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
|
||||||
|
Log.d(TAG, "surfaceChanged");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void surfaceCreated(SurfaceHolder arg0) {
|
||||||
|
Log.d(TAG, "surfaceCreated");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void surfaceDestroyed(SurfaceHolder arg0) {
|
||||||
|
Log.d(TAG, "surfaceDestroyed");
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,69 @@
|
|||||||
|
package net.ossrs.sea;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import net.ossrs.sea.rtmp.RtmpPublisher;
|
||||||
|
import net.ossrs.sea.rtmp.io.RtmpConnection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Srs implementation of an RTMP publisher
|
||||||
|
*
|
||||||
|
* @author francois, leoma
|
||||||
|
*/
|
||||||
|
public class SrsRtmpPublisher implements RtmpPublisher {
|
||||||
|
|
||||||
|
private RtmpPublisher rtmpConnection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor for URLs in the format: rtmp://host[:port]/application[?streamName]
|
||||||
|
*
|
||||||
|
* @param url a RTMP URL in the format: rtmp://host[:port]/application[?streamName]
|
||||||
|
*/
|
||||||
|
public SrsRtmpPublisher(String url) {
|
||||||
|
rtmpConnection = new RtmpConnection(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void connect() throws IOException {
|
||||||
|
rtmpConnection.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void shutdown() {
|
||||||
|
rtmpConnection.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void publish(String publishType) throws IllegalStateException, IOException {
|
||||||
|
if (publishType == null) {
|
||||||
|
throw new IllegalStateException("No publish type specified");
|
||||||
|
}
|
||||||
|
rtmpConnection.publish(publishType);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void closeStream() throws IllegalStateException {
|
||||||
|
rtmpConnection.closeStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void publishVideoData(byte[] data, int dts) throws IllegalStateException {
|
||||||
|
if (data == null || data.length == 0) {
|
||||||
|
throw new IllegalStateException("Invalid Video Data");
|
||||||
|
}
|
||||||
|
if (dts < 0) {
|
||||||
|
throw new IllegalStateException("Invalid DTS");
|
||||||
|
}
|
||||||
|
rtmpConnection.publishVideoData(data, dts);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void publishAudioData(byte[] data, int dts) throws IllegalStateException {
|
||||||
|
if (data == null || data.length == 0) {
|
||||||
|
throw new IllegalStateException("Invalid Audio Data");
|
||||||
|
}
|
||||||
|
if (dts < 0) {
|
||||||
|
throw new IllegalStateException("Invalid DTS");
|
||||||
|
}
|
||||||
|
rtmpConnection.publishAudioData(data, dts);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,72 @@
|
|||||||
|
package net.ossrs.sea.rtmp;
|
||||||
|
|
||||||
|
import java.security.InvalidKeyException;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some helper utilities for SHA256, mostly (used during handshake)
|
||||||
|
* This is separated in order to be more easily replaced on platforms that
|
||||||
|
* do not have the javax.crypto.* and/or java.security.* packages
|
||||||
|
*
|
||||||
|
* This implementation is directly inspired by the RTMPHandshake class of the
|
||||||
|
* Red5 Open Source Flash Server project
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class Crypto {
|
||||||
|
|
||||||
|
private static final String TAG = "Crypto";
|
||||||
|
|
||||||
|
private Mac hmacSHA256;
|
||||||
|
|
||||||
|
public Crypto() {
|
||||||
|
try {
|
||||||
|
hmacSHA256 = Mac.getInstance("HmacSHA256");
|
||||||
|
} catch (SecurityException e) {
|
||||||
|
Log.e(TAG, "Security exception when getting HMAC", e);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
Log.e(TAG, "HMAC SHA256 does not exist");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates an HMAC SHA256 hash using a default key length.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* @param input
|
||||||
|
* @param key
|
||||||
|
* @return hmac hashed bytes
|
||||||
|
*/
|
||||||
|
public byte[] calculateHmacSHA256(byte[] input, byte[] key) {
|
||||||
|
byte[] output = null;
|
||||||
|
try {
|
||||||
|
hmacSHA256.init(new SecretKeySpec(key, "HmacSHA256"));
|
||||||
|
output = hmacSHA256.doFinal(input);
|
||||||
|
} catch (InvalidKeyException e) {
|
||||||
|
Log.e(TAG, "Invalid key", e);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates an HMAC SHA256 hash using a set key length.
|
||||||
|
*
|
||||||
|
* @param input
|
||||||
|
* @param key
|
||||||
|
* @param length
|
||||||
|
* @return hmac hashed bytes
|
||||||
|
*/
|
||||||
|
public byte[] calculateHmacSHA256(byte[] input, byte[] key, int length) {
|
||||||
|
byte[] output = null;
|
||||||
|
try {
|
||||||
|
hmacSHA256.init(new SecretKeySpec(key, 0, length, "HmacSHA256"));
|
||||||
|
output = hmacSHA256.doFinal(input);
|
||||||
|
} catch (InvalidKeyException e) {
|
||||||
|
Log.e(TAG, "Invalid key", e);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,165 @@
|
|||||||
|
GNU LESSER GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
|
||||||
|
This version of the GNU Lesser General Public License incorporates
|
||||||
|
the terms and conditions of version 3 of the GNU General Public
|
||||||
|
License, supplemented by the additional permissions listed below.
|
||||||
|
|
||||||
|
0. Additional Definitions.
|
||||||
|
|
||||||
|
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||||
|
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||||
|
General Public License.
|
||||||
|
|
||||||
|
"The Library" refers to a covered work governed by this License,
|
||||||
|
other than an Application or a Combined Work as defined below.
|
||||||
|
|
||||||
|
An "Application" is any work that makes use of an interface provided
|
||||||
|
by the Library, but which is not otherwise based on the Library.
|
||||||
|
Defining a subclass of a class defined by the Library is deemed a mode
|
||||||
|
of using an interface provided by the Library.
|
||||||
|
|
||||||
|
A "Combined Work" is a work produced by combining or linking an
|
||||||
|
Application with the Library. The particular version of the Library
|
||||||
|
with which the Combined Work was made is also called the "Linked
|
||||||
|
Version".
|
||||||
|
|
||||||
|
The "Minimal Corresponding Source" for a Combined Work means the
|
||||||
|
Corresponding Source for the Combined Work, excluding any source code
|
||||||
|
for portions of the Combined Work that, considered in isolation, are
|
||||||
|
based on the Application, and not on the Linked Version.
|
||||||
|
|
||||||
|
The "Corresponding Application Code" for a Combined Work means the
|
||||||
|
object code and/or source code for the Application, including any data
|
||||||
|
and utility programs needed for reproducing the Combined Work from the
|
||||||
|
Application, but excluding the System Libraries of the Combined Work.
|
||||||
|
|
||||||
|
1. Exception to Section 3 of the GNU GPL.
|
||||||
|
|
||||||
|
You may convey a covered work under sections 3 and 4 of this License
|
||||||
|
without being bound by section 3 of the GNU GPL.
|
||||||
|
|
||||||
|
2. Conveying Modified Versions.
|
||||||
|
|
||||||
|
If you modify a copy of the Library, and, in your modifications, a
|
||||||
|
facility refers to a function or data to be supplied by an Application
|
||||||
|
that uses the facility (other than as an argument passed when the
|
||||||
|
facility is invoked), then you may convey a copy of the modified
|
||||||
|
version:
|
||||||
|
|
||||||
|
a) under this License, provided that you make a good faith effort to
|
||||||
|
ensure that, in the event an Application does not supply the
|
||||||
|
function or data, the facility still operates, and performs
|
||||||
|
whatever part of its purpose remains meaningful, or
|
||||||
|
|
||||||
|
b) under the GNU GPL, with none of the additional permissions of
|
||||||
|
this License applicable to that copy.
|
||||||
|
|
||||||
|
3. Object Code Incorporating Material from Library Header Files.
|
||||||
|
|
||||||
|
The object code form of an Application may incorporate material from
|
||||||
|
a header file that is part of the Library. You may convey such object
|
||||||
|
code under terms of your choice, provided that, if the incorporated
|
||||||
|
material is not limited to numerical parameters, data structure
|
||||||
|
layouts and accessors, or small macros, inline functions and templates
|
||||||
|
(ten or fewer lines in length), you do both of the following:
|
||||||
|
|
||||||
|
a) Give prominent notice with each copy of the object code that the
|
||||||
|
Library is used in it and that the Library and its use are
|
||||||
|
covered by this License.
|
||||||
|
|
||||||
|
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||||
|
document.
|
||||||
|
|
||||||
|
4. Combined Works.
|
||||||
|
|
||||||
|
You may convey a Combined Work under terms of your choice that,
|
||||||
|
taken together, effectively do not restrict modification of the
|
||||||
|
portions of the Library contained in the Combined Work and reverse
|
||||||
|
engineering for debugging such modifications, if you also do each of
|
||||||
|
the following:
|
||||||
|
|
||||||
|
a) Give prominent notice with each copy of the Combined Work that
|
||||||
|
the Library is used in it and that the Library and its use are
|
||||||
|
covered by this License.
|
||||||
|
|
||||||
|
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||||
|
document.
|
||||||
|
|
||||||
|
c) For a Combined Work that displays copyright notices during
|
||||||
|
execution, include the copyright notice for the Library among
|
||||||
|
these notices, as well as a reference directing the user to the
|
||||||
|
copies of the GNU GPL and this license document.
|
||||||
|
|
||||||
|
d) Do one of the following:
|
||||||
|
|
||||||
|
0) Convey the Minimal Corresponding Source under the terms of this
|
||||||
|
License, and the Corresponding Application Code in a form
|
||||||
|
suitable for, and under terms that permit, the user to
|
||||||
|
recombine or relink the Application with a modified version of
|
||||||
|
the Linked Version to produce a modified Combined Work, in the
|
||||||
|
manner specified by section 6 of the GNU GPL for conveying
|
||||||
|
Corresponding Source.
|
||||||
|
|
||||||
|
1) Use a suitable shared library mechanism for linking with the
|
||||||
|
Library. A suitable mechanism is one that (a) uses at run time
|
||||||
|
a copy of the Library already present on the user's computer
|
||||||
|
system, and (b) will operate properly with a modified version
|
||||||
|
of the Library that is interface-compatible with the Linked
|
||||||
|
Version.
|
||||||
|
|
||||||
|
e) Provide Installation Information, but only if you would otherwise
|
||||||
|
be required to provide such information under section 6 of the
|
||||||
|
GNU GPL, and only to the extent that such information is
|
||||||
|
necessary to install and execute a modified version of the
|
||||||
|
Combined Work produced by recombining or relinking the
|
||||||
|
Application with a modified version of the Linked Version. (If
|
||||||
|
you use option 4d0, the Installation Information must accompany
|
||||||
|
the Minimal Corresponding Source and Corresponding Application
|
||||||
|
Code. If you use option 4d1, you must provide the Installation
|
||||||
|
Information in the manner specified by section 6 of the GNU GPL
|
||||||
|
for conveying Corresponding Source.)
|
||||||
|
|
||||||
|
5. Combined Libraries.
|
||||||
|
|
||||||
|
You may place library facilities that are a work based on the
|
||||||
|
Library side by side in a single library together with other library
|
||||||
|
facilities that are not Applications and are not covered by this
|
||||||
|
License, and convey such a combined library under terms of your
|
||||||
|
choice, if you do both of the following:
|
||||||
|
|
||||||
|
a) Accompany the combined library with a copy of the same work based
|
||||||
|
on the Library, uncombined with any other library facilities,
|
||||||
|
conveyed under the terms of this License.
|
||||||
|
|
||||||
|
b) Give prominent notice with the combined library that part of it
|
||||||
|
is a work based on the Library, and explaining where to find the
|
||||||
|
accompanying uncombined form of the same work.
|
||||||
|
|
||||||
|
6. Revised Versions of the GNU Lesser General Public License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions
|
||||||
|
of the GNU Lesser General Public License from time to time. Such new
|
||||||
|
versions will be similar in spirit to the present version, but may
|
||||||
|
differ in detail to address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Library as you received it specifies that a certain numbered version
|
||||||
|
of the GNU Lesser General Public License "or any later version"
|
||||||
|
applies to it, you have the option of following the terms and
|
||||||
|
conditions either of that published version or of any later version
|
||||||
|
published by the Free Software Foundation. If the Library as you
|
||||||
|
received it does not specify a version number of the GNU Lesser
|
||||||
|
General Public License, you may choose any version of the GNU Lesser
|
||||||
|
General Public License ever published by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Library as you received it specifies that a proxy can decide
|
||||||
|
whether future versions of the GNU Lesser General Public License shall
|
||||||
|
apply, that proxy's public statement of acceptance of any version is
|
||||||
|
permanent authorization for you to choose that version for the
|
||||||
|
Library.
|
@ -0,0 +1,46 @@
|
|||||||
|
package net.ossrs.sea.rtmp;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple RTMP publisher, using vanilla Java networking (no NIO)
|
||||||
|
* This was created primarily to address a NIO bug in Android 2.2 when
|
||||||
|
* used with Apache Mina, but also to provide an easy-to-use way to access
|
||||||
|
* RTMP streams
|
||||||
|
*
|
||||||
|
* @author francois, leo
|
||||||
|
*/
|
||||||
|
public interface RtmpPublisher {
|
||||||
|
|
||||||
|
void connect() throws IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issues an RTMP "publish" command and write the media content stream packets (audio and video).
|
||||||
|
*
|
||||||
|
* @param publishType specify the way to publish raw RTMP packets among "live", "record" and "append"
|
||||||
|
* @return An outputStream allowing you to write the incoming media content data
|
||||||
|
* @throws IllegalStateException if the client is not connected to a RTMP server
|
||||||
|
* @throws IOException if a network/IO error occurs
|
||||||
|
*/
|
||||||
|
void publish(String publishType) throws IllegalStateException, IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops and closes the current RTMP stream
|
||||||
|
*/
|
||||||
|
void closeStream() throws IllegalStateException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shuts down the RTMP client and stops all threads associated with it
|
||||||
|
*/
|
||||||
|
void shutdown();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* publish a video content packet to server
|
||||||
|
*/
|
||||||
|
void publishVideoData(byte[] data, int dts) throws IllegalStateException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* publish an audio content packet to server
|
||||||
|
*/
|
||||||
|
void publishAudioData(byte[] data, int dts) throws IllegalStateException;
|
||||||
|
}
|
@ -0,0 +1,138 @@
|
|||||||
|
package net.ossrs.sea.rtmp;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Misc utility method
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class Util {
|
||||||
|
|
||||||
|
private static final String HEXES = "0123456789ABCDEF";
|
||||||
|
|
||||||
|
public static void writeUnsignedInt32(OutputStream out, int value) throws IOException {
|
||||||
|
out.write((byte) (value >>> 24));
|
||||||
|
out.write((byte) (value >>> 16));
|
||||||
|
out.write((byte) (value >>> 8));
|
||||||
|
out.write((byte) value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int readUnsignedInt32(InputStream in) throws IOException {
|
||||||
|
return ((in.read() & 0xff) << 24) | ((in.read() & 0xff) << 16) | ((in.read() & 0xff) << 8) | (in.read() & 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int readUnsignedInt24(InputStream in) throws IOException {
|
||||||
|
return ((in.read() & 0xff) << 16) | ((in.read() & 0xff) << 8) | (in.read() & 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int readUnsignedInt16(InputStream in) throws IOException {
|
||||||
|
return ((in.read() & 0xff) << 8) | (in.read() & 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void writeUnsignedInt24(OutputStream out, int value) throws IOException {
|
||||||
|
out.write((byte) (value >>> 16));
|
||||||
|
out.write((byte) (value >>> 8));
|
||||||
|
out.write((byte) value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void writeUnsignedInt16(OutputStream out, int value) throws IOException {
|
||||||
|
out.write((byte) (value >>> 8));
|
||||||
|
out.write((byte) value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int toUnsignedInt32(byte[] bytes) {
|
||||||
|
return (((int) bytes[0] & 0xff) << 24) | (((int)bytes[1] & 0xff) << 16) | (((int)bytes[2] & 0xff) << 8) | ((int)bytes[3] & 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int toUnsignedInt32LittleEndian(byte[] bytes) {
|
||||||
|
return ((bytes[3] & 0xff) << 24) | ((bytes[2] & 0xff) << 16) | ((bytes[1] & 0xff) << 8) | (bytes[0] & 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void writeUnsignedInt32LittleEndian(OutputStream out, int value) throws IOException {
|
||||||
|
out.write((byte) value);
|
||||||
|
out.write((byte) (value >>> 8));
|
||||||
|
out.write((byte) (value >>> 16));
|
||||||
|
out.write((byte) (value >>> 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int toUnsignedInt24(byte[] bytes) {
|
||||||
|
return ((bytes[1] & 0xff) << 16) | ((bytes[2] & 0xff) << 8) | (bytes[3] & 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int toUnsignedInt16(byte[] bytes) {
|
||||||
|
return ((bytes[2] & 0xff) << 8) | (bytes[3] & 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String toHexString(byte[] raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final StringBuilder hex = new StringBuilder(2 * raw.length);
|
||||||
|
for (final byte b : raw) {
|
||||||
|
hex.append(HEXES.charAt((b & 0xF0) >> 4)).append(HEXES.charAt((b & 0x0F)));
|
||||||
|
}
|
||||||
|
return hex.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String toHexString(byte b) {
|
||||||
|
return new StringBuilder().append(HEXES.charAt((b & 0xF0) >> 4)).append(HEXES.charAt((b & 0x0F))).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads bytes from the specified inputstream into the specified target buffer until it is filled up
|
||||||
|
*/
|
||||||
|
public static void readBytesUntilFull(InputStream in, byte[] targetBuffer) throws IOException {
|
||||||
|
int totalBytesRead = 0;
|
||||||
|
int read;
|
||||||
|
final int targetBytes = targetBuffer.length;
|
||||||
|
do {
|
||||||
|
read = in.read(targetBuffer, totalBytesRead, (targetBytes - totalBytesRead));
|
||||||
|
if (read != -1) {
|
||||||
|
totalBytesRead += read;
|
||||||
|
} else {
|
||||||
|
throw new IOException("Unexpected EOF reached before read buffer was filled");
|
||||||
|
}
|
||||||
|
} while (totalBytesRead < targetBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] toByteArray(double d) {
|
||||||
|
long l = Double.doubleToRawLongBits(d);
|
||||||
|
return new byte[]{
|
||||||
|
(byte) ((l >> 56) & 0xff),
|
||||||
|
(byte) ((l >> 48) & 0xff),
|
||||||
|
(byte) ((l >> 40) & 0xff),
|
||||||
|
(byte) ((l >> 32) & 0xff),
|
||||||
|
(byte) ((l >> 24) & 0xff),
|
||||||
|
(byte) ((l >> 16) & 0xff),
|
||||||
|
(byte) ((l >> 8) & 0xff),
|
||||||
|
(byte) (l & 0xff),};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] unsignedInt32ToByteArray(int value) throws IOException {
|
||||||
|
return new byte[]{
|
||||||
|
(byte) (value >>> 24),
|
||||||
|
(byte) (value >>> 16),
|
||||||
|
(byte) (value >>> 8),
|
||||||
|
(byte) value};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double readDouble(InputStream in) throws IOException {
|
||||||
|
long bits = ((long) (in.read() & 0xff) << 56) | ((long) (in.read() & 0xff) << 48) | ((long) (in.read() & 0xff) << 40) | ((long) (in.read() & 0xff) << 32) | ((in.read() & 0xff) << 24) | ((in.read() & 0xff) << 16) | ((in.read() & 0xff) << 8) | (in.read() & 0xff);
|
||||||
|
return Double.longBitsToDouble(bits);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void writeDouble(OutputStream out, double d) throws IOException {
|
||||||
|
long l = Double.doubleToRawLongBits(d);
|
||||||
|
out.write(new byte[]{
|
||||||
|
(byte) ((l >> 56) & 0xff),
|
||||||
|
(byte) ((l >> 48) & 0xff),
|
||||||
|
(byte) ((l >> 40) & 0xff),
|
||||||
|
(byte) ((l >> 32) & 0xff),
|
||||||
|
(byte) ((l >> 24) & 0xff),
|
||||||
|
(byte) ((l >> 16) & 0xff),
|
||||||
|
(byte) ((l >> 8) & 0xff),
|
||||||
|
(byte) (l & 0xff)});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,66 @@
|
|||||||
|
package net.ossrs.sea.rtmp.amf;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import net.ossrs.sea.rtmp.Util;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AMF Array
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class AmfArray implements AmfData {
|
||||||
|
|
||||||
|
private List<AmfData> items;
|
||||||
|
private int size = -1;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeTo(OutputStream out) throws IOException {
|
||||||
|
throw new UnsupportedOperationException("Not supported yet.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void readFrom(InputStream in) throws IOException {
|
||||||
|
// Skip data type byte (we assume it's already read)
|
||||||
|
int length = Util.readUnsignedInt32(in);
|
||||||
|
size = 5; // 1 + 4
|
||||||
|
items = new ArrayList<AmfData>(length);
|
||||||
|
for (int i = 0; i < length; i++) {
|
||||||
|
AmfData dataItem = AmfDecoder.readFrom(in);
|
||||||
|
size += dataItem.getSize();
|
||||||
|
items.add(dataItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getSize() {
|
||||||
|
if (size == -1) {
|
||||||
|
size = 5; // 1 + 4
|
||||||
|
if (items != null) {
|
||||||
|
for (AmfData dataItem : items) {
|
||||||
|
size += dataItem.getSize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the amount of items in this the array */
|
||||||
|
public int getLength() {
|
||||||
|
return items != null ? items.size() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AmfData> getItems() {
|
||||||
|
if (items == null) {
|
||||||
|
items = new ArrayList<AmfData>();
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addItem(AmfData dataItem) {
|
||||||
|
getItems().add(this);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,50 @@
|
|||||||
|
package net.ossrs.sea.rtmp.amf;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class AmfBoolean implements AmfData {
|
||||||
|
|
||||||
|
private boolean value;
|
||||||
|
|
||||||
|
public boolean isValue() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setValue(boolean value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AmfBoolean(boolean value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AmfBoolean() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeTo(OutputStream out) throws IOException {
|
||||||
|
out.write(AmfType.BOOLEAN.getValue());
|
||||||
|
out.write(value ? 0x01 : 0x00);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void readFrom(InputStream in) throws IOException {
|
||||||
|
value = (in.read() == 0x01) ? true : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean readBooleanFrom(InputStream in) throws IOException {
|
||||||
|
// Skip data type byte (we assume it's already read)
|
||||||
|
return (in.read() == 0x01) ? true : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getSize() {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,31 @@
|
|||||||
|
package net.ossrs.sea.rtmp.amf;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base AMF data object. All other AMF data type instances derive from this
|
||||||
|
* (including AmfObject)
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public interface AmfData {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write/Serialize this AMF data intance (Object/string/integer etc) to
|
||||||
|
* the specified OutputStream
|
||||||
|
*/
|
||||||
|
void writeTo(OutputStream out) throws IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and parse bytes from the specified input stream to populate this
|
||||||
|
* AMFData instance (deserialize)
|
||||||
|
*
|
||||||
|
* @return the amount of bytes read
|
||||||
|
*/
|
||||||
|
void readFrom(InputStream in) throws IOException;
|
||||||
|
|
||||||
|
/** @return the amount of bytes required for this object */
|
||||||
|
int getSize();
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
package net.ossrs.sea.rtmp.amf;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class AmfDecoder {
|
||||||
|
|
||||||
|
public static AmfData readFrom(InputStream in) throws IOException {
|
||||||
|
|
||||||
|
byte amfTypeByte = (byte) in.read();
|
||||||
|
AmfType amfType = AmfType.valueOf(amfTypeByte);
|
||||||
|
|
||||||
|
AmfData amfData;
|
||||||
|
switch (amfType) {
|
||||||
|
case NUMBER:
|
||||||
|
amfData = new AmfNumber();
|
||||||
|
break;
|
||||||
|
case BOOLEAN:
|
||||||
|
amfData = new AmfBoolean();
|
||||||
|
break;
|
||||||
|
case STRING:
|
||||||
|
amfData = new AmfString();
|
||||||
|
break;
|
||||||
|
case OBJECT:
|
||||||
|
amfData = new AmfObject();
|
||||||
|
break;
|
||||||
|
case NULL:
|
||||||
|
return new AmfNull();
|
||||||
|
case UNDEFINED:
|
||||||
|
return new AmfUndefined();
|
||||||
|
case MAP:
|
||||||
|
amfData = new AmfMap();
|
||||||
|
break;
|
||||||
|
case ARRAY:
|
||||||
|
amfData = new AmfArray();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new IOException("Unknown/unimplemented AMF data type: " + amfType);
|
||||||
|
}
|
||||||
|
|
||||||
|
amfData.readFrom(in);
|
||||||
|
return amfData;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,52 @@
|
|||||||
|
package net.ossrs.sea.rtmp.amf;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.util.Map;
|
||||||
|
import net.ossrs.sea.rtmp.Util;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AMF map; that is, an "object"-like structure of key/value pairs, but with
|
||||||
|
* an array-like size indicator at the start (which is seemingly always 0)
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class AmfMap extends AmfObject {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeTo(OutputStream out) throws IOException {
|
||||||
|
// Begin the map/object/array/whatever exactly this is
|
||||||
|
out.write(AmfType.MAP.getValue());
|
||||||
|
|
||||||
|
// Write the "array size" == 0
|
||||||
|
Util.writeUnsignedInt32(out, 0);
|
||||||
|
|
||||||
|
// Write key/value pairs in this object
|
||||||
|
for (Map.Entry<String, AmfData> entry : properties.entrySet()) {
|
||||||
|
// The key must be a STRING type, and thus the "type-definition" byte is implied (not included in message)
|
||||||
|
AmfString.writeStringTo(out, entry.getKey(), true);
|
||||||
|
entry.getValue().writeTo(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// End the object
|
||||||
|
out.write(OBJECT_END_MARKER);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void readFrom(InputStream in) throws IOException {
|
||||||
|
// Skip data type byte (we assume it's already read)
|
||||||
|
int length = Util.readUnsignedInt32(in); // Seems this is always 0
|
||||||
|
super.readFrom(in);
|
||||||
|
size += 4; // Add the bytes read for parsing the array size (length)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getSize() {
|
||||||
|
if (size == -1) {
|
||||||
|
size = super.getSize();
|
||||||
|
size += 4; // array length bytes
|
||||||
|
}
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* To change this template, choose Tools | Templates
|
||||||
|
* and open the template in the editor.
|
||||||
|
*/
|
||||||
|
package net.ossrs.sea.rtmp.amf;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class AmfNull implements AmfData {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeTo(OutputStream out) throws IOException {
|
||||||
|
out.write(AmfType.NULL.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void readFrom(InputStream in) throws IOException {
|
||||||
|
// Skip data type byte (we assume it's already read)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void writeNullTo(OutputStream out) throws IOException {
|
||||||
|
out.write(AmfType.NULL.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getSize() {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,62 @@
|
|||||||
|
package net.ossrs.sea.rtmp.amf;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import net.ossrs.sea.rtmp.Util;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AMF0 Number data type
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class AmfNumber implements AmfData {
|
||||||
|
|
||||||
|
double value;
|
||||||
|
/** Size of an AMF number, in bytes (including type bit) */
|
||||||
|
public static final int SIZE = 9;
|
||||||
|
|
||||||
|
public AmfNumber(double value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AmfNumber() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getValue() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setValue(double value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeTo(OutputStream out) throws IOException {
|
||||||
|
out.write(AmfType.NUMBER.getValue());
|
||||||
|
Util.writeDouble(out, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void readFrom(InputStream in) throws IOException {
|
||||||
|
// Skip data type byte (we assume it's already read)
|
||||||
|
value = Util.readDouble(in);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double readNumberFrom(InputStream in) throws IOException {
|
||||||
|
// Skip data type byte
|
||||||
|
in.read();
|
||||||
|
return Util.readDouble(in);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void writeNumberTo(OutputStream out, double number) throws IOException {
|
||||||
|
out.write(AmfType.NUMBER.getValue());
|
||||||
|
Util.writeDouble(out, number);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getSize() {
|
||||||
|
return SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,109 @@
|
|||||||
|
package net.ossrs.sea.rtmp.amf;
|
||||||
|
|
||||||
|
import java.io.BufferedInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AMF object
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class AmfObject implements AmfData {
|
||||||
|
|
||||||
|
protected Map<String, AmfData> properties = new LinkedHashMap<String, AmfData>();
|
||||||
|
protected int size = -1;
|
||||||
|
/** Byte sequence that marks the end of an AMF object */
|
||||||
|
protected static final byte[] OBJECT_END_MARKER = new byte[]{0x00, 0x00, 0x09};
|
||||||
|
|
||||||
|
public AmfObject() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public AmfData getProperty(String key) {
|
||||||
|
return properties.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProperty(String key, AmfData value) {
|
||||||
|
properties.put(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProperty(String key, boolean value) {
|
||||||
|
properties.put(key, new AmfBoolean(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProperty(String key, String value) {
|
||||||
|
properties.put(key, new AmfString(value, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProperty(String key, int value) {
|
||||||
|
properties.put(key, new AmfNumber(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProperty(String key, double value) {
|
||||||
|
properties.put(key, new AmfNumber(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeTo(OutputStream out) throws IOException {
|
||||||
|
// Begin the object
|
||||||
|
out.write(AmfType.OBJECT.getValue());
|
||||||
|
|
||||||
|
// Write key/value pairs in this object
|
||||||
|
for (Map.Entry<String, AmfData> entry : properties.entrySet()) {
|
||||||
|
// The key must be a STRING type, and thus the "type-definition" byte is implied (not included in message)
|
||||||
|
AmfString.writeStringTo(out, entry.getKey(), true);
|
||||||
|
entry.getValue().writeTo(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// End the object
|
||||||
|
out.write(OBJECT_END_MARKER);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void readFrom(InputStream in) throws IOException {
|
||||||
|
// Skip data type byte (we assume it's already read)
|
||||||
|
size = 1;
|
||||||
|
InputStream markInputStream = in.markSupported() ? in : new BufferedInputStream(in);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
// Look for the 3-byte object end marker [0x00 0x00 0x09]
|
||||||
|
markInputStream.mark(3);
|
||||||
|
byte[] endMarker = new byte[3];
|
||||||
|
markInputStream.read(endMarker);
|
||||||
|
|
||||||
|
if (endMarker[0] == OBJECT_END_MARKER[0] && endMarker[1] == OBJECT_END_MARKER[1] && endMarker[2] == OBJECT_END_MARKER[2]) {
|
||||||
|
// End marker found
|
||||||
|
size += 3;
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// End marker not found; reset the stream to the marked position and read an AMF property
|
||||||
|
markInputStream.reset();
|
||||||
|
// Read the property key...
|
||||||
|
String key = AmfString.readStringFrom(in, true);
|
||||||
|
size += AmfString.sizeOf(key, true);
|
||||||
|
// ...and the property value
|
||||||
|
AmfData value = AmfDecoder.readFrom(markInputStream);
|
||||||
|
size += value.getSize();
|
||||||
|
properties.put(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getSize() {
|
||||||
|
if (size == -1) {
|
||||||
|
size = 1; // object marker
|
||||||
|
for (Map.Entry<String, AmfData> entry : properties.entrySet()) {
|
||||||
|
size += AmfString.sizeOf(entry.getKey(), true);
|
||||||
|
size += entry.getValue().getSize();
|
||||||
|
}
|
||||||
|
size += 3; // end of object marker
|
||||||
|
|
||||||
|
}
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,128 @@
|
|||||||
|
package net.ossrs.sea.rtmp.amf;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.io.UnsupportedEncodingException;
|
||||||
|
import java.lang.String;
|
||||||
|
import android.util.Log;
|
||||||
|
import net.ossrs.sea.rtmp.Util;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class AmfString implements AmfData {
|
||||||
|
|
||||||
|
private static final String TAG = "AmfString";
|
||||||
|
|
||||||
|
private String value;
|
||||||
|
private boolean key;
|
||||||
|
private int size = -1;
|
||||||
|
|
||||||
|
public AmfString() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public AmfString(String value, boolean isKey) {
|
||||||
|
this.value = value;
|
||||||
|
this.key = isKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AmfString(String value) {
|
||||||
|
this(value, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AmfString(boolean isKey) {
|
||||||
|
this.key = isKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getValue() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setValue(String value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isKey() {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setKey(boolean key) {
|
||||||
|
this.key = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeTo(OutputStream out) throws IOException {
|
||||||
|
// Strings are ASCII encoded
|
||||||
|
byte[] byteValue = this.value.getBytes("ASCII");
|
||||||
|
// Write the STRING data type definition (except if this String is used as a key)
|
||||||
|
if (!key) {
|
||||||
|
out.write(AmfType.STRING.getValue());
|
||||||
|
}
|
||||||
|
// Write 2 bytes indicating string length
|
||||||
|
Util.writeUnsignedInt16(out, byteValue.length);
|
||||||
|
// Write string
|
||||||
|
out.write(byteValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void readFrom(InputStream in) throws IOException {
|
||||||
|
// Skip data type byte (we assume it's already read)
|
||||||
|
int length = Util.readUnsignedInt16(in);
|
||||||
|
size = 3 + length; // 1 + 2 + length
|
||||||
|
// Read string value
|
||||||
|
byte[] byteValue = new byte[length];
|
||||||
|
Util.readBytesUntilFull(in, byteValue);
|
||||||
|
value = new String(byteValue, "ASCII");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String readStringFrom(InputStream in, boolean isKey) throws IOException {
|
||||||
|
if (!isKey) {
|
||||||
|
// Read past the data type byte
|
||||||
|
in.read();
|
||||||
|
}
|
||||||
|
int length = Util.readUnsignedInt16(in);
|
||||||
|
// Read string value
|
||||||
|
byte[] byteValue = new byte[length];
|
||||||
|
Util.readBytesUntilFull(in, byteValue);
|
||||||
|
return new String(byteValue, "ASCII");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void writeStringTo(OutputStream out, String string, boolean isKey) throws IOException {
|
||||||
|
// Strings are ASCII encoded
|
||||||
|
byte[] byteValue = string.getBytes("ASCII");
|
||||||
|
// Write the STRING data type definition (except if this String is used as a key)
|
||||||
|
if (!isKey) {
|
||||||
|
out.write(AmfType.STRING.getValue());
|
||||||
|
}
|
||||||
|
// Write 2 bytes indicating string length
|
||||||
|
Util.writeUnsignedInt16(out, byteValue.length);
|
||||||
|
// Write string
|
||||||
|
out.write(byteValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getSize() {
|
||||||
|
if (size == -1) {
|
||||||
|
try {
|
||||||
|
size = (isKey() ? 0 : 1) + 2 + value.getBytes("ASCII").length;
|
||||||
|
} catch (UnsupportedEncodingException ex) {
|
||||||
|
Log.e(TAG, "AmfString.getSize(): caught exception", ex);
|
||||||
|
throw new RuntimeException(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the byte size of the resulting AMF string of the specified value */
|
||||||
|
public static int sizeOf(String string, boolean isKey) {
|
||||||
|
try {
|
||||||
|
int size = (isKey ? 0 : 1) + 2 + string.getBytes("ASCII").length;
|
||||||
|
return size;
|
||||||
|
} catch (UnsupportedEncodingException ex) {
|
||||||
|
Log.e(TAG, "AmfString.SizeOf(): caught exception", ex);
|
||||||
|
throw new RuntimeException(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,45 @@
|
|||||||
|
package net.ossrs.sea.rtmp.amf;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AMF0 data type enum
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public enum AmfType {
|
||||||
|
|
||||||
|
/** Number (encoded as IEEE 64-bit double precision floating point number) */
|
||||||
|
NUMBER(0x00),
|
||||||
|
/** Boolean (Encoded as a single byte of value 0x00 or 0x01) */
|
||||||
|
BOOLEAN(0x01),
|
||||||
|
/** String (ASCII encoded) */
|
||||||
|
STRING(0x02),
|
||||||
|
/** Object - set of key/value pairs */
|
||||||
|
OBJECT(0x03),
|
||||||
|
NULL(0x05),
|
||||||
|
UNDEFINED(0x06),
|
||||||
|
MAP(0x08),
|
||||||
|
ARRAY(0x0A);
|
||||||
|
private byte value;
|
||||||
|
private static final Map<Byte, AmfType> quickLookupMap = new HashMap<Byte, AmfType>();
|
||||||
|
|
||||||
|
static {
|
||||||
|
for (AmfType amfType : AmfType.values()) {
|
||||||
|
quickLookupMap.put(amfType.getValue(), amfType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AmfType(int intValue) {
|
||||||
|
this.value = (byte) intValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte getValue() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AmfType valueOf(byte amfTypeByte) {
|
||||||
|
return quickLookupMap.get(amfTypeByte);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* To change this template, choose Tools | Templates
|
||||||
|
* and open the template in the editor.
|
||||||
|
*/
|
||||||
|
package net.ossrs.sea.rtmp.amf;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @author leoma
|
||||||
|
*/
|
||||||
|
public class AmfUndefined implements AmfData {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeTo(OutputStream out) throws IOException {
|
||||||
|
out.write(AmfType.UNDEFINED.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void readFrom(InputStream in) throws IOException {
|
||||||
|
// Skip data type byte (we assume it's already read)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void writeUndefinedTo(OutputStream out) throws IOException {
|
||||||
|
out.write(AmfType.UNDEFINED.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getSize() {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,76 @@
|
|||||||
|
package net.ossrs.sea.rtmp.io;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import net.ossrs.sea.rtmp.Util;
|
||||||
|
import net.ossrs.sea.rtmp.packets.RtmpHeader;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chunk stream channel information
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class ChunkStreamInfo {
|
||||||
|
|
||||||
|
public static final byte RTMP_STREAM_CHANNEL = 0x05;
|
||||||
|
public static final byte RTMP_COMMAND_CHANNEL = 0x03;
|
||||||
|
public static final byte RTMP_VIDEO_CHANNEL = 0x06;
|
||||||
|
public static final byte RTMP_AUDIO_CHANNEL = 0x07;
|
||||||
|
public static final byte RTMP_CONTROL_CHANNEL = 0x02;
|
||||||
|
private RtmpHeader prevHeaderRx;
|
||||||
|
private RtmpHeader prevHeaderTx;
|
||||||
|
private long realLastTimestamp = 0;
|
||||||
|
private ByteArrayOutputStream baos = new ByteArrayOutputStream(1024 * 128);
|
||||||
|
|
||||||
|
/** @return the previous header that was received on this channel, or <code>null</code> if no previous header was received */
|
||||||
|
public RtmpHeader prevHeaderRx() {
|
||||||
|
return prevHeaderRx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the previous header that was received on this channel, or <code>null</code> if no previous header was sent */
|
||||||
|
public void setPrevHeaderRx(RtmpHeader previousHeader) {
|
||||||
|
this.prevHeaderRx = previousHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the previous header that was transmitted on this channel */
|
||||||
|
public RtmpHeader getPrevHeaderTx() {
|
||||||
|
return prevHeaderTx;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean canReusePrevHeaderTx(RtmpHeader.MessageType forMessageType) {
|
||||||
|
return (prevHeaderTx != null && prevHeaderTx.getMessageType() == forMessageType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the previous header that was transmitted on this channel */
|
||||||
|
public void setPrevHeaderTx(RtmpHeader prevHeaderTx) {
|
||||||
|
this.prevHeaderTx = prevHeaderTx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Utility method for calculating & synchronizing transmitted timestamps & timestamp deltas */
|
||||||
|
public long markRealAbsoluteTimestampTx() {
|
||||||
|
realLastTimestamp = System.currentTimeMillis() - realLastTimestamp;
|
||||||
|
return realLastTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return <code>true</code> if all packet data has been stored, or <code>false</code> if not */
|
||||||
|
public boolean storePacketChunk(InputStream in, int chunkSize) throws IOException {
|
||||||
|
final int remainingBytes = prevHeaderRx.getPacketLength() - baos.size();
|
||||||
|
byte[] chunk = new byte[Math.min(remainingBytes, chunkSize)];
|
||||||
|
Util.readBytesUntilFull(in, chunk);
|
||||||
|
baos.write(chunk);
|
||||||
|
return (baos.size() == prevHeaderRx.getPacketLength());
|
||||||
|
}
|
||||||
|
|
||||||
|
public ByteArrayInputStream getStoredPacketInputStream() {
|
||||||
|
ByteArrayInputStream bis = new ByteArrayInputStream(baos.toByteArray());
|
||||||
|
baos.reset();
|
||||||
|
return bis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears all currently-stored packet chunks (used when an ABORT packet is received) */
|
||||||
|
public void clearStoredChunks() {
|
||||||
|
baos.reset();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
package net.ossrs.sea.rtmp.io;
|
||||||
|
|
||||||
|
import net.ossrs.sea.rtmp.packets.RtmpPacket;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler interface for received RTMP packets
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public interface PacketRxHandler {
|
||||||
|
|
||||||
|
public void handleRxPacket(RtmpPacket rtmpPacket);
|
||||||
|
|
||||||
|
public void notifyWindowAckRequired(final int numBytesReadThusFar);
|
||||||
|
}
|
@ -0,0 +1,69 @@
|
|||||||
|
package net.ossrs.sea.rtmp.io;
|
||||||
|
|
||||||
|
import java.io.EOFException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import android.util.Log;
|
||||||
|
import net.ossrs.sea.rtmp.packets.RtmpPacket;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RTMPConnection's read thread
|
||||||
|
*
|
||||||
|
* @author francois, leo
|
||||||
|
*/
|
||||||
|
public class ReadThread extends Thread {
|
||||||
|
|
||||||
|
private static final String TAG = "ReadThread";
|
||||||
|
|
||||||
|
private RtmpDecoder rtmpDecoder;
|
||||||
|
private InputStream in;
|
||||||
|
private PacketRxHandler packetRxHandler;
|
||||||
|
private ThreadController threadController;
|
||||||
|
|
||||||
|
public ReadThread(RtmpSessionInfo rtmpSessionInfo, InputStream in, PacketRxHandler packetRxHandler, ThreadController threadController) {
|
||||||
|
super("RtmpReadThread");
|
||||||
|
this.in = in;
|
||||||
|
this.packetRxHandler = packetRxHandler;
|
||||||
|
this.rtmpDecoder = new RtmpDecoder(rtmpSessionInfo);
|
||||||
|
this.threadController = threadController;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
while (!Thread.interrupted()) {
|
||||||
|
try {
|
||||||
|
RtmpPacket rtmpPacket = rtmpDecoder.readPacket(in);
|
||||||
|
packetRxHandler.handleRxPacket(rtmpPacket);
|
||||||
|
} catch (EOFException eof) {
|
||||||
|
// The handler thread will wait until be invoked.
|
||||||
|
packetRxHandler.handleRxPacket(null);
|
||||||
|
// } catch (WindowAckRequired war) {
|
||||||
|
// Log.i(TAG, "Window Acknowledgment required, notifying packet handler...");
|
||||||
|
// packetRxHandler.notifyWindowAckRequired(war.getBytesRead());
|
||||||
|
// if (war.getRtmpPacket() != null) {
|
||||||
|
// // Pass to handler
|
||||||
|
// packetRxHandler.handleRxPacket(war.getRtmpPacket());
|
||||||
|
// }
|
||||||
|
} catch (Exception ex) {
|
||||||
|
if (!this.isInterrupted()) {
|
||||||
|
Log.e(TAG, "Caught exception while reading/decoding packet, shutting down...", ex);
|
||||||
|
this.interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Close inputstream
|
||||||
|
try {
|
||||||
|
in.close();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Log.w(TAG, "Failed to close inputstream", ex);
|
||||||
|
}
|
||||||
|
Log.i(TAG, "exiting");
|
||||||
|
if (threadController != null) {
|
||||||
|
threadController.threadHasExited(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void shutdown() {
|
||||||
|
Log.d(TAG, "Stopping read thread...");
|
||||||
|
this.interrupt();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,437 @@
|
|||||||
|
package net.ossrs.sea.rtmp.io;
|
||||||
|
|
||||||
|
import java.io.BufferedInputStream;
|
||||||
|
import java.io.BufferedOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.Socket;
|
||||||
|
import java.net.SocketAddress;
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import android.util.Log;
|
||||||
|
import net.ossrs.sea.rtmp.RtmpPublisher;
|
||||||
|
import net.ossrs.sea.rtmp.amf.AmfNull;
|
||||||
|
import net.ossrs.sea.rtmp.amf.AmfNumber;
|
||||||
|
import net.ossrs.sea.rtmp.amf.AmfObject;
|
||||||
|
import net.ossrs.sea.rtmp.packets.Abort;
|
||||||
|
import net.ossrs.sea.rtmp.packets.Acknowledgement;
|
||||||
|
import net.ossrs.sea.rtmp.packets.Handshake;
|
||||||
|
import net.ossrs.sea.rtmp.packets.Command;
|
||||||
|
import net.ossrs.sea.rtmp.packets.Audio;
|
||||||
|
import net.ossrs.sea.rtmp.packets.Video;
|
||||||
|
import net.ossrs.sea.rtmp.packets.UserControl;
|
||||||
|
import net.ossrs.sea.rtmp.packets.RtmpPacket;
|
||||||
|
import net.ossrs.sea.rtmp.packets.WindowAckSize;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main RTMP connection implementation class
|
||||||
|
*
|
||||||
|
* @author francois, leoma
|
||||||
|
*/
|
||||||
|
public class RtmpConnection implements RtmpPublisher, PacketRxHandler, ThreadController {
|
||||||
|
|
||||||
|
private static final String TAG = "RtmpConnection";
|
||||||
|
private static final Pattern rtmpUrlPattern = Pattern.compile("^rtmp://([^/:]+)(:(\\d+))*/([^/]+)(/(.*))*$");
|
||||||
|
|
||||||
|
private String appName;
|
||||||
|
private String host;
|
||||||
|
private String streamName;
|
||||||
|
private String publishType;
|
||||||
|
private String swfUrl = "";
|
||||||
|
private String tcUrl = "";
|
||||||
|
private String pageUrl = "";
|
||||||
|
private int port;
|
||||||
|
private Socket socket;
|
||||||
|
private RtmpSessionInfo rtmpSessionInfo;
|
||||||
|
private int transactionIdCounter = 0;
|
||||||
|
private static final int SOCKET_CONNECT_TIMEOUT_MS = 3000;
|
||||||
|
private WriteThread writeThread;
|
||||||
|
private final ConcurrentLinkedQueue<RtmpPacket> rxPacketQueue;
|
||||||
|
private final Object rxPacketLock = new Object();
|
||||||
|
private boolean active = false;
|
||||||
|
private volatile boolean fullyConnected = false;
|
||||||
|
private final Object connectingLock = new Object();
|
||||||
|
private final Object publishLock = new Object();
|
||||||
|
private volatile boolean connecting = false;
|
||||||
|
private int currentStreamId = -1;
|
||||||
|
|
||||||
|
public RtmpConnection(String url) {
|
||||||
|
this.tcUrl = url.substring(0, url.lastIndexOf('/'));
|
||||||
|
Matcher matcher = rtmpUrlPattern.matcher(url);
|
||||||
|
if (matcher.matches()) {
|
||||||
|
this.host = matcher.group(1);
|
||||||
|
String portStr = matcher.group(3);
|
||||||
|
this.port = portStr != null ? Integer.parseInt(portStr) : 1935;
|
||||||
|
this.appName = matcher.group(4);
|
||||||
|
this.streamName = matcher.group(6);
|
||||||
|
rtmpSessionInfo = new RtmpSessionInfo();
|
||||||
|
rxPacketQueue = new ConcurrentLinkedQueue<RtmpPacket>();
|
||||||
|
} else {
|
||||||
|
throw new RuntimeException("Invalid RTMP URL. Must be in format: rtmp://host[:port]/application[/streamName]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void connect() throws IOException {
|
||||||
|
Log.d(TAG, "connect() called. Host: " + host + ", port: " + port + ", appName: " + appName + ", publishPath: " + streamName);
|
||||||
|
socket = new Socket();
|
||||||
|
SocketAddress socketAddress = new InetSocketAddress(host, port);
|
||||||
|
socket.connect(socketAddress, SOCKET_CONNECT_TIMEOUT_MS);
|
||||||
|
BufferedInputStream in = new BufferedInputStream(socket.getInputStream());
|
||||||
|
BufferedOutputStream out = new BufferedOutputStream(socket.getOutputStream());
|
||||||
|
Log.d(TAG, "connect(): socket connection established, doing handhake...");
|
||||||
|
handshake(in, out);
|
||||||
|
active = true;
|
||||||
|
Log.d(TAG, "connect(): handshake done");
|
||||||
|
ReadThread readThread = new ReadThread(rtmpSessionInfo, in, this, this);
|
||||||
|
writeThread = new WriteThread(rtmpSessionInfo, out, this);
|
||||||
|
readThread.start();
|
||||||
|
writeThread.start();
|
||||||
|
|
||||||
|
// Start the "main" handling thread
|
||||||
|
new Thread(new Runnable() {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "starting main rx handler loop");
|
||||||
|
handleRxPacketLoop();
|
||||||
|
} catch (IOException ex) {
|
||||||
|
Logger.getLogger(RtmpConnection.class.getName()).log(Level.SEVERE, null, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
rtmpConnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void publish(String type) throws IllegalStateException, IOException {
|
||||||
|
if (connecting) {
|
||||||
|
synchronized (connectingLock) {
|
||||||
|
try {
|
||||||
|
connectingLock.wait();
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.publishType = type;
|
||||||
|
createStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createStream() {
|
||||||
|
if (!fullyConnected) {
|
||||||
|
throw new IllegalStateException("Not connected to RTMP server");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStreamId != -1) {
|
||||||
|
throw new IllegalStateException("Current stream object has existed");
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "createStream(): Sending releaseStream command...");
|
||||||
|
// transactionId == 2
|
||||||
|
Command releaseStream = new Command("releaseStream", ++transactionIdCounter);
|
||||||
|
releaseStream.getHeader().setChunkStreamId(ChunkStreamInfo.RTMP_STREAM_CHANNEL);
|
||||||
|
releaseStream.addData(new AmfNull()); // command object: null for "createStream"
|
||||||
|
releaseStream.addData(streamName); // command object: null for "releaseStream"
|
||||||
|
writeThread.send(releaseStream);
|
||||||
|
|
||||||
|
Log.d(TAG, "createStream(): Sending FCPublish command...");
|
||||||
|
// transactionId == 3
|
||||||
|
Command FCPublish = new Command("FCPublish", ++transactionIdCounter);
|
||||||
|
FCPublish.getHeader().setChunkStreamId(ChunkStreamInfo.RTMP_STREAM_CHANNEL);
|
||||||
|
FCPublish.addData(new AmfNull()); // command object: null for "FCPublish"
|
||||||
|
FCPublish.addData(streamName);
|
||||||
|
writeThread.send(FCPublish);
|
||||||
|
|
||||||
|
Log.d(TAG, "createStream(): Sending createStream command...");
|
||||||
|
final ChunkStreamInfo chunkStreamInfo = rtmpSessionInfo.getChunkStreamInfo(ChunkStreamInfo.RTMP_COMMAND_CHANNEL);
|
||||||
|
// transactionId == 4
|
||||||
|
Command createStream = new Command("createStream", ++transactionIdCounter, chunkStreamInfo);
|
||||||
|
createStream.addData(new AmfNull()); // command object: null for "createStream"
|
||||||
|
writeThread.send(createStream);
|
||||||
|
|
||||||
|
// Waiting for "publish" command response.
|
||||||
|
synchronized (publishLock) {
|
||||||
|
try {
|
||||||
|
publishLock.wait();
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fmlePublish() throws IllegalStateException {
|
||||||
|
if (!fullyConnected) {
|
||||||
|
throw new IllegalStateException("Not connected to RTMP server");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStreamId == -1) {
|
||||||
|
throw new IllegalStateException("No current stream object exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "fmlePublish(): Sending publish command...");
|
||||||
|
// transactionId == 0
|
||||||
|
Command publish = new Command("publish", 0);
|
||||||
|
publish.getHeader().setChunkStreamId(ChunkStreamInfo.RTMP_STREAM_CHANNEL);
|
||||||
|
publish.getHeader().setMessageStreamId(currentStreamId);
|
||||||
|
publish.addData(new AmfNull()); // command object: null for "publish"
|
||||||
|
publish.addData(streamName);
|
||||||
|
publish.addData(publishType);
|
||||||
|
writeThread.send(publish);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void closeStream() throws IllegalStateException {
|
||||||
|
if (!fullyConnected) {
|
||||||
|
throw new IllegalStateException("Not connected to RTMP server");
|
||||||
|
}
|
||||||
|
if (currentStreamId == -1) {
|
||||||
|
throw new IllegalStateException("No current stream object exists");
|
||||||
|
}
|
||||||
|
streamName = null;
|
||||||
|
Log.d(TAG, "closeStream(): setting current stream ID to -1");
|
||||||
|
currentStreamId = -1;
|
||||||
|
Command closeStream = new Command("closeStream", 0);
|
||||||
|
closeStream.getHeader().setChunkStreamId(ChunkStreamInfo.RTMP_STREAM_CHANNEL);
|
||||||
|
closeStream.getHeader().setMessageStreamId(currentStreamId);
|
||||||
|
closeStream.addData(new AmfNull()); // command object: null for "closeStream"
|
||||||
|
writeThread.send(closeStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs the RTMP handshake sequence with the server
|
||||||
|
*/
|
||||||
|
private void handshake(InputStream in, OutputStream out) throws IOException {
|
||||||
|
Handshake handshake = new Handshake();
|
||||||
|
handshake.writeC0(out);
|
||||||
|
handshake.writeC1(out); // Write C1 without waiting for S0
|
||||||
|
out.flush();
|
||||||
|
handshake.readS0(in);
|
||||||
|
handshake.readS1(in);
|
||||||
|
handshake.writeC2(out);
|
||||||
|
handshake.readS2(in);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rtmpConnect() throws IOException, IllegalStateException {
|
||||||
|
if (fullyConnected || connecting) {
|
||||||
|
throw new IllegalStateException("Already connecting, or connected to RTMP server");
|
||||||
|
}
|
||||||
|
Log.d(TAG, "rtmpConnect(): Building 'connect' invoke packet");
|
||||||
|
Command invoke = new Command("connect", ++transactionIdCounter, rtmpSessionInfo.getChunkStreamInfo(ChunkStreamInfo.RTMP_COMMAND_CHANNEL));
|
||||||
|
invoke.getHeader().setMessageStreamId(0);
|
||||||
|
|
||||||
|
AmfObject args = new AmfObject();
|
||||||
|
args.setProperty("app", appName);
|
||||||
|
args.setProperty("flashVer", "LNX 11,2,202,233"); // Flash player OS: Linux, version: 11.2.202.233
|
||||||
|
args.setProperty("swfUrl", swfUrl);
|
||||||
|
args.setProperty("tcUrl", tcUrl);
|
||||||
|
args.setProperty("fpad", false);
|
||||||
|
args.setProperty("capabilities", 239);
|
||||||
|
args.setProperty("audioCodecs", 3575);
|
||||||
|
args.setProperty("videoCodecs", 252);
|
||||||
|
args.setProperty("videoFunction", 1);
|
||||||
|
args.setProperty("pageUrl", pageUrl);
|
||||||
|
args.setProperty("objectEncoding", 0);
|
||||||
|
|
||||||
|
invoke.addData(args);
|
||||||
|
|
||||||
|
connecting = true;
|
||||||
|
|
||||||
|
Log.d(TAG, "rtmpConnect(): Writing 'connect' invoke packet");
|
||||||
|
invoke.getHeader().setAbsoluteTimestamp(0);
|
||||||
|
writeThread.send(invoke);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handleRxPacket(RtmpPacket rtmpPacket) {
|
||||||
|
if (rtmpPacket != null) {
|
||||||
|
rxPacketQueue.add(rtmpPacket);
|
||||||
|
}
|
||||||
|
synchronized (rxPacketLock) {
|
||||||
|
rxPacketLock.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleRxPacketLoop() throws IOException {
|
||||||
|
// Handle all queued received RTMP packets
|
||||||
|
while (active) {
|
||||||
|
while (!rxPacketQueue.isEmpty()) {
|
||||||
|
RtmpPacket rtmpPacket = rxPacketQueue.poll();
|
||||||
|
//Log.d(TAG, "handleRxPacketLoop(): RTMP rx packet message type: " + rtmpPacket.getHeader().getMessageType());
|
||||||
|
switch (rtmpPacket.getHeader().getMessageType()) {
|
||||||
|
case ABORT:
|
||||||
|
rtmpSessionInfo.getChunkStreamInfo(((Abort) rtmpPacket).getChunkStreamId()).clearStoredChunks();
|
||||||
|
break;
|
||||||
|
case USER_CONTROL_MESSAGE: {
|
||||||
|
UserControl ping = (UserControl) rtmpPacket;
|
||||||
|
switch (ping.getType()) {
|
||||||
|
case PING_REQUEST: {
|
||||||
|
ChunkStreamInfo channelInfo = rtmpSessionInfo.getChunkStreamInfo(ChunkStreamInfo.RTMP_CONTROL_CHANNEL);
|
||||||
|
Log.d(TAG, "handleRxPacketLoop(): Sending PONG reply..");
|
||||||
|
UserControl pong = new UserControl(ping, channelInfo);
|
||||||
|
writeThread.send(pong);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case STREAM_EOF:
|
||||||
|
Log.i(TAG, "handleRxPacketLoop(): Stream EOF reached, closing RTMP writer...");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case WINDOW_ACKNOWLEDGEMENT_SIZE:
|
||||||
|
WindowAckSize windowAckSize = (WindowAckSize) rtmpPacket;
|
||||||
|
Log.d(TAG, "handleRxPacketLoop(): Setting acknowledgement window size to: " + windowAckSize.getAcknowledgementWindowSize());
|
||||||
|
rtmpSessionInfo.setAcknowledgmentWindowSize(windowAckSize.getAcknowledgementWindowSize());
|
||||||
|
break;
|
||||||
|
case SET_PEER_BANDWIDTH:
|
||||||
|
int acknowledgementWindowsize = rtmpSessionInfo.getAcknowledgementWindowSize();
|
||||||
|
final ChunkStreamInfo chunkStreamInfo = rtmpSessionInfo.getChunkStreamInfo(ChunkStreamInfo.RTMP_CONTROL_CHANNEL);
|
||||||
|
Log.d(TAG, "handleRxPacketLoop(): Send acknowledgement window size: " + acknowledgementWindowsize);
|
||||||
|
writeThread.send(new WindowAckSize(acknowledgementWindowsize, chunkStreamInfo));
|
||||||
|
break;
|
||||||
|
case COMMAND_AMF0:
|
||||||
|
handleRxInvoke((Command) rtmpPacket);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
Log.w(TAG, "handleRxPacketLoop(): Not handling unimplemented/unknown packet of type: " + rtmpPacket.getHeader().getMessageType());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Wait for next received packet
|
||||||
|
synchronized (rxPacketLock) {
|
||||||
|
try {
|
||||||
|
rxPacketLock.wait();
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
Log.w(TAG, "handleRxPacketLoop: Interrupted", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdownImpl();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleRxInvoke(Command invoke) throws IOException {
|
||||||
|
String commandName = invoke.getCommandName();
|
||||||
|
|
||||||
|
if (commandName.equals("_result")) {
|
||||||
|
// This is the result of one of the methods invoked by us
|
||||||
|
String method = rtmpSessionInfo.takeInvokedCommand(invoke.getTransactionId());
|
||||||
|
|
||||||
|
Log.d(TAG, "handleRxInvoke: Got result for invoked method: " + method);
|
||||||
|
if ("connect".equals(method)) {
|
||||||
|
// We can now send createStream commands
|
||||||
|
connecting = false;
|
||||||
|
fullyConnected = true;
|
||||||
|
synchronized (connectingLock) {
|
||||||
|
connectingLock.notifyAll();
|
||||||
|
}
|
||||||
|
} else if ("createStream".contains(method)) {
|
||||||
|
// Get stream id
|
||||||
|
currentStreamId = (int) ((AmfNumber) invoke.getData().get(1)).getValue();
|
||||||
|
Log.d(TAG, "handleRxInvoke(): Stream ID to publish: " + currentStreamId);
|
||||||
|
if (streamName != null && publishType != null) {
|
||||||
|
fmlePublish();
|
||||||
|
}
|
||||||
|
} else if ("releaseStream".contains(method)) {
|
||||||
|
// Do nothing
|
||||||
|
} else if ("FCPublish".contains(method)) {
|
||||||
|
// Do nothing
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "handleRxInvoke(): '_result' message received for unknown method: " + method);
|
||||||
|
}
|
||||||
|
} else if (commandName.equals("onBWDone")) {
|
||||||
|
// Do nothing
|
||||||
|
} else if (commandName.equals("onFCPublish")) {
|
||||||
|
Log.d(TAG, "handleRxInvoke(): 'onFCPublish'");
|
||||||
|
synchronized (publishLock) {
|
||||||
|
publishLock.notifyAll();
|
||||||
|
}
|
||||||
|
} else if (commandName.equals("onStatus")) {
|
||||||
|
// Do nothing
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "handleRxInvoke(): Uknown/unhandled server invoke: " + invoke);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void threadHasExited(Thread thread) {
|
||||||
|
shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void shutdown() {
|
||||||
|
active = false;
|
||||||
|
synchronized (rxPacketLock) {
|
||||||
|
rxPacketLock.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void shutdownImpl() {
|
||||||
|
// Shut down read/write threads, if necessary
|
||||||
|
if (Thread.activeCount() > 1) {
|
||||||
|
Log.i(TAG, "shutdown(): Shutting down read/write threads");
|
||||||
|
Thread[] threads = new Thread[Thread.activeCount()];
|
||||||
|
Thread.enumerate(threads);
|
||||||
|
for (Thread thread : threads) {
|
||||||
|
if (thread instanceof ReadThread && thread.isAlive()) {
|
||||||
|
((ReadThread) thread).shutdown();
|
||||||
|
} else if (thread instanceof WriteThread && thread.isAlive()) {
|
||||||
|
((WriteThread) thread).shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (socket != null) {
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Log.w(TAG, "shutdown(): failed to close socket", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void notifyWindowAckRequired(final int numBytesReadThusFar) {
|
||||||
|
Log.i(TAG, "notifyWindowAckRequired() called");
|
||||||
|
// Create and send window bytes read acknowledgement
|
||||||
|
writeThread.send(new Acknowledgement(numBytesReadThusFar));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void publishVideoData(byte[] data, int dts) throws IllegalStateException {
|
||||||
|
if (!fullyConnected) {
|
||||||
|
throw new IllegalStateException("Not connected to RTMP server");
|
||||||
|
}
|
||||||
|
if (currentStreamId == -1) {
|
||||||
|
throw new IllegalStateException("No current stream object exists");
|
||||||
|
}
|
||||||
|
Video video = new Video();
|
||||||
|
video.setData(data);
|
||||||
|
video.getHeader().setMessageStreamId(currentStreamId);
|
||||||
|
video.getHeader().setAbsoluteTimestamp(dts);
|
||||||
|
writeThread.send(video);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void publishAudioData(byte[] data, int dts) throws IllegalStateException {
|
||||||
|
if (!fullyConnected) {
|
||||||
|
throw new IllegalStateException("Not connected to RTMP server");
|
||||||
|
}
|
||||||
|
if (currentStreamId == -1) {
|
||||||
|
throw new IllegalStateException("No current stream object exists");
|
||||||
|
}
|
||||||
|
Audio audio = new Audio();
|
||||||
|
audio.setData(data);
|
||||||
|
audio.getHeader().setMessageStreamId(currentStreamId);
|
||||||
|
audio.getHeader().setAbsoluteTimestamp(dts);
|
||||||
|
writeThread.send(audio);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,95 @@
|
|||||||
|
package net.ossrs.sea.rtmp.io;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import android.util.Log;
|
||||||
|
import net.ossrs.sea.rtmp.packets.Abort;
|
||||||
|
import net.ossrs.sea.rtmp.packets.Audio;
|
||||||
|
import net.ossrs.sea.rtmp.packets.Command;
|
||||||
|
import net.ossrs.sea.rtmp.packets.Data;
|
||||||
|
import net.ossrs.sea.rtmp.packets.RtmpHeader;
|
||||||
|
import net.ossrs.sea.rtmp.packets.RtmpPacket;
|
||||||
|
import net.ossrs.sea.rtmp.packets.SetChunkSize;
|
||||||
|
import net.ossrs.sea.rtmp.packets.SetPeerBandwidth;
|
||||||
|
import net.ossrs.sea.rtmp.packets.UserControl;
|
||||||
|
import net.ossrs.sea.rtmp.packets.Video;
|
||||||
|
import net.ossrs.sea.rtmp.packets.WindowAckSize;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class RtmpDecoder {
|
||||||
|
|
||||||
|
private static final String TAG = "RtmpDecoder";
|
||||||
|
|
||||||
|
private RtmpSessionInfo rtmpSessionInfo;
|
||||||
|
|
||||||
|
public RtmpDecoder(RtmpSessionInfo rtmpSessionInfo) {
|
||||||
|
this.rtmpSessionInfo = rtmpSessionInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RtmpPacket readPacket(InputStream in) throws IOException {
|
||||||
|
|
||||||
|
RtmpHeader header = RtmpHeader.readHeader(in, rtmpSessionInfo);
|
||||||
|
RtmpPacket rtmpPacket;
|
||||||
|
Log.d(TAG, "readPacket(): header.messageType: " + header.getMessageType());
|
||||||
|
|
||||||
|
ChunkStreamInfo chunkStreamInfo = rtmpSessionInfo.getChunkStreamInfo(header.getChunkStreamId());
|
||||||
|
|
||||||
|
chunkStreamInfo.setPrevHeaderRx(header);
|
||||||
|
|
||||||
|
if (header.getPacketLength() > rtmpSessionInfo.getChunkSize()) {
|
||||||
|
//Log.d(TAG, "readPacket(): packet size (" + header.getPacketLength() + ") is bigger than chunk size (" + rtmpSessionInfo.getChunkSize() + "); storing chunk data");
|
||||||
|
// This packet consists of more than one chunk; store the chunks in the chunk stream until everything is read
|
||||||
|
if (!chunkStreamInfo.storePacketChunk(in, rtmpSessionInfo.getChunkSize())) {
|
||||||
|
Log.d(TAG, " readPacket(): returning null because of incomplete packet");
|
||||||
|
return null; // packet is not yet complete
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, " readPacket(): stored chunks complete packet; reading packet");
|
||||||
|
in = chunkStreamInfo.getStoredPacketInputStream();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
//Log.d(TAG, "readPacket(): packet size (" + header.getPacketLength() + ") is LESS than chunk size (" + rtmpSessionInfo.getChunkSize() + "); reading packet fully");
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (header.getMessageType()) {
|
||||||
|
|
||||||
|
case SET_CHUNK_SIZE: {
|
||||||
|
SetChunkSize setChunkSize = new SetChunkSize(header);
|
||||||
|
setChunkSize.readBody(in);
|
||||||
|
Log.d(TAG, "readPacket(): Setting chunk size to: " + setChunkSize.getChunkSize());
|
||||||
|
rtmpSessionInfo.setChunkSize(setChunkSize.getChunkSize());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
case ABORT:
|
||||||
|
rtmpPacket = new Abort(header);
|
||||||
|
break;
|
||||||
|
case USER_CONTROL_MESSAGE:
|
||||||
|
rtmpPacket = new UserControl(header);
|
||||||
|
break;
|
||||||
|
case WINDOW_ACKNOWLEDGEMENT_SIZE:
|
||||||
|
rtmpPacket = new WindowAckSize(header);
|
||||||
|
break;
|
||||||
|
case SET_PEER_BANDWIDTH:
|
||||||
|
rtmpPacket = new SetPeerBandwidth(header);
|
||||||
|
break;
|
||||||
|
case AUDIO:
|
||||||
|
rtmpPacket = new Audio(header);
|
||||||
|
break;
|
||||||
|
case VIDEO:
|
||||||
|
rtmpPacket = new Video(header);
|
||||||
|
break;
|
||||||
|
case COMMAND_AMF0:
|
||||||
|
rtmpPacket = new Command(header);
|
||||||
|
break;
|
||||||
|
case DATA_AMF0:
|
||||||
|
rtmpPacket = new Data(header);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new IOException("No packet body implementation for message type: " + header.getMessageType());
|
||||||
|
}
|
||||||
|
rtmpPacket.readBody(in);
|
||||||
|
return rtmpPacket;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,72 @@
|
|||||||
|
package net.ossrs.sea.rtmp.io;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import net.ossrs.sea.rtmp.packets.RtmpPacket;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class RtmpSessionInfo {
|
||||||
|
|
||||||
|
/** The (total) number of bytes read for this window (resets to 0 if the agreed-upon RTMP window acknowledgement size is reached) */
|
||||||
|
private int windowBytesRead;
|
||||||
|
/** The window acknowledgement size for this RTMP session, in bytes; default to max to avoid unnecessary "Acknowledgment" messages from being sent */
|
||||||
|
private int acknowledgementWindowSize = Integer.MAX_VALUE;
|
||||||
|
/** Used internally to store the total number of bytes read (used when sending Acknowledgement messages) */
|
||||||
|
private int totalBytesRead = 0;
|
||||||
|
|
||||||
|
/** Default chunk size is 128 bytes */
|
||||||
|
private int chunkSize = 128;
|
||||||
|
private Map<Integer, ChunkStreamInfo> chunkChannels = new HashMap<Integer, ChunkStreamInfo>();
|
||||||
|
private Map<Integer, String> invokedMethods = new ConcurrentHashMap<Integer, String>();
|
||||||
|
|
||||||
|
public ChunkStreamInfo getChunkStreamInfo(int chunkStreamId) {
|
||||||
|
ChunkStreamInfo chunkStreamInfo = chunkChannels.get(chunkStreamId);
|
||||||
|
if (chunkStreamInfo == null) {
|
||||||
|
chunkStreamInfo = new ChunkStreamInfo();
|
||||||
|
chunkChannels.put(chunkStreamId, chunkStreamInfo);
|
||||||
|
}
|
||||||
|
return chunkStreamInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String takeInvokedCommand(int transactionId) {
|
||||||
|
return invokedMethods.remove(transactionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String addInvokedCommand(int transactionId, String commandName) {
|
||||||
|
return invokedMethods.put(transactionId, commandName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getChunkSize() {
|
||||||
|
return chunkSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChunkSize(int chunkSize) {
|
||||||
|
this.chunkSize = chunkSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getAcknowledgementWindowSize() {
|
||||||
|
return acknowledgementWindowSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAcknowledgmentWindowSize(int acknowledgementWindowSize) {
|
||||||
|
this.acknowledgementWindowSize = acknowledgementWindowSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the specified amount of bytes to the total number of bytes read for this RTMP window;
|
||||||
|
* @param numBytes the number of bytes to add
|
||||||
|
* @return <code>true</code> if an "acknowledgement" packet should be sent, <code>false</code> otherwise
|
||||||
|
*/
|
||||||
|
public final void addToWindowBytesRead(final int numBytes, final RtmpPacket packet) throws WindowAckRequired {
|
||||||
|
windowBytesRead += numBytes;
|
||||||
|
totalBytesRead += numBytes;
|
||||||
|
if (windowBytesRead >= acknowledgementWindowSize) {
|
||||||
|
windowBytesRead -= acknowledgementWindowSize;
|
||||||
|
throw new WindowAckRequired(totalBytesRead, packet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
package net.ossrs.sea.rtmp.io;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple interface for the "parent" of one or more worker threads, so that
|
||||||
|
* these worker threads can signal the parent if they stop (e.g. in the event of
|
||||||
|
* parent/main thread not expecting a child thread to exit, such as when an irrecoverable
|
||||||
|
* error has occurred in that child thread).
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public interface ThreadController {
|
||||||
|
|
||||||
|
/** Called when a child thread has exited its run() loop */
|
||||||
|
void threadHasExited(Thread thread);
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
package net.ossrs.sea.rtmp.io;
|
||||||
|
|
||||||
|
import net.ossrs.sea.rtmp.packets.RtmpPacket;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by RTMP read thread when an Acknowledgement packet needs to be sent
|
||||||
|
* to acknowledge the RTMP window size. It contains the RTMP packet that was
|
||||||
|
* read when this event occurred (if any).
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class WindowAckRequired extends Exception {
|
||||||
|
|
||||||
|
private RtmpPacket rtmpPacket;
|
||||||
|
private int bytesRead;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used when the window acknowledgement size was reached, whilst fully reading
|
||||||
|
* an RTMP packet or not. If a packet is present, it should still be handled as if it was returned
|
||||||
|
* by the RTMP decoder.
|
||||||
|
*
|
||||||
|
* @param bytesReadThusFar The (total) number of bytes received so far
|
||||||
|
* @param rtmpPacket The packet that was read (and thus should be handled), can be <code>null</code>
|
||||||
|
*/
|
||||||
|
public WindowAckRequired(int bytesReadThusFar, RtmpPacket rtmpPacket) {
|
||||||
|
this.rtmpPacket = rtmpPacket;
|
||||||
|
this.bytesRead = bytesReadThusFar;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return The RTMP packet that should be handled, or <code>null</code> if no full packet is available
|
||||||
|
*/
|
||||||
|
public RtmpPacket getRtmpPacket() {
|
||||||
|
return rtmpPacket;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getBytesRead() {
|
||||||
|
return bytesRead;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,108 @@
|
|||||||
|
package net.ossrs.sea.rtmp.io;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.SocketException;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||||
|
import android.util.Log;
|
||||||
|
import net.ossrs.sea.rtmp.packets.Command;
|
||||||
|
import net.ossrs.sea.rtmp.packets.RtmpPacket;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RTMPConnection's write thread
|
||||||
|
*
|
||||||
|
* @author francois, leo
|
||||||
|
*/
|
||||||
|
public class WriteThread extends Thread {
|
||||||
|
|
||||||
|
private static final String TAG = "WriteThread";
|
||||||
|
|
||||||
|
private RtmpSessionInfo rtmpSessionInfo;
|
||||||
|
private OutputStream out;
|
||||||
|
private ConcurrentLinkedQueue<RtmpPacket> writeQueue = new ConcurrentLinkedQueue<RtmpPacket>();
|
||||||
|
private final Object txPacketLock = new Object();
|
||||||
|
private volatile boolean active = true;
|
||||||
|
private ThreadController threadController;
|
||||||
|
|
||||||
|
public WriteThread(RtmpSessionInfo rtmpSessionInfo, OutputStream out, ThreadController threadController) {
|
||||||
|
super("RtmpWriteThread");
|
||||||
|
this.rtmpSessionInfo = rtmpSessionInfo;
|
||||||
|
this.out = out;
|
||||||
|
this.threadController = threadController;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
|
||||||
|
while (active) {
|
||||||
|
try {
|
||||||
|
while (!writeQueue.isEmpty()) {
|
||||||
|
RtmpPacket rtmpPacket = writeQueue.poll();
|
||||||
|
final ChunkStreamInfo chunkStreamInfo = rtmpSessionInfo.getChunkStreamInfo(rtmpPacket.getHeader().getChunkStreamId());
|
||||||
|
chunkStreamInfo.setPrevHeaderTx(rtmpPacket.getHeader());
|
||||||
|
rtmpPacket.writeTo(out, rtmpSessionInfo.getChunkSize(), chunkStreamInfo);
|
||||||
|
Log.d(TAG, "WriteThread: wrote packet: " + rtmpPacket + ", size: " + rtmpPacket.getHeader().getPacketLength());
|
||||||
|
if (rtmpPacket instanceof Command) {
|
||||||
|
rtmpSessionInfo.addInvokedCommand(((Command) rtmpPacket).getTransactionId(), ((Command) rtmpPacket).getCommandName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.flush();
|
||||||
|
} catch (SocketException se) {
|
||||||
|
Log.e(TAG, "Caught SocketException during write loop, shutting down", se);
|
||||||
|
active = false;
|
||||||
|
continue;
|
||||||
|
} catch (IOException ex) {
|
||||||
|
Log.e(TAG, "Caught IOException during write loop, shutting down", ex);
|
||||||
|
active = false;
|
||||||
|
continue; // Exit this thread
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waiting for next packet
|
||||||
|
synchronized (txPacketLock) {
|
||||||
|
try {
|
||||||
|
txPacketLock.wait();
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
Log.w(TAG, "Interrupted", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close outputstream
|
||||||
|
try {
|
||||||
|
out.close();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Log.w(TAG, "Failed to close outputstream", ex);
|
||||||
|
}
|
||||||
|
Log.d(TAG, "exiting");
|
||||||
|
if (threadController != null) {
|
||||||
|
threadController.threadHasExited(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transmit the specified RTMP packet (thread-safe) */
|
||||||
|
public void send(RtmpPacket rtmpPacket) {
|
||||||
|
if (rtmpPacket != null) {
|
||||||
|
writeQueue.offer(rtmpPacket);
|
||||||
|
}
|
||||||
|
synchronized (txPacketLock) {
|
||||||
|
txPacketLock.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transmit the specified RTMP packet (thread-safe) */
|
||||||
|
public void send(RtmpPacket... rtmpPackets) {
|
||||||
|
writeQueue.addAll(Arrays.asList(rtmpPackets));
|
||||||
|
synchronized (txPacketLock) {
|
||||||
|
txPacketLock.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void shutdown() {
|
||||||
|
Log.d(TAG, "Stopping write thread...");
|
||||||
|
active = false;
|
||||||
|
synchronized (txPacketLock) {
|
||||||
|
txPacketLock.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,47 @@
|
|||||||
|
package net.ossrs.sea.rtmp.packets;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import net.ossrs.sea.rtmp.Util;
|
||||||
|
import net.ossrs.sea.rtmp.io.ChunkStreamInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A "Abort" RTMP control message, received on chunk stream ID 2 (control channel)
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class Abort extends RtmpPacket {
|
||||||
|
|
||||||
|
private int chunkStreamId;
|
||||||
|
|
||||||
|
public Abort(RtmpHeader header) {
|
||||||
|
super(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Abort(int chunkStreamId) {
|
||||||
|
super(new RtmpHeader(RtmpHeader.ChunkType.TYPE_1_RELATIVE_LARGE, ChunkStreamInfo.RTMP_CONTROL_CHANNEL, RtmpHeader.MessageType.SET_CHUNK_SIZE));
|
||||||
|
this.chunkStreamId = chunkStreamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the ID of the chunk stream to be aborted */
|
||||||
|
public int getChunkStreamId() {
|
||||||
|
return chunkStreamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the ID of the chunk stream to be aborted */
|
||||||
|
public void setChunkStreamId(int chunkStreamId) {
|
||||||
|
this.chunkStreamId = chunkStreamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void readBody(InputStream in) throws IOException {
|
||||||
|
// Value is received in the 4 bytes of the body
|
||||||
|
chunkStreamId = Util.readUnsignedInt32(in);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void writeBody(OutputStream out) throws IOException {
|
||||||
|
Util.writeUnsignedInt32(out, chunkStreamId);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,62 @@
|
|||||||
|
package net.ossrs.sea.rtmp.packets;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import net.ossrs.sea.rtmp.Util;
|
||||||
|
import net.ossrs.sea.rtmp.io.ChunkStreamInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (Window) Acknowledgement
|
||||||
|
*
|
||||||
|
* The client or the server sends the acknowledgment to the peer after
|
||||||
|
* receiving bytes equal to the window size. The window size is the
|
||||||
|
* maximum number of bytes that the sender sends without receiving
|
||||||
|
* acknowledgment from the receiver. The server sends the window size to
|
||||||
|
* the client after application connects. This message specifies the
|
||||||
|
* sequence number, which is the number of the bytes received so far.
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class Acknowledgement extends RtmpPacket {
|
||||||
|
|
||||||
|
private int sequenceNumber;
|
||||||
|
|
||||||
|
public Acknowledgement(RtmpHeader header) {
|
||||||
|
super(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Acknowledgement(int numBytesReadThusFar) {
|
||||||
|
super(new RtmpHeader(RtmpHeader.ChunkType.TYPE_0_FULL, ChunkStreamInfo.RTMP_CONTROL_CHANNEL, RtmpHeader.MessageType.ACKNOWLEDGEMENT));
|
||||||
|
this.sequenceNumber = numBytesReadThusFar;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getAcknowledgementWindowSize() {
|
||||||
|
return sequenceNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the sequence number, which is the number of the bytes received so far */
|
||||||
|
public int getSequenceNumber() {
|
||||||
|
return sequenceNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the sequence number, which is the number of the bytes received so far */
|
||||||
|
public void setSequenceNumber(int numBytesRead) {
|
||||||
|
this.sequenceNumber = numBytesRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void readBody(InputStream in) throws IOException {
|
||||||
|
sequenceNumber = Util.readUnsignedInt32(in);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void writeBody(OutputStream out) throws IOException {
|
||||||
|
Util.writeUnsignedInt32(out, sequenceNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "RTMP Acknowledgment (sequence number: " + sequenceNumber + ")";
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
package net.ossrs.sea.rtmp.packets;
|
||||||
|
|
||||||
|
import net.ossrs.sea.rtmp.io.ChunkStreamInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audio data packet
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class Audio extends ContentData {
|
||||||
|
|
||||||
|
public Audio(RtmpHeader header) {
|
||||||
|
super(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Audio() {
|
||||||
|
super(new RtmpHeader(RtmpHeader.ChunkType.TYPE_0_FULL, ChunkStreamInfo.RTMP_AUDIO_CHANNEL, RtmpHeader.MessageType.AUDIO));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "RTMP Audio";
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,80 @@
|
|||||||
|
package net.ossrs.sea.rtmp.packets;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import net.ossrs.sea.rtmp.amf.AmfNumber;
|
||||||
|
import net.ossrs.sea.rtmp.amf.AmfString;
|
||||||
|
import net.ossrs.sea.rtmp.io.ChunkStreamInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encapsulates an command/"invoke" RTMP packet
|
||||||
|
*
|
||||||
|
* Invoke/command packet structure (AMF encoded):
|
||||||
|
* (String) <commmand name>
|
||||||
|
* (Number) <Transaction ID>
|
||||||
|
* (Mixed) <Argument> ex. Null, String, Object: {key1:value1, key2:value2 ... }
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class Command extends VariableBodyRtmpPacket {
|
||||||
|
|
||||||
|
private static final String TAG = "Command";
|
||||||
|
|
||||||
|
private String commandName;
|
||||||
|
private int transactionId;
|
||||||
|
|
||||||
|
public Command(RtmpHeader header) {
|
||||||
|
super(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Command(String commandName, int transactionId, ChunkStreamInfo channelInfo) {
|
||||||
|
super(new RtmpHeader((channelInfo.canReusePrevHeaderTx(RtmpHeader.MessageType.COMMAND_AMF0) ? RtmpHeader.ChunkType.TYPE_1_RELATIVE_LARGE : RtmpHeader.ChunkType.TYPE_0_FULL), ChunkStreamInfo.RTMP_COMMAND_CHANNEL, RtmpHeader.MessageType.COMMAND_AMF0));
|
||||||
|
this.commandName = commandName;
|
||||||
|
this.transactionId = transactionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Command(String commandName, int transactionId) {
|
||||||
|
super(new RtmpHeader(RtmpHeader.ChunkType.TYPE_0_FULL, ChunkStreamInfo.RTMP_COMMAND_CHANNEL, RtmpHeader.MessageType.COMMAND_AMF0));
|
||||||
|
this.commandName = commandName;
|
||||||
|
this.transactionId = transactionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCommandName() {
|
||||||
|
return commandName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCommandName(String commandName) {
|
||||||
|
this.commandName = commandName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getTransactionId() {
|
||||||
|
return transactionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTransactionId(int transactionId) {
|
||||||
|
this.transactionId = transactionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void readBody(InputStream in) throws IOException {
|
||||||
|
// The command name and transaction ID are always present (AMF string followed by number)
|
||||||
|
commandName = AmfString.readStringFrom(in, false);
|
||||||
|
transactionId = (int) AmfNumber.readNumberFrom(in);
|
||||||
|
int bytesRead = AmfString.sizeOf(commandName, false) + AmfNumber.SIZE;
|
||||||
|
readVariableData(in, bytesRead);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void writeBody(OutputStream out) throws IOException {
|
||||||
|
AmfString.writeStringTo(out, commandName, false);
|
||||||
|
AmfNumber.writeNumberTo(out, transactionId);
|
||||||
|
// Write body data
|
||||||
|
writeVariableData(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "RTMP Command (command: " + commandName + ", transaction ID: " + transactionId + ")";
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,44 @@
|
|||||||
|
package net.ossrs.sea.rtmp.packets;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import net.ossrs.sea.rtmp.Util;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content (audio/video) data packet base
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public abstract class ContentData extends RtmpPacket {
|
||||||
|
|
||||||
|
protected byte[] data;
|
||||||
|
|
||||||
|
public ContentData(RtmpHeader header) {
|
||||||
|
super(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] getData() {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setData(byte[] data) {
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void readBody(InputStream in) throws IOException {
|
||||||
|
data = new byte[this.header.getPacketLength()];
|
||||||
|
Util.readBytesUntilFull(in, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method is public for content (audio/video)
|
||||||
|
* Write this packet body without chunking;
|
||||||
|
* useful for dumping audio/video streams
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void writeBody(OutputStream out) throws IOException {
|
||||||
|
out.write(data);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,59 @@
|
|||||||
|
package net.ossrs.sea.rtmp.packets;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import net.ossrs.sea.rtmp.amf.AmfString;
|
||||||
|
import net.ossrs.sea.rtmp.io.ChunkStreamInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AMF Data packet
|
||||||
|
*
|
||||||
|
* Also known as NOTIFY in some RTMP implementations.
|
||||||
|
*
|
||||||
|
* The client or the server sends this message to send Metadata or any user data
|
||||||
|
* to the peer. Metadata includes details about the data (audio, video etc.)
|
||||||
|
* like creation time, duration, theme and so on.
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class Data extends VariableBodyRtmpPacket {
|
||||||
|
|
||||||
|
private String type;
|
||||||
|
|
||||||
|
public Data(RtmpHeader header) {
|
||||||
|
super(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Data(String type) {
|
||||||
|
super(new RtmpHeader(RtmpHeader.ChunkType.TYPE_0_FULL, ChunkStreamInfo.RTMP_COMMAND_CHANNEL, RtmpHeader.MessageType.DATA_AMF0));
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setType(String type) {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void readBody(InputStream in) throws IOException {
|
||||||
|
// Read notification type
|
||||||
|
type = AmfString.readStringFrom(in, false);
|
||||||
|
int bytesRead = AmfString.sizeOf(type, false);
|
||||||
|
// Read data body
|
||||||
|
readVariableData(in, bytesRead);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method is public for Data to make it easy to dump its contents to
|
||||||
|
* another output stream
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void writeBody(OutputStream out) throws IOException {
|
||||||
|
AmfString.writeStringTo(out, type, false);
|
||||||
|
writeVariableData(out);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,221 @@
|
|||||||
|
package net.ossrs.sea.rtmp.packets;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.util.Random;
|
||||||
|
import android.util.Log;
|
||||||
|
import net.ossrs.sea.rtmp.Crypto;
|
||||||
|
import net.ossrs.sea.rtmp.Util;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the RTMP handshake song 'n dance
|
||||||
|
*
|
||||||
|
* Thanks to http://thompsonng.blogspot.com/2010/11/rtmp-part-10-handshake.html for some very useful information on
|
||||||
|
* the the hidden "features" of the RTMP handshake
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public final class Handshake {
|
||||||
|
private static final String TAG = "Handshake";
|
||||||
|
/** S1 as sent by the server */
|
||||||
|
private byte[] s1;
|
||||||
|
private static final int PROTOCOL_VERSION = 0x03;
|
||||||
|
private static final int HANDSHAKE_SIZE = 1536;
|
||||||
|
private static final int SHA256_DIGEST_SIZE = 32;
|
||||||
|
|
||||||
|
private static final int DIGEST_OFFSET_INDICATOR_POS = 772; // should either be byte 772 or byte 8
|
||||||
|
|
||||||
|
private static final byte[] GENUINE_FP_KEY = {
|
||||||
|
(byte) 0x47, (byte) 0x65, (byte) 0x6E, (byte) 0x75, (byte) 0x69, (byte) 0x6E, (byte) 0x65, (byte) 0x20,
|
||||||
|
(byte) 0x41, (byte) 0x64, (byte) 0x6F, (byte) 0x62, (byte) 0x65, (byte) 0x20, (byte) 0x46, (byte) 0x6C,
|
||||||
|
(byte) 0x61, (byte) 0x73, (byte) 0x68, (byte) 0x20, (byte) 0x50, (byte) 0x6C, (byte) 0x61, (byte) 0x79,
|
||||||
|
(byte) 0x65, (byte) 0x72, (byte) 0x20, (byte) 0x30, (byte) 0x30, (byte) 0x31, // Genuine Adobe Flash Player 001
|
||||||
|
(byte) 0xF0, (byte) 0xEE, (byte) 0xC2, (byte) 0x4A, (byte) 0x80, (byte) 0x68, (byte) 0xBE, (byte) 0xE8,
|
||||||
|
(byte) 0x2E, (byte) 0x00, (byte) 0xD0, (byte) 0xD1, (byte) 0x02, (byte) 0x9E, (byte) 0x7E, (byte) 0x57,
|
||||||
|
(byte) 0x6E, (byte) 0xEC, (byte) 0x5D, (byte) 0x2D, (byte) 0x29, (byte) 0x80, (byte) 0x6F, (byte) 0xAB,
|
||||||
|
(byte) 0x93, (byte) 0xB8, (byte) 0xE6, (byte) 0x36, (byte) 0xCF, (byte) 0xEB, (byte) 0x31, (byte) 0xAE};
|
||||||
|
|
||||||
|
/** Generates and writes the first handshake packet (C0) */
|
||||||
|
public final void writeC0(OutputStream out) throws IOException {
|
||||||
|
Log.d(TAG, "writeC0");
|
||||||
|
out.write(PROTOCOL_VERSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
public final void readS0(InputStream in) throws IOException {
|
||||||
|
Log.d(TAG, "readS0");
|
||||||
|
byte s0 = (byte) in.read();
|
||||||
|
if (s0 != PROTOCOL_VERSION) {
|
||||||
|
if (s0 == -1) {
|
||||||
|
throw new IOException("InputStream closed");
|
||||||
|
} else {
|
||||||
|
throw new IOException("Invalid RTMP protocol version; expected " + PROTOCOL_VERSION + ", got " + s0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generates and writes the second handshake packet (C1) */
|
||||||
|
public final void writeC1(OutputStream out) throws IOException {
|
||||||
|
Log.d(TAG, "writeC1");
|
||||||
|
// Util.writeUnsignedInt32(out, (int) (System.currentTimeMillis() / 1000)); // Bytes 0 - 3 bytes: current epoch (timestamp)
|
||||||
|
//out.write(new byte[]{0x09, 0x00, 0x7c, 0x02}); // Bytes 4 - 7: Flash player version: 9.0.124.2
|
||||||
|
|
||||||
|
// out.write(new byte[]{(byte) 0x80, 0x00, 0x07, 0x02}); // Bytes 4 - 7: Flash player version: 11.2.202.233
|
||||||
|
|
||||||
|
|
||||||
|
Log.d(TAG, "writeC1(): Calculating digest offset");
|
||||||
|
Random random = new Random();
|
||||||
|
// Since we are faking a real Flash Player handshake, include a digest in C1
|
||||||
|
// Choose digest offset point (scheme 1; that is, offset is indicated by bytes 772 - 775 (4 bytes) )
|
||||||
|
final int digestOffset = random.nextInt(HANDSHAKE_SIZE - DIGEST_OFFSET_INDICATOR_POS - 4 - 8 - SHA256_DIGEST_SIZE); //random.nextInt(DIGEST_OFFSET_INDICATOR_POS - SHA256_DIGEST_SIZE);
|
||||||
|
|
||||||
|
final int absoluteDigestOffset = ((digestOffset % 728) + DIGEST_OFFSET_INDICATOR_POS + 4);
|
||||||
|
Log.d(TAG, "writeC1(): (real value of) digestOffset: " + digestOffset);
|
||||||
|
|
||||||
|
|
||||||
|
Log.d(TAG, "writeC1(): recalculated digestOffset: " + absoluteDigestOffset);
|
||||||
|
|
||||||
|
int remaining = digestOffset;
|
||||||
|
final byte[] digestOffsetBytes = new byte[4];
|
||||||
|
for (int i = 3; i >= 0; i--) {
|
||||||
|
if (remaining > 255) {
|
||||||
|
digestOffsetBytes[i] = (byte)255;
|
||||||
|
remaining -= 255;
|
||||||
|
} else {
|
||||||
|
digestOffsetBytes[i] = (byte)remaining;
|
||||||
|
remaining -= remaining;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Calculate the offset value that will be written
|
||||||
|
//inal byte[] digestOffsetBytes = Util.unsignedInt32ToByteArray(digestOffset);// //((digestOffset - DIGEST_OFFSET_INDICATOR_POS) % 728)); // Thanks to librtmp for the mod 728
|
||||||
|
Log.d(TAG, "writeC1(): digestOffsetBytes: " + Util.toHexString(digestOffsetBytes)); //Util.unsignedInt32ToByteArray((digestOffset % 728))));
|
||||||
|
|
||||||
|
// Create random bytes up to the digest offset point
|
||||||
|
byte[] partBeforeDigest = new byte[absoluteDigestOffset];
|
||||||
|
Log.d(TAG, "partBeforeDigest(): size: " + partBeforeDigest.length);
|
||||||
|
random.nextBytes(partBeforeDigest);
|
||||||
|
|
||||||
|
Log.d(TAG, "writeC1(): Writing timestamp and Flash Player version");
|
||||||
|
byte[] timeStamp = Util.unsignedInt32ToByteArray((int) (System.currentTimeMillis() / 1000));
|
||||||
|
System.arraycopy(timeStamp, 0, partBeforeDigest, 0, 4); // Bytes 0 - 3 bytes: current epoch timestamp
|
||||||
|
System.arraycopy(new byte[]{(byte) 0x80, 0x00, 0x07, 0x02}, 0, partBeforeDigest, 4, 4); // Bytes 4 - 7: Flash player version: 11.2.202.233
|
||||||
|
|
||||||
|
// Create random bytes for the part after the digest
|
||||||
|
byte[] partAfterDigest = new byte[HANDSHAKE_SIZE - absoluteDigestOffset - SHA256_DIGEST_SIZE]; // subtract 8 because of initial 8 bytes already written
|
||||||
|
Log.d(TAG, "partAfterDigest(): size: " + partAfterDigest.length);
|
||||||
|
random.nextBytes(partAfterDigest);
|
||||||
|
|
||||||
|
|
||||||
|
// Set the offset byte
|
||||||
|
// if (digestOffset > 772) {
|
||||||
|
Log.d(TAG, "copying digest offset bytes in partBeforeDigest");
|
||||||
|
System.arraycopy(digestOffsetBytes, 0, partBeforeDigest, 772, 4);
|
||||||
|
// } else {
|
||||||
|
// Implied offset of partAfterDigest is digestOffset + 32
|
||||||
|
/// Log.d(TAG, "copying digest offset bytes in partAfterDigest");
|
||||||
|
/// Log.d(TAG, " writing to location: " + (DIGEST_OFFSET_INDICATOR_POS - digestOffset - SHA256_DIGEST_SIZE - 8));
|
||||||
|
// System.arraycopy(digestOffsetBytes, 0, partAfterDigest, (DIGEST_OFFSET_INDICATOR_POS - digestOffset - SHA256_DIGEST_SIZE - 8), 4);
|
||||||
|
// }
|
||||||
|
|
||||||
|
Log.d(TAG, "writeC1(): Calculating digest");
|
||||||
|
byte[] tempBuffer = new byte[HANDSHAKE_SIZE - SHA256_DIGEST_SIZE];
|
||||||
|
System.arraycopy(partBeforeDigest, 0, tempBuffer, 0, partBeforeDigest.length);
|
||||||
|
System.arraycopy(partAfterDigest, 0, tempBuffer, partBeforeDigest.length, partAfterDigest.length);
|
||||||
|
|
||||||
|
Crypto crypto = new Crypto();
|
||||||
|
byte[] digest = crypto.calculateHmacSHA256(tempBuffer, GENUINE_FP_KEY, 30);
|
||||||
|
|
||||||
|
// Now write the packet
|
||||||
|
Log.d(TAG, "writeC1(): writing C1 packet");
|
||||||
|
out.write(partBeforeDigest);
|
||||||
|
out.write(digest);
|
||||||
|
out.write(partAfterDigest);
|
||||||
|
}
|
||||||
|
|
||||||
|
public final void readS1(InputStream in) throws IOException {
|
||||||
|
// S1 == 1536 bytes. We do not bother with checking the content of it
|
||||||
|
Log.d(TAG, "readS1");
|
||||||
|
s1 = new byte[HANDSHAKE_SIZE];
|
||||||
|
|
||||||
|
// Read server time (4 bytes)
|
||||||
|
int totalBytesRead = 0;
|
||||||
|
int read;
|
||||||
|
do {
|
||||||
|
read = in.read(s1, totalBytesRead, (HANDSHAKE_SIZE - totalBytesRead));
|
||||||
|
if (read != -1) {
|
||||||
|
totalBytesRead += read;
|
||||||
|
}
|
||||||
|
} while (totalBytesRead < HANDSHAKE_SIZE);
|
||||||
|
|
||||||
|
if (totalBytesRead != HANDSHAKE_SIZE) {
|
||||||
|
throw new IOException("Unexpected EOF while reading S1, expected " + HANDSHAKE_SIZE + " bytes, but only read " + totalBytesRead + " bytes");
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "readS1(): S1 total bytes read OK");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generates and writes the third handshake packet (C2) */
|
||||||
|
public final void writeC2(OutputStream out) throws IOException {
|
||||||
|
Log.d(TAG, "readC2");
|
||||||
|
// C2 is an echo of S1
|
||||||
|
if (s1 == null) {
|
||||||
|
throw new IllegalStateException("C2 cannot be written without S1 being read first");
|
||||||
|
}
|
||||||
|
out.write(s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public final void readS2(InputStream in) throws IOException {
|
||||||
|
// S2 should be an echo of C1, but we are not too strict
|
||||||
|
Log.d(TAG, "readS2");
|
||||||
|
byte[] sr_serverTime = new byte[4];
|
||||||
|
byte[] s2_serverVersion = new byte[4];
|
||||||
|
byte[] s2_rest = new byte[HANDSHAKE_SIZE - 8]; // subtract 4+4 bytes for time and version
|
||||||
|
|
||||||
|
// Read server time (4 bytes)
|
||||||
|
int totalBytesRead = 0;
|
||||||
|
int read;
|
||||||
|
do {
|
||||||
|
read = in.read(sr_serverTime, totalBytesRead, (4 - totalBytesRead));
|
||||||
|
if (read == -1) {
|
||||||
|
// End of stream reached - should not have happened at this point
|
||||||
|
throw new IOException("Unexpected EOF while reading S2 bytes 0-3");
|
||||||
|
} else {
|
||||||
|
totalBytesRead += read;
|
||||||
|
}
|
||||||
|
} while (totalBytesRead < 4);
|
||||||
|
|
||||||
|
// Read server version (4 bytes)
|
||||||
|
totalBytesRead = 0;
|
||||||
|
do {
|
||||||
|
read = in.read(s2_serverVersion, totalBytesRead, (4 - totalBytesRead));
|
||||||
|
if (read == -1) {
|
||||||
|
// End of stream reached - should not have happened at this point
|
||||||
|
throw new IOException("Unexpected EOF while reading S2 bytes 4-7");
|
||||||
|
} else {
|
||||||
|
totalBytesRead += read;
|
||||||
|
}
|
||||||
|
} while (totalBytesRead < 4);
|
||||||
|
|
||||||
|
// Read 1528 bytes (to make up S1 total size of 1536 bytes)
|
||||||
|
final int remainingBytes = HANDSHAKE_SIZE - 8;
|
||||||
|
totalBytesRead = 0;
|
||||||
|
do {
|
||||||
|
read = in.read(s2_rest, totalBytesRead, (remainingBytes - totalBytesRead));
|
||||||
|
if (read != -1) {
|
||||||
|
totalBytesRead += read;
|
||||||
|
}
|
||||||
|
} while (totalBytesRead < remainingBytes && read != -1);
|
||||||
|
|
||||||
|
if (totalBytesRead != remainingBytes) {
|
||||||
|
throw new IOException("Unexpected EOF while reading remainder of S2, expected " + remainingBytes + " bytes, but only read " + totalBytesRead + " bytes");
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "readS2(): S2 total bytes read OK");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Technically we should check that S2 == C1, but for now this is ignored
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,431 @@
|
|||||||
|
/*
|
||||||
|
* To change this template, choose Tools | Templates
|
||||||
|
* and open the template in the editor.
|
||||||
|
*/
|
||||||
|
package net.ossrs.sea.rtmp.packets;
|
||||||
|
|
||||||
|
import java.io.EOFException;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import android.util.Log;
|
||||||
|
import net.ossrs.sea.rtmp.Util;
|
||||||
|
import net.ossrs.sea.rtmp.io.ChunkStreamInfo;
|
||||||
|
import net.ossrs.sea.rtmp.io.RtmpSessionInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class RtmpHeader {
|
||||||
|
|
||||||
|
private static final String TAG = "RtmpHeader";
|
||||||
|
/**
|
||||||
|
* RTMP packet/message type definitions.
|
||||||
|
* Note: docstrings are adapted from the official Adobe RTMP spec:
|
||||||
|
* http://www.adobe.com/devnet/rtmp/
|
||||||
|
*/
|
||||||
|
public static enum MessageType {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protocol control message 1
|
||||||
|
* Set Chunk Size, is used to notify the peer a new maximum chunk size to use.
|
||||||
|
*/
|
||||||
|
SET_CHUNK_SIZE(0x01),
|
||||||
|
/**
|
||||||
|
* Protocol control message 2
|
||||||
|
* Abort Message, is used to notify the peer if it is waiting for chunks
|
||||||
|
* to complete a message, then to discard the partially received message
|
||||||
|
* over a chunk stream and abort processing of that message.
|
||||||
|
*/
|
||||||
|
ABORT(0x02),
|
||||||
|
/**
|
||||||
|
* Protocol control message 3
|
||||||
|
* The client or the server sends the acknowledgment to the peer after
|
||||||
|
* receiving bytes equal to the window size. The window size is the
|
||||||
|
* maximum number of bytes that the sender sends without receiving
|
||||||
|
* acknowledgment from the receiver.
|
||||||
|
*/
|
||||||
|
ACKNOWLEDGEMENT(0x03),
|
||||||
|
/**
|
||||||
|
* Protocol control message 4
|
||||||
|
* The client or the server sends this message to notify the peer about
|
||||||
|
* the user control events. This message carries Event type and Event
|
||||||
|
* data.
|
||||||
|
* Also known as a PING message in some RTMP implementations.
|
||||||
|
*/
|
||||||
|
USER_CONTROL_MESSAGE(0x04),
|
||||||
|
/**
|
||||||
|
* Protocol control message 5
|
||||||
|
* The client or the server sends this message to inform the peer which
|
||||||
|
* window size to use when sending acknowledgment.
|
||||||
|
* Also known as ServerBW ("server bandwidth") in some RTMP implementations.
|
||||||
|
*/
|
||||||
|
WINDOW_ACKNOWLEDGEMENT_SIZE(0x05),
|
||||||
|
/**
|
||||||
|
* Protocol control message 6
|
||||||
|
* The client or the server sends this message to update the output
|
||||||
|
* bandwidth of the peer. The output bandwidth value is the same as the
|
||||||
|
* window size for the peer.
|
||||||
|
* Also known as ClientBW ("client bandwidth") in some RTMP implementations.
|
||||||
|
*/
|
||||||
|
SET_PEER_BANDWIDTH(0x06),
|
||||||
|
/**
|
||||||
|
* RTMP audio packet (0x08)
|
||||||
|
* The client or the server sends this message to send audio data to the peer.
|
||||||
|
*/
|
||||||
|
AUDIO(0x08),
|
||||||
|
/**
|
||||||
|
* RTMP video packet (0x09)
|
||||||
|
* The client or the server sends this message to send video data to the peer.
|
||||||
|
*/
|
||||||
|
VIDEO(0x09),
|
||||||
|
/**
|
||||||
|
* RTMP message type 0x0F
|
||||||
|
* The client or the server sends this message to send Metadata or any
|
||||||
|
* user data to the peer. Metadata includes details about the data (audio, video etc.)
|
||||||
|
* like creation time, duration, theme and so on.
|
||||||
|
* This is the AMF3-encoded version.
|
||||||
|
*/
|
||||||
|
DATA_AMF3(0x0F),
|
||||||
|
/**
|
||||||
|
* RTMP message type 0x10
|
||||||
|
* A shared object is a Flash object (a collection of name value pairs)
|
||||||
|
* that are in synchronization across multiple clients, instances, and
|
||||||
|
* so on.
|
||||||
|
* This is the AMF3 version: kMsgContainerEx=16 for AMF3.
|
||||||
|
*/
|
||||||
|
SHARED_OBJECT_AMF3(0x10),
|
||||||
|
/**
|
||||||
|
* RTMP message type 0x11
|
||||||
|
* Command messages carry the AMF-encoded commands between the client
|
||||||
|
* and the server.
|
||||||
|
* A command message consists of command name, transaction ID, and command object that
|
||||||
|
* contains related parameters.
|
||||||
|
* This is the AMF3-encoded version.
|
||||||
|
*/
|
||||||
|
COMMAND_AMF3(0x11),
|
||||||
|
/**
|
||||||
|
* RTMP message type 0x12
|
||||||
|
* The client or the server sends this message to send Metadata or any
|
||||||
|
* user data to the peer. Metadata includes details about the data (audio, video etc.)
|
||||||
|
* like creation time, duration, theme and so on.
|
||||||
|
* This is the AMF0-encoded version.
|
||||||
|
*/
|
||||||
|
DATA_AMF0(0x12),
|
||||||
|
/**
|
||||||
|
* RTMP message type 0x14
|
||||||
|
* Command messages carry the AMF-encoded commands between the client
|
||||||
|
* and the server.
|
||||||
|
* A command message consists of command name, transaction ID, and command object that
|
||||||
|
* contains related parameters.
|
||||||
|
* This is the common AMF0 version, also known as INVOKE in some RTMP implementations.
|
||||||
|
*/
|
||||||
|
COMMAND_AMF0(0x14),
|
||||||
|
/**
|
||||||
|
* RTMP message type 0x13
|
||||||
|
* A shared object is a Flash object (a collection of name value pairs)
|
||||||
|
* that are in synchronization across multiple clients, instances, and
|
||||||
|
* so on.
|
||||||
|
* This is the AMF0 version: kMsgContainer=19 for AMF0.
|
||||||
|
*/
|
||||||
|
SHARED_OBJECT_AMF0(0x13),
|
||||||
|
/**
|
||||||
|
* RTMP message type 0x16
|
||||||
|
* An aggregate message is a single message that contains a list of sub-messages.
|
||||||
|
*/
|
||||||
|
AGGREGATE_MESSAGE(0x16);
|
||||||
|
private byte value;
|
||||||
|
private static final Map<Byte, MessageType> quickLookupMap = new HashMap<Byte, MessageType>();
|
||||||
|
|
||||||
|
static {
|
||||||
|
for (MessageType messageTypId : MessageType.values()) {
|
||||||
|
quickLookupMap.put(messageTypId.getValue(), messageTypId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private MessageType(int value) {
|
||||||
|
this.value = (byte) value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the value of this chunk type */
|
||||||
|
public byte getValue() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MessageType valueOf(byte messageTypeId) {
|
||||||
|
if (quickLookupMap.containsKey(messageTypeId)) {
|
||||||
|
return quickLookupMap.get(messageTypeId);
|
||||||
|
} else {
|
||||||
|
throw new IllegalArgumentException("Unknown message type byte: " + Util.toHexString(messageTypeId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static enum ChunkType {
|
||||||
|
|
||||||
|
/** Full 12-byte RTMP chunk header */
|
||||||
|
TYPE_0_FULL(0x00, 12),
|
||||||
|
/** Relative 8-byte RTMP chunk header (message stream ID is not included) */
|
||||||
|
TYPE_1_RELATIVE_LARGE(0x01, 8),
|
||||||
|
/** Relative 4-byte RTMP chunk header (only timestamp delta) */
|
||||||
|
TYPE_2_RELATIVE_TIMESTAMP_ONLY(0x02, 4),
|
||||||
|
/** Relative 1-byte RTMP chunk header (no "real" header, just the 1-byte indicating chunk header type & chunk stream ID) */
|
||||||
|
TYPE_3_RELATIVE_SINGLE_BYTE(0x03, 1);
|
||||||
|
/** The byte value of this chunk header type */
|
||||||
|
private byte value;
|
||||||
|
/** The full size (in bytes) of this RTMP header (including the basic header byte) */
|
||||||
|
private int size;
|
||||||
|
private static final Map<Byte, ChunkType> quickLookupMap = new HashMap<Byte, ChunkType>();
|
||||||
|
|
||||||
|
static {
|
||||||
|
for (ChunkType messageTypId : ChunkType.values()) {
|
||||||
|
quickLookupMap.put(messageTypId.getValue(), messageTypId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChunkType(int byteValue, int fullHeaderSize) {
|
||||||
|
this.value = (byte) byteValue;
|
||||||
|
this.size = fullHeaderSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the byte value of this chunk header type */
|
||||||
|
public byte getValue() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getSize() {
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ChunkType valueOf(byte chunkHeaderType) {
|
||||||
|
if (quickLookupMap.containsKey(chunkHeaderType)) {
|
||||||
|
return quickLookupMap.get(chunkHeaderType);
|
||||||
|
} else {
|
||||||
|
throw new IllegalArgumentException("Unknown chunk header type byte: " + Util.toHexString(chunkHeaderType));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private ChunkType chunkType;
|
||||||
|
private int chunkStreamId;
|
||||||
|
private int absoluteTimestamp;
|
||||||
|
private int timestampDelta = -1;
|
||||||
|
private int packetLength;
|
||||||
|
private MessageType messageType;
|
||||||
|
private int messageStreamId;
|
||||||
|
|
||||||
|
public RtmpHeader() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public RtmpHeader(ChunkType chunkType, int chunkStreamId, MessageType messageType) {
|
||||||
|
this.chunkType = chunkType;
|
||||||
|
this.chunkStreamId = chunkStreamId;
|
||||||
|
this.messageType = messageType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RtmpHeader readHeader(InputStream in, RtmpSessionInfo rtmpSessionInfo) throws IOException {
|
||||||
|
RtmpHeader rtmpHeader = new RtmpHeader();
|
||||||
|
rtmpHeader.readHeaderImpl(in, rtmpSessionInfo);
|
||||||
|
return rtmpHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void readHeaderImpl(InputStream in, RtmpSessionInfo rtmpSessionInfo) throws IOException {
|
||||||
|
|
||||||
|
int basicHeaderByte = in.read();
|
||||||
|
if (basicHeaderByte == -1) {
|
||||||
|
throw new EOFException("Unexpected EOF while reading RTMP packet basic header");
|
||||||
|
}
|
||||||
|
// Read byte 0: chunk type and chunk stream ID
|
||||||
|
parseBasicHeader((byte) basicHeaderByte);
|
||||||
|
|
||||||
|
switch (chunkType) {
|
||||||
|
case TYPE_0_FULL: { // b00 = 12 byte header (full header)
|
||||||
|
// Read bytes 1-3: Absolute timestamp
|
||||||
|
absoluteTimestamp = Util.readUnsignedInt24(in);
|
||||||
|
timestampDelta = 0;
|
||||||
|
// Read bytes 4-6: Packet length
|
||||||
|
packetLength = Util.readUnsignedInt24(in);
|
||||||
|
// Read byte 7: Message type ID
|
||||||
|
messageType = MessageType.valueOf((byte) in.read());
|
||||||
|
// Read bytes 8-11: Message stream ID (apparently little-endian order)
|
||||||
|
byte[] messageStreamIdBytes = new byte[4];
|
||||||
|
Util.readBytesUntilFull(in, messageStreamIdBytes);
|
||||||
|
messageStreamId = Util.toUnsignedInt32LittleEndian(messageStreamIdBytes);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case TYPE_1_RELATIVE_LARGE: { // b01 = 8 bytes - like type 0. not including message stream ID (4 last bytes)
|
||||||
|
// Read bytes 1-3: Timestamp delta
|
||||||
|
timestampDelta = Util.readUnsignedInt24(in);
|
||||||
|
// Read bytes 4-6: Packet length
|
||||||
|
packetLength = Util.readUnsignedInt24(in);
|
||||||
|
// Read byte 7: Message type ID
|
||||||
|
messageType = MessageType.valueOf((byte) in.read());
|
||||||
|
RtmpHeader prevHeader = rtmpSessionInfo.getChunkStreamInfo(chunkStreamId).prevHeaderRx();
|
||||||
|
try {
|
||||||
|
messageStreamId = prevHeader.messageStreamId;
|
||||||
|
absoluteTimestamp = prevHeader.absoluteTimestamp + timestampDelta;
|
||||||
|
} catch (NullPointerException ex) {
|
||||||
|
messageStreamId = 0;
|
||||||
|
absoluteTimestamp = timestampDelta;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case TYPE_2_RELATIVE_TIMESTAMP_ONLY: { // b10 = 4 bytes - Basic Header and timestamp (3 bytes) are included
|
||||||
|
// Read bytes 1-3: Timestamp delta
|
||||||
|
timestampDelta = Util.readUnsignedInt24(in);
|
||||||
|
RtmpHeader prevHeader = rtmpSessionInfo.getChunkStreamInfo(chunkStreamId).prevHeaderRx();
|
||||||
|
packetLength = prevHeader.packetLength;
|
||||||
|
messageType = prevHeader.messageType;
|
||||||
|
messageStreamId = prevHeader.messageStreamId;
|
||||||
|
absoluteTimestamp = prevHeader.absoluteTimestamp + timestampDelta;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case TYPE_3_RELATIVE_SINGLE_BYTE: { // b11 = 1 byte: basic header only
|
||||||
|
RtmpHeader prevHeader = rtmpSessionInfo.getChunkStreamInfo(chunkStreamId).prevHeaderRx();
|
||||||
|
timestampDelta = prevHeader.timestampDelta;
|
||||||
|
absoluteTimestamp = prevHeader.absoluteTimestamp + timestampDelta;
|
||||||
|
packetLength = prevHeader.packetLength;
|
||||||
|
messageType = prevHeader.messageType;
|
||||||
|
messageStreamId = prevHeader.messageStreamId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
Log.e(TAG, "readHeaderImpl(): Invalid chunk type; basic header byte was: " + Util.toHexString((byte) basicHeaderByte));
|
||||||
|
throw new IOException("Invalid chunk type; basic header byte was: " + Util.toHexString((byte) basicHeaderByte));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void writeTo(OutputStream out, final ChunkStreamInfo chunkStreamInfo) throws IOException {
|
||||||
|
// Write basic header byte
|
||||||
|
out.write(((byte) (chunkType.getValue() << 6) | chunkStreamId));
|
||||||
|
switch (chunkType) {
|
||||||
|
case TYPE_0_FULL: { // b00 = 12 byte header (full header)
|
||||||
|
chunkStreamInfo.markRealAbsoluteTimestampTx();
|
||||||
|
Util.writeUnsignedInt24(out, absoluteTimestamp);
|
||||||
|
Util.writeUnsignedInt24(out, packetLength);
|
||||||
|
out.write(messageType.getValue());
|
||||||
|
Util.writeUnsignedInt32LittleEndian(out, messageStreamId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case TYPE_1_RELATIVE_LARGE: { // b01 = 8 bytes - like type 0. not including message ID (4 last bytes)
|
||||||
|
if (timestampDelta == -1) {
|
||||||
|
timestampDelta = (int) chunkStreamInfo.markRealAbsoluteTimestampTx();
|
||||||
|
}
|
||||||
|
absoluteTimestamp = chunkStreamInfo.getPrevHeaderTx().getAbsoluteTimestamp() + timestampDelta;
|
||||||
|
Util.writeUnsignedInt24(out, timestampDelta);
|
||||||
|
Util.writeUnsignedInt24(out, packetLength);
|
||||||
|
out.write(messageType.getValue());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case TYPE_2_RELATIVE_TIMESTAMP_ONLY: { // b10 = 4 bytes - Basic Header and timestamp (3 bytes) are included
|
||||||
|
if (timestampDelta == -1) {
|
||||||
|
timestampDelta = (int) chunkStreamInfo.markRealAbsoluteTimestampTx();
|
||||||
|
}
|
||||||
|
absoluteTimestamp = chunkStreamInfo.getPrevHeaderTx().getAbsoluteTimestamp() + timestampDelta;
|
||||||
|
Util.writeUnsignedInt24(out, timestampDelta);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case TYPE_3_RELATIVE_SINGLE_BYTE: { // b11 = 1 byte: basic header only
|
||||||
|
if (timestampDelta == -1) {
|
||||||
|
timestampDelta = (int) chunkStreamInfo.markRealAbsoluteTimestampTx();
|
||||||
|
}
|
||||||
|
absoluteTimestamp = chunkStreamInfo.getPrevHeaderTx().getAbsoluteTimestamp() + timestampDelta;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new IOException("Invalid chunk type: " + chunkType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void parseBasicHeader(byte basicHeaderByte) {
|
||||||
|
chunkType = ChunkType.valueOf((byte) ((0xff & basicHeaderByte) >>> 6)); // 2 most significant bits define the chunk type
|
||||||
|
chunkStreamId = basicHeaderByte & 0x3F; // 6 least significant bits define chunk stream ID
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the RTMP chunk stream ID (channel ID) for this chunk */
|
||||||
|
public int getChunkStreamId() {
|
||||||
|
return chunkStreamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChunkType getChunkType() {
|
||||||
|
return chunkType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPacketLength() {
|
||||||
|
return packetLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getMessageStreamId() {
|
||||||
|
return messageStreamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MessageType getMessageType() {
|
||||||
|
return messageType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getAbsoluteTimestamp() {
|
||||||
|
return absoluteTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAbsoluteTimestamp(int absoluteTimestamp) {
|
||||||
|
this.absoluteTimestamp = absoluteTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getTimestampDelta() {
|
||||||
|
return timestampDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimestampDelta(int timestampDelta) {
|
||||||
|
this.timestampDelta = timestampDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// /** Get the timestamp as specified by the server */
|
||||||
|
// public int getTimestamp() {
|
||||||
|
// return timestamp;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// /** Calculate and return the timestamp delta relative to START_TIMESTAMP */
|
||||||
|
// public int getTimestampDelta() {
|
||||||
|
// return (int) System.currentTimeMillis() - START_TIMESTAMP;
|
||||||
|
// }
|
||||||
|
/** Sets the RTMP chunk stream ID (channel ID) for this chunk */
|
||||||
|
public void setChunkStreamId(int channelId) {
|
||||||
|
this.chunkStreamId = channelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChunkType(ChunkType chunkType) {
|
||||||
|
this.chunkType = chunkType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMessageStreamId(int messageStreamId) {
|
||||||
|
this.messageStreamId = messageStreamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMessageType(MessageType messageType) {
|
||||||
|
this.messageType = messageType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPacketLength(int packetLength) {
|
||||||
|
this.packetLength = packetLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
// public void initStartTimeStamp() {
|
||||||
|
// if (START_TIMESTAMP == -1) {
|
||||||
|
// START_TIMESTAMP = (int) System.currentTimeMillis();
|
||||||
|
// }
|
||||||
|
// timestamp = 0;
|
||||||
|
// }
|
||||||
|
public void writeAggregateHeaderByte(OutputStream out) throws IOException {
|
||||||
|
// Aggregate header 0x11 : 11.. ....
|
||||||
|
out.write(0xC0 | chunkStreamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void writeAggregateHeaderByte(OutputStream out, int chunkStreamId) throws IOException {
|
||||||
|
// Aggregate header 0x11 : 11.. ....
|
||||||
|
out.write(0xC0 | chunkStreamId);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
package net.ossrs.sea.rtmp.packets;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import net.ossrs.sea.rtmp.io.ChunkStreamInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public abstract class RtmpPacket {
|
||||||
|
|
||||||
|
protected RtmpHeader header;
|
||||||
|
|
||||||
|
public RtmpPacket(RtmpHeader header) {
|
||||||
|
this.header = header;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RtmpHeader getHeader() {
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract void readBody(InputStream in) throws IOException;
|
||||||
|
|
||||||
|
protected abstract void writeBody(OutputStream out) throws IOException;
|
||||||
|
|
||||||
|
public void writeTo(OutputStream out, final int chunkSize, final ChunkStreamInfo chunkStreamInfo) throws IOException {
|
||||||
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
writeBody(baos);
|
||||||
|
byte[] body = baos.toByteArray();
|
||||||
|
header.setPacketLength(body.length);
|
||||||
|
// Write header for first chunk
|
||||||
|
header.writeTo(out, chunkStreamInfo);
|
||||||
|
int remainingBytes = body.length;
|
||||||
|
int pos = 0;
|
||||||
|
while (remainingBytes > chunkSize) {
|
||||||
|
// Write packet for chunk
|
||||||
|
out.write(body, pos, chunkSize);
|
||||||
|
remainingBytes -= chunkSize;
|
||||||
|
pos += chunkSize;
|
||||||
|
// Write header for remain chunk
|
||||||
|
header.writeAggregateHeaderByte(out);
|
||||||
|
}
|
||||||
|
out.write(body, pos, remainingBytes);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,45 @@
|
|||||||
|
package net.ossrs.sea.rtmp.packets;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import net.ossrs.sea.rtmp.Util;
|
||||||
|
import net.ossrs.sea.rtmp.io.ChunkStreamInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A "Set chunk size" RTMP message, received on chunk stream ID 2 (control channel)
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class SetChunkSize extends RtmpPacket {
|
||||||
|
|
||||||
|
private int chunkSize;
|
||||||
|
|
||||||
|
public SetChunkSize(RtmpHeader header) {
|
||||||
|
super(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SetChunkSize(int chunkSize) {
|
||||||
|
super(new RtmpHeader(RtmpHeader.ChunkType.TYPE_1_RELATIVE_LARGE, ChunkStreamInfo.RTMP_CONTROL_CHANNEL, RtmpHeader.MessageType.SET_CHUNK_SIZE));
|
||||||
|
this.chunkSize = chunkSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getChunkSize() {
|
||||||
|
return chunkSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChunkSize(int chunkSize) {
|
||||||
|
this.chunkSize = chunkSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void readBody(InputStream in) throws IOException {
|
||||||
|
// Value is received in the 4 bytes of the body
|
||||||
|
chunkSize = Util.readUnsignedInt32(in);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void writeBody(OutputStream out) throws IOException {
|
||||||
|
Util.writeUnsignedInt32(out, chunkSize);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,104 @@
|
|||||||
|
package net.ossrs.sea.rtmp.packets;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import net.ossrs.sea.rtmp.Util;
|
||||||
|
import net.ossrs.sea.rtmp.io.ChunkStreamInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set Peer Bandwidth
|
||||||
|
*
|
||||||
|
* Also known as ClientrBW ("client bandwidth") in some RTMP implementations.
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class SetPeerBandwidth extends RtmpPacket {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bandwidth limiting type
|
||||||
|
*/
|
||||||
|
public static enum LimitType {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In a hard (0) request, the peer must send the data in the provided bandwidth.
|
||||||
|
*/
|
||||||
|
HARD(0),
|
||||||
|
/**
|
||||||
|
* In a soft (1) request, the bandwidth is at the discretion of the peer
|
||||||
|
* and the sender can limit the bandwidth.
|
||||||
|
*/
|
||||||
|
SOFT(1),
|
||||||
|
/**
|
||||||
|
* In a dynamic (2) request, the bandwidth can be hard or soft.
|
||||||
|
*/
|
||||||
|
DYNAMIC(2);
|
||||||
|
private int intValue;
|
||||||
|
private static final Map<Integer, LimitType> quickLookupMap = new HashMap<Integer, LimitType>();
|
||||||
|
|
||||||
|
static {
|
||||||
|
for (LimitType type : LimitType.values()) {
|
||||||
|
quickLookupMap.put(type.getIntValue(), type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private LimitType(int intValue) {
|
||||||
|
this.intValue = intValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getIntValue() {
|
||||||
|
return intValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LimitType valueOf(int intValue) {
|
||||||
|
return quickLookupMap.get(intValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private int acknowledgementWindowSize;
|
||||||
|
private LimitType limitType;
|
||||||
|
|
||||||
|
public SetPeerBandwidth(RtmpHeader header) {
|
||||||
|
super(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SetPeerBandwidth(int acknowledgementWindowSize, LimitType limitType, ChunkStreamInfo channelInfo) {
|
||||||
|
super(new RtmpHeader(channelInfo.canReusePrevHeaderTx(RtmpHeader.MessageType.SET_PEER_BANDWIDTH) ? RtmpHeader.ChunkType.TYPE_2_RELATIVE_TIMESTAMP_ONLY : RtmpHeader.ChunkType.TYPE_0_FULL, ChunkStreamInfo.RTMP_CONTROL_CHANNEL, RtmpHeader.MessageType.WINDOW_ACKNOWLEDGEMENT_SIZE));
|
||||||
|
this.acknowledgementWindowSize = acknowledgementWindowSize;
|
||||||
|
this.limitType = limitType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getAcknowledgementWindowSize() {
|
||||||
|
return acknowledgementWindowSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAcknowledgementWindowSize(int acknowledgementWindowSize) {
|
||||||
|
this.acknowledgementWindowSize = acknowledgementWindowSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LimitType getLimitType() {
|
||||||
|
return limitType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLimitType(LimitType limitType) {
|
||||||
|
this.limitType = limitType;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void readBody(InputStream in) throws IOException {
|
||||||
|
acknowledgementWindowSize = Util.readUnsignedInt32(in);
|
||||||
|
limitType = LimitType.valueOf(in.read());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void writeBody(OutputStream out) throws IOException {
|
||||||
|
Util.writeUnsignedInt32(out, acknowledgementWindowSize);
|
||||||
|
out.write(limitType.getIntValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "RTMP Set Peer Bandwidth";
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,241 @@
|
|||||||
|
package net.ossrs.sea.rtmp.packets;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import net.ossrs.sea.rtmp.Util;
|
||||||
|
import net.ossrs.sea.rtmp.io.ChunkStreamInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Control message, such as ping
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class UserControl extends RtmpPacket {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control message type
|
||||||
|
* Docstring adapted from the official Adobe RTMP spec, section 3.7
|
||||||
|
*/
|
||||||
|
public static enum Type {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type: 0
|
||||||
|
* The server sends this event to notify the client that a stream has become
|
||||||
|
* functional and can be used for communication. By default, this event
|
||||||
|
* is sent on ID 0 after the application connect command is successfully
|
||||||
|
* received from the client.
|
||||||
|
*
|
||||||
|
* Event Data:
|
||||||
|
* eventData[0] (int) the stream ID of the stream that became functional
|
||||||
|
*/
|
||||||
|
STREAM_BEGIN(0),
|
||||||
|
/**
|
||||||
|
* Type: 1
|
||||||
|
* The server sends this event to notify the client that the playback of
|
||||||
|
* data is over as requested on this stream. No more data is sent without
|
||||||
|
* issuing additional commands. The client discards the messages received
|
||||||
|
* for the stream.
|
||||||
|
*
|
||||||
|
* Event Data:
|
||||||
|
* eventData[0]: the ID of thestream on which playback has ended.
|
||||||
|
*/
|
||||||
|
STREAM_EOF(1),
|
||||||
|
/**
|
||||||
|
* Type: 2
|
||||||
|
* The server sends this event to notify the client that there is no
|
||||||
|
* more data on the stream. If the server does not detect any message for
|
||||||
|
* a time period, it can notify the subscribed clients that the stream is
|
||||||
|
* dry.
|
||||||
|
*
|
||||||
|
* Event Data:
|
||||||
|
* eventData[0]: the stream ID of the dry stream.
|
||||||
|
*/
|
||||||
|
STREAM_DRY(2),
|
||||||
|
/**
|
||||||
|
* Type: 3
|
||||||
|
* The client sends this event to inform the server of the buffer size
|
||||||
|
* (in milliseconds) that is used to buffer any data coming over a stream.
|
||||||
|
* This event is sent before the server starts processing the stream.
|
||||||
|
*
|
||||||
|
* Event Data:
|
||||||
|
* eventData[0]: the stream ID and
|
||||||
|
* eventData[1]: the buffer length, in milliseconds.
|
||||||
|
*/
|
||||||
|
SET_BUFFER_LENGTH(3),
|
||||||
|
/**
|
||||||
|
* Type: 4
|
||||||
|
* The server sends this event to notify the client that the stream is a
|
||||||
|
* recorded stream.
|
||||||
|
*
|
||||||
|
* Event Data:
|
||||||
|
* eventData[0]: the stream ID of the recorded stream.
|
||||||
|
*/
|
||||||
|
STREAM_IS_RECORDED(4),
|
||||||
|
/**
|
||||||
|
* Type: 6
|
||||||
|
* The server sends this event to test whether the client is reachable.
|
||||||
|
*
|
||||||
|
* Event Data:
|
||||||
|
* eventData[0]: a timestamp representing the local server time when the server dispatched the command.
|
||||||
|
*
|
||||||
|
* The client responds with PING_RESPONSE on receiving PING_REQUEST.
|
||||||
|
*/
|
||||||
|
PING_REQUEST(6),
|
||||||
|
/**
|
||||||
|
* Type: 7
|
||||||
|
* The client sends this event to the server in response to the ping request.
|
||||||
|
*
|
||||||
|
* Event Data:
|
||||||
|
* eventData[0]: the 4-byte timestamp which was received with the PING_REQUEST.
|
||||||
|
*/
|
||||||
|
PONG_REPLY(7),
|
||||||
|
/**
|
||||||
|
* Type: 31 (0x1F)
|
||||||
|
*
|
||||||
|
* This user control type is not specified in any official documentation, but
|
||||||
|
* is sent by Flash Media Server 3.5. Thanks to the rtmpdump devs for their
|
||||||
|
* explanation:
|
||||||
|
*
|
||||||
|
* Buffer Empty (unofficial name): After the server has sent a complete buffer, and
|
||||||
|
* sends this Buffer Empty message, it will wait until the play
|
||||||
|
* duration of that buffer has passed before sending a new buffer.
|
||||||
|
* The Buffer Ready message will be sent when the new buffer starts.
|
||||||
|
*
|
||||||
|
* (see also: http://repo.or.cz/w/rtmpdump.git/blob/8880d1456b282ee79979adbe7b6a6eb8ad371081:/librtmp/rtmp.c#l2787)
|
||||||
|
*/
|
||||||
|
BUFFER_EMPTY(31),
|
||||||
|
/**
|
||||||
|
* Type: 32 (0x20)
|
||||||
|
*
|
||||||
|
* This user control type is not specified in any official documentation, but
|
||||||
|
* is sent by Flash Media Server 3.5. Thanks to the rtmpdump devs for their
|
||||||
|
* explanation:
|
||||||
|
*
|
||||||
|
* Buffer Ready (unofficial name): After the server has sent a complete buffer, and
|
||||||
|
* sends a Buffer Empty message, it will wait until the play
|
||||||
|
* duration of that buffer has passed before sending a new buffer.
|
||||||
|
* The Buffer Ready message will be sent when the new buffer starts.
|
||||||
|
* (There is no BufferReady message for the very first buffer;
|
||||||
|
* presumably the Stream Begin message is sufficient for that
|
||||||
|
* purpose.)
|
||||||
|
*
|
||||||
|
* (see also: http://repo.or.cz/w/rtmpdump.git/blob/8880d1456b282ee79979adbe7b6a6eb8ad371081:/librtmp/rtmp.c#l2787)
|
||||||
|
*/
|
||||||
|
BUFFER_READY(32);
|
||||||
|
|
||||||
|
private int intValue;
|
||||||
|
private static final Map<Integer, Type> quickLookupMap = new HashMap<Integer, Type>();
|
||||||
|
|
||||||
|
static {
|
||||||
|
for (Type type : Type.values()) {
|
||||||
|
quickLookupMap.put(type.getIntValue(), type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Type(int intValue) {
|
||||||
|
this.intValue = intValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getIntValue() {
|
||||||
|
return intValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Type valueOf(int intValue) {
|
||||||
|
return quickLookupMap.get(intValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private Type type;
|
||||||
|
private int[] eventData;
|
||||||
|
|
||||||
|
public UserControl(RtmpHeader header) {
|
||||||
|
super(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserControl(ChunkStreamInfo channelInfo) {
|
||||||
|
super(new RtmpHeader(channelInfo.canReusePrevHeaderTx(RtmpHeader.MessageType.USER_CONTROL_MESSAGE) ? RtmpHeader.ChunkType.TYPE_2_RELATIVE_TIMESTAMP_ONLY : RtmpHeader.ChunkType.TYPE_0_FULL, ChunkStreamInfo.RTMP_CONTROL_CHANNEL, RtmpHeader.MessageType.USER_CONTROL_MESSAGE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience construtor that creates a "pong" message for the specified ping */
|
||||||
|
public UserControl(UserControl replyToPing, ChunkStreamInfo channelInfo) {
|
||||||
|
this(Type.PONG_REPLY, channelInfo);
|
||||||
|
this.eventData = replyToPing.eventData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserControl(Type type, ChunkStreamInfo channelInfo) {
|
||||||
|
this(channelInfo);
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Type getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setType(Type type) {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method for getting the first event data item, as most user control
|
||||||
|
* message types only have one event data item anyway
|
||||||
|
* This is equivalent to calling <code>getEventData()[0]</code>
|
||||||
|
*/
|
||||||
|
public int getFirstEventData() {
|
||||||
|
return eventData[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public int[] getEventData() {
|
||||||
|
return eventData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Used to set (a single) event data for most user control message types */
|
||||||
|
public void setEventData(int eventData) {
|
||||||
|
if (type == Type.SET_BUFFER_LENGTH) {
|
||||||
|
throw new IllegalStateException("SET_BUFFER_LENGTH requires two event data values; use setEventData(int, int) instead");
|
||||||
|
}
|
||||||
|
this.eventData = new int[]{eventData};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Used to set event data for the SET_BUFFER_LENGTH user control message types */
|
||||||
|
public void setEventData(int streamId, int bufferLength) {
|
||||||
|
if (type != Type.SET_BUFFER_LENGTH) {
|
||||||
|
throw new IllegalStateException("User control type " + type + " requires only one event data value; use setEventData(int) instead");
|
||||||
|
}
|
||||||
|
this.eventData = new int[]{streamId, bufferLength};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void readBody(InputStream in) throws IOException {
|
||||||
|
// Bytes 0-1: first parameter: ping type (mandatory)
|
||||||
|
type = Type.valueOf(Util.readUnsignedInt16(in));
|
||||||
|
int bytesRead = 2;
|
||||||
|
// Event data (1 for most types, 2 for SET_BUFFER_LENGTH)
|
||||||
|
if (type == Type.SET_BUFFER_LENGTH) {
|
||||||
|
setEventData(Util.readUnsignedInt32(in), Util.readUnsignedInt32(in));
|
||||||
|
bytesRead += 8;
|
||||||
|
} else {
|
||||||
|
setEventData(Util.readUnsignedInt32(in));
|
||||||
|
bytesRead += 4;
|
||||||
|
}
|
||||||
|
// To ensure some strange non-specified UserControl/ping message does not slip through
|
||||||
|
assert header.getPacketLength() == bytesRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void writeBody(OutputStream out) throws IOException {
|
||||||
|
// Write the user control message type
|
||||||
|
Util.writeUnsignedInt16(out, type.getIntValue());
|
||||||
|
// Now write the event data
|
||||||
|
Util.writeUnsignedInt32(out, eventData[0]);
|
||||||
|
if (type == Type.SET_BUFFER_LENGTH) {
|
||||||
|
Util.writeUnsignedInt32(out, eventData[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "RTMP User Control (type: " + type + ", event data: " + eventData + ")";
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,77 @@
|
|||||||
|
package net.ossrs.sea.rtmp.packets;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import net.ossrs.sea.rtmp.amf.AmfBoolean;
|
||||||
|
import net.ossrs.sea.rtmp.amf.AmfData;
|
||||||
|
import net.ossrs.sea.rtmp.amf.AmfDecoder;
|
||||||
|
import net.ossrs.sea.rtmp.amf.AmfNull;
|
||||||
|
import net.ossrs.sea.rtmp.amf.AmfNumber;
|
||||||
|
import net.ossrs.sea.rtmp.amf.AmfString;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RTMP packet with a "variable" body structure (i.e. the structure of the
|
||||||
|
* body depends on some other state/parameter in the packet.
|
||||||
|
*
|
||||||
|
* Examples of this type of packet are Command and Data; this abstract class
|
||||||
|
* exists mostly for code re-use.
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public abstract class VariableBodyRtmpPacket extends RtmpPacket {
|
||||||
|
|
||||||
|
protected List<AmfData> data;
|
||||||
|
|
||||||
|
public VariableBodyRtmpPacket(RtmpHeader header) {
|
||||||
|
super(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AmfData> getData() {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addData(String string) {
|
||||||
|
addData(new AmfString(string));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addData(double number) {
|
||||||
|
addData(new AmfNumber(number));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addData(boolean bool) {
|
||||||
|
addData(new AmfBoolean(bool));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addData(AmfData dataItem) {
|
||||||
|
if (data == null) {
|
||||||
|
this.data = new ArrayList<AmfData>();
|
||||||
|
}
|
||||||
|
if (dataItem == null) {
|
||||||
|
dataItem = new AmfNull();
|
||||||
|
}
|
||||||
|
this.data.add(dataItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void readVariableData(final InputStream in, int bytesAlreadyRead) throws IOException {
|
||||||
|
// ...now read in arguments (if any)
|
||||||
|
do {
|
||||||
|
AmfData dataItem = AmfDecoder.readFrom(in);
|
||||||
|
addData(dataItem);
|
||||||
|
bytesAlreadyRead += dataItem.getSize();
|
||||||
|
} while (bytesAlreadyRead < header.getPacketLength());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void writeVariableData(final OutputStream out) throws IOException {
|
||||||
|
if (data != null) {
|
||||||
|
for (AmfData dataItem : data) {
|
||||||
|
dataItem.writeTo(out);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Write a null
|
||||||
|
AmfNull.writeNullTo(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
package net.ossrs.sea.rtmp.packets;
|
||||||
|
|
||||||
|
import net.ossrs.sea.rtmp.io.ChunkStreamInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video data packet
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class Video extends ContentData {
|
||||||
|
|
||||||
|
public Video(RtmpHeader header) {
|
||||||
|
super(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Video() {
|
||||||
|
super(new RtmpHeader(RtmpHeader.ChunkType.TYPE_0_FULL, ChunkStreamInfo.RTMP_VIDEO_CHANNEL, RtmpHeader.MessageType.VIDEO));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "RTMP Video";
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,52 @@
|
|||||||
|
package net.ossrs.sea.rtmp.packets;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import net.ossrs.sea.rtmp.Util;
|
||||||
|
import net.ossrs.sea.rtmp.io.ChunkStreamInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Window Acknowledgement Size
|
||||||
|
*
|
||||||
|
* Also known as ServerBW ("Server bandwidth") in some RTMP implementations.
|
||||||
|
*
|
||||||
|
* @author francois
|
||||||
|
*/
|
||||||
|
public class WindowAckSize extends RtmpPacket {
|
||||||
|
|
||||||
|
private int acknowledgementWindowSize;
|
||||||
|
|
||||||
|
public WindowAckSize(RtmpHeader header) {
|
||||||
|
super(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
public WindowAckSize(int acknowledgementWindowSize, ChunkStreamInfo channelInfo) {
|
||||||
|
super(new RtmpHeader(channelInfo.canReusePrevHeaderTx(RtmpHeader.MessageType.WINDOW_ACKNOWLEDGEMENT_SIZE) ? RtmpHeader.ChunkType.TYPE_2_RELATIVE_TIMESTAMP_ONLY : RtmpHeader.ChunkType.TYPE_0_FULL, ChunkStreamInfo.RTMP_CONTROL_CHANNEL, RtmpHeader.MessageType.WINDOW_ACKNOWLEDGEMENT_SIZE));
|
||||||
|
this.acknowledgementWindowSize = acknowledgementWindowSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public int getAcknowledgementWindowSize() {
|
||||||
|
return acknowledgementWindowSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAcknowledgementWindowSize(int acknowledgementWindowSize) {
|
||||||
|
this.acknowledgementWindowSize = acknowledgementWindowSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void readBody(InputStream in) throws IOException {
|
||||||
|
acknowledgementWindowSize = Util.readUnsignedInt32(in);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void writeBody(OutputStream out) throws IOException {
|
||||||
|
Util.writeUnsignedInt32(out, acknowledgementWindowSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "RTMP Window Acknowledgment Size";
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,73 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RelativeLayout 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"
|
||||||
|
android:paddingBottom="@dimen/activity_vertical_margin"
|
||||||
|
android:paddingLeft="@dimen/activity_horizontal_margin"
|
||||||
|
android:paddingRight="@dimen/activity_horizontal_margin"
|
||||||
|
android:paddingTop="@dimen/activity_vertical_margin"
|
||||||
|
tools:context="net.ossrs.sea.MainActivity">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="publish"
|
||||||
|
android:id="@+id/publish"
|
||||||
|
android:layout_alignParentTop="true" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="stop"
|
||||||
|
android:id="@+id/stop"
|
||||||
|
android:layout_toRightOf="@id/publish"
|
||||||
|
android:layout_marginTop="0dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="switch"
|
||||||
|
android:id="@+id/swCam"
|
||||||
|
android:layout_alignBottom="@id/stop"
|
||||||
|
android:layout_toRightOf="@id/stop" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="rotate"
|
||||||
|
android:id="@+id/rotate"
|
||||||
|
android:layout_above="@+id/url"
|
||||||
|
android:layout_toRightOf="@id/swCam" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/vbitrate"
|
||||||
|
android:textSize="14dp"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_below="@id/publish"
|
||||||
|
android:layout_marginTop="0dp" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="14dp"
|
||||||
|
android:id="@+id/url"
|
||||||
|
android:layout_below="@id/publish"
|
||||||
|
android:layout_above="@+id/frameLayout"
|
||||||
|
android:layout_toRightOf="@id/vbitrate" />
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_below="@id/vbitrate"
|
||||||
|
android:layout_centerHorizontal="true"
|
||||||
|
android:layout_marginTop="0dp"
|
||||||
|
android:id="@+id/frameLayout">
|
||||||
|
|
||||||
|
<SurfaceView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:id="@+id/preview" />
|
||||||
|
</FrameLayout>
|
||||||
|
</RelativeLayout>
|
@ -0,0 +1,6 @@
|
|||||||
|
<menu 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" tools:context="net.ossrs.sea.MainActivity">
|
||||||
|
<item android:id="@+id/action_settings" android:title="action_settings"
|
||||||
|
android:orderInCategory="100" app:showAsAction="never" />
|
||||||
|
</menu>
|
Binary file not shown.
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
After Width: | Height: | Size: 2.2 KiB |
Binary file not shown.
After Width: | Height: | Size: 4.7 KiB |
Binary file not shown.
After Width: | Height: | Size: 7.5 KiB |
Binary file not shown.
After Width: | Height: | Size: 10 KiB |
@ -0,0 +1,6 @@
|
|||||||
|
<resources>
|
||||||
|
<!-- Example customization of dimensions originally defined in res/values/dimens.xml
|
||||||
|
(such as screen margins) for screens with more than 820dp of available width. This
|
||||||
|
would include 7" and 10" devices in landscape (~960dp and ~1280dp respectively). -->
|
||||||
|
<dimen name="activity_horizontal_margin">64dp</dimen>
|
||||||
|
</resources>
|
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="colorPrimary">#3F51B5</color>
|
||||||
|
<color name="colorPrimaryDark">#303F9F</color>
|
||||||
|
<color name="colorAccent">#FF4081</color>
|
||||||
|
</resources>
|
@ -0,0 +1,5 @@
|
|||||||
|
<resources>
|
||||||
|
<!-- Default screen margins, per the Android Design guidelines. -->
|
||||||
|
<dimen name="activity_horizontal_margin">16dp</dimen>
|
||||||
|
<dimen name="activity_vertical_margin">16dp</dimen>
|
||||||
|
</resources>
|
@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Sea</string>
|
||||||
|
</resources>
|
@ -0,0 +1,11 @@
|
|||||||
|
<resources>
|
||||||
|
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
</resources>
|
@ -0,0 +1,15 @@
|
|||||||
|
package net.ossrs.sea;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To work on unit tests, switch the Test Artifact in the Build Variants view.
|
||||||
|
*/
|
||||||
|
public class ExampleUnitTest {
|
||||||
|
@Test
|
||||||
|
public void addition_isCorrect() throws Exception {
|
||||||
|
assertEquals(4, 2 + 2);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
|
buildscript {
|
||||||
|
repositories {
|
||||||
|
jcenter()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath 'com.android.tools.build:gradle:2.0.0-rc1'
|
||||||
|
|
||||||
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
// in the individual module build.gradle files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
jcenter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task clean(type: Delete) {
|
||||||
|
delete rootProject.buildDir
|
||||||
|
}
|
@ -0,0 +1 @@
|
|||||||
|
include ':app'
|
Loading…
Reference in New Issue