diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 370bc3b85b..ff7065d5d5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -602,6 +602,12 @@ + + () { - @Override - public void onReceiveValue(String s) { - //TODO: the message should be acked here instead of in PebbleIoThread - LOG.debug("Callback from appmessage: " + s); - } - }); - } + LOG.debug("to WEBVIEW: event: {} message: {}", jsEvent, appMessage); + PebbleJsService.Companion.getInstance().evaluateJsForDevice(message.device, "if (typeof Pebble == 'object') Pebble.evaluate('" + jsEvent + "',[" + appMessage + "]);", s -> { + //TODO: the message should be acked here instead of in PebbleIoThread + LOG.debug("Callback from appmessage: {}", s); }); } @@ -219,11 +212,8 @@ class PebbleIoThread extends GBDeviceIoThread { } } if (((PebbleCoordinator) gbDevice.getDeviceCoordinator()).isBackgroundJsEnabled(gbDevice)) { - Intent startIntent = new Intent(getContext(), ExternalPebbleJSActivity.class); - startIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - startIntent.putExtra(GBDevice.EXTRA_DEVICE, gbDevice); - startIntent.putExtra(ExternalPebbleJSActivity.START_BG_WEBVIEW, true); - getContext().startActivity(startIntent); + PebbleJsService.Companion.startService(getContext()); + PebbleUtils.startJsEngineForDevice(getContext(), gbDevice, new UUID(0, 0)); } else { LOG.debug("Not enabling background Webview, is disabled in preferences."); } @@ -414,7 +404,7 @@ class PebbleIoThread extends GBDeviceIoThread { } if (((PebbleCoordinator) gbDevice.getDeviceCoordinator()).isBackgroundJsEnabled(gbDevice)) { - WebViewSingleton.getInstance().disposeWebView(); + PebbleUtils.stopJsEngineForDevice(gbDevice); } gbDevice.sendDeviceUpdateIntent(getContext()); @@ -547,9 +537,9 @@ class PebbleIoThread extends GBDeviceIoThread { LOG.info("got GBDeviceEventAppManagement START event for uuid: {}", appMgmt.uuid); if (((PebbleCoordinator) gbDevice.getDeviceCoordinator()).isBackgroundJsEnabled(gbDevice)) { if (mPebbleProtocol.hasAppMessageHandler(appMgmt.uuid)) { - WebViewSingleton.getInstance().stopJavascriptInterface(); + PebbleUtils.stopJsEngineForDevice(gbDevice); } else { - WebViewSingleton.getInstance().runJavascriptInterface(getContext(), gbDevice, appMgmt.uuid); + PebbleUtils.startJsEngineForDevice(getContext(), gbDevice, appMgmt.uuid); } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/PebbleProtocol.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/PebbleProtocol.java index 7ba49e5695..c4dc75cc12 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/PebbleProtocol.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/PebbleProtocol.java @@ -1782,6 +1782,7 @@ public class PebbleProtocol extends GBDeviceProtocol { appMessage.appUUID = uuid; appMessage.id = last_id & 0xff; appMessage.message = jsonArray.toString(); + appMessage.device = getDevice(); return new GBDeviceEvent[]{appMessage, sendBytesAck}; } @@ -2562,6 +2563,7 @@ public class PebbleProtocol extends GBDeviceProtocol { } evtAppMessage.id = idLookup[last_id & 0xff]; evtAppMessage.appUUID = currentRunningApp; + evtAppMessage.device = getDevice(); } devEvts = new GBDeviceEvent[]{evtAppMessage}; break; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/webview/PebbleJsService.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/webview/PebbleJsService.kt new file mode 100644 index 0000000000..0809c1b2ae --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/webview/PebbleJsService.kt @@ -0,0 +1,170 @@ +/* Copyright (C) 2025 Arjan Schrijver + + 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.service.devices.pebble.webview + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.view.View +import android.webkit.ValueCallback +import android.webkit.WebView +import androidx.core.app.NotificationCompat +import nodomain.freeyourgadget.gadgetbridge.R +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice +import nodomain.freeyourgadget.gadgetbridge.util.GB +import nodomain.freeyourgadget.gadgetbridge.webview.GBChromeClient +import nodomain.freeyourgadget.gadgetbridge.webview.GBWebClient +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +class PebbleJsService : Service() { + + companion object { + @Volatile + private var instance: PebbleJsService? = null + + fun getInstance(): PebbleJsService? = instance + + fun startService(context: Context) { + val intent = Intent(context, PebbleJsService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + } + + val LOG: Logger = LoggerFactory.getLogger(PebbleJsService::class.java) + private val webviews = ConcurrentHashMap() + private val currentRunningUUID = ConcurrentHashMap() + + override fun onCreate() { + super.onCreate() + instance = this + startForegroundServiceNotification() + } + + override fun onDestroy() { + instance = null + destroyAllWebViews() + super.onDestroy() + } + + override fun onBind(intent: Intent?) = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return START_STICKY + } + + private fun startForegroundServiceNotification() { + val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + nm.createNotificationChannel( + NotificationChannel(GB.NOTIFICATION_CHANNEL_ID_PEBBLE_JS, "Pebble JS service", NotificationManager.IMPORTANCE_LOW) + ) + } + + val notification = NotificationCompat.Builder(this, GB.NOTIFICATION_CHANNEL_ID_PEBBLE_JS) + .setContentTitle("Pebble JavaScript service") + .setContentText("Running Pebble app/watchface JavaScript") + .setSmallIcon(R.drawable.ic_play) + .build() + + startForeground(1005, notification) + } + + fun startJsForDevice(device: GBDevice, uuid: UUID) { + Handler(Looper.getMainLooper()).post { + if (!webviews.containsKey(device.address)) { + LOG.info("WEBVIEW starting for device ${device.address}") + WebView.setWebContentsDebuggingEnabled(true) + val uiContext = applicationContext.createConfigurationContext(resources.configuration) + val wv = WebView(uiContext).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.databaseEnabled = true + settings.allowContentAccess = true + settings.allowFileAccess = true + settings.allowFileAccessFromFileURLs = true + settings.allowUniversalAccessFromFileURLs = true + visibility = View.GONE + webViewClient = GBWebClient(GBWebClient.REQUEST_TYPE_PEBBLE_BACKGROUND_JS) + webChromeClient = GBChromeClient() + } + wv.setWillNotDraw(true) + wv.clearCache(true) + wv.resumeTimers() + webviews[device.address] = wv + } + + if (uuid == currentRunningUUID) { + LOG.debug("WEBVIEW uuid not changed keeping the old context") + } else { + val jsInterface = JSInterface(device, uuid) + LOG.debug("WEBVIEW uuid changed, restarting") + currentRunningUUID[device.address] = uuid + webviews[device.address]?.onResume() + webviews[device.address]?.removeJavascriptInterface("GBjs") + webviews[device.address]?.addJavascriptInterface(jsInterface, "GBjs") + webviews[device.address]?.loadUrl("file:///android_asset/app_config/configure.html?rand=" + Math.random() * 500) + } + } + } + + fun evaluateJsForDevice(device: GBDevice, js: String, resultCallback: ValueCallback?) { + // The webviews can live in the service, but interaction with them must always be performed from the main thread + Handler(Looper.getMainLooper()).post { + val wv = webviews[device.address] + wv?.evaluateJavascript(js, resultCallback) + } + } + + fun stopJsForDevice(device: GBDevice) { + Handler(Looper.getMainLooper()).post { + webviews.remove(device.address)?.destroy() + if (webviews.isEmpty()) { + LOG.info("Last webview stopped, stopping Pebble JS Service...") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + stopForeground(true) + } + stopSelf() + } + } + } + + private fun destroyAllWebViews() { + Handler(Looper.getMainLooper()).post { + webviews.values.forEach { it.destroy() } + webviews.clear() + } + } + + fun checkAppRunning(device: GBDevice, appUUID: UUID): Boolean { + return currentRunningUUID[device.address]?.equals(appUUID) ?: false + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GB.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GB.java index 0c1aca22c6..1a6bfa592c 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GB.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GB.java @@ -81,6 +81,7 @@ public class GB { public static final String NOTIFICATION_CHANNEL_ID_LOW_BATTERY = "low_battery"; public static final String NOTIFICATION_CHANNEL_ID_FULL_BATTERY = "full_battery"; public static final String NOTIFICATION_CHANNEL_ID_GPS = "gps"; + public static final String NOTIFICATION_CHANNEL_ID_PEBBLE_JS = "pebble_js"; public static final int NOTIFICATION_ID = 1; public static final int NOTIFICATION_ID_INSTALL = 2; @@ -91,6 +92,7 @@ public class GB { public static final int NOTIFICATION_ID_GPS = 7; public static final int NOTIFICATION_ID_SCAN = 8; public static final int NOTIFICATION_ID_FULL_BATTERY = 9; + public static final int NOTIFICATION_ID_PEBBLE_JS = 10; public static final int NOTIFICATION_ID_ERROR = 42; private static final Logger LOG = LoggerFactory.getLogger(GB.class); @@ -172,6 +174,12 @@ public class GB { context.getString(R.string.notification_channel_gps), NotificationManager.IMPORTANCE_MIN); notificationManager.createNotificationChannel(channelGps); + + NotificationChannel channelPebbleJs = new NotificationChannel( + NOTIFICATION_CHANNEL_ID_PEBBLE_JS, + "Pebble JS runner", + NotificationManager.IMPORTANCE_MIN); + notificationManager.createNotificationChannel(channelPebbleJs); } notificationChannelsCreated = true; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/PebbleUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/PebbleUtils.java index c12692b597..475bccbaf8 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/PebbleUtils.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/PebbleUtils.java @@ -17,7 +17,10 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.util; +import android.content.Context; import android.graphics.Color; +import android.os.Handler; +import android.os.Looper; import android.util.SparseArray; import androidx.annotation.Nullable; @@ -33,6 +36,9 @@ import java.io.IOException; import java.util.Iterator; import java.util.UUID; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.webview.PebbleJsService; + public class PebbleUtils { private static final Logger LOG = LoggerFactory.getLogger(PebbleUtils.class); @@ -191,4 +197,32 @@ public class PebbleUtils { } return jsAppMessage.toString(); } + + public static void startJsEngineForDevice(Context context, GBDevice device, UUID uuid) { + PebbleJsService service = PebbleJsService.Companion.getInstance(); + + if (service == null) { + LOG.warn("PebbleJsService not running yet, starting it"); + PebbleJsService.Companion.startService(context); + + // Delay start until service is ready + new Handler(Looper.getMainLooper()).postDelayed(() -> { + PebbleJsService s = PebbleJsService.Companion.getInstance(); + if (s != null) { + s.startJsForDevice(device, uuid); + } + }, 600); + + return; + } + + service.startJsForDevice(device, uuid); + } + + public static void stopJsEngineForDevice(GBDevice device) { + PebbleJsService service = PebbleJsService.Companion.getInstance(); + if (service != null) { + service.stopJsForDevice(device); + } + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/WebViewSingleton.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/WebViewSingleton.java deleted file mode 100644 index 48d9d18cfb..0000000000 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/WebViewSingleton.java +++ /dev/null @@ -1,169 +0,0 @@ -/* 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.content.Context; -import android.content.MutableContextWrapper; -import android.os.Handler; -import android.os.Looper; -import android.webkit.WebSettings; -import android.webkit.WebView; - -import androidx.annotation.NonNull; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.UUID; - -import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; -import nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.webview.JSInterface; -import nodomain.freeyourgadget.gadgetbridge.webview.GBChromeClient; -import nodomain.freeyourgadget.gadgetbridge.webview.GBWebClient; - -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 WebViewSingleton() { - } - - public static synchronized void ensureCreated(Context context, int requestType) { - 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.resumeTimers(); - instance.webView.setWebViewClient(new GBWebClient(requestType)); - 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); - //allow local js files access - webSettings.setAllowContentAccess(true); - webSettings.setAllowFileAccess(true); - webSettings.setAllowFileAccessFromFileURLs(true); - webSettings.setAllowUniversalAccessFromFileURLs(true); - } - } - - public static WebViewSingleton getInstance() { - return instance; - } - - - @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 Context context, @NonNull GBDevice device, @NonNull UUID uuid) { - ensureCreated(context, GBWebClient.REQUEST_TYPE_PEBBLE_BACKGROUND_JS); - InternetHelperSingleton.INSTANCE.ensureInternetHelperBound(); - 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(webView -> { - webView.onResume(); - webView.removeJavascriptInterface("GBjs"); - webView.addJavascriptInterface(jsInterface, "GBjs"); - webView.loadUrl("file:///android_asset/app_config/configure.html?rand=" + Math.random() * 500); - }); - } - } - - public void stopJavascriptInterface() { - invokeWebview(webView -> { - webView.removeJavascriptInterface("GBjs"); - webView.loadUrl("about:blank"); - }); - } - - public void disposeWebView() { - currentRunningUUID = null; - invokeWebview(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(() -> { - 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); - } -}