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)