/*
MixedReplaceMediaServer.java
Copyright (c) 2015 NTT DOCOMO,INC.
Released under the MIT license
http://opensource.org/licenses/mit-license.php
*/
package org.deviceconnect.android.deviceplugin.theta.utils;
import android.net.Uri;
import org.deviceconnect.android.deviceplugin.theta.BuildConfig;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.StringTokenizer;
import java.util.UUID;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.logging.Logger;
/**
* Mixed Replace Media Server.
*
* @author NTT DOCOMO, INC.
*/
public class MixedReplaceMediaServer {
/**
* Logger.
*/
private Logger mLogger = Logger.getLogger("theta.dplugin");
/**
* Max value of cache of media.
*/
private static final int MAX_MEDIA_CACHE = 2;
/**
* Max value of client.
*/
private static final int MAX_CLIENT_SIZE = 8;
/**
* Port of the Socket.
*/
private int mPort = -1;
/**
* The boundary of a multipart.
*/
private String mBoundary = UUID.randomUUID().toString();
/**
* Content type.
* Default is "image/jpg".
*/
private String mContentType = "image/jpeg";
/**
* Stop flag.
*/
private boolean mIsServerStopped;
/**
* Name of web server.
*/
private String mServerName = "DevicePlugin Server";
/**
* Server Socket.
*/
private ServerSocket mServerSocket;
/**
* Manage a thread.
*/
private final ExecutorService mExecutor = Executors.newFixedThreadPool(MAX_CLIENT_SIZE);
/**
* List a Server Runnable.
*/
private final List<ServerRunnable> mRunnables = Collections.synchronizedList(new ArrayList<ServerRunnable>());
/**
* Server Event Listener.
*/
private ServerEventListener mServerEventListener;
/**
* Set a boundary.
*
* @param boundary boundary of a multipart
*/
public void setBoundary(final String boundary) {
if (boundary == null) {
throw new IllegalArgumentException("boundary is null.");
}
if (boundary.isEmpty()) {
throw new IllegalArgumentException("boundary is empty.");
}
mBoundary = boundary;
}
/**
* Get a boundary.
*
* @return boundary
*/
public String getBoundary() {
return mBoundary;
}
/**
* Set a content type.
* <p>
* Default is "image/jpg".
* </p>
*
* @param contentType content type
*/
public void setContentType(final String contentType) {
mContentType = contentType;
}
/**
* Get a content type.
*
* @return content type
*/
public String getContentType() {
return mContentType;
}
/**
* Set a port of web server.
*
* @param port port of a web server
*/
public void setPort(final int port) {
if (port < 1000) {
throw new IllegalArgumentException("Port is smaller than 1000.");
}
mPort = port;
}
/**
* Get a port of web server.
*
* @return port
*/
public int getPort() {
return mPort;
}
/**
* Set a name of server.
*
* @param name name of server
*/
public void setServerName(final String name) {
if (name == null) {
throw new IllegalArgumentException("name is null.");
}
mServerName = name;
}
/**
* Get a name of server.
*
* @return name of server
*/
public String getServerName() {
return mServerName;
}
/**
* Get a url of server.
*
* @return url
*/
public String getUrl() {
if (mServerSocket == null) {
return null;
}
return "http://localhost:" + mServerSocket.getLocalPort();
}
/**
* Get a server running status.
*
* @return server status
*/
public synchronized boolean isRunning() {
return !mIsServerStopped;
}
/**
* Inserts the media data into queue.
*
* @param media media data
*/
public void offerMedia(final String segment, final byte[] media) {
if (media == null) {
return;
}
if (!mIsServerStopped) {
synchronized (mRunnables) {
for (ServerRunnable run : mRunnables) {
run.offerMedia(segment, media);
}
}
}
}
public void stopMedia(final String segment) {
synchronized (mRunnables) {
for (ServerRunnable run : mRunnables) {
run.stopMedia(segment);
}
}
}
/**
* Start a mixed replace media server.
* <p>
* If a port is not set, looking for a port that is not used between 9000 to 10000, set to server.
* </p>
*
* @return the local IP address of this server or {@code null} if this server cannot start.
*/
public synchronized String start() {
try {
mServerSocket = openServerSocket();
mLogger.fine("Open a server socket.");
} catch (IOException e) {
// Failed to open server socket
mIsServerStopped = true;
return null;
}
mIsServerStopped = false;
new Thread(new Runnable() {
@Override
public void run() {
try {
while (!mIsServerStopped) {
ServerRunnable run = new ServerRunnable(mServerSocket.accept());
synchronized (MixedReplaceMediaServer.this) {
mExecutor.execute(run);
}
}
} catch (IOException e) {
mLogger.warning("Error server socket[" + mServerName + "]");
} finally {
stop();
}
}
}, "MotionJPEG Server Thread").start();
return getUrl();
}
/**
* Open a server socket that looking for a port that can be used.
*
* @return ServerSocket
* @throws java.io.IOException if an error occurs while open socket.
*/
private ServerSocket openServerSocket() throws IOException {
if (mPort != -1) {
return new ServerSocket(mPort);
} else {
for (int i = 9000; i <= 10000; i++) {
try {
return new ServerSocket(i);
} catch (IOException e) {
continue;
}
}
throw new IOException("Cannot open server socket.");
}
}
/**
* Stop a mixed replace media server.
*/
public synchronized void stop() {
if (mIsServerStopped) {
return;
}
mIsServerStopped = true;
mExecutor.shutdown();
synchronized (mRunnables) {
for (ServerRunnable run : mRunnables) {
run.stopAllMedia();
}
}
if (mServerSocket != null) {
try {
mServerSocket.close();
mServerSocket = null;
} catch (IOException e) {
if (BuildConfig.DEBUG) {
e.printStackTrace();
}
}
}
if (mServerEventListener != null) {
mServerEventListener.onCloseServer();
}
mLogger.fine("MixedReplaceMediaServer is stop.");
}
/**
* Class of Server.
*/
private class ServerRunnable implements Runnable {
/**
* Defined buffer size.
*/
private static final int BUF_SIZE = 8192;
/**
* Socket.
*/
private final Socket mSocket;
/**
* Stream for writing.
*/
private OutputStream mStream;
/**
* Request.
*/
private Request mRequest;
/**
* Queues that holds the media.
*/
private final Map<String, BlockingQueue<byte[]>> mMediaQueues =
new HashMap<String, BlockingQueue<byte[]>>();
/**
* Whether media delivery is stopped or not.
*/
private boolean mIsMediaStopped;
/**
* Constructor.
*
* @param socket socket
*/
public ServerRunnable(final Socket socket) {
mSocket = socket;
}
@Override
public void run() {
mLogger.fine("accept client.");
mRunnables.add(this);
try {
mStream = mSocket.getOutputStream();
byte[] buf = new byte[BUF_SIZE];
InputStream in = mSocket.getInputStream();
int len = in.read(buf, 0, BUF_SIZE);
if (len == -1) {
// error
return;
}
HttpHeader header = decodeHeader(buf, len);
mRequest = new Request(header);
if (mRunnables.size() > MAX_CLIENT_SIZE) {
mStream.write(generateServiceUnavailable().getBytes());
mStream.flush();
return;
}
String segment = Uri.parse(mRequest.getUri()).getLastPathSegment();
boolean isGet = header.hasParam("snapshot");
byte[] jpeg = null;
if (mServerEventListener != null) {
jpeg = mServerEventListener.onConnect(mRequest);
}
if (isGet) {
if (jpeg == null) {
BlockingQueue<byte[]> mediaQueue = mMediaQueues.get(segment);
if (mediaQueue != null) {
jpeg = mediaQueue.take();
} else {
jpeg = new byte[0];
}
}
StringBuilder sb = new StringBuilder();
sb.append("HTTP/1.0 200 OK\r\n");
sb.append("Server: " + mServerName + "\r\n");
sb.append("Connection: close\r\n");
sb.append("Content-Type: image/jpeg\r\n");
sb.append("Content-Length: " + jpeg.length + "\r\n");
sb.append("\r\n");
mStream.write(sb.toString().getBytes());
mStream.flush();
mStream.write(jpeg);
mStream.flush();
return;
}
mStream.write(generateHttpHeader().getBytes());
mStream.flush();
while (!mIsServerStopped && !mIsMediaStopped) {
BlockingQueue<byte[]> mediaQueue = mMediaQueues.get(segment);
if (mediaQueue != null) {
byte[] media = mediaQueue.take();
if (media.length > 0) {
sendMedia(media);
}
}
}
} catch (InterruptedException e) {
if (mStream != null) {
try {
mStream.write(generateInternalServerError().getBytes());
mStream.flush();
} catch (IOException e1) {
mLogger.warning("Error server socket[" + mServerName + "]");
}
}
Thread.currentThread().interrupt();
} catch (IOException e) {
if (mStream != null) {
try {
mStream.write(generateBadRequest().getBytes());
mStream.flush();
} catch (IOException e1) {
mLogger.warning("Error server socket[" + mServerName + "]");
}
}
} catch (Throwable e) {
e.printStackTrace();
} finally {
mLogger.fine("socket close.");
if (mServerEventListener != null && mRequest != null) {
mServerEventListener.onDisconnect(mRequest);
}
if (mStream != null) {
try {
mStream.close();
} catch (IOException e) {
if (BuildConfig.DEBUG) {
e.printStackTrace();
}
}
}
try {
mSocket.close();
} catch (IOException e) {
if (BuildConfig.DEBUG) {
e.printStackTrace();
}
}
mRunnables.remove(this);
}
}
/**
* Inserts the media data into queue.
*
* @param segment the segment of URI of media
* @param media the media to add
* @return true if the media data was added to this queue, else false
*/
private boolean offerMedia(final String segment, final byte[] media) {
BlockingQueue<byte[]> mediaQueue;
synchronized (mMediaQueues) {
mediaQueue = mMediaQueues.get(segment);
if (mediaQueue == null) {
mediaQueue = new ArrayBlockingQueue<byte[]>(MAX_MEDIA_CACHE);
mMediaQueues.put(segment, mediaQueue);
}
}
if (mediaQueue.size() == MAX_MEDIA_CACHE) {
mediaQueue.remove();
}
return mediaQueue.offer(media);
}
/**
* Send a media data.
*
* @param media media data
* @throws java.io.IOException if an error occurs while sending media data.
*/
private void sendMedia(final byte[] media) throws IOException {
StringBuilder sb = new StringBuilder();
sb.append("--" + mBoundary + "\r\n");
sb.append("Content-type: " + mContentType + "\r\n");
sb.append("Content-Length: " + media.length + "\r\n");
sb.append("\r\n");
mStream.write(sb.toString().getBytes());
mStream.write(media);
mStream.write("\r\n\r\n".getBytes());
mStream.flush();
}
private void stopMedia(final String segment) {
mIsMediaStopped = true;
synchronized (mMediaQueues) {
BlockingQueue<byte[]> mediaQueue = mMediaQueues.remove(segment);
if (mediaQueue != null) {
mediaQueue.offer(new byte[0]);
}
}
}
private void stopAllMedia() {
synchronized (mMediaQueues) {
for (Map.Entry<String, BlockingQueue<byte[]>> entry : mMediaQueues.entrySet()) {
entry.getValue().clear();
}
mMediaQueues.clear();
}
}
/**
* Decode a Http header.
*
* @param buf buffer of http header
* @param len buffer size
* @return HTTP header
* @throws java.io.IOException if this http header is invalid.
*/
private HttpHeader decodeHeader(final byte[] buf, final int len) throws IOException {
HashMap<String, String> pre = new HashMap<String, String>();
HashMap<String, String> headers = new HashMap<String, String>();
HashMap<String, String> params = new HashMap<String, String>();
BufferedReader in = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(buf, 0, len)));
// Read the request line
String inLine = in.readLine();
if (inLine == null) {
throw new IOException("no headers.");
}
StringTokenizer st = new StringTokenizer(inLine);
if (!st.hasMoreTokens()) {
throw new IOException("Header is invalid format.");
}
String method = st.nextToken();
if (!method.toLowerCase(Locale.getDefault()).equals("get")) {
throw new IOException("Method is invalid.");
}
pre.put("method", method);
if (!st.hasMoreTokens()) {
throw new IOException("Header is invalid format.");
}
String uri = st.nextToken();
// Decode parameters from the URI
int qmi = uri.indexOf('?');
if (qmi >= 0) {
decodeParams(uri.substring(qmi + 1), params);
uri = decodePercent(uri.substring(0, qmi));
} else {
decodeParams(null, params);
uri = decodePercent(uri);
}
pre.put("uri", uri);
// If there's another token, it's protocol version,
// followed by HTTP headers. Ignore version but parse headers.
// NOTE: this now forces header names lowercase since they are
// case insensitive and vary by client.
if (st.hasMoreTokens()) {
String line = in.readLine();
while (line != null && line.trim().length() > 0) {
int p = line.indexOf(':');
if (p >= 0) {
headers.put(line.substring(0, p).trim().toLowerCase(Locale.US),
line.substring(p + 1).trim());
}
line = in.readLine();
}
}
String segment = Uri.parse(uri).getLastPathSegment();
if (segment == null) {
throw new IOException("Header is invalid format.");
}
return new HttpHeader(uri, params);
}
/**
* Decode of uri param.
*
* @param params uri
* @param p
*/
private void decodeParams(final String params, final Map<String, String> p) {
if (params == null) {
return;
}
StringTokenizer st = new StringTokenizer(params, "&");
while (st.hasMoreTokens()) {
String e = st.nextToken();
int sep = e.indexOf('=');
if (sep >= 0) {
p.put(decodePercent(e.substring(0, sep)).trim(), decodePercent(e.substring(sep + 1)));
} else {
p.put(decodePercent(e).trim(), "");
}
}
}
/**
* Decode of uri.
*
* @param str uri
* @return The decoded URI
*/
private String decodePercent(final String str) {
try {
return URLDecoder.decode(str, "UTF8");
} catch (UnsupportedEncodingException ignored) {
return null;
}
}
}
/**
* Generate a http header.
*
* @return http header
*/
private String generateHttpHeader() {
StringBuilder sb = new StringBuilder();
sb.append("HTTP/1.0 200 OK\r\n");
sb.append("Server: " + mServerName + "\r\n");
sb.append("Connection: close\r\n");
sb.append("Max-Age: 0\r\n");
sb.append("Expires: 0\r\n");
sb.append("Cache-Control: no-store, no-cache, must-revalidate, pre-check=0, post-check=0, max-age=0\r\n");
sb.append("Pragma: no-cache\r\n");
sb.append("Content-Type: multipart/x-mixed-replace; ");
sb.append("boundary=" + mBoundary + "\r\n");
sb.append("\r\n");
sb.append("--" + mBoundary + "\r\n");
return sb.toString();
}
/**
* Generate a Bad Request.
*
* @return Bad Request
*/
private String generateBadRequest() {
return generateErrorHeader("400");
}
/**
* Generate a Not Found.
*
* @return Bad Request
*/
private String generateNotFound() {
return generateErrorHeader("404");
}
/**
* Generate a Internal Serve rError.
*
* @return Internal Server Error
*/
private String generateInternalServerError() {
return generateErrorHeader("500");
}
/**
* Generate a Service Unavailable.
*
* @return Service Unavailable
*/
private String generateServiceUnavailable() {
return generateErrorHeader("503");
}
/**
* Generate a error http header.
*
* @param status Status
* @return http header
*/
private String generateErrorHeader(final String status) {
StringBuilder sb = new StringBuilder();
sb.append("HTTP/1.0 " + status + " OK\r\n");
sb.append("Server: " + mServerName + "\r\n");
sb.append("Connection: close\r\n");
sb.append("\r\n");
return sb.toString();
}
public void setServerEventListener(final ServerEventListener listener) {
mServerEventListener = listener;
}
public interface ServerEventListener {
byte[] onConnect(Request request);
void onDisconnect(Request request);
void onCloseServer();
}
public class Request {
private HttpHeader mHeader;
private Request(final HttpHeader header) {
mHeader = header;
}
public String getUri() {
return getUrl() + mHeader.getUri();
}
public boolean isGet() {
return mHeader.hasParam("snapshot");
}
}
private static class HttpHeader {
final String mUri;
final Map<String, String> mParams;
HttpHeader(final String uri, final Map<String, String> params) {
mUri = uri;
mParams = params;
}
String getUri() {
return mUri;
}
String getParam(final String key) {
return mParams.get(key);
}
boolean hasParam(final String key) {
return getParam(key) != null;
}
}
}