/*
DConnectServerEventListenerImpl.java
Copyright (c) 2014 NTT DOCOMO,INC.
Released under the MIT license
http://opensource.org/licenses/mit-license.php
*/
package org.deviceconnect.android.manager;
import android.app.Service;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import org.deviceconnect.android.localoauth.ClientPackageInfo;
import org.deviceconnect.android.localoauth.LocalOAuth2Main;
import org.deviceconnect.android.manager.event.EventBroker;
import org.deviceconnect.android.manager.util.DConnectUtil;
import org.deviceconnect.android.provider.FileManager;
import org.deviceconnect.message.DConnectMessage;
import org.deviceconnect.message.intent.message.IntentDConnectMessage;
import org.deviceconnect.profile.FileProfileConstants;
import org.deviceconnect.server.DConnectServerError;
import org.deviceconnect.server.DConnectServerEventListener;
import org.deviceconnect.server.http.HttpRequest;
import org.deviceconnect.server.http.HttpResponse;
import org.deviceconnect.server.http.HttpResponse.StatusCode;
import org.deviceconnect.server.websocket.DConnectWebSocket;
import org.json.JSONException;
import org.json.JSONObject;
import org.restlet.ext.oauth.PackageInfoOAuth;
import java.io.UnsupportedEncodingException;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Logger;
/**
* Webサーバからのイベントを受領するクラス.
* @author NTT DOCOMO, INC.
*/
class DConnectServerEventListenerImpl implements DConnectServerEventListener {
/**
* HTTPサーバからリクエストのマップ.
*/
private final Map<Integer, Intent> mRequestMap = new ConcurrentHashMap<>();
/** ロガー. */
private final Logger mLogger = Logger.getLogger("dconnect.manager");
/** JSONレスポンス用のContent-Type. */
private static final String CONTENT_TYPE_JSON = "application/json; charset=UTF-8";
/** HTTPリクエストのセグメント数(APIのみ) {@value}. */
private static final int SEGMENT_API = 1;
/** HTTPリクエストのセグメント数(Profileのみ) {@value}. */
private static final int SEGMENT_PROFILE = 2;
/** HTTPリクエストのセグメント数(ProfilesとAttribute) {@value}. */
private static final int SEGMENT_ATTRIBUTE = 3;
/** HTTPリクエストのセグメント数(ProfileとInterfacesとAttribute) {@value}. */
private static final int SEGMENT_INTERFACES = 4;
/** ポーリング時間(ms). */
private static final int POLLING_WAIT_TIME = 10000;
/** デフォルトのタイムアウト時間(ms). */
private static final int DEFAULT_RESTFUL_TIMEOUT = 180000;
/** タイムアウト時間(ms). */
private int mTimeout = DEFAULT_RESTFUL_TIMEOUT;
/** このクラスが属するコンテキスト. */
private Context mContext;
/** ファイルを管理するためのクラス. */
private FileManager mFileMgr;
/** ロックオブジェクト. */
private final Object mLockObj = new Object();
/**
* コンストラクタ.
* @param context このクラスが属するコンテキスト
*/
DConnectServerEventListenerImpl(final Context context) {
mContext = context;
}
/**
* ファイルを操作するためのマネージャー.
* @param fileMgr マネージャー
*/
void setFileManager(final FileManager fileMgr) {
mFileMgr = fileMgr;
}
/**
* Device Connect Managerからレスポンスを受け取る.
*
* @param intent レスポンス
*/
void onResponse(final Intent intent) {
int requestCode = intent.getIntExtra(
IntentDConnectMessage.EXTRA_REQUEST_CODE, Integer.MIN_VALUE);
mRequestMap.put(requestCode, intent);
// レスポンスを受け取ったのでスレッドを再開
synchronized (mLockObj) {
mLockObj.notifyAll();
}
}
@Override
public void onError(final DConnectServerError error) {
mLogger.severe(error.toString());
// HTTPサーバが起動できなかったので、終了する
((Service) mContext).stopSelf();
}
@Override
public void onServerLaunched() {
mLogger.info("HttpServer was started.");
}
@Override
public void onWebSocketConnected(final DConnectWebSocket webSocket) {
if (BuildConfig.DEBUG) {
mLogger.info("onWebSocketConnected: WebSocket = " + webSocket.toString());
}
}
@Override
public void onWebSocketDisconnected(final DConnectWebSocket webSocket) {
if (BuildConfig.DEBUG) {
mLogger.info("onWebSocketDisconnected: WebSocket = " + webSocket.toString());
}
DConnectService service = (DConnectService) mContext;
EventBroker eventBroker = service.getEventBroker();
WebSocketInfo disconnected = null;
for (WebSocketInfo info : getWebSocketInfoManager().getWebSocketInfos()) {
if (info.getRawId().equals(webSocket.getId())) {
disconnected = info;
break;
}
}
if (disconnected != null) {
eventBroker.removeEventSession(disconnected.getOrigin());
getWebSocketInfoManager().removeWebSocketInfo(disconnected.getOrigin());
}
}
@Override
public void onWebSocketMessage(final DConnectWebSocket webSocket, final String message) {
if (BuildConfig.DEBUG) {
mLogger.info("onWebSocketMessage: message = " + message);
}
try {
JSONObject json = new JSONObject(message);
String uri = webSocket.getUri();
String origin = webSocket.getClientOrigin();
String eventKey;
if (uri.equalsIgnoreCase("/gotapi/websocket")) { // MEMO パスの大文字小文字を無視
String accessToken = json.optString(DConnectMessage.EXTRA_ACCESS_TOKEN);
if (accessToken == null) {
mLogger.warning("onWebSocketMessage: accessToken is not specified");
sendError(webSocket, 1, "accessToken is not specified.");
return;
}
if (requiresOrigin()) {
if (origin == null) {
sendError(webSocket, 2, "origin is not specified.");
return;
}
if (usesLocalOAuth() && !isValidAccessToken(accessToken, origin)) {
sendError(webSocket, 3, "accessToken is invalid.");
return;
}
} else {
if (origin == null) {
origin = DConnectService.ANONYMOUS_ORIGIN;
}
}
eventKey = origin;
// NOTE: 既存のイベントセッションを保持する.
if (getWebSocketInfoManager().getWebSocketInfo(eventKey) != null) {
sendError(webSocket, 4, "already established.");
webSocket.disconnect();
return;
}
sendSuccess(webSocket);
} else {
if (origin == null) {
origin = DConnectService.ANONYMOUS_ORIGIN;
}
eventKey = json.optString(DConnectMessage.EXTRA_SESSION_KEY);
// NOTE: 既存のイベントセッションを破棄する.
if (getWebSocketInfoManager().getWebSocketInfo(eventKey) != null) {
((DConnectService) mContext).disconnectWebSocketWithReceiverId(eventKey);
}
}
if (eventKey == null) {
mLogger.warning("onWebSocketMessage: Failed to generate eventKey: uri = " + uri + ", origin = " + origin);
return;
}
getWebSocketInfoManager().addWebSocketInfo(eventKey, origin + uri, webSocket.getId());
} catch (JSONException e) {
mLogger.warning("onWebSocketMessage: Failed to parse message as JSON object: " + message);
}
}
@Override
public boolean onReceivedHttpRequest(final HttpRequest request, final HttpResponse response) {
final int requestCode = UUID.randomUUID().hashCode();
String[] paths = parsePath(request);
Map<String, String> parameters = request.getQueryParameters();
Map<String, String> files = request.getFiles();
String method = request.getMethod().name();
String api = null;
String httpMethod = null;
String profile = null;
String interfaces = null;
String attribute = null;
boolean existMethod = isHttpMethodIncluded(paths);
long start = System.currentTimeMillis();
if (BuildConfig.DEBUG) {
mLogger.info(String.format("@@@ Request URI: %s %s", method, request.getUri()));
}
if (existMethod) {
// HTTPメソッドがパスに含まれている
if (paths.length == SEGMENT_API) {
api = paths[0];
} else if (paths.length == SEGMENT_PROFILE) {
api = paths[0];
profile = paths[1];
} else if (paths.length == SEGMENT_ATTRIBUTE) {
api = paths[0];
httpMethod = paths[1];
profile = paths[2];
} else if (paths.length == SEGMENT_INTERFACES) {
api = paths[0];
httpMethod = paths[1];
profile = paths[2];
attribute = paths[3];
} else if (paths.length == (SEGMENT_INTERFACES + 1)) {
api = paths[0];
httpMethod = paths[1];
profile = paths[2];
interfaces = paths[3];
attribute = paths[4];
}
} else {
// HTTPメソッドがパスに含まれていない
if (paths.length == SEGMENT_API) {
api = paths[0];
} else if (paths.length == SEGMENT_PROFILE) {
api = paths[0];
profile = paths[1];
} else if (paths.length == SEGMENT_ATTRIBUTE) {
api = paths[0];
profile = paths[1];
attribute = paths[2];
} else if (paths.length == SEGMENT_INTERFACES) {
api = paths[0];
profile = paths[1];
interfaces = paths[2];
attribute = paths[3];
}
}
if (api == null) {
// apiが存在しない場合はエラー
response.setCode(StatusCode.BAD_REQUEST);
setErrorResponse(response, 19, "api is empty.");
return true;
}
// プロファイルが存在しない場合にはエラー
if (profile == null) {
response.setCode(StatusCode.BAD_REQUEST);
setErrorResponse(response, 19, "profile is empty.");
return true;
} else if (isMethod(profile)) { // Profile名がhttpMethodの場合
response.setCode(StatusCode.BAD_REQUEST);
setInvalidProfile(response);
return true;
}
// Httpメソッドに対応するactionを取得
String action = DConnectUtil.convertHttpMethod2DConnectMethod(method);
if (action == null) {
response.setCode(StatusCode.NOT_IMPLEMENTED);
setErrorResponse(response, 1, "Not implements a http method.");
return true;
}
// URLにmethodが指定されている場合は、そちらのHTTPメソッドを優先する
if (httpMethod != null) {
if (action.equals(IntentDConnectMessage.ACTION_GET)) {
action = DConnectUtil.convertHttpMethod2DConnectMethod(httpMethod.toUpperCase());
} else {
// 元々のHTTPリクエストがGET以外の場合はエラーを返す.
setInvalidURL(response);
return true;
}
}
// files の時は、Device Connect Managerまでは渡さずに、ここで処理を行う
if ("files".equalsIgnoreCase(profile)) {
if (request.getMethod().equals(HttpRequest.Method.GET)) {
String uri = parameters.get("uri");
try {
ContentResolver r = mContext.getContentResolver();
response.setBody(r.openInputStream(Uri.parse(uri)));
response.setContentLength(-1);
response.setCode(StatusCode.OK);
} catch (Exception e) {
response.setCode(StatusCode.NOT_FOUND);
setErrorResponse(response, 1, "Not found a resource.");
}
} else {
response.setCode(StatusCode.BAD_REQUEST);
setErrorResponse(response, 1, "Not implements a method.");
}
return true;
}
Intent intent = new Intent(action);
intent.setClass(mContext, DConnectService.class);
intent.putExtra(IntentDConnectMessage.EXTRA_API, api);
intent.putExtra(IntentDConnectMessage.EXTRA_PROFILE, profile);
if (interfaces != null) {
intent.putExtra(IntentDConnectMessage.EXTRA_INTERFACE, interfaces);
}
if (attribute != null) {
intent.putExtra(IntentDConnectMessage.EXTRA_ATTRIBUTE, attribute);
}
if (parameters != null) {
for (String key : parameters.keySet()) {
intent.putExtra(key, parameters.get(key));
}
}
if (files != null && parameters != null) {
// TODO: 複数ファイルがあった時に、どのようにプラグイン渡すか検討が必要
for (String key : files.keySet()) {
String v = files.get(key);
if (v != null && !v.isEmpty()) {
String uri = mFileMgr.getContentUri() + "/" + v.substring(v.lastIndexOf('/') + 1);
String fileName = parameters.get(key);
if (fileName != null) {
intent.putExtra(FileProfileConstants.PARAM_FILE_NAME, fileName);
}
intent.putExtra(FileProfileConstants.PARAM_URI, uri);
}
}
}
// アプリケーションのオリジン解析
parseOriginHeader(request, intent);
intent.putExtra(IntentDConnectMessage.EXTRA_REQUEST_CODE, requestCode);
intent.putExtra(DConnectService.EXTRA_INNER_TYPE, DConnectService.INNER_TYPE_HTTP);
mContext.startService(intent);
// レスポンスが返ってくるまで待つ
// ただし、タイムアウト時間を設定しておき、永遠には待たない。
Intent resp = waitForResponse(requestCode);
try {
if (resp == null) {
// ここのエラーはタイムアウトの場合のみ
setTimeoutResponse(response);
} else {
convertResponse(response, resp);
}
} catch (JSONException e) {
setJSONFormatError(response);
} catch (UnsupportedEncodingException e) {
setUnknownError(response);
}
if (BuildConfig.DEBUG) {
mLogger.info(String.format(Locale.getDefault(), "@@@ Request URI END(%d): %s %s",
(System.currentTimeMillis() - start), method, request.getUri()));
}
return true;
}
/**
* レスポンスが返ってくるまで待ちます.
* <p>
* ただし、タイムアウトなどを起こした場合にはnullが返却される。
* </p>
* @param requestCode リクエストコード
* @return レスポンス用のIntent
*/
private Intent waitForResponse(final int requestCode) {
final long now = System.currentTimeMillis();
while (mRequestMap.get(requestCode) == null
&& (System.currentTimeMillis() - now) < mTimeout) {
synchronized (mLockObj) {
try {
mLockObj.wait(POLLING_WAIT_TIME);
} catch (InterruptedException e) {
mLogger.warning("Exception ouccered in wait.");
}
}
}
return mRequestMap.remove(requestCode);
}
/**
* Origin要求の設定を取得します.
* @return Originを要求する場合はtrue、それ以外はfalse
*/
private boolean requiresOrigin() {
return ((DConnectService) mContext).requiresOrigin();
}
/**
* Local OAuth設定を取得します.
* @return Local OAuthが有効の場合はtrue、それ以外はfalse
*/
private boolean usesLocalOAuth() {
return ((DConnectService) mContext).usesLocalOAuth();
}
/**
* WebSocketInfoManagerを取得します.
* @return WebSocketInfoManagerのインスタンス
*/
private WebSocketInfoManager getWebSocketInfoManager() {
DConnectApplication app = (DConnectApplication) ((DConnectService) mContext).getApplication();
return app.getWebSocketInfoManager();
}
/**
* WebSocketに成功メッセージを送信します.
* @param webSocket メッセージを送信するWebSocket
*/
private void sendSuccess(final DConnectWebSocket webSocket) {
webSocket.sendMessage("{\"result\":0}");
}
/**
* WebSocketにエラーを送信します.
* @param webSocket エラーを送信するWebSocket
* @param errorCode エラーコード
* @param errorMessage エラーメッセージ
*/
private void sendError(final DConnectWebSocket webSocket, final int errorCode, final String errorMessage) {
String message = "{\"result\":1,\"errorCode\":" + errorCode + ",\"errorMessage\":\"" + errorMessage + "\"}";
webSocket.sendMessage(message);
}
/**
* アクセストークンとOriginの組み合わせが妥当かチェックします.
* @param accessToken アクセストークン
* @param origin オリジン
* @return 妥当な場合はtrue、それ以外はfalse
*/
private boolean isValidAccessToken(final String accessToken, final String origin) {
ClientPackageInfo client = LocalOAuth2Main.findClientPackageInfoByAccessToken(accessToken);
if (client == null) {
return false;
}
PackageInfoOAuth oauth = client.getPackageInfo();
return oauth != null && oauth.getPackageName().equals(origin);
}
/**
* URLパスを「/」で分割した配列を作成します.
* <p>
* 分割できない場合には、0の配列を返却します。
* </p>
* @param request Httpリクエスト
* @return パスの配列
*/
private String[] parsePath(final HttpRequest request) {
String path = request.getUri();
if (path == null || !path.contains("/")) {
return new String[0];
}
return path.substring(1).split("/");
}
/**
* タイムアウトエラーのレスポンスを作成する.
* @param response レスポンスを格納するインスタンス
*/
private void setTimeoutResponse(final HttpResponse response) {
setErrorResponse(response, DConnectMessage.ErrorCode.TIMEOUT);
}
/**
* プロファイルが空の場合のエラーレスポンスを作成する.
*
* @param response レスポンスを格納するインスタンス
*/
private void setEmptyProfile(final HttpResponse response) {
setErrorResponse(response, DConnectMessage.ErrorCode.NOT_SUPPORT_PROFILE);
}
/**
* URLが不正の場合のエラーレスポンスを作成する.
* @param response レスポンスを格納するインスタンス
*/
private void setInvalidURL(final HttpResponse response) {
setErrorResponse(response, DConnectMessage.ErrorCode.INVALID_URL);
}
/**
* Profileが不正の場合のエラーレスポンスを作成する.
* @param response レスポンスを格納するインスタンス
*/
private void setInvalidProfile(final HttpResponse response){
setErrorResponse(response,DConnectMessage.ErrorCode.INVALID_PROFILE);
}
/**
* 原因不明エラーが発生した場合のエラーレスポンスを作成する.
*
* @param response レスポンスを格納するインスタンス
*/
private void setUnknownError(final HttpResponse response) {
setErrorResponse(response, DConnectMessage.ErrorCode.UNKNOWN);
}
/**
* JSON変換エラーが発生した場合のエラーレスポンスを作成する.
*
* @param response レスポンスを格納するインスタンス
*/
private void setJSONFormatError(final HttpResponse response) {
setErrorResponse(response, DConnectMessage.ErrorCode.UNKNOWN.getCode(), "JSON format is invalid");
}
/**
* エラーコードをレスポンスに設定する.
*
* @param response レスポンスを格納するインスタンス
* @param errorCode エラーコード
*/
private void setErrorResponse(final HttpResponse response, final DConnectMessage.ErrorCode errorCode) {
setErrorResponse(response, errorCode.getCode(), errorCode.toString());
}
/**
* レスポンスにエラーを設定する.
*
* @param response レスポンスを格納するHttpレスポンス
* @param errorCode エラーコード
* @param errorMessage エラーメッセージ
*/
private void setErrorResponse(final HttpResponse response, final int errorCode, final String errorMessage) {
StringBuilder sb = new StringBuilder();
sb.append("{\"");
sb.append(DConnectMessage.EXTRA_RESULT);
sb.append("\":");
sb.append(DConnectMessage.RESULT_ERROR);
sb.append(",");
sb.append("\"");
sb.append(DConnectMessage.EXTRA_ERROR_CODE);
sb.append("\": ");
sb.append(errorCode);
sb.append(",");
sb.append("\"");
sb.append(DConnectMessage.EXTRA_ERROR_MESSAGE);
sb.append("\":\"");
sb.append(errorMessage);
sb.append("\"}");
response.setContentType(CONTENT_TYPE_JSON);
response.setBody(sb.toString().getBytes());
}
/**
* HTTPリクエストヘッダからアプリケーションのオリジンを取得する.
* @param request HTTPリクエスト
* @param intent key-valueを格納するIntent
*/
private void parseOriginHeader(final HttpRequest request, final Intent intent) {
Map<String, String> headers = request.getHeaders();
if (headers == null) {
return;
}
String nativeOrigin = parseNativeOriginHeader(headers);
if (nativeOrigin != null) {
intent.putExtra(IntentDConnectMessage.EXTRA_ORIGIN, nativeOrigin);
return;
}
String webOrigin = parseWebOriginHeader(headers);
if (webOrigin != null) {
intent.putExtra(IntentDConnectMessage.EXTRA_ORIGIN, webOrigin);
intent.putExtra(DConnectService.EXTRA_INNER_APP_TYPE, DConnectService.INNER_APP_TYPE_WEB);
}
}
/**
* HTTPリクエストヘッダからWebアプリのオリジンを取得する.
*
* @param headers HTTPリクエストヘッダ
* @return Webアプリのオリジン
*/
private String parseWebOriginHeader(final Map<String, String> headers) {
for (Entry<String, String> entry : headers.entrySet()) {
String key = entry.getKey();
if (key.equalsIgnoreCase("origin")) {
String value = entry.getValue();
if (value != null) {
return value;
}
break;
}
}
return null;
}
/**
* HTTPリクエストヘッダからAndroidネイティブアプリのオリジンを取得する.
*
* @param headers HTTPリクエストヘッダ
* @return Androidネイティブアプリのオリジン
*/
private String parseNativeOriginHeader(final Map<String, String> headers) {
for (Entry<String, String> entry : headers.entrySet()) {
String key = entry.getKey();
if (key.equalsIgnoreCase(DConnectMessage.HEADER_GOTAPI_ORIGIN)) {
String value = entry.getValue();
if (value != null) {
return value;
}
break;
}
}
return null;
}
/**
* HTTPのレスポンスを組み立てる.
* @param response 返答を格納するレスポンス
* @param resp response用のIntent
* @throws JSONException JSONの解析に失敗した場合
* @throws UnsupportedEncodingException 文字列のエンコードに失敗した場合
*/
private void convertResponse(final HttpResponse response, final Intent resp)
throws JSONException, UnsupportedEncodingException {
JSONObject root = new JSONObject();
DConnectUtil.convertBundleToJSON(root, resp.getExtras());
response.setContentType(CONTENT_TYPE_JSON);
response.setBody(root.toString().getBytes("UTF-8"));
}
/**
* セグメントの中にHttpメソッドが含まれているか確認する.
* @param paths セグメント
* @return Httpメソッドが含まれている場合はtrue、それ以外はfalse
*/
private boolean isHttpMethodIncluded(final String[] paths) {
return paths != null && paths.length >= SEGMENT_ATTRIBUTE && isMethod(paths[1]);
}
/**
* DeviceConnectがサポートしているOne ShotのHTTPメソッドかどうか.
* @param method HTTPメソッド
* @return true:DeviceConnectがサポートしているOne shotのHTTPメソッドである。<br>
* false:DeviceConnectがサポートしているOne shotのHTTPメソッドではない。
*/
private boolean isMethod(final String method) {
return method.equalsIgnoreCase(DConnectMessage.METHOD_GET)
|| method.equalsIgnoreCase(DConnectMessage.METHOD_POST)
|| method.equalsIgnoreCase(DConnectMessage.METHOD_PUT)
|| method.equalsIgnoreCase(DConnectMessage.METHOD_DELETE);
}
}