From bb06b8c7674f5051c329b846ba2e43ab4bbc7eda Mon Sep 17 00:00:00 2001 From: Skylar Sadlier Date: Tue, 18 Aug 2026 15:05:03 -0600 Subject: [PATCH 1/2] feat: add TimelineAction support to TimelinePin Adds an optional actions list to TimelinePin so companion apps can put actions in a pin's action menu on the watch. An OPEN_WATCH_APP action launches the watchapp the pin is parented to, passing an optional uint32 launchCode via launch args so the app can deep-link to the pinned content. Follows the TimelineReminder pattern from #14: model + KDoc in common-api, bundle round-trip in common. launchCode is validated to the unsigned 32-bit range. Co-Authored-By: Claude Fable 5 --- .../pebblekit2/common/model/TimelinePin.kt | 41 +++++++++++++++++++ .../common/model/TimelinePinSerialization.kt | 41 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/common-api/src/main/kotlin/io/rebble/pebblekit2/common/model/TimelinePin.kt b/common-api/src/main/kotlin/io/rebble/pebblekit2/common/model/TimelinePin.kt index d00ce92..ba3a30c 100644 --- a/common-api/src/main/kotlin/io/rebble/pebblekit2/common/model/TimelinePin.kt +++ b/common-api/src/main/kotlin/io/rebble/pebblekit2/common/model/TimelinePin.kt @@ -31,10 +31,51 @@ public data class TimelinePin( * Each reminder is inserted into BlobDatabase.Reminder and linked to this pin. */ val reminders: List = emptyList(), + + /** + * Optional actions shown in the pin's action menu on the watch, before the + * system-provided "Remove" action. + */ + val actions: List = emptyList(), ) { public companion object } +/** + * An action in a [TimelinePin]'s action menu. + * + * An [OPEN_WATCH_APP][TimelineActionType.OPEN_WATCH_APP] action launches the watchapp the pin is + * parented to. The watchapp sees launch reason `timelineAction` and receives [launchCode] via + * `launch_get_args()`, allowing the pin to deep-link to content inside the app. + * + * @param title label shown in the action menu + * @param type what the action does when selected + * @param launchCode uint32 passed to the watchapp as launch args ([TimelineActionType.OPEN_WATCH_APP] only) + */ +public data class TimelineAction( + val title: String, + val type: TimelineActionType = TimelineActionType.OPEN_WATCH_APP, + val launchCode: Long? = null, +) { + init { + require(launchCode == null || launchCode in 0..MAX_LAUNCH_CODE) { + "launchCode must fit in an unsigned 32-bit integer, got $launchCode" + } + } + + public companion object { + private const val MAX_LAUNCH_CODE: Long = 0xFFFFFFFFL + } +} + +public enum class TimelineActionType(public val code: String) { + OPEN_WATCH_APP("openWatchApp"), + HTTP("http"), + ; + + public companion object +} + /** * A reminder that fires before a [TimelinePin] and buzzes the watch. * diff --git a/common/src/main/kotlin/io/rebble/pebblekit2/common/model/TimelinePinSerialization.kt b/common/src/main/kotlin/io/rebble/pebblekit2/common/model/TimelinePinSerialization.kt index 359b0c1..945bf7d 100644 --- a/common/src/main/kotlin/io/rebble/pebblekit2/common/model/TimelinePinSerialization.kt +++ b/common/src/main/kotlin/io/rebble/pebblekit2/common/model/TimelinePinSerialization.kt @@ -10,12 +10,36 @@ public fun TimelinePin.Companion.fromBundle(bundle: Bundle): TimelinePin { val reminders = (0 until reminderCount).map { i -> TimelineReminder.fromBundle(bundle.getBundle("$KEY_REMINDER_PREFIX$i") ?: Bundle()) } + val actionCount = bundle.getInt(KEY_ACTIONS_COUNT, 0) + val actions = (0 until actionCount).map { i -> + TimelineAction.fromBundle(bundle.getBundle("$KEY_ACTION_PREFIX$i") ?: Bundle()) + } return TimelinePin( id = bundle.getString(KEY_ID) ?: error("missing id"), startTime = Instant.parse(bundle.getString(KEY_START_TIME) ?: error("missing start time")), duration = bundle.getString(KEY_DURATION)?.let { Duration.parse(it) }, layout = TimelineLayout.fromBundle(bundle), reminders = reminders, + actions = actions, + ) +} + +public fun TimelineAction.Companion.fromBundle(bundle: Bundle): TimelineAction { + val typeCode = bundle.getString(KEY_ACTION_TYPE).orEmpty() + val type = TimelineActionType.entries.firstOrNull { it.code == typeCode } + ?: run { + Logger.withTag("PebbleKit") + .e { "Got unknown action type '$typeCode' while decoding TimelinePin" } + TimelineActionType.OPEN_WATCH_APP + } + return TimelineAction( + title = bundle.getString(KEY_ACTION_TITLE) ?: error("missing action title"), + type = type, + launchCode = if (bundle.containsKey(KEY_ACTION_LAUNCH_CODE)) { + bundle.getLong(KEY_ACTION_LAUNCH_CODE) + } else { + null + }, ) } @@ -37,6 +61,18 @@ public fun TimelinePin.toBundle(): Bundle { reminders.forEachIndexed { i, reminder -> putBundle("$KEY_REMINDER_PREFIX$i", reminder.toBundle()) } + putInt(KEY_ACTIONS_COUNT, actions.size) + actions.forEachIndexed { i, action -> + putBundle("$KEY_ACTION_PREFIX$i", action.toBundle()) + } + } +} + +public fun TimelineAction.toBundle(): Bundle { + return Bundle().apply { + putString(KEY_ACTION_TITLE, title) + putString(KEY_ACTION_TYPE, type.code) + launchCode?.let { putLong(KEY_ACTION_LAUNCH_CODE, it) } } } @@ -107,3 +143,8 @@ private const val KEY_LAYOUT_LAST_UPDATED = "LAYOUT_LAST_UPDATED" private const val KEY_REMINDERS_COUNT = "REMINDERS_COUNT" private const val KEY_REMINDER_PREFIX = "REMINDER_" private const val KEY_REMINDER_TIME = "REMINDER_TIME" +private const val KEY_ACTIONS_COUNT = "ACTIONS_COUNT" +private const val KEY_ACTION_PREFIX = "ACTION_" +private const val KEY_ACTION_TITLE = "ACTION_TITLE" +private const val KEY_ACTION_TYPE = "ACTION_TYPE" +private const val KEY_ACTION_LAUNCH_CODE = "ACTION_LAUNCH_CODE" From 7a2ddb945e264dd078aced449e11cb9ba3834700 Mon Sep 17 00:00:00 2001 From: Skylar Sadlier Date: Wed, 19 Aug 2026 08:45:04 -0600 Subject: [PATCH 2/2] Use UInt for TimelineAction.launchCode Compiler-enforced unsigned 32-bit range instead of a runtime require, per review. Also fixes the detekt NullableToStringCall failure in the removed require message. Bundle serialization stores it as a Long. Co-Authored-By: Claude Fable 5 --- .../rebble/pebblekit2/common/model/TimelinePin.kt | 14 +++----------- .../common/model/TimelinePinSerialization.kt | 4 ++-- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/common-api/src/main/kotlin/io/rebble/pebblekit2/common/model/TimelinePin.kt b/common-api/src/main/kotlin/io/rebble/pebblekit2/common/model/TimelinePin.kt index ba3a30c..06bab8c 100644 --- a/common-api/src/main/kotlin/io/rebble/pebblekit2/common/model/TimelinePin.kt +++ b/common-api/src/main/kotlin/io/rebble/pebblekit2/common/model/TimelinePin.kt @@ -50,22 +50,14 @@ public data class TimelinePin( * * @param title label shown in the action menu * @param type what the action does when selected - * @param launchCode uint32 passed to the watchapp as launch args ([TimelineActionType.OPEN_WATCH_APP] only) + * @param launchCode passed to the watchapp as launch args ([TimelineActionType.OPEN_WATCH_APP] only) */ public data class TimelineAction( val title: String, val type: TimelineActionType = TimelineActionType.OPEN_WATCH_APP, - val launchCode: Long? = null, + val launchCode: UInt? = null, ) { - init { - require(launchCode == null || launchCode in 0..MAX_LAUNCH_CODE) { - "launchCode must fit in an unsigned 32-bit integer, got $launchCode" - } - } - - public companion object { - private const val MAX_LAUNCH_CODE: Long = 0xFFFFFFFFL - } + public companion object } public enum class TimelineActionType(public val code: String) { diff --git a/common/src/main/kotlin/io/rebble/pebblekit2/common/model/TimelinePinSerialization.kt b/common/src/main/kotlin/io/rebble/pebblekit2/common/model/TimelinePinSerialization.kt index 945bf7d..b002315 100644 --- a/common/src/main/kotlin/io/rebble/pebblekit2/common/model/TimelinePinSerialization.kt +++ b/common/src/main/kotlin/io/rebble/pebblekit2/common/model/TimelinePinSerialization.kt @@ -36,7 +36,7 @@ public fun TimelineAction.Companion.fromBundle(bundle: Bundle): TimelineAction { title = bundle.getString(KEY_ACTION_TITLE) ?: error("missing action title"), type = type, launchCode = if (bundle.containsKey(KEY_ACTION_LAUNCH_CODE)) { - bundle.getLong(KEY_ACTION_LAUNCH_CODE) + bundle.getLong(KEY_ACTION_LAUNCH_CODE).toUInt() } else { null }, @@ -72,7 +72,7 @@ public fun TimelineAction.toBundle(): Bundle { return Bundle().apply { putString(KEY_ACTION_TITLE, title) putString(KEY_ACTION_TYPE, type.code) - launchCode?.let { putLong(KEY_ACTION_LAUNCH_CODE, it) } + launchCode?.let { putLong(KEY_ACTION_LAUNCH_CODE, it.toLong()) } } }