Skip to content

Watchapp notification subscription api#1

Closed
killdano wants to merge 2 commits into
masterfrom
watchapp-notification-subscription-api
Closed

Watchapp notification subscription api#1
killdano wants to merge 2 commits into
masterfrom
watchapp-notification-subscription-api

Conversation

@killdano
Copy link
Copy Markdown
Owner

@killdano killdano commented May 2, 2026

Add notification subscription API for watchapps

This PR introduces a platform capability for watchapps to receive structured notification events from packages they declare interest in. Designed for live mirroring use cases where a watchapp wants to surface a phone-side app's ongoing-activity state on the watch face — turn-by-turn from Google Maps, now-playing from Spotify, fitness session from Strava, ride status from Uber, etc.

The motivating use case is MirrorMap, which uses Maps' navigation notifications (turn text + distance + ETA + arrow icon) to drive a live HUD overlay on the watch independent of any MirrorMap-side route state. But nothing in this PR is Maps-specific — any package can be declared in the watchapp's notificationFilter array.

Builds on coredevices#171

This PR is stacked on top of [Add share-intent target API for watchapps](coredevices#171) because the two share PKJS infrastructure — both add new event types via the same registration pattern in startup.js, dispatch through the same JsRunner mechanism, and read package.json fields via the same PbwAppInfo parser. The base branch for this PR is set to share-intent-target-api, so the diff shows only the incremental notification-subscription changes. After PR 1 lands, this PR's base will auto-rebase to master.

What's new

Watchapp side

Watchapps opt in via a new field in package.json:

{
  "notificationFilter": ["com.google.android.apps.maps", "com.spotify.music"]
}

PKJS receives notifications via:

Pebble.addEventListener('appnotification', function(e) {
  // e.package         — source package name
  // e.posted          — true on post, false on removal
  // e.title, e.text   — convenience-surfaced text fields
  // e.subText, e.infoText
  // e.smallIconBase64 — base64 PNG, 32×32 (see "Icon forwarding" below)
  // e.largeIconBase64 — base64 PNG, 32×32
  // e.extras          — full extras Bundle as a JSON object
});

Watchapps that need to catch up on existing notification state when spinning up (e.g. user launched watchapp mid-trip with Maps already navigating) call:

Pebble.getActiveNotifications(['com.google.android.apps.maps'])
  // returns array of notification objects in the same shape as appnotification events

Consent model

A watchapp's declaration in package.json is the consent signal. The user explicitly installed a watchapp that announced its notification interests; no separate per-watchapp consent prompt is required. The Pebble app's existing notification access grant — a single OS-level permission already granted by the user — is the only OS-level gate.

This keeps the UX simple while remaining responsible: watchapps can't silently expand their notification access after install (the filter list is read from the PBW manifest at install time and fixed for the lifetime of that PBW version).

Foreground-only dispatch

PKJS only exists for the watchapp currently running on the watch, so notifications only flow while the user has the watchapp open. This is a deliberate choice — it bounds the privacy surface area and avoids the watchapp-as-background-listener antipattern. Watchapps that need to bootstrap state on launch use getActiveNotifications for catch-up.

Icon forwarding

smallIconBase64 and largeIconBase64 ship the notification's icons as base64-encoded 32×32 PNGs.

This is included because notification icons are often the most semantically rich visual signal — Maps' turn arrows (left, right, straight, U-turn, exit, roundabout, etc.) live in the smallIcon, music apps put album art in largeIcon, fitness apps put activity glyphs in smallIcon. Without forwarding the icons, watchapps would have to bake a separate icon set into firmware for every source app they want to mirror — which doesn't scale.

NotificationIconExtractor rasterizes the icons via Drawable.draw() to a fixed 32×32 ARGB bitmap and PNG-encodes them. Failures are best-effort (logged + skipped) — they don't affect the rest of the payload. Icons are skipped on removal events (the icon adds no signal when the notification is being cleared).

Cost: ~280-820 bytes per icon base64-encoded; trivial overhead at typical notification rates.

Architecture

Three new classes split by platform:

  • WatchappNotificationDispatcher (commonMain) — fans incoming notifications out to the currently-running PKJS watchapps that subscribe to the source package. Cheap to call on every notification (typically O(connected_watches) with usually 1 watch and 1 running PKJS).

  • WatchappNotificationSerializer (androidMain) — converts an Android StatusBarNotification to JSON for PKJS consumption. Surfaces well-known fields at top level for convenience plus a raw extras map for app-specific keys (e.g. Maps's ongoingActivityNoti.next_step_message). Non-primitive extras (Parcelables, RemoteInput) are skipped — JSON isn't the right format for those.

  • NotificationIconExtractor (androidMain) — Drawable → 32×32 bitmap → PNG → base64. Fast path when the Drawable is already a bitmap of the target size; otherwise standard Canvas.draw() rasterize.

LibPebbleNotificationListener already had the platform-level notification access; this PR adds a sibling fan-out path to the existing notification-relay pipeline. The original watch-UI relay is unchanged; failures in the new watchapp dispatch path don't affect it.

Testing notes

End-to-end validated on a Samsung Galaxy + Pebble Time Steel with MirrorMap as the subscriber. Tested:

  • ✅ Live notification streaming during Google Maps active navigation (notifications arriving every ~10s, parsed for turn text + distance + ETA, displayed on watch via MirrorMap's HUD overlay)
  • getActiveNotifications cold-start retrieval (user launches MirrorMap mid-trip with Maps already navigating; gets immediate state without waiting for next push)
  • ✅ Notification removal events firing the posted: false event when Maps clears its persistent ongoing-activity notification
  • ✅ Icon forwarding verified end-to-end via PKJS console log: smallIcon ~968 bytes base64, largeIcon ~596 bytes base64, both null on removal events

Caveats / known limitations

  • CloudPebble strips unknown manifest fields. Same workaround as PR 1 — post-build appinfo.json patching for now.
  • iOS receiving side is not implemented. Stub JavascriptCoreJsRunner.kt no-op for iOS. PKJS event interface is platform-neutral so iOS can be added later.
  • No app-specific parsing. This PR forwards raw notification fields. Watchapps that want structured turn-direction or maneuver-type data from Maps notifications need to do their own parsing of the text/extras (or use the smallIcon as a maneuver-type signal).

Files

New

  • libpebble3/.../notification/WatchappNotificationDispatcher.kt
  • libpebble3/.../notification/WatchappNotificationSerializer.kt
  • libpebble3/.../notification/NotificationIconExtractor.kt
  • libpebble3/.../di/WatchappNotificationModule.kt

Modified — see commit for full list. Highlights: package.json parsing (PbwAppInfo.kt) for notificationFilter field, PKJS event registration, fan-out hook in LibPebbleNotificationListener.kt.


cc @ericmigi — per our discussion. Stacked on coredevices#171 (share-intent target API).

killdano added 2 commits May 2, 2026 15:22
Lets watchapps register as Android share targets so users can "Share to
<watchapp>" from any other app on their phone. The shared payload (text,
URL, or both) routes to the watchapp's PKJS via a new `'shareintent'`
event listener.

This is a platform capability that benefits any watchapp accepting
external input — navigation apps receiving Maps URLs, music apps
receiving Spotify links, note-taking apps receiving selected text, etc.
The motivating use case is MirrorMap (a Pebble nav watchapp), where
sharing a Google Maps URL into the watchapp is the primary user flow,
but nothing in this PR is Maps-specific.

## What's new

### Watchapp side

Watchapps opt in via two new fields in `package.json`:

```json
{
  "shareTarget": {
    "mimeTypes": ["text/plain"]
  }
}
```

PKJS receives shared payloads via:

```javascript
Pebble.addEventListener('shareintent', function(e) {
  // e.text — the shared text/URL
});
```

### Android app side

Three Android share-sheet surfacing strategies, layered for compatibility:

1. **Static manifest entry** ("Pebble") — universal fallback, works on every
   share-sheet implementation
2. **Sharing Shortcuts** with Direct Share metadata — surfaces individual
   watchapps as named entries on Android 11+ where supported
3. **ChooserTargetService** compat — same goal for older / Samsung-style
   share sheets where Sharing Shortcuts don't surface

When the static "Pebble" path is taken and multiple watchapps subscribe to
the shared MIME type, the user is presented with a chooser dialog to pick
the destination watchapp. When only one watchapp subscribes, dispatch is
direct (no extra tap).

### Per-watchapp icons in the share sheet

Each watchapp's share-target shortcut renders with the watchapp's own icon
extracted from its installed PBW. This required a small `.pbpack` resource
pack parser (`PbwResourcePack.kt`) since Pebble watchapps use a custom
binary format that bundles all resources into a single file. The parser
walks the manifest and resource table to extract the menu icon (declared
with `menuIcon: true` in `package.json`'s resources array) as a raw PNG,
then falls back to scanning the PBW zip root for any PNG if the resource
pack lookup fails.

The extracted icons are tinted via ColorMatrix to a brand-cohesive
appearance for share-target presentation while preserving alpha for
anti-aliasing on rounded backgrounds.

### Java-side short URL resolver

Many sharing flows generate short URLs that redirect to the actual content
URL (Google Maps' `maps.app.goo.gl`, Twitter's `t.co`, etc.). PKJS can in
principle resolve these via XHR, but in practice many short URL services
employ aggressive anti-bot measures that block XMLHttpRequest's User-Agent.

`ShareUrlResolver` runs the redirect resolution on the Android side using
the existing OkHttp/Ktor stack with a standard browser-like User-Agent.
The resolved long URL is what gets delivered to PKJS via the `shareintent`
event, so watchapp authors don't have to re-implement HTTP redirect
following in JavaScript or work around bot-detection headers.

The resolver:
- Has structured concurrency (cancels cleanly with the share dispatch)
- Retries once on transient network failure
- Uses a dedicated HttpClient instance so its configuration doesn't leak
  into other libpebble3 HTTP usage
- Times out at 10s — beyond that the share fails gracefully

Currently configured for Google Maps short URL hosts; extending to other
short URL services is a one-line addition.

## Cold-start handling

Share intents commonly arrive while the target watchapp is not currently
running on the watch. The dispatch path:

1. Receives the share intent via the new manifest entry / shortcut
2. Looks up the subscribed watchapp(s) by MIME type
3. Asks the libpebble3 connection to launch the watchapp on the watch
4. Waits for PKJS to signal "ready" (CMD_INIT round-trip complete)
5. Delivers the share event

The launch + ready-signal phase has a 30s timeout for cold-starts where
the watchapp PBW needs to be transferred fresh, with a sub-timeout on the
producer flow for the initial state read.

## Testing notes

End-to-end validated on a Samsung Galaxy + Pebble Time Steel with MirrorMap
as the share target. Tested:

- Cold-start (watchapp not running, app not in foreground) — share intent
  triggers watchapp launch, share is delivered after ready signal
- Warm-start (watchapp foreground) — share is delivered without relaunch
- Multi-watchapp scenario — chooser dialog surfaces correctly
- Per-watchapp icons render with their actual PBW menu icon
- Short URL resolution — `maps.app.goo.gl` URLs resolve to the long Maps
  directions URL before PKJS sees them

## Files

New:
- `composeApp/src/androidMain/kotlin/coredevices/coreapp/sharing/ShareTargetActivity.kt`
- `composeApp/src/androidMain/res/xml/share_targets.xml`
- `libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareTargetSync.kt`
- `libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/ShareIntentModule.kt`
- `libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/disk/pbw/PbwResourcePack.kt`
- `libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareIntentDispatcher.kt`
- `libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareTargetEntry.kt`
- `libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareTargetsProducer.kt`
- `libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareUrlResolver.kt`

Modified:
- `composeApp/build.gradle.kts` — Direct Share / shortcut metadata deps
- `composeApp/src/androidMain/AndroidManifest.xml` — share-target manifest entry
- `composeApp/src/androidMain/kotlin/coredevices/coreapp/MainApplication.kt` — bootstrap
- `composeApp/src/androidMain/kotlin/coredevices/coreapp/di/androidDefaultModule.kt` — koin
- `gradle/libs.versions.toml` — version catalog updates
- `libpebble3/src/androidMain/assets/startup.js` — PKJS event registration
- `libpebble3/src/androidMain/kotlin/.../js/WebViewJsRunner.kt` — event dispatch
- `libpebble3/src/commonMain/kotlin/.../di/LibPebbleModule.kt` — koin
- `libpebble3/src/commonMain/kotlin/.../disk/pbw/DiskUtil.kt` — resource pack helpers
- `libpebble3/src/commonMain/kotlin/.../js/JsRunner.kt` — event interface
- `libpebble3/src/commonMain/kotlin/.../js/PKJSApp.kt` — ready signal observation
- `libpebble3/src/commonMain/kotlin/.../metadata/pbw/appinfo/PbwAppInfo.kt` — shareTarget field
- `libpebble3/src/iosMain/kotlin/.../js/JavascriptCoreJsRunner.kt` — iOS no-op stub
Lets watchapps receive structured notification events from packages they
declare interest in. Designed for live mirroring use cases where a
watchapp wants to surface a phone-side app's ongoing-activity state on
the watch face — turn-by-turn from Google Maps, now-playing from
Spotify, fitness session from Strava, ride status from Uber, etc.

The motivating use case is MirrorMap, which uses Maps' navigation
notifications (turn text + distance + ETA + arrow icon) to drive a
live HUD overlay on the watch independent of any MirrorMap-side route
state. But nothing in this PR is Maps-specific — any package can be
declared in the watchapp's `notificationFilter` array.

## What's new

### Watchapp side

Watchapps opt in via a new field in `package.json`:

```json
{
  "notificationFilter": ["com.google.android.apps.maps", "com.spotify.music"]
}
```

PKJS receives notifications via:

```javascript
Pebble.addEventListener('appnotification', function(e) {
  // e.package         — source package name
  // e.posted          — true on post, false on removal
  // e.title, e.text   — convenience-surfaced text fields
  // e.subText, e.infoText
  // e.smallIconBase64 — base64 PNG, 32×32 (see "Icon forwarding" below)
  // e.largeIconBase64 — base64 PNG, 32×32
  // e.extras          — full extras Bundle as a JSON object
});
```

Watchapps that need to catch up on existing notification state when
spinning up (e.g. user launched watchapp mid-trip with Maps already
navigating) call:

```javascript
Pebble.getActiveNotifications(['com.google.android.apps.maps'])
  // returns array of notification objects in the same shape
```

### Consent model

A watchapp's declaration in `package.json` is the consent signal. The
user explicitly installed a watchapp that announced its notification
interests; no separate per-watchapp consent prompt is required. The
Pebble app's existing notification access grant — a single OS-level
permission already granted by the user — is the only OS-level gate.

This keeps the UX simple while remaining responsible: watchapps can't
silently expand their notification access after install (the filter
list is read from the PBW manifest at install time and fixed for the
lifetime of that PBW version).

### Foreground-only dispatch

PKJS only exists for the watchapp currently running on the watch, so
notifications only flow while the user has the watchapp open. This is
a deliberate choice — it bounds the privacy surface area and avoids
the watchapp-as-background-listener antipattern. Watchapps that need
to bootstrap state on launch use `getActiveNotifications` for catch-up.

### Icon forwarding

`smallIconBase64` and `largeIconBase64` ship the notification's icons
as base64-encoded 32×32 PNGs. This is included because notification
icons are often the most semantically rich visual signal — Maps' turn
arrows (left, right, straight, U-turn, exit, roundabout, etc.) live
in the smallIcon, music apps put album art in largeIcon, fitness apps
put activity glyphs in smallIcon. Without forwarding the icons,
watchapps would have to bake a separate icon set into firmware for
every source app they want to mirror — which doesn't scale.

`NotificationIconExtractor` rasterizes the icons via Drawable.draw()
to a fixed 32×32 ARGB bitmap and PNG-encodes them. Failures are
best-effort (logged + skipped) — they don't affect the rest of the
payload. Icons are skipped on removal events (the icon adds no signal
when the notification is being cleared).

Cost: ~280-820 bytes per icon base64-encoded; trivial overhead at
typical notification rates.

### Architecture

Three new classes split by platform:

- `WatchappNotificationDispatcher` (commonMain) — fans incoming
  notifications out to the currently-running PKJS watchapps that
  subscribe to the source package. Cheap to call on every
  notification (typically O(connected_watches) with usually 1 watch
  and 1 running PKJS).

- `WatchappNotificationSerializer` (androidMain) — converts an
  Android `StatusBarNotification` to JSON for PKJS consumption.
  Surfaces well-known fields at top level for convenience plus a raw
  `extras` map for app-specific keys (e.g. Maps's
  `ongoingActivityNoti.next_step_message`). Non-primitive extras
  (Parcelables, RemoteInput) are skipped — JSON isn't the right
  format for those.

- `NotificationIconExtractor` (androidMain) — Drawable → 32×32
  bitmap → PNG → base64. Fast path when the Drawable is already a
  bitmap of the target size; otherwise standard Canvas.draw()
  rasterize.

`LibPebbleNotificationListener` already had the platform-level
notification access; this PR adds a sibling fan-out path to the
existing notification-relay pipeline. The original watch-UI relay is
unchanged; failures in the new watchapp dispatch path don't affect it.

## Builds on PR 1

Stacked on the share-intent target API PR. The two PRs share PKJS
infrastructure — both add new event types via the same registration
pattern in `startup.js` and dispatch through the same `JsRunner`
mechanism, and both read `package.json` fields via the same
`PbwAppInfo` parser. PR 1 should land first, PR 2 second; the GitHub
PR base is set accordingly so reviewers see only the incremental diff.

## Testing notes

End-to-end validated on a Samsung Galaxy + Pebble Time Steel with
MirrorMap as the subscriber. Tested:

- Live notification streaming during Google Maps active navigation
  (notifications arriving every ~10s, parsed for turn text + distance
  + ETA, displayed on watch via MirrorMap's HUD overlay)
- `getActiveNotifications` cold-start retrieval (user launches
  MirrorMap mid-trip with Maps already navigating; gets immediate
  state without waiting for next push)
- Notification removal events firing the `posted: false` event when
  Maps clears its persistent ongoing-activity notification
- Icon forwarding verified end-to-end via PKJS console log:
  smallIcon ~968 bytes base64, largeIcon ~596 bytes base64, both
  null on removal events

## Files

New (commonMain):
- `libpebble3/src/commonMain/kotlin/.../notification/WatchappNotificationDispatcher.kt`
- `libpebble3/src/commonMain/kotlin/.../di/WatchappNotificationModule.kt`

New (androidMain):
- `libpebble3/src/androidMain/kotlin/.../notification/WatchappNotificationSerializer.kt`
- `libpebble3/src/androidMain/kotlin/.../notification/NotificationIconExtractor.kt`

Modified:
- `libpebble3/src/commonMain/kotlin/.../metadata/pbw/appinfo/PbwAppInfo.kt` — notificationFilter field
- `libpebble3/src/commonMain/kotlin/.../js/PKJSApp.kt` — onAppNotification entry point
- `libpebble3/src/commonMain/kotlin/.../js/JsRunner.kt` — event interface
- `libpebble3/src/commonMain/kotlin/.../js/PKJSInterface.kt` — getActiveNotifications binding
- `libpebble3/src/commonMain/kotlin/.../di/LibPebbleModule.kt` — koin
- `libpebble3/src/androidMain/kotlin/.../js/WebViewJsRunner.kt` — Android event dispatch
- `libpebble3/src/androidMain/kotlin/.../js/WebViewPKJSInterface.kt` — Android getActiveNotifications
- `libpebble3/src/androidMain/kotlin/.../notification/LibPebbleNotificationListener.kt` — fan-out hook
- `libpebble3/src/androidMain/assets/startup.js` — PKJS event registration
- `libpebble3/src/iosMain/kotlin/.../js/JavascriptCoreJsRunner.kt` — iOS no-op stub
- `libpebble3/src/iosMain/kotlin/.../js/JSCPKJSInterface.kt` — iOS no-op stub
@killdano killdano closed this May 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant