Migrate all files to AAR project

This commit is contained in:
Cameron Gutman
2017-05-12 17:53:55 -07:00
parent 4566c1855b
commit 822f498646
54 changed files with 36 additions and 2 deletions

View File

@@ -0,0 +1,25 @@
package com.limelight;
import java.io.IOException;
import java.util.logging.FileHandler;
import java.util.logging.Logger;
public class LimeLog {
private static final Logger LOGGER = Logger.getLogger(LimeLog.class.getName());
public static void info(String msg) {
LOGGER.info(msg);
}
public static void warning(String msg) {
LOGGER.warning(msg);
}
public static void severe(String msg) {
LOGGER.severe(msg);
}
public static void setFileHandler(String fileName) throws IOException {
LOGGER.addHandler(new FileHandler(fileName));
}
}

View File

@@ -0,0 +1,41 @@
package com.limelight.nvstream;
import java.net.InetAddress;
import javax.crypto.SecretKey;
import com.limelight.nvstream.av.video.VideoDecoderRenderer;
import com.limelight.nvstream.av.video.VideoDecoderRenderer.VideoFormat;
public class ConnectionContext {
// Gen 3 servers are 2.1.1 - 2.2.1
public static final int SERVER_GENERATION_3 = 3;
// Gen 4 servers are 2.2.2+
public static final int SERVER_GENERATION_4 = 4;
// Gen 5 servers are 2.10.2+
public static final int SERVER_GENERATION_5 = 5;
// Gen 6 servers haven't been seen in the wild
public static final int SERVER_GENERATION_6 = 6;
// Gen 7 servers are GFE 2.11.2.46+
public static final int SERVER_GENERATION_7 = 7;
public InetAddress serverAddress;
public StreamConfiguration streamConfig;
public VideoDecoderRenderer videoDecoderRenderer;
public NvConnectionListener connListener;
public SecretKey riKey;
public int riKeyId;
public int serverGeneration;
// This is the version quad from the appversion tag of /serverinfo
public int[] serverAppVersion;
public VideoFormat negotiatedVideoFormat;
public int negotiatedWidth, negotiatedHeight;
public int negotiatedFps;
}

View File

@@ -0,0 +1,469 @@
package com.limelight.nvstream;
import java.io.IOException;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import org.xmlpull.v1.XmlPullParserException;
import com.limelight.LimeLog;
import com.limelight.nvstream.av.audio.AudioStream;
import com.limelight.nvstream.av.audio.AudioRenderer;
import com.limelight.nvstream.av.video.VideoDecoderRenderer;
import com.limelight.nvstream.av.video.VideoDecoderRenderer.VideoFormat;
import com.limelight.nvstream.av.video.VideoStream;
import com.limelight.nvstream.control.ControlStream;
import com.limelight.nvstream.http.GfeHttpResponseException;
import com.limelight.nvstream.http.LimelightCryptoProvider;
import com.limelight.nvstream.http.NvApp;
import com.limelight.nvstream.http.NvHTTP;
import com.limelight.nvstream.http.PairingManager;
import com.limelight.nvstream.input.ControllerStream;
import com.limelight.nvstream.rtsp.RtspConnection;
public class NvConnection {
// Context parameters
private String host;
private LimelightCryptoProvider cryptoProvider;
private String uniqueId;
private ConnectionContext context;
// Stream objects
private ControlStream controlStream;
private ControllerStream inputStream;
private VideoStream videoStream;
private AudioStream audioStream;
// Start parameters
private int drFlags;
private Object videoRenderTarget;
private AudioRenderer audioRenderer;
public NvConnection(String host, String uniqueId, NvConnectionListener listener, StreamConfiguration config, LimelightCryptoProvider cryptoProvider)
{
this.host = host;
this.cryptoProvider = cryptoProvider;
this.uniqueId = uniqueId;
this.context = new ConnectionContext();
this.context.connListener = listener;
this.context.streamConfig = config;
try {
// This is unique per connection
this.context.riKey = generateRiAesKey();
} catch (NoSuchAlgorithmException e) {
// Should never happen
e.printStackTrace();
}
this.context.riKeyId = generateRiKeyId();
this.context.negotiatedVideoFormat = VideoFormat.Unknown;
}
private static SecretKey generateRiAesKey() throws NoSuchAlgorithmException {
KeyGenerator keyGen = KeyGenerator.getInstance("AES");
// RI keys are 128 bits
keyGen.init(128);
return keyGen.generateKey();
}
private static int generateRiKeyId() {
return new SecureRandom().nextInt();
}
public void stop()
{
if (inputStream != null) {
inputStream.abort();
inputStream = null;
}
if (audioStream != null) {
audioStream.abort();
}
if (videoStream != null) {
videoStream.abort();
}
if (controlStream != null) {
controlStream.abort();
}
}
private boolean startApp() throws XmlPullParserException, IOException
{
NvHTTP h = new NvHTTP(context.serverAddress, uniqueId, null, cryptoProvider);
String serverInfo = h.getServerInfo();
context.serverAppVersion = h.getServerAppVersionQuad(serverInfo);
if (context.serverAppVersion == null) {
context.connListener.displayMessage("Server version malformed");
return false;
}
int majorVersion = context.serverAppVersion[0];
LimeLog.info("Server major version: "+majorVersion);
if (majorVersion == 0) {
context.connListener.displayMessage("Server version malformed");
return false;
}
else if (majorVersion < 3) {
// Even though we support major version 3 (2.1.x), GFE 2.2.2 is preferred.
context.connListener.displayMessage("This app requires GeForce Experience 2.2.2 or later. Please upgrade GFE on your PC and try again.");
return false;
}
else if (majorVersion > 7) {
// Warn the user but allow them to continue
context.connListener.displayTransientMessage("This version of GFE is not currently supported. You may experience issues until this app is updated.");
}
switch (majorVersion) {
case 3:
context.serverGeneration = ConnectionContext.SERVER_GENERATION_3;
break;
case 4:
context.serverGeneration = ConnectionContext.SERVER_GENERATION_4;
break;
case 5:
context.serverGeneration = ConnectionContext.SERVER_GENERATION_5;
break;
case 6:
context.serverGeneration = ConnectionContext.SERVER_GENERATION_6;
break;
case 7:
default:
context.serverGeneration = ConnectionContext.SERVER_GENERATION_7;
break;
}
if (h.getPairState(serverInfo) != PairingManager.PairState.PAIRED) {
context.connListener.displayMessage("Device not paired with computer");
return false;
}
//
// Decide on negotiated stream parameters now
//
// Check for a supported stream resolution
if (context.streamConfig.getHeight() >= 2160 && !h.supports4K(serverInfo)) {
// Client wants 4K but the server can't do it
context.connListener.displayTransientMessage("Your PC does not have a supported GPU or GFE version for 4K streaming. The stream will be 1080p.");
// Lower resolution to 1080p
context.negotiatedWidth = 1920;
context.negotiatedHeight = 1080;
context.negotiatedFps = context.streamConfig.getRefreshRate();
}
else if (context.streamConfig.getHeight() >= 2160 && context.streamConfig.getRefreshRate() >= 60 && !h.supports4K60(serverInfo)) {
// Client wants 4K 60 FPS but the server can't do it
context.connListener.displayTransientMessage("Your GPU does not support 4K 60 FPS streaming. The stream will be 4K 30 FPS.");
context.negotiatedWidth = context.streamConfig.getWidth();
context.negotiatedHeight = context.streamConfig.getHeight();
context.negotiatedFps = 30;
}
else {
// Take what the client wanted
context.negotiatedWidth = context.streamConfig.getWidth();
context.negotiatedHeight = context.streamConfig.getHeight();
context.negotiatedFps = context.streamConfig.getRefreshRate();
}
//
// Video stream format will be decided during the RTSP handshake
//
NvApp app = context.streamConfig.getApp();
// If the client did not provide an exact app ID, do a lookup with the applist
if (!context.streamConfig.getApp().isInitialized()) {
LimeLog.info("Using deprecated app lookup method - Please specify an app ID in your StreamConfiguration instead");
app = h.getAppByName(context.streamConfig.getApp().getAppName());
if (app == null) {
context.connListener.displayMessage("The app " + context.streamConfig.getApp().getAppName() + " is not in GFE app list");
return false;
}
}
// If there's a game running, resume it
if (h.getCurrentGame(serverInfo) != 0) {
try {
if (h.getCurrentGame(serverInfo) == app.getAppId()) {
if (!h.resumeApp(context)) {
context.connListener.displayMessage("Failed to resume existing session");
return false;
}
} else {
return quitAndLaunch(h, app);
}
} catch (GfeHttpResponseException e) {
if (e.getErrorCode() == 470) {
// This is the error you get when you try to resume a session that's not yours.
// Because this is fairly common, we'll display a more detailed message.
context.connListener.displayMessage("This session wasn't started by this device," +
" so it cannot be resumed. End streaming on the original " +
"device or the PC itself and try again. (Error code: "+e.getErrorCode()+")");
return false;
}
else if (e.getErrorCode() == 525) {
context.connListener.displayMessage("The application is minimized. Resume it on the PC manually or " +
"quit the session and start streaming again.");
return false;
} else {
throw e;
}
}
LimeLog.info("Resumed existing game session");
return true;
}
else {
return launchNotRunningApp(h, app);
}
}
protected boolean quitAndLaunch(NvHTTP h, NvApp app) throws IOException,
XmlPullParserException {
try {
if (!h.quitApp()) {
context.connListener.displayMessage("Failed to quit previous session! You must quit it manually");
return false;
}
} catch (GfeHttpResponseException e) {
if (e.getErrorCode() == 599) {
context.connListener.displayMessage("This session wasn't started by this device," +
" so it cannot be quit. End streaming on the original " +
"device or the PC itself. (Error code: "+e.getErrorCode()+")");
return false;
}
else {
throw e;
}
}
return launchNotRunningApp(h, app);
}
private boolean launchNotRunningApp(NvHTTP h, NvApp app)
throws IOException, XmlPullParserException {
// Launch the app since it's not running
if (!h.launchApp(context, app.getAppId())) {
context.connListener.displayMessage("Failed to launch application");
return false;
}
LimeLog.info("Launched new game session");
return true;
}
private boolean doRtspHandshake() throws IOException
{
RtspConnection r = new RtspConnection(context);
r.doRtspHandshake();
return true;
}
private boolean startControlStream() throws IOException
{
controlStream = new ControlStream(context);
controlStream.initialize();
controlStream.start();
return true;
}
private boolean startVideoStream() throws IOException
{
videoStream = new VideoStream(context, controlStream);
return videoStream.startVideoStream(videoRenderTarget, drFlags);
}
private boolean startAudioStream() throws IOException
{
audioStream = new AudioStream(context, audioRenderer);
return audioStream.startAudioStream();
}
private boolean startInputConnection() throws IOException
{
// Because input events can be delivered at any time, we must only assign
// it to the instance variable once the object is properly initialized.
// This avoids the race where inputStream != null but inputStream.initialize()
// has not returned yet.
ControllerStream tempController = new ControllerStream(context);
tempController.initialize(controlStream);
tempController.start();
inputStream = tempController;
return true;
}
private void establishConnection() {
for (NvConnectionListener.Stage currentStage : NvConnectionListener.Stage.values())
{
boolean success = false;
if (currentStage == NvConnectionListener.Stage.LAUNCH_APP) {
// Display the app name instead of the stage name
currentStage.setName(context.streamConfig.getApp().getAppName());
}
context.connListener.stageStarting(currentStage);
try {
switch (currentStage)
{
case LAUNCH_APP:
success = startApp();
break;
case RTSP_HANDSHAKE:
success = doRtspHandshake();
break;
case CONTROL_START:
success = startControlStream();
break;
case VIDEO_START:
success = startVideoStream();
break;
case AUDIO_START:
success = startAudioStream();
break;
case INPUT_START:
success = startInputConnection();
break;
}
} catch (Exception e) {
e.printStackTrace();
context.connListener.displayMessage(e.getMessage());
success = false;
}
if (success) {
context.connListener.stageComplete(currentStage);
}
else {
context.connListener.stageFailed(currentStage);
return;
}
}
// Move the mouse cursor very slightly to wake the screen up for
// gamepad-only scenarios
sendMouseMove((short) 1, (short) 1);
try {
Thread.sleep(10);
} catch (InterruptedException e) {}
sendMouseMove((short) -1, (short) -1);
try {
Thread.sleep(10);
} catch (InterruptedException e) {}
context.connListener.connectionStarted();
}
public void start(String localDeviceName, Object videoRenderTarget, int drFlags, AudioRenderer audioRenderer, VideoDecoderRenderer videoDecoderRenderer)
{
this.drFlags = drFlags;
this.audioRenderer = audioRenderer;
this.videoRenderTarget = videoRenderTarget;
this.context.videoDecoderRenderer = videoDecoderRenderer;
new Thread(new Runnable() {
public void run() {
try {
context.serverAddress = InetAddress.getByName(host);
} catch (UnknownHostException e) {
context.connListener.connectionTerminated(e);
return;
}
establishConnection();
}
}).start();
}
public void sendMouseMove(final short deltaX, final short deltaY)
{
if (inputStream == null)
return;
inputStream.sendMouseMove(deltaX, deltaY);
}
public void sendMouseButtonDown(final byte mouseButton)
{
if (inputStream == null)
return;
inputStream.sendMouseButtonDown(mouseButton);
}
public void sendMouseButtonUp(final byte mouseButton)
{
if (inputStream == null)
return;
inputStream.sendMouseButtonUp(mouseButton);
}
public void sendControllerInput(final short controllerNumber,
final short activeGamepadMask, final short buttonFlags,
final byte leftTrigger, final byte rightTrigger,
final short leftStickX, final short leftStickY,
final short rightStickX, final short rightStickY)
{
if (inputStream == null)
return;
inputStream.sendControllerInput(controllerNumber, activeGamepadMask,
buttonFlags, leftTrigger,
rightTrigger, leftStickX, leftStickY,
rightStickX, rightStickY);
}
public void sendControllerInput(final short buttonFlags,
final byte leftTrigger, final byte rightTrigger,
final short leftStickX, final short leftStickY,
final short rightStickX, final short rightStickY)
{
if (inputStream == null)
return;
inputStream.sendControllerInput(buttonFlags, leftTrigger,
rightTrigger, leftStickX, leftStickY,
rightStickX, rightStickY);
}
public void sendKeyboardInput(final short keyMap, final byte keyDirection, final byte modifier) {
if (inputStream == null)
return;
inputStream.sendKeyboardInput(keyMap, keyDirection, modifier);
}
public void sendMouseScroll(final byte scrollClicks) {
if (inputStream == null)
return;
inputStream.sendMouseScroll(scrollClicks);
}
public VideoFormat getActiveVideoFormat() {
return context.negotiatedVideoFormat;
}
}

View File

@@ -0,0 +1,36 @@
package com.limelight.nvstream;
public interface NvConnectionListener {
public enum Stage {
LAUNCH_APP("app"),
RTSP_HANDSHAKE("RTSP handshake"),
CONTROL_START("control connection"),
VIDEO_START("video stream"),
AUDIO_START("audio stream"),
INPUT_START("input connection");
private String name;
private Stage(String name) {
this.name = name;
}
void setName(String name) {
this.name = name;
}
public String getName() {
return name;
}
};
public void stageStarting(Stage stage);
public void stageComplete(Stage stage);
public void stageFailed(Stage stage);
public void connectionStarted();
public void connectionTerminated(Exception e);
public void displayMessage(String message);
public void displayTransientMessage(String message);
}

View File

@@ -0,0 +1,171 @@
package com.limelight.nvstream;
import com.limelight.nvstream.http.NvApp;
public class StreamConfiguration {
public static final int INVALID_APP_ID = 0;
public static final int AUDIO_CONFIGURATION_STEREO = 1;
public static final int AUDIO_CONFIGURATION_5_1 = 2;
private static final int CHANNEL_COUNT_STEREO = 2;
private static final int CHANNEL_COUNT_5_1 = 6;
private static final int CHANNEL_MASK_STEREO = 0x3;
private static final int CHANNEL_MASK_5_1 = 0xFC;
private NvApp app;
private int width, height;
private int refreshRate;
private int bitrate;
private boolean sops;
private boolean enableAdaptiveResolution;
private boolean playLocalAudio;
private int maxPacketSize;
private boolean remote;
private int audioChannelMask;
private int audioChannelCount;
private boolean supportsHevc;
public static class Builder {
private StreamConfiguration config = new StreamConfiguration();
public StreamConfiguration.Builder setApp(NvApp app) {
config.app = app;
return this;
}
public StreamConfiguration.Builder setRemote(boolean remote) {
config.remote = remote;
return this;
}
public StreamConfiguration.Builder setResolution(int width, int height) {
config.width = width;
config.height = height;
return this;
}
public StreamConfiguration.Builder setRefreshRate(int refreshRate) {
config.refreshRate = refreshRate;
return this;
}
public StreamConfiguration.Builder setBitrate(int bitrate) {
config.bitrate = bitrate;
return this;
}
public StreamConfiguration.Builder setEnableSops(boolean enable) {
config.sops = enable;
return this;
}
public StreamConfiguration.Builder enableAdaptiveResolution(boolean enable) {
config.enableAdaptiveResolution = enable;
return this;
}
public StreamConfiguration.Builder enableLocalAudioPlayback(boolean enable) {
config.playLocalAudio = enable;
return this;
}
public StreamConfiguration.Builder setMaxPacketSize(int maxPacketSize) {
config.maxPacketSize = maxPacketSize;
return this;
}
public StreamConfiguration.Builder setAudioConfiguration(int audioConfig) {
if (audioConfig == AUDIO_CONFIGURATION_STEREO) {
config.audioChannelCount = CHANNEL_COUNT_STEREO;
config.audioChannelMask = CHANNEL_MASK_STEREO;
}
else if (audioConfig == AUDIO_CONFIGURATION_5_1) {
config.audioChannelCount = CHANNEL_COUNT_5_1;
config.audioChannelMask = CHANNEL_MASK_5_1;
}
else {
throw new IllegalArgumentException("Invalid audio configuration");
}
return this;
}
public StreamConfiguration.Builder setHevcSupported(boolean supportsHevc) {
config.supportsHevc = supportsHevc;
return this;
}
public StreamConfiguration build() {
return config;
}
}
private StreamConfiguration() {
// Set default attributes
this.app = new NvApp("Steam");
this.width = 1280;
this.height = 720;
this.refreshRate = 60;
this.bitrate = 10000;
this.maxPacketSize = 1024;
this.sops = true;
this.enableAdaptiveResolution = false;
this.audioChannelCount = CHANNEL_COUNT_STEREO;
this.audioChannelMask = CHANNEL_MASK_STEREO;
this.supportsHevc = false;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
public int getRefreshRate() {
return refreshRate;
}
public int getBitrate() {
return bitrate;
}
public int getMaxPacketSize() {
return maxPacketSize;
}
public NvApp getApp() {
return app;
}
public boolean getSops() {
return sops;
}
public boolean getAdaptiveResolutionEnabled() {
return enableAdaptiveResolution;
}
public boolean getPlayLocalAudio() {
return playLocalAudio;
}
public boolean getRemote() {
return remote;
}
public int getAudioChannelCount() {
return audioChannelCount;
}
public int getAudioChannelMask() {
return audioChannelMask;
}
public boolean getHevcSupported() {
return supportsHevc;
}
}

View File

@@ -0,0 +1,57 @@
package com.limelight.nvstream.av;
public class ByteBufferDescriptor {
public byte[] data;
public int offset;
public int length;
public ByteBufferDescriptor nextDescriptor;
public ByteBufferDescriptor(byte[] data, int offset, int length)
{
this.data = data;
this.offset = offset;
this.length = length;
}
public ByteBufferDescriptor(ByteBufferDescriptor desc)
{
this.data = desc.data;
this.offset = desc.offset;
this.length = desc.length;
}
public void reinitialize(byte[] data, int offset, int length)
{
this.data = data;
this.offset = offset;
this.length = length;
this.nextDescriptor = null;
}
public void print()
{
print(offset, length);
}
public void print(int length)
{
print(this.offset, length);
}
public void print(int offset, int length)
{
for (int i = offset; i < offset+length;) {
if (i + 8 <= offset+length) {
System.out.printf("%x: %02x %02x %02x %02x %02x %02x %02x %02x\n", i,
data[i], data[i+1], data[i+2], data[i+3], data[i+4], data[i+5], data[i+6], data[i+7]);
i += 8;
}
else {
System.out.printf("%x: %02x \n", i, data[i]);
i++;
}
}
System.out.println();
}
}

View File

@@ -0,0 +1,13 @@
package com.limelight.nvstream.av;
public interface ConnectionStatusListener {
public void connectionDetectedFrameLoss(int firstLostFrame, int nextSuccessfulFrame);
public void connectionSinkTooSlow(int firstLostFrame, int nextSuccessfulFrame);
public void connectionReceivedCompleteFrame(int frameIndex);
public void connectionSawFrame(int frameIndex);
public void connectionLostPackets(int lastReceivedPacket, int nextReceivedPacket);
}

View File

@@ -0,0 +1,63 @@
package com.limelight.nvstream.av;
import com.limelight.nvstream.av.video.VideoPacket;
public class DecodeUnit {
public static final int DU_FLAG_CODEC_CONFIG = 0x1;
public static final int DU_FLAG_SYNC_FRAME = 0x2;
private ByteBufferDescriptor bufferHead;
private int dataLength;
private int frameNumber;
private long receiveTimestamp;
private int flags;
private VideoPacket backingPacketHead;
public DecodeUnit() {
}
public void initialize(ByteBufferDescriptor bufferHead, int dataLength,
int frameNumber, long receiveTimestamp, int flags, VideoPacket backingPacketHead)
{
this.bufferHead = bufferHead;
this.dataLength = dataLength;
this.frameNumber = frameNumber;
this.receiveTimestamp = receiveTimestamp;
this.flags = flags;
this.backingPacketHead = backingPacketHead;
}
public long getReceiveTimestamp()
{
return receiveTimestamp;
}
public ByteBufferDescriptor getBufferHead()
{
return bufferHead;
}
public int getDataLength()
{
return dataLength;
}
public int getFrameNumber()
{
return frameNumber;
}
public int getFlags()
{
return flags;
}
// Internal use only
public VideoPacket removeBackingPacketHead() {
VideoPacket pkt = backingPacketHead;
if (pkt != null) {
backingPacketHead = pkt.nextPacket;
}
return pkt;
}
}

View File

@@ -0,0 +1,88 @@
package com.limelight.nvstream.av;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
public class RtpPacket implements RtpPacketFields {
private byte packetType;
private short seqNum;
private int headerSize;
private ByteBufferDescriptor buffer;
private ByteBuffer bb;
public static final int FLAG_EXTENSION = 0x10;
public static final int FIXED_HEADER_SIZE = 12;
public static final int MAX_HEADER_SIZE = 16;
public RtpPacket(byte[] buffer)
{
this.buffer = new ByteBufferDescriptor(buffer, 0, buffer.length);
this.bb = ByteBuffer.wrap(buffer).order(ByteOrder.BIG_ENDIAN);
}
public void initializeWithLength(int length)
{
// Rewind to start
bb.rewind();
// Read the RTP header byte
byte header = bb.get();
// Get the packet type
packetType = bb.get();
// Get the sequence number
seqNum = bb.getShort();
// If an extension is present, read the fields
headerSize = FIXED_HEADER_SIZE;
if ((header & FLAG_EXTENSION) != 0) {
headerSize += 4; // 2 additional fields
}
// Update descriptor length
buffer.length = length;
}
public byte getPacketType()
{
return packetType;
}
public short getRtpSequenceNumber()
{
return seqNum;
}
public byte[] getBuffer()
{
return buffer.data;
}
public void initializePayloadDescriptor(ByteBufferDescriptor bb)
{
bb.reinitialize(buffer.data, buffer.offset+headerSize, buffer.length-headerSize);
}
@Override
public int referencePacket() {
// There's no circular buffer for audio packets so this is a no-op
return 0;
}
@Override
public int dereferencePacket() {
// There's no circular buffer for audio packets so this is a no-op
return 0;
}
@Override
public int getRefCount() {
// There's no circular buffer for audio packets so this is a no-op
return 0;
}
}

View File

@@ -0,0 +1,13 @@
package com.limelight.nvstream.av;
public interface RtpPacketFields {
public byte getPacketType();
public short getRtpSequenceNumber();
public int referencePacket();
public int dereferencePacket();
public int getRefCount();
}

View File

@@ -0,0 +1,239 @@
package com.limelight.nvstream.av;
import java.util.Iterator;
import java.util.LinkedList;
import com.limelight.LimeLog;
import com.limelight.utils.TimeHelper;
public class RtpReorderQueue {
private final int maxSize;
private final int maxQueueTime;
private final LinkedList<RtpQueueEntry> queue;
private short nextRtpSequenceNumber;
private long oldestQueuedTime;
public enum RtpQueueStatus {
HANDLE_IMMEDIATELY,
QUEUED_NOTHING_READY,
QUEUED_PACKETS_READY,
REJECTED
};
public RtpReorderQueue() {
this.maxSize = 16;
this.maxQueueTime = 40;
this.queue = new LinkedList<RtpQueueEntry>();
this.oldestQueuedTime = Long.MAX_VALUE;
this.nextRtpSequenceNumber = Short.MAX_VALUE;
}
public RtpReorderQueue(int maxSize, int maxQueueTime) {
this.maxSize = maxSize;
this.maxQueueTime = maxQueueTime;
this.queue = new LinkedList<RtpQueueEntry>();
this.oldestQueuedTime = Long.MAX_VALUE;
this.nextRtpSequenceNumber = Short.MAX_VALUE;
}
private boolean queuePacket(boolean head, RtpPacketFields packet) {
short seq = packet.getRtpSequenceNumber();
if (nextRtpSequenceNumber != Short.MAX_VALUE) {
// Don't queue packets we're already ahead of
if (SequenceHelper.isBeforeSigned(seq, nextRtpSequenceNumber, false)) {
return false;
}
// Don't queue duplicates either
for (RtpQueueEntry existingEntry : queue) {
if (existingEntry.sequenceNumber == seq) {
return false;
}
}
}
RtpQueueEntry entry = new RtpQueueEntry();
entry.packet = packet;
entry.queueTime = TimeHelper.getMonotonicMillis();
entry.sequenceNumber = seq;
if (oldestQueuedTime == Long.MAX_VALUE) {
oldestQueuedTime = entry.queueTime;
}
// Add a reference to the packet while it's in the queue
packet.referencePacket();
if (head) {
queue.addFirst(entry);
}
else {
queue.addLast(entry);
}
return true;
}
private void updateOldestQueued() {
oldestQueuedTime = Long.MAX_VALUE;
for (RtpQueueEntry entry : queue) {
if (entry.queueTime < oldestQueuedTime) {
oldestQueuedTime = entry.queueTime;
}
}
}
private RtpQueueEntry getEntryByLowestSeq() {
if (queue.isEmpty()) {
return null;
}
RtpQueueEntry lowestSeqEntry = queue.getFirst();
short nextSeq = lowestSeqEntry.sequenceNumber;
for (RtpQueueEntry entry : queue) {
if (SequenceHelper.isBeforeSigned(entry.sequenceNumber, nextSeq, true)) {
lowestSeqEntry = entry;
nextSeq = entry.sequenceNumber;
}
}
if (nextSeq != Short.MAX_VALUE) {
nextRtpSequenceNumber = nextSeq;
}
return lowestSeqEntry;
}
private RtpQueueEntry validateQueueConstraints() {
if (queue.isEmpty()) {
return null;
}
boolean dequeuePacket = false;
// Check that the queue's time constraint is satisfied
if (TimeHelper.getMonotonicMillis() - oldestQueuedTime > maxQueueTime) {
LimeLog.info("Returning RTP packet queued for too long: "+(TimeHelper.getMonotonicMillis() - oldestQueuedTime));
dequeuePacket = true;
}
// Check that the queue's size constraint is satisfied. We subtract one
// because this is validating that the queue will meet constraints _after_
// the current packet is enqueued.
if (!dequeuePacket && queue.size() == maxSize - 1) {
LimeLog.info("Returning RTP packet after queue overgrowth");
dequeuePacket = true;
}
if (dequeuePacket) {
// Return the lowest seq queued
return getEntryByLowestSeq();
}
else {
return null;
}
}
public RtpQueueStatus addPacket(RtpPacketFields packet) {
if (nextRtpSequenceNumber != Short.MAX_VALUE &&
SequenceHelper.isBeforeSigned(packet.getRtpSequenceNumber(), nextRtpSequenceNumber, false)) {
// Reject packets behind our current sequence number
return RtpQueueStatus.REJECTED;
}
if (queue.isEmpty()) {
// Return immediately for an exact match with an empty queue
if (nextRtpSequenceNumber == Short.MAX_VALUE ||
packet.getRtpSequenceNumber() == nextRtpSequenceNumber) {
nextRtpSequenceNumber = (short) (packet.getRtpSequenceNumber() + 1);
return RtpQueueStatus.HANDLE_IMMEDIATELY;
}
else {
// Queue is empty currently so we'll put this packet on there
if (queuePacket(false, packet)) {
return RtpQueueStatus.QUEUED_NOTHING_READY;
}
else {
return RtpQueueStatus.REJECTED;
}
}
}
else {
// Validate that the queue remains within our contraints
RtpQueueEntry lowestEntry = validateQueueConstraints();
// If the queue is now empty after validating queue constraints,
// this packet can be returned immediately
if (lowestEntry == null && queue.isEmpty()) {
nextRtpSequenceNumber = (short) (packet.getRtpSequenceNumber() + 1);
return RtpQueueStatus.HANDLE_IMMEDIATELY;
}
// Queue has data inside, so we need to see where this packet fits
if (packet.getRtpSequenceNumber() == nextRtpSequenceNumber) {
// It fits in a hole where we need a packet, now we have some ready
if (queuePacket(true, packet)) {
return RtpQueueStatus.QUEUED_PACKETS_READY;
}
else {
return RtpQueueStatus.REJECTED;
}
}
else {
if (queuePacket(false, packet)) {
// Constraint validation may have changed the oldest packet to one that
// matches the next sequence number
return (lowestEntry != null) ? RtpQueueStatus.QUEUED_PACKETS_READY :
RtpQueueStatus.QUEUED_NOTHING_READY;
}
else {
return RtpQueueStatus.REJECTED;
}
}
}
}
// This function returns a referenced packet. The caller must dereference
// the packet when it is finished.
public RtpPacketFields getQueuedPacket() {
RtpQueueEntry queuedEntry = null;
// Find the matching entry
Iterator<RtpQueueEntry> i = queue.iterator();
while (i.hasNext()) {
RtpQueueEntry entry = i.next();
if (entry.sequenceNumber == nextRtpSequenceNumber) {
nextRtpSequenceNumber++;
queuedEntry = entry;
i.remove();
break;
}
}
// Bail if we found nothing
if (queuedEntry == null) {
// Update the oldest queued packet time
updateOldestQueued();
return null;
}
// We don't update the oldest queued entry here, because we know
// the caller will call again until it receives null
return queuedEntry.packet;
}
private class RtpQueueEntry {
public RtpPacketFields packet;
public short sequenceNumber;
public long queueTime;
}
}

View File

@@ -0,0 +1,20 @@
package com.limelight.nvstream.av;
public class SequenceHelper {
public static boolean isBeforeSigned(int numA, int numB, boolean ambiguousCase) {
// This should be the common case for most callers
if (numA == numB) {
return false;
}
// If numA and numB have the same signs,
// we can just do a regular comparison.
if ((numA < 0 && numB < 0) || (numA >= 0 && numB >= 0)) {
return numA < numB;
}
else {
// The sign switch is ambiguous
return ambiguousCase;
}
}
}

View File

@@ -0,0 +1,116 @@
package com.limelight.nvstream.av.audio;
import com.limelight.LimeLog;
import com.limelight.nvstream.av.ByteBufferDescriptor;
import com.limelight.nvstream.av.RtpPacket;
import com.limelight.nvstream.av.SequenceHelper;
import com.limelight.nvstream.av.buffer.AbstractPopulatedBufferList;
import com.limelight.nvstream.av.buffer.AtomicPopulatedBufferList;
public class AudioDepacketizer {
private static final int DU_LIMIT = 30;
private AbstractPopulatedBufferList<ByteBufferDescriptor> decodedUnits;
// Direct submit state
private AudioRenderer directSubmitRenderer;
private byte[] directSubmitData;
// Cached objects
private ByteBufferDescriptor cachedDesc = new ByteBufferDescriptor(null, 0, 0);
// Sequencing state
private short lastSequenceNumber;
public AudioDepacketizer(AudioRenderer directSubmitRenderer, final int bufferSizeShorts)
{
this.directSubmitRenderer = directSubmitRenderer;
if (directSubmitRenderer != null) {
this.directSubmitData = new byte[bufferSizeShorts*2];
}
else {
decodedUnits = new AtomicPopulatedBufferList<ByteBufferDescriptor>(DU_LIMIT, new AbstractPopulatedBufferList.BufferFactory() {
public Object createFreeBuffer() {
return new ByteBufferDescriptor(new byte[bufferSizeShorts*2], 0, bufferSizeShorts*2);
}
public void cleanupObject(Object o) {
// Nothing to do
}
});
}
}
private void decodeData(byte[] data, int off, int len)
{
// Submit this data to the decoder
int decodeLen;
ByteBufferDescriptor bb;
if (directSubmitData != null) {
bb = null;
decodeLen = OpusDecoder.decode(data, off, len, directSubmitData);
}
else {
bb = decodedUnits.pollFreeObject();
if (bb == null) {
LimeLog.warning("Audio player too slow! Forced to drop decoded samples");
decodedUnits.clearPopulatedObjects();
bb = decodedUnits.pollFreeObject();
if (bb == null) {
LimeLog.severe("Audio player is leaking buffers!");
return;
}
}
decodeLen = OpusDecoder.decode(data, off, len, bb.data);
}
if (decodeLen > 0) {
if (directSubmitRenderer != null) {
directSubmitRenderer.playDecodedAudio(directSubmitData, 0, decodeLen);
}
else {
bb.length = decodeLen;
decodedUnits.addPopulatedObject(bb);
}
}
else if (directSubmitRenderer == null) {
decodedUnits.freePopulatedObject(bb);
}
}
public void decodeInputData(RtpPacket packet)
{
short seq = packet.getRtpSequenceNumber();
// Toss out the current NAL if we receive a packet that is
// out of sequence
if (lastSequenceNumber != 0 &&
(short)(lastSequenceNumber + 1) != seq)
{
LimeLog.warning("Received OOS audio data (expected "+(lastSequenceNumber + 1)+", got "+seq+")");
// Only tell the decoder if we got packets ahead of what we expected
// If the packet is behind the current sequence number, drop it
if (!SequenceHelper.isBeforeSigned(seq, (short)(lastSequenceNumber + 1), false)) {
decodeData(null, 0, 0);
}
else {
return;
}
}
lastSequenceNumber = seq;
// This is all the depacketizing we need to do
packet.initializePayloadDescriptor(cachedDesc);
decodeData(cachedDesc.data, cachedDesc.offset, cachedDesc.length);
}
public ByteBufferDescriptor getNextDecodedData() throws InterruptedException {
return decodedUnits.takePopulatedObject();
}
public void freeDecodedData(ByteBufferDescriptor data) {
decodedUnits.freePopulatedObject(data);
}
}

View File

@@ -0,0 +1,14 @@
package com.limelight.nvstream.av.audio;
public interface AudioRenderer {
// playDecodedAudio() is lightweight, so don't use an extra thread for playback
public static final int CAPABILITY_DIRECT_SUBMIT = 0x1;
public int getCapabilities();
public boolean streamInitialized(int channelCount, int channelMask, int samplesPerFrame, int sampleRate);
public void playDecodedAudio(byte[] audioData, int offset, int length);
public void streamClosing();
}

View File

@@ -0,0 +1,274 @@
package com.limelight.nvstream.av.audio;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetSocketAddress;
import java.net.SocketException;
import java.util.LinkedList;
import com.limelight.nvstream.ConnectionContext;
import com.limelight.nvstream.av.ByteBufferDescriptor;
import com.limelight.nvstream.av.RtpPacket;
import com.limelight.nvstream.av.RtpReorderQueue;
public class AudioStream {
private static final int RTP_PORT = 48000;
private static final int SAMPLE_RATE = 48000;
private static final int SHORTS_PER_CHANNEL = 240;
private static final int RTP_RECV_BUFFER = 64 * 1024;
private static final int MAX_PACKET_SIZE = 250;
private DatagramSocket rtp;
private AudioDepacketizer depacketizer;
private LinkedList<Thread> threads = new LinkedList<Thread>();
private boolean aborting = false;
private ConnectionContext context;
private AudioRenderer streamListener;
public AudioStream(ConnectionContext context, AudioRenderer streamListener)
{
this.context = context;
this.streamListener = streamListener;
}
public void abort()
{
if (aborting) {
return;
}
aborting = true;
for (Thread t : threads) {
t.interrupt();
}
// Close the socket to interrupt the receive thread
if (rtp != null) {
rtp.close();
}
// Wait for threads to terminate
for (Thread t : threads) {
try {
t.join();
} catch (InterruptedException e) { }
}
streamListener.streamClosing();
threads.clear();
}
public boolean startAudioStream() throws SocketException
{
setupRtpSession();
if (!setupAudio()) {
abort();
return false;
}
startReceiveThread();
if ((streamListener.getCapabilities() & AudioRenderer.CAPABILITY_DIRECT_SUBMIT) == 0) {
startDecoderThread();
}
startUdpPingThread();
return true;
}
private void setupRtpSession() throws SocketException
{
rtp = new DatagramSocket();
rtp.setReceiveBufferSize(RTP_RECV_BUFFER);
}
private static final int[] STREAMS_2 = new int[] {1, 1};
private static final int[] STREAMS_5_1 = new int[] {4, 2};
private static final byte[] MAPPING_2 = new byte[] {0, 1};
private static final byte[] MAPPING_5_1 = new byte[] {0, 4, 1, 5, 2, 3};
private boolean setupAudio()
{
int err;
int channels = context.streamConfig.getAudioChannelCount();
byte[] mapping;
int[] streams;
if (channels == 2) {
mapping = MAPPING_2;
streams = STREAMS_2;
}
else if (channels == 6) {
mapping = MAPPING_5_1;
streams = STREAMS_5_1;
}
else {
throw new IllegalStateException("Unsupported surround configuration");
}
err = OpusDecoder.init(SAMPLE_RATE, SHORTS_PER_CHANNEL, channels,
streams[0], streams[1], mapping);
if (err != 0) {
throw new IllegalStateException("Opus decoder failed to initialize: "+err);
}
if (!streamListener.streamInitialized(context.streamConfig.getAudioChannelCount(),
context.streamConfig.getAudioChannelMask(),
context.streamConfig.getAudioChannelCount()*SHORTS_PER_CHANNEL,
SAMPLE_RATE)) {
return false;
}
if ((streamListener.getCapabilities() & AudioRenderer.CAPABILITY_DIRECT_SUBMIT) != 0) {
depacketizer = new AudioDepacketizer(streamListener, context.streamConfig.getAudioChannelCount()*SHORTS_PER_CHANNEL);
}
else {
depacketizer = new AudioDepacketizer(null, context.streamConfig.getAudioChannelCount()*SHORTS_PER_CHANNEL);
}
return true;
}
private void startDecoderThread()
{
// Decoder thread
Thread t = new Thread() {
@Override
public void run() {
while (!isInterrupted())
{
ByteBufferDescriptor samples;
try {
samples = depacketizer.getNextDecodedData();
} catch (InterruptedException e) {
context.connListener.connectionTerminated(e);
return;
}
streamListener.playDecodedAudio(samples.data, samples.offset, samples.length);
depacketizer.freeDecodedData(samples);
}
}
};
threads.add(t);
t.setName("Audio - Player");
t.setPriority(Thread.NORM_PRIORITY + 2);
t.start();
}
private void startReceiveThread()
{
// Receive thread
Thread t = new Thread() {
@Override
public void run() {
byte[] buffer = new byte[MAX_PACKET_SIZE];
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
RtpPacket queuedPacket, rtpPacket = new RtpPacket(buffer);
RtpReorderQueue rtpQueue = new RtpReorderQueue();
RtpReorderQueue.RtpQueueStatus queueStatus;
while (!isInterrupted())
{
try {
rtp.receive(packet);
// DecodeInputData() doesn't hold onto the buffer so we are free to reuse it
rtpPacket.initializeWithLength(packet.getLength());
// Throw away non-audio packets before queuing
if (rtpPacket.getPacketType() != 97) {
// Only type 97 is audio
packet.setLength(MAX_PACKET_SIZE);
continue;
}
queueStatus = rtpQueue.addPacket(rtpPacket);
if (queueStatus == RtpReorderQueue.RtpQueueStatus.HANDLE_IMMEDIATELY) {
// Send directly to the depacketizer
depacketizer.decodeInputData(rtpPacket);
packet.setLength(MAX_PACKET_SIZE);
}
else {
if (queueStatus != RtpReorderQueue.RtpQueueStatus.REJECTED) {
// The queue consumed our packet, so we must allocate a new one
buffer = new byte[MAX_PACKET_SIZE];
packet = new DatagramPacket(buffer, buffer.length);
rtpPacket = new RtpPacket(buffer);
}
else {
packet.setLength(MAX_PACKET_SIZE);
}
// If packets are ready, pull them and send them to the depacketizer
if (queueStatus == RtpReorderQueue.RtpQueueStatus.QUEUED_PACKETS_READY) {
while ((queuedPacket = (RtpPacket) rtpQueue.getQueuedPacket()) != null) {
depacketizer.decodeInputData(queuedPacket);
queuedPacket.dereferencePacket();
}
}
}
} catch (IOException e) {
context.connListener.connectionTerminated(e);
return;
}
}
}
};
threads.add(t);
t.setName("Audio - Receive");
t.setPriority(Thread.NORM_PRIORITY + 1);
t.start();
}
private void startUdpPingThread()
{
// Ping thread
Thread t = new Thread() {
@Override
public void run() {
// PING in ASCII
final byte[] pingPacketData = new byte[] {0x50, 0x49, 0x4E, 0x47};
DatagramPacket pingPacket = new DatagramPacket(pingPacketData, pingPacketData.length);
pingPacket.setSocketAddress(new InetSocketAddress(context.serverAddress, RTP_PORT));
// Send PING every 500 ms
while (!isInterrupted())
{
try {
rtp.send(pingPacket);
} catch (IOException e) {
context.connListener.connectionTerminated(e);
return;
}
try {
Thread.sleep(500);
} catch (InterruptedException e) {
context.connListener.connectionTerminated(e);
return;
}
}
}
};
threads.add(t);
t.setPriority(Thread.MIN_PRIORITY);
t.setName("Audio - Ping");
t.start();
}
}

View File

@@ -0,0 +1,11 @@
package com.limelight.nvstream.av.audio;
public class OpusDecoder {
static {
System.loadLibrary("nv_opus_dec");
}
public static native int init(int sampleRate, int samplesPerChannel, int channelCount, int streams, int coupledStreams, byte[] mapping);
public static native void destroy();
public static native int decode(byte[] indata, int inoff, int inlen, byte[] outpcmdata);
}

View File

@@ -0,0 +1,41 @@
package com.limelight.nvstream.av.buffer;
public abstract class AbstractPopulatedBufferList<T> {
protected final int maxQueueSize;
protected final BufferFactory factory;
public AbstractPopulatedBufferList(int maxQueueSize, BufferFactory factory) {
this.factory = factory;
this.maxQueueSize = maxQueueSize;
}
public abstract int getPopulatedCount();
public abstract int getFreeCount();
public abstract T pollFreeObject();
public abstract void addPopulatedObject(T object);
public abstract void freePopulatedObject(T object);
public void clearPopulatedObjects() {
T object;
while ((object = pollPopulatedObject()) != null) {
freePopulatedObject(object);
}
}
public abstract T pollPopulatedObject();
public abstract T peekPopulatedObject();
public T takePopulatedObject() throws InterruptedException {
throw new UnsupportedOperationException("Blocking is unsupported on this buffer list");
}
public static interface BufferFactory {
public Object createFreeBuffer();
public void cleanupObject(Object o);
}
}

View File

@@ -0,0 +1,61 @@
package com.limelight.nvstream.av.buffer;
import java.util.concurrent.ArrayBlockingQueue;
public class AtomicPopulatedBufferList<T> extends AbstractPopulatedBufferList<T> {
private final ArrayBlockingQueue<T> populatedList;
private final ArrayBlockingQueue<T> freeList;
@SuppressWarnings("unchecked")
public AtomicPopulatedBufferList(int maxQueueSize, BufferFactory factory) {
super(maxQueueSize, factory);
this.populatedList = new ArrayBlockingQueue<T>(maxQueueSize, false);
this.freeList = new ArrayBlockingQueue<T>(maxQueueSize, false);
for (int i = 0; i < maxQueueSize; i++) {
freeList.add((T) factory.createFreeBuffer());
}
}
@Override
public int getPopulatedCount() {
return populatedList.size();
}
@Override
public int getFreeCount() {
return freeList.size();
}
@Override
public T pollFreeObject() {
return freeList.poll();
}
@Override
public void addPopulatedObject(T object) {
populatedList.add(object);
}
@Override
public void freePopulatedObject(T object) {
factory.cleanupObject(object);
freeList.add(object);
}
@Override
public T pollPopulatedObject() {
return populatedList.poll();
}
@Override
public T peekPopulatedObject() {
return populatedList.peek();
}
@Override
public T takePopulatedObject() throws InterruptedException {
return populatedList.take();
}
}

View File

@@ -0,0 +1,68 @@
package com.limelight.nvstream.av.buffer;
import java.util.ArrayList;
public class UnsynchronizedPopulatedBufferList<T> extends AbstractPopulatedBufferList<T> {
private final ArrayList<T> populatedList;
private final ArrayList<T> freeList;
@SuppressWarnings("unchecked")
public UnsynchronizedPopulatedBufferList(int maxQueueSize, BufferFactory factory) {
super(maxQueueSize, factory);
this.populatedList = new ArrayList<T>(maxQueueSize);
this.freeList = new ArrayList<T>(maxQueueSize);
for (int i = 0; i < maxQueueSize; i++) {
freeList.add((T) factory.createFreeBuffer());
}
}
@Override
public int getPopulatedCount() {
return populatedList.size();
}
@Override
public int getFreeCount() {
return freeList.size();
}
@Override
public T pollFreeObject() {
if (freeList.isEmpty()) {
return null;
}
return freeList.remove(0);
}
@Override
public void addPopulatedObject(T object) {
populatedList.add(object);
}
@Override
public void freePopulatedObject(T object) {
factory.cleanupObject(object);
freeList.add(object);
}
@Override
public T pollPopulatedObject() {
if (populatedList.isEmpty()) {
return null;
}
return populatedList.remove(0);
}
@Override
public T peekPopulatedObject() {
if (populatedList.isEmpty()) {
return null;
}
return populatedList.get(0);
}
}

View File

@@ -0,0 +1,50 @@
package com.limelight.nvstream.av.video;
import com.limelight.nvstream.av.DecodeUnit;
public abstract class VideoDecoderRenderer {
public enum VideoFormat {
Unknown,
H264,
H265
};
public static final int FLAG_PREFER_QUALITY = 0x1;
public static final int FLAG_FORCE_HARDWARE_DECODING = 0x2;
public static final int FLAG_FORCE_SOFTWARE_DECODING = 0x4;
public static final int FLAG_FILL_SCREEN = 0x8;
// Allows the resolution to dynamically change mid-stream
public static final int CAPABILITY_ADAPTIVE_RESOLUTION = 0x1;
// Allows decode units to be submitted directly from the receive thread
public static final int CAPABILITY_DIRECT_SUBMIT = 0x2;
// !!! EXPERIMENTAL !!!
// Allows reference frame invalidation to be use to recover from packet loss
public static final int CAPABILITY_REFERENCE_FRAME_INVALIDATION = 0x4;
public int getCapabilities() {
return 0;
}
public int getAverageEndToEndLatency() {
return 0;
}
public int getAverageDecoderLatency() {
return 0;
}
public void directSubmitDecodeUnit(DecodeUnit du) {
throw new UnsupportedOperationException("CAPABILITY_DIRECT_SUBMIT requires overriding directSubmitDecodeUnit()");
}
public abstract boolean setup(VideoFormat format, int width, int height, int redrawRate, Object renderTarget, int drFlags);
public abstract boolean start(VideoDepacketizer depacketizer);
public abstract void stop();
public abstract void release();
}

View File

@@ -0,0 +1,656 @@
package com.limelight.nvstream.av.video;
import com.limelight.LimeLog;
import com.limelight.nvstream.ConnectionContext;
import com.limelight.nvstream.av.ByteBufferDescriptor;
import com.limelight.nvstream.av.DecodeUnit;
import com.limelight.nvstream.av.ConnectionStatusListener;
import com.limelight.nvstream.av.SequenceHelper;
import com.limelight.nvstream.av.buffer.AbstractPopulatedBufferList;
import com.limelight.nvstream.av.buffer.AtomicPopulatedBufferList;
import com.limelight.nvstream.av.buffer.UnsynchronizedPopulatedBufferList;
import com.limelight.utils.TimeHelper;
public class VideoDepacketizer {
// Current frame state
private int frameDataLength = 0;
private ByteBufferDescriptor frameDataChainHead;
private ByteBufferDescriptor frameDataChainTail;
private VideoPacket backingPacketHead;
private VideoPacket backingPacketTail;
// Sequencing state
private int lastPacketInStream = -1;
private int nextFrameNumber = 1;
private int startFrameNumber = 0;
private boolean waitingForNextSuccessfulFrame;
private boolean waitingForIdrFrame = true;
private long frameStartTime;
private boolean decodingFrame;
private boolean strictIdrFrameWait;
// Cached objects
private ByteBufferDescriptor cachedReassemblyDesc = new ByteBufferDescriptor(null, 0, 0);
private ByteBufferDescriptor cachedSpecialDesc = new ByteBufferDescriptor(null, 0, 0);
private ConnectionStatusListener controlListener;
private final int nominalPacketDataLength;
private static final int CONSECUTIVE_DROP_LIMIT = 120;
private int consecutiveFrameDrops = 0;
private static final int DU_LIMIT = 15;
private AbstractPopulatedBufferList<DecodeUnit> decodedUnits;
private final int frameHeaderOffset;
public VideoDepacketizer(ConnectionContext context, ConnectionStatusListener controlListener, int nominalPacketSize)
{
this.controlListener = controlListener;
this.nominalPacketDataLength = nominalPacketSize - VideoPacket.HEADER_SIZE;
if ((context.serverAppVersion[0] > 7) ||
(context.serverAppVersion[0] == 7 && context.serverAppVersion[1] > 1) ||
(context.serverAppVersion[0] == 7 && context.serverAppVersion[1] == 1 && context.serverAppVersion[2] >= 350)) {
// >= 7.1.350 should use the 8 byte header again
frameHeaderOffset = 8;
}
else if ((context.serverAppVersion[0] > 7) ||
(context.serverAppVersion[0] == 7 && context.serverAppVersion[1] > 1) ||
(context.serverAppVersion[0] == 7 && context.serverAppVersion[1] == 1 && context.serverAppVersion[2] >= 320)) {
// [7.1.320, 7.1.350) should use the 12 byte frame header
frameHeaderOffset = 12;
}
else if (context.serverGeneration >= ConnectionContext.SERVER_GENERATION_5) {
// [5.x, 7.1.320) should use the 8 byte header
frameHeaderOffset = 8;
}
else {
frameHeaderOffset = 0;
}
boolean unsynchronized;
if (context.videoDecoderRenderer != null) {
int videoCaps = context.videoDecoderRenderer.getCapabilities();
this.strictIdrFrameWait = (videoCaps & VideoDecoderRenderer.CAPABILITY_REFERENCE_FRAME_INVALIDATION) == 0;
unsynchronized = (videoCaps & VideoDecoderRenderer.CAPABILITY_DIRECT_SUBMIT) != 0;
}
else {
// If there's no renderer, it doesn't matter if we synchronize or wait for IDRs
this.strictIdrFrameWait = false;
unsynchronized = true;
}
AbstractPopulatedBufferList.BufferFactory factory = new AbstractPopulatedBufferList.BufferFactory() {
public Object createFreeBuffer() {
return new DecodeUnit();
}
public void cleanupObject(Object o) {
DecodeUnit du = (DecodeUnit) o;
// Disassociate video packets from this DU
VideoPacket pkt;
while ((pkt = du.removeBackingPacketHead()) != null) {
pkt.dereferencePacket();
}
}
};
if (unsynchronized) {
decodedUnits = new UnsynchronizedPopulatedBufferList<DecodeUnit>(DU_LIMIT, factory);
}
else {
decodedUnits = new AtomicPopulatedBufferList<DecodeUnit>(DU_LIMIT, factory);
}
}
private void dropFrameState()
{
// We'll need an IDR frame now if we're in strict mode
if (strictIdrFrameWait) {
waitingForIdrFrame = true;
}
// Count the number of consecutive frames dropped
consecutiveFrameDrops++;
// If we reach our limit, immediately request an IDR frame
// and reset
if (consecutiveFrameDrops == CONSECUTIVE_DROP_LIMIT) {
LimeLog.warning("Reached consecutive drop limit");
// Restart the count
consecutiveFrameDrops = 0;
// Request an IDR frame (0 tuple always generates an IDR frame)
controlListener.connectionDetectedFrameLoss(0, 0);
}
cleanupFrameState();
}
private void cleanupFrameState()
{
backingPacketTail = null;
while (backingPacketHead != null) {
backingPacketHead.dereferencePacket();
backingPacketHead = backingPacketHead.nextPacket;
}
frameDataChainHead = frameDataChainTail = null;
frameDataLength = 0;
}
private static boolean isReferencePictureNalu(byte nalType) {
switch (nalType) {
case 0x20:
case 0x22:
case 0x24:
case 0x26:
case 0x28:
case 0x2A:
// H265
return true;
case 0x65:
// H264
return true;
default:
return false;
}
}
private void reassembleFrame(int frameNumber)
{
// This is the start of a new frame
if (frameDataChainHead != null) {
ByteBufferDescriptor firstBuffer = frameDataChainHead;
int flags = 0;
if (NAL.getSpecialSequenceDescriptor(firstBuffer, cachedSpecialDesc) && NAL.isAnnexBFrameStart(cachedSpecialDesc)) {
switch (cachedSpecialDesc.data[cachedSpecialDesc.offset+cachedSpecialDesc.length]) {
// H265
case 0x40: // VPS
case 0x42: // SPS
case 0x44: // PPS
flags |= DecodeUnit.DU_FLAG_CODEC_CONFIG;
break;
// H264
case 0x67: // SPS
case 0x68: // PPS
flags |= DecodeUnit.DU_FLAG_CODEC_CONFIG;
break;
}
if (isReferencePictureNalu(cachedSpecialDesc.data[cachedSpecialDesc.offset+cachedSpecialDesc.length])) {
flags |= DecodeUnit.DU_FLAG_SYNC_FRAME;
}
}
// Construct the video decode unit
DecodeUnit du = decodedUnits.pollFreeObject();
if (du == null) {
LimeLog.warning("Video decoder is too slow! Forced to drop decode units");
// Invalidate all frames from the start of the DU queue
// (0 tuple always generates an IDR frame)
controlListener.connectionSinkTooSlow(0, 0);
waitingForIdrFrame = true;
// Remove existing frames
decodedUnits.clearPopulatedObjects();
// Clear frame state and wait for an IDR
dropFrameState();
return;
}
// Initialize the free DU
du.initialize(frameDataChainHead, frameDataLength, frameNumber,
frameStartTime, flags, backingPacketHead);
// Packets now owned by the DU
backingPacketTail = backingPacketHead = null;
controlListener.connectionReceivedCompleteFrame(frameNumber);
// Submit the DU to the consumer
decodedUnits.addPopulatedObject(du);
// Clear old state
cleanupFrameState();
// Clear frame drops
consecutiveFrameDrops = 0;
}
}
private void chainBufferToCurrentFrame(ByteBufferDescriptor desc) {
desc.nextDescriptor = null;
// Chain the packet
if (frameDataChainTail != null) {
frameDataChainTail.nextDescriptor = desc;
frameDataChainTail = desc;
}
else {
frameDataChainHead = frameDataChainTail = desc;
}
frameDataLength += desc.length;
}
private void chainPacketToCurrentFrame(VideoPacket packet) {
packet.referencePacket();
packet.nextPacket = null;
// Chain the packet
if (backingPacketTail != null) {
backingPacketTail.nextPacket = packet;
backingPacketTail = packet;
}
else {
backingPacketHead = backingPacketTail = packet;
}
}
private void addInputDataSlow(VideoPacket packet, ByteBufferDescriptor location)
{
boolean isDecodingVideoData = false;
while (location.length != 0)
{
// Remember the start of the NAL data in this packet
int start = location.offset;
// Check for a special sequence
if (NAL.getSpecialSequenceDescriptor(location, cachedSpecialDesc))
{
if (NAL.isAnnexBStartSequence(cachedSpecialDesc))
{
// We're decoding video data now
isDecodingVideoData = true;
// Check if it's the end of the last frame
if (NAL.isAnnexBFrameStart(cachedSpecialDesc))
{
// Update the global state that we're decoding a new frame
this.decodingFrame = true;
// Reassemble any pending NAL
reassembleFrame(packet.getFrameIndex());
// Reload cachedSpecialDesc after reassembleFrame overwrote it
NAL.getSpecialSequenceDescriptor(location, cachedSpecialDesc);
if (isReferencePictureNalu(cachedSpecialDesc.data[cachedSpecialDesc.offset+cachedSpecialDesc.length])) {
// This is the NALU code for I-frame data
waitingForIdrFrame = false;
// Cancel any pending IDR frame request
waitingForNextSuccessfulFrame = false;
}
}
// Skip the start sequence
location.length -= cachedSpecialDesc.length;
location.offset += cachedSpecialDesc.length;
}
else
{
// Check if this is padding after a full video frame
if (isDecodingVideoData && NAL.isPadding(cachedSpecialDesc)) {
// The decode unit is complete
reassembleFrame(packet.getFrameIndex());
}
// Not decoding video
isDecodingVideoData = false;
// Just skip this byte
location.length--;
location.offset++;
}
}
// Move to the next special sequence
while (location.length != 0)
{
// Catch the easy case first where byte 0 != 0x00
if (location.data[location.offset] == 0x00)
{
// Check if this should end the current NAL
if (NAL.getSpecialSequenceDescriptor(location, cachedSpecialDesc))
{
// Only stop if we're decoding something or this
// isn't padding
if (isDecodingVideoData || !NAL.isPadding(cachedSpecialDesc))
{
break;
}
}
}
// This byte is part of the NAL data
location.offset++;
location.length--;
}
if (isDecodingVideoData && decodingFrame)
{
// The slow path may result in multiple decode units per packet.
// The VideoPacket objects only support being in 1 DU list, so we'll
// copy this data into a new array rather than reference the packet, if
// this NALU ends before the end of the frame. Only copying if this doesn't
// go to the end of the frame means we'll be only copying the SPS and PPS which
// are quite small, while the actual I-frame data is referenced via the packet.
if (location.length != 0) {
// Copy the packet data into a new array
byte[] dataCopy = new byte[location.offset-start];
System.arraycopy(location.data, start, dataCopy, 0, dataCopy.length);
// Chain a descriptor referencing the copied data
chainBufferToCurrentFrame(new ByteBufferDescriptor(dataCopy, 0, dataCopy.length));
}
else {
// Chain this packet to the current frame
chainPacketToCurrentFrame(packet);
// Add a buffer descriptor describing the NAL data in this packet
chainBufferToCurrentFrame(new ByteBufferDescriptor(location.data, start, location.offset-start));
}
}
}
}
private void addInputDataFast(VideoPacket packet, ByteBufferDescriptor location, boolean firstPacket)
{
if (firstPacket) {
// Setup state for the new frame
frameStartTime = TimeHelper.getMonotonicMillis();
}
// Add the payload data to the chain
chainBufferToCurrentFrame(new ByteBufferDescriptor(location));
// The receive thread can't use this until we're done with it
chainPacketToCurrentFrame(packet);
}
private static boolean isFirstPacket(int flags) {
// Clear the picture data flag
flags &= ~VideoPacket.FLAG_CONTAINS_PIC_DATA;
// Check if it's just the start or both start and end of a frame
return (flags == (VideoPacket.FLAG_SOF | VideoPacket.FLAG_EOF) ||
flags == VideoPacket.FLAG_SOF);
}
public void addInputData(VideoPacket packet)
{
// Load our reassembly descriptor
packet.initializePayloadDescriptor(cachedReassemblyDesc);
int flags = packet.getFlags();
int frameIndex = packet.getFrameIndex();
boolean firstPacket = isFirstPacket(flags);
// Drop duplicates or re-ordered packets
int streamPacketIndex = packet.getStreamPacketIndex();
if (SequenceHelper.isBeforeSigned((short)streamPacketIndex, (short)(lastPacketInStream + 1), false)) {
return;
}
// Drop packets from a previously completed frame
if (SequenceHelper.isBeforeSigned(frameIndex, nextFrameNumber, false)) {
return;
}
// Notify the listener of the latest frame we've seen from the PC
controlListener.connectionSawFrame(frameIndex);
// Look for a frame start before receiving a frame end
if (firstPacket && decodingFrame)
{
LimeLog.warning("Network dropped end of a frame");
nextFrameNumber = frameIndex;
// Unexpected start of next frame before terminating the last
waitingForNextSuccessfulFrame = true;
// Clear the old state and wait for an IDR
dropFrameState();
}
// Look for a non-frame start before a frame start
else if (!firstPacket && !decodingFrame) {
// Check if this looks like a real frame
if (flags == VideoPacket.FLAG_CONTAINS_PIC_DATA ||
flags == VideoPacket.FLAG_EOF ||
cachedReassemblyDesc.length < nominalPacketDataLength)
{
LimeLog.warning("Network dropped beginning of a frame");
nextFrameNumber = frameIndex + 1;
waitingForNextSuccessfulFrame = true;
dropFrameState();
decodingFrame = false;
return;
}
else {
// FEC data
return;
}
}
// Check sequencing of this frame to ensure we didn't
// miss one in between
else if (firstPacket) {
// Make sure this is the next consecutive frame
if (SequenceHelper.isBeforeSigned(nextFrameNumber, frameIndex, true)) {
LimeLog.warning("Network dropped an entire frame");
nextFrameNumber = frameIndex;
// Wait until an IDR frame comes
waitingForNextSuccessfulFrame = true;
dropFrameState();
}
else if (nextFrameNumber != frameIndex) {
// Duplicate packet or FEC dup
decodingFrame = false;
return;
}
// We're now decoding a frame
decodingFrame = true;
}
// If it's not the first packet of a frame
// we need to drop it if the stream packet index
// doesn't match
if (!firstPacket && decodingFrame) {
if (streamPacketIndex != (int)(lastPacketInStream + 1)) {
LimeLog.warning("Network dropped middle of a frame");
nextFrameNumber = frameIndex + 1;
waitingForNextSuccessfulFrame = true;
dropFrameState();
decodingFrame = false;
return;
}
}
// Notify the server of any packet losses
if (streamPacketIndex != (int)(lastPacketInStream + 1)) {
// Packets were lost so report this to the server
controlListener.connectionLostPackets(lastPacketInStream, streamPacketIndex);
}
lastPacketInStream = streamPacketIndex;
// If this is the first packet, skip the frame header (if one exists)
if (firstPacket) {
cachedReassemblyDesc.offset += frameHeaderOffset;
cachedReassemblyDesc.length -= frameHeaderOffset;
}
if (firstPacket && isIdrFrameStart(cachedReassemblyDesc))
{
// The slow path doesn't update the frame start time by itself
frameStartTime = TimeHelper.getMonotonicMillis();
// SPS and PPS prefix is padded between NALs, so we must decode it with the slow path
addInputDataSlow(packet, cachedReassemblyDesc);
}
else
{
// Everything else can take the fast path
addInputDataFast(packet, cachedReassemblyDesc, firstPacket);
}
if ((flags & VideoPacket.FLAG_EOF) != 0) {
// Move on to the next frame
decodingFrame = false;
nextFrameNumber = frameIndex + 1;
// If waiting for next successful frame and we got here
// with an end flag, we can send a message to the server
if (waitingForNextSuccessfulFrame) {
// This is the next successful frame after a loss event
controlListener.connectionDetectedFrameLoss(startFrameNumber, nextFrameNumber - 1);
waitingForNextSuccessfulFrame = false;
}
// If we need an IDR frame first, then drop this frame
if (waitingForIdrFrame) {
LimeLog.warning("Waiting for IDR frame");
dropFrameState();
return;
}
reassembleFrame(frameIndex);
startFrameNumber = nextFrameNumber;
}
}
private boolean isIdrFrameStart(ByteBufferDescriptor desc) {
return NAL.getSpecialSequenceDescriptor(desc, cachedSpecialDesc) &&
NAL.isAnnexBFrameStart(cachedSpecialDesc) &&
(cachedSpecialDesc.data[cachedSpecialDesc.offset+cachedSpecialDesc.length] == 0x67 || // H264 SPS
cachedSpecialDesc.data[cachedSpecialDesc.offset+cachedSpecialDesc.length] == 0x40); // H265 VPS
}
public DecodeUnit takeNextDecodeUnit() throws InterruptedException
{
return decodedUnits.takePopulatedObject();
}
public DecodeUnit pollNextDecodeUnit()
{
return decodedUnits.pollPopulatedObject();
}
public void freeDecodeUnit(DecodeUnit du)
{
decodedUnits.freePopulatedObject(du);
}
}
class NAL {
// This assumes that the buffer passed in is already a special sequence
public static boolean isAnnexBStartSequence(ByteBufferDescriptor specialSeq)
{
// The start sequence is 00 00 01 or 00 00 00 01
return (specialSeq.data[specialSeq.offset+specialSeq.length-1] == 0x01);
}
// This assumes that the buffer passed in is already a special sequence
public static boolean isAnnexBFrameStart(ByteBufferDescriptor specialSeq)
{
if (specialSeq.length != 4)
return false;
// The frame start sequence is 00 00 00 01
return (specialSeq.data[specialSeq.offset+specialSeq.length-1] == 0x01);
}
// This assumes that the buffer passed in is already a special sequence
public static boolean isPadding(ByteBufferDescriptor specialSeq)
{
// The padding sequence is 00 00 00
return (specialSeq.data[specialSeq.offset+specialSeq.length-1] == 0x00);
}
// Returns a buffer descriptor describing the start sequence
public static boolean getSpecialSequenceDescriptor(ByteBufferDescriptor buffer, ByteBufferDescriptor outputDesc)
{
// NAL start sequence is 00 00 00 01 or 00 00 01
if (buffer.length < 3)
return false;
// 00 00 is magic
if (buffer.data[buffer.offset] == 0x00 &&
buffer.data[buffer.offset+1] == 0x00)
{
// Another 00 could be the end of the special sequence
// 00 00 00 or the middle of 00 00 00 01
if (buffer.data[buffer.offset+2] == 0x00)
{
if (buffer.length >= 4 &&
buffer.data[buffer.offset+3] == 0x01)
{
// It's the Annex B start sequence 00 00 00 01
outputDesc.reinitialize(buffer.data, buffer.offset, 4);
}
else
{
// It's 00 00 00
outputDesc.reinitialize(buffer.data, buffer.offset, 3);
}
return true;
}
else if (buffer.data[buffer.offset+2] == 0x01 ||
buffer.data[buffer.offset+2] == 0x02)
{
// These are easy: 00 00 01 or 00 00 02
outputDesc.reinitialize(buffer.data, buffer.offset, 3);
return true;
}
else if (buffer.data[buffer.offset+2] == 0x03)
{
// 00 00 03 is special because it's a subsequence of the
// NAL wrapping substitute for 00 00 00, 00 00 01, 00 00 02,
// or 00 00 03 in the RBSP sequence. We need to check the next
// byte to see whether it's 00, 01, 02, or 03 (a valid RBSP substitution)
// or whether it's something else
if (buffer.length < 4)
return false;
if (buffer.data[buffer.offset+3] >= 0x00 &&
buffer.data[buffer.offset+3] <= 0x03)
{
// It's not really a special sequence after all
return false;
}
else
{
// It's not a standard replacement so it's a special sequence
outputDesc.reinitialize(buffer.data, buffer.offset, 3);
return true;
}
}
}
return false;
}
}

View File

@@ -0,0 +1,144 @@
package com.limelight.nvstream.av.video;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.concurrent.atomic.AtomicInteger;
import com.limelight.nvstream.av.ByteBufferDescriptor;
import com.limelight.nvstream.av.RtpPacket;
import com.limelight.nvstream.av.RtpPacketFields;
public class VideoPacket implements RtpPacketFields {
private final ByteBufferDescriptor buffer;
private final ByteBuffer byteBuffer;
private final boolean useAtomicRefCount;
private int dataOffset;
private int frameIndex;
private int flags;
private int streamPacketIndex;
private short rtpSequenceNumber;
private AtomicInteger duAtomicRefCount = new AtomicInteger();
private int duRefCount;
// Only for use in DecodeUnit for packet queuing
public VideoPacket nextPacket;
public static final int FLAG_CONTAINS_PIC_DATA = 0x1;
public static final int FLAG_EOF = 0x2;
public static final int FLAG_SOF = 0x4;
public static final int HEADER_SIZE = 16;
public VideoPacket(byte[] buffer, boolean useAtomicRefCount)
{
this.buffer = new ByteBufferDescriptor(buffer, 0, buffer.length);
this.byteBuffer = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN);
this.useAtomicRefCount = useAtomicRefCount;
}
public void initializeWithLengthNoRtpHeader(int length)
{
// Back to beginning
byteBuffer.rewind();
// No sequence number field is present in these packets
// Read the video header fields
streamPacketIndex = (byteBuffer.getInt() >> 8) & 0xFFFFFF;
frameIndex = byteBuffer.getInt();
flags = byteBuffer.getInt() & 0xFF;
// Data offset without the RTP header
dataOffset = HEADER_SIZE;
// Update descriptor length
buffer.length = length;
}
public void initializeWithLength(int length)
{
// Read the RTP sequence number field (big endian)
byteBuffer.position(2);
rtpSequenceNumber = byteBuffer.getShort();
rtpSequenceNumber = (short)(((rtpSequenceNumber << 8) & 0xFF00) | (((rtpSequenceNumber >> 8) & 0x00FF)));
// Skip the rest of the RTP header
byteBuffer.position(RtpPacket.MAX_HEADER_SIZE);
// Read the video header fields
streamPacketIndex = (byteBuffer.getInt() >> 8) & 0xFFFFFF;
frameIndex = byteBuffer.getInt();
flags = byteBuffer.getInt() & 0xFF;
// Data offset includes the RTP header
dataOffset = RtpPacket.MAX_HEADER_SIZE + HEADER_SIZE;
// Update descriptor length
buffer.length = length;
}
public int getFlags()
{
return flags;
}
public int getFrameIndex()
{
return frameIndex;
}
public int getStreamPacketIndex()
{
return streamPacketIndex;
}
public byte[] getBuffer()
{
return buffer.data;
}
public void initializePayloadDescriptor(ByteBufferDescriptor bb)
{
bb.reinitialize(buffer.data, buffer.offset+dataOffset, buffer.length-dataOffset);
}
public byte getPacketType() {
// No consumers use this field so we don't look it up
return -1;
}
public short getRtpSequenceNumber() {
return rtpSequenceNumber;
}
public int referencePacket() {
if (useAtomicRefCount) {
return duAtomicRefCount.incrementAndGet();
}
else {
return ++duRefCount;
}
}
public int dereferencePacket() {
if (useAtomicRefCount) {
return duAtomicRefCount.decrementAndGet();
}
else {
return --duRefCount;
}
}
public int getRefCount() {
if (useAtomicRefCount) {
return duAtomicRefCount.get();
}
else {
return duRefCount;
}
}
}

View File

@@ -0,0 +1,300 @@
package com.limelight.nvstream.av.video;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketException;
import java.util.LinkedList;
import com.limelight.LimeLog;
import com.limelight.nvstream.ConnectionContext;
import com.limelight.nvstream.av.ConnectionStatusListener;
import com.limelight.nvstream.av.DecodeUnit;
import com.limelight.nvstream.av.RtpPacket;
import com.limelight.nvstream.av.RtpReorderQueue;
public class VideoStream {
private static final int RTP_PORT = 47998;
private static final int FIRST_FRAME_PORT = 47996;
private static final int FIRST_FRAME_TIMEOUT = 5000;
private static final int RTP_RECV_BUFFER = 256 * 1024;
// We can't request an IDR frame until the depacketizer knows
// that a packet was lost. This timeout bounds the time that
// the RTP queue will wait for missing/reordered packets.
private static final int MAX_RTP_QUEUE_DELAY_MS = 10;
// The ring size MUST be greater than or equal to
// the maximum number of packets in a fully
// presentable frame
private static final int VIDEO_RING_SIZE = 384;
private DatagramSocket rtp;
private Socket firstFrameSocket;
private LinkedList<Thread> threads = new LinkedList<Thread>();
private ConnectionContext context;
private ConnectionStatusListener avConnListener;
private VideoDepacketizer depacketizer;
private VideoDecoderRenderer decRend;
private boolean startedRendering;
private boolean aborting = false;
public VideoStream(ConnectionContext context, ConnectionStatusListener avConnListener)
{
this.context = context;
this.avConnListener = avConnListener;
}
public void abort()
{
if (aborting) {
return;
}
aborting = true;
// Interrupt threads
for (Thread t : threads) {
t.interrupt();
}
// Close the socket to interrupt the receive thread
if (rtp != null) {
rtp.close();
}
if (firstFrameSocket != null) {
try {
firstFrameSocket.close();
} catch (IOException e) {}
}
// Wait for threads to terminate
for (Thread t : threads) {
try {
t.join();
} catch (InterruptedException e) { }
}
if (decRend != null) {
if (startedRendering) {
decRend.stop();
}
decRend.release();
}
threads.clear();
}
private void connectFirstFrame() throws IOException
{
firstFrameSocket = new Socket();
firstFrameSocket.setSoTimeout(FIRST_FRAME_TIMEOUT);
firstFrameSocket.connect(new InetSocketAddress(context.serverAddress, FIRST_FRAME_PORT), FIRST_FRAME_TIMEOUT);
}
private void readFirstFrame() throws IOException
{
// We can actually ignore this data. It's the act of gracefully closing the socket
// that matters.
firstFrameSocket.close();
firstFrameSocket = null;
}
public void setupRtpSession() throws SocketException
{
rtp = new DatagramSocket();
rtp.setReceiveBufferSize(RTP_RECV_BUFFER);
}
public boolean setupDecoderRenderer(VideoDecoderRenderer decRend, Object renderTarget, int drFlags) {
this.decRend = decRend;
depacketizer = new VideoDepacketizer(context, avConnListener, context.streamConfig.getMaxPacketSize());
if (decRend != null) {
try {
if (!decRend.setup(context.negotiatedVideoFormat, context.negotiatedWidth,
context.negotiatedHeight, context.negotiatedFps,
renderTarget, drFlags)) {
return false;
}
if (!decRend.start(depacketizer)) {
abort();
return false;
}
startedRendering = true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
return true;
}
public boolean startVideoStream(Object renderTarget, int drFlags) throws IOException
{
// Setup the decoder and renderer
if (!setupDecoderRenderer(context.videoDecoderRenderer, renderTarget, drFlags)) {
// Nothing to cleanup here
throw new IOException("Video decoder failed to initialize. Your device may not support the selected resolution.");
}
// Open RTP sockets and start session
setupRtpSession();
if (this.decRend != null) {
// Start the receive thread early to avoid missing
// early packets that are part of the IDR frame
startReceiveThread();
}
// Open the first frame port connection on Gen 3 servers
if (context.serverGeneration == ConnectionContext.SERVER_GENERATION_3) {
connectFirstFrame();
}
// Start pinging before reading the first frame
// so GFE knows where to send UDP data
startUdpPingThread();
// Read the first frame on Gen 3 servers
if (context.serverGeneration == ConnectionContext.SERVER_GENERATION_3) {
readFirstFrame();
}
return true;
}
private void startReceiveThread()
{
// Receive thread
Thread t = new Thread() {
@Override
public void run() {
VideoPacket ring[] = new VideoPacket[VIDEO_RING_SIZE];
VideoPacket queuedPacket;
int ringIndex = 0;
RtpReorderQueue rtpQueue = new RtpReorderQueue(16, MAX_RTP_QUEUE_DELAY_MS);
RtpReorderQueue.RtpQueueStatus queueStatus;
boolean directSubmit = (decRend != null && (decRend.getCapabilities() &
VideoDecoderRenderer.CAPABILITY_DIRECT_SUBMIT) != 0);
// Preinitialize the ring buffer
int requiredBufferSize = context.streamConfig.getMaxPacketSize() + RtpPacket.MAX_HEADER_SIZE;
for (int i = 0; i < VIDEO_RING_SIZE; i++) {
ring[i] = new VideoPacket(new byte[requiredBufferSize], !directSubmit);
}
byte[] buffer;
DatagramPacket packet = new DatagramPacket(new byte[1], 1); // Placeholder array
int iterationStart;
while (!isInterrupted())
{
try {
// Pull the next buffer in the ring and reset it
buffer = ring[ringIndex].getBuffer();
// Read the video data off the network
packet.setData(buffer, 0, buffer.length);
rtp.receive(packet);
// Initialize the video packet
ring[ringIndex].initializeWithLength(packet.getLength());
queueStatus = rtpQueue.addPacket(ring[ringIndex]);
if (queueStatus == RtpReorderQueue.RtpQueueStatus.HANDLE_IMMEDIATELY) {
// Submit immediately because the packet is in order
depacketizer.addInputData(ring[ringIndex]);
}
else if (queueStatus == RtpReorderQueue.RtpQueueStatus.QUEUED_PACKETS_READY) {
// The packet queue now has packets ready
while ((queuedPacket = (VideoPacket) rtpQueue.getQueuedPacket()) != null) {
depacketizer.addInputData(queuedPacket);
queuedPacket.dereferencePacket();
}
}
// If the DR supports direct submission, call the direct submit callback
if (directSubmit) {
DecodeUnit du;
while ((du = depacketizer.pollNextDecodeUnit()) != null) {
decRend.directSubmitDecodeUnit(du);
}
}
// Go to the next free element in the ring
iterationStart = ringIndex;
do {
ringIndex = (ringIndex + 1) % VIDEO_RING_SIZE;
if (ringIndex == iterationStart) {
// Reinitialize the video ring since they're all being used
LimeLog.warning("Packet ring wrapped around!");
for (int i = 0; i < VIDEO_RING_SIZE; i++) {
ring[i] = new VideoPacket(new byte[requiredBufferSize], !directSubmit);
}
break;
}
} while (ring[ringIndex].getRefCount() != 0);
} catch (IOException e) {
context.connListener.connectionTerminated(e);
return;
}
}
}
};
threads.add(t);
t.setName("Video - Receive");
t.setPriority(Thread.MAX_PRIORITY - 1);
t.start();
}
private void startUdpPingThread()
{
// Ping thread
Thread t = new Thread() {
@Override
public void run() {
// PING in ASCII
final byte[] pingPacketData = new byte[] {0x50, 0x49, 0x4E, 0x47};
DatagramPacket pingPacket = new DatagramPacket(pingPacketData, pingPacketData.length);
pingPacket.setSocketAddress(new InetSocketAddress(context.serverAddress, RTP_PORT));
// Send PING every 500 ms
while (!isInterrupted())
{
try {
rtp.send(pingPacket);
} catch (IOException e) {
context.connListener.connectionTerminated(e);
return;
}
try {
Thread.sleep(500);
} catch (InterruptedException e) {
context.connListener.connectionTerminated(e);
return;
}
}
}
};
threads.add(t);
t.setName("Video - Ping");
t.setPriority(Thread.MIN_PRIORITY);
t.start();
}
}

View File

@@ -0,0 +1,724 @@
package com.limelight.nvstream.control;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.concurrent.LinkedBlockingQueue;
import com.limelight.LimeLog;
import com.limelight.nvstream.ConnectionContext;
import com.limelight.nvstream.av.ConnectionStatusListener;
import com.limelight.nvstream.av.video.VideoDecoderRenderer;
import com.limelight.nvstream.enet.EnetConnection;
import com.limelight.utils.TimeHelper;
public class ControlStream implements ConnectionStatusListener, InputPacketSender {
private static final int TCP_PORT = 47995;
private static final int UDP_PORT = 47999;
private static final int CONTROL_TIMEOUT = 10000;
private static final int IDX_START_A = 0;
private static final int IDX_REQUEST_IDR_FRAME = 0;
private static final int IDX_START_B = 1;
private static final int IDX_INVALIDATE_REF_FRAMES = 2;
private static final int IDX_LOSS_STATS = 3;
private static final int IDX_INPUT_DATA = 5;
private static final short packetTypesGen3[] = {
0x1407, // Request IDR frame
0x1410, // Start B
0x1404, // Invalidate reference frames
0x140c, // Loss Stats
0x1417, // Frame Stats (unused)
-1, // Input data (unused)
};
private static final short packetTypesGen4[] = {
0x0606, // Request IDR frame
0x0609, // Start B
0x0604, // Invalidate reference frames
0x060a, // Loss Stats
0x0611, // Frame Stats (unused)
-1, // Input data (unused)
};
private static final short packetTypesGen5[] = {
0x0305, // Start A
0x0307, // Start B
0x0301, // Invalidate reference frames
0x0201, // Loss Stats
0x0204, // Frame Stats (unused)
0x0207, // Input data
};
private static final short packetTypesGen7[] = {
0x0305, // Start A
0x0307, // Start B
0x0301, // Invalidate reference frames
0x0201, // Loss Stats
0x0204, // Frame Stats (unused)
0x0206, // Input data
};
private static final short payloadLengthsGen3[] = {
-1, // Request IDR frame
16, // Start B
24, // Invalidate reference frames
32, // Loss Stats
64, // Frame Stats
-1, // Input Data
};
private static final short payloadLengthsGen4[] = {
-1, // Request IDR frame
-1, // Start B
24, // Invalidate reference frames
32, // Loss Stats
64, // Frame Stats
-1, // Input Data
};
private static final short payloadLengthsGen5[] = {
-1, // Start A
16, // Start B
24, // Invalidate reference frames
32, // Loss Stats
80, // Frame Stats
-1, // Input Data
};
private static final short payloadLengthsGen7[] = {
-1, // Start A
16, // Start B
24, // Invalidate reference frames
32, // Loss Stats
80, // Frame Stats
-1, // Input Data
};
private static final byte[] precontructedPayloadsGen3[] = {
new byte[]{0, 0}, // Request IDR frame
null, // Start B
null, // Invalidate reference frames
null, // Loss Stats
null, // Frame Stats
null, // Input Data
};
private static final byte[] precontructedPayloadsGen4[] = {
new byte[]{0, 0}, // Request IDR frame
new byte[]{0}, // Start B
null, // Invalidate reference frames
null, // Loss Stats
null, // Frame Stats
null, // Input Data
};
private static final byte[] precontructedPayloadsGen5[] = {
new byte[]{0, 0}, // Start A
null, // Start B
null, // Invalidate reference frames
null, // Loss Stats
null, // Frame Stats
null, // Input Data
};
private static final byte[] precontructedPayloadsGen7[] = {
new byte[]{0, 0}, // Start A
null, // Start B
null, // Invalidate reference frames
null, // Loss Stats
null, // Frame Stats
null, // Input Data
};
public static final int LOSS_REPORT_INTERVAL_MS = 50;
private int lastGoodFrame;
private int lastSeenFrame;
private int lossCountSinceLastReport;
private ConnectionContext context;
// If we drop at least 10 frames in 15 second (or less) window
// more than 5 times in 60 seconds, we'll display a warning
public static final int LOSS_PERIOD_MS = 15000;
public static final int LOSS_EVENT_TIME_THRESHOLD_MS = 60000;
public static final int MAX_LOSS_COUNT_IN_PERIOD = 10;
public static final int LOSS_EVENTS_TO_WARN = 5;
public static final int MAX_SLOW_SINK_COUNT = 2;
public static final int MESSAGE_DELAY_FACTOR = 3;
private long lossTimestamp;
private long lossEventTimestamp;
private int lossCount;
private int lossEventCount;
private int slowSinkCount;
// Used on Gen 5 servers and above
private EnetConnection enetConnection;
// Used on Gen 4 servers and below
private Socket s;
private InputStream in;
private OutputStream out;
private Thread lossStatsThread;
private Thread resyncThread;
private LinkedBlockingQueue<int[]> invalidReferenceFrameTuples = new LinkedBlockingQueue<int[]>();
private boolean aborting = false;
private boolean forceIdrRequest;
private final short[] packetTypes;
private final short[] payloadLengths;
private final byte[][] preconstructedPayloads;
public ControlStream(ConnectionContext context)
{
this.context = context;
switch (context.serverGeneration)
{
case ConnectionContext.SERVER_GENERATION_3:
packetTypes = packetTypesGen3;
payloadLengths = payloadLengthsGen3;
preconstructedPayloads = precontructedPayloadsGen3;
break;
case ConnectionContext.SERVER_GENERATION_4:
packetTypes = packetTypesGen4;
payloadLengths = payloadLengthsGen4;
preconstructedPayloads = precontructedPayloadsGen4;
break;
case ConnectionContext.SERVER_GENERATION_5:
packetTypes = packetTypesGen5;
payloadLengths = payloadLengthsGen5;
preconstructedPayloads = precontructedPayloadsGen5;
break;
case ConnectionContext.SERVER_GENERATION_7:
default:
packetTypes = packetTypesGen7;
payloadLengths = payloadLengthsGen7;
preconstructedPayloads = precontructedPayloadsGen7;
break;
}
if (context.videoDecoderRenderer != null) {
forceIdrRequest = (context.videoDecoderRenderer.getCapabilities() &
VideoDecoderRenderer.CAPABILITY_REFERENCE_FRAME_INVALIDATION) == 0;
}
}
public void initialize() throws IOException
{
if (context.serverGeneration >= ConnectionContext.SERVER_GENERATION_5) {
enetConnection = EnetConnection.connect(context.serverAddress.getHostAddress(), UDP_PORT, CONTROL_TIMEOUT);
}
else {
s = new Socket();
s.setTcpNoDelay(true);
s.connect(new InetSocketAddress(context.serverAddress, TCP_PORT), CONTROL_TIMEOUT);
in = s.getInputStream();
out = s.getOutputStream();
}
}
private void sendPacket(NvCtlPacket packet) throws IOException
{
// Prevent multiple clients from writing to the stream at the same time
synchronized (this) {
if (context.serverGeneration >= ConnectionContext.SERVER_GENERATION_5) {
enetConnection.pumpSocket();
packet.write(enetConnection);
}
else {
packet.write(out);
out.flush();
}
}
}
private void sendAndDiscardReply(NvCtlPacket packet) throws IOException
{
synchronized (this) {
sendPacket(packet);
if (context.serverGeneration >= ConnectionContext.SERVER_GENERATION_5) {
enetConnection.readPacket(0, CONTROL_TIMEOUT);
}
else {
new NvCtlResponse(in);
}
}
}
private void sendLossStats(ByteBuffer bb) throws IOException
{
bb.rewind();
bb.putInt(lossCountSinceLastReport); // Packet loss count
bb.putInt(LOSS_REPORT_INTERVAL_MS); // Time since last report in milliseconds
bb.putInt(1000);
bb.putLong(lastGoodFrame); // Last successfully received frame
bb.putInt(0);
bb.putInt(0);
bb.putInt(0x14);
sendPacket(new NvCtlPacket(packetTypes[IDX_LOSS_STATS],
payloadLengths[IDX_LOSS_STATS], bb.array()));
}
public void sendInputPacket(byte[] data, short length) throws IOException {
sendPacket(new NvCtlPacket(packetTypes[IDX_INPUT_DATA], length, data));
}
public void abort()
{
if (aborting) {
return;
}
aborting = true;
if (s != null) {
try {
s.close();
} catch (IOException e) {}
}
if (lossStatsThread != null) {
lossStatsThread.interrupt();
try {
lossStatsThread.join();
} catch (InterruptedException e) {}
}
if (resyncThread != null) {
resyncThread.interrupt();
try {
resyncThread.join();
} catch (InterruptedException e) {}
}
if (enetConnection != null) {
try {
enetConnection.close();
} catch (IOException e) {}
}
}
public void start() throws IOException
{
// Use a finite timeout during the handshake process
if (s != null) {
s.setSoTimeout(CONTROL_TIMEOUT);
}
doStartA();
doStartB();
// Return to an infinte read timeout after the initial control handshake
if (s != null) {
s.setSoTimeout(0);
}
lossStatsThread = new Thread() {
@Override
public void run() {
ByteBuffer bb = ByteBuffer.allocate(payloadLengths[IDX_LOSS_STATS]).order(ByteOrder.LITTLE_ENDIAN);
while (!isInterrupted())
{
try {
sendLossStats(bb);
lossCountSinceLastReport = 0;
} catch (IOException e) {
context.connListener.connectionTerminated(e);
return;
}
try {
Thread.sleep(LOSS_REPORT_INTERVAL_MS);
} catch (InterruptedException e) {
context.connListener.connectionTerminated(e);
return;
}
}
}
};
lossStatsThread.setPriority(Thread.MIN_PRIORITY + 1);
lossStatsThread.setName("Control - Loss Stats Thread");
lossStatsThread.start();
resyncThread = new Thread() {
@Override
public void run() {
while (!isInterrupted())
{
int[] tuple;
boolean idrFrameRequired = false;
// Wait for a tuple
try {
tuple = invalidReferenceFrameTuples.take();
} catch (InterruptedException e) {
context.connListener.connectionTerminated(e);
return;
}
// Check for the magic IDR frame tuple
int[] lastTuple = null;
if (tuple[0] != 0 || tuple[1] != 0) {
// Aggregate all lost frames into one range
for (;;) {
int[] nextTuple = lastTuple = invalidReferenceFrameTuples.poll();
if (nextTuple == null) {
break;
}
// Check if this tuple has IDR frame magic values
if (nextTuple[0] == 0 && nextTuple[1] == 0) {
// We will need an IDR frame now, but we won't break out
// of the loop because we want to dequeue all pending requests
idrFrameRequired = true;
}
lastTuple = nextTuple;
}
}
else {
// We must require an IDR frame
idrFrameRequired = true;
}
try {
if (forceIdrRequest || idrFrameRequired) {
requestIdrFrame();
}
else {
// Update the end of the range to the latest tuple
if (lastTuple != null) {
tuple[1] = lastTuple[1];
}
invalidateReferenceFrames(tuple[0], tuple[1]);
}
} catch (IOException e) {
context.connListener.connectionTerminated(e);
return;
}
}
}
};
resyncThread.setName("Control - Resync Thread");
resyncThread.setPriority(Thread.MAX_PRIORITY - 1);
resyncThread.start();
}
private void doStartA() throws IOException
{
sendAndDiscardReply(new NvCtlPacket(packetTypes[IDX_START_A],
(short) preconstructedPayloads[IDX_START_A].length,
preconstructedPayloads[IDX_START_A]));
}
private void doStartB() throws IOException
{
// Gen 3 and 5 both use a packet of this form
if (context.serverGeneration != ConnectionContext.SERVER_GENERATION_4) {
ByteBuffer payload = ByteBuffer.wrap(new byte[payloadLengths[IDX_START_B]]).order(ByteOrder.LITTLE_ENDIAN);
payload.putInt(0);
payload.putInt(0);
payload.putInt(0);
payload.putInt(0xa);
sendAndDiscardReply(new NvCtlPacket(packetTypes[IDX_START_B],
payloadLengths[IDX_START_B], payload.array()));
}
else {
sendAndDiscardReply(new NvCtlPacket(packetTypes[IDX_START_B],
(short) preconstructedPayloads[IDX_START_B].length,
preconstructedPayloads[IDX_START_B]));
}
}
private void requestIdrFrame() throws IOException {
// On Gen 3, we use the invalidate reference frames trick.
// On Gen 4+, we use the known IDR frame request packet
// On Gen 5, we're currently using the invalidate reference frames trick again.
if (context.serverGeneration >= ConnectionContext.SERVER_GENERATION_5) {
ByteBuffer conf = ByteBuffer.wrap(new byte[payloadLengths[IDX_INVALIDATE_REF_FRAMES]]).order(ByteOrder.LITTLE_ENDIAN);
//conf.putLong(firstLostFrame);
//conf.putLong(nextSuccessfulFrame);
// Early on, we'll use a special IDR sequence. Otherwise,
// we'll just say we lost the last 32 frames. This is larger
// than the number of buffered frames in the encoder (16) so
// it should trigger an IDR frame.
if (lastSeenFrame < 0x20) {
conf.putLong(0);
conf.putLong(0x20);
}
else {
conf.putLong(lastSeenFrame - 0x20);
conf.putLong(lastSeenFrame);
}
conf.putLong(0);
sendAndDiscardReply(new NvCtlPacket(packetTypes[IDX_INVALIDATE_REF_FRAMES],
payloadLengths[IDX_INVALIDATE_REF_FRAMES], conf.array()));
}
else {
sendAndDiscardReply(new NvCtlPacket(packetTypes[IDX_REQUEST_IDR_FRAME],
(short) preconstructedPayloads[IDX_REQUEST_IDR_FRAME].length,
preconstructedPayloads[IDX_REQUEST_IDR_FRAME]));
}
LimeLog.warning("IDR frame request sent");
}
private void invalidateReferenceFrames(int firstLostFrame, int nextSuccessfulFrame) throws IOException {
LimeLog.warning("Invalidating reference frames from "+firstLostFrame+" to "+nextSuccessfulFrame);
ByteBuffer conf = ByteBuffer.wrap(new byte[payloadLengths[IDX_INVALIDATE_REF_FRAMES]]).order(ByteOrder.LITTLE_ENDIAN);
conf.putLong(firstLostFrame);
conf.putLong(nextSuccessfulFrame);
conf.putLong(0);
sendAndDiscardReply(new NvCtlPacket(packetTypes[IDX_INVALIDATE_REF_FRAMES],
payloadLengths[IDX_INVALIDATE_REF_FRAMES], conf.array()));
LimeLog.warning("Reference frame invalidation sent");
}
static class NvCtlPacket {
public short type;
public short paylen;
public byte[] payload;
private static final ByteBuffer headerBuffer = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN);
private static final ByteBuffer serializationBuffer = ByteBuffer.allocate(256).order(ByteOrder.LITTLE_ENDIAN);
public NvCtlPacket(InputStream in) throws IOException
{
// Use the class's static header buffer for parsing the header
synchronized (headerBuffer) {
int offset = 0;
byte[] header = headerBuffer.array();
do
{
int bytesRead = in.read(header, offset, header.length - offset);
if (bytesRead < 0) {
break;
}
offset += bytesRead;
} while (offset != header.length);
if (offset != header.length) {
throw new IOException("Socket closed prematurely");
}
headerBuffer.rewind();
type = headerBuffer.getShort();
paylen = headerBuffer.getShort();
}
if (paylen != 0)
{
payload = new byte[paylen];
int offset = 0;
do
{
int bytesRead = in.read(payload, offset, payload.length - offset);
if (bytesRead < 0) {
break;
}
offset += bytesRead;
} while (offset != payload.length);
if (offset != payload.length) {
throw new IOException("Socket closed prematurely");
}
}
}
public NvCtlPacket(byte[] packet)
{
synchronized (headerBuffer) {
headerBuffer.rewind();
headerBuffer.put(packet, 0, 4);
headerBuffer.rewind();
type = headerBuffer.getShort();
paylen = headerBuffer.getShort();
}
if (paylen != 0)
{
payload = new byte[paylen];
System.arraycopy(packet, 4, payload, 0, paylen);
}
}
public NvCtlPacket(short type, short paylen)
{
this.type = type;
this.paylen = paylen;
}
public NvCtlPacket(short type, short paylen, byte[] payload)
{
this.type = type;
this.paylen = paylen;
this.payload = payload;
}
public short getType()
{
return type;
}
public short getPaylen()
{
return paylen;
}
public void setType(short type)
{
this.type = type;
}
public void setPaylen(short paylen)
{
this.paylen = paylen;
}
public void write(OutputStream out) throws IOException
{
// Use the class's serialization buffer to construct the wireform to send
synchronized (serializationBuffer) {
serializationBuffer.rewind();
serializationBuffer.limit(serializationBuffer.capacity());
serializationBuffer.putShort(type);
serializationBuffer.putShort(paylen);
serializationBuffer.put(payload, 0, paylen);
out.write(serializationBuffer.array(), 0, serializationBuffer.position());
}
}
public void write(EnetConnection conn) throws IOException
{
// Use the class's serialization buffer to construct the wireform to send
synchronized (serializationBuffer) {
serializationBuffer.rewind();
serializationBuffer.limit(serializationBuffer.capacity());
serializationBuffer.putShort(type);
serializationBuffer.put(payload, 0, paylen);
serializationBuffer.limit(serializationBuffer.position());
conn.writePacket(serializationBuffer);
}
}
}
class NvCtlResponse extends NvCtlPacket {
public short status;
public NvCtlResponse(InputStream in) throws IOException {
super(in);
}
public NvCtlResponse(short type, short paylen) {
super(type, paylen);
}
public NvCtlResponse(short type, short paylen, byte[] payload) {
super(type, paylen, payload);
}
public NvCtlResponse(byte[] payload) {
super(payload);
}
public void setStatusCode(short status)
{
this.status = status;
}
public short getStatusCode()
{
return status;
}
}
private void resyncConnection(int firstLostFrame, int nextSuccessfulFrame) {
invalidReferenceFrameTuples.add(new int[]{firstLostFrame, nextSuccessfulFrame});
}
public void connectionDetectedFrameLoss(int firstLostFrame, int nextSuccessfulFrame) {
resyncConnection(firstLostFrame, nextSuccessfulFrame);
// Suppress connection warnings for the first 150 frames to allow the connection
// to stabilize
if (lastGoodFrame < 150) {
return;
}
// Reset the loss count if it's been too long
if (TimeHelper.getMonotonicMillis() > LOSS_PERIOD_MS + lossTimestamp) {
lossCount = 0;
lossTimestamp = TimeHelper.getMonotonicMillis();
}
// Count this loss event
if (++lossCount == MAX_LOSS_COUNT_IN_PERIOD) {
// Reset the loss event count if it's been too long
if (TimeHelper.getMonotonicMillis() > LOSS_EVENT_TIME_THRESHOLD_MS + lossEventTimestamp) {
lossEventCount = 0;
lossEventTimestamp = TimeHelper.getMonotonicMillis();
}
if (++lossEventCount == LOSS_EVENTS_TO_WARN) {
context.connListener.displayTransientMessage("Poor network connection");
lossEventCount = 0;
lossEventTimestamp = 0;
}
lossCount = 0;
lossTimestamp = 0;
}
}
public void connectionSinkTooSlow(int firstLostFrame, int nextSuccessfulFrame) {
resyncConnection(firstLostFrame, nextSuccessfulFrame);
// Suppress connection warnings for the first 150 frames to allow the connection
// to stabilize
if (lastGoodFrame < 150) {
return;
}
if (++slowSinkCount == MAX_SLOW_SINK_COUNT) {
context.connListener.displayTransientMessage("Your device is processing the A/V data too slowly. Try lowering stream resolution and/or frame rate.");
slowSinkCount = -MAX_SLOW_SINK_COUNT * MESSAGE_DELAY_FACTOR;
}
}
public void connectionReceivedCompleteFrame(int frameIndex) {
lastGoodFrame = frameIndex;
}
public void connectionSawFrame(int frameIndex) {
lastSeenFrame = frameIndex;
}
public void connectionLostPackets(int lastReceivedPacket, int nextReceivedPacket) {
// Update the loss count for the next loss report
lossCountSinceLastReport += (nextReceivedPacket - lastReceivedPacket) - 1;
}
}

View File

@@ -0,0 +1,7 @@
package com.limelight.nvstream.control;
import java.io.IOException;
public interface InputPacketSender {
void sendInputPacket(byte[] data, short length) throws IOException;
}

View File

@@ -0,0 +1,112 @@
package com.limelight.nvstream.enet;
import java.io.Closeable;
import java.io.IOException;
import java.nio.ByteBuffer;
public class EnetConnection implements Closeable {
private long enetPeer;
private long enetClient;
private static final int ENET_PACKET_FLAG_RELIABLE = 1;
static {
System.loadLibrary("jnienet");
initializeEnet();
}
private EnetConnection() {}
public static EnetConnection connect(String host, int port, int timeout) throws IOException {
EnetConnection conn = new EnetConnection();
conn.enetClient = createClient(host);
if (conn.enetClient == 0) {
throw new IOException("Unable to create ENet client");
}
conn.enetPeer = connectToPeer(conn.enetClient, host, port, timeout);
if (conn.enetPeer == 0) {
try {
conn.close();
} catch (IOException e) {}
throw new IOException("Unable to connect to UDP port "+port);
}
return conn;
}
public void pumpSocket() throws IOException {
int ret;
while ((ret = readPacket(enetClient, null, 0, 0)) > 0);
if (ret < 0) {
throw new IOException("ENet connection failed");
}
}
public ByteBuffer readPacket(int maxSize, int timeout) throws IOException {
ByteBuffer buffer;
byte[] array;
int length;
if (maxSize != 0) {
buffer = ByteBuffer.allocate(maxSize);
array = buffer.array();
length = buffer.limit();
}
else {
// The caller doesn't want the packet back
buffer = null;
array = null;
length = 0;
}
int readLength = readPacket(enetClient, array, length, timeout);
if (readLength > length && length != 0) {
// This is a packet that was unexpectedly large compared to
// what the caller was expected.
throw new IOException("Received ENet packet too large: "+readLength);
}
else if (readLength <= 0) {
// We either got nothing or a socket error
throw new IOException("Failed to receive ENet packet");
}
else if (length == 0) {
// We received a packet but the caller didn't want it back
return null;
}
else {
// A packet was received which matched the caller's expectations
buffer.limit(readLength);
return buffer;
}
}
public void writePacket(ByteBuffer buffer) throws IOException {
if (!writePacket(enetClient, enetPeer, buffer.array(), buffer.limit(), ENET_PACKET_FLAG_RELIABLE)) {
throw new IOException("Failed to send ENet packet");
}
}
@Override
public void close() throws IOException {
if (enetPeer != 0) {
disconnectPeer(enetPeer);
enetPeer = 0;
}
if (enetClient != 0) {
destroyClient(enetClient);
enetClient = 0;
}
}
private static native int initializeEnet();
private static native long createClient(String address);
private static native long connectToPeer(long client, String host, int port, int timeout);
private static native int readPacket(long client, byte[] data, int length, int timeout);
private static native boolean writePacket(long client, long peer, byte[] data, int length, int packetFlags);
private static native void destroyClient(long client);
private static native void disconnectPeer(long peer);
}

View File

@@ -0,0 +1,64 @@
package com.limelight.nvstream.http;
import java.net.InetAddress;
import java.util.UUID;
public class ComputerDetails {
public enum State {
ONLINE, OFFLINE, UNKNOWN
}
public enum Reachability {
LOCAL, REMOTE, OFFLINE, UNKNOWN
}
public State state;
public Reachability reachability;
public String name;
public UUID uuid;
public InetAddress localIp;
public InetAddress remoteIp;
public PairingManager.PairState pairState;
public String macAddress;
public int runningGameId;
public String rawAppList;
public ComputerDetails() {
// Use defaults
state = State.UNKNOWN;
reachability = Reachability.UNKNOWN;
}
public ComputerDetails(ComputerDetails details) {
// Copy details from the other computer
update(details);
}
public void update(ComputerDetails details) {
this.state = details.state;
this.reachability = details.reachability;
this.name = details.name;
this.uuid = details.uuid;
this.localIp = details.localIp;
this.remoteIp = details.remoteIp;
this.macAddress = details.macAddress;
this.pairState = details.pairState;
this.runningGameId = details.runningGameId;
this.rawAppList = details.rawAppList;
}
@Override
public String toString() {
StringBuilder str = new StringBuilder();
str.append("State: ").append(state).append("\n");
str.append("Reachability: ").append(reachability).append("\n");
str.append("Name: ").append(name).append("\n");
str.append("UUID: ").append(uuid).append("\n");
str.append("Local IP: ").append(localIp).append("\n");
str.append("Remote IP: ").append(remoteIp).append("\n");
str.append("MAC Address: ").append(macAddress).append("\n");
str.append("Pair State: ").append(pairState).append("\n");
str.append("Running Game ID: ").append(runningGameId).append("\n");
return str.toString();
}
}

View File

@@ -0,0 +1,28 @@
package com.limelight.nvstream.http;
import java.io.IOException;
public class GfeHttpResponseException extends IOException {
private static final long serialVersionUID = 1543508830807804222L;
private int errorCode;
private String errorMsg;
public GfeHttpResponseException(int errorCode, String errorMsg) {
this.errorCode = errorCode;
this.errorMsg = errorMsg;
}
public int getErrorCode() {
return errorCode;
}
public String getErrorMessage() {
return errorMsg;
}
@Override
public String getMessage() {
return "GFE error: "+errorMsg+" (Error code: "+errorCode+")";
}
}

View File

@@ -0,0 +1,11 @@
package com.limelight.nvstream.http;
import java.security.cert.X509Certificate;
import java.security.interfaces.RSAPrivateKey;
public interface LimelightCryptoProvider {
public X509Certificate getClientCertificate();
public RSAPrivateKey getClientPrivateKey();
public byte[] getPemEncodedClientCertificate();
public String encodeBase64String(byte[] data);
}

View File

@@ -0,0 +1,51 @@
package com.limelight.nvstream.http;
import com.limelight.LimeLog;
public class NvApp {
private String appName = "";
private int appId;
private boolean initialized;
public NvApp() {}
public NvApp(String appName) {
this.appName = appName;
}
public NvApp(String appName, int appId) {
this.appName = appName;
this.appId = appId;
this.initialized = true;
}
public void setAppName(String appName) {
this.appName = appName;
}
public void setAppId(String appId) {
try {
this.appId = Integer.parseInt(appId);
this.initialized = true;
} catch (NumberFormatException e) {
LimeLog.warning("Malformed app ID: "+appId);
}
}
public void setAppId(int appId) {
this.appId = appId;
this.initialized = true;
}
public String getAppName() {
return this.appName;
}
public int getAppId() {
return this.appId;
}
public boolean isInitialized() {
return this.initialized;
}
}

View File

@@ -0,0 +1,652 @@
package com.limelight.nvstream.http;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.StringReader;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.Socket;
import java.security.Principal;
import java.security.PrivateKey;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.util.LinkedList;
import java.util.ListIterator;
import java.util.Scanner;
import java.util.Stack;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.KeyManager;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSession;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509KeyManager;
import javax.net.ssl.X509TrustManager;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlPullParserFactory;
import com.limelight.LimeLog;
import com.limelight.nvstream.ConnectionContext;
import com.limelight.nvstream.http.PairingManager.PairState;
import com.squareup.okhttp.ConnectionPool;
import com.squareup.okhttp.OkHttpClient;
import com.squareup.okhttp.Request;
import com.squareup.okhttp.Response;
import com.squareup.okhttp.ResponseBody;
public class NvHTTP {
private String uniqueId;
private PairingManager pm;
private InetAddress address;
public static final int HTTPS_PORT = 47984;
public static final int HTTP_PORT = 47989;
public static final int CONNECTION_TIMEOUT = 3000;
public static final int READ_TIMEOUT = 5000;
private static boolean verbose = false;
public String baseUrlHttps;
public String baseUrlHttp;
private OkHttpClient httpClient = new OkHttpClient();
private OkHttpClient httpClientWithReadTimeout;
private TrustManager[] trustAllCerts;
private KeyManager[] ourKeyman;
private void initializeHttpState(final LimelightCryptoProvider cryptoProvider) {
trustAllCerts = new TrustManager[] {
new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
public void checkClientTrusted(X509Certificate[] certs, String authType) {}
public void checkServerTrusted(X509Certificate[] certs, String authType) {}
}};
ourKeyman = new KeyManager[] {
new X509KeyManager() {
public String chooseClientAlias(String[] keyTypes,
Principal[] issuers, Socket socket) { return "Limelight-RSA"; }
public String chooseServerAlias(String keyType, Principal[] issuers,
Socket socket) { return null; }
public X509Certificate[] getCertificateChain(String alias) {
return new X509Certificate[] {cryptoProvider.getClientCertificate()};
}
public String[] getClientAliases(String keyType, Principal[] issuers) { return null; }
public PrivateKey getPrivateKey(String alias) {
return cryptoProvider.getClientPrivateKey();
}
public String[] getServerAliases(String keyType, Principal[] issuers) { return null; }
}
};
// Ignore differences between given hostname and certificate hostname
HostnameVerifier hv = new HostnameVerifier() {
public boolean verify(String hostname, SSLSession session) { return true; }
};
httpClient.setConnectionPool(new ConnectionPool(0, 0));
httpClient.setHostnameVerifier(hv);
httpClient.setConnectTimeout(CONNECTION_TIMEOUT, TimeUnit.MILLISECONDS);
httpClientWithReadTimeout = httpClient.clone();
httpClientWithReadTimeout.setReadTimeout(READ_TIMEOUT, TimeUnit.MILLISECONDS);
}
public NvHTTP(InetAddress host, String uniqueId, String deviceName, LimelightCryptoProvider cryptoProvider) {
this.uniqueId = uniqueId;
this.address = host;
String safeAddress;
if (host instanceof Inet6Address) {
// RFC2732-formatted IPv6 address for use in URL
safeAddress = "["+host.getHostAddress()+"]";
}
else {
safeAddress = host.getHostAddress();
}
initializeHttpState(cryptoProvider);
this.baseUrlHttps = "https://" + safeAddress + ":" + HTTPS_PORT;
this.baseUrlHttp = "http://" + safeAddress + ":" + HTTP_PORT;
this.pm = new PairingManager(this, cryptoProvider);
}
String buildUniqueIdUuidString() {
return "uniqueid="+uniqueId+"&uuid="+UUID.randomUUID();
}
static String getXmlString(Reader r, String tagname) throws XmlPullParserException, IOException {
XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
factory.setNamespaceAware(true);
XmlPullParser xpp = factory.newPullParser();
xpp.setInput(r);
int eventType = xpp.getEventType();
Stack<String> currentTag = new Stack<String>();
while (eventType != XmlPullParser.END_DOCUMENT) {
switch (eventType) {
case (XmlPullParser.START_TAG):
if (xpp.getName().equals("root")) {
verifyResponseStatus(xpp);
}
currentTag.push(xpp.getName());
break;
case (XmlPullParser.END_TAG):
currentTag.pop();
break;
case (XmlPullParser.TEXT):
if (currentTag.peek().equals(tagname)) {
return xpp.getText().trim();
}
break;
}
eventType = xpp.next();
}
return null;
}
static String getXmlString(String str, String tagname) throws XmlPullParserException, IOException {
return getXmlString(new StringReader(str), tagname);
}
static String getXmlString(InputStream in, String tagname) throws XmlPullParserException, IOException {
return getXmlString(new InputStreamReader(in), tagname);
}
private static void verifyResponseStatus(XmlPullParser xpp) throws GfeHttpResponseException {
int statusCode = Integer.parseInt(xpp.getAttributeValue(XmlPullParser.NO_NAMESPACE, "status_code"));
if (statusCode != 200) {
throw new GfeHttpResponseException(statusCode, xpp.getAttributeValue(XmlPullParser.NO_NAMESPACE, "status_message"));
}
}
public String getServerInfo() throws MalformedURLException, IOException, XmlPullParserException {
String resp;
//
// TODO: Shield Hub uses HTTP for this and is able to get an accurate PairStatus with HTTP.
// For some reason, we always see PairStatus is 0 over HTTP and only 1 over HTTPS. It looks
// like there are extra request headers required to make this stuff work over HTTP.
//
try {
resp = openHttpConnectionToString(baseUrlHttps + "/serverinfo?"+buildUniqueIdUuidString(), true);
// This will throw an exception if the request came back with a failure status.
// We want this because it will throw us into the HTTP case if the client is unpaired.
getServerVersion(resp);
}
catch (GfeHttpResponseException e) {
if (e.getErrorCode() == 401) {
// Cert validation error - fall back to HTTP
return openHttpConnectionToString(baseUrlHttp + "/serverinfo", true);
}
// If it's not a cert validation error, throw it
throw e;
}
return resp;
}
public ComputerDetails getComputerDetails() throws MalformedURLException, IOException, XmlPullParserException {
ComputerDetails details = new ComputerDetails();
String serverInfo = getServerInfo();
details.name = getXmlString(serverInfo, "hostname");
details.uuid = UUID.fromString(getXmlString(serverInfo, "uniqueid"));
details.macAddress = getXmlString(serverInfo, "mac");
// If there's no LocalIP field, use the address we hit the server on
String localIpStr = getXmlString(serverInfo, "LocalIP");
if (localIpStr == null) {
localIpStr = address.getHostAddress();
}
// If there's no ExternalIP field, use the address we hit the server on
String externalIpStr = getXmlString(serverInfo, "ExternalIP");
if (externalIpStr == null) {
externalIpStr = address.getHostAddress();
}
details.localIp = InetAddress.getByName(localIpStr);
details.remoteIp = InetAddress.getByName(externalIpStr);
try {
details.pairState = Integer.parseInt(getXmlString(serverInfo, "PairStatus")) == 1 ?
PairState.PAIRED : PairState.NOT_PAIRED;
} catch (NumberFormatException e) {
details.pairState = PairState.FAILED;
}
try {
details.runningGameId = getCurrentGame(serverInfo);
} catch (NumberFormatException e) {
details.runningGameId = 0;
}
// We could reach it so it's online
details.state = ComputerDetails.State.ONLINE;
return details;
}
// This hack is Android-specific but we do it on all platforms
// because it doesn't really matter
private void performAndroidTlsHack(OkHttpClient client) {
// Doing this each time we create a socket is required
// to avoid the SSLv3 fallback that causes connection failures
try {
SSLContext sc = SSLContext.getInstance("TLSv1");
sc.init(ourKeyman, trustAllCerts, new SecureRandom());
client.setSslSocketFactory(sc.getSocketFactory());
} catch (Exception e) {
e.printStackTrace();
}
}
// Read timeout should be enabled for any HTTP query that requires no outside action
// on the GFE server. Examples of queries that DO require outside action are launch, resume, and quit.
// The initial pair query does require outside action (user entering a PIN) but subsequent pairing
// queries do not.
private ResponseBody openHttpConnection(String url, boolean enableReadTimeout) throws IOException {
Request request = new Request.Builder().url(url).build();
Response response;
if (enableReadTimeout) {
performAndroidTlsHack(httpClientWithReadTimeout);
response = httpClientWithReadTimeout.newCall(request).execute();
}
else {
performAndroidTlsHack(httpClient);
response = httpClient.newCall(request).execute();
}
ResponseBody body = response.body();
if (response.isSuccessful()) {
return body;
}
// Unsuccessful, so close the response body
try {
if (body != null) {
body.close();
}
} catch (IOException e) {}
if (response.code() == 404) {
throw new FileNotFoundException(url);
}
else {
throw new IOException("HTTP request failed: "+response.code());
}
}
String openHttpConnectionToString(String url, boolean enableReadTimeout) throws MalformedURLException, IOException {
if (verbose) {
LimeLog.info("Requesting URL: "+url);
}
ResponseBody resp;
try {
resp = openHttpConnection(url, enableReadTimeout);
} catch (IOException e) {
if (verbose) {
e.printStackTrace();
}
throw e;
}
StringBuilder strb = new StringBuilder();
try {
Scanner s = new Scanner(resp.byteStream());
try {
while (s.hasNext()) {
strb.append(s.next());
strb.append(' ');
}
} finally {
s.close();
}
} finally {
resp.close();
}
if (verbose) {
LimeLog.info(url+" -> "+strb.toString());
}
return strb.toString();
}
public String getServerVersion(String serverInfo) throws XmlPullParserException, IOException {
return getXmlString(serverInfo, "appversion");
}
public PairingManager.PairState getPairState() throws IOException, XmlPullParserException {
return pm.getPairState(getServerInfo());
}
public PairingManager.PairState getPairState(String serverInfo) throws IOException, XmlPullParserException {
return pm.getPairState(serverInfo);
}
public long getMaxLumaPixelsH264(String serverInfo) throws XmlPullParserException, IOException {
String str = getXmlString(serverInfo, "MaxLumaPixelsH264");
if (str != null) {
try {
return Long.parseLong(str);
} catch (NumberFormatException e) {
return 0;
}
} else {
return 0;
}
}
public long getMaxLumaPixelsHEVC(String serverInfo) throws XmlPullParserException, IOException {
String str = getXmlString(serverInfo, "MaxLumaPixelsHEVC");
if (str != null) {
try {
return Long.parseLong(str);
} catch (NumberFormatException e) {
return 0;
}
} else {
return 0;
}
}
public String getGpuType(String serverInfo) throws XmlPullParserException, IOException {
return getXmlString(serverInfo, "gputype");
}
public boolean supports4K(String serverInfo) throws XmlPullParserException, IOException {
// serverinfo returns supported resolutions in descending order, so getting the first
// height will give us whether we support 4K. If this is not present, we don't support
// 4K.
String heightStr = getXmlString(serverInfo, "Height");
if (heightStr == null) {
return false;
}
// Only allow 4K on GFE 3.x
String gfeVersionStr = getXmlString(serverInfo, "GfeVersion");
if (gfeVersionStr == null || gfeVersionStr.startsWith("2.")) {
return false;
}
try {
if (Integer.parseInt(heightStr) >= 2160) {
// Found a 4K resolution in the list
return true;
}
} catch (NumberFormatException ignored) {}
return false;
}
public boolean supports4K60(String serverInfo) throws XmlPullParserException, IOException {
// If we don't support 4K at all, bail early
if (!supports4K(serverInfo)) {
return false;
}
// serverinfo returns supported resolutions in descending order, so getting the first
// refresh rate will give us whether we support 4K60. If this is 30, we don't support
// 4K 60 FPS.
String fpsStr = getXmlString(serverInfo, "RefreshRate");
if (fpsStr == null) {
return false;
}
try {
if (Integer.parseInt(fpsStr) >= 60) {
// 4K supported and 60 FPS is the first entry
return true;
}
} catch (NumberFormatException ignored) {}
return false;
}
public int getCurrentGame(String serverInfo) throws IOException, XmlPullParserException {
// GFE 2.8 started keeping currentgame set to the last game played. As a result, it no longer
// has the semantics that its name would indicate. To contain the effects of this change as much
// as possible, we'll force the current game to zero if the server isn't in a streaming session.
String serverState = getXmlString(serverInfo, "state");
if (serverState != null && !serverState.endsWith("_SERVER_AVAILABLE")) {
String game = getXmlString(serverInfo, "currentgame");
return Integer.parseInt(game);
}
else {
return 0;
}
}
public boolean isCurrentClient(String serverInfo) throws XmlPullParserException, IOException {
String currentClient = getXmlString(serverInfo, "CurrentClient");
if (currentClient != null) {
return !currentClient.equals("0");
}
else {
// For versions of GFE that lack this field, we'll assume we are
// the current client. If we're not, we'll get a response error that
// will let us know.
return true;
}
}
public NvApp getAppById(int appId) throws IOException, XmlPullParserException {
LinkedList<NvApp> appList = getAppList();
for (NvApp appFromList : appList) {
if (appFromList.getAppId() == appId) {
return appFromList;
}
}
return null;
}
/* NOTE: Only use this function if you know what you're doing.
* It's totally valid to have two apps named the same thing,
* or even nothing at all! Look apps up by ID if at all possible
* using the above function */
public NvApp getAppByName(String appName) throws IOException, XmlPullParserException {
LinkedList<NvApp> appList = getAppList();
for (NvApp appFromList : appList) {
if (appFromList.getAppName().equalsIgnoreCase(appName)) {
return appFromList;
}
}
return null;
}
public PairingManager.PairState pair(String serverInfo, String pin) throws Exception {
return pm.pair(serverInfo, pin);
}
public static LinkedList<NvApp> getAppListByReader(Reader r) throws XmlPullParserException, IOException {
XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
factory.setNamespaceAware(true);
XmlPullParser xpp = factory.newPullParser();
xpp.setInput(r);
int eventType = xpp.getEventType();
LinkedList<NvApp> appList = new LinkedList<NvApp>();
Stack<String> currentTag = new Stack<String>();
boolean rootTerminated = false;
while (eventType != XmlPullParser.END_DOCUMENT) {
switch (eventType) {
case (XmlPullParser.START_TAG):
if (xpp.getName().equals("root")) {
verifyResponseStatus(xpp);
}
currentTag.push(xpp.getName());
if (xpp.getName().equals("App")) {
appList.addLast(new NvApp());
}
break;
case (XmlPullParser.END_TAG):
currentTag.pop();
if (xpp.getName().equals("root")) {
rootTerminated = true;
}
break;
case (XmlPullParser.TEXT):
NvApp app = appList.getLast();
if (currentTag.peek().equals("AppTitle")) {
app.setAppName(xpp.getText().trim());
} else if (currentTag.peek().equals("ID")) {
app.setAppId(xpp.getText().trim());
}
break;
}
eventType = xpp.next();
}
// Throw a malformed XML exception if we've not seen the root tag ended
if (!rootTerminated) {
throw new XmlPullParserException("Malformed XML: Root tag was not terminated");
}
// Ensure that all apps in the list are initialized
ListIterator<NvApp> i = appList.listIterator();
while (i.hasNext()) {
NvApp app = i.next();
// Remove uninitialized apps
if (!app.isInitialized()) {
LimeLog.warning("GFE returned incomplete app: "+app.getAppId()+" "+app.getAppName());
i.remove();
}
}
return appList;
}
public String getAppListRaw() throws MalformedURLException, IOException {
return openHttpConnectionToString(baseUrlHttps + "/applist?"+buildUniqueIdUuidString(), true);
}
public LinkedList<NvApp> getAppList() throws GfeHttpResponseException, IOException, XmlPullParserException {
if (verbose) {
// Use the raw function so the app list is printed
return getAppListByReader(new StringReader(getAppListRaw()));
}
else {
ResponseBody resp = openHttpConnection(baseUrlHttps + "/applist?" + buildUniqueIdUuidString(), true);
LinkedList<NvApp> appList = getAppListByReader(new InputStreamReader(resp.byteStream()));
resp.close();
return appList;
}
}
public void unpair() throws IOException {
openHttpConnectionToString(baseUrlHttps + "/unpair?"+buildUniqueIdUuidString(), true);
}
public InputStream getBoxArt(NvApp app) throws IOException {
ResponseBody resp = openHttpConnection(baseUrlHttps + "/appasset?"+ buildUniqueIdUuidString() +
"&appid=" + app.getAppId() + "&AssetType=2&AssetIdx=0", true);
return resp.byteStream();
}
public int getServerMajorVersion(String serverInfo) throws XmlPullParserException, IOException {
int[] appVersionQuad = getServerAppVersionQuad(serverInfo);
if (appVersionQuad != null) {
return appVersionQuad[0];
}
else {
return 0;
}
}
public int[] getServerAppVersionQuad(String serverInfo) throws XmlPullParserException, IOException {
try {
String serverVersion = getServerVersion(serverInfo);
if (serverVersion == null) {
LimeLog.warning("Missing server version field");
return null;
}
String[] serverVersionSplit = serverVersion.split("\\.");
if (serverVersionSplit.length != 4) {
LimeLog.warning("Malformed server version field");
return null;
}
int[] ret = new int[serverVersionSplit.length];
for (int i = 0; i < ret.length; i++) {
ret[i] = Integer.parseInt(serverVersionSplit[i]);
}
return ret;
} catch (NumberFormatException e) {
e.printStackTrace();
return null;
}
}
final private static char[] hexArray = "0123456789ABCDEF".toCharArray();
private static String bytesToHex(byte[] bytes) {
char[] hexChars = new char[bytes.length * 2];
for ( int j = 0; j < bytes.length; j++ ) {
int v = bytes[j] & 0xFF;
hexChars[j * 2] = hexArray[v >>> 4];
hexChars[j * 2 + 1] = hexArray[v & 0x0F];
}
return new String(hexChars);
}
public boolean launchApp(ConnectionContext context, int appId) throws IOException, XmlPullParserException {
String xmlStr = openHttpConnectionToString(baseUrlHttps +
"/launch?" + buildUniqueIdUuidString() +
"&appid=" + appId +
"&mode=" + context.negotiatedWidth + "x" + context.negotiatedHeight + "x" + context.negotiatedFps +
"&additionalStates=1&sops=" + (context.streamConfig.getSops() ? 1 : 0) +
"&rikey="+bytesToHex(context.riKey.getEncoded()) +
"&rikeyid="+context.riKeyId +
"&localAudioPlayMode=" + (context.streamConfig.getPlayLocalAudio() ? 1 : 0) +
"&surroundAudioInfo=" + ((context.streamConfig.getAudioChannelMask() << 16) + context.streamConfig.getAudioChannelCount()),
false);
String gameSession = getXmlString(xmlStr, "gamesession");
return gameSession != null && !gameSession.equals("0");
}
public boolean resumeApp(ConnectionContext context) throws IOException, XmlPullParserException {
String xmlStr = openHttpConnectionToString(baseUrlHttps + "/resume?" + buildUniqueIdUuidString() +
"&rikey="+bytesToHex(context.riKey.getEncoded()) +
"&rikeyid="+context.riKeyId, false);
String resume = getXmlString(xmlStr, "resume");
return Integer.parseInt(resume) != 0;
}
public boolean quitApp() throws IOException, XmlPullParserException {
// First check if this client is allowed to quit the app. Newer GFE versions
// will just return success even if quitting fails if we're not the original requestor.
if (!isCurrentClient(getServerInfo())) {
// Generate a synthetic GfeResponseException letting the caller know
// that they can't kill someone else's stream.
throw new GfeHttpResponseException(599, "");
}
String xmlStr = openHttpConnectionToString(baseUrlHttps + "/cancel?" + buildUniqueIdUuidString(), false);
String cancel = getXmlString(xmlStr, "cancel");
return Integer.parseInt(cancel) != 0;
}
}

View File

@@ -0,0 +1,319 @@
package com.limelight.nvstream.http;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
import javax.crypto.ShortBufferException;
import javax.crypto.spec.SecretKeySpec;
import org.xmlpull.v1.XmlPullParserException;
import com.limelight.LimeLog;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.io.*;
import java.net.MalformedURLException;
import java.security.*;
import java.security.cert.*;
import java.util.Arrays;
import java.util.Locale;
import java.util.Random;
public class PairingManager {
private NvHTTP http;
private PrivateKey pk;
private X509Certificate cert;
private SecretKey aesKey;
private byte[] pemCertBytes;
public enum PairState {
NOT_PAIRED,
PAIRED,
PIN_WRONG,
FAILED,
ALREADY_IN_PROGRESS
}
public PairingManager(NvHTTP http, LimelightCryptoProvider cryptoProvider) {
this.http = http;
this.cert = cryptoProvider.getClientCertificate();
this.pemCertBytes = cryptoProvider.getPemEncodedClientCertificate();
this.pk = cryptoProvider.getClientPrivateKey();
}
final private static char[] hexArray = "0123456789ABCDEF".toCharArray();
private static String bytesToHex(byte[] bytes) {
char[] hexChars = new char[bytes.length * 2];
for ( int j = 0; j < bytes.length; j++ ) {
int v = bytes[j] & 0xFF;
hexChars[j * 2] = hexArray[v >>> 4];
hexChars[j * 2 + 1] = hexArray[v & 0x0F];
}
return new String(hexChars);
}
private static byte[] hexToBytes(String s) {
int len = s.length();
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4)
+ Character.digit(s.charAt(i+1), 16));
}
return data;
}
private X509Certificate extractPlainCert(String text) throws XmlPullParserException, IOException, CertificateException
{
String certText = NvHTTP.getXmlString(text, "plaincert");
if (certText != null) {
byte[] certBytes = hexToBytes(certText);
CertificateFactory cf = CertificateFactory.getInstance("X.509");
return (X509Certificate)cf.generateCertificate(new ByteArrayInputStream(certBytes));
}
else {
return null;
}
}
private byte[] generateRandomBytes(int length)
{
byte[] rand = new byte[length];
new SecureRandom().nextBytes(rand);
return rand;
}
private static byte[] saltPin(byte[] salt, String pin) throws UnsupportedEncodingException {
byte[] saltedPin = new byte[salt.length + pin.length()];
System.arraycopy(salt, 0, saltedPin, 0, salt.length);
System.arraycopy(pin.getBytes("UTF-8"), 0, saltedPin, salt.length, pin.length());
return saltedPin;
}
private static boolean verifySignature(byte[] data, byte[] signature, Certificate cert) throws NoSuchAlgorithmException, SignatureException, InvalidKeyException {
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initVerify(cert.getPublicKey());
sig.update(data);
return sig.verify(signature);
}
private static byte[] signData(byte[] data, PrivateKey key) throws NoSuchAlgorithmException, SignatureException, InvalidKeyException {
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initSign(key);
sig.update(data);
byte[] signature = new byte[256];
sig.sign(signature, 0, signature.length);
return signature;
}
private static byte[] decryptAes(byte[] encryptedData, SecretKey secretKey) throws NoSuchAlgorithmException, SignatureException,
InvalidKeyException, ShortBufferException, IllegalBlockSizeException, BadPaddingException, NoSuchPaddingException {
Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding");
int blockRoundedSize = ((encryptedData.length + 15) / 16) * 16;
byte[] blockRoundedEncrypted = Arrays.copyOf(encryptedData, blockRoundedSize);
byte[] fullDecrypted = new byte[blockRoundedSize];
cipher.init(Cipher.DECRYPT_MODE, secretKey);
cipher.doFinal(blockRoundedEncrypted, 0,
blockRoundedSize, fullDecrypted);
return fullDecrypted;
}
private static byte[] encryptAes(byte[] data, SecretKey secretKey) throws NoSuchAlgorithmException, SignatureException,
InvalidKeyException, ShortBufferException, IllegalBlockSizeException, BadPaddingException, NoSuchPaddingException {
Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding");
int blockRoundedSize = ((data.length + 15) / 16) * 16;
byte[] blockRoundedData = Arrays.copyOf(data, blockRoundedSize);
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
return cipher.doFinal(blockRoundedData);
}
private static SecretKey generateAesKey(PairingHashAlgorithm hashAlgo, byte[] keyData) {
byte[] aesTruncated = Arrays.copyOf(hashAlgo.hashData(keyData), 16);
return new SecretKeySpec(aesTruncated, "AES");
}
private static byte[] concatBytes(byte[] a, byte[] b) {
byte[] c = new byte[a.length + b.length];
System.arraycopy(a, 0, c, 0, a.length);
System.arraycopy(b, 0, c, a.length, b.length);
return c;
}
public static String generatePinString() {
Random r = new Random();
return String.format((Locale)null, "%d%d%d%d",
r.nextInt(10), r.nextInt(10),
r.nextInt(10), r.nextInt(10));
}
public PairState getPairState(String serverInfo) throws MalformedURLException, IOException, XmlPullParserException {
if (!NvHTTP.getXmlString(serverInfo, "PairStatus").equals("1")) {
return PairState.NOT_PAIRED;
}
return PairState.PAIRED;
}
public PairState pair(String serverInfo, String pin) throws MalformedURLException, IOException, XmlPullParserException, CertificateException, InvalidKeyException, NoSuchAlgorithmException, SignatureException, ShortBufferException, IllegalBlockSizeException, BadPaddingException, NoSuchPaddingException {
PairingHashAlgorithm hashAlgo;
int serverMajorVersion = http.getServerMajorVersion(serverInfo);
LimeLog.info("Pairing with server generation: "+serverMajorVersion);
if (serverMajorVersion >= 7) {
// Gen 7+ uses SHA-256 hashing
hashAlgo = new Sha256PairingHash();
}
else {
// Prior to Gen 7, SHA-1 is used
hashAlgo = new Sha1PairingHash();
}
// Generate a salt for hashing the PIN
byte[] salt = generateRandomBytes(16);
// Combine the salt and pin, then create an AES key from them
byte[] saltAndPin = saltPin(salt, pin);
aesKey = generateAesKey(hashAlgo, saltAndPin);
// Send the salt and get the server cert. This doesn't have a read timeout
// because the user must enter the PIN before the server responds
String getCert = http.openHttpConnectionToString(http.baseUrlHttp +
"/pair?"+http.buildUniqueIdUuidString()+"&devicename=roth&updateState=1&phrase=getservercert&salt="+
bytesToHex(salt)+"&clientcert="+bytesToHex(pemCertBytes),
false);
if (!NvHTTP.getXmlString(getCert, "paired").equals("1")) {
return PairState.FAILED;
}
X509Certificate serverCert = extractPlainCert(getCert);
if (serverCert == null) {
// Attempting to pair while another device is pairing will cause GFE
// to give an empty cert in the response.
return PairState.ALREADY_IN_PROGRESS;
}
// Generate a random challenge and encrypt it with our AES key
byte[] randomChallenge = generateRandomBytes(16);
byte[] encryptedChallenge = encryptAes(randomChallenge, aesKey);
// Send the encrypted challenge to the server
String challengeResp = http.openHttpConnectionToString(http.baseUrlHttp +
"/pair?"+http.buildUniqueIdUuidString()+"&devicename=roth&updateState=1&clientchallenge="+bytesToHex(encryptedChallenge),
true);
if (!NvHTTP.getXmlString(challengeResp, "paired").equals("1")) {
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
return PairState.FAILED;
}
// Decode the server's response and subsequent challenge
byte[] encServerChallengeResponse = hexToBytes(NvHTTP.getXmlString(challengeResp, "challengeresponse"));
byte[] decServerChallengeResponse = decryptAes(encServerChallengeResponse, aesKey);
byte[] serverResponse = Arrays.copyOfRange(decServerChallengeResponse, 0, hashAlgo.getHashLength());
byte[] serverChallenge = Arrays.copyOfRange(decServerChallengeResponse, hashAlgo.getHashLength(), hashAlgo.getHashLength() + 16);
// Using another 16 bytes secret, compute a challenge response hash using the secret, our cert sig, and the challenge
byte[] clientSecret = generateRandomBytes(16);
byte[] challengeRespHash = hashAlgo.hashData(concatBytes(concatBytes(serverChallenge, cert.getSignature()), clientSecret));
byte[] challengeRespEncrypted = encryptAes(challengeRespHash, aesKey);
String secretResp = http.openHttpConnectionToString(http.baseUrlHttp +
"/pair?"+http.buildUniqueIdUuidString()+"&devicename=roth&updateState=1&serverchallengeresp="+bytesToHex(challengeRespEncrypted),
true);
if (!NvHTTP.getXmlString(secretResp, "paired").equals("1")) {
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
return PairState.FAILED;
}
// Get the server's signed secret
byte[] serverSecretResp = hexToBytes(NvHTTP.getXmlString(secretResp, "pairingsecret"));
byte[] serverSecret = Arrays.copyOfRange(serverSecretResp, 0, 16);
byte[] serverSignature = Arrays.copyOfRange(serverSecretResp, 16, 272);
// Ensure the authenticity of the data
if (!verifySignature(serverSecret, serverSignature, serverCert)) {
// Cancel the pairing process
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
// Looks like a MITM
return PairState.FAILED;
}
// Ensure the server challenge matched what we expected (aka the PIN was correct)
byte[] serverChallengeRespHash = hashAlgo.hashData(concatBytes(concatBytes(randomChallenge, serverCert.getSignature()), serverSecret));
if (!Arrays.equals(serverChallengeRespHash, serverResponse)) {
// Cancel the pairing process
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
// Probably got the wrong PIN
return PairState.PIN_WRONG;
}
// Send the server our signed secret
byte[] clientPairingSecret = concatBytes(clientSecret, signData(clientSecret, pk));
String clientSecretResp = http.openHttpConnectionToString(http.baseUrlHttp +
"/pair?"+http.buildUniqueIdUuidString()+"&devicename=roth&updateState=1&clientpairingsecret="+bytesToHex(clientPairingSecret),
true);
if (!NvHTTP.getXmlString(clientSecretResp, "paired").equals("1")) {
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
return PairState.FAILED;
}
// Do the initial challenge (seems neccessary for us to show as paired)
String pairChallenge = http.openHttpConnectionToString(http.baseUrlHttps +
"/pair?"+http.buildUniqueIdUuidString()+"&devicename=roth&updateState=1&phrase=pairchallenge", true);
if (!NvHTTP.getXmlString(pairChallenge, "paired").equals("1")) {
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
return PairState.FAILED;
}
return PairState.PAIRED;
}
private static interface PairingHashAlgorithm {
public int getHashLength();
public byte[] hashData(byte[] data);
}
private static class Sha1PairingHash implements PairingHashAlgorithm {
public int getHashLength() {
return 20;
}
public byte[] hashData(byte[] data) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-1");
return md.digest(data);
}
catch (NoSuchAlgorithmException e) {
// Shouldn't ever happen
e.printStackTrace();
return null;
}
}
}
private static class Sha256PairingHash implements PairingHashAlgorithm {
public int getHashLength() {
return 32;
}
public byte[] hashData(byte[] data) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return md.digest(data);
}
catch (NoSuchAlgorithmException e) {
// Shouldn't ever happen
e.printStackTrace();
return null;
}
}
}
}

View File

@@ -0,0 +1,97 @@
package com.limelight.nvstream.input;
public class ControllerBatchingBlock {
private byte[] axisDirs = new byte[6];
private short buttonFlags;
private byte leftTrigger;
private byte rightTrigger;
private short leftStickX;
private short leftStickY;
private short rightStickX;
private short rightStickY;
private short controllerNumber;
private short activeGamepadMask;
public ControllerBatchingBlock(MultiControllerPacket initialPacket) {
this.controllerNumber = initialPacket.controllerNumber;
this.activeGamepadMask = initialPacket.activeGamepadMask;
this.buttonFlags = initialPacket.buttonFlags;
this.leftTrigger = initialPacket.leftTrigger;
this.rightTrigger = initialPacket.rightTrigger;
this.leftStickX = initialPacket.leftStickX;
this.leftStickY = initialPacket.leftStickY;
this.rightStickX = initialPacket.rightStickX;
this.rightStickY = initialPacket.rightStickY;
}
private boolean checkDirs(short currentVal, short newVal, int dirIndex) {
if (currentVal == newVal) {
return true;
}
// We want to send a packet if we've now zeroed an axis
if (newVal == 0) {
return false;
}
if (axisDirs[dirIndex] == 0) {
if (newVal < currentVal) {
axisDirs[dirIndex] = -1;
}
else {
axisDirs[dirIndex] = 1;
}
}
else if (axisDirs[dirIndex] == -1) {
return newVal < currentVal;
}
else if (newVal < currentVal) {
return false;
}
return true;
}
// Controller packet batching is far more restricted than mouse move batching.
// We have several restrictions that will cause batching to break up the controller packets.
// 1) Button flags must be the same for all packets in the batch
// 2) The movement direction of all axes must remain the same or be neutral
public boolean submitNewPacket(MultiControllerPacket packet) {
if (buttonFlags != packet.buttonFlags ||
controllerNumber != packet.controllerNumber ||
activeGamepadMask != packet.activeGamepadMask ||
!checkDirs(leftTrigger, packet.leftTrigger, 0) ||
!checkDirs(rightTrigger, packet.rightTrigger, 1) ||
!checkDirs(leftStickX, packet.leftStickX, 2) ||
!checkDirs(leftStickY, packet.leftStickY, 3) ||
!checkDirs(rightStickX, packet.rightStickX, 4) ||
!checkDirs(rightStickY, packet.rightStickY, 5))
{
return false;
}
this.controllerNumber = packet.controllerNumber;
this.activeGamepadMask = packet.activeGamepadMask;
this.leftTrigger = packet.leftTrigger;
this.rightTrigger = packet.rightTrigger;
this.leftStickX = packet.leftStickX;
this.leftStickY = packet.leftStickY;
this.rightStickX = packet.rightStickX;
this.rightStickY = packet.rightStickY;
return true;
}
public void reinitializePacket(MultiControllerPacket packet) {
packet.controllerNumber = controllerNumber;
packet.activeGamepadMask = activeGamepadMask;
packet.buttonFlags = buttonFlags;
packet.leftTrigger = leftTrigger;
packet.rightTrigger = rightTrigger;
packet.leftStickX = leftStickX;
packet.leftStickY = leftStickY;
packet.rightStickX = rightStickX;
packet.rightStickY = rightStickY;
}
}

View File

@@ -0,0 +1,85 @@
package com.limelight.nvstream.input;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
public class ControllerPacket extends MultiControllerPacket {
private static final byte[] HEADER =
{
0x0A,
0x00,
0x00,
0x00,
0x00,
0x14
};
private static final byte[] TAIL =
{
(byte)0x9C,
0x00,
0x00,
0x00,
0x55,
0x00
};
private static final int PACKET_TYPE = 0x18;
public static final short A_FLAG = 0x1000;
public static final short B_FLAG = 0x2000;
public static final short X_FLAG = 0x4000;
public static final short Y_FLAG = (short)0x8000;
public static final short UP_FLAG = 0x0001;
public static final short DOWN_FLAG = 0x0002;
public static final short LEFT_FLAG = 0x0004;
public static final short RIGHT_FLAG = 0x0008;
public static final short LB_FLAG = 0x0100;
public static final short RB_FLAG = 0x0200;
public static final short PLAY_FLAG = 0x0010;
public static final short BACK_FLAG = 0x0020;
public static final short LS_CLK_FLAG = 0x0040;
public static final short RS_CLK_FLAG = 0x0080;
public static final short SPECIAL_BUTTON_FLAG = 0x0400;
private static final short PAYLOAD_LENGTH = 24;
private static final short PACKET_LENGTH = PAYLOAD_LENGTH +
InputPacket.HEADER_LENGTH;
public ControllerPacket(short buttonFlags, byte leftTrigger, byte rightTrigger,
short leftStickX, short leftStickY,
short rightStickX, short rightStickY)
{
super(PACKET_TYPE, (short) 0, (short) 0, buttonFlags, leftTrigger, rightTrigger, leftStickX,
leftStickY, rightStickX, rightStickY);
this.buttonFlags = buttonFlags;
this.leftTrigger = leftTrigger;
this.rightTrigger = rightTrigger;
this.leftStickX = leftStickX;
this.leftStickY = leftStickY;
this.rightStickX = rightStickX;
this.rightStickY = rightStickY;
}
@Override
public void toWirePayload(ByteBuffer bb) {
bb.order(ByteOrder.LITTLE_ENDIAN);
bb.put(HEADER);
bb.putShort(buttonFlags);
bb.put(leftTrigger);
bb.put(rightTrigger);
bb.putShort(leftStickX);
bb.putShort(leftStickY);
bb.putShort(rightStickX);
bb.putShort(rightStickY);
bb.put(TAIL);
}
@Override
public int getPacketLength() {
return PACKET_LENGTH;
}
}

View File

@@ -0,0 +1,432 @@
package com.limelight.nvstream.input;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Iterator;
import java.util.concurrent.LinkedBlockingQueue;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
import javax.crypto.ShortBufferException;
import javax.crypto.spec.IvParameterSpec;
import com.limelight.nvstream.ConnectionContext;
import com.limelight.nvstream.control.InputPacketSender;
public class ControllerStream {
private final static int PORT = 35043;
private final static int CONTROLLER_TIMEOUT = 10000;
private ConnectionContext context;
// Only used on Gen 4 or below servers
private Socket s;
private OutputStream out;
// Used on Gen 5+ servers
private InputPacketSender controlSender;
private InputCipher cipher;
private Thread inputThread;
private LinkedBlockingQueue<InputPacket> inputQueue = new LinkedBlockingQueue<InputPacket>();
private ByteBuffer stagingBuffer = ByteBuffer.allocate(128);
private ByteBuffer sendBuffer = ByteBuffer.allocate(128).order(ByteOrder.BIG_ENDIAN);
public ControllerStream(ConnectionContext context)
{
this.context = context;
if (context.serverGeneration >= ConnectionContext.SERVER_GENERATION_7) {
// Newer GFE versions use AES GCM
cipher = new AesGcmCipher();
}
else {
// Older versions used AES CBC
cipher = new AesCbcCipher();
}
ByteBuffer bb = ByteBuffer.allocate(16);
bb.putInt(context.riKeyId);
cipher.initialize(context.riKey, bb.array());
}
public void initialize(InputPacketSender controlSender) throws IOException
{
if (context.serverGeneration >= ConnectionContext.SERVER_GENERATION_5) {
// Gen 5 sends input over the control stream
this.controlSender = controlSender;
}
else {
// Gen 4 and below uses a separate TCP connection for input
s = new Socket();
s.connect(new InetSocketAddress(context.serverAddress, PORT), CONTROLLER_TIMEOUT);
s.setTcpNoDelay(true);
out = s.getOutputStream();
}
}
public void start()
{
inputThread = new Thread() {
@Override
public void run() {
while (!isInterrupted()) {
InputPacket packet;
try {
packet = inputQueue.take();
} catch (InterruptedException e) {
context.connListener.connectionTerminated(e);
return;
}
// Try to batch mouse move packets
if (!inputQueue.isEmpty() && packet instanceof MouseMovePacket) {
MouseMovePacket initialMouseMove = (MouseMovePacket) packet;
int totalDeltaX = initialMouseMove.deltaX;
int totalDeltaY = initialMouseMove.deltaY;
// Combine the deltas with other mouse move packets in the queue
synchronized (inputQueue) {
Iterator<InputPacket> i = inputQueue.iterator();
while (i.hasNext()) {
InputPacket queuedPacket = i.next();
if (queuedPacket instanceof MouseMovePacket) {
MouseMovePacket queuedMouseMove = (MouseMovePacket) queuedPacket;
// Add this packet's deltas to the running total
totalDeltaX += queuedMouseMove.deltaX;
totalDeltaY += queuedMouseMove.deltaY;
// Remove this packet from the queue
i.remove();
}
}
}
// Total deltas could overflow the short so we must split them if required
do {
short partialDeltaX = (short)(totalDeltaX < 0 ?
Math.max(Short.MIN_VALUE, totalDeltaX) :
Math.min(Short.MAX_VALUE, totalDeltaX));
short partialDeltaY = (short)(totalDeltaY < 0 ?
Math.max(Short.MIN_VALUE, totalDeltaY) :
Math.min(Short.MAX_VALUE, totalDeltaY));
initialMouseMove.deltaX = partialDeltaX;
initialMouseMove.deltaY = partialDeltaY;
try {
sendPacket(initialMouseMove);
} catch (IOException e) {
context.connListener.connectionTerminated(e);
return;
}
totalDeltaX -= partialDeltaX;
totalDeltaY -= partialDeltaY;
} while (totalDeltaX != 0 && totalDeltaY != 0);
}
// Try to batch axis changes on controller packets too
else if (!inputQueue.isEmpty() && packet instanceof MultiControllerPacket) {
MultiControllerPacket initialControllerPacket = (MultiControllerPacket) packet;
ControllerBatchingBlock batchingBlock = null;
synchronized (inputQueue) {
Iterator<InputPacket> i = inputQueue.iterator();
while (i.hasNext()) {
InputPacket queuedPacket = i.next();
if (queuedPacket instanceof MultiControllerPacket) {
// Only initialize the batching block if we got here
if (batchingBlock == null) {
batchingBlock = new ControllerBatchingBlock(initialControllerPacket);
}
if (batchingBlock.submitNewPacket((MultiControllerPacket) queuedPacket))
{
// Batching was successful, so remove this packet
i.remove();
}
else
{
// Unable to batch so we must stop
break;
}
}
}
}
if (batchingBlock != null) {
// Reinitialize the initial packet with the new values
batchingBlock.reinitializePacket(initialControllerPacket);
}
try {
sendPacket(packet);
} catch (IOException e) {
context.connListener.connectionTerminated(e);
return;
}
}
else {
// Send any other packet as-is
try {
sendPacket(packet);
} catch (IOException e) {
context.connListener.connectionTerminated(e);
return;
}
}
}
}
};
inputThread.setName("Input - Queue");
inputThread.setPriority(Thread.NORM_PRIORITY + 1);
inputThread.start();
}
public void abort()
{
if (inputThread != null) {
inputThread.interrupt();
try {
inputThread.join();
} catch (InterruptedException e) {}
}
if (s != null) {
try {
s.close();
} catch (IOException e) {}
}
}
private void sendPacket(InputPacket packet) throws IOException {
// Store the packet in wire form in the byte buffer
packet.toWire(stagingBuffer);
int packetLen = packet.getPacketLength();
// Get final encrypted size of this block
int paddedLength = cipher.getEncryptedSize(packetLen);
// Allocate a byte buffer to represent the final packet
sendBuffer.rewind();
sendBuffer.putInt(paddedLength);
try {
cipher.encrypt(stagingBuffer.array(), packetLen, sendBuffer.array(), 4);
} catch (Exception e) {
// Should never happen
e.printStackTrace();
return;
}
// Send the packet over the control stream on Gen 5+
if (context.serverGeneration >= ConnectionContext.SERVER_GENERATION_5) {
controlSender.sendInputPacket(sendBuffer.array(), (short) (paddedLength + 4));
// For reasons that I can't understand, NVIDIA decides to use the last 16
// bytes of ciphertext in the most recent game controller packet as the IV for
// future encryption. I think it may be a buffer overrun on their end but we'll have
// to mimic it to work correctly.
if (context.serverGeneration >= ConnectionContext.SERVER_GENERATION_7 && paddedLength >= 32) {
cipher.initialize(context.riKey,
Arrays.copyOfRange(sendBuffer.array(), 4 + paddedLength - 16, 4 + paddedLength));
}
}
else {
// Send the packet over the TCP connection on Gen 4 and below
out.write(sendBuffer.array(), 0, paddedLength + 4);
out.flush();
}
}
private void queuePacket(InputPacket packet) {
synchronized (inputQueue) {
inputQueue.add(packet);
}
}
public void sendControllerInput(short buttonFlags, byte leftTrigger, byte rightTrigger,
short leftStickX, short leftStickY, short rightStickX, short rightStickY)
{
if (context.serverGeneration == ConnectionContext.SERVER_GENERATION_3) {
// Use legacy controller packets for generation 3
queuePacket(new ControllerPacket(buttonFlags, leftTrigger,
rightTrigger, leftStickX, leftStickY,
rightStickX, rightStickY));
}
else {
// Use multi-controller packets for generation 4 and above
queuePacket(new MultiControllerPacket(context,
(short) 0, (short) 0x1,
buttonFlags, leftTrigger,
rightTrigger, leftStickX, leftStickY,
rightStickX, rightStickY));
}
}
public void sendControllerInput(short controllerNumber, short activeGamepadMask,
short buttonFlags, byte leftTrigger, byte rightTrigger,
short leftStickX, short leftStickY, short rightStickX, short rightStickY)
{
if (context.serverGeneration == ConnectionContext.SERVER_GENERATION_3) {
// Use legacy controller packets for generation 3
queuePacket(new ControllerPacket(buttonFlags, leftTrigger,
rightTrigger, leftStickX, leftStickY,
rightStickX, rightStickY));
}
else {
// Use multi-controller packets for generation 4 and above
queuePacket(new MultiControllerPacket(context,
controllerNumber, activeGamepadMask,
buttonFlags, leftTrigger,
rightTrigger, leftStickX, leftStickY,
rightStickX, rightStickY));
}
}
public void sendMouseButtonDown(byte mouseButton)
{
queuePacket(new MouseButtonPacket(context, true, mouseButton));
}
public void sendMouseButtonUp(byte mouseButton)
{
queuePacket(new MouseButtonPacket(context, false, mouseButton));
}
public void sendMouseMove(short deltaX, short deltaY)
{
queuePacket(new MouseMovePacket(context, deltaX, deltaY));
}
public void sendKeyboardInput(short keyMap, byte keyDirection, byte modifier)
{
queuePacket(new KeyboardPacket(keyMap, keyDirection, modifier));
}
public void sendMouseScroll(byte scrollClicks)
{
queuePacket(new MouseScrollPacket(context, scrollClicks));
}
private static interface InputCipher {
public void initialize(SecretKey key, byte[] iv);
public int getEncryptedSize(int plaintextSize);
public void encrypt(byte[] inputData, int inputLength, byte[] outputData, int outputOffset);
}
private static class AesCbcCipher implements InputCipher {
private Cipher cipher;
public void initialize(SecretKey key, byte[] iv) {
try {
cipher = Cipher.getInstance("AES/CBC/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(iv));
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
} catch (NoSuchPaddingException e) {
e.printStackTrace();
} catch (InvalidKeyException e) {
e.printStackTrace();
} catch (InvalidAlgorithmParameterException e) {
e.printStackTrace();
}
}
public int getEncryptedSize(int plaintextSize) {
// CBC requires padding to the next multiple of 16
return ((plaintextSize + 15) / 16) * 16;
}
private int inPlacePadData(byte[] data, int length) {
// This implements the PKCS7 padding algorithm
if ((length % 16) == 0) {
// Already a multiple of 16
return length;
}
int paddedLength = getEncryptedSize(length);
byte paddingByte = (byte)(16 - (length % 16));
for (int i = length; i < paddedLength; i++) {
data[i] = paddingByte;
}
return paddedLength;
}
public void encrypt(byte[] inputData, int inputLength, byte[] outputData, int outputOffset) {
int encryptedLength = inPlacePadData(inputData, inputLength);
try {
cipher.update(inputData, 0, encryptedLength, outputData, outputOffset);
} catch (ShortBufferException e) {
e.printStackTrace();
}
}
}
private static class AesGcmCipher implements InputCipher {
private SecretKey key;
private byte[] iv;
public int getEncryptedSize(int plaintextSize) {
// GCM uses no padding + 16 bytes tag for message authentication
return plaintextSize + 16;
}
@Override
public void initialize(SecretKey key, byte[] iv) {
this.key = key;
this.iv = iv;
}
@Override
public void encrypt(byte[] inputData, int inputLength, byte[] outputData, int outputOffset) {
// Reconstructing the cipher on every invocation really sucks but we have to do it
// because of the way NVIDIA is using GCM where each message is tagged. Java doesn't
// have an easy way that I know of to get a tag out mid-stream.
Cipher cipher;
try {
cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(iv));
// This is also non-ideal. Java gives us <ciphertext><tag> but we want to send <tag><ciphertext>
// so we'll take the output and arraycopy it into the right spot in the output buffer
byte[] rawCipherOut = cipher.doFinal(inputData, 0, inputLength);
System.arraycopy(rawCipherOut, inputLength, outputData, outputOffset, 16);
System.arraycopy(rawCipherOut, 0, outputData, outputOffset + 16, inputLength);
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
} catch (NoSuchPaddingException e) {
e.printStackTrace();
} catch (InvalidKeyException e) {
e.printStackTrace();
} catch (InvalidAlgorithmParameterException e) {
e.printStackTrace();
} catch (IllegalBlockSizeException e) {
e.printStackTrace();
} catch (BadPaddingException e) {
e.printStackTrace();
}
}
}
}

View File

@@ -0,0 +1,32 @@
package com.limelight.nvstream.input;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
public abstract class InputPacket {
public static final int HEADER_LENGTH = 0x4;
protected int packetType;
public InputPacket(int packetType)
{
this.packetType = packetType;
}
public abstract void toWirePayload(ByteBuffer bb);
public abstract int getPacketLength();
public void toWireHeader(ByteBuffer bb)
{
bb.order(ByteOrder.BIG_ENDIAN);
bb.putInt(packetType);
}
public void toWire(ByteBuffer bb)
{
bb.rewind();
toWireHeader(bb);
toWirePayload(bb);
}
}

View File

@@ -0,0 +1,44 @@
package com.limelight.nvstream.input;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
public class KeyboardPacket extends InputPacket {
private static final int PACKET_TYPE = 0x0A;
private static final int PACKET_LENGTH = 14;
public static final byte KEY_DOWN = 0x03;
public static final byte KEY_UP = 0x04;
public static final byte MODIFIER_SHIFT = 0x01;
public static final byte MODIFIER_CTRL = 0x02;
public static final byte MODIFIER_ALT = 0x04;
private short keyCode;
private byte keyDirection;
private byte modifier;
public KeyboardPacket(short keyCode, byte keyDirection, byte modifier) {
super(PACKET_TYPE);
this.keyCode = keyCode;
this.keyDirection = keyDirection;
this.modifier = modifier;
}
@Override
public void toWirePayload(ByteBuffer bb) {
bb.order(ByteOrder.LITTLE_ENDIAN);
bb.put(keyDirection);
bb.putShort((short)0);
bb.putShort((short)0);
bb.putShort(keyCode);
bb.put(modifier);
bb.put((byte)0);
bb.put((byte)0);
}
@Override
public int getPacketLength() {
return PACKET_LENGTH;
}
}

View File

@@ -0,0 +1,20 @@
package com.limelight.nvstream.input;
import com.limelight.nvstream.NvConnection;
public abstract class KeycodeTranslator {
public abstract short translate(int keycode);
protected NvConnection conn;
public KeycodeTranslator(NvConnection conn) {
this.conn = conn;
}
public void sendKeyDown(short keyMap, byte modifier) {
conn.sendKeyboardInput(keyMap, KeyboardPacket.KEY_DOWN, modifier);
}
public void sendKeyUp(short keyMap, byte modifier) {
conn.sendKeyboardInput(keyMap, KeyboardPacket.KEY_UP, modifier);
}
}

View File

@@ -0,0 +1,51 @@
package com.limelight.nvstream.input;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import com.limelight.nvstream.ConnectionContext;
public class MouseButtonPacket extends InputPacket {
byte buttonEventType;
byte mouseButton;
private static final int PACKET_TYPE = 0x5;
private static final int PAYLOAD_LENGTH = 5;
private static final int PACKET_LENGTH = PAYLOAD_LENGTH +
InputPacket.HEADER_LENGTH;
private static final byte PRESS_EVENT = 0x07;
private static final byte RELEASE_EVENT = 0x08;
public static final byte BUTTON_LEFT = 0x01;
public static final byte BUTTON_MIDDLE = 0x02;
public static final byte BUTTON_RIGHT = 0x03;
public MouseButtonPacket(ConnectionContext context, boolean buttonDown, byte mouseButton)
{
super(PACKET_TYPE);
this.mouseButton = mouseButton;
buttonEventType = buttonDown ?
PRESS_EVENT : RELEASE_EVENT;
// On Gen 5 servers, the button event codes are incremented by one
if (context.serverGeneration >= ConnectionContext.SERVER_GENERATION_5) {
buttonEventType++;
}
}
@Override
public void toWirePayload(ByteBuffer bb) {
bb.order(ByteOrder.BIG_ENDIAN);
bb.put(buttonEventType);
bb.putInt(mouseButton);
}
@Override
public int getPacketLength() {
return PACKET_LENGTH;
}
}

View File

@@ -0,0 +1,48 @@
package com.limelight.nvstream.input;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import com.limelight.nvstream.ConnectionContext;
public class MouseMovePacket extends InputPacket {
private static final int HEADER_CODE = 0x06;
private static final int PACKET_TYPE = 0x8;
private static final int PAYLOAD_LENGTH = 8;
private static final int PACKET_LENGTH = PAYLOAD_LENGTH +
InputPacket.HEADER_LENGTH;
private int headerCode;
// Accessed in ControllerStream for batching
short deltaX;
short deltaY;
public MouseMovePacket(ConnectionContext context, short deltaX, short deltaY)
{
super(PACKET_TYPE);
this.headerCode = HEADER_CODE;
// On Gen 5 servers, the header code is incremented by one
if (context.serverGeneration >= ConnectionContext.SERVER_GENERATION_5) {
headerCode++;
}
this.deltaX = deltaX;
this.deltaY = deltaY;
}
@Override
public void toWirePayload(ByteBuffer bb) {
bb.order(ByteOrder.LITTLE_ENDIAN).putInt(headerCode);
bb.order(ByteOrder.BIG_ENDIAN);
bb.putShort(deltaX);
bb.putShort(deltaY);
}
@Override
public int getPacketLength() {
return PACKET_LENGTH;
}
}

View File

@@ -0,0 +1,49 @@
package com.limelight.nvstream.input;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import com.limelight.nvstream.ConnectionContext;
public class MouseScrollPacket extends InputPacket {
private static final int HEADER_CODE = 0x09;
private static final int PACKET_TYPE = 0xa;
private static final int PAYLOAD_LENGTH = 10;
private static final int PACKET_LENGTH = PAYLOAD_LENGTH +
InputPacket.HEADER_LENGTH;
private int headerCode;
private short scroll;
public MouseScrollPacket(ConnectionContext context, byte scrollClicks)
{
super(PACKET_TYPE);
this.headerCode = HEADER_CODE;
// On Gen 5 servers, the header code is incremented by one
if (context.serverGeneration >= ConnectionContext.SERVER_GENERATION_5) {
headerCode++;
}
this.scroll = (short)(scrollClicks * 120);
}
@Override
public void toWirePayload(ByteBuffer bb) {
bb.order(ByteOrder.LITTLE_ENDIAN).putInt(headerCode);
bb.order(ByteOrder.BIG_ENDIAN);
bb.putShort(scroll);
bb.putShort(scroll);
bb.putShort((short) 0);
}
@Override
public int getPacketLength() {
return PACKET_LENGTH;
}
}

View File

@@ -0,0 +1,112 @@
package com.limelight.nvstream.input;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import com.limelight.nvstream.ConnectionContext;
public class MultiControllerPacket extends InputPacket {
private static final byte[] TAIL =
{
(byte)0x9C,
0x00,
0x00,
0x00,
0x55,
0x00
};
private static final int HEADER_CODE = 0x0d;
private static final int PACKET_TYPE = 0x1e;
private static final short PAYLOAD_LENGTH = 30;
private static final short PACKET_LENGTH = PAYLOAD_LENGTH +
InputPacket.HEADER_LENGTH;
short controllerNumber;
short activeGamepadMask;
short buttonFlags;
byte leftTrigger;
byte rightTrigger;
short leftStickX;
short leftStickY;
short rightStickX;
short rightStickY;
private int headerCode;
public MultiControllerPacket(ConnectionContext context,
short controllerNumber, short activeGamepadMask,
short buttonFlags, byte leftTrigger, byte rightTrigger,
short leftStickX, short leftStickY,
short rightStickX, short rightStickY)
{
super(PACKET_TYPE);
this.headerCode = HEADER_CODE;
// On Gen 5 servers, the header code is decremented by one
if (context.serverGeneration >= ConnectionContext.SERVER_GENERATION_5) {
headerCode--;
}
this.controllerNumber = controllerNumber;
this.activeGamepadMask = activeGamepadMask;
this.buttonFlags = buttonFlags;
this.leftTrigger = leftTrigger;
this.rightTrigger = rightTrigger;
this.leftStickX = leftStickX;
this.leftStickY = leftStickY;
this.rightStickX = rightStickX;
this.rightStickY = rightStickY;
}
public MultiControllerPacket(int packetType,
short controllerNumber, short activeGamepadMask,
short buttonFlags,
byte leftTrigger, byte rightTrigger,
short leftStickX, short leftStickY,
short rightStickX, short rightStickY)
{
super(packetType);
this.controllerNumber = controllerNumber;
this.activeGamepadMask = activeGamepadMask;
this.buttonFlags = buttonFlags;
this.leftTrigger = leftTrigger;
this.rightTrigger = rightTrigger;
this.leftStickX = leftStickX;
this.leftStickY = leftStickY;
this.rightStickX = rightStickX;
this.rightStickY = rightStickY;
}
@Override
public void toWirePayload(ByteBuffer bb) {
bb.order(ByteOrder.LITTLE_ENDIAN);
bb.putInt(headerCode);
bb.putShort((short) 0x1a);
bb.putShort(controllerNumber);
bb.putShort(activeGamepadMask);
bb.putShort((short) 0x14);
bb.putShort(buttonFlags);
bb.put(leftTrigger);
bb.put(rightTrigger);
bb.putShort(leftStickX);
bb.putShort(leftStickY);
bb.putShort(rightStickX);
bb.putShort(rightStickY);
bb.put(TAIL);
}
@Override
public int getPacketLength() {
return PACKET_LENGTH;
}
}

View File

@@ -0,0 +1,44 @@
package com.limelight.nvstream.mdns;
import java.net.InetAddress;
public class MdnsComputer {
private InetAddress ipAddr;
private String name;
public MdnsComputer(String name, InetAddress addr) {
this.name = name;
this.ipAddr = addr;
}
public String getName() {
return name;
}
public InetAddress getAddress() {
return ipAddr;
}
@Override
public int hashCode() {
return name.hashCode();
}
@Override
public boolean equals(Object o) {
if (o instanceof MdnsComputer) {
MdnsComputer other = (MdnsComputer)o;
if (other.ipAddr.equals(ipAddr) &&
other.name.equals(name)) {
return true;
}
}
return false;
}
@Override
public String toString() {
return "["+name+" - "+ipAddr+"]";
}
}

View File

@@ -0,0 +1,244 @@
package com.limelight.nvstream.mdns;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import javax.jmdns.JmmDNS;
import javax.jmdns.ServiceEvent;
import javax.jmdns.ServiceInfo;
import javax.jmdns.ServiceListener;
import com.limelight.LimeLog;
public class MdnsDiscoveryAgent implements ServiceListener {
public static final String SERVICE_TYPE = "_nvstream._tcp.local.";
private MdnsDiscoveryListener listener;
private Thread discoveryThread;
private HashMap<InetAddress, MdnsComputer> computers = new HashMap<InetAddress, MdnsComputer>();
private HashSet<String> pendingResolution = new HashSet<String>();
// The resolver factory's instance member has a static lifetime which
// means our ref count and listener must be static also.
private static int resolverRefCount = 0;
private static HashSet<ServiceListener> listeners = new HashSet<ServiceListener>();
private static ServiceListener nvstreamListener = new ServiceListener() {
@Override
public void serviceAdded(ServiceEvent event) {
HashSet<ServiceListener> localListeners;
// Copy the listener set into a new set so we can invoke
// the callbacks without holding the listeners monitor the
// whole time.
synchronized (listeners) {
localListeners = new HashSet<ServiceListener>(listeners);
}
for (ServiceListener listener : localListeners) {
listener.serviceAdded(event);
}
}
@Override
public void serviceRemoved(ServiceEvent event) {
HashSet<ServiceListener> localListeners;
// Copy the listener set into a new set so we can invoke
// the callbacks without holding the listeners monitor the
// whole time.
synchronized (listeners) {
localListeners = new HashSet<ServiceListener>(listeners);
}
for (ServiceListener listener : localListeners) {
listener.serviceRemoved(event);
}
}
@Override
public void serviceResolved(ServiceEvent event) {
HashSet<ServiceListener> localListeners;
// Copy the listener set into a new set so we can invoke
// the callbacks without holding the listeners monitor the
// whole time.
synchronized (listeners) {
localListeners = new HashSet<ServiceListener>(listeners);
}
for (ServiceListener listener : localListeners) {
listener.serviceResolved(event);
}
}
};
private static JmmDNS referenceResolver() {
synchronized (MdnsDiscoveryAgent.class) {
JmmDNS instance = JmmDNS.Factory.getInstance();
if (++resolverRefCount == 1) {
// This will cause the listener to be invoked for known hosts immediately.
// JmDNS only supports one listener per service, so we have to do this here
// with a static listener.
instance.addServiceListener(SERVICE_TYPE, nvstreamListener);
}
return instance;
}
}
private static void dereferenceResolver() {
synchronized (MdnsDiscoveryAgent.class) {
if (--resolverRefCount == 0) {
try {
JmmDNS.Factory.close();
} catch (IOException e) {}
}
}
}
public MdnsDiscoveryAgent(MdnsDiscoveryListener listener) {
this.listener = listener;
}
private void handleResolvedServiceInfo(ServiceInfo info) {
pendingResolution.remove(info.getName());
try {
handleServiceInfo(info);
} catch (UnsupportedEncodingException e) {
// Invalid DNS response
LimeLog.info("mDNS: Invalid response for machine: "+info.getName());
return;
}
}
private void handleServiceInfo(ServiceInfo info) throws UnsupportedEncodingException {
Inet4Address addrs[] = info.getInet4Addresses();
LimeLog.info("mDNS: "+info.getName()+" has "+addrs.length+" addresses");
// Add a computer object for each IPv4 address reported by the PC
for (Inet4Address addr : addrs) {
synchronized (computers) {
MdnsComputer computer = new MdnsComputer(info.getName(), addr);
if (computers.put(computer.getAddress(), computer) == null) {
// This was a new entry
listener.notifyComputerAdded(computer);
}
}
}
}
public void startDiscovery(final int discoveryIntervalMs) {
// Kill any existing discovery before starting a new one
stopDiscovery();
// Add our listener to the set
synchronized (listeners) {
listeners.add(MdnsDiscoveryAgent.this);
}
discoveryThread = new Thread() {
@Override
public void run() {
// This may result in listener callbacks so we must register
// our listener first.
JmmDNS resolver = referenceResolver();
try {
while (!Thread.interrupted()) {
// Start an mDNS request
resolver.requestServiceInfo(SERVICE_TYPE, null, discoveryIntervalMs);
// Run service resolution again for pending machines
ArrayList<String> pendingNames = new ArrayList<String>(pendingResolution);
for (String name : pendingNames) {
LimeLog.info("mDNS: Retrying service resolution for machine: "+name);
ServiceInfo[] infos = resolver.getServiceInfos(SERVICE_TYPE, name, 500);
if (infos != null && infos.length != 0) {
LimeLog.info("mDNS: Resolved (retry) with "+infos.length+" service entries");
for (ServiceInfo svcinfo : infos) {
handleResolvedServiceInfo(svcinfo);
}
}
}
// Wait for the next polling interval
try {
Thread.sleep(discoveryIntervalMs);
} catch (InterruptedException e) {
break;
}
}
}
finally {
// Dereference the resolver
dereferenceResolver();
}
}
};
discoveryThread.setName("mDNS Discovery Thread");
discoveryThread.start();
}
public void stopDiscovery() {
// Remove our listener from the set
synchronized (listeners) {
listeners.remove(MdnsDiscoveryAgent.this);
}
// If there's already a running thread, interrupt it
if (discoveryThread != null) {
discoveryThread.interrupt();
discoveryThread = null;
}
}
public List<MdnsComputer> getComputerSet() {
synchronized (computers) {
return new ArrayList<MdnsComputer>(computers.values());
}
}
@Override
public void serviceAdded(ServiceEvent event) {
LimeLog.info("mDNS: Machine appeared: "+event.getInfo().getName());
ServiceInfo info = event.getDNS().getServiceInfo(SERVICE_TYPE, event.getInfo().getName(), 500);
if (info == null) {
// This machine is pending resolution
pendingResolution.add(event.getInfo().getName());
return;
}
LimeLog.info("mDNS: Resolved (blocking)");
handleResolvedServiceInfo(info);
}
@Override
public void serviceRemoved(ServiceEvent event) {
LimeLog.info("mDNS: Machine disappeared: "+event.getInfo().getName());
Inet4Address addrs[] = event.getInfo().getInet4Addresses();
for (Inet4Address addr : addrs) {
synchronized (computers) {
MdnsComputer computer = computers.remove(addr);
if (computer != null) {
listener.notifyComputerRemoved(computer);
break;
}
}
}
}
@Override
public void serviceResolved(ServiceEvent event) {
LimeLog.info("mDNS: Machine resolved (callback): "+event.getInfo().getName());
handleResolvedServiceInfo(event.getInfo());
}
}

View File

@@ -0,0 +1,7 @@
package com.limelight.nvstream.mdns;
public interface MdnsDiscoveryListener {
public void notifyComputerAdded(MdnsComputer computer);
public void notifyComputerRemoved(MdnsComputer computer);
public void notifyDiscoveryFailure(Exception e);
}

View File

@@ -0,0 +1,282 @@
package com.limelight.nvstream.rtsp;
import java.io.IOException;
import java.net.Inet6Address;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.util.HashMap;
import com.limelight.nvstream.ConnectionContext;
import com.limelight.nvstream.av.video.VideoDecoderRenderer.VideoFormat;
import com.limelight.nvstream.enet.EnetConnection;
import com.tinyrtsp.rtsp.message.RtspMessage;
import com.tinyrtsp.rtsp.message.RtspRequest;
import com.tinyrtsp.rtsp.message.RtspResponse;
import com.tinyrtsp.rtsp.parser.RtspParser;
import com.tinyrtsp.rtsp.parser.RtspStream;
public class RtspConnection {
public static final int PORT = 48010;
public static final int RTSP_TIMEOUT = 10000;
private int sequenceNumber = 1;
private int sessionId = 0;
private EnetConnection enetConnection;
private ConnectionContext context;
private String hostStr;
public RtspConnection(ConnectionContext context) {
this.context = context;
if (context.serverAddress instanceof Inet6Address) {
// RFC2732-formatted IPv6 address for use in URL
this.hostStr = "["+context.serverAddress.getHostAddress()+"]";
}
else {
this.hostStr = context.serverAddress.getHostAddress();
}
}
private String getRtspVideoStreamName() {
if (context.serverGeneration >= ConnectionContext.SERVER_GENERATION_5) {
return "video/0/0";
}
else {
return "video";
}
}
private String getRtspAudioStreamName() {
if (context.serverGeneration >= ConnectionContext.SERVER_GENERATION_5) {
return "audio/0/0";
}
else {
return "audio";
}
}
public static int getRtspVersionFromContext(ConnectionContext context) {
switch (context.serverGeneration)
{
case ConnectionContext.SERVER_GENERATION_3:
return 10;
case ConnectionContext.SERVER_GENERATION_4:
return 11;
case ConnectionContext.SERVER_GENERATION_5:
return 12;
case ConnectionContext.SERVER_GENERATION_6:
// Gen 6 has never been seen in the wild
return 13;
case ConnectionContext.SERVER_GENERATION_7:
default:
return 14;
}
}
private RtspRequest createRtspRequest(String command, String target) {
RtspRequest m = new RtspRequest(command, target, "RTSP/1.0",
sequenceNumber++, new HashMap<String, String>(), null);
m.setOption("X-GS-ClientVersion", ""+getRtspVersionFromContext(context));
return m;
}
private String byteBufferToString(byte[] bytes, int length) {
StringBuilder message = new StringBuilder();
for (int i = 0; i < length; i++) {
message.append((char) bytes[i]);
}
return message.toString();
}
private RtspResponse transactRtspMessageEnet(RtspMessage m) throws IOException {
byte[] header, payload;
header = m.toWireNoPayload();
payload = m.toWirePayloadOnly();
// Send the RTSP header
enetConnection.writePacket(ByteBuffer.wrap(header));
// Send payload in a separate packet if there's payload on this
if (payload != null) {
enetConnection.writePacket(ByteBuffer.wrap(payload));
}
// Wait for a response
ByteBuffer responseHeader = enetConnection.readPacket(2048, RTSP_TIMEOUT);
// Parse the response and determine whether it has a payload
RtspResponse message = (RtspResponse) RtspParser.parseMessageNoPayload(byteBufferToString(responseHeader.array(), responseHeader.limit()));
if (message.getOption("Content-Length") != null) {
// The payload comes in a second packet
ByteBuffer responsePayload = enetConnection.readPacket(65536, RTSP_TIMEOUT);
message.setPayload(byteBufferToString(responsePayload.array(), responsePayload.limit()));
}
return message;
}
private RtspResponse transactRtspMessageTcp(RtspMessage m) throws IOException {
Socket s = new Socket();
try {
s.setTcpNoDelay(true);
s.connect(new InetSocketAddress(context.serverAddress, PORT), RTSP_TIMEOUT);
s.setSoTimeout(RTSP_TIMEOUT);
RtspStream rtspStream = new RtspStream(s.getInputStream(), s.getOutputStream());
try {
rtspStream.write(m);
return (RtspResponse) rtspStream.read();
} finally {
rtspStream.close();
}
} finally {
s.close();
}
}
private RtspResponse transactRtspMessage(RtspMessage m) throws IOException {
if (context.serverGeneration >= ConnectionContext.SERVER_GENERATION_5) {
return transactRtspMessageEnet(m);
}
else {
return transactRtspMessageTcp(m);
}
}
private RtspResponse requestOptions() throws IOException {
RtspRequest m = createRtspRequest("OPTIONS", "rtsp://"+hostStr);
return transactRtspMessage(m);
}
private RtspResponse requestDescribe() throws IOException {
RtspRequest m = createRtspRequest("DESCRIBE", "rtsp://"+hostStr);
m.setOption("Accept", "application/sdp");
m.setOption("If-Modified-Since", "Thu, 01 Jan 1970 00:00:00 GMT");
return transactRtspMessage(m);
}
private RtspResponse setupStream(String streamName) throws IOException {
RtspRequest m = createRtspRequest("SETUP", "streamid="+streamName);
if (sessionId != 0) {
m.setOption("Session", ""+sessionId);
}
if (context.serverGeneration >= ConnectionContext.SERVER_GENERATION_6) {
// It looks like GFE doesn't care what we say our port is but
// we need to give it some port to successfully complete the
// handshake process.
m.setOption("Transport", "unicast;X-GS-ClientPort=50000-50001");
}
else {
m.setOption("Transport", " ");
}
m.setOption("If-Modified-Since", "Thu, 01 Jan 1970 00:00:00 GMT");
return transactRtspMessage(m);
}
private RtspResponse playStream(String streamName) throws IOException {
RtspRequest m = createRtspRequest("PLAY", "streamid="+streamName);
m.setOption("Session", ""+sessionId);
return transactRtspMessage(m);
}
private RtspResponse sendVideoAnnounce() throws IOException {
RtspRequest m = createRtspRequest("ANNOUNCE", "streamid=video");
m.setOption("Session", ""+sessionId);
m.setOption("Content-type", "application/sdp");
m.setPayload(SdpGenerator.generateSdpFromContext(context));
m.setOption("Content-length", ""+m.getPayload().length());
return transactRtspMessage(m);
}
private void processDescribeResponse(RtspResponse r) {
// The RTSP DESCRIBE reply will contain a collection of SDP media attributes that
// describe the various supported video stream formats and include the SPS, PPS,
// and VPS (if applicable). We will use this information to determine whether the
// server can support HEVC. For some reason, they still set the MIME type of the HEVC
// format to H264, so we can't just look for the HEVC MIME type. What we'll do instead is
// look for the base 64 encoded VPS NALU prefix that is unique to the HEVC bitstream.
String describeSdpContent = r.getPayload();
if (context.streamConfig.getHevcSupported() &&
describeSdpContent.contains("sprop-parameter-sets=AAAAAU")) {
context.negotiatedVideoFormat = VideoFormat.H265;
}
else {
context.negotiatedVideoFormat = VideoFormat.H264;
}
}
private void processRtspSetupAudio(RtspResponse r) throws IOException {
try {
sessionId = Integer.parseInt(r.getOption("Session"));
} catch (NumberFormatException e) {
throw new IOException("RTSP SETUP response was malformed");
}
}
public void doRtspHandshake() throws IOException {
RtspResponse r;
// Gen 5+ servers do RTSP over ENet instead of TCP
if (context.serverGeneration >= ConnectionContext.SERVER_GENERATION_5) {
enetConnection = EnetConnection.connect(context.serverAddress.getHostAddress(), PORT, RTSP_TIMEOUT);
}
try {
r = requestOptions();
if (r.getStatusCode() != 200) {
throw new IOException("RTSP OPTIONS request failed: "+r.getStatusCode());
}
r = requestDescribe();
if (r.getStatusCode() != 200) {
throw new IOException("RTSP DESCRIBE request failed: "+r.getStatusCode());
}
// Process the RTSP DESCRIBE response
processDescribeResponse(r);
r = setupStream(getRtspAudioStreamName());
if (r.getStatusCode() != 200) {
throw new IOException("RTSP SETUP request failed: "+r.getStatusCode());
}
// Process the RTSP SETUP streamid=audio response
processRtspSetupAudio(r);
r = setupStream(getRtspVideoStreamName());
if (r.getStatusCode() != 200) {
throw new IOException("RTSP SETUP request failed: "+r.getStatusCode());
}
if (context.serverGeneration >= ConnectionContext.SERVER_GENERATION_5) {
r = setupStream("control/1/0");
if (r.getStatusCode() != 200) {
throw new IOException("RTSP SETUP request failed: "+r.getStatusCode());
}
}
r = sendVideoAnnounce();
if (r.getStatusCode() != 200) {
throw new IOException("RTSP ANNOUNCE request failed: "+r.getStatusCode());
}
r = playStream("video");
if (r.getStatusCode() != 200) {
throw new IOException("RTSP PLAY request failed: "+r.getStatusCode());
}
r = playStream("audio");
if (r.getStatusCode() != 200) {
throw new IOException("RTSP PLAY request failed: "+r.getStatusCode());
}
} finally {
if (enetConnection != null) {
enetConnection.close();
enetConnection = null;
}
}
}
}

View File

@@ -0,0 +1,208 @@
package com.limelight.nvstream.rtsp;
import java.net.Inet6Address;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import com.limelight.nvstream.ConnectionContext;
import com.limelight.nvstream.av.video.VideoDecoderRenderer.VideoFormat;
public class SdpGenerator {
private static void addSessionAttribute(StringBuilder config, String attribute, String value) {
config.append("a="+attribute+":"+value+" \r\n");
}
private static void addSessionAttributeBytes(StringBuilder config, String attribute, byte[] value) {
char str[] = new char[value.length];
for (int i = 0; i < value.length; i++) {
str[i] = (char)value[i];
}
addSessionAttribute(config, attribute, new String(str));
}
private static void addSessionAttributeInt(StringBuilder config, String attribute, int value) {
ByteBuffer b = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN);
b.putInt(value);
addSessionAttributeBytes(config, attribute, b.array());
}
private static void addGen3Attributes(StringBuilder config, ConnectionContext context) {
addSessionAttribute(config, "x-nv-general.serverAddress", context.serverAddress.getHostAddress());
addSessionAttributeInt(config, "x-nv-general.featureFlags", 0x42774141);
addSessionAttributeInt(config, "x-nv-video[0].transferProtocol", 0x41514141);
addSessionAttributeInt(config, "x-nv-video[1].transferProtocol", 0x41514141);
addSessionAttributeInt(config, "x-nv-video[2].transferProtocol", 0x41514141);
addSessionAttributeInt(config, "x-nv-video[3].transferProtocol", 0x41514141);
addSessionAttributeInt(config, "x-nv-video[0].rateControlMode", 0x42414141);
addSessionAttributeInt(config, "x-nv-video[1].rateControlMode", 0x42514141);
addSessionAttributeInt(config, "x-nv-video[2].rateControlMode", 0x42514141);
addSessionAttributeInt(config, "x-nv-video[3].rateControlMode", 0x42514141);
addSessionAttribute(config, "x-nv-vqos[0].bw.flags", "14083");
addSessionAttribute(config, "x-nv-vqos[0].videoQosMaxConsecutiveDrops", "0");
addSessionAttribute(config, "x-nv-vqos[1].videoQosMaxConsecutiveDrops", "0");
addSessionAttribute(config, "x-nv-vqos[2].videoQosMaxConsecutiveDrops", "0");
addSessionAttribute(config, "x-nv-vqos[3].videoQosMaxConsecutiveDrops", "0");
}
private static void addGen4Attributes(StringBuilder config, ConnectionContext context) {
addSessionAttribute(config, "x-nv-general.serverAddress", "rtsp://"+context.serverAddress.getHostAddress()+":48010");
addSessionAttribute(config, "x-nv-video[0].rateControlMode", "4");
}
private static void addGen5Attributes(StringBuilder config, ConnectionContext context) {
// We want to use the new ENet connections for control and input
addSessionAttribute(config, "x-nv-general.useReliableUdp", "1");
addSessionAttribute(config, "x-nv-ri.useControlChannel", "1");
// Disable dynamic resolution switching
addSessionAttribute(config, "x-nv-vqos[0].drc.enable", "0");
}
public static String generateSdpFromContext(ConnectionContext context) {
// By now, we must have decided on a format
if (context.negotiatedVideoFormat == VideoFormat.Unknown) {
throw new IllegalStateException("Video format negotiation must be completed before generating SDP response");
}
// Also, resolution and frame rate must be set
if (context.negotiatedWidth == 0 || context.negotiatedHeight == 0 || context.negotiatedFps == 0) {
throw new IllegalStateException("Video resolution/FPS negotiation must be completed before generating SDP response");
}
StringBuilder config = new StringBuilder();
config.append("v=0").append("\r\n"); // SDP Version 0
config.append("o=android 0 "+RtspConnection.getRtspVersionFromContext(context)+" IN ");
if (context.serverAddress instanceof Inet6Address) {
config.append("IPv6 ");
}
else {
config.append("IPv4 ");
}
config.append(context.serverAddress.getHostAddress());
config.append("\r\n");
config.append("s=NVIDIA Streaming Client").append("\r\n");
addSessionAttribute(config, "x-nv-video[0].clientViewportWd", ""+context.negotiatedWidth);
addSessionAttribute(config, "x-nv-video[0].clientViewportHt", ""+context.negotiatedHeight);
addSessionAttribute(config, "x-nv-video[0].maxFPS", ""+context.negotiatedFps);
addSessionAttribute(config, "x-nv-video[0].packetSize", ""+context.streamConfig.getMaxPacketSize());
addSessionAttribute(config, "x-nv-video[0].timeoutLengthMs", "7000");
addSessionAttribute(config, "x-nv-video[0].framesWithInvalidRefThreshold", "0");
// H.265 can encode much more efficiently, but we have a problem since not all
// users will be using H.265 and we don't have an independent bitrate setting
// for H.265. We'll use use the selected bitrate * .75 when H.265 is in use.
int bitrate;
if (context.negotiatedVideoFormat == VideoFormat.H265) {
bitrate = (int)(context.streamConfig.getBitrate()*0.75);
}
else {
bitrate = context.streamConfig.getBitrate();
}
if (context.serverGeneration >= ConnectionContext.SERVER_GENERATION_5) {
addSessionAttribute(config, "x-nv-vqos[0].bw.minimumBitrateKbps", ""+bitrate);
addSessionAttribute(config, "x-nv-vqos[0].bw.maximumBitrateKbps", ""+bitrate);
}
else {
if (context.streamConfig.getRemote()) {
addSessionAttribute(config, "x-nv-video[0].averageBitrate", "4");
addSessionAttribute(config, "x-nv-video[0].peakBitrate", "4");
}
// We don't support dynamic bitrate scaling properly (it tends to bounce between min and max and never
// settle on the optimal bitrate if it's somewhere in the middle), so we'll just latch the bitrate
// to the requested value.
addSessionAttribute(config, "x-nv-vqos[0].bw.minimumBitrate", ""+bitrate);
addSessionAttribute(config, "x-nv-vqos[0].bw.maximumBitrate", ""+bitrate);
}
// Using FEC turns padding on which makes us have to take the slow path
// in the depacketizer, not to mention exposing some ambiguous cases with
// distinguishing padding from valid sequences. Since we can only perform
// execute an FEC recovery on a 1 packet frame, we'll just turn it off completely.
addSessionAttribute(config, "x-nv-vqos[0].fec.enable", "0");
addSessionAttribute(config, "x-nv-vqos[0].videoQualityScoreUpdateTime", "5000");
if (context.streamConfig.getRemote()) {
addSessionAttribute(config, "x-nv-vqos[0].qosTrafficType", "0");
}
else {
addSessionAttribute(config, "x-nv-vqos[0].qosTrafficType", "5");
}
if (context.streamConfig.getRemote()) {
addSessionAttribute(config, "x-nv-aqos.qosTrafficType", "0");
}
else {
addSessionAttribute(config, "x-nv-aqos.qosTrafficType", "4");
}
// Add generation-specific attributes
switch (context.serverGeneration) {
case ConnectionContext.SERVER_GENERATION_3:
addGen3Attributes(config, context);
break;
case ConnectionContext.SERVER_GENERATION_4:
addGen4Attributes(config, context);
break;
case ConnectionContext.SERVER_GENERATION_5:
default:
addGen5Attributes(config, context);
break;
}
// Gen 4+ supports H.265 and surround sound
if (context.serverGeneration >= ConnectionContext.SERVER_GENERATION_4) {
// If client and server are able, request HEVC
if (context.negotiatedVideoFormat == VideoFormat.H265) {
addSessionAttribute(config, "x-nv-clientSupportHevc", "1");
addSessionAttribute(config, "x-nv-vqos[0].bitStreamFormat", "1");
// Disable slicing on HEVC
addSessionAttribute(config, "x-nv-video[0].videoEncoderSlicesPerFrame", "1");
}
else {
// Otherwise, use AVC
addSessionAttribute(config, "x-nv-clientSupportHevc", "0");
addSessionAttribute(config, "x-nv-vqos[0].bitStreamFormat", "0");
// Use slicing for increased performance on some decoders
addSessionAttribute(config, "x-nv-video[0].videoEncoderSlicesPerFrame", "4");
}
// Enable surround sound if configured for it
addSessionAttribute(config, "x-nv-audio.surround.numChannels", ""+context.streamConfig.getAudioChannelCount());
addSessionAttribute(config, "x-nv-audio.surround.channelMask", ""+context.streamConfig.getAudioChannelMask());
if (context.streamConfig.getAudioChannelCount() > 2) {
addSessionAttribute(config, "x-nv-audio.surround.enable", "1");
}
else {
addSessionAttribute(config, "x-nv-audio.surround.enable", "0");
}
}
config.append("t=0 0").append("\r\n");
if (context.serverGeneration == ConnectionContext.SERVER_GENERATION_3) {
config.append("m=video 47996 ").append("\r\n");
}
else {
config.append("m=video 47998 ").append("\r\n");
}
return config.toString();
}
}

View File

@@ -0,0 +1,78 @@
package com.limelight.nvstream.wol;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.util.Scanner;
import com.limelight.LimeLog;
import com.limelight.nvstream.http.ComputerDetails;
public class WakeOnLanSender {
private static final int[] PORTS_TO_TRY = new int[] {
7, 9, // Standard WOL ports
47998, 47999, 48000 // Ports opened by GFE
};
public static void sendWolPacket(ComputerDetails computer) throws IOException {
DatagramSocket sock = new DatagramSocket(0);
byte[] payload = createWolPayload(computer);
// Try both remote and local addresses
for (int i = 0; i < 2; i++) {
InetAddress addr;
if (i == 0) {
addr = computer.localIp;
}
else {
addr = computer.remoteIp;
}
// Try all the ports for each address
for (int port : PORTS_TO_TRY) {
DatagramPacket dp = new DatagramPacket(payload, payload.length);
dp.setAddress(addr);
dp.setPort(port);
sock.send(dp);
}
}
sock.close();
}
private static byte[] macStringToBytes(String macAddress) {
byte[] macBytes = new byte[6];
@SuppressWarnings("resource")
Scanner scan = new Scanner(macAddress).useDelimiter(":");
for (int i = 0; i < macBytes.length && scan.hasNext(); i++) {
try {
macBytes[i] = (byte) Integer.parseInt(scan.next(), 16);
} catch (NumberFormatException e) {
LimeLog.warning("Malformed MAC address: "+macAddress+" (index: "+i+")");
break;
}
}
scan.close();
return macBytes;
}
private static byte[] createWolPayload(ComputerDetails computer) {
byte[] payload = new byte[102];
byte[] macAddress = macStringToBytes(computer.macAddress);
int i;
// 6 bytes of FF
for (i = 0; i < 6; i++) {
payload[i] = (byte)0xFF;
}
// 16 repetitions of the MAC address
for (int j = 0; j < 16; j++) {
System.arraycopy(macAddress, 0, payload, i, macAddress.length);
i += macAddress.length;
}
return payload;
}
}

View File

@@ -0,0 +1,7 @@
package com.limelight.utils;
public class TimeHelper {
public static long getMonotonicMillis() {
return System.nanoTime() / 1000000L;
}
}

View File

@@ -0,0 +1,47 @@
package com.limelight.utils;
public class Vector2d {
private float x;
private float y;
private double magnitude;
public static final Vector2d ZERO = new Vector2d();
public Vector2d() {
initialize(0, 0);
}
public void initialize(float x, float y) {
this.x = x;
this.y = y;
this.magnitude = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2));
}
public double getMagnitude() {
return magnitude;
}
public void getNormalized(Vector2d vector) {
vector.initialize((float)(x / magnitude), (float)(y / magnitude));
}
public void scalarMultiply(double factor) {
initialize((float)(x * factor), (float)(y * factor));
}
public void setX(float x) {
initialize(x, this.y);
}
public void setY(float y) {
initialize(this.x, y);
}
public float getX() {
return x;
}
public float getY() {
return y;
}
}