/* Copyright (C) 2016-2017 Andreas Shimokawa, Carsten Pfeiffer, Daniele Gobbetti, Lem Dulfo, Uwe Hermann This file is part of Gadgetbridge. Gadgetbridge is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Gadgetbridge is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.util; import android.app.Activity; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.MutableContextWrapper; import android.content.ServiceConnection; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.IBinder; import android.os.Looper; import android.os.Message; import android.os.Messenger; import android.support.annotation.NonNull; import android.util.SparseArray; import android.webkit.ValueCallback; import android.webkit.WebResourceResponse; import android.webkit.WebSettings; import android.webkit.WebView; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.nio.charset.Charset; import java.util.HashMap; import java.util.Iterator; import java.util.Map; import java.util.UUID; import java.util.concurrent.CountDownLatch; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventAppMessage; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.webview.GBChromeClient; import nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.webview.GBWebClient; import nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.webview.JSInterface; public class WebViewSingleton { private static final Logger LOG = LoggerFactory.getLogger(WebViewSingleton.class); private WebView instance = null; private MutableContextWrapper contextWrapper; private Looper mainLooper; private static WebViewSingleton webViewSingleton = new WebViewSingleton(); private static UUID currentRunningUUID; public static Messenger internetHelper = null; public static boolean internetHelperBound; public static CountDownLatch latch; public static WebResourceResponse internetResponse; public final static Messenger internetHelperListener = new Messenger(new IncomingHandler()); private WebViewSingleton() { } public static synchronized void ensureCreated(Activity context) { if (webViewSingleton.instance == null) { webViewSingleton.contextWrapper = new MutableContextWrapper(context); webViewSingleton.mainLooper = context.getMainLooper(); webViewSingleton.instance = new WebView(webViewSingleton.contextWrapper); WebView.setWebContentsDebuggingEnabled(true); webViewSingleton.instance.setWillNotDraw(true); webViewSingleton.instance.clearCache(true); webViewSingleton.instance.setWebViewClient(new GBWebClient()); webViewSingleton.instance.setWebChromeClient(new GBChromeClient()); WebSettings webSettings = webViewSingleton.instance.getSettings(); webSettings.setJavaScriptEnabled(true); //needed to access the DOM webSettings.setDomStorageEnabled(true); //needed for localstorage webSettings.setDatabaseEnabled(true); } } //Internet helper outgoing connection private static ServiceConnection internetHelperConnection = new ServiceConnection() { public void onServiceConnected(ComponentName className, IBinder service) { LOG.info("internet helper service bound"); internetHelperBound = true; internetHelper = new Messenger(service); } public void onServiceDisconnected(ComponentName className) { LOG.info("internet helper service unbound"); internetHelper = null; internetHelperBound = false; } }; //Internet helper inbound (responses) handler private static class IncomingHandler extends Handler { private String getCharsetFromHeaders(String contentType) { if (contentType != null && contentType.toLowerCase().trim().contains("charset=")) { String[] parts = contentType.toLowerCase().trim().split("="); if (parts.length > 0) return parts[1]; } return null; } @Override public void handleMessage(Message msg) { Bundle data = msg.getData(); LOG.debug("WEBVIEW: internet helper returned: " + data.getString("response")); Map headers = new HashMap<>(); headers.put("Access-Control-Allow-Origin", "*"); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { internetResponse = new WebResourceResponse(data.getString("content-type"), data.getString("content-encoding"), 200, "OK", headers, new ByteArrayInputStream(data.getString("response").getBytes(Charset.forName(getCharsetFromHeaders(data.getString("content-type"))))) ); } else { internetResponse = new WebResourceResponse(data.getString("content-type"), data.getString("content-encoding"), new ByteArrayInputStream(data.getString("response").getBytes(Charset.forName(getCharsetFromHeaders(data.getString("content-type")))))); } latch.countDown(); } } @NonNull public static WebView getWebView(Context context) { webViewSingleton.contextWrapper.setBaseContext(context); return webViewSingleton.instance; } public static void runJavascriptInterface(@NonNull GBDevice device, @NonNull UUID uuid) { if (uuid.equals(currentRunningUUID)) { LOG.debug("WEBVIEW uuid not changed keeping the old context"); } else { final JSInterface jsInterface = new JSInterface(device, uuid); LOG.debug("WEBVIEW uuid changed, restarting"); currentRunningUUID = uuid; new Handler(webViewSingleton.mainLooper).post(new Runnable() { @Override public void run() { webViewSingleton.instance.onResume(); webViewSingleton.instance.removeJavascriptInterface("GBjs"); webViewSingleton.instance.addJavascriptInterface(jsInterface, "GBjs"); webViewSingleton.instance.loadUrl("file:///android_asset/app_config/configure.html?rand=" + Math.random() * 500); } }); if (!internetHelperBound) { Intent intent = new Intent(); intent.setComponent(new ComponentName("nodomain.freeyourgadget.internethelper", "nodomain.freeyourgadget.internethelper.MyService")); webViewSingleton.contextWrapper.getApplicationContext().bindService(intent, internetHelperConnection, Context.BIND_AUTO_CREATE); } } } public static void appMessage(GBDeviceEventAppMessage message) { final String jsEvent; if (webViewSingleton.instance == null) { LOG.warn("WEBVIEW is not initialized, cannot send appMessages to it"); return; } if (!message.appUUID.equals(currentRunningUUID)) { LOG.info("WEBVIEW ignoring message for app that is not currently running: " + message.appUUID + " message: " + message.message + " type: " + message.type); return; } // TODO: handle ACK and NACK types with ids if (message.type != GBDeviceEventAppMessage.TYPE_APPMESSAGE) { jsEvent = (GBDeviceEventAppMessage.TYPE_NACK == GBDeviceEventAppMessage.TYPE_APPMESSAGE) ? "NACK" + message.id : "ACK" + message.id; LOG.debug("WEBVIEW received ACK/NACK:" + message.message + " for uuid: " + message.appUUID + " ID: " + message.id); } else { jsEvent = "appmessage"; } final String appMessage = parseIncomingAppMessage(message.message, message.appUUID); LOG.debug("to WEBVIEW: event: " + jsEvent + " message: " + appMessage); new Handler(webViewSingleton.mainLooper).post(new Runnable() { @Override public void run() { webViewSingleton.instance.evaluateJavascript("Pebble.evaluate('" + jsEvent + "',[" + appMessage + "]);", new ValueCallback() { @Override public void onReceiveValue(String s) { //TODO: the message should be acked here instead of in PebbleIoThread LOG.debug("Callback from appmessage: " + s); } }); } }); } public static void stopJavascriptInterface() { new Handler(webViewSingleton.mainLooper).post(new Runnable() { @Override public void run() { if (webViewSingleton.instance != null) { webViewSingleton.instance.removeJavascriptInterface("GBjs"); webViewSingleton.instance.loadUrl("about:blank"); } } }); } public static void disposeWebView() { if (internetHelperBound) { LOG.debug("WEBVIEW: will unbind the internet helper"); webViewSingleton.contextWrapper.getApplicationContext().unbindService(internetHelperConnection); internetHelperBound = false; } currentRunningUUID = null; new Handler(webViewSingleton.mainLooper).post(new Runnable() { @Override public void run() { if (webViewSingleton.instance != null) { webViewSingleton.instance.removeJavascriptInterface("GBjs"); // webViewSingleton.instance.setWebChromeClient(null); // webViewSingleton.instance.setWebViewClient(null); webViewSingleton.instance.clearHistory(); webViewSingleton.instance.clearCache(true); webViewSingleton.instance.loadUrl("about:blank"); // webViewSingleton.instance.freeMemory(); webViewSingleton.instance.pauseTimers(); // instance.destroy(); // instance = null; // contextWrapper = null; // jsInterface = null; } } }); } public static JSONObject getAppConfigurationKeys(UUID uuid) { try { File destDir = PebbleUtils.getPbwCacheDir(); File configurationFile = new File(destDir, uuid.toString() + ".json"); if (configurationFile.exists()) { String jsonString = FileUtils.getStringFromFile(configurationFile); JSONObject json = new JSONObject(jsonString); return json.getJSONObject("appKeys"); } } catch (IOException | JSONException e) { LOG.warn("Unable to parse configuration JSON file", e); } return null; } private static String parseIncomingAppMessage(String msg, UUID uuid) { JSONObject jsAppMessage = new JSONObject(); JSONObject knownKeys = getAppConfigurationKeys(uuid); SparseArray appKeysMap = new SparseArray<>(); if (knownKeys == null || msg == null) { return "{}"; } String inKey, outKey; //knownKeys contains "name"->"index", we need to reverse that for (Iterator key = knownKeys.keys(); key.hasNext(); ) { inKey = key.next(); appKeysMap.put(knownKeys.optInt(inKey), inKey); } try { JSONArray incoming = new JSONArray(msg); JSONObject outgoing = new JSONObject(); for (int i = 0; i < incoming.length(); i++) { JSONObject in = incoming.getJSONObject(i); outKey = null; Object outValue = null; for (Iterator key = in.keys(); key.hasNext(); ) { inKey = key.next(); switch (inKey) { case "key": outKey = appKeysMap.get(in.optInt(inKey)); break; case "value": outValue = in.get(inKey); } } if (outKey != null && outValue != null) { outgoing.put(outKey, outValue); } } jsAppMessage.put("payload", outgoing); } catch (Exception e) { LOG.warn("Unable to parse incoming app message", e); } return jsAppMessage.toString(); } }