Watchapp notification subscription api#1
Closed
killdano wants to merge 2 commits into
Closed
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
notificationFilterarray.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 sameJsRunnermechanism, and readpackage.jsonfields via the samePbwAppInfoparser. The base branch for this PR is set toshare-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:
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:
Consent model
A watchapp's declaration in
package.jsonis 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
getActiveNotificationsfor catch-up.Icon forwarding
smallIconBase64andlargeIconBase64ship 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.
NotificationIconExtractorrasterizes the icons viaDrawable.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 AndroidStatusBarNotificationto JSON for PKJS consumption. Surfaces well-known fields at top level for convenience plus a rawextrasmap for app-specific keys (e.g. Maps'songoingActivityNoti.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.LibPebbleNotificationListeneralready 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:
getActiveNotificationscold-start retrieval (user launches MirrorMap mid-trip with Maps already navigating; gets immediate state without waiting for next push)posted: falseevent when Maps clears its persistent ongoing-activity notificationCaveats / known limitations
JavascriptCoreJsRunner.ktno-op for iOS. PKJS event interface is platform-neutral so iOS can be added later.Files
New
libpebble3/.../notification/WatchappNotificationDispatcher.ktlibpebble3/.../notification/WatchappNotificationSerializer.ktlibpebble3/.../notification/NotificationIconExtractor.ktlibpebble3/.../di/WatchappNotificationModule.ktModified — see commit for full list. Highlights:
package.jsonparsing (PbwAppInfo.kt) fornotificationFilterfield, PKJS event registration, fan-out hook inLibPebbleNotificationListener.kt.cc @ericmigi — per our discussion. Stacked on coredevices#171 (share-intent target API).