diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index 711f19741..6cc57544d 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -164,6 +164,12 @@ kotlin {
implementation(libs.play.update)
implementation(libs.play.update.ktx)
implementation(libs.coil.gif)
+ // androidx.sharetarget provides ChooserTargetServiceCompat,
+ // referenced from AndroidManifest.xml on ShareTargetActivity.
+ // Provides backward-compat surfacing of dynamic Sharing
+ // Shortcuts as Direct Share targets through the legacy
+ // ChooserTargetService API used by some launchers.
+ implementation(libs.androidx.sharetarget)
}
androidInstrumentedTest.dependencies {
implementation(libs.androidx.test.runner)
diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index 8c99c9020..b366847f1 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -30,10 +30,13 @@
android:name=".MainActivity"
android:launchMode="singleTask">
-
-
-
-
+
+
+
+
+
@@ -110,6 +113,44 @@
android:name="com.viktormykhailiv.kmp.health.HealthConnectPermissionActivity"
android:noHistory="true"
android:excludeFromRecents="true" />
+
+
+
+
+
+
+
+
+
+
+
().context
+ ShareTargetSync(
+ activityClass = ComponentName(context, ShareTargetActivity::class.java),
+ // TODO: thread per-watchapp icons through from the locker so
+ // each shortcut shows its real watchapp icon. For now everyone
+ // gets the Pebble launcher icon.
+ fallbackIconResId = UtilR.mipmap.ic_launcher,
+ )
+ }
}
\ No newline at end of file
diff --git a/composeApp/src/androidMain/kotlin/coredevices/coreapp/sharing/ShareTargetActivity.kt b/composeApp/src/androidMain/kotlin/coredevices/coreapp/sharing/ShareTargetActivity.kt
new file mode 100644
index 000000000..9b06d849d
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/coredevices/coreapp/sharing/ShareTargetActivity.kt
@@ -0,0 +1,190 @@
+package coredevices.coreapp.sharing
+
+import android.app.Activity
+import android.app.AlertDialog
+import android.content.DialogInterface
+import android.content.Intent
+import android.os.Bundle
+import android.util.Patterns
+import android.widget.Toast
+import co.touchlab.kermit.Logger
+import io.rebble.libpebblecommon.di.LibPebbleKoinComponent
+import io.rebble.libpebblecommon.shareintent.ShareIntentDispatcher
+import io.rebble.libpebblecommon.shareintent.ShareTargetEntry
+import io.rebble.libpebblecommon.shareintent.ShareTargetSync.Companion.EXTRA_WATCHAPP_UUID
+import io.rebble.libpebblecommon.shareintent.ShareTargetSync.Companion.SHORTCUT_ID_PREFIX
+import org.koin.core.component.inject
+import kotlin.uuid.Uuid
+
+/**
+ * Lightweight activity that handles the OS share-sheet routing the user
+ * picked from a Sharing Shortcut surfaced by
+ * [io.rebble.libpebblecommon.shareintent.ShareTargetSync] or from the
+ * static "Pebble" intent-filter entry.
+ *
+ * Routing paths:
+ *
+ * - **Direct Share** (user picked "Share to " from the share
+ * sheet's direct-share row): system constructs an ACTION_SEND intent
+ * with [Intent.EXTRA_SHORTCUT_ID] set to the shortcut's id. We decode
+ * the watchapp uuid from that and dispatch directly.
+ *
+ * - **Launcher long-press** (user tapped "Share to " from a
+ * long-press menu): system uses the shortcut's registered intent
+ * verbatim, which carries [EXTRA_WATCHAPP_UUID].
+ *
+ * - **Static fallback** (user picked "Pebble" from the apps row): no
+ * shortcut involvement; neither extra is set. Behavior depends on
+ * how many share-capable watchapps are installed:
+ * - zero: toast prompting the user to install a share-capable watchapp
+ * - one: dispatch directly to that watchapp (no chooser needed)
+ * - two+: show a chooser dialog so the user picks which watchapp
+ *
+ * The activity finishes immediately after dispatch — the actual delivery
+ * survives via the application-scoped coroutine in the dispatcher.
+ *
+ * Implements [LibPebbleKoinComponent] because [ShareIntentDispatcher] is
+ * registered in libpebble3's isolated Koin context, not the host app's
+ * global one.
+ */
+class ShareTargetActivity : Activity(), LibPebbleKoinComponent {
+ private val dispatcher: ShareIntentDispatcher by inject()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ try {
+ handleShareIntent(intent)
+ } catch (e: Exception) {
+ logger.e(e) { "share intent handling failed" }
+ Toast.makeText(this, "Couldn't share to your Pebble", Toast.LENGTH_SHORT).show()
+ finish()
+ }
+ // Note: we do NOT call finish() here unconditionally — the chooser
+ // dialog path needs the activity alive to host the AlertDialog.
+ // Each routing branch in handleShareIntent() finishes itself when
+ // appropriate (immediately for direct dispatch; on dialog dismiss
+ // for the chooser path).
+ }
+
+ private fun handleShareIntent(intent: Intent?) {
+ if (intent == null) {
+ finish()
+ return
+ }
+
+ // Extract payload first; we need it for every routing path.
+ val text = intent.getStringExtra(Intent.EXTRA_TEXT)
+ val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
+ if (text.isNullOrBlank()) {
+ logger.w { "share intent missing EXTRA_TEXT" }
+ Toast.makeText(this, "Nothing to share", Toast.LENGTH_SHORT).show()
+ finish()
+ return
+ }
+ val url = extractFirstUrl(text)
+
+ // The watchapp uuid can arrive via either of two extras depending
+ // on which path the share sheet took to launch us:
+ //
+ // - Direct Share path: system sets Intent.EXTRA_SHORTCUT_ID to the
+ // shortcut id ("share-watchapp-"). Our custom
+ // EXTRA_WATCHAPP_UUID is NOT preserved on this path.
+ //
+ // - Launcher-shortcut tap path: system uses the shortcut's
+ // registered intent verbatim, which carries EXTRA_WATCHAPP_UUID.
+ //
+ // - Static "Pebble" entry: neither extra is set — falls through
+ // to the chooser/auto-pick logic below.
+ val uuidString = intent.getStringExtra(EXTRA_WATCHAPP_UUID)
+ ?: intent.getStringExtra(Intent.EXTRA_SHORTCUT_ID)
+ ?.removePrefix(SHORTCUT_ID_PREFIX)
+ ?.takeIf { it.isNotBlank() }
+
+ if (uuidString.isNullOrBlank()) {
+ handleAmbiguousFallback(text = text, url = url, subject = subject)
+ return
+ }
+
+ // We have a specific watchapp targeted — dispatch directly.
+ val uuid = try {
+ Uuid.parse(uuidString)
+ } catch (e: IllegalArgumentException) {
+ logger.w(e) { "bad uuid in share intent: $uuidString" }
+ finish()
+ return
+ }
+ Toast.makeText(this, "Sharing to your Pebble…", Toast.LENGTH_SHORT).show()
+ dispatcher.enqueue(uuid, text = text, url = url, subject = subject)
+ finish()
+ }
+
+ /**
+ * Static "Pebble" entry path: no specific watchapp identified. Decide
+ * what to do based on how many share-capable watchapps are installed.
+ */
+ private fun handleAmbiguousFallback(text: String, url: String?, subject: String?) {
+ val targets = dispatcher.availableTargets.value
+ when {
+ targets.isEmpty() -> {
+ Toast.makeText(
+ this,
+ "No watchapp is set up to receive shares. Install a watchapp that supports sharing.",
+ Toast.LENGTH_LONG,
+ ).show()
+ finish()
+ }
+ targets.size == 1 -> {
+ // Single share-capable watchapp — no chooser needed.
+ val target = targets.first()
+ logger.i { "ambiguous fallback resolved to sole target ${target.uuid}" }
+ Toast.makeText(this, "Sharing to your Pebble…", Toast.LENGTH_SHORT).show()
+ dispatcher.enqueue(target.uuid, text = text, url = url, subject = subject)
+ finish()
+ }
+ else -> {
+ showChooserDialog(targets, text = text, url = url, subject = subject)
+ }
+ }
+ }
+
+ /**
+ * Multiple share-capable watchapps installed — let the user pick one.
+ * The dialog hosts itself on this activity; on dismiss/cancel the
+ * activity finishes without dispatching.
+ */
+ private fun showChooserDialog(
+ targets: List,
+ text: String,
+ url: String?,
+ subject: String?,
+ ) {
+ // Sort alphabetically by display name for a stable, predictable order.
+ val sorted = targets.sortedBy { displayNameFor(it) }
+ val labels = sorted.map { displayNameFor(it) }.toTypedArray()
+
+ AlertDialog.Builder(this)
+ .setTitle("Share to which watchapp?")
+ .setItems(labels) { _: DialogInterface, which: Int ->
+ val picked = sorted[which]
+ logger.i { "chooser picked ${picked.uuid}" }
+ Toast.makeText(this, "Sharing to your Pebble…", Toast.LENGTH_SHORT).show()
+ dispatcher.enqueue(picked.uuid, text = text, url = url, subject = subject)
+ finish()
+ }
+ .setOnCancelListener { finish() }
+ .setOnDismissListener { if (!isFinishing) finish() }
+ .show()
+ }
+
+ private fun displayNameFor(entry: ShareTargetEntry): String =
+ entry.shareTarget.label?.takeIf { it.isNotBlank() }
+ ?: entry.longName.takeIf { it.isNotBlank() }
+ ?: entry.shortName
+
+ private fun extractFirstUrl(text: String): String? =
+ Patterns.WEB_URL.matcher(text).takeIf { it.find() }?.group()
+
+ companion object {
+ private val logger = Logger.withTag(ShareTargetActivity::class.simpleName!!)
+ }
+}
diff --git a/composeApp/src/androidMain/res/xml/share_targets.xml b/composeApp/src/androidMain/res/xml/share_targets.xml
new file mode 100644
index 000000000..f468d835f
--- /dev/null
+++ b/composeApp/src/androidMain/res/xml/share_targets.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 62f8aff79..08ce13be3 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -24,6 +24,7 @@ settings = "1.3.0"
kable = "0.42.0"
kmpio = "0.3.0"
androidx-core = "1.17.0"
+androidx-sharetarget = "1.2.0"
monitorVersion = "1.8.0"
androidXTestVersion = "1.7.0"
androidXRulesVersion = "1.7.0"
@@ -125,6 +126,7 @@ settings-serialization = { group = "com.russhwolf", name = "multiplatform-settin
kable = { group = "com.juul.kable", name = "kable-core", version.ref = "kable" }
kmpio = { module = "io.github.skolson:kmp-io", version.ref = "kmpio" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
+androidx-sharetarget = { module = "androidx.sharetarget:sharetarget", version.ref = "androidx-sharetarget" }
androidx-monitor = { group = "androidx.test", name = "monitor", version.ref = "monitorVersion" }
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidXTestVersion" }
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidXRulesVersion" }
diff --git a/libpebble3/src/androidMain/assets/startup.js b/libpebble3/src/androidMain/assets/startup.js
index 25f11a40a..f6262fcb0 100644
--- a/libpebble3/src/androidMain/assets/startup.js
+++ b/libpebble3/src/androidMain/assets/startup.js
@@ -138,6 +138,8 @@ navigator.geolocation.clearWatch = (id) => {
APP_MESSAGE_NACK: 'appmessage_nack',
GET_TIMELINE_TOKEN_SUCCESS: 'getTimelineTokenSuccess',
GET_TIMELINE_TOKEN_FAILURE: 'getTimelineTokenFailure',
+ SHARE_INTENT: 'shareintent',
+ APP_NOTIFICATION: 'appnotification',
};
Object.freeze(PebbleEventTypes);
const DEFAULT_TIMEOUT = 5000; // 5 seconds
@@ -292,6 +294,46 @@ navigator.geolocation.clearWatch = (id) => {
}
dispatchPebbleEvent(PebbleEventTypes.GET_TIMELINE_TOKEN_FAILURE, { payload });
};
+ /**
+ * Dispatched when the OS routes a share intent (Android ACTION_SEND, etc.)
+ * to this watchapp. The payload object is `{ text, url, subject }` —
+ * `text` is always present; `url` is a best-effort URL extraction; both
+ * `url` and `subject` may be null.
+ */
+ global.signalShareIntent = (data) => {
+ var payload;
+ if (typeof data === 'string') {
+ // Android: payload arrives as a JSON string from evaluateJavascript
+ payload = data ? JSON.parse(data) : {};
+ } else {
+ // iOS: payload is already an object
+ payload = data || {};
+ }
+ dispatchPebbleEvent(PebbleEventTypes.SHARE_INTENT, payload);
+ };
+ /**
+ * Dispatched when the OS surfaces a notification from a package this
+ * watchapp's package.json `notificationFilter` declares interest in,
+ * while this watchapp's PKJS is running. The payload object contains:
+ * { package: String, posted: Boolean, key: String, postTime: Number,
+ * category: String|null, title: String|null, text: String|null,
+ * subText: String|null, infoText: String|null, groupKey: String|null,
+ * extras: { ... raw extras keys ... } }
+ * `posted: false` means the notification was just removed (e.g. user
+ * dismissed it, or the source app cleared a persistent ongoing-activity
+ * notification — useful for "trip ended" detection).
+ */
+ global.signalAppNotification = (data) => {
+ var payload;
+ if (typeof data === 'string') {
+ // Android: payload arrives as a JSON string from evaluateJavascript
+ payload = data ? JSON.parse(data) : {};
+ } else {
+ // iOS: payload is already an object
+ payload = data || {};
+ }
+ dispatchPebbleEvent(PebbleEventTypes.APP_NOTIFICATION, payload);
+ };
const PebbleAPI = {
addEventListener: (type, callback, useCapture) => {
@@ -383,6 +425,26 @@ navigator.geolocation.clearWatch = (id) => {
deleteTimelinePin: (id) => {
_Pebble.deleteTimelinePin(id);
},
+ /**
+ * Returns currently-posted notifications matching the watchapp's
+ * declared notificationFilter, optionally narrowed further by
+ * `packages`. Synchronous — useful for catch-up when PKJS spins up
+ * mid-event-stream (e.g. user opens MirrorMap while Maps is already
+ * navigating). Returns an array of notification payload objects.
+ *
+ * @param {string[]|undefined} packages Optional package-name list.
+ * Empty/undefined = use the full declared notificationFilter.
+ */
+ getActiveNotifications: (packages) => {
+ const filter = Array.isArray(packages) ? packages.join(',') : '';
+ const raw = _Pebble.getActiveNotifications(filter);
+ try {
+ return raw ? JSON.parse(raw) : [];
+ } catch (e) {
+ console.error('PKJS Error parsing getActiveNotifications response', e);
+ return [];
+ }
+ },
}
global.Pebble.addEventListener = PebbleAPI.addEventListener;
global.Pebble.removeEventListener = PebbleAPI.removeEventListener;
@@ -395,6 +457,7 @@ navigator.geolocation.clearWatch = (id) => {
global.Pebble.appGlanceReload = PebbleAPI.appGlanceReload;
global.Pebble.insertTimelinePin = PebbleAPI.insertTimelinePin;
global.Pebble.deleteTimelinePin = PebbleAPI.deleteTimelinePin;
+ global.Pebble.getActiveNotifications = PebbleAPI.getActiveNotifications;
// Enable intercepting XHR calls (on Android - this doesn't work on iOS so we don't add
// shouldIntercept to the PKJS interface there).
diff --git a/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/js/WebViewJsRunner.kt b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/js/WebViewJsRunner.kt
index d02ca7be1..f7b244fa5 100644
--- a/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/js/WebViewJsRunner.kt
+++ b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/js/WebViewJsRunner.kt
@@ -424,6 +424,33 @@ class WebViewJsRunner(
}
}
+ override suspend fun signalShareIntent(text: String, url: String?, subject: String?) {
+ readyState.first { it }
+ val payload = buildJsonObject {
+ put("text", text)
+ put("url", url)
+ put("subject", subject)
+ }.toString()
+ withContext(Dispatchers.Main) {
+ // Send as a JSON-encoded string (matches signalNewAppMessageData /
+ // signalTimelineTokenSuccess). startup.js handles both string and
+ // object payloads.
+ webView?.evaluateJavascript("window.signalShareIntent(${Json.encodeToString(payload)})", null)
+ }
+ }
+
+ override suspend fun signalAppNotification(notificationJson: String) {
+ readyState.first { it }
+ withContext(Dispatchers.Main) {
+ // Pass as a JSON-encoded string (matches signalNewAppMessageData).
+ // startup.js will parse it on receipt.
+ webView?.evaluateJavascript(
+ "window.signalAppNotification(${Json.encodeToString(notificationJson)})",
+ null
+ )
+ }
+ }
+
override suspend fun eval(js: String) {
withContext(Dispatchers.Main) {
webView?.evaluateJavascript(js, null) ?: run {
diff --git a/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/js/WebViewPKJSInterface.kt b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/js/WebViewPKJSInterface.kt
index ac7e73e55..c149f628d 100644
--- a/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/js/WebViewPKJSInterface.kt
+++ b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/js/WebViewPKJSInterface.kt
@@ -5,6 +5,12 @@ import android.webkit.JavascriptInterface
import android.widget.Toast
import co.touchlab.kermit.Logger
import io.rebble.libpebblecommon.connection.LibPebble
+import io.rebble.libpebblecommon.di.LibPebbleKoinComponent
+import io.rebble.libpebblecommon.io.rebble.libpebblecommon.notification.AndroidPebbleNotificationListenerConnection
+import io.rebble.libpebblecommon.notification.WatchappNotificationSerializer
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.buildJsonArray
+import org.koin.core.component.inject
class WebViewPKJSInterface(
jsRunner: JsRunner,
@@ -12,10 +18,15 @@ class WebViewPKJSInterface(
private val context: Context,
libPebble: LibPebble,
jsTokenUtil: JsTokenUtil,
-): PKJSInterface(jsRunner, device, libPebble, jsTokenUtil) {
+): PKJSInterface(jsRunner, device, libPebble, jsTokenUtil), LibPebbleKoinComponent {
companion object {
private val logger = Logger.withTag(WebViewPKJSInterface::class.simpleName!!)
}
+
+ // Lazy because not every PKJS instance needs notification access; cheap
+ // to fetch from Koin when first called.
+ private val notificationConnection: AndroidPebbleNotificationListenerConnection by inject()
+
@JavascriptInterface
override fun showSimpleNotificationOnPebble(title: String, notificationText: String) {
super.showSimpleNotificationOnPebble(title, notificationText)
@@ -40,4 +51,63 @@ class WebViewPKJSInterface(
override fun openURL(url: String): String {
return super.openURL(url)
}
+
+ /**
+ * @param packageFilter Comma-separated list of package names. Empty string
+ * means "use the watchapp's full notificationFilter".
+ */
+ @JavascriptInterface
+ override fun getActiveNotifications(packageFilter: String): String {
+ val watchappFilter = jsRunner.appInfo.notificationFilter
+ if (watchappFilter.isEmpty()) {
+ // Watchapp didn't declare any subscriptions; nothing to return.
+ return "[]"
+ }
+
+ val effectiveFilter: Set = run {
+ val requested = packageFilter
+ .split(',')
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ .toSet()
+ // Intersect with declared filter so a watchapp can never read
+ // notifications from a package it didn't declare interest in,
+ // even if it asks. Empty requested set = use full declared set.
+ if (requested.isEmpty()) watchappFilter.toSet()
+ else requested.intersect(watchappFilter.toSet())
+ }
+ if (effectiveFilter.isEmpty()) return "[]"
+
+ val service = notificationConnection.getService()
+ if (service == null) {
+ logger.w { "getActiveNotifications: notification listener not bound" }
+ return "[]"
+ }
+
+ return try {
+ val active = service.activeNotifications ?: return "[]"
+ val json = buildJsonArray {
+ for (sbn in active) {
+ if (sbn.packageName !in effectiveFilter) continue
+ // serialize() returns a JSON object string; reparse as
+ // JsonElement so we add it as a structured array entry,
+ // not as an embedded string. The notification listener
+ // service IS a Context — pass it through for icon
+ // extraction (smallIcon / largeIcon as base64 PNGs).
+ val element = Json.parseToJsonElement(
+ WatchappNotificationSerializer.serialize(sbn, posted = true, context = service)
+ )
+ add(element)
+ }
+ }
+ json.toString()
+ } catch (e: SecurityException) {
+ // Listener service can throw if access was just revoked.
+ logger.w(e) { "getActiveNotifications: SecurityException reading active notifications" }
+ "[]"
+ } catch (e: Exception) {
+ logger.w(e) { "getActiveNotifications failed" }
+ "[]"
+ }
+ }
}
diff --git a/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/notification/LibPebbleNotificationListener.kt b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/notification/LibPebbleNotificationListener.kt
index b4930e4c7..c262865d9 100644
--- a/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/notification/LibPebbleNotificationListener.kt
+++ b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/notification/LibPebbleNotificationListener.kt
@@ -52,6 +52,8 @@ class LibPebbleNotificationListener : NotificationListenerService(), LibPebbleKo
private val notificationHandler: NotificationHandler by inject()
private val notificationCallDetector: NotificationCallDetector by inject()
private val connection: AndroidPebbleNotificationListenerConnection by inject()
+ // PR 2: fan out notifications to subscribed PKJS watchapps. Singleton.
+ private val watchappDispatcher: WatchappNotificationDispatcher by inject()
private val configHolder: NotificationConfigFlow by inject()
@@ -181,6 +183,18 @@ class LibPebbleNotificationListener : NotificationListenerService(), LibPebbleKo
}
notificationHandler.handleNotificationPosted(sbn)
+
+ // PR 2: also fan out to any running PKJS watchapp that subscribes to
+ // this package. Independent of the watch-UI relay above — failure
+ // here doesn't affect the existing notification pipeline. Pass `this`
+ // (the listener service) as the Context for resolving icon drawables
+ // out of the source app's resources.
+ try {
+ val json = WatchappNotificationSerializer.serialize(sbn, posted = true, context = this)
+ watchappDispatcher.dispatch(sbn.packageName, json)
+ } catch (e: Exception) {
+ logger.w(e) { "watchapp notification dispatch failed for ${sbn.packageName.obfuscate(privateLogger)}" }
+ }
}
override fun onNotificationRemoved(
@@ -193,6 +207,17 @@ class LibPebbleNotificationListener : NotificationListenerService(), LibPebbleKo
return
}
notificationHandler.handleNotificationRemoved(sbn)
+
+ // PR 2: notify subscribed watchapps that the notification was cleared.
+ // For nav use cases this is the "trip ended" signal — Maps removes
+ // its persistent navigation notification when the user ends the trip.
+ // Icons are skipped on removal events (the icon adds no signal there).
+ try {
+ val json = WatchappNotificationSerializer.serialize(sbn, posted = false, context = null)
+ watchappDispatcher.dispatch(sbn.packageName, json)
+ } catch (e: Exception) {
+ logger.w(e) { "watchapp removed-notification dispatch failed for ${sbn.packageName.obfuscate(privateLogger)}" }
+ }
}
private fun controlListenerHints() = notificationListenerScope.launch {
diff --git a/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/notification/NotificationIconExtractor.kt b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/notification/NotificationIconExtractor.kt
new file mode 100644
index 000000000..972ec0c6d
--- /dev/null
+++ b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/notification/NotificationIconExtractor.kt
@@ -0,0 +1,162 @@
+package io.rebble.libpebblecommon.notification
+
+import android.app.Notification
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.Drawable
+import android.os.Build
+import android.util.Base64
+import co.touchlab.kermit.Logger
+import java.io.ByteArrayOutputStream
+
+/**
+ * Extracts notification icons (small icon + large icon) from an Android
+ * [Notification] and encodes them as base64 PNG strings suitable for
+ * inclusion in the JSON payload PKJS watchapps consume.
+ *
+ * Why bother:
+ * Notification icons are typically the most semantically rich visual
+ * signal in a notification. Google Maps' navigation notification, for
+ * example, draws a turn-arrow icon in its smallIcon slot — left turn,
+ * right turn, straight, U-turn, exit ramp, roundabout, etc. — that's
+ * the only structured signal of the maneuver type. The text fields
+ * contain free-form prose that's locale-dependent and varies by trip
+ * ("toward Saticoy St", "Make a U-turn", etc).
+ *
+ * Watchapps doing nav, music control, fitness mirroring, etc. routinely
+ * want to render the source notification's iconography on the watch.
+ * Rather than every watchapp inventing its own way of getting at icon
+ * bitmaps (which it can't from JS anyway), the platform extracts +
+ * encodes them once and includes them in the JSON event payload.
+ *
+ * Cost:
+ * A 32×32 PNG of a typical monochrome icon is ~200-600 bytes raw, ~280-820
+ * bytes base64-encoded. Trivial overhead vs the rest of the notification
+ * payload. CPU cost is one rasterize + one PNG-encode per dispatched
+ * notification — negligible at the ~1Hz rates notifications fire at.
+ *
+ * Failure mode:
+ * Best-effort. Any failure in icon extraction (Drawable load, bitmap
+ * creation, PNG encode) is logged and skipped — the rest of the payload
+ * is unaffected. Watchapps treat the icon fields as optional.
+ */
+internal object NotificationIconExtractor {
+
+ private val logger = Logger.withTag("NotificationIconExtractor")
+
+ /**
+ * Target size for rasterized notification icons. Pebble watch faces are
+ * 144x168 (Aplite/Basalt) up to 200x228 (Emery), and the typical use
+ * site is a top-bar slot of 24-32px square. 32px gives watchapps headroom
+ * to downscale themselves; smaller would require server-side knowledge of
+ * the target watch's display.
+ *
+ * Watchapps that need the icon at a different size do so themselves —
+ * they receive the base64 PNG and decode/scale on the JS side or ship
+ * it through to the C side as-is.
+ */
+ private const val ICON_SIZE_PX = 32
+
+ /**
+ * Holds extracted icon data. All fields nullable because any extraction
+ * step can legitimately fail or produce nothing.
+ */
+ data class Icons(
+ /** Base64-encoded PNG of the notification's smallIcon, or null. */
+ val smallIconBase64: String?,
+ /** Base64-encoded PNG of the notification's largeIcon, or null. */
+ val largeIconBase64: String?,
+ ) {
+ companion object {
+ val EMPTY = Icons(null, null)
+ }
+ }
+
+ /**
+ * Extract both icons. Either or both may be null in the result.
+ *
+ * @param context Used to resolve Icon drawables (icons reference
+ * resources in the source app's package; loadDrawable needs a Context
+ * to dereference). The notification listener service IS a Context;
+ * pass `this` from the listener.
+ * @param notification The notification to extract icons from.
+ */
+ fun extract(context: Context, notification: Notification): Icons {
+ val small = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ // Notification.smallIcon is an Icon (added API 23). loadDrawable
+ // crosses the package boundary into the source app's resources,
+ // which is what we want — Maps' arrow-icon resource is in Maps'
+ // own package.
+ try {
+ notification.smallIcon?.loadDrawable(context)?.let(::drawableToPngBase64)
+ } catch (e: Exception) {
+ logger.v(e) { "smallIcon extract failed" }
+ null
+ }
+ } else null
+
+ val large = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ try {
+ notification.getLargeIcon()?.loadDrawable(context)?.let(::drawableToPngBase64)
+ } catch (e: Exception) {
+ logger.v(e) { "largeIcon extract failed" }
+ null
+ }
+ } else null
+
+ return Icons(small, large)
+ }
+
+ /**
+ * Rasterize a [Drawable] to a [ICON_SIZE_PX]×[ICON_SIZE_PX] bitmap and
+ * return base64-encoded PNG. Tints are NOT applied — watchapps decide
+ * how to render the alpha channel against their own theme.
+ *
+ * Returns null if any step fails or yields an empty bitmap. We don't
+ * preserve the original drawable's intrinsic size because watchapps
+ * don't have a way to negotiate dimensions; a fixed target size is
+ * predictable.
+ */
+ private fun drawableToPngBase64(drawable: Drawable): String? {
+ return try {
+ // Fast path: drawable is already a bitmap of the right size.
+ if (drawable is BitmapDrawable && drawable.bitmap != null &&
+ drawable.bitmap.width == ICON_SIZE_PX &&
+ drawable.bitmap.height == ICON_SIZE_PX) {
+ return bitmapToPngBase64(drawable.bitmap)
+ }
+ // Else rasterize.
+ val bmp = Bitmap.createBitmap(ICON_SIZE_PX, ICON_SIZE_PX, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bmp)
+ drawable.setBounds(0, 0, ICON_SIZE_PX, ICON_SIZE_PX)
+ drawable.draw(canvas)
+ val encoded = bitmapToPngBase64(bmp)
+ bmp.recycle()
+ encoded
+ } catch (e: Exception) {
+ logger.v(e) { "drawable rasterize failed" }
+ null
+ }
+ }
+
+ private fun bitmapToPngBase64(bmp: Bitmap): String? {
+ return try {
+ val baos = ByteArrayOutputStream()
+ // 100 = lossless for PNG (the value is just ignored for PNG, but
+ // the docs say to pass 100 for "best quality").
+ if (!bmp.compress(Bitmap.CompressFormat.PNG, 100, baos)) {
+ logger.v { "PNG compress returned false" }
+ return null
+ }
+ val bytes = baos.toByteArray()
+ // NO_WRAP avoids the line breaks the default Base64 encoder
+ // inserts at column 76, which JS doesn't need.
+ Base64.encodeToString(bytes, Base64.NO_WRAP)
+ } catch (e: Exception) {
+ logger.v(e) { "PNG encode failed" }
+ null
+ }
+ }
+}
diff --git a/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/notification/WatchappNotificationSerializer.kt b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/notification/WatchappNotificationSerializer.kt
new file mode 100644
index 000000000..e3364bcb7
--- /dev/null
+++ b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/notification/WatchappNotificationSerializer.kt
@@ -0,0 +1,123 @@
+package io.rebble.libpebblecommon.notification
+
+import android.app.Notification
+import android.content.Context
+import android.os.Bundle
+import android.service.notification.StatusBarNotification
+import co.touchlab.kermit.Logger
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+import kotlinx.serialization.json.putJsonObject
+
+/**
+ * Converts an Android [StatusBarNotification] into the JSON shape PKJS
+ * watchapps consume via `'appnotification'` events / `Pebble.getActiveNotifications`.
+ *
+ * The format is intentionally close to what's available from the
+ * NotificationListenerService — well-known fields surfaced at top level for
+ * convenience plus a raw `extras` map for watchapps that need to dig
+ * further (e.g. parsing app-specific keys like Google Maps's
+ * `ongoingActivityNoti.next_step_message`).
+ *
+ * Non-primitive extras (Parcelables, RemoteInput, Bitmaps) are skipped from
+ * the `extras` map — a watchapp that needs them would need a richer pipe
+ * than JSON anyway. The notification's smallIcon and largeIcon ARE surfaced
+ * separately at the top level as base64-encoded PNGs (see
+ * [NotificationIconExtractor]), since they're often the most semantically
+ * meaningful visual signal in a notification (e.g. Maps' turn-arrow icon).
+ */
+internal object WatchappNotificationSerializer {
+
+ private val logger = Logger.withTag("WatchappNotificationSerializer")
+
+ /**
+ * @param sbn The OS notification
+ * @param posted true if just posted, false if removed
+ * @param context Used to resolve notification icon drawables. The
+ * listener service IS a Context; pass `this` from the listener call
+ * sites. Pass null on platforms where icon extraction isn't supported
+ * (e.g. iOS receiving side, when added) — the JSON output simply
+ * omits the icon fields.
+ */
+ fun serialize(sbn: StatusBarNotification, posted: Boolean, context: Context?): String {
+ val n: Notification = sbn.notification
+ val extras: Bundle = n.extras ?: Bundle.EMPTY
+
+ // Best-effort icon extraction. Skipped entirely on removal events
+ // (the icon adds no signal there) and when no Context is available.
+ val icons: NotificationIconExtractor.Icons = if (posted && context != null) {
+ try {
+ NotificationIconExtractor.extract(context, n)
+ } catch (e: Exception) {
+ logger.v(e) { "icon extract threw, continuing without icons" }
+ NotificationIconExtractor.Icons.EMPTY
+ }
+ } else {
+ NotificationIconExtractor.Icons.EMPTY
+ }
+
+ return buildJsonObject {
+ put("package", sbn.packageName)
+ put("posted", posted)
+ put("key", sbn.key)
+ put("postTime", sbn.postTime)
+ put("category", n.category)
+ // Convenience surfacing — these are the keys nearly every notif
+ // populates and that nearly every watchapp parser will look at.
+ put("title", extras.getCharSequence(Notification.EXTRA_TITLE)?.toString())
+ put("text", extras.getCharSequence(Notification.EXTRA_TEXT)?.toString())
+ put("subText", extras.getCharSequence(Notification.EXTRA_SUB_TEXT)?.toString())
+ put("infoText", extras.getCharSequence(Notification.EXTRA_INFO_TEXT)?.toString())
+ put("groupKey", sbn.groupKey)
+ // Notification icons as base64-encoded PNGs at a fixed 32×32px.
+ // Both nullable. Watchapps that don't care just ignore them; the
+ // bytes are tiny (~few hundred bytes per icon) so always
+ // forwarding them is cheaper than negotiating per-watchapp opt-in.
+ put("smallIconBase64", icons.smallIconBase64)
+ put("largeIconBase64", icons.largeIconBase64)
+ putJsonObject("extras") {
+ for (key in extras.keySet()) {
+ encodeExtra(extras, key)?.let { jsonValue ->
+ // We can't use put(key, JsonElement) directly because
+ // JsonObjectBuilder doesn't accept arbitrary JsonElement
+ // — we go through the type-specific puts to keep
+ // JsonNull / JsonPrimitive distinctions clean.
+ when (jsonValue) {
+ is JsonPrimitive -> when {
+ jsonValue.isString -> put(key, jsonValue.content)
+ else -> put(key, jsonValue)
+ }
+ JsonNull -> put(key, null as String?)
+ else -> put(key, jsonValue)
+ }
+ }
+ }
+ }
+ }.toString()
+ }
+
+ @Suppress("DEPRECATION")
+ private fun encodeExtra(extras: Bundle, key: String): JsonElement? {
+ val value = extras.get(key) ?: return JsonNull
+ return when (value) {
+ is CharSequence -> JsonPrimitive(value.toString())
+ is String -> JsonPrimitive(value)
+ is Boolean -> JsonPrimitive(value)
+ is Int -> JsonPrimitive(value)
+ is Long -> JsonPrimitive(value)
+ is Float -> JsonPrimitive(value.toDouble())
+ is Double -> JsonPrimitive(value)
+ // Skip Parcelable, Bitmap, Icon, RemoteInput, byte[], etc. The JSON
+ // representation for these would be large and useless to a JS
+ // watchapp; logging at trace because notifications routinely have
+ // these (e.g. EXTRA_LARGE_ICON).
+ else -> {
+ logger.v { "skipping non-primitive extra '$key' (${value::class.simpleName})" }
+ null
+ }
+ }
+ }
+}
diff --git a/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareTargetSync.kt b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareTargetSync.kt
new file mode 100644
index 000000000..881b8abe4
--- /dev/null
+++ b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareTargetSync.kt
@@ -0,0 +1,313 @@
+package io.rebble.libpebblecommon.shareintent
+
+import android.content.ComponentName
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.ColorMatrix
+import android.graphics.ColorMatrixColorFilter
+import android.graphics.Paint
+import android.graphics.Rect
+import android.graphics.drawable.BitmapDrawable
+import androidx.core.content.pm.ShortcutInfoCompat
+import androidx.core.content.pm.ShortcutManagerCompat
+import androidx.core.content.res.ResourcesCompat
+import androidx.core.graphics.drawable.IconCompat
+import co.touchlab.kermit.Logger
+import io.rebble.libpebblecommon.connection.AppContext
+import io.rebble.libpebblecommon.di.LibPebbleCoroutineScope
+import io.rebble.libpebblecommon.di.LibPebbleKoinComponent
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.koin.core.component.inject
+import kotlin.uuid.Uuid
+
+/**
+ * Keeps Android Sharing Shortcuts in sync with the set of installed watchapps
+ * that have declared `shareTarget` in their `package.json`. Each declared
+ * watchapp surfaces in the system share sheet as its own entry with its own
+ * label and icon; tapping it routes the share into [activityClass] with
+ * [EXTRA_WATCHAPP_UUID] populated.
+ *
+ * Per-watchapp icons come from the PBW's menu icon (extracted by
+ * [ShareTargetsProducer] into [ShareTargetEntry.iconBytes]), upscaled and
+ * composited onto a colored background for share-sheet rendering. Watchapps
+ * with no extractable menu icon fall back to [fallbackIconResId] (the host
+ * app's launcher icon).
+ *
+ * Caller (typically the host app's startup code) must:
+ * 1. Provide the [ComponentName] of the activity that handles ACTION_SEND
+ * from these shortcuts. The activity must be declared in the host
+ * application's manifest with an `` matching
+ * `android.intent.action.SEND` for the appropriate MIME types.
+ * 2. Call [start] once during application initialization.
+ *
+ * Uses [androidx.core.content.pm.ShortcutManagerCompat] which is available
+ * back to API 25 — well below the host app's minSdk 26.
+ */
+class ShareTargetSync(
+ private val activityClass: ComponentName,
+ /**
+ * Resource id of the fallback icon for share-target shortcuts. Used
+ * when a watchapp has no extractable menu icon in its PBW. The host
+ * app should provide a sensible default (e.g. its launcher icon).
+ */
+ private val fallbackIconResId: Int,
+) : LibPebbleKoinComponent {
+ private val appContext: AppContext by inject()
+ private val scope: LibPebbleCoroutineScope by inject()
+ private val producer: ShareTargetsProducer by inject()
+ private val context = appContext.context
+ private val logger = Logger.withTag(ShareTargetSync::class.simpleName!!)
+ private val targets get() = producer.flow
+
+ companion object {
+ const val EXTRA_WATCHAPP_UUID = "io.rebble.libpebblecommon.shareintent.WATCHAPP_UUID"
+ const val SHORTCUT_CATEGORY = "io.rebble.libpebblecommon.WATCHAPP_SHARE"
+ const val SHORTCUT_ID_PREFIX = "share-watchapp-"
+
+ /**
+ * Target dimensions for the rasterized shortcut icon. Android share
+ * sheets render at ~48dp at typical density, scaled up to ~96px on
+ * the display. We choose 192 to look acceptable on high-density
+ * screens and to leave room for the launcher's adaptive-icon shape
+ * masking.
+ */
+ private const val SHORTCUT_ICON_PX = 192
+
+ /**
+ * Background color for the rendered shortcut icon — Repebble's
+ * brand crimson. Pebble menu icons are designed for the watch's
+ * display: typically black-on-transparent (1-bit) with the watch
+ * firmware drawing them as off-white silhouettes against the
+ * menu's colored background. We replicate that aesthetic: tint
+ * non-transparent pixels to a warm off-white and draw onto this
+ * crimson background. Result is consistent with how the icons
+ * look on the watch and gives all share-target shortcuts a unified
+ * Pebble-branded appearance in the share sheet.
+ */
+ private const val SHORTCUT_ICON_BG_COLOR: Int = 0xFFA41D1A.toInt()
+
+ /**
+ * Foreground color for the rendered icon — Repebble's warm cream
+ * white. Slightly off pure white to feel softer against the red
+ * background and to match the brand's typography color.
+ */
+ private const val SHORTCUT_ICON_FG_R: Float = 245f // 0xF5
+ private const val SHORTCUT_ICON_FG_G: Float = 240f // 0xF0
+ private const val SHORTCUT_ICON_FG_B: Float = 232f // 0xE8
+ }
+
+ /**
+ * Lazily-resolved bitmap form of the fallback icon resource. Direct-share
+ * shortcuts on Samsung OneUI only surface in the share sheet when their
+ * icon is delivered as a bitmap (showing up as
+ * `bitmapPath=/data/.../X.png` in `cmd shortcut get-shortcuts`), not as
+ * a resource reference.
+ */
+ private val fallbackIconBitmap: Bitmap? by lazy {
+ try {
+ val drawable = ResourcesCompat.getDrawable(context.resources, fallbackIconResId, null)
+ ?: return@lazy null
+ if (drawable is BitmapDrawable && drawable.bitmap != null) {
+ return@lazy drawable.bitmap
+ }
+ val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: SHORTCUT_ICON_PX
+ val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: SHORTCUT_ICON_PX
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bitmap)
+ drawable.setBounds(0, 0, canvas.width, canvas.height)
+ drawable.draw(canvas)
+ bitmap
+ } catch (e: Exception) {
+ logger.w(e) { "couldn't rasterize fallback icon resource $fallbackIconResId" }
+ null
+ }
+ }
+
+ /**
+ * Per-watchapp cache of rasterized icon bitmaps keyed by uuid. The cache
+ * key includes [ByteArray.contentHashCode] of the source bytes so an
+ * upgraded watchapp (different bytes for the same uuid) gets a fresh
+ * render. Without this, every locker re-emission would re-decode and
+ * re-draw the bitmap on the IO thread — wasteful on the share-sheet
+ * critical path.
+ */
+ private val iconCache = HashMap()
+ private data class CachedIcon(val sourceHash: Int, val bitmap: Bitmap)
+
+ /**
+ * Returns an [IconCompat] suitable for both the shortcut and the
+ * Person attached to it. Prefers the watchapp's own menu icon scaled
+ * up onto a colored background; falls back to the host app's launcher
+ * icon when the watchapp has no extractable menu icon or rasterization
+ * fails.
+ *
+ * Callers are expected to invoke this at shortcut-publish time only
+ * (not on every share dispatch); the rasterization, while cached, is
+ * still expensive enough to want to amortize.
+ */
+ private fun iconFor(entry: ShareTargetEntry): IconCompat {
+ val rendered = renderWatchappIcon(entry)
+ if (rendered != null) return IconCompat.createWithBitmap(rendered)
+ // Watchapp has no menu icon, decoding failed, or it's an unsupported
+ // format — fall back to the host app's launcher icon as a bitmap so
+ // we still get direct-share surfacing on Samsung (which requires
+ // bitmap-backed icons; resource references aren't surfaced).
+ fallbackIconBitmap?.let { return IconCompat.createWithBitmap(it) }
+ // Last-resort: resource reference. Some launchers may still surface
+ // it; better than no icon at all.
+ return IconCompat.createWithResource(context, fallbackIconResId)
+ }
+
+ private fun renderWatchappIcon(entry: ShareTargetEntry): Bitmap? {
+ val sourceBytes = entry.iconBytes ?: return null
+ val sourceHash = sourceBytes.contentHashCode()
+ iconCache[entry.uuid]?.let { cached ->
+ if (cached.sourceHash == sourceHash) return cached.bitmap
+ }
+ val source = try {
+ BitmapFactory.decodeByteArray(sourceBytes, 0, sourceBytes.size)
+ } catch (e: Exception) {
+ logger.w(e) { "couldn't decode menu icon bytes for ${entry.uuid}" }
+ null
+ } ?: return null
+ val rendered = composeShortcutBitmap(source)
+ iconCache[entry.uuid] = CachedIcon(sourceHash, rendered)
+ return rendered
+ }
+
+ /**
+ * Composes a watchapp's small menu icon onto a square colored
+ * background sized for share-sheet rendering. Pebble menu icons are
+ * authored as black-on-transparent PNGs with the assumption the watch
+ * firmware will draw them as off-white silhouettes against the menu's
+ * colored background; we replicate that here by tinting all opaque
+ * pixels to the brand cream via a [ColorMatrix] that:
+ *
+ * - Zeros out the RGB channels (so source color information is
+ * discarded — black, dark gray, and any color all map to the
+ * constant)
+ * - Adds the brand cream RGB constants
+ * - Preserves the alpha channel (so anti-aliased edges still feather
+ * cleanly into the red background)
+ *
+ * Net effect: every visible pixel becomes brand-cream-with-original-
+ * alpha, drawn onto Pebble brand red. Matches the watch's own menu
+ * rendering and the repebble.com visual identity.
+ *
+ * The source icon is scaled with nearest-neighbor sampling to preserve
+ * its pixel-art character (Pebble menu icons are typically 25×25;
+ * smooth interpolation makes them look like blurry blobs).
+ *
+ * The icon is centered with a small inset so the launcher's circular
+ * mask doesn't crop the artwork.
+ */
+ private fun composeShortcutBitmap(source: Bitmap): Bitmap {
+ val out = Bitmap.createBitmap(SHORTCUT_ICON_PX, SHORTCUT_ICON_PX, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(out)
+ canvas.drawColor(SHORTCUT_ICON_BG_COLOR)
+
+ // Fit the icon into ~70% of the canvas so the launcher's circular
+ // shape mask doesn't clip it. Preserve aspect ratio.
+ val targetEdge = (SHORTCUT_ICON_PX * 0.70f).toInt()
+ val srcAspect = source.width.toFloat() / source.height.toFloat()
+ val destW: Int
+ val destH: Int
+ if (srcAspect >= 1f) {
+ destW = targetEdge
+ destH = (targetEdge / srcAspect).toInt().coerceAtLeast(1)
+ } else {
+ destH = targetEdge
+ destW = (targetEdge * srcAspect).toInt().coerceAtLeast(1)
+ }
+ val left = (SHORTCUT_ICON_PX - destW) / 2
+ val top = (SHORTCUT_ICON_PX - destH) / 2
+ val srcRect = Rect(0, 0, source.width, source.height)
+ val destRect = Rect(left, top, left + destW, top + destH)
+
+ // Color matrix that tints opaque pixels brand cream while preserving
+ // alpha. Layout: 4×5 matrix, rows are [R, G, B, A] outputs, columns
+ // are [R, G, B, A, constant] inputs. We zero out RGB inputs and set
+ // the constant for R/G/B to brand cream channel values; alpha
+ // passes through unchanged so anti-aliased edges feather cleanly.
+ val tint = ColorMatrix(floatArrayOf(
+ 0f, 0f, 0f, 0f, SHORTCUT_ICON_FG_R,
+ 0f, 0f, 0f, 0f, SHORTCUT_ICON_FG_G,
+ 0f, 0f, 0f, 0f, SHORTCUT_ICON_FG_B,
+ 0f, 0f, 0f, 1f, 0f,
+ ))
+ val paint = Paint().apply {
+ // No filtering = nearest-neighbor — preserves pixel-art look at scale.
+ isFilterBitmap = false
+ colorFilter = ColorMatrixColorFilter(tint)
+ }
+ canvas.drawBitmap(source, srcRect, destRect, paint)
+ return out
+ }
+
+ fun start() {
+ targets
+ .onEach { applyShortcuts(it) }
+ .launchIn(scope)
+ }
+
+ private fun applyShortcuts(entries: List) {
+ val desiredById: Map =
+ entries.associateBy { "$SHORTCUT_ID_PREFIX${it.uuid}" }
+
+ // Drop cache entries for watchapps that are no longer share targets.
+ val keptUuids = entries.map { it.uuid }.toHashSet()
+ iconCache.keys.retainAll(keptUuids)
+
+ // Remove stale shortcuts we previously published. Other dynamic
+ // shortcuts the host app may publish are untouched.
+ val existing = ShortcutManagerCompat.getDynamicShortcuts(context)
+ val staleIds = existing
+ .map { it.id }
+ .filter { it.startsWith(SHORTCUT_ID_PREFIX) && it !in desiredById }
+ if (staleIds.isNotEmpty()) {
+ logger.d { "removing stale share shortcuts: $staleIds" }
+ ShortcutManagerCompat.removeDynamicShortcuts(context, staleIds)
+ }
+
+ for ((id, entry) in desiredById) {
+ try {
+ ShortcutManagerCompat.pushDynamicShortcut(context, buildShortcut(id, entry))
+ } catch (e: Exception) {
+ logger.w(e) { "failed to push shortcut for ${entry.uuid}" }
+ }
+ }
+ logger.d { "synced ${desiredById.size} share-target shortcut(s)" }
+ }
+
+ private fun buildShortcut(id: String, entry: ShareTargetEntry): ShortcutInfoCompat {
+ // Per Google's reference SharingShortcutsManager sample, the shortcut
+ // intent's action should be ACTION_DEFAULT (the launcher long-press
+ // path), NOT ACTION_SEND. The system constructs its own ACTION_SEND
+ // for the direct-share path, with EXTRA_SHORTCUT_ID identifying the
+ // chosen target.
+ val intent = Intent(Intent.ACTION_DEFAULT).apply {
+ component = activityClass
+ putExtra(EXTRA_WATCHAPP_UUID, entry.uuid.toString())
+ }
+
+ val label = entry.shareTarget.label?.takeIf { it.isNotBlank() } ?: entry.shortName
+ val icon = iconFor(entry)
+
+ return ShortcutInfoCompat.Builder(context, id)
+ .setShortLabel(label)
+ .setLongLabel(if (entry.longName.isNotBlank()) "Share to ${entry.longName}" else "Share to $label")
+ .setIcon(icon)
+ // setLongLived flags the shortcut as cacheable by system services
+ // even after it's unpublished — required for direct-share
+ // surfacing on Samsung OneUI and elsewhere.
+ .setLongLived(true)
+ .setRank(0)
+ .setCategories(setOf(SHORTCUT_CATEGORY))
+ .setIntent(intent)
+ .build()
+ }
+}
diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/LibPebbleModule.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/LibPebbleModule.kt
index cde0d0643..fc035ecb5 100644
--- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/LibPebbleModule.kt
+++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/LibPebbleModule.kt
@@ -304,7 +304,24 @@ private object LibPebbleKoinContext {
val koin = koinApp.koin
}
-internal interface LibPebbleKoinComponent : KoinComponent {
+/**
+ * Implement this on classes that need to inject from libpebble3's isolated
+ * Koin context. Required for any class that lives outside the libpebble3
+ * module (e.g. activities, services in the host app) but needs to resolve
+ * libpebble3-internal types like [io.rebble.libpebblecommon.shareintent.ShareIntentDispatcher]
+ * or [io.rebble.libpebblecommon.connection.LibPebble].
+ *
+ * libpebble3 uses an isolated Koin context (see [LibPebbleKoinContext]) to
+ * avoid colliding with host app modules. The default `org.koin.android.ext.android.inject`
+ * extensions on [android.app.Activity] / [android.app.Service] resolve from
+ * the GLOBAL Koin context and won't find libpebble3 types — those classes
+ * should implement this interface and use Koin's `KoinComponent.inject()`
+ * instead.
+ *
+ * Internal libpebble3 classes can also implement this; for them it's just a
+ * convenience that ensures the right context.
+ */
+interface LibPebbleKoinComponent : KoinComponent {
override fun getKoin(): Koin = LibPebbleKoinContext.koin
}
@@ -323,6 +340,8 @@ fun initKoin(
listOf(
module {
includes(platformModule, pkjsPlatformModule)
+ includes(shareIntentModule)
+ includes(watchappNotificationModule)
single { LibPebbleConfigHolder(defaultValue = defaultConfig, get(), get()) }
single { LibPebbleConfigFlow(get().config) }
diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/ShareIntentModule.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/ShareIntentModule.kt
new file mode 100644
index 000000000..d9d8f2c51
--- /dev/null
+++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/ShareIntentModule.kt
@@ -0,0 +1,24 @@
+package io.rebble.libpebblecommon.di
+
+import io.rebble.libpebblecommon.shareintent.ShareIntentDispatcher
+import io.rebble.libpebblecommon.shareintent.ShareTargetsProducer
+import io.rebble.libpebblecommon.shareintent.ShareUrlResolver
+import org.koin.core.module.dsl.singleOf
+import org.koin.dsl.module
+
+/**
+ * Singletons for the share-intent platform API (PR 1).
+ *
+ * Platform-specific glue (e.g. Android's `ShareTargetSync`) is registered in
+ * the host application's Android/iOS Koin module, since it needs the host's
+ * activity [android.content.ComponentName] and resource ids.
+ */
+val shareIntentModule = module {
+ singleOf(::ShareIntentDispatcher)
+ singleOf(::ShareTargetsProducer)
+ // ShareUrlResolver gets its own HttpClient instance rather than the
+ // libpebble3 singleton — see class kdoc for reasoning. The no-arg
+ // primary constructor builds an OkHttp client with engine-level
+ // timeouts; the secondary internal constructor is for tests only.
+ single { ShareUrlResolver() }
+}
diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/WatchappNotificationModule.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/WatchappNotificationModule.kt
new file mode 100644
index 000000000..29bf9c5d0
--- /dev/null
+++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/WatchappNotificationModule.kt
@@ -0,0 +1,20 @@
+package io.rebble.libpebblecommon.di
+
+import io.rebble.libpebblecommon.notification.WatchappNotificationDispatcher
+import org.koin.core.module.dsl.singleOf
+import org.koin.dsl.module
+
+/**
+ * Singleton for the watchapp notification subscription API (PR 2).
+ *
+ * The Android-side `LibPebbleNotificationListener` injects this and calls
+ * `dispatch()` on every posted/removed notification. The dispatcher is a
+ * lightweight router that consults `LibPebble.watches` for currently-running
+ * PKJS apps and delivers to those whose `notificationFilter` matches.
+ *
+ * No platform-specific glue — works the same for any platform that wires a
+ * notification source to call `dispatch(packageName, notificationJson)`.
+ */
+val watchappNotificationModule = module {
+ singleOf(::WatchappNotificationDispatcher)
+}
diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/disk/pbw/DiskUtil.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/disk/pbw/DiskUtil.kt
index 2f4e8e458..9cf8b6f63 100644
--- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/disk/pbw/DiskUtil.kt
+++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/disk/pbw/DiskUtil.kt
@@ -9,6 +9,7 @@ import kotlinx.io.Source
import kotlinx.io.buffered
import kotlinx.io.files.Path
import kotlinx.io.okio.asKotlinxIoRawSource
+import kotlinx.io.readByteArray
import kotlinx.io.readString
import kotlinx.serialization.json.Json
import okio.FileSystem
@@ -19,6 +20,7 @@ import okio.openZip
object DiskUtil {
private const val MANIFEST_FILENAME = "manifest.json"
private const val APPINFO_FILENAME = "appinfo.json"
+ private const val RESOURCE_PACK_FILENAME = "app_resources.pbpack"
private val pbwJson = Json {
ignoreUnknownKeys = true
isLenient = true
@@ -84,4 +86,48 @@ object DiskUtil {
}.buffered()
return source
}
-}
\ No newline at end of file
+
+ /**
+ * Read an arbitrary file from the .pbw zip at the given root-relative
+ * path. Returns null if the file isn't present in the zip — common for
+ * resources that are compiled into `app_resources.pbpack` rather than
+ * included as raw source files.
+ *
+ * Used by share-target icon extraction: some toolchains include the
+ * original menu-icon PNG at the zip root in addition to the compiled
+ * resource pack; if so, we can use it directly without having to parse
+ * the resource pack.
+ */
+ fun readPbwResourceFileOrNull(pbwPath: Path, resourceFilePath: String): ByteArray? {
+ return try {
+ val zip = openZip(pbwPath)
+ val path = resourceFilePath.toPath()
+ if (!zip.exists(path)) return null
+ zip.source(path).asKotlinxIoRawSource().buffered().use { src ->
+ src.readByteArray()
+ }
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ /**
+ * Read the entire `app_resources.pbpack` for a given watch type, or
+ * null if absent. The bytes returned are the raw `.pbpack` binary,
+ * suitable for parsing with [PbwResourcePack.extractResource].
+ *
+ * Used by share-target icon extraction when the source PNG isn't
+ * present at its declared path inside the .pbw (the typical case for
+ * PBWs built by the standard SDK / CloudPebble — those compile all
+ * media into the resource pack and don't ship raw sources).
+ */
+ fun readPbwResourcePackBytesOrNull(pbwPath: Path, watchType: WatchType): ByteArray? {
+ return try {
+ requirePbwBinaryBlob(pbwPath, watchType, RESOURCE_PACK_FILENAME).use { src ->
+ src.readByteArray()
+ }
+ } catch (e: Exception) {
+ null
+ }
+ }
+}
diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/disk/pbw/PbwResourcePack.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/disk/pbw/PbwResourcePack.kt
new file mode 100644
index 000000000..e0711a410
--- /dev/null
+++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/disk/pbw/PbwResourcePack.kt
@@ -0,0 +1,86 @@
+package io.rebble.libpebblecommon.disk.pbw
+
+/**
+ * Reader for Pebble's `.pbpack` resource pack format.
+ *
+ * A `.pbpack` is a flat binary file shipped at `/app_resources.pbpack`
+ * inside every `.pbw`. It bundles the watchapp's compiled resources (PNGs,
+ * fonts, raw blobs) into a single file that the watch firmware mounts and
+ * indexes by integer resource id.
+ *
+ * Format (all little-endian):
+ *
+ * ```
+ * [0..12) manifest: uint32 numFiles, uint32 contentCrc, uint32 timestamp
+ * [12..4108) table: 256 × { uint32 fileId, uint32 offset, uint32 length, uint32 crc }
+ * fileId=0 marks unused slots after numFiles real entries
+ * [4108..) contents: resource bytes, packed back-to-back; entry.offset is
+ * relative to this contents region, NOT the file start
+ * ```
+ *
+ * The fileId field counts up from 1 for the first valid entry. Some
+ * historical `.pbpack`s reuse content (multiple table entries pointing at
+ * the same offset), so the table is the authoritative ordering — content
+ * itself is just a byte pool.
+ *
+ * Format reference: https://github.com/Snack-X/pebble-language-pack/blob/master/sdk/pbpack.py
+ *
+ * This reader is **best-effort and read-only**: it skips the per-entry CRC
+ * validation (the watch firmware verifies on load; we just want the bytes
+ * for display purposes) and returns null on any parse error rather than
+ * throwing. Callers that need integrity guarantees should layer that on
+ * separately.
+ */
+internal object PbwResourcePack {
+ private const val MANIFEST_SIZE = 12
+ private const val TABLE_ENTRY_SIZE = 16
+ private const val MAX_NUM_FILES = 256
+ private const val CONTENT_START = MANIFEST_SIZE + (MAX_NUM_FILES * TABLE_ENTRY_SIZE) // 4108
+
+ /**
+ * Returns the raw bytes of the resource at [resourceIndex] (0-based,
+ * matching the order resources are declared in `appinfo.json`'s
+ * `resources.media` array). Returns null when:
+ * - the pack is too small / truncated
+ * - the manifest's numFiles is zero or out of range
+ * - the requested index is past the end of the resource list
+ * - the entry's offset/length lies outside the file
+ *
+ * Does NOT verify the per-entry CRC — see class doc.
+ */
+ fun extractResource(packBytes: ByteArray, resourceIndex: Int): ByteArray? {
+ if (resourceIndex < 0) return null
+ if (packBytes.size < CONTENT_START) return null
+
+ val numFiles = readU32LE(packBytes, 0).toInt()
+ if (numFiles <= 0 || numFiles > MAX_NUM_FILES) return null
+ if (resourceIndex >= numFiles) return null
+
+ val tableEntryStart = MANIFEST_SIZE + (resourceIndex * TABLE_ENTRY_SIZE)
+ // Bounds check: the table entry itself must be within the table region.
+ if (tableEntryStart + TABLE_ENTRY_SIZE > CONTENT_START) return null
+
+ val fileId = readU32LE(packBytes, tableEntryStart).toInt()
+ // fileId == 0 marks the end of the populated table; treat as missing.
+ if (fileId == 0) return null
+
+ val relativeOffset = readU32LE(packBytes, tableEntryStart + 4).toInt()
+ val length = readU32LE(packBytes, tableEntryStart + 8).toInt()
+ if (length < 0 || relativeOffset < 0) return null
+
+ val absoluteOffset = CONTENT_START + relativeOffset
+ if (absoluteOffset + length > packBytes.size) return null
+
+ return packBytes.copyOfRange(absoluteOffset, absoluteOffset + length)
+ }
+
+ private fun readU32LE(bytes: ByteArray, offset: Int): UInt {
+ // Manual little-endian decode keeps this kotlinx-io / okio independent
+ // and works identically across all KMP targets.
+ val b0 = bytes[offset].toUByte().toUInt()
+ val b1 = bytes[offset + 1].toUByte().toUInt()
+ val b2 = bytes[offset + 2].toUByte().toUInt()
+ val b3 = bytes[offset + 3].toUByte().toUInt()
+ return b0 or (b1 shl 8) or (b2 shl 16) or (b3 shl 24)
+ }
+}
diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/js/JsRunner.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/js/JsRunner.kt
index 9a12369f0..776444577 100644
--- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/js/JsRunner.kt
+++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/js/JsRunner.kt
@@ -27,6 +27,28 @@ abstract class JsRunner(
abstract suspend fun signalReady()
abstract suspend fun signalShowConfiguration()
abstract suspend fun signalWebviewClosed(data: String?)
+ /**
+ * Fired when the OS routes a shared payload (e.g. an Android `ACTION_SEND`)
+ * to this watchapp. The watchapp must declare itself as a share target via
+ * `shareTarget` in `package.json` to receive these events.
+ *
+ * @param text The shared text or URL string. Always present.
+ * @param url Best-effort extraction of a URL from [text], or null if none.
+ * @param subject Optional subject (e.g. Android `EXTRA_SUBJECT`).
+ */
+ abstract suspend fun signalShareIntent(text: String, url: String?, subject: String?)
+ /**
+ * Fired when the OS surfaces a notification from a package that this
+ * watchapp's [PbwAppInfo.notificationFilter] subscribes to, while this
+ * watchapp's PKJS is running. The payload is a JSON string the bootstrap
+ * `startup.js` parses and dispatches via `'appnotification'` events.
+ *
+ * @param notificationJson Pre-serialized notification payload. Layout:
+ * `{ "package": String, "posted": Boolean, "key": String, "postTime": Long,
+ * "category": String?, "title": String?, "text": String?,
+ * "subText": String?, "extras": { ... raw extras keys ... } }`
+ */
+ abstract suspend fun signalAppNotification(notificationJson: String)
abstract suspend fun eval(js: String)
abstract suspend fun evalWithResult(js: String): Any?
abstract fun debugForceGC()
diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/js/PKJSApp.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/js/PKJSApp.kt
index 290aff272..0fab828f7 100644
--- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/js/PKJSApp.kt
+++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/js/PKJSApp.kt
@@ -13,6 +13,7 @@ import io.rebble.libpebblecommon.services.appmessage.AppMessageResult
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
@@ -20,8 +21,12 @@ import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@@ -62,6 +67,21 @@ class PKJSApp(
): LibPebbleKoinComponent, CompanionApp {
companion object {
private val logger = Logger.withTag(PKJSApp::class.simpleName!!)
+ /**
+ * Maximum wait for [awaitWatchAppReady] to observe a first inbound
+ * appMessage from the watchapp. Sized to give a cold-launching
+ * watchapp time to finish its C-side init() and announce itself,
+ * without keeping the user waiting unreasonably if the watchapp
+ * happens not to message PKJS at startup.
+ *
+ * Talkative watchapps (those that send anything to PKJS during
+ * init — most nontrivial watchapps do) will hit the signal well
+ * inside this window. Quiet watchapps that don't message PKJS at
+ * startup will hit the timeout and have any pending share intent
+ * delivered without a perfect "ready" signal — same behavior as
+ * before this state existed, so no regression.
+ */
+ val WATCHAPP_READY_TIMEOUT = 12.seconds
}
val uuid: Uuid by lazy { Uuid.parse(appInfo.uuid) }
private var jsRunner: JsRunner? = null
@@ -72,6 +92,63 @@ class PKJSApp(
val logMessages: ReceiveChannel = _logMessages
val sessionIsReady get() = jsRunner?.readyState?.value ?: false
+ /**
+ * Observable form of [sessionIsReady]. Emits the current PKJS readiness
+ * state and any future transitions. Stays at `false` while [jsRunner]
+ * is null (before [start]); after start, mirrors the runner's own
+ * readyState transitions.
+ *
+ * Useful for dispatchers that need to observe the false→true transition
+ * rather than just sample current state. The pre-existing
+ * `sessionIsReady` getter samples once and misses transitions if
+ * polled before the runner exists or before its state flips.
+ */
+ private val _jsRunnerExists = MutableStateFlow(false)
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val sessionReadyFlow: Flow = _jsRunnerExists
+ .flatMapLatest { exists ->
+ if (!exists) flowOf(false)
+ else jsRunner?.readyState ?: flowOf(false)
+ }
+
+ /**
+ * Flips to `true` the first time the watchapp on-watch sends an
+ * appMessage to PKJS. Stays true for the lifetime of this PKJSApp.
+ *
+ * This is a more reliable "watchapp is fully ready" signal than
+ * [sessionIsReady]: the latter only confirms the JS runtime has
+ * started, not that the watch's C-side init() has completed and
+ * subscribed to inbox messages. Without waiting for this, share
+ * intent dispatches that fire CMD_PHONE_NAV_START can race the
+ * watchapp's init and get dropped.
+ *
+ * "First message received" is a reasonable proxy because: a
+ * watchapp that's sending us a message has clearly registered its
+ * inbox subscription on the watch side, which is what we need.
+ * Watchapps that never message PKJS at startup don't get the
+ * benefit of this signal — for them, the dispatcher should fall
+ * back to a timeout.
+ */
+ private val _firstWatchMessageReceived = MutableStateFlow(false)
+ val firstWatchMessageReceived: StateFlow = _firstWatchMessageReceived
+
+ /**
+ * Suspend until either:
+ * - the watchapp sends its first inbound appMessage (returns true), or
+ * - [WATCHAPP_READY_TIMEOUT] elapses (returns false).
+ *
+ * Use this before delivering time-sensitive PKJS events (e.g. share
+ * intents that translate into appMessages back to the watch) when
+ * cold-starting the watchapp. If the watchapp was already running,
+ * the state will likely already be true and this returns instantly.
+ */
+ suspend fun awaitWatchAppReady(): Boolean {
+ if (_firstWatchMessageReceived.value) return true
+ return withTimeoutOrNull(WATCHAPP_READY_TIMEOUT) {
+ _firstWatchMessageReceived.first { it }
+ } ?: false
+ }
+
private suspend fun replyNACK(id: UByte) {
withTimeoutOrNull(1000) {
device.sendAppMessageResult(AppMessageResult.ACK(id))
@@ -90,6 +167,13 @@ class PKJSApp(
private fun launchIncomingAppMessageHandler(messages: Flow, scope: CoroutineScope) {
messages.onEach { appMessageData ->
+ // First inbound message from the watchapp signals that its
+ // C-side init() has completed and its inbox subscription is
+ // active — i.e. it's safe to push events that translate into
+ // outbound appMessages. See [firstWatchMessageReceived] kdoc.
+ if (!_firstWatchMessageReceived.value) {
+ _firstWatchMessageReceived.value = true
+ }
jsRunner?.let { runner ->
if (!runner.readyState.value) {
logger.w { "JsRunner not ready, waiting" }
@@ -180,6 +264,11 @@ class PKJSApp(
val scope = connectionScope + Job() + CoroutineName("PKJSApp-$uuid") + exceptionHandler
runningScope = scope
jsRunner = injectJsRunner(scope)
+ // Toggle the gate that lets [sessionReadyFlow] start observing the
+ // newly-created runner's readyState. Without this, observers
+ // attached before start() would be stuck on the initial false
+ // emission and never re-evaluate when the runner appears.
+ _jsRunnerExists.value = true
launchIncomingAppMessageHandler(incomingAppMessages, scope)
launchOutgoingAppMessageHandler(device, scope)
jsRunner?.start() ?: error("JsRunner not initialized")
@@ -189,6 +278,10 @@ class PKJSApp(
jsRunner?.stop()
runningScope?.cancel()
jsRunner = null
+ _jsRunnerExists.value = false
+ // Reset the watch-ready signal so a future restart doesn't
+ // observe a stale "ready" from a prior session.
+ _firstWatchMessageReceived.value = false
}
fun triggerOnWebviewClosed(data: String) {
@@ -196,6 +289,31 @@ class PKJSApp(
jsRunner?.signalWebviewClosed(data)
}
}
+
+ /**
+ * Deliver a share intent to this watchapp's PKJS. Caller must ensure the
+ * runner has reached ready state (see [sessionIsReady]) before invoking,
+ * otherwise the event will be dispatched into a JS context that has no
+ * registered listeners yet.
+ */
+ fun triggerOnShareIntent(text: String, url: String?, subject: String?) {
+ runningScope?.launch {
+ jsRunner?.signalShareIntent(text, url, subject)
+ }
+ }
+
+ /**
+ * Deliver a notification payload to this watchapp's PKJS. Caller has
+ * already verified that the source package is in [PbwAppInfo.notificationFilter].
+ * No-op if the runner isn't initialized; the dispatch will simply be
+ * dropped (acceptable — the watchapp will pull current state via
+ * `Pebble.getActiveNotifications` on next ready).
+ */
+ fun triggerOnAppNotification(notificationJson: String) {
+ runningScope?.launch {
+ jsRunner?.signalAppNotification(notificationJson)
+ }
+ }
}
fun AppMessageDictionary.toJSData(appKeys: Map): String {
diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/js/PKJSInterface.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/js/PKJSInterface.kt
index 9f2a0298b..6dd865c7c 100644
--- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/js/PKJSInterface.kt
+++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/js/PKJSInterface.kt
@@ -71,4 +71,21 @@ abstract class PKJSInterface(
}
return url
}
+
+ /**
+ * Returns currently-posted notifications matching the watchapp's
+ * declared [io.rebble.libpebblecommon.metadata.pbw.appinfo.PbwAppInfo.notificationFilter],
+ * narrowed by [packageFilter] if non-blank. Used by watchapps to catch
+ * up on existing notification state when their PKJS spins up — otherwise
+ * the watchapp only sees notifications that arrive *after* it starts.
+ *
+ * Returns a JSON-encoded array string with the same per-notification
+ * shape as the `'appnotification'` event payload (without the `posted`
+ * field, which is implicitly true for currently-active notifications).
+ * Returns "[]" if the platform has no live-notification source (iOS today).
+ *
+ * @param packageFilter Optional comma-separated list of package names.
+ * Empty / blank = use the watchapp's full declared filter.
+ */
+ abstract fun getActiveNotifications(packageFilter: String): String
}
diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/metadata/pbw/appinfo/PbwAppInfo.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/metadata/pbw/appinfo/PbwAppInfo.kt
index bc46b70d8..fb5a86d9f 100644
--- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/metadata/pbw/appinfo/PbwAppInfo.kt
+++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/metadata/pbw/appinfo/PbwAppInfo.kt
@@ -18,4 +18,35 @@ data class PbwAppInfo(
val targetPlatforms: List = listOf("aplite"),
val watchapp: Watchapp = Watchapp(),
val companionApp: CompanionApp? = null,
+ /**
+ * If present, this watchapp is registered as an OS-level share target.
+ * On Android this surfaces the watchapp as a Sharing Shortcut in the
+ * system share sheet; tapping it routes the shared payload to the
+ * watchapp's PKJS via a `'shareintent'` event.
+ */
+ val shareTarget: ShareTarget? = null,
+ /**
+ * Android package names this watchapp wishes to receive notifications
+ * from. Notifications posted by any of these packages are dispatched to
+ * the watchapp's PKJS via an `'appnotification'` event whenever the
+ * watchapp is the active foreground app on the watch (i.e. PKJS is
+ * running). The user must have already granted notification access to
+ * the Pebble app itself; no per-watchapp consent is required because the
+ * declaration in `package.json` is treated as the consent moment.
+ *
+ * Empty / absent means this watchapp does not receive notifications.
+ */
+ val notificationFilter: List = emptyList(),
+)
+
+/**
+ * Declares that the containing watchapp can receive shared content from
+ * other apps. Read from `package.json` at PBW build time.
+ */
+@Serializable
+data class ShareTarget(
+ /** MIME types the watchapp accepts. Currently only "text/plain" is honored. */
+ val mimeTypes: List = listOf("text/plain"),
+ /** Optional display label override; defaults to [PbwAppInfo.shortName]. */
+ val label: String? = null,
)
diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/notification/WatchappNotificationDispatcher.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/notification/WatchappNotificationDispatcher.kt
new file mode 100644
index 000000000..764b53d5e
--- /dev/null
+++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/notification/WatchappNotificationDispatcher.kt
@@ -0,0 +1,68 @@
+package io.rebble.libpebblecommon.notification
+
+import co.touchlab.kermit.Logger
+import io.rebble.libpebblecommon.connection.ConnectedPebbleDevice
+import io.rebble.libpebblecommon.connection.LibPebble
+import io.rebble.libpebblecommon.js.PKJSApp
+
+/**
+ * Fans notifications received by the platform notification listener out to
+ * any currently-running PKJS watchapp whose
+ * [io.rebble.libpebblecommon.metadata.pbw.appinfo.PbwAppInfo.notificationFilter]
+ * subscribes to the source package.
+ *
+ * Foreground-only by design: PKJS only exists for the watchapp currently
+ * running on the watch, so notifications only flow while the user has the
+ * watchapp open. Watchapps catch up on existing state via
+ * `Pebble.getActiveNotifications` when they spin up.
+ *
+ * Consent model: a watchapp's declaration in `package.json` is the consent
+ * signal. The user explicitly installed a watchapp that announced it would
+ * read these notifications; no separate per-watchapp prompt is needed. The
+ * Pebble app's existing notification access grant (a single OS-level
+ * permission already granted by the user) is the only OS-level gate.
+ */
+class WatchappNotificationDispatcher(
+ private val libPebble: LibPebble,
+) {
+ companion object {
+ private val logger = Logger.withTag(WatchappNotificationDispatcher::class.simpleName!!)
+ }
+
+ /**
+ * @param packageName Source package of the notification.
+ * @param notificationJson Pre-serialized payload (Android: built by
+ * [WatchappNotificationSerializer]; iOS: TBD when iOS receiving side
+ * lands).
+ */
+ fun dispatch(packageName: String, notificationJson: String) {
+ val targets = currentSubscribedApps(packageName)
+ if (targets.isEmpty()) return
+ logger.v { "dispatching $packageName notification to ${targets.size} watchapp(s)" }
+ for (app in targets) {
+ try {
+ app.triggerOnAppNotification(notificationJson)
+ } catch (e: Exception) {
+ logger.w(e) { "failed to deliver notification to ${app.uuid}" }
+ }
+ }
+ }
+
+ /**
+ * Find currently-running PKJS watchapps that subscribe to [packageName].
+ * Cheap to call on every notification — typically O(connected_watches)
+ * with usually 1 watch and 1 running PKJS.
+ */
+ private fun currentSubscribedApps(packageName: String): List {
+ return libPebble.watches.value
+ .asSequence()
+ .filterIsInstance()
+ .flatMap { it.currentCompanionAppSessions.value.asSequence() }
+ .filterIsInstance()
+ .filter { it.subscribesTo(packageName) }
+ .toList()
+ }
+}
+
+private fun PKJSApp.subscribesTo(packageName: String): Boolean =
+ appInfo.notificationFilter.contains(packageName)
diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareIntentDispatcher.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareIntentDispatcher.kt
new file mode 100644
index 000000000..eb8949da2
--- /dev/null
+++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareIntentDispatcher.kt
@@ -0,0 +1,324 @@
+package io.rebble.libpebblecommon.shareintent
+
+import co.touchlab.kermit.Logger
+import io.rebble.libpebblecommon.connection.ConnectedPebbleDevice
+import io.rebble.libpebblecommon.connection.LibPebble
+import io.rebble.libpebblecommon.di.LibPebbleCoroutineScope
+import io.rebble.libpebblecommon.js.PKJSApp
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlin.time.Duration.Companion.seconds
+import kotlin.uuid.Uuid/**
+ * Orchestrates routing an OS-level share intent to the watchapp that
+ * declared itself as a share target.
+ *
+ * The flow is:
+ * 1. Ask the watch to bring the target app to the foreground (this triggers
+ * [io.rebble.libpebblecommon.connection.endpointmanager.CompanionAppLifecycleManager]
+ * to spin up the app's PKJS instance, if it isn't already running).
+ * 2. Wait for that watchapp's PKJS to reach ready state on any connected watch.
+ * 3. Deliver the share payload via the new `'shareintent'` PKJS event.
+ *
+ * Callers may use [enqueue] for fire-and-forget dispatch (typical for an
+ * Activity that finishes immediately), or [dispatch] when they need the
+ * boolean success/failure result for UI feedback. Both ultimately run on
+ * the application-scoped [scope] so that activity death doesn't cancel the
+ * launch+wait flow.
+ */
+class ShareIntentDispatcher(
+ private val libPebble: LibPebble,
+ private val scope: LibPebbleCoroutineScope,
+ private val producer: ShareTargetsProducer,
+ private val urlResolver: ShareUrlResolver,
+) {
+ /**
+ * Snapshot of the producer's most recent emission, available
+ * synchronously via [.value]. Used by [enqueueForFirstAvailable] which
+ * runs on the activity main thread and can't suspend to wait for an
+ * emission.
+ *
+ * Note that [producer.flow] is itself a shared flow (single upstream
+ * collection across all subscribers), so wrapping it in an additional
+ * StateFlow here doesn't multiply I/O. We just need [StateFlow.value]
+ * for the synchronous read.
+ */
+ private val cachedTargets: StateFlow> =
+ producer.flow.stateIn(
+ scope = scope,
+ started = SharingStarted.Eagerly,
+ initialValue = emptyList(),
+ )
+
+ /**
+ * Read-only view of the currently registered share-capable watchapps.
+ * Suitable for the host app's share-target activity to drive a chooser
+ * UI when the user picks the static "Pebble" entry rather than a
+ * per-watchapp Sharing Shortcut.
+ */
+ val availableTargets: StateFlow> = cachedTargets
+
+ companion object {
+ private val logger = Logger.withTag(ShareIntentDispatcher::class.simpleName!!)
+ /**
+ * Maximum wait for the target watchapp's PKJS to become ready after
+ * a share intent dispatch.
+ *
+ * Sized for the cold-start case: when the share intent triggers a
+ * fresh watchapp launch, the path is:
+ *
+ * Bluetooth roundtrip (launch command) → watch firmware launches
+ * the watchapp → watch C-side init() runs → watch announces app
+ * start to companion → companion app spawns PKJS runtime → PKJS
+ * evaluates index.js → PKJS initialization completes → ready.
+ *
+ * This sequence takes 15-25 seconds on real hardware in our testing
+ * (varies with BT signal quality and watch model). 30 seconds is a
+ * conservative ceiling that covers the 95th percentile without
+ * keeping the user waiting an unreasonable time on genuine failure.
+ *
+ * If the watchapp was already running before the share fired, PKJS
+ * is usually ready in <1s — this timeout is the cold-start budget.
+ */
+ val SHARE_LAUNCH_TIMEOUT = 30.seconds
+ }
+
+ /**
+ * Fire-and-forget. Returns immediately; the launch/wait/signal flow
+ * runs on [scope]. Failures are logged but not surfaced — use [dispatch]
+ * if the caller needs to react to the outcome.
+ */
+ fun enqueue(uuid: Uuid, text: String, url: String? = null, subject: String? = null) {
+ scope.launch {
+ val ok = dispatch(uuid, text, url, subject)
+ if (!ok) {
+ logger.w { "fire-and-forget share dispatch for $uuid did not deliver" }
+ }
+ }
+ }
+
+ /**
+ * Fallback path for when an [Activity] receives an ACTION_SEND with no
+ * specific watchapp UUID — typically because the user picked the static
+ * manifest-level share entry rather than one of the per-watchapp dynamic
+ * Sharing Shortcuts (e.g. shortcuts haven't surfaced yet, or the OS chose
+ * to display only the activity-level entry).
+ *
+ * Looks at the current set of share-target watchapps from [producer]:
+ * - 0 apps declare shareTarget → returns false; caller surfaces error
+ * - 1+ apps → dispatches to the first match
+ *
+ * V1 behavior: with multiple share-target watchapps installed, we don't
+ * show a chooser — we just pick the first. The user can use the dynamic
+ * Sharing Shortcut entries (which DO carry a UUID) when they want to
+ * pick a specific watchapp. A future iteration could surface a real
+ * chooser activity here.
+ *
+ * Returns true if a dispatch was scheduled, false if no eligible
+ * watchapp could be found.
+ */
+ fun enqueueForFirstAvailable(text: String, url: String? = null, subject: String? = null): Boolean {
+ // Read the cached snapshot. No re-subscription, no zip re-opens,
+ // no file descriptor pressure on the share-intent hot path.
+ //
+ // Failure mode: if the dispatcher was constructed less than a
+ // second ago and the producer hasn't emitted yet, we'll see an
+ // empty list. That's the cold-start-after-force-stop case. The
+ // caller surfaces "no watchapp set up to receive shares" — which
+ // is preferable to blocking the activity main thread for several
+ // seconds via runBlocking. The user can retry the share once the
+ // app is fully spun up.
+ val targets = cachedTargets.value
+ if (targets.isEmpty()) {
+ logger.w {
+ "no watchapp declares shareTarget (cached snapshot empty; " +
+ "may be cold start with locker not yet loaded)"
+ }
+ return false
+ }
+ val first = targets.first()
+ logger.i {
+ "fallback dispatch (no UUID in intent): picking first of " +
+ "${targets.size} share-target watchapp(s) → ${first.uuid} (${first.shortName})"
+ }
+ enqueue(first.uuid, text, url, subject)
+ return true
+ }
+
+ /**
+ * Suspending dispatch. @return true if the share was delivered, false on
+ * timeout / no watch / no connected device able to run the watchapp.
+ *
+ * Note: even when called from a caller-supplied coroutine context, the
+ * actual await runs on [scope] — the result is reported back via a
+ * completion channel. This protects us against premature cancellation
+ * if the caller (e.g. an Activity) dies during the wait.
+ */
+ @OptIn(ExperimentalCoroutinesApi::class)
+ suspend fun dispatch(
+ uuid: Uuid,
+ text: String,
+ url: String? = null,
+ subject: String? = null,
+ ): Boolean = coroutineScope {
+ logger.d { "dispatch share intent for $uuid" }
+
+ // Step 1: ensure the target watchapp is running on at least one
+ // connected watch.
+ //
+ // Subtle but important: only call launchApp if the target ISN'T
+ // already running. Calling launchApp on the active watchapp causes
+ // the watch firmware to send an AppRunStateStop+AppRunStateStart
+ // pair, which the companion treats as "app changed" and tears down
+ // / re-creates the PKJSApp's WebView. That re-creation:
+ // - cancels any in-flight tile / resource loads from `new Image()`
+ // - clears the JS-side state including any "this tile is
+ // pending" guard flags watchapps use to deduplicate work
+ // - destroys WebKit's image cache (clearCache(true) in stop())
+ // The user-visible effect is "tiles stop loading after a share" —
+ // because the watchapp's PKJS state was reset mid-flight while the
+ // watch firmware is still asking for tiles via the old session.
+ //
+ // For the share-intent UX, the only thing we need launchApp to
+ // achieve is "make sure the target watchapp is foreground." If it
+ // already is, the call is unnecessary — and harmful, per above.
+ val alreadyRunning = libPebble.watches.value
+ .filterIsInstance()
+ .any { it.runningApp.value == uuid }
+ if (!alreadyRunning) {
+ logger.d { "launching $uuid (not currently running)" }
+ libPebble.launchApp(uuid)
+ } else {
+ logger.d { "$uuid already running on at least one watch; skipping launch" }
+ }
+
+ // Step 2: in parallel with the watch launch + PKJS wait, kick off
+ // URL resolution. Most maps shares from Android arrive as
+ // `maps.app.goo.gl/...` short URLs that don't contain destination
+ // info; resolving here (with a real HTTP client + browser headers)
+ // gets the long form. PKJS-side XHR resolution doesn't work because
+ // Firebase Dynamic Links serves an HTTP 403 to WebView XHR
+ // requests. See ShareUrlResolver for full reasoning.
+ //
+ // Structured under coroutineScope so if dispatch() times out or is
+ // cancelled, the resolver cancels deterministically with it (no
+ // orphan HTTP requests holding the shared client's connection
+ // pool). Earlier versions used scope.async (application scope)
+ // which decoupled lifetime — that left in-flight resolutions
+ // running even after the dispatch timed out, contributing to load
+ // on the shared httpClient.
+ //
+ // Running concurrently with the launch+wait means the resolution
+ // overlaps with Bluetooth roundtrips and PKJS spinup, which usually
+ // exceed the resolver's <500ms typical case. Net-zero added latency
+ // most of the time.
+ val resolvedUrlDeferred = async { urlResolver.resolveIfShortened(url ?: "") }
+
+ // Step 3: wait for *some* connected watch to expose a fully-ready
+ // PKJSApp matching this uuid.
+ //
+ // "Fully ready" combines two signals:
+ // - PKJSApp.firstWatchMessageReceived: the watch's C-side init()
+ // has finished and its inbox subscription is active. This is
+ // the ideal signal — it proves the watchapp can receive
+ // events the dispatcher will trigger.
+ // - PKJSApp.sessionReadyFlow: the JS runtime is up. Weaker signal
+ // but the only thing available for "quiet" watchapps that
+ // don't message PKJS at startup.
+ //
+ // We prefer firstWatchMessageReceived when we can get it, but
+ // fall back to sessionReadyFlow after WATCHAPP_READY_TIMEOUT so
+ // quiet watchapps still get their share intents.
+ //
+ // Why we can't just observe `sessionIsReady` like the original
+ // code: currentCompanionAppSessions is a Flow>
+ // — it emits when the *list* of sessions changes (apps added or
+ // removed), not when an existing PKJSApp's internal state
+ // transitions. A cold-start dispatch sees the session added to
+ // the list while still initializing, the predicate sampling
+ // sessionIsReady returns false on that emission, and no further
+ // emission ever fires when the app actually becomes ready. The
+ // wait then hits SHARE_LAUNCH_TIMEOUT (30s) and gives up.
+ //
+ // Two-step structure:
+ // (a) find the PKJSApp by uuid via the session-list flow
+ // (b) once found, wait on its own readiness flows for state
+ // transitions
+ val readyApp: PKJSApp? = withTimeoutOrNull(SHARE_LAUNCH_TIMEOUT) {
+ // (a) Find the PKJSApp matching our uuid. The flatMapLatest
+ // chain rebuilds the merged session flow if connected watches
+ // change mid-wait. We only need *the* matching PKJSApp here,
+ // not its readiness state.
+ val app = libPebble.watches
+ .flatMapLatest { devices ->
+ val sessionFlows = devices
+ .filterIsInstance()
+ .map { it.currentCompanionAppSessions }
+ if (sessionFlows.isEmpty()) {
+ flowOf(null)
+ } else {
+ combine(sessionFlows) { sessionsByDevice ->
+ sessionsByDevice
+ .asSequence()
+ .flatten()
+ .filterIsInstance()
+ .firstOrNull { it.uuid == uuid }
+ }
+ }
+ }
+ .first { it != null }!!
+
+ // (b) Wait for the strict signal first.
+ val gotWatchSignal = withTimeoutOrNull(PKJSApp.WATCHAPP_READY_TIMEOUT) {
+ app.firstWatchMessageReceived.first { it }
+ } != null
+
+ if (gotWatchSignal) {
+ app
+ } else {
+ logger.w {
+ "watchapp ready signal not received within " +
+ "${PKJSApp.WATCHAPP_READY_TIMEOUT}; falling back to " +
+ "PKJS-ready (watchapp may miss the event)"
+ }
+ // Fall back: wait for PKJS readiness alone. Bounded
+ // implicitly by the outer SHARE_LAUNCH_TIMEOUT.
+ app.sessionReadyFlow.first { it }
+ app
+ }
+ }
+
+ if (readyApp != null) {
+ // Wait for the in-flight URL resolution. resolveIfShortened's
+ // own timeout is shorter than this, so by the time PKJS+watch
+ // are ready we've almost always got a result.
+ val resolvedUrl = resolvedUrlDeferred.await().takeIf { it.isNotEmpty() }
+ // Pass the *resolved* URL as the url field. The text field stays
+ // as the user originally shared it (which may be the short URL,
+ // possibly with surrounding text from sharing-app prefixes).
+ // PKJS receives both: it should prefer `url` for parsing, fall
+ // back to `text` extraction if `url` is empty/unset.
+ readyApp.triggerOnShareIntent(text, resolvedUrl ?: url, subject)
+ logger.i { "delivered share intent to $uuid (url resolved: ${resolvedUrl != url})" }
+ true
+ } else {
+ // Cancel the in-flight resolution. Structured coroutineScope
+ // would also cancel it on dispatch return, but explicit cancel
+ // here releases the slot in the resolver's underlying HTTP
+ // request immediately rather than at scope exit.
+ resolvedUrlDeferred.cancel()
+ logger.w { "share intent for $uuid timed out waiting for PKJS ready" }
+ false
+ }
+ }
+}
diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareTargetEntry.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareTargetEntry.kt
new file mode 100644
index 000000000..41cc7e771
--- /dev/null
+++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareTargetEntry.kt
@@ -0,0 +1,60 @@
+package io.rebble.libpebblecommon.shareintent
+
+import io.rebble.libpebblecommon.metadata.pbw.appinfo.ShareTarget
+import kotlin.uuid.Uuid
+
+/**
+ * A flat record of a single watchapp's share-target metadata, suitable for
+ * platform-specific share-sheet integrations (e.g. Android Sharing Shortcuts).
+ *
+ * Constructed from a [io.rebble.libpebblecommon.database.entity.LockerEntry]
+ * paired with its parsed [io.rebble.libpebblecommon.metadata.pbw.appinfo.PbwAppInfo].
+ */
+data class ShareTargetEntry(
+ val uuid: Uuid,
+ /** Display name; prefer [ShareTarget.label] if set, else the watchapp's [shortName]. */
+ val label: String,
+ val shortName: String,
+ val longName: String,
+ val shareTarget: ShareTarget,
+ /**
+ * Raw bytes of the watchapp's menu icon resource (a PNG declared in
+ * `package.json` with `menuIcon: true`), or `null` if the watchapp has
+ * no menu icon, the resource couldn't be read, or it isn't a PNG.
+ *
+ * Watchapp menu icons are typically small (≤25×25 px), monochrome, and
+ * designed for the watch's display. Platform-specific share-sheet code
+ * (e.g. Android's [io.rebble.libpebblecommon.shareintent.ShareTargetSync])
+ * is responsible for scaling and styling for share-sheet rendering, with
+ * a sensible fallback when this is null.
+ */
+ val iconBytes: ByteArray? = null,
+) {
+ // Equals/hashCode overridden to handle ByteArray sensibly. Without this,
+ // ByteArray's reference-equality semantics defeat distinctUntilChanged()
+ // upstream, causing every locker re-emission to look "different" and
+ // re-publish all shortcuts unnecessarily.
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is ShareTargetEntry) return false
+ return uuid == other.uuid &&
+ label == other.label &&
+ shortName == other.shortName &&
+ longName == other.longName &&
+ shareTarget == other.shareTarget &&
+ iconBytes.contentEqualsOrBothNull(other.iconBytes)
+ }
+
+ override fun hashCode(): Int {
+ var result = uuid.hashCode()
+ result = 31 * result + label.hashCode()
+ result = 31 * result + shortName.hashCode()
+ result = 31 * result + longName.hashCode()
+ result = 31 * result + shareTarget.hashCode()
+ result = 31 * result + (iconBytes?.contentHashCode() ?: 0)
+ return result
+ }
+}
+
+private fun ByteArray?.contentEqualsOrBothNull(other: ByteArray?): Boolean =
+ if (this == null) other == null else other != null && this.contentEquals(other)
diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareTargetsProducer.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareTargetsProducer.kt
new file mode 100644
index 000000000..454b066e8
--- /dev/null
+++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareTargetsProducer.kt
@@ -0,0 +1,169 @@
+package io.rebble.libpebblecommon.shareintent
+
+import co.touchlab.kermit.Logger
+import io.rebble.libpebblecommon.database.dao.LockerEntryRealDao
+import io.rebble.libpebblecommon.database.entity.LockerEntry
+import io.rebble.libpebblecommon.di.LibPebbleCoroutineScope
+import io.rebble.libpebblecommon.disk.pbw.DiskUtil
+import io.rebble.libpebblecommon.disk.pbw.PbwApp
+import io.rebble.libpebblecommon.disk.pbw.PbwResourcePack
+import io.rebble.libpebblecommon.locker.AppType
+import io.rebble.libpebblecommon.locker.Locker
+import io.rebble.libpebblecommon.locker.LockerPBWCache
+import io.rebble.libpebblecommon.metadata.WatchType
+import io.rebble.libpebblecommon.metadata.pbw.appinfo.Media
+import io.rebble.libpebblecommon.metadata.pbw.appinfo.PbwAppInfo
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.shareIn
+import kotlinx.io.files.Path
+
+/**
+ * Bridges [Locker] state into a stream of [ShareTargetEntry] suitable for
+ * platform-specific share-sheet wiring (e.g. [io.rebble.libpebblecommon.shareintent.ShareTargetSync]
+ * on Android).
+ *
+ * For each installed watchapp, we lazily load its `PbwApp` (a small zip read
+ * to access `appinfo.json`), check for a `shareTarget` declaration, and emit
+ * a flat record. We also extract the watchapp's menu icon PNG from the PBW
+ * (when present) so platform code can render per-watchapp identity in the
+ * share sheet. This recomputes whenever the locker contents change. Loads
+ * happen on [Dispatchers.IO] to keep the calling context unblocked.
+ *
+ * Watchfaces are filtered out — they don't have PKJS and can't receive
+ * share-intent events, so they have no business being share targets even if
+ * a malformed `package.json` declared one.
+ *
+ * The exposed [flow] is a *shared* flow (single upstream collection,
+ * multiple downstream subscribers). Without this, every collector triggers
+ * an independent crawl of the locker that opens every watchapp's PBW zip
+ * — significant disk I/O on [Dispatchers.IO]. With multiple subscribers
+ * (currently [ShareTargetSync] for shortcut sync and [ShareIntentDispatcher]
+ * for cached fallback dispatch), the I/O multiplies. Sharing means a single
+ * upstream collection serves both.
+ *
+ * The IO load itself was the cause of a maps-stop-loading bug after share
+ * intents: heavy disk reads on Dispatchers.IO would pin enough of the
+ * dispatcher's thread pool that PKJS WebView's URLRequestContext got
+ * starved of net-thread time. Symptoms included Image() loads stopping,
+ * XHRs returning status=0 + TIMEOUT, watchapp tile loading dying. See
+ * the related commit message for the diagnostic trail.
+ */
+class ShareTargetsProducer(
+ private val lockerEntryDao: LockerEntryRealDao,
+ private val lockerPBWCache: LockerPBWCache,
+ private val locker: Locker,
+ private val scope: LibPebbleCoroutineScope,
+) {
+ companion object {
+ private val logger = Logger.withTag(ShareTargetsProducer::class.simpleName!!)
+ }
+
+ val flow: Flow> =
+ lockerEntryDao
+ .getAllFlow(AppType.Watchapp.code, searchQuery = null, limit = Int.MAX_VALUE)
+ .map { entries -> entries.mapNotNull { tryRead(it) } }
+ .flowOn(Dispatchers.IO)
+ .distinctUntilChanged()
+ .shareIn(
+ scope = scope,
+ // Eagerly: start collecting at flow construction time. This
+ // pre-warms the share-targets list during app startup so
+ // [ShareIntentDispatcher.cachedTargets] has data ready by
+ // the time the user's first share fires. WhileSubscribed
+ // would defer collection until first subscriber, which adds
+ // perceptible latency and zip-read I/O exactly when the user
+ // is most performance-sensitive (waiting on a share).
+ started = SharingStarted.Eagerly,
+ // replay = 1 lets late subscribers (e.g. lazy-injected
+ // dispatcher) immediately see the most recent emission
+ // without re-running the pipeline.
+ replay = 1,
+ )
+
+ private suspend fun tryRead(entry: LockerEntry): ShareTargetEntry? {
+ val (path, info) = try {
+ val p = lockerPBWCache.getPBWFileForApp(entry.id, entry.version, locker)
+ p to PbwApp(p).info
+ } catch (e: Exception) {
+ // PBW not yet downloaded, corrupt, or otherwise unreadable. Treat
+ // as "no share target declared." Logged at debug because it is
+ // expected during locker-sync transients.
+ logger.d(e) { "couldn't read PBW for ${entry.id}" }
+ return null
+ }
+ val target = info.shareTarget ?: return null
+ return ShareTargetEntry(
+ uuid = entry.id,
+ label = target.label?.takeIf { it.isNotBlank() } ?: info.shortName,
+ shortName = info.shortName,
+ longName = info.longName,
+ shareTarget = target,
+ iconBytes = readMenuIconBytes(path, info),
+ )
+ }
+
+ /**
+ * Best-effort extraction of the watchapp's menu-icon PNG from its PBW.
+ *
+ * Tries two sources in order:
+ *
+ * 1. **Raw source PNG at zip root.** Some toolchains preserve the
+ * original PNG file at its declared `resources.media[i].file` path
+ * inside the PBW. If present we just read it.
+ *
+ * 2. **Compiled `app_resources.pbpack`.** The standard Pebble SDK and
+ * CloudPebble compile all media into a flat `.pbpack` resource
+ * pack and do NOT ship the source PNGs. We parse the pack and pull
+ * the resource at the menu icon's index in the `resources.media`
+ * array (which corresponds to its 1-based file_id in the pack —
+ * the SDK preserves declaration order).
+ *
+ * Returns null when:
+ * - no media resource is flagged as the menu icon
+ * - the resource isn't a PNG ("type" != "png")
+ * - neither source yields readable bytes
+ * - any I/O / parse error occurs (treated as "no icon available";
+ * callers fall back to a platform-provided generic icon)
+ *
+ * Watchapp menu icons are typically tiny (≤25×25), often white-on-
+ * transparent for the watch's display. Platform code is responsible for
+ * scaling and styling for share-sheet rendering.
+ */
+ private fun readMenuIconBytes(pbwPath: Path, info: PbwAppInfo): ByteArray? {
+ val menuIconResource = info.resources.media.firstOrNull { media ->
+ media.menuIcon.value
+ } ?: return null
+ if (!menuIconResource.type.equals("png", ignoreCase = true)) {
+ return null
+ }
+
+ // Tier 1: the source PNG might be shipped at its declared path.
+ DiskUtil.readPbwResourceFileOrNull(pbwPath, menuIconResource.resourceFile)
+ ?.let { return it }
+
+ // Tier 2: extract from the compiled resource pack. The resource
+ // pack groups its contents by integer index, in the order the
+ // resources were declared in `appinfo.json`'s `resources.media`
+ // array; index here matches table position in the .pbpack.
+ val resourceIndex = info.resources.media.indexOf(menuIconResource)
+ if (resourceIndex < 0) return null
+
+ // Resource packs are per-platform — pick any platform the watchapp
+ // declares. Menu icons are typically the same across platforms in
+ // a multi-platform PBW, so any one works for sharing-UI rendering.
+ val watchType = info.targetPlatforms
+ .firstNotNullOfOrNull { codename -> WatchType.fromCodename(codename) }
+ ?: return null
+
+ val packBytes = DiskUtil.readPbwResourcePackBytesOrNull(pbwPath, watchType)
+ ?: return null
+
+ return PbwResourcePack.extractResource(packBytes, resourceIndex)
+ }
+}
diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareUrlResolver.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareUrlResolver.kt
new file mode 100644
index 000000000..9310b03c7
--- /dev/null
+++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareUrlResolver.kt
@@ -0,0 +1,175 @@
+package io.rebble.libpebblecommon.shareintent
+
+import co.touchlab.kermit.Logger
+import io.ktor.client.HttpClient
+import io.ktor.client.plugins.HttpTimeout
+import io.ktor.client.request.get
+import io.ktor.client.request.header
+import io.ktor.client.statement.HttpResponse
+import io.ktor.http.Url
+import io.ktor.http.isSuccess
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Resolves Google Maps short URLs (`maps.app.goo.gl`, `goo.gl/maps`) to their
+ * full long-form URLs.
+ *
+ * Owns a dedicated [HttpClient] instance rather than using libpebble3's shared
+ * singleton client. Reasons:
+ *
+ * 1. **Isolation**: Google's anti-bot heuristics occasionally make the
+ * resolver retry, hold connections longer than usual, or hit timeouts.
+ * Sharing the singleton client means our retry pressure can starve
+ * unrelated consumers (FirmwareDownloader, Locker, watchapp PBW
+ * downloads, etc.) of connection-pool slots.
+ * 2. **Engine-level timeouts**: the singleton client has no HttpTimeout
+ * plugin installed, so requests can hang indefinitely on the underlying
+ * OkHttp engine. We need bounded socket/connect/request timeouts at
+ * the engine level so a stuck Google response can't hold a connection
+ * forever.
+ * 3. **Targeted lifecycle**: when libpebble3 is shut down, we close our
+ * client deterministically rather than relying on the shared client's
+ * lifecycle.
+ *
+ * See the resolution path docs in [resolveIfShortened] for the why-not-PKJS-XHR
+ * background.
+ */
+class ShareUrlResolver internal constructor(
+ private val httpClient: HttpClient,
+) {
+ /**
+ * Production constructor. Builds an HttpClient configured for the
+ * resolver's specific use case:
+ * - Engine-level timeouts (HttpTimeout plugin) so stuck connections
+ * don't pin pool slots.
+ * - Default redirect-following (Ktor follows up to 20 redirects by
+ * default; Google short URLs are typically 1-2 hops).
+ *
+ * Tests can inject a custom HttpClient via the internal constructor.
+ */
+ constructor() : this(httpClient = HttpClient {
+ install(HttpTimeout) {
+ requestTimeoutMillis = 5_000 // total request lifetime
+ connectTimeoutMillis = 2_000 // TCP connect
+ socketTimeoutMillis = 3_000 // between bytes
+ }
+ })
+ companion object {
+ private val logger = Logger.withTag(ShareUrlResolver::class.simpleName!!)
+
+ /**
+ * Hosts that emit Google Maps short URLs. The resolver will only
+ * follow redirects when the *input* URL is on this list. Once the
+ * redirect chain leaves these hosts, we accept whatever final URL
+ * the HTTP layer reports.
+ */
+ private val SHORT_URL_HOSTS = setOf(
+ "maps.app.goo.gl",
+ "goo.gl", // for the older /maps/ form
+ )
+
+ /**
+ * Stable mobile-Chrome User-Agent. Not version-spoofing — picked to
+ * be representative rather than chasing the latest Chrome release.
+ * Updated rarely.
+ */
+ private const val MOBILE_UA =
+ "Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 " +
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
+
+ private val RESOLVE_TIMEOUT = 6.seconds
+ private const val MAX_ATTEMPTS = 2
+ private const val RETRY_DELAY_MS = 300L
+ }
+
+ /**
+ * If [url] is a Google Maps short URL, attempt to resolve it to its
+ * long form. If [url] is anything else, returns it unchanged. On
+ * resolution failure (after retries), returns the original short URL.
+ *
+ * Never throws — wraps failures into a fall-open return.
+ *
+ * Retry strategy: Google's Firebase Dynamic Links anti-bot heuristic is
+ * stochastic — the same headers can yield 200 once and 403 the next
+ * second. A single retry roughly doubles our success rate at minimal
+ * cost. Total worst-case latency is bounded by RESOLVE_TIMEOUT and
+ * runs concurrently with PKJS spinup so usually invisible.
+ */
+ suspend fun resolveIfShortened(url: String): String {
+ if (!isShortenedMapsUrl(url)) return url
+
+ val resolved = withTimeoutOrNull(RESOLVE_TIMEOUT) {
+ for (attempt in 1..MAX_ATTEMPTS) {
+ val r = try {
+ doResolve(url)
+ } catch (e: Exception) {
+ logger.w(e) { "resolve attempt $attempt failed for $url" }
+ null
+ }
+ if (r != null) {
+ if (attempt > 1) logger.i { "resolve succeeded on attempt $attempt" }
+ return@withTimeoutOrNull r
+ }
+ if (attempt < MAX_ATTEMPTS) {
+ // Brief backoff before retry. Doesn't need to be long —
+ // Google's anti-bot decision seems request-local rather
+ // than IP-rate-based, so even ~300ms is enough to land
+ // a different decision tree.
+ kotlinx.coroutines.delay(RETRY_DELAY_MS)
+ }
+ }
+ null
+ }
+ return if (resolved != null) {
+ logger.i { "resolved $url -> $resolved" }
+ resolved
+ } else {
+ logger.w { "resolve gave no result for $url after $MAX_ATTEMPTS attempts, falling open" }
+ url
+ }
+ }
+
+ /**
+ * Public for testing / introspection. Returns true if the URL's host is
+ * one of the whitelisted Google Maps short-URL hosts.
+ */
+ fun isShortenedMapsUrl(url: String): Boolean {
+ val host = try { Url(url).host } catch (_: Exception) { return false }
+ return host in SHORT_URL_HOSTS
+ }
+
+ private suspend fun doResolve(url: String): String? {
+ val startNs = kotlin.time.TimeSource.Monotonic.markNow()
+
+ // Ktor's HttpClient follows redirects by default. After the call, the
+ // response.call.request.url is the *final* URL (post-redirects).
+ // That's the resolution we want.
+ //
+ // Outer timeout is enforced by withTimeoutOrNull in resolveIfShortened;
+ // we don't install the HttpTimeout plugin on the client here because
+ // it's a shared singleton with other use cases.
+ val response: HttpResponse = httpClient.get(url) {
+ header("User-Agent", MOBILE_UA)
+ header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
+ header("Accept-Language", "en-US,en;q=0.9")
+ }
+
+ val statusCode = response.status.value
+ val elapsedMs = startNs.elapsedNow().inWholeMilliseconds
+ val finalUrl = response.call.request.url.toString()
+
+ logger.d { "resolve $url → status=$statusCode finalUrl=$finalUrl elapsed=${elapsedMs}ms" }
+
+ if (!response.status.isSuccess() && statusCode !in 300..399) {
+ return null
+ }
+
+ // Sanity check: if the "final" URL is the same as the input we got
+ // no useful redirect. Treat as failure so caller falls open.
+ if (finalUrl == url) {
+ return null
+ }
+ return finalUrl
+ }
+}
diff --git a/libpebble3/src/iosMain/kotlin/io/rebble/libpebblecommon/js/JSCPKJSInterface.kt b/libpebble3/src/iosMain/kotlin/io/rebble/libpebblecommon/js/JSCPKJSInterface.kt
index 6c7238555..c95afb0e2 100644
--- a/libpebble3/src/iosMain/kotlin/io/rebble/libpebblecommon/js/JSCPKJSInterface.kt
+++ b/libpebble3/src/iosMain/kotlin/io/rebble/libpebblecommon/js/JSCPKJSInterface.kt
@@ -12,6 +12,7 @@ class JSCPKJSInterface(jsRunner: JsRunner, device: CompanionAppDevice, libPebble
"getWatchToken" to this::getWatchToken,
"showToast" to this::showToast,
"openURL" to this::openURL,
+ "getActiveNotifications" to this::getActiveNotifications,
)
override val name = "Pebble"
@@ -21,6 +22,7 @@ class JSCPKJSInterface(jsRunner: JsRunner, device: CompanionAppDevice, libPebble
"getWatchToken" -> getWatchToken()
"showToast" -> { showToast(args[0].toString()); null }
"openURL" -> openURL(args[0].toString())
+ "getActiveNotifications" -> getActiveNotifications(args.getOrNull(0)?.toString().orEmpty())
else -> error("Unknown method: $method")
}
@@ -29,6 +31,17 @@ class JSCPKJSInterface(jsRunner: JsRunner, device: CompanionAppDevice, libPebble
logger.e { "showToast() not implemented" }
}
+ /**
+ * iOS notification source-of-truth (UserNotifications framework / a Share
+ * Extension subscribing to NotificationCenter delegate callbacks) is not
+ * yet wired up. Return empty array so PKJS apps that try the API on iOS
+ * degrade gracefully — they'll still get nothing useful, but won't error.
+ */
+ override fun getActiveNotifications(packageFilter: String): String {
+ logger.v { "getActiveNotifications() not yet implemented on iOS" }
+ return "[]"
+ }
+
override fun close() {
// No-op
}
diff --git a/libpebble3/src/iosMain/kotlin/io/rebble/libpebblecommon/js/JavascriptCoreJsRunner.kt b/libpebble3/src/iosMain/kotlin/io/rebble/libpebblecommon/js/JavascriptCoreJsRunner.kt
index 6cf86dd7a..59a6d9995 100644
--- a/libpebble3/src/iosMain/kotlin/io/rebble/libpebblecommon/js/JavascriptCoreJsRunner.kt
+++ b/libpebble3/src/iosMain/kotlin/io/rebble/libpebblecommon/js/JavascriptCoreJsRunner.kt
@@ -267,6 +267,30 @@ class JavascriptCoreJsRunner(
}
}
+ override suspend fun signalShareIntent(text: String, url: String?, subject: String?) {
+ val payload = Json.encodeToString(
+ mapOf(
+ "text" to text,
+ "url" to url,
+ "subject" to subject,
+ )
+ )
+ withContext(threadContext) {
+ jsContext?.evalCatching("globalThis.signalShareIntent($payload)")
+ }
+ }
+
+ override suspend fun signalAppNotification(notificationJson: String) {
+ // iOS notification source-of-truth (UNUserNotificationCenter / a Share
+ // Extension) is not yet wired up — the JS-side signal hook lives here
+ // for symmetry with Android so PKJS code that subscribes to
+ // 'appnotification' on iOS won't fail when the iOS receiving side
+ // lands later. For now this is a passthrough to JSCore.
+ withContext(threadContext) {
+ jsContext?.evalCatching("globalThis.signalAppNotification(${Json.encodeToString(notificationJson)})")
+ }
+ }
+
override suspend fun eval(js: String) {
withContext(threadContext) {
jsContext?.evalCatching(js)