package org.wikipedia.bridge;
import android.annotation.SuppressLint;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.support.annotation.NonNull;
import android.util.Log;
import android.webkit.ConsoleMessage;
import android.webkit.JavascriptInterface;
import android.webkit.JsPromptResult;
import android.webkit.WebChromeClient;
import android.webkit.WebView;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.wikipedia.util.UriUtil.decodeURL;
/**
* Two way communications bridge between JS in a WebView and Java.
*/
public class CommunicationBridge {
private final WebView webView;
private final Map<String, List<JSEventListener>> eventListeners;
private final BridgeMarshaller marshaller;
private boolean isDOMReady = false;
private final List<String> pendingJSMessages = new ArrayList<>();
public interface JSEventListener {
void onMessage(String messageType, JSONObject messagePayload);
}
@SuppressLint({"AddJavascriptInterface", "SetJavaScriptEnabled"})
public CommunicationBridge(final WebView webView, final String baseURL) {
this.webView = webView;
this.marshaller = new BridgeMarshaller();
webView.getSettings().setJavaScriptEnabled(true);
webView.setWebChromeClient(new CommunicatingChrome());
webView.addJavascriptInterface(marshaller, "marshaller");
webView.loadUrl(baseURL); // TODO: remove once we finish the page load experiment
eventListeners = new HashMap<>();
this.addListener("DOMLoaded", new JSEventListener() {
@Override
public void onMessage(String messageType, JSONObject messagePayload) {
isDOMReady = true;
for (String jsString : pendingJSMessages) {
CommunicationBridge.this.webView.loadUrl(jsString);
}
}
});
}
public void cleanup() {
eventListeners.clear();
if (incomingMessageHandler != null) {
incomingMessageHandler.removeCallbacksAndMessages(null);
incomingMessageHandler = null;
}
}
public void addListener(String type, JSEventListener listener) {
if (eventListeners.containsKey(type)) {
eventListeners.get(type).add(listener);
} else {
List<JSEventListener> listeners = new ArrayList<>();
listeners.add(listener);
eventListeners.put(type, listeners);
}
}
public void sendMessage(String messageName, JSONObject messageData) {
String messagePointer = marshaller.putPayload(messageData.toString());
String jsString = "javascript:handleMessage( \"" + messageName + "\", \"" + messagePointer + "\" );";
if (!isDOMReady) {
pendingJSMessages.add(jsString);
} else {
webView.loadUrl(jsString);
}
}
private static final int MESSAGE_HANDLE_MESSAGE_FROM_JS = 1;
private Handler incomingMessageHandler = new Handler(Looper.getMainLooper(), new Handler.Callback() {
@Override
public boolean handleMessage(Message msg) {
JSONObject messagePack = (JSONObject) msg.obj;
String type = messagePack.optString("type");
if (!eventListeners.containsKey(type)) {
throw new RuntimeException("No such message type registered: " + type);
}
List<JSEventListener> listeners = eventListeners.get(type);
for (JSEventListener listener : listeners) {
listener.onMessage(type, messagePack.optJSONObject("payload"));
}
return false;
}
});
private class CommunicatingChrome extends WebChromeClient {
@Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
try {
// If incomingMessageHandler is null, it means that we've been cleaned up, but we're
// still receiving some final messages from the WebView, so we'll just ignore them.
// But we should still return true and "confirm" the JsPromptResult down below.
if (incomingMessageHandler != null) {
JSONObject messagePack = new JSONObject(decodeURL(message));
Message msg = Message.obtain(incomingMessageHandler, MESSAGE_HANDLE_MESSAGE_FROM_JS, messagePack);
incomingMessageHandler.sendMessage(msg);
}
} catch (JSONException e) {
throw new RuntimeException(e);
}
result.confirm();
return true;
}
@Override
public boolean onConsoleMessage(@NonNull ConsoleMessage consoleMessage) {
Log.d("WikipediaWeb", consoleMessage.sourceId() + ":" + consoleMessage.lineNumber() + " - " + consoleMessage.message());
return true;
}
}
private static class BridgeMarshaller {
private Map<String, String> queueItems = new HashMap<>();
private int counter = 0;
/**
* Called from the JS via the JSBridge to get actual payload from a messagePointer.
*
* Warning: This is going to be called on an indeterminable background thread, not main thread.
*
* @param pointer Key returned from #putPayload
*/
@JavascriptInterface
public String getPayload(String pointer) {
synchronized (this) {
return queueItems.remove(pointer);
}
}
public String putPayload(String payload) {
String key = "pointerKey_" + counter;
counter++;
synchronized (this) {
queueItems.put(key, payload);
}
return key;
}
}
}