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..6bf8252e2 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,13 @@ 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.metadata.pbw.appinfo.NotificationSubscription
+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 +19,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 +52,78 @@ 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 "[]"
+ }
+
+ // Map of packageName → its NotificationSubscription, so that for
+ // each notification we can apply the matching subscription's
+ // `fields` opt-in. A watchapp can in principle declare the same
+ // package twice with different field sets; we keep the first
+ // declaration deterministically (Map.put semantics on first wins).
+ val subscriptions: Map =
+ watchappFilter.associateBy { it.packageName }
+
+ 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()) subscriptions.keys
+ else requested.intersect(subscriptions.keys)
+ }
+ 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
+ val subscription = subscriptions[sbn.packageName] ?: 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 and
+ // RemoteViews extraction. Each entry is gated by the
+ // subscription's `fields` opt-in for that package.
+ val element = Json.parseToJsonElement(
+ WatchappNotificationSerializer.serialize(
+ sbn = sbn,
+ posted = true,
+ context = service,
+ subscription = subscription,
+ )
+ )
+ 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/BigPictureExtractor.kt b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/notification/BigPictureExtractor.kt
new file mode 100644
index 000000000..a05cbb925
--- /dev/null
+++ b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/notification/BigPictureExtractor.kt
@@ -0,0 +1,213 @@
+package io.rebble.libpebblecommon.notification
+
+import android.app.Notification
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.Rect
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.Icon
+import android.os.Build
+import android.os.Bundle
+import android.util.Base64
+import co.touchlab.kermit.Logger
+import java.io.ByteArrayOutputStream
+
+/**
+ * Extracts the [Notification.BigPictureStyle] photograph from a
+ * notification, downsamples it to fit a configured byte cap, and
+ * encodes it as base64 PNG suitable for inclusion in the JSON payload
+ * PKJS watchapps consume.
+ *
+ * Why bother:
+ * `BigPictureStyle` is where photo-app notifications (Photos, Gallery,
+ * Instagram-style apps) and a notable subset of music/podcast players
+ * put album art at higher resolution than fits in `Notification.largeIcon`.
+ * Watchapps that mirror media playback or display visual notifications
+ * need this content; without it they'd be limited to the 32×32 largeIcon
+ * (sometimes a heavily compressed thumbnail).
+ *
+ * Cost & cap:
+ * Source bitmaps can be huge (1080×1080+). PNG-encoding those raw would
+ * produce 100KB+ blobs, which is a non-starter for Bluetooth delivery
+ * to a Pebble (typical sustained throughput ~3-4 KB/s). The extractor
+ * downsamples adaptively: tries [TARGET_PX_LARGE] first, then
+ * [TARGET_PX_MEDIUM], then [TARGET_PX_SMALL]. The first encoding that
+ * fits under [DEFAULT_BYTE_CAP] wins. If even the smallest size
+ * exceeds the cap (very rare — only on photographic content with high
+ * color complexity at small sizes), returns null.
+ *
+ * Watchapps that need finer control can decode the base64 themselves
+ * and re-process; the source pixels are already at watchapp-renderable
+ * resolution after our downsample.
+ *
+ * Failure mode:
+ * Best-effort. Any failure (missing extras key, unsupported icon type,
+ * bitmap creation, PNG encode) is logged at trace and returns null.
+ * Never throws.
+ */
+internal object BigPictureExtractor {
+
+ private val logger = Logger.withTag("BigPictureExtractor")
+
+ /**
+ * 8 KB upper bound on the encoded base64 PNG. Chosen to match the
+ * cap used elsewhere in the notification payload pipeline; rough
+ * upper limit on what's reasonable to ship over Bluetooth to a
+ * watch on a per-notification basis.
+ */
+ const val DEFAULT_BYTE_CAP = 8192
+
+ /**
+ * Downsample target ladder. Tried in order: large first (best
+ * fidelity), falling back to smaller sizes when the encoded result
+ * exceeds the byte cap. 144 chosen to match Pebble Time Steel /
+ * Aplite / Basalt screen width — watchapps render at this size
+ * directly without further downscaling.
+ */
+ private const val TARGET_PX_LARGE = 144
+ private const val TARGET_PX_MEDIUM = 96
+ private const val TARGET_PX_SMALL = 64
+
+ /**
+ * Extract the BigPicture from a notification and encode it.
+ *
+ * @param context Used to resolve [Icon] references on API 31+ where
+ * BigPictureStyle's picture is sometimes stored as an Icon rather
+ * than a Bitmap. The notification listener service IS a Context.
+ * @param notification The notification to extract from.
+ * @param byteCap Maximum encoded base64 length; default
+ * [DEFAULT_BYTE_CAP]. Encodings larger than this trigger fallback
+ * to a smaller target size; if even the smallest exceeds the cap
+ * the extractor returns null.
+ * @return Base64-encoded PNG, or null if the notification has no
+ * BigPicture, the picture couldn't be loaded, or no downsample
+ * target produced a result under [byteCap].
+ */
+ fun extract(
+ context: Context,
+ notification: Notification,
+ byteCap: Int = DEFAULT_BYTE_CAP,
+ ): String? {
+ val source: Bitmap = loadBigPictureBitmap(context, notification) ?: return null
+ return encodeWithFallback(source, byteCap)
+ }
+
+ /**
+ * Pull the BigPicture out of the notification. Tries the API 31+
+ * Icon-typed field first (`EXTRA_PICTURE_ICON`), then falls back to
+ * the legacy Bitmap-typed field (`EXTRA_PICTURE`). Either may be
+ * present on any given notification depending on which Notification
+ * builder API the source app used.
+ */
+ @Suppress("DEPRECATION")
+ private fun loadBigPictureBitmap(context: Context, notification: Notification): Bitmap? { val extras: Bundle = notification.extras ?: return null
+ // API 31+: BigPictureStyle.bigPicture(Icon) writes EXTRA_PICTURE_ICON.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ try {
+ val icon = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ extras.getParcelable(Notification.EXTRA_PICTURE_ICON, Icon::class.java)
+ } else {
+ extras.getParcelable(Notification.EXTRA_PICTURE_ICON)
+ }
+ if (icon != null) {
+ val drawable = icon.loadDrawable(context)
+ if (drawable is BitmapDrawable && drawable.bitmap != null) {
+ return drawable.bitmap
+ }
+ // Non-bitmap drawables (vector etc.) — rasterize at
+ // intrinsic size.
+ val w = drawable?.intrinsicWidth ?: 0
+ val h = drawable?.intrinsicHeight ?: 0
+ if (drawable != null && w > 0 && h > 0) {
+ val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bmp)
+ drawable.setBounds(0, 0, w, h)
+ drawable.draw(canvas)
+ return bmp
+ }
+ }
+ } catch (e: Exception) {
+ logger.v(e) { "EXTRA_PICTURE_ICON load failed; falling back to EXTRA_PICTURE" }
+ }
+ }
+ // Legacy: BigPictureStyle.bigPicture(Bitmap) writes
+ // EXTRA_PICTURE directly. Available on all API levels we
+ // support.
+ return try {
+ extras.getParcelable(Notification.EXTRA_PICTURE)
+ } catch (e: Exception) {
+ logger.v(e) { "EXTRA_PICTURE load failed" }
+ null
+ }
+ }
+
+ /**
+ * Encode [source] to base64 PNG, trying each target size in order
+ * until one fits under [byteCap]. If none fit, return null.
+ */
+ private fun encodeWithFallback(source: Bitmap, byteCap: Int): String? {
+ val targets = intArrayOf(TARGET_PX_LARGE, TARGET_PX_MEDIUM, TARGET_PX_SMALL)
+ for (target in targets) {
+ val encoded = downsampleAndEncode(source, target) ?: continue
+ if (encoded.length <= byteCap) return encoded
+ }
+ logger.v {
+ "all downsample targets exceeded byte cap (cap=$byteCap, " +
+ "source=${source.width}x${source.height})"
+ }
+ return null
+ }
+
+ /**
+ * Downsample [source] to fit within a [target]×[target] box
+ * (preserving aspect ratio) and encode as base64 PNG.
+ */
+ private fun downsampleAndEncode(source: Bitmap, target: Int): String? {
+ return try {
+ // Compute aspect-preserving destination size that fits in
+ // target × target. We prefer a slightly-smaller-than-target
+ // result to a stretched result — the watchapp can scale up
+ // if it wants.
+ val sw = source.width
+ val sh = source.height
+ if (sw <= 0 || sh <= 0) return null
+ val scale = minOf(target.toFloat() / sw, target.toFloat() / sh, 1.0f)
+ val dw = maxOf(1, (sw * scale).toInt())
+ val dh = maxOf(1, (sh * scale).toInt())
+
+ val dest = Bitmap.createBitmap(dw, dh, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(dest)
+ // Bilinear filtering — better than nearest for photographs;
+ // PNG encode size is similar either way (random pixel-level
+ // detail dominates the entropy).
+ val paint = Paint(Paint.FILTER_BITMAP_FLAG)
+ canvas.drawBitmap(source, Rect(0, 0, sw, sh), Rect(0, 0, dw, dh), paint)
+ val encoded = bitmapToPngBase64(dest)
+ dest.recycle()
+ encoded
+ } catch (e: Exception) {
+ logger.v(e) { "downsample to $target 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
+ }
+ // NO_WRAP avoids the line breaks the default Base64 encoder
+ // inserts at column 76, which JS doesn't need.
+ Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
+ } catch (e: Exception) {
+ logger.v(e) { "PNG encode failed" }
+ null
+ }
+ }
+}
diff --git a/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/notification/IconExtrasExtractor.kt b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/notification/IconExtrasExtractor.kt
new file mode 100644
index 000000000..a0b1d85aa
--- /dev/null
+++ b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/notification/IconExtrasExtractor.kt
@@ -0,0 +1,191 @@
+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.graphics.drawable.Icon
+import android.os.Build
+import android.os.Bundle
+import android.util.Base64
+import co.touchlab.kermit.Logger
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonObjectBuilder
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+import kotlinx.serialization.json.putJsonObject
+import java.io.ByteArrayOutputStream
+
+/**
+ * Extracts every [Icon][android.graphics.drawable.Icon]-typed entry from
+ * a [Notification]'s extras [Bundle], rasterizes each to a 32×32 ARGB PNG,
+ * base64-encodes it, and emits a JSON object map keyed by the extras key.
+ *
+ * Why bother:
+ * Android 14+ introduced the Ongoing Activity API, which lets apps
+ * render rich live notifications with custom glyphs. Google Maps' nav
+ * notification is the canonical example — the turn-direction arrow,
+ * lane guidance, arrival flag, and ETA chip icon are all stored as
+ * Icon parcelables in the notification's extras bundle under keys
+ * like `android.ongoingActivityNoti.chipIcon`,
+ * `android.ongoingActivityNoti.nowbarIcon`,
+ * `android.ongoingActivityNoti.secondIcon`.
+ *
+ * These don't appear in the standard `Notification.smallIcon` /
+ * `largeIcon` slots (which carry the app's brand glyph), and they
+ * don't appear in `Notification.contentView` / `bigContentView`
+ * RemoteViews (which Ongoing Activity notifications often don't
+ * populate at all). The extras bundle is where they live, full stop.
+ *
+ * Watchapps that mirror nav, fitness, media, or any other live-
+ * activity-style notifications need access to these. This extractor
+ * provides it as a stable Android API surface (`Bundle.get(key)`
+ * followed by `Icon.loadDrawable(context)`) — no reflection, no
+ * layout-tree walking, no version-fragile RemoteViews introspection.
+ *
+ * Output JSON shape (object keyed by extras key):
+ * ```
+ * {
+ * "android.ongoingActivityNoti.chipIcon": {
+ * "base64": "", // 32×32 ARGB, NO_WRAP
+ * "hash": "", // djb2 over the rasterized pixels
+ * "intrinsicW": , // Drawable.intrinsicWidth (-1 if unknown)
+ * "intrinsicH": // Drawable.intrinsicHeight
+ * },
+ * "android.ongoingActivityNoti.nowbarIcon": { ... },
+ * ...
+ * }
+ * ```
+ *
+ * Hash is djb2 over the rasterized 32×32 pixel ints — useful for
+ * cheap state-change detection across notifications (e.g. distinguishing
+ * one Maps turn-arrow glyph from another) without diffing the full
+ * base64 payload.
+ *
+ * Cost:
+ * One Drawable load + 32×32 raster + PNG encode per Icon entry.
+ * Maps' nav notification typically has 2-3 icon extras, totalling
+ * ~1.5 KB of base64 payload per notification. Trivial vs the rest
+ * of the dispatch pipeline.
+ *
+ * Failure mode:
+ * Best-effort. Per-icon failures (load fail, rasterize fail, encode
+ * fail) are logged at trace and that key is skipped in the output;
+ * other Icon entries continue to be processed. Never throws.
+ *
+ * API gating:
+ * The [Icon] class is API 23+. Pre-API-23 returns an empty object
+ * (no Icon-typed extras can exist in the bundle below that level).
+ */
+internal object IconExtrasExtractor {
+
+ private val logger = Logger.withTag("IconExtrasExtractor")
+
+ /** Match the icon size used by [NotificationIconExtractor] for comparability. */
+ private const val ICON_SIZE_PX = 32
+
+ /**
+ * Walk [notification]'s extras, rasterize every Icon-typed entry,
+ * return a JsonObject mapping extras-key → per-icon details.
+ *
+ * @param context Used to call `Icon.loadDrawable(context)`. The
+ * notification listener service IS a Context with the right
+ * permissions to resolve drawables from the source app.
+ */
+ fun extract(context: Context, notification: Notification): JsonObject {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ return buildJsonObject { /* Icon class doesn't exist pre-API-23 */ }
+ }
+ val extras: Bundle = notification.extras ?: return buildJsonObject { }
+ return buildJsonObject {
+ for (key in extras.keySet()) {
+ val value: Any? = try {
+ @Suppress("DEPRECATION")
+ extras.get(key)
+ } catch (e: Exception) {
+ logger.v(e) { "extras.get('$key') threw, skipping" }
+ null
+ }
+ if (value !is Icon) continue
+ emitIconEntry(context, key, value)
+ }
+ }
+ }
+
+ private fun JsonObjectBuilder.emitIconEntry(context: Context, key: String, icon: Icon) {
+ val drawable: Drawable = try {
+ icon.loadDrawable(context)
+ } catch (e: Throwable) {
+ logger.v(e) { "Icon.loadDrawable failed for '$key'" }
+ return
+ } ?: return
+
+ val intrinsicW = drawable.intrinsicWidth
+ val intrinsicH = drawable.intrinsicHeight
+
+ val raster: Bitmap = try {
+ // Fast path: drawable is already a bitmap of the target size.
+ if (drawable is BitmapDrawable && drawable.bitmap != null &&
+ drawable.bitmap.width == ICON_SIZE_PX &&
+ drawable.bitmap.height == ICON_SIZE_PX
+ ) {
+ drawable.bitmap
+ } else {
+ 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)
+ bmp
+ }
+ } catch (e: Throwable) {
+ logger.v(e) { "rasterize failed for '$key'" }
+ return
+ }
+
+ val hash = djb2OfPixels(raster) ?: return
+ val base64 = pngBase64(raster)
+
+ // Recycle the bitmap unless it was the BitmapDrawable's own
+ // (recycling that would corrupt the source drawable).
+ if (raster !== (drawable as? BitmapDrawable)?.bitmap) raster.recycle()
+
+ if (base64 == null) return
+
+ putJsonObject(key) {
+ put("base64", base64)
+ put("hash", hash)
+ put("intrinsicW", intrinsicW)
+ put("intrinsicH", intrinsicH)
+ }
+ }
+
+ private fun djb2OfPixels(bmp: Bitmap): String? {
+ return try {
+ val pixels = IntArray(ICON_SIZE_PX * ICON_SIZE_PX)
+ bmp.getPixels(pixels, 0, ICON_SIZE_PX, 0, 0, ICON_SIZE_PX, ICON_SIZE_PX)
+ var h = 5381L
+ for (px in pixels) {
+ h = ((h shl 5) + h + px.toLong()) and 0xFFFFFFFFL
+ }
+ h.toString(16)
+ } catch (e: Throwable) {
+ logger.v(e) { "pixel hash failed" }
+ null
+ }
+ }
+
+ private fun pngBase64(bmp: Bitmap): String? {
+ return try {
+ val baos = ByteArrayOutputStream()
+ if (!bmp.compress(Bitmap.CompressFormat.PNG, 100, baos)) return null
+ Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
+ } catch (e: Throwable) {
+ logger.v(e) { "png encode failed" }
+ null
+ }
+ }
+}
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..620f86c9b 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,35 @@ class LibPebbleNotificationListener : NotificationListenerService(), LibPebbleKo
}
notificationHandler.handleNotificationPosted(sbn)
+
+ // 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.
+ //
+ // Per-watchapp serialization: each subscribing watchapp may have
+ // declared a different `fields` set in its notificationFilter, so
+ // the dispatcher invokes the SerializerCallback once per
+ // subscriber, passing the matching subscription. This keeps
+ // heavyweight extraction (BigPicture decoding, MediaSession
+ // lookup, RemoteViews traversal) gated to watchapps that asked.
+ try {
+ val listenerCtx: Context = this
+ watchappDispatcher.dispatch(sbn.packageName) { subscription ->
+ try {
+ WatchappNotificationSerializer.serialize(
+ sbn = sbn,
+ posted = true,
+ context = listenerCtx,
+ subscription = subscription,
+ )
+ } catch (e: Exception) {
+ logger.w(e) { "serialize failed for ${sbn.packageName.obfuscate(privateLogger)}" }
+ null
+ }
+ }
+ } catch (e: Exception) {
+ logger.w(e) { "watchapp notification dispatch failed for ${sbn.packageName.obfuscate(privateLogger)}" }
+ }
}
override fun onNotificationRemoved(
@@ -193,6 +224,29 @@ class LibPebbleNotificationListener : NotificationListenerService(), LibPebbleKo
return
}
notificationHandler.handleNotificationRemoved(sbn)
+
+ // 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 (and other heavyweight fields) are skipped on removal
+ // events; the per-watchapp serialize() honors that for any caller.
+ try {
+ watchappDispatcher.dispatch(sbn.packageName) { subscription ->
+ try {
+ WatchappNotificationSerializer.serialize(
+ sbn = sbn,
+ posted = false,
+ context = null,
+ subscription = subscription,
+ )
+ } catch (e: Exception) {
+ logger.w(e) { "serialize-removed failed for ${sbn.packageName.obfuscate(privateLogger)}" }
+ null
+ }
+ }
+ } 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/MediaSessionExtractor.kt b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/notification/MediaSessionExtractor.kt
new file mode 100644
index 000000000..4e005fb13
--- /dev/null
+++ b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/notification/MediaSessionExtractor.kt
@@ -0,0 +1,243 @@
+package io.rebble.libpebblecommon.notification
+
+import android.app.Notification
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.Rect
+import android.media.MediaMetadata
+import android.media.session.MediaController
+import android.media.session.MediaSession
+import android.media.session.PlaybackState
+import android.os.Build
+import android.os.Bundle
+import android.util.Base64
+import co.touchlab.kermit.Logger
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+import java.io.ByteArrayOutputStream
+
+/**
+ * Extracts structured media metadata from a [MediaStyle][Notification.MediaStyle]
+ * notification by following the embedded [MediaSession.Token] and
+ * querying the corresponding [MediaController].
+ *
+ * Why bother:
+ * `Notification.title` / `text` give you the song name and artist as
+ * loose strings, but watchapps (music control surfaces, album-art
+ * displays, scrubber UIs) typically want structured fields:
+ * `title` / `artist` / `album` separated, duration as a number,
+ * playback position, an explicit play/pause/stopped state, and album
+ * art at usable resolution. All of that lives on the MediaSession,
+ * not in the notification's surface text.
+ *
+ * What we surface (JSON object, all fields nullable):
+ * `title` METADATA_KEY_TITLE
+ * `artist` METADATA_KEY_ARTIST
+ * `album` METADATA_KEY_ALBUM
+ * `durationMs` METADATA_KEY_DURATION (Long, milliseconds)
+ * `positionMs` PlaybackState.position (Long, milliseconds)
+ * `playbackState` String form of PlaybackState.state — "playing",
+ * "paused", "stopped", "buffering", "fast_forwarding",
+ * "rewinding", "skipping_to_next", "skipping_to_previous",
+ * "skipping_to_queue_item", "error", "connecting",
+ * "none". Returned as null when no PlaybackState is
+ * available.
+ * `albumArtBase64` Base64-encoded PNG of the album art bitmap from
+ * METADATA_KEY_ART (preferred), falling back to
+ * METADATA_KEY_ALBUM_ART then METADATA_KEY_DISPLAY_ICON.
+ * Downsampled to fit a configured byte cap; null if
+ * absent or oversize.
+ *
+ * Cost & cap:
+ * Album art bitmaps are typically 300×300 to 1080×1080. Downsampled
+ * to fit [DEFAULT_BYTE_CAP] (8 KB) of base64 PNG; falls through size
+ * tiers same as [BigPictureExtractor].
+ *
+ * Failure mode:
+ * Best-effort. Any failure (no media session token in extras, can't
+ * open MediaController, no metadata, etc.) returns null. Never throws.
+ */
+internal object MediaSessionExtractor {
+
+ private val logger = Logger.withTag("MediaSessionExtractor")
+
+ /**
+ * Same 8 KB upper bound used by [BigPictureExtractor]. Album art
+ * compresses about as well as a typical BigPicture so the cap and
+ * downsample ladder match.
+ */
+ const val DEFAULT_BYTE_CAP = 8192
+
+ private const val ART_TARGET_PX_LARGE = 144
+ private const val ART_TARGET_PX_MEDIUM = 96
+ private const val ART_TARGET_PX_SMALL = 64
+
+ /**
+ * Extract media metadata as a JSON object. Returns null if the
+ * notification has no MediaSession token or extraction fails.
+ *
+ * @param context Used to construct the [MediaController]. The
+ * notification listener service IS a Context with the right
+ * permission to read media sessions referenced by notifications.
+ * @param notification The notification being extracted from.
+ * @param albumArtByteCap Cap for the encoded album-art base64
+ * length. Defaults to [DEFAULT_BYTE_CAP].
+ */
+ fun extract(
+ context: Context,
+ notification: Notification,
+ albumArtByteCap: Int = DEFAULT_BYTE_CAP,
+ ): JsonObject? {
+ val token: MediaSession.Token = extractMediaSessionToken(notification) ?: return null
+
+ val controller: MediaController = try {
+ MediaController(context, token)
+ } catch (e: Exception) {
+ // MediaController construction can fail if the originating
+ // session has been released between notification posting
+ // and our extraction. Soft-fail.
+ logger.v(e) { "MediaController construction failed" }
+ return null
+ }
+
+ val metadata: MediaMetadata? = try { controller.metadata } catch (e: Exception) {
+ logger.v(e) { "controller.metadata read failed" }
+ null
+ }
+ val playback: PlaybackState? = try { controller.playbackState } catch (e: Exception) {
+ logger.v(e) { "controller.playbackState read failed" }
+ null
+ }
+
+ if (metadata == null && playback == null) return null
+
+ return buildJsonObject {
+ put("title", metadata?.getString(MediaMetadata.METADATA_KEY_TITLE))
+ put("artist", metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST))
+ put("album", metadata?.getString(MediaMetadata.METADATA_KEY_ALBUM))
+
+ val durationMs = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L
+ // METADATA_KEY_DURATION returns 0 when not set (per docs).
+ // Surface as null in that case to keep the JSON shape
+ // consistent ("present and known" vs "absent").
+ if (durationMs > 0) put("durationMs", durationMs)
+ else put("durationMs", JsonNull)
+
+ // PlaybackState.position is the most-recently-reported play
+ // head. It's an instantaneous snapshot — for live ETA the
+ // watchapp should combine it with PlaybackState.lastPositionUpdateTime
+ // and the playback rate, but for a typical "show track
+ // position" UI the bare number is what watchapps use.
+ val pos = playback?.position
+ put("positionMs", if (pos != null && pos >= 0) JsonPrimitive(pos) else JsonNull)
+
+ put("playbackState", playback?.state?.let(::playbackStateToString))
+
+ put("albumArtBase64", extractAlbumArtBase64(metadata, albumArtByteCap))
+ }
+ }
+
+ /**
+ * Pull the MediaSession token from the notification's extras.
+ * `Notification.EXTRA_MEDIA_SESSION` ("android.mediaSession") is the
+ * key MediaStyle uses; same on all API levels we support.
+ */
+ @Suppress("DEPRECATION")
+ private fun extractMediaSessionToken(notification: Notification): MediaSession.Token? {
+ val extras: Bundle = notification.extras ?: return null
+ return try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ extras.getParcelable(Notification.EXTRA_MEDIA_SESSION, MediaSession.Token::class.java)
+ } else {
+ extras.getParcelable(Notification.EXTRA_MEDIA_SESSION)
+ }
+ } catch (e: Exception) {
+ logger.v(e) { "EXTRA_MEDIA_SESSION read failed" }
+ null
+ }
+ }
+
+ /**
+ * Pull album art Bitmap from the metadata, preferring the standard
+ * keys in order, downsample to fit the byte cap, return base64 PNG
+ * or null.
+ *
+ * Key preference: `METADATA_KEY_ART` is the highest-fidelity slot
+ * (typically the original full-resolution album art). Falls back
+ * to `METADATA_KEY_ALBUM_ART` (older key, often the same image)
+ * and then `METADATA_KEY_DISPLAY_ICON` (small thumbnail intended
+ * for compact UIs).
+ */
+ private fun extractAlbumArtBase64(metadata: MediaMetadata?, byteCap: Int): String? {
+ if (metadata == null) return null
+ val source: Bitmap = metadata.getBitmap(MediaMetadata.METADATA_KEY_ART)
+ ?: metadata.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART)
+ ?: metadata.getBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON)
+ ?: return null
+ return encodeAlbumArtWithFallback(source, byteCap)
+ }
+
+ private fun encodeAlbumArtWithFallback(source: Bitmap, byteCap: Int): String? {
+ val targets = intArrayOf(ART_TARGET_PX_LARGE, ART_TARGET_PX_MEDIUM, ART_TARGET_PX_SMALL)
+ for (target in targets) {
+ val encoded = downsampleAndEncode(source, target) ?: continue
+ if (encoded.length <= byteCap) return encoded
+ }
+ logger.v {
+ "all album-art downsample targets exceeded byte cap (cap=$byteCap, " +
+ "source=${source.width}x${source.height})"
+ }
+ return null
+ }
+
+ private fun downsampleAndEncode(source: Bitmap, target: Int): String? {
+ return try {
+ val sw = source.width
+ val sh = source.height
+ if (sw <= 0 || sh <= 0) return null
+ val scale = minOf(target.toFloat() / sw, target.toFloat() / sh, 1.0f)
+ val dw = maxOf(1, (sw * scale).toInt())
+ val dh = maxOf(1, (sh * scale).toInt())
+ val dest = Bitmap.createBitmap(dw, dh, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(dest)
+ val paint = Paint(Paint.FILTER_BITMAP_FLAG)
+ canvas.drawBitmap(source, Rect(0, 0, sw, sh), Rect(0, 0, dw, dh), paint)
+ val baos = ByteArrayOutputStream()
+ if (!dest.compress(Bitmap.CompressFormat.PNG, 100, baos)) {
+ dest.recycle()
+ return null
+ }
+ dest.recycle()
+ Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
+ } catch (e: Exception) {
+ logger.v(e) { "album-art downsample to $target failed" }
+ null
+ }
+ }
+
+ /**
+ * PlaybackState.STATE_* constants → string form for JSON. Picked
+ * lowercase-snake-case so JS consumers can do simple equality checks
+ * (`if (state === 'playing')`) without dealing with magic numbers.
+ */
+ private fun playbackStateToString(state: Int): String = when (state) {
+ PlaybackState.STATE_NONE -> "none"
+ PlaybackState.STATE_STOPPED -> "stopped"
+ PlaybackState.STATE_PAUSED -> "paused"
+ PlaybackState.STATE_PLAYING -> "playing"
+ PlaybackState.STATE_FAST_FORWARDING -> "fast_forwarding"
+ PlaybackState.STATE_REWINDING -> "rewinding"
+ PlaybackState.STATE_BUFFERING -> "buffering"
+ PlaybackState.STATE_ERROR -> "error"
+ PlaybackState.STATE_CONNECTING -> "connecting"
+ PlaybackState.STATE_SKIPPING_TO_PREVIOUS -> "skipping_to_previous"
+ PlaybackState.STATE_SKIPPING_TO_NEXT -> "skipping_to_next"
+ PlaybackState.STATE_SKIPPING_TO_QUEUE_ITEM -> "skipping_to_queue_item"
+ else -> "unknown"
+ }
+}
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..087a82275
--- /dev/null
+++ b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/notification/NotificationIconExtractor.kt
@@ -0,0 +1,173 @@
+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 the requested icons. Either or both may be null in the
+ * result — extraction is best-effort and returns null on any failure.
+ *
+ * @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.
+ * @param wantSmall Whether to attempt smallIcon extraction. When
+ * false, the corresponding field in the returned [Icons] is null
+ * without any extraction work performed. Defaults to true for
+ * backward compatibility with callers that don't gate.
+ * @param wantLarge Same as [wantSmall], for largeIcon.
+ */
+ fun extract(
+ context: Context,
+ notification: Notification,
+ wantSmall: Boolean = true,
+ wantLarge: Boolean = true,
+ ): Icons {
+ val small = if (wantSmall && 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 (wantLarge && 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..5f3b47dfd
--- /dev/null
+++ b/libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/notification/WatchappNotificationSerializer.kt
@@ -0,0 +1,362 @@
+package io.rebble.libpebblecommon.notification
+
+import android.app.Notification
+import android.content.Context
+import android.os.Build
+import android.os.Bundle
+import android.service.notification.StatusBarNotification
+import co.touchlab.kermit.Logger
+import io.rebble.libpebblecommon.metadata.pbw.appinfo.NotificationSubscription
+import io.rebble.libpebblecommon.metadata.pbw.appinfo.NotificationSubscription.Field
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonObjectBuilder
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.addJsonObject
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+import kotlinx.serialization.json.putJsonArray
+import kotlinx.serialization.json.putJsonObject
+
+/**
+ * Converts an Android [StatusBarNotification] into the JSON shape PKJS
+ * watchapps consume via `'appnotification'` events / `Pebble.getActiveNotifications`.
+ *
+ * Field extraction is gated per-watchapp by [NotificationSubscription.fields].
+ * Each subscribed watchapp may request a different subset; extraction work
+ * (BigPicture downsampling, MediaSession lookup, RemoteViews traversal,
+ * etc.) is skipped entirely for watchapps that didn't ask. Notification-
+ * level metadata (`package`, `posted`, `key`, `postTime`, `groupKey`) is
+ * always emitted regardless of `fields` — watchapps need it to interpret
+ * the rest of the payload.
+ *
+ * Non-primitive entries in [Notification.extras] (Parcelables, RemoteInputs,
+ * Bitmaps, byte arrays) are skipped from the [Field.EXTRAS] output — they
+ * don't survive JSON crossing and are usually too large for Bluetooth
+ * anyway. Apps that hide structured data in extras as well-defined
+ * primitive keys (e.g. older Maps versions exposed a navigation-state
+ * int) come through unchanged.
+ */
+internal object WatchappNotificationSerializer {
+
+ private val logger = Logger.withTag("WatchappNotificationSerializer")
+
+ /**
+ * @param sbn The OS notification.
+ * @param posted true if just posted, false if removed (cleared).
+ * @param context Used to resolve drawables for icon and RemoteViews
+ * extraction. 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) — icon-shaped fields will simply be omitted.
+ * @param subscription The watchapp's subscription, including its
+ * requested fields. Used to gate extraction.
+ */
+ fun serialize(
+ sbn: StatusBarNotification,
+ posted: Boolean,
+ context: Context?,
+ subscription: NotificationSubscription,
+ ): String {
+ val n: Notification = sbn.notification
+ val extras: Bundle = n.extras ?: Bundle.EMPTY
+ val fields = subscription.fields
+
+ // Diagnostic: surface the parsed fields set so we can confirm
+ // per-watchapp opt-in is reaching the serializer correctly. Fires
+ // once per dispatched notification, at trace level.
+ logger.v {
+ "serialize ${sbn.packageName} posted=$posted fields=$fields"
+ }
+
+ return buildJsonObject {
+ // --- Always-included notification-level metadata ----------
+ // These are the keys watchapps need to interpret the rest of
+ // the payload. They're cheap (no extraction work) so we
+ // always emit them regardless of `fields` opt-in.
+ put("package", sbn.packageName)
+ put("posted", posted)
+ put("key", sbn.key)
+ put("postTime", sbn.postTime)
+ put("groupKey", sbn.groupKey)
+
+ // --- Text content fields ----------------------------------
+ if (Field.TITLE in fields) {
+ put("title", extras.getCharSequence(Notification.EXTRA_TITLE)?.toString())
+ }
+ if (Field.TEXT in fields) {
+ put("text", extras.getCharSequence(Notification.EXTRA_TEXT)?.toString())
+ }
+ if (Field.SUB_TEXT in fields) {
+ put("subText", extras.getCharSequence(Notification.EXTRA_SUB_TEXT)?.toString())
+ }
+ if (Field.INFO_TEXT in fields) {
+ put("infoText", extras.getCharSequence(Notification.EXTRA_INFO_TEXT)?.toString())
+ }
+
+ // --- Semantic ---------------------------------------------
+ if (Field.CATEGORY in fields) {
+ put("category", n.category)
+ }
+
+ // --- Standard icons (smallIcon / largeIcon) ---------------
+ // Skipped on removal events (icon adds no signal there) and
+ // when no Context is available. Only extract icons the
+ // watchapp actually asked for — saves a Drawable.draw() pass
+ // per skipped icon.
+ val wantsSmall = Field.SMALL_ICON_BASE64 in fields
+ val wantsLarge = Field.LARGE_ICON_BASE64 in fields
+ if ((wantsSmall || wantsLarge) && posted && context != null) {
+ val icons: NotificationIconExtractor.Icons = try {
+ NotificationIconExtractor.extract(
+ context = context,
+ notification = n,
+ wantSmall = wantsSmall,
+ wantLarge = wantsLarge,
+ )
+ } catch (e: Exception) {
+ logger.v(e) { "icon extract threw, continuing without icons" }
+ NotificationIconExtractor.Icons.EMPTY
+ }
+ if (wantsSmall) put("smallIconBase64", icons.smallIconBase64)
+ if (wantsLarge) put("largeIconBase64", icons.largeIconBase64)
+ } else {
+ if (wantsSmall) put("smallIconBase64", null as String?)
+ if (wantsLarge) put("largeIconBase64", null as String?)
+ }
+
+ // --- BigPicture / MediaMetadata / Messaging / Inbox -------
+ // These are heavyweight (Bitmap decoding, MediaSession lookup,
+ // structured array assembly) — extracted only when requested.
+ if (Field.BIG_PICTURE_BASE64 in fields) {
+ // Extracted only on posted events with a valid Context;
+ // a removal event has nothing useful to encode and a
+ // null Context can't resolve Icon-typed pictures.
+ val bigPicture: String? = if (posted && context != null) {
+ try {
+ BigPictureExtractor.extract(context, n)
+ } catch (e: Exception) {
+ logger.v(e) { "BigPicture extract threw, continuing" }
+ null
+ }
+ } else null
+ put("bigPictureBase64", bigPicture)
+ }
+ if (Field.MEDIA_METADATA in fields) {
+ val mediaJson: JsonElement = if (posted && context != null) {
+ try {
+ MediaSessionExtractor.extract(context, n) ?: JsonNull
+ } catch (e: Exception) {
+ logger.v(e) { "MediaSession extract threw, continuing" }
+ JsonNull
+ }
+ } else JsonNull
+ put("mediaMetadata", mediaJson)
+ }
+ if (Field.MESSAGING_MESSAGES in fields) {
+ putJsonArray("messagingMessages") {
+ extractMessagingMessages(n).forEach { msgObj ->
+ add(msgObj)
+ }
+ }
+ }
+ if (Field.INBOX_LINES in fields) {
+ putJsonArray("inboxLines") {
+ extractInboxLines(extras).forEach { line ->
+ add(JsonPrimitive(line))
+ }
+ }
+ }
+
+ // --- Actions ----------------------------------------------
+ if (Field.ACTIONS in fields) {
+ putJsonArray("actions") {
+ val actions = n.actions
+ if (actions != null) {
+ for (action in actions) {
+ addJsonObject {
+ put("title", action.title?.toString())
+ // The PendingIntent itself can't be
+ // exercised remotely from the watch in
+ // v1 — surfacing presence so watchapps
+ // can render "actions available" hints.
+ put("hasIntent", action.actionIntent != null)
+ }
+ }
+ }
+ }
+ }
+
+ // --- Notification.extras (primitive whitelist) ------------
+ if (Field.EXTRAS in fields) {
+ putJsonObject("extras") {
+ encodeExtras(extras)
+ }
+ }
+
+ // --- Icon-typed extras (Android 14+ Ongoing Activity glyphs) ---
+ // Maps' turn arrows, lane guidance, arrival flag etc. live here
+ // under android.ongoingActivityNoti.* keys — NOT in the
+ // standard smallIcon / largeIcon slots, and not in
+ // contentView / bigContentView RemoteViews (which Maps'
+ // ongoing-activity notifications don't populate at all).
+ if (Field.ICON_EXTRAS in fields) {
+ val iconExtras: JsonObject = if (posted && context != null) {
+ try {
+ IconExtrasExtractor.extract(context, n)
+ } catch (e: Exception) {
+ logger.v(e) { "IconExtrasExtractor threw, continuing" }
+ JsonObject(emptyMap())
+ }
+ } else {
+ JsonObject(emptyMap())
+ }
+ put("iconExtras", iconExtras)
+ }
+ }.toString()
+ }
+
+ /**
+ * Walk [extras]' primitive entries into the surrounding JSON object.
+ * Non-primitive types (Parcelables, Bitmaps, RemoteInput, byte
+ * arrays) are dropped — they don't survive JSON crossing and are
+ * typically too large for Bluetooth anyway. Logged at trace because
+ * notifications routinely have these (e.g. EXTRA_LARGE_ICON).
+ */
+ private fun JsonObjectBuilder.encodeExtras(extras: Bundle) {
+ for (key in extras.keySet()) {
+ val jsonValue = encodeExtra(extras, key) ?: continue
+ // Can't use put(key, JsonElement) directly because
+ // JsonObjectBuilder doesn't accept arbitrary JsonElement —
+ // route 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)
+ }
+ }
+ }
+
+ @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)
+ else -> {
+ logger.v { "skipping non-primitive extra '$key' (${value::class.simpleName})" }
+ null
+ }
+ }
+ }
+
+ /**
+ * Extract messages from a [Notification.MessagingStyle]-styled
+ * notification by parsing `Notification.EXTRA_MESSAGES` directly.
+ * We avoid `Notification.MessagingStyle.extractMessagingStyleFromNotification`
+ * because it doesn't reliably resolve against every Android compileSdk
+ * level even where it's documented to exist; reading the underlying
+ * extras bundles uses only stable Bundle APIs and works on every
+ * platform version that produces MessagingStyle notifications (API 24+).
+ *
+ * Per-message JSON shape: `{ sender, timestamp, text }`. `sender` is
+ * read from the `sender_person` Person on API 28+ (matching what
+ * `Notification.MessagingStyle.Message.senderPerson` would return),
+ * falling back to the legacy CharSequence `sender` key on older
+ * platforms. May be null in either case (MessagingStyle convention:
+ * null sender = "you").
+ */
+ @Suppress("DEPRECATION")
+ private fun extractMessagingMessages(n: Notification): List {
+ val extras: Bundle = n.extras ?: return emptyList()
+ val rawMessages = try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ extras.getParcelableArray(
+ Notification.EXTRA_MESSAGES,
+ android.os.Parcelable::class.java
+ )
+ } else {
+ extras.getParcelableArray(Notification.EXTRA_MESSAGES)
+ }
+ } catch (e: Exception) {
+ logger.v(e) { "EXTRA_MESSAGES read failed" }
+ null
+ } ?: return emptyList()
+
+ if (rawMessages.isEmpty()) return emptyList()
+
+ val out = mutableListOf()
+ for (raw in rawMessages) {
+ if (raw !is Bundle) continue
+ val text = try {
+ raw.getCharSequence("text")?.toString()
+ } catch (e: Exception) { null }
+ val timestamp = try {
+ raw.getLong("time")
+ } catch (e: Exception) { 0L }
+ out += buildJsonObject {
+ put("sender", senderNameFromMessageBundle(raw))
+ put("timestamp", timestamp)
+ put("text", text)
+ }
+ }
+ return out
+ }
+
+ /**
+ * Derive the message's sender display name from a per-message
+ * Bundle. Tries `sender_person` (a Person Parcelable on API 28+,
+ * matching what MessagingStyle.Message.senderPerson would expose)
+ * first, falling back to the legacy CharSequence `sender` key.
+ * Both can legitimately be null — MessagingStyle convention treats
+ * a null sender as "this message is from the user themselves."
+ */
+ @Suppress("DEPRECATION")
+ private fun senderNameFromMessageBundle(bundle: Bundle): String? {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ try {
+ val person: android.app.Person? =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ bundle.getParcelable("sender_person", android.app.Person::class.java)
+ } else {
+ bundle.getParcelable("sender_person")
+ }
+ val name = person?.name
+ if (name != null) return name.toString()
+ } catch (e: Throwable) {
+ // Fall through to the legacy `sender` CharSequence key.
+ }
+ }
+ return try {
+ bundle.getCharSequence("sender")?.toString()
+ } catch (e: Throwable) {
+ null
+ }
+ }
+
+ /**
+ * Extract inbox-style notification lines. `Notification.EXTRA_TEXT_LINES`
+ * is a `CharSequence[]` array populated by `Notification.InboxStyle`.
+ * Returns an empty list if the notification isn't InboxStyle or the
+ * extras key isn't populated.
+ */
+ private fun extractInboxLines(extras: Bundle): List {
+ val raw = try {
+ extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES)
+ } catch (e: Exception) {
+ logger.v(e) { "EXTRA_TEXT_LINES read failed" }
+ null
+ } ?: return emptyList()
+ return raw.mapNotNull { it?.toString() }
+ }
+}
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/NotificationSubscription.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/metadata/pbw/appinfo/NotificationSubscription.kt
new file mode 100644
index 000000000..7850905f9
--- /dev/null
+++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/metadata/pbw/appinfo/NotificationSubscription.kt
@@ -0,0 +1,278 @@
+package io.rebble.libpebblecommon.metadata.pbw.appinfo
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.descriptors.buildClassSerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonDecoder
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonEncoder
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonPrimitive
+
+/**
+ * Declares one notification source a watchapp wishes to subscribe to,
+ * plus the set of payload fields the watchapp wants extracted and
+ * forwarded for notifications from that source.
+ *
+ * Two ergonomic forms are accepted in `package.json`:
+ *
+ * 1. Bare string (legacy form, equivalent to opting into [DEFAULT_FIELDS]):
+ * ```
+ * "notificationFilter": ["com.google.android.apps.maps"]
+ * ```
+ *
+ * 2. Object form (explicit field opt-in):
+ * ```
+ * "notificationFilter": [
+ * { "package": "com.google.android.apps.maps",
+ * "fields": ["title", "text", "category"] }
+ * ]
+ * ```
+ *
+ * The two forms can be mixed within the same array — bare strings are
+ * shorthand, objects are precise.
+ *
+ * The `fields` list controls bandwidth and CPU: only the listed fields
+ * are extracted phone-side and emitted in the JSON payload pushed to
+ * the watchapp's PKJS as an `'appnotification'` event. Notification-
+ * level metadata (`package`, `posted`, `key`, `postTime`, `groupKey`) is
+ * always included regardless of `fields` — watchapps need it to
+ * interpret the rest of the payload.
+ *
+ * When `fields` is omitted, [DEFAULT_FIELDS] is used. This matches the
+ * payload shape from before per-field opt-in existed, so watchapps that
+ * worked under the old schema continue to work unchanged.
+ *
+ * See [Field] for the v1 set of recognized field names.
+ */
+@Serializable(with = NotificationSubscription.Serializer::class)
+data class NotificationSubscription(
+ /** Android package name of the source app (e.g. `"com.google.android.apps.maps"`). */
+ val packageName: String,
+ /**
+ * Set of [Field] names the watchapp wants extracted from each
+ * matching notification. Names not in [Field.ALL_NAMES] are silently
+ * ignored (forward compatibility — a future libpebble3 may add
+ * fields a current watchapp doesn't know about, and vice versa).
+ */
+ val fields: Set = DEFAULT_FIELDS,
+) {
+ companion object {
+ /**
+ * Field set delivered when a subscription is declared without an
+ * explicit `fields` array. Chosen to match the payload shape
+ * delivered by libpebble3 versions before per-field opt-in
+ * existed, so legacy `notificationFilter: ["pkg"]` declarations
+ * receive exactly the same fields they did before.
+ */
+ val DEFAULT_FIELDS: Set = setOf(
+ Field.TITLE,
+ Field.TEXT,
+ Field.SUB_TEXT,
+ Field.INFO_TEXT,
+ Field.CATEGORY,
+ Field.SMALL_ICON_BASE64,
+ Field.LARGE_ICON_BASE64,
+ Field.EXTRAS,
+ )
+ }
+
+ /**
+ * Catalog of v1 field names a watchapp may declare. Adding a field
+ * here is the only schema change needed to introduce a new
+ * extractor; the platform-side serializer reads `fields` and emits
+ * each requested name's payload independently.
+ *
+ * Field names are stable and forward-compatible: a watchapp built
+ * against a newer libpebble3 declaring `"mediaMetadata"` will still
+ * load on an older libpebble3 (it just won't get that field in
+ * payloads).
+ */
+ object Field {
+ // --- Text content (extras.getCharSequence based) ---
+
+ /** Notification's primary title (`Notification.EXTRA_TITLE`). */
+ const val TITLE = "title"
+
+ /** Notification's main text body (`Notification.EXTRA_TEXT`). */
+ const val TEXT = "text"
+
+ /** Smaller secondary text line (`Notification.EXTRA_SUB_TEXT`). */
+ const val SUB_TEXT = "subText"
+
+ /** Auxiliary info shown to the right (`Notification.EXTRA_INFO_TEXT`). */
+ const val INFO_TEXT = "infoText"
+
+ // --- Semantic ---
+
+ /**
+ * Notification category constant such as `"navigation"`, `"call"`,
+ * `"msg"`, `"transport"`, `"alarm"`, etc. Useful as a cheap
+ * dispatch hint for watchapps subscribed to multiple packages.
+ */
+ const val CATEGORY = "category"
+
+ // --- Standard Android icons ---
+
+ /**
+ * Notification's `smallIcon` rasterized to a 32×32 ARGB PNG and
+ * base64-encoded. On many apps (Google Maps included) this is
+ * the brand glyph rather than turn-specific iconography — see
+ * [ICON_EXTRAS] for apps that publish dynamic per-state icons
+ * via the Android 14+ Ongoing Activity API.
+ */
+ const val SMALL_ICON_BASE64 = "smallIconBase64"
+
+ /**
+ * Notification's `largeIcon` rasterized to a 32×32 ARGB PNG and
+ * base64-encoded. Used by music apps for compact album art and
+ * by chat apps for sender avatars.
+ */
+ const val LARGE_ICON_BASE64 = "largeIconBase64"
+
+ // --- BigPictureStyle / MediaStyle / MessagingStyle / InboxStyle ---
+
+ /**
+ * The full-resolution photo from a `BigPictureStyle` notification,
+ * downsampled to fit a configured byte cap (default 8KB) and
+ * base64-encoded. This is where photo apps (Photos, gallery,
+ * Instagram-style notifications) and some music apps put album
+ * art at higher resolution than [LARGE_ICON_BASE64].
+ */
+ const val BIG_PICTURE_BASE64 = "bigPictureBase64"
+
+ /**
+ * Structured media metadata from the `MediaSession` associated
+ * with a `MediaStyle` notification: `{ title, artist, album,
+ * durationMs, positionMs, playbackState, albumArtBase64 }`.
+ * `albumArtBase64` is omitted if larger than the cap (default
+ * 8KB after downsampling). All fields nullable individually.
+ */
+ const val MEDIA_METADATA = "mediaMetadata"
+
+ /**
+ * Array of messages from a `MessagingStyle` notification, each
+ * `{ sender, timestamp, text }`. Messaging apps (Signal, WhatsApp,
+ * SMS, etc.) deliver conversation context this way; the
+ * notification's [TEXT] field typically carries only the most
+ * recent message.
+ */
+ const val MESSAGING_MESSAGES = "messagingMessages"
+
+ /**
+ * Array of strings from an `InboxStyle` notification — used by
+ * email/news apps showing N unread items. Each entry is a
+ * single line as the source app intends it.
+ */
+ const val INBOX_LINES = "inboxLines"
+
+ // --- Interaction surface ---
+
+ /**
+ * Array of `{ title, hasIntent }` for each declared notification
+ * action button. PendingIntents themselves aren't forwarded
+ * (can't be exercised remotely from the watch in v1), but the
+ * presence flag lets watchapps surface "this notification has
+ * actions" affordances.
+ */
+ const val ACTIONS = "actions"
+
+ // --- App-defined data ---
+
+ /**
+ * The `Notification.extras` bundle's primitive entries
+ * (CharSequence / String / Boolean / numeric types) merged into
+ * a flat map. Non-primitive entries (Parcelables, Bitmaps,
+ * RemoteInputs, byte arrays) are stripped — they don't survive
+ * JSON crossing and are usually too large for Bluetooth anyway.
+ * Apps occasionally publish well-defined integer codes in
+ * extras (e.g. older Maps versions exposed a navigation-state
+ * int); subscribing to [EXTRAS] is the way to read them.
+ */
+ const val EXTRAS = "extras"
+
+ /**
+ * Icon-typed entries from [android.app.Notification.extras],
+ * rasterized to 32×32 base64 PNGs and emitted as an object map
+ * keyed by extras key. This is where Android 14+ Ongoing
+ * Activity notifications stash their dynamic glyphs — Google
+ * Maps' turn-direction arrow lives at
+ * `android.ongoingActivityNoti.chipIcon` /
+ * `android.ongoingActivityNoti.nowbarIcon` /
+ * `android.ongoingActivityNoti.secondIcon`, none of which
+ * surface via the standard [SMALL_ICON_BASE64] / [LARGE_ICON_BASE64]
+ * slots.
+ *
+ * Per-icon shape: `{ base64, hash, intrinsicW, intrinsicH }`.
+ * Hash is djb2 over the rasterized 32×32 pixel ints; useful
+ * for cheap state-change detection across notifications without
+ * comparing the full base64 payload.
+ */
+ const val ICON_EXTRAS = "iconExtras"
+
+ /** All recognized field names. Used for forward-compat filtering. */
+ val ALL_NAMES: Set = setOf(
+ TITLE, TEXT, SUB_TEXT, INFO_TEXT,
+ CATEGORY,
+ SMALL_ICON_BASE64, LARGE_ICON_BASE64,
+ BIG_PICTURE_BASE64, MEDIA_METADATA,
+ MESSAGING_MESSAGES, INBOX_LINES,
+ ACTIONS,
+ EXTRAS, ICON_EXTRAS,
+ )
+ }
+
+ /**
+ * Polymorphic serializer accepting either bare-string or object
+ * form in JSON. On encode we always emit object form for
+ * round-trip stability.
+ */
+ object Serializer : KSerializer {
+ override val descriptor: SerialDescriptor =
+ buildClassSerialDescriptor("NotificationSubscription")
+
+ override fun deserialize(decoder: Decoder): NotificationSubscription {
+ val jsonDecoder = decoder as? JsonDecoder
+ ?: error("NotificationSubscription requires Json")
+ return when (val element: JsonElement = jsonDecoder.decodeJsonElement()) {
+ is JsonPrimitive -> {
+ require(element.isString) {
+ "notificationFilter entry must be a string or object, got: $element"
+ }
+ NotificationSubscription(packageName = element.content)
+ }
+ is JsonObject -> {
+ val pkg = element["package"]?.jsonPrimitive?.content
+ ?: error("notificationFilter object missing required 'package' field")
+ val fields = element["fields"]
+ ?.jsonArray
+ ?.mapTo(mutableSetOf()) { it.jsonPrimitive.content }
+ ?: DEFAULT_FIELDS.toMutableSet()
+ NotificationSubscription(packageName = pkg, fields = fields)
+ }
+ is JsonArray -> error(
+ "notificationFilter entry must be a string or object, got array: $element"
+ )
+ }
+ }
+
+ override fun serialize(encoder: Encoder, value: NotificationSubscription) {
+ val jsonEncoder = encoder as? JsonEncoder
+ ?: error("NotificationSubscription requires Json")
+ jsonEncoder.encodeJsonElement(buildJsonObject {
+ put("package", JsonPrimitive(value.packageName))
+ put(
+ "fields",
+ JsonArray(value.fields.map { JsonPrimitive(it) })
+ )
+ })
+ }
+ }
+}
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..dcfa486c2 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,52 @@ 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,
+ /**
+ * Notification subscriptions this watchapp wants to receive. Each
+ * entry names an Android source package and the set of payload
+ * fields the watchapp wants extracted from notifications posted by
+ * that package. Subscribed notifications 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.
+ *
+ * Two shapes are accepted in `package.json`:
+ *
+ * - Bare string: `"com.google.android.apps.maps"` — equivalent to
+ * subscribing with [NotificationSubscription.DEFAULT_FIELDS].
+ * This is the legacy form; existing watchapps written for older
+ * libpebble3 versions continue to parse correctly.
+ * - Object: `{ "package": "com.google.android.apps.maps",
+ * "fields": ["title", "text", "category"] }`
+ * — opts into a specific field set, controlling bandwidth and
+ * CPU cost on the phone side.
+ *
+ * Empty / absent means this watchapp does not receive notifications.
+ *
+ * See [NotificationSubscription.Field] for the catalog of v1 field
+ * names.
+ */
+ 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..94cc4124a
--- /dev/null
+++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/notification/WatchappNotificationDispatcher.kt
@@ -0,0 +1,102 @@
+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
+import io.rebble.libpebblecommon.metadata.pbw.appinfo.NotificationSubscription
+
+/**
+ * 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.
+ *
+ * Per-watchapp serialization: each subscribed watchapp may have asked for
+ * a different set of fields ([NotificationSubscription.fields]).
+ * Serialization is therefore deferred until the dispatch loop knows which
+ * watchapp it's serializing for; the platform listener passes a
+ * [SerializerCallback] rather than a pre-built JSON string. Field
+ * extraction is gated on what each watchapp asked for so heavyweight
+ * extractors (BigPicture decoding, MediaSession lookup, RemoteViews
+ * traversal) are skipped entirely for watchapps that didn't request them.
+ */
+class WatchappNotificationDispatcher(
+ private val libPebble: LibPebble,
+) {
+ companion object {
+ private val logger = Logger.withTag(WatchappNotificationDispatcher::class.simpleName!!)
+ }
+
+ /**
+ * Build a notification payload tailored to one subscribing watchapp.
+ * Implemented platform-side because Android's `StatusBarNotification`
+ * extraction can't be expressed in commonMain.
+ */
+ fun interface SerializerCallback {
+ /**
+ * @param subscription Which watchapp is receiving — its
+ * [NotificationSubscription.fields] determines what the
+ * platform side extracts and emits.
+ * @return Serialized JSON ready for `triggerOnAppNotification`,
+ * or null if extraction failed unrecoverably (notification
+ * will be silently skipped for this watchapp).
+ */
+ fun build(subscription: NotificationSubscription): String?
+ }
+
+ /**
+ * @param packageName Source package of the notification.
+ * @param serializer Per-watchapp payload builder; called once per
+ * subscribing watchapp.
+ */
+ fun dispatch(packageName: String, serializer: SerializerCallback) {
+ val targets = currentSubscribedApps(packageName)
+ if (targets.isEmpty()) return
+ logger.v { "dispatching $packageName notification to ${targets.size} watchapp(s)" }
+ for ((app, subscription) in targets) {
+ try {
+ val json = serializer.build(subscription) ?: continue
+ app.triggerOnAppNotification(json)
+ } catch (e: Exception) {
+ logger.w(e) { "failed to deliver notification to ${app.uuid}" }
+ }
+ }
+ }
+
+ /**
+ * Find currently-running PKJS watchapps subscribed to [packageName],
+ * paired with the matching subscription so the dispatch loop knows
+ * which fields each watchapp asked for. 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()
+ .mapNotNull { app ->
+ val sub = app.subscriptionFor(packageName) ?: return@mapNotNull null
+ app to sub
+ }
+ .toList()
+ }
+}
+
+private fun PKJSApp.subscriptionFor(packageName: String): NotificationSubscription? =
+ appInfo.notificationFilter.firstOrNull { it.packageName == 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..aa08e7799
--- /dev/null
+++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareUrlResolver.kt
@@ -0,0 +1,213 @@
+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 = 16.seconds
+ private const val MAX_ATTEMPTS = 4
+
+ /**
+ * Backoff delays BEFORE each attempt, in milliseconds. Index 0 is
+ * before attempt 1 (initial fire), index N is before attempt N+1.
+ *
+ * attempt 1: 0ms baseline (immediate, but with jitter)
+ * attempt 2: 2000ms after failure of 1
+ * attempt 3: 4000ms after failure of 2
+ * attempt 4: 6000ms after failure of 3
+ *
+ * Each value gets ±[JITTER_MS] of symmetric noise added. The reason
+ * we don't fire at exactly 0/2/4/6s: Google's anti-bot heuristics
+ * appear to fingerprint request timing patterns, so perfectly
+ * regular intervals are themselves a signal. Jitter adds 200ms of
+ * naturalness — small enough to not hurt UX, large enough to
+ * scramble the period.
+ *
+ * Even attempt 1 gets jittered (0..200ms) to avoid the "fire
+ * immediately on share intent" pattern that's distinctive in
+ * server logs.
+ */
+ private val BACKOFF_MS = longArrayOf(0L, 2_000L, 4_000L, 6_000L)
+ private const val JITTER_MS = 200L
+ }
+
+ /**
+ * Symmetric jitter around a base delay. Returns base + uniform[-J, +J].
+ * For BACKOFF_MS[0] = 0L this returns 0..JITTER_MS (clamped non-negative).
+ */
+ private fun jittered(baseMs: Long): Long {
+ // kotlin.random.Random is fine here — we don't need crypto-grade
+ // randomness, just unpredictable-enough timing.
+ val noise = kotlin.random.Random.nextLong(-JITTER_MS, JITTER_MS + 1)
+ val v = baseMs + noise
+ return if (v < 0L) 0L else v
+ }
+
+ /**
+ * 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 404/403 the next
+ * second. We use four attempts with exponentially-increasing backoff
+ * (0, 2s, 4s, 6s) plus per-attempt jitter (±200ms) to spread requests
+ * across the suspect rate-limit window without looking like a robotic
+ * polling loop. Total worst-case latency is bounded by RESOLVE_TIMEOUT
+ * and runs concurrently with PKJS spinup so partial latency is hidden.
+ */
+ suspend fun resolveIfShortened(url: String): String {
+ if (!isShortenedMapsUrl(url)) return url
+
+ val resolved = withTimeoutOrNull(RESOLVE_TIMEOUT) {
+ for (attempt in 1..MAX_ATTEMPTS) {
+ // Pre-attempt wait with jitter. Even the first attempt gets
+ // 0..JITTER_MS of jitter so back-to-back shares don't all
+ // fire at exactly the same offset from the share intent.
+ val waitMs = jittered(BACKOFF_MS[attempt - 1])
+ if (waitMs > 0L) {
+ logger.v { "resolve attempt $attempt waiting ${waitMs}ms before fire" }
+ kotlinx.coroutines.delay(waitMs)
+ }
+ 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
+ }
+ // Loop continues; next iteration's pre-attempt wait kicks in.
+ }
+ 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)