/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele Gobbetti, José Rebelo, Taavi Eomäe 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.content.pm.PackageManager; 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.os.RemoteException; import android.webkit.WebResourceResponse; import android.webkit.WebSettings; import android.webkit.WebView; import androidx.annotation.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.ByteArrayInputStream; import java.nio.charset.Charset; import java.util.HashMap; import java.util.Map; import java.util.UUID; import java.util.concurrent.CountDownLatch; 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 static final WebViewSingleton instance = new WebViewSingleton(); private WebView webView = null; private MutableContextWrapper contextWrapper; private Looper mainLooper; private UUID currentRunningUUID; private Messenger internetHelper = null; public boolean internetHelperBound; private boolean internetHelperInstalled; private CountDownLatch latch; private WebResourceResponse internetResponse; private Messenger internetHelperListener; private WebViewSingleton() { } //Internet helper outgoing connection private final 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; } }; public static synchronized void ensureCreated(Activity context) { if (instance.webView == null) { instance.contextWrapper = new MutableContextWrapper(context); instance.mainLooper = context.getMainLooper(); instance.webView = new WebView(instance.contextWrapper); WebView.setWebContentsDebuggingEnabled(true); instance.webView.setWillNotDraw(true); instance.webView.clearCache(true); instance.webView.setWebViewClient(new GBWebClient()); instance.webView.setWebChromeClient(new GBChromeClient()); WebSettings webSettings = instance.webView.getSettings(); //noinspection SetJavaScriptEnabled webSettings.setJavaScriptEnabled(true); //needed to access the DOM webSettings.setDomStorageEnabled(true); //needed for localstorage webSettings.setDatabaseEnabled(true); // #3373 #3424 - Fix configuration for pebble apps // TODO: this should use a WebViewAssetLoader webSettings.setAllowFileAccess(true); } } public static WebViewSingleton getInstance() { return instance; } public WebResourceResponse send(Message webRequest) throws RemoteException, InterruptedException { webRequest.replyTo = internetHelperListener; latch = new CountDownLatch(1); //the messenger should run on a single thread, hence we don't need to be worried about concurrency. This approach however is certainly not ideal. internetHelper.send(webRequest); latch.await(); return internetResponse; } //Internet helper inbound (responses) handler private 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", "*"); 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"))))) ); latch.countDown(); } } @NonNull public WebView getWebView(Context context) { contextWrapper.setBaseContext(context); return webView; } /** * Checks that the webview is up and the given app is running. * @param uuid the uuid of the application expected to be running * @throws IllegalStateException when the webview is not active or the app is not running */ public void checkAppRunning(@NonNull UUID uuid) { if (webView == null) { throw new IllegalStateException("instance.webView is null!"); } if (!uuid.equals(currentRunningUUID)) { throw new IllegalStateException("Expected app " + uuid + " is not running, but " + currentRunningUUID + " is."); } } public void runJavascriptInterface(@NonNull Activity context, @NonNull GBDevice device, @NonNull UUID uuid) { ensureCreated(context); runJavascriptInterface(device, uuid); } public 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; invokeWebview(new WebViewRunnable() { @Override public void invoke(WebView webView) { webView.onResume(); webView.removeJavascriptInterface("GBjs"); webView.addJavascriptInterface(jsInterface, "GBjs"); webView.loadUrl("file:///android_asset/app_config/configure.html?rand=" + Math.random() * 500); } }); if (contextWrapper != null && !internetHelperBound && !internetHelperInstalled) { String internetHelperPkg = "nodomain.freeyourgadget.internethelper"; String internetHelperCls = internetHelperPkg + ".MyService"; try { contextWrapper.getPackageManager().getApplicationInfo(internetHelperPkg, 0); Intent intent = new Intent(); intent.setComponent(new ComponentName(internetHelperPkg, internetHelperCls)); contextWrapper.getApplicationContext().bindService(intent, internetHelperConnection, Context.BIND_AUTO_CREATE); internetHelperListener = new Messenger(new IncomingHandler()); internetHelperInstalled = true; } catch (PackageManager.NameNotFoundException e) { internetHelperInstalled = false; LOG.info("WEBVIEW: Internet helper not installed, only mimicked HTTP requests will work."); } } } } public void stopJavascriptInterface() { invokeWebview(new WebViewRunnable() { @Override public void invoke(WebView webView) { webView.removeJavascriptInterface("GBjs"); webView.loadUrl("about:blank"); } }); } public void disposeWebView() { if (internetHelperBound) { LOG.debug("WEBVIEW: will unbind the internet helper"); contextWrapper.getApplicationContext().unbindService(internetHelperConnection); internetHelperBound = false; } currentRunningUUID = null; invokeWebview(new WebViewRunnable() { @Override public void invoke(WebView webView) { webView.removeJavascriptInterface("GBjs"); // webView.setWebChromeClient(null); // webView.setWebViewClient(null); webView.clearHistory(); webView.clearCache(true); webView.loadUrl("about:blank"); // webView.freeMemory(); webView.pauseTimers(); // webView.destroy(); // webView = null; // contextWrapper = null; // jsInterface = null; } }); } public void invokeWebview(final WebViewRunnable runnable) { if (webView == null || mainLooper == null) { LOG.warn("Webview already disposed, ignoring runnable"); return; } new Handler(mainLooper).post(new Runnable() { @Override public void run() { if (webView == null) { LOG.warn("Webview already disposed, cannot invoke runnable"); return; } runnable.invoke(webView); } }); } public interface WebViewRunnable { /** * Called in the main thread with a non-null webView webView * @param webView the webview, never null */ void invoke(WebView webView); } }