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);
- }
-}