From 224b96c0ffd19034925d87cffeae57b02366edcc Mon Sep 17 00:00:00 2001 From: Andreas Unterkircher Date: Thu, 30 Jul 2026 21:37:11 +0200 Subject: [PATCH 1/5] fw/shell: add button lock hold duration preference Add a buttonLockHoldMs preference storing how long the Back+Down combo must be held to toggle the upcoming button lock feature. A value of 0 (the default) disables the feature. The handler accepts only the values offered by the Settings UI (0/1/2/3/5/10 seconds). Co-Authored-By: Claude Fable 5 Signed-off-by: Andreas Unterkircher --- src/fw/shell/normal/prefs.c | 27 ++++++++++++++++++++++++++ src/fw/shell/normal/prefs_values.h.inc | 1 + src/fw/shell/prefs.h | 4 ++++ 3 files changed, 32 insertions(+) diff --git a/src/fw/shell/normal/prefs.c b/src/fw/shell/normal/prefs.c index 010b3200e6..c4ff63ec15 100644 --- a/src/fw/shell/normal/prefs.c +++ b/src/fw/shell/normal/prefs.c @@ -346,6 +346,7 @@ static GColor s_theme_highlight_color = GColorVividCerulean; #define PREF_KEY_MUSIC_SHOW_VOLUME_CONTROLS "musicShowVolumeControls" #define PREF_KEY_MUSIC_SHOW_PROGRESS_BAR "musicShowProgressBar" #define PREF_KEY_MUSIC_SHOW_ALBUM_ART "musicShowAlbumArt" +#define PREF_KEY_BUTTON_LOCK_HOLD_MS "buttonLockHoldMs" static bool s_menu_scroll_wrap_around = false; static MenuScrollVibeBehavior s_menu_scroll_vibe_behavior = MenuScrollNoVibe; @@ -353,6 +354,9 @@ static bool s_music_show_volume_controls = true; static bool s_music_show_progress_bar = true; static bool s_music_show_album_art = false; +//! Hold duration for the button lock combo; 0 disables the feature. +static uint32_t s_button_lock_hold_ms = 0; + // ============================================================================================ // Handlers for each pref that validate the new setting and store the new value in our globals. // This handler will be called when the setting is changed from inside the firmware using one of @@ -922,6 +926,21 @@ static bool prv_set_s_theme_highlight_color(GColor *color) { } #endif +static bool prv_set_s_button_lock_hold_ms(uint32_t *hold_ms) { + switch (*hold_ms) { + case 0: + case 1000: + case 2000: + case 3000: + case 5000: + case 10000: + s_button_lock_hold_ms = *hold_ms; + return true; + default: + return false; + } +} + static bool prv_set_s_menu_scroll_wrap_around(bool *enabled) { s_menu_scroll_wrap_around = *enabled; return true; @@ -2239,6 +2258,14 @@ void shell_prefs_set_theme_highlight_color(GColor color) { #endif } +uint32_t shell_prefs_get_button_lock_hold_ms(void) { + return s_button_lock_hold_ms; +} + +void shell_prefs_set_button_lock_hold_ms(uint32_t hold_ms) { + prv_pref_set(PREF_KEY_BUTTON_LOCK_HOLD_MS, &hold_ms, sizeof(uint32_t)); +} + bool shell_prefs_get_menu_scroll_wrap_around_enable(void) { return s_menu_scroll_wrap_around; } diff --git a/src/fw/shell/normal/prefs_values.h.inc b/src/fw/shell/normal/prefs_values.h.inc index 2017ef9c3f..a1cdf38424 100644 --- a/src/fw/shell/normal/prefs_values.h.inc +++ b/src/fw/shell/normal/prefs_values.h.inc @@ -77,3 +77,4 @@ PREFS_MACRO(PREF_KEY_MUSIC_SHOW_VOLUME_CONTROLS, s_music_show_volume_controls) PREFS_MACRO(PREF_KEY_MUSIC_SHOW_PROGRESS_BAR, s_music_show_progress_bar) PREFS_MACRO(PREF_KEY_MUSIC_SHOW_ALBUM_ART, s_music_show_album_art) + PREFS_MACRO(PREF_KEY_BUTTON_LOCK_HOLD_MS, s_button_lock_hold_ms) diff --git a/src/fw/shell/prefs.h b/src/fw/shell/prefs.h index b9d45627d2..5d80a902df 100644 --- a/src/fw/shell/prefs.h +++ b/src/fw/shell/prefs.h @@ -261,6 +261,10 @@ void shell_prefs_set_theme_highlight_color(GColor color); bool shell_prefs_get_menu_scroll_wrap_around_enable(void); void shell_prefs_set_menu_scroll_wrap_around_enable(bool enable); +//! Hold duration for the button lock combo in ms; 0 means the feature is disabled. +uint32_t shell_prefs_get_button_lock_hold_ms(void); +void shell_prefs_set_button_lock_hold_ms(uint32_t hold_ms); + typedef enum MenuScrollVibeBehavior { MenuScrollNoVibe, MenuScrollVibeOnWrapAround, From 158e6ddd5b2a0803e3b0aec656a0f09144a1f5a0 Mon Sep 17 00:00:00 2001 From: Andreas Unterkircher Date: Thu, 30 Jul 2026 21:41:49 +0200 Subject: [PATCH 2/5] fw/shell/normal: add button lock service Holding Back+Down for the configured duration locks all button input; holding the combo again unlocks. While locked, button events are masked from every task in the kernel event loop; on touch-capable boards the touch sensor is disabled too, restored to the persisted touch pref on unlock. Lock/unlock gives a vibe pulse and a brief popup, and pressing a button while locked shows an unlock hint. The module delivers a button UP only if its DOWN was delivered, keeping click recognizers balanced, and cancels the back-button force-quit timer when the combo becomes pending so holding the combo inside an app does not force-quit it. The first combo button may still perform its normal press action, matching quick launch combo behavior. Locked state is RAM-only: a reboot always unlocks. The ISR-level hardware reset combo and the back-quickpress coredump remain functional while locked. Co-Authored-By: Claude Fable 5 Signed-off-by: Andreas Unterkircher --- src/fw/kernel/event_loop.c | 10 +- src/fw/shell/normal/button_lock.c | 170 +++++++++++++++++++++++++ src/fw/shell/normal/button_lock.h | 25 ++++ src/fw/shell/normal/prefs.c | 7 +- src/fw/shell/normal/shell_event_loop.c | 2 + src/fw/shell/prf/stubs.c | 9 ++ src/fw/shell/sdk/stubs.c | 9 ++ 7 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 src/fw/shell/normal/button_lock.c create mode 100644 src/fw/shell/normal/button_lock.h diff --git a/src/fw/kernel/event_loop.c b/src/fw/kernel/event_loop.c index 914fc2f85e..4b1d9eb4d2 100644 --- a/src/fw/kernel/event_loop.c +++ b/src/fw/kernel/event_loop.c @@ -57,6 +57,7 @@ #include "pbl/services/wakeup.h" #include "pbl/services/runlevel.h" #include "shell/normal/app_idle_timeout.h" +#include "shell/normal/button_lock.h" #include "shell/normal/watchface.h" #include "shell/prefs.h" #include "shell/shell_event_loop.h" @@ -160,12 +161,13 @@ static void back_button_force_quit_handler(void *data) { static void launcher_handle_button_event(PebbleEvent *e) { ButtonId button_id = e->button.button_id; const bool watchface_running = app_manager_is_watchface_running(); + const bool swallow = button_lock_handle_button_event(e); // trigger the backlight on any button down event if (e->type == PEBBLE_BUTTON_DOWN_EVENT) { PBL_ANALYTICS_ADD(button_pressed_count, 1); - if (button_id == BUTTON_ID_BACK && !watchface_running && + if (!swallow && button_id == BUTTON_ID_BACK && !watchface_running && process_metadata_get_run_level(app_manager_get_current_app_md()) == ProcessAppRunLevelNormal) { // Start timer for force-quitting app @@ -206,6 +208,12 @@ static void launcher_handle_button_event(PebbleEvent *e) { app_idle_timeout_refresh(); + if (swallow) { + // Button lock: hide the event from every task. + e->task_mask = (PebbleTaskBitset)~0; + return; + } + if (compositor_is_animating()) { // mask the app task if we're already animating e->task_mask |= 1 << PebbleTask_App; diff --git a/src/fw/shell/normal/button_lock.c b/src/fw/shell/normal/button_lock.c new file mode 100644 index 0000000000..bc9d5f7850 --- /dev/null +++ b/src/fw/shell/normal/button_lock.c @@ -0,0 +1,170 @@ +/* SPDX-FileCopyrightText: 2026 Core Devices LLC */ +/* SPDX-License-Identifier: Apache-2.0 */ + +#include "button_lock.h" + +#include "applib/ui/dialogs/dialog.h" +#include "applib/ui/dialogs/dialog_private.h" +#include "applib/ui/dialogs/simple_dialog.h" +#include "applib/ui/vibes.h" +#include "kernel/event_loop.h" +#include "kernel/ui/modals/modal_manager.h" +#include "process_management/app_manager.h" +#include "shell/normal/watchface.h" +#include "shell/prefs.h" +#include "system/passert.h" +#include "pbl/services/new_timer/new_timer.h" +#include "pbl/services/i18n/i18n.h" +#include + +#ifdef CONFIG_TOUCH +#include "pbl/services/touch/touch.h" +#endif + +#define BUTTON_LOCK_COMBO ((1 << BUTTON_ID_BACK) | (1 << BUTTON_ID_DOWN)) +#define BUTTON_LOCK_POPUP_TIMEOUT_MS (1800) + +static TimerID s_combo_timer = TIMER_INVALID_ID; +static uint8_t s_buttons_held; +//! Deliver a button UP iff its DOWN was delivered, so click recognizers in +//! the app/watchface never see an unbalanced press. +static uint8_t s_downs_delivered; +static bool s_combo_pending; +//! Set once a hold toggled the lock; blocks re-triggering until all buttons +//! are released, so a continuous hold toggles exactly once. +static bool s_combo_consumed; +static bool s_toggle_cancelled; +static bool s_locked; +static SimpleDialog *s_hint_dialog; + +static void prv_hint_dialog_unload(void *context) { + s_hint_dialog = NULL; +} + +static const DialogCallbacks s_hint_dialog_callbacks = { + .unload = prv_hint_dialog_unload, +}; + +static SimpleDialog *prv_push_popup(const char *text, const DialogCallbacks *callbacks) { + SimpleDialog *simple_dialog = simple_dialog_create("ButtonLock"); + Dialog *dialog = simple_dialog_get_dialog(simple_dialog); + const char *msg = i18n_get(text, dialog); + dialog_set_text(dialog, msg); + dialog_set_timeout(dialog, BUTTON_LOCK_POPUP_TIMEOUT_MS); + if (callbacks) { + dialog_set_callbacks(dialog, callbacks, NULL); + } + i18n_free(msg, dialog); + simple_dialog_push(simple_dialog, modal_manager_get_window_stack(ModalPriorityGeneric)); + return simple_dialog; +} + +static void prv_show_hint_popup(void) { + if (s_hint_dialog) { + return; + } + s_hint_dialog = prv_push_popup(i18n_noop("Hold Back + Down to unlock"), &s_hint_dialog_callbacks); +} + +static void prv_pop_hint_popup(void) { + if (!s_hint_dialog) { + return; + } + dialog_pop(simple_dialog_get_dialog(s_hint_dialog)); + s_hint_dialog = NULL; +} + +//! KernelMain callback posted by the combo hold timer. +static void prv_toggle_lock_cb(void *data) { + if (s_toggle_cancelled || !s_combo_pending) { + return; + } + s_combo_consumed = true; + s_locked = !s_locked; + PBL_LOG_DBG("Button lock %s", s_locked ? "engaged" : "released"); + +#ifdef CONFIG_TOUCH + if (s_locked) { + touch_service_set_globally_enabled(false); + } else { + // touch_is_globally_enabled() is the persisted user pref, not the runtime + // switch flipped above, so this restores the user's touch setting. + touch_service_set_globally_enabled(touch_is_globally_enabled()); + } +#endif + + if (s_locked) { + vibes_short_pulse(); + } else { + vibes_double_pulse(); + } + + prv_pop_hint_popup(); + prv_push_popup(s_locked ? i18n_noop("Buttons Locked") : i18n_noop("Buttons Unlocked"), NULL); +} + +//! Runs on the NewTimer thread; just bounce to KernelMain. +static void prv_combo_timer_cb(void *data) { + launcher_task_add_callback(prv_toggle_lock_cb, NULL); +} + +void button_lock_init(void) { + s_combo_timer = new_timer_create(); +} + +bool button_lock_is_locked(void) { + return s_locked; +} + +bool button_lock_handle_button_event(PebbleEvent *e) { + const ButtonId button_id = e->button.button_id; + const bool is_down = (e->type == PEBBLE_BUTTON_DOWN_EVENT); + + if (is_down) { + s_buttons_held |= (1 << button_id); + } else { + s_buttons_held &= ~(1 << button_id); + } + if (s_buttons_held == 0) { + s_combo_consumed = false; + } + + const bool combo_held = (s_buttons_held == BUTTON_LOCK_COMBO) && !s_combo_consumed && + (shell_prefs_get_button_lock_hold_ms() != 0); + + if (combo_held && !s_combo_pending) { + s_combo_pending = true; + s_toggle_cancelled = false; + launcher_cancel_force_quit(); + if (!s_locked && app_manager_is_watchface_running()) { + // Kill the first combo button's armed quick launch long click. + watchface_reset_click_manager(); + } + PBL_ASSERTN(new_timer_start(s_combo_timer, shell_prefs_get_button_lock_hold_ms(), + prv_combo_timer_cb, NULL, 0 /* flags */)); + // Swallow the combo-completing DOWN: the first combo button's DOWN was already delivered + // (its click fires like with the quick launch combos), the second button must stay + // invisible so no click recognizer arms for it. Its UP is swallowed via s_downs_delivered. + return true; + } + if (!combo_held && s_combo_pending) { + s_combo_pending = false; + s_toggle_cancelled = true; + new_timer_stop(s_combo_timer); + } + + if (is_down) { + if (s_locked || s_combo_pending) { + if (s_locked && !s_combo_pending) { + prv_show_hint_popup(); + } + return true; + } + s_downs_delivered |= (1 << button_id); + return false; + } + + const bool deliver = (s_downs_delivered & (1 << button_id)); + s_downs_delivered &= ~(1 << button_id); + return !deliver; +} diff --git a/src/fw/shell/normal/button_lock.h b/src/fw/shell/normal/button_lock.h new file mode 100644 index 0000000000..0afab9aa9f --- /dev/null +++ b/src/fw/shell/normal/button_lock.h @@ -0,0 +1,25 @@ +/* SPDX-FileCopyrightText: 2026 Core Devices LLC */ +/* SPDX-License-Identifier: Apache-2.0 */ + +#pragma once + +#include "kernel/events.h" + +//! @file +//! +//! Button lock: holding Back+Down for a configurable duration +//! (shell_prefs_get_button_lock_hold_ms, 0 = disabled) locks all button and +//! touch input; holding the combo again unlocks. +//! +//! The locked state is intentionally RAM-only: a reboot always unlocks. The +//! hardware reset combo is handled at ISR level in the button driver and is +//! unaffected by the lock. + +//! Create resources used by the button lock. Called from shell_event_loop_init. +void button_lock_init(void); + +bool button_lock_is_locked(void); + +//! Feed every button event on KernelMain before any other handling. +//! @return true if the event must be swallowed (masked from all tasks). +bool button_lock_handle_button_event(PebbleEvent *e); diff --git a/src/fw/shell/normal/prefs.c b/src/fw/shell/normal/prefs.c index c4ff63ec15..1cea4632f8 100644 --- a/src/fw/shell/normal/prefs.c +++ b/src/fw/shell/normal/prefs.c @@ -2,6 +2,7 @@ /* SPDX-License-Identifier: Apache-2.0 */ #include "quick_launch.h" +#include "shell/normal/button_lock.h" #include "shell/normal/quick_launch.h" #include "shell/normal/watchface.h" #include "shell/normal/prefs_sync.h" @@ -497,7 +498,11 @@ static bool prv_set_s_touch_enabled(bool *enabled) { #endif s_touch_enabled = *enabled; #ifdef CONFIG_TOUCH - touch_service_set_globally_enabled(*enabled); + // While the button lock holds touch disabled, only update the persisted pref (e.g. on a + // phone-side write); unlocking restores the touch service from it. + if (!button_lock_is_locked()) { + touch_service_set_globally_enabled(*enabled); + } if (prv_touch_navigation_effective() != was_effective) { touch_nav_set_enabled(prv_touch_navigation_effective()); } else if (was_on != *enabled) { diff --git a/src/fw/shell/normal/shell_event_loop.c b/src/fw/shell/normal/shell_event_loop.c index d8182ab27b..ee58e8a0da 100644 --- a/src/fw/shell/normal/shell_event_loop.c +++ b/src/fw/shell/normal/shell_event_loop.c @@ -29,6 +29,7 @@ #include "pbl/services/timeline/event.h" #include "shell/normal/app_idle_timeout.h" #include "shell/normal/battery_ui.h" +#include "shell/normal/button_lock.h" #include "shell/normal/watchface.h" #include "shell/prefs.h" @@ -57,6 +58,7 @@ void shell_event_loop_init(void) { app_outbox_service_init(); app_message_sender_init(); watchface_init(); + button_lock_init(); timeline_peek_init(); // Start activity tracking if enabled if (activity_prefs_tracking_is_enabled()) { diff --git a/src/fw/shell/prf/stubs.c b/src/fw/shell/prf/stubs.c index 8e2d66141b..10f23aa388 100644 --- a/src/fw/shell/prf/stubs.c +++ b/src/fw/shell/prf/stubs.c @@ -20,6 +20,7 @@ #include "pbl/services/notifications/do_not_disturb.h" #include "pbl/services/notifications/alerts_private.h" #include "pbl/services/persist.h" +#include "shell/normal/button_lock.h" #include "shell/prefs.h" #include "shell/system_theme.h" @@ -48,6 +49,14 @@ void watchface_set_default_install_id(AppInstallId id) { void watchface_handle_button_event(PebbleEvent *e) { } +bool button_lock_handle_button_event(PebbleEvent *e) { + return false; +} + +bool button_lock_is_locked(void) { + return false; +} + void app_idle_timeout_refresh(void) { } diff --git a/src/fw/shell/sdk/stubs.c b/src/fw/shell/sdk/stubs.c index 17f507f61f..7d81bd9c92 100644 --- a/src/fw/shell/sdk/stubs.c +++ b/src/fw/shell/sdk/stubs.c @@ -7,6 +7,7 @@ #include "pbl/services/activity/activity.h" #include "pbl/services/timeline/peek.h" #include "resource/resource_ids.auto.h" +#include "shell/normal/button_lock.h" #include "shell/prefs.h" #include "pbl/util/uuid.h" @@ -30,6 +31,14 @@ void app_idle_timeout_touch_up(void) { void watchface_start_low_power(bool enable) { } +bool button_lock_handle_button_event(PebbleEvent *e) { + return false; +} + +bool button_lock_is_locked(void) { + return false; +} + uint32_t backlight_get_timeout_ms(void) { return DEFAULT_BACKLIGHT_TIMEOUT_MS; } From 4296f40cca1a34b7945063f16780c29f4bfcb2a1 Mon Sep 17 00:00:00 2001 From: Andreas Unterkircher Date: Thu, 30 Jul 2026 21:43:10 +0200 Subject: [PATCH 3/5] fw/apps/settings: add Button Lock option to System settings New row in Settings > System opening an option menu with the button lock hold duration: Off (default), 1, 2, 3, 5 or 10 seconds. Co-Authored-By: Claude Fable 5 Signed-off-by: Andreas Unterkircher --- src/fw/apps/system/settings/system.c | 44 ++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/fw/apps/system/settings/system.c b/src/fw/apps/system/settings/system.c index cc9c37ea22..ddbdcb85b9 100644 --- a/src/fw/apps/system/settings/system.c +++ b/src/fw/apps/system/settings/system.c @@ -150,6 +150,7 @@ typedef enum { SystemMenuItemInformation, SystemMenuItemCertification, SystemMenuItemStationaryToggle, + SystemMenuItemButtonLock, SystemMenuItemDebugging, SystemMenuItemShutDown, SystemMenuItemFactoryReset, @@ -160,11 +161,48 @@ static const char *s_item_titles[SystemMenuItem_Count] = { [SystemMenuItemInformation] = i18n_noop("Information"), [SystemMenuItemCertification] = i18n_noop("Certification"), [SystemMenuItemStationaryToggle] = i18n_noop("Stand-By Mode"), + [SystemMenuItemButtonLock] = i18n_noop("Button Lock"), [SystemMenuItemDebugging] = i18n_noop("Debugging"), [SystemMenuItemShutDown] = i18n_noop("Shut Down"), [SystemMenuItemFactoryReset] = i18n_noop("Factory Reset"), }; +// Button Lock Settings +///////////////////////////// + +static const uint32_t s_button_lock_hold_values[] = {0, 1000, 2000, 3000, 5000, 10000}; + +static const char *s_button_lock_hold_labels[] = { + i18n_noop("Off"), i18n_noop("1 Second"), i18n_noop("2 Seconds"), + i18n_noop("3 Seconds"), i18n_noop("5 Seconds"), i18n_noop("10 Seconds"), +}; + +static int prv_button_lock_get_selection_index(void) { + const uint32_t hold_ms = shell_prefs_get_button_lock_hold_ms(); + for (size_t i = 0; i < ARRAY_LENGTH(s_button_lock_hold_values); i++) { + if (s_button_lock_hold_values[i] == hold_ms) { + return i; + } + } + return 0; +} + +static void prv_button_lock_menu_select(OptionMenu *option_menu, int selection, void *context) { + shell_prefs_set_button_lock_hold_ms(s_button_lock_hold_values[selection]); + app_window_stack_remove(&option_menu->window, true /* animated */); +} + +static void prv_button_lock_menu_push(SettingsSystemData *data) { + const OptionMenuCallbacks callbacks = { + .select = prv_button_lock_menu_select, + }; + const char *title = PBL_IF_RECT_ELSE(i18n_noop("BUTTON LOCK"), i18n_noop("Button Lock")); + settings_option_menu_push(title, OptionMenuContentType_SingleLine, + prv_button_lock_get_selection_index(), &callbacks, + ARRAY_LENGTH(s_button_lock_hold_labels), true /* icons_enabled */, + s_button_lock_hold_labels, data); +} + // Common status bar component is used across all windows that need them. // This will init it and set the correct style to be used within the settings // app. @@ -1292,6 +1330,9 @@ static void prv_draw_row_cb(SettingsCallbacks *context, GContext *ctx, const Lay case SystemMenuItemStationaryToggle: subtitle = stationary_get_enabled() ? i18n_get("On", data) : i18n_get("Off", data); break; + case SystemMenuItemButtonLock: + subtitle = i18n_get(s_button_lock_hold_labels[prv_button_lock_get_selection_index()], data); + break; case SystemMenuItemShutDown: case SystemMenuItemInformation: case SystemMenuItemCertification: @@ -1322,6 +1363,9 @@ static void prv_select_click_cb(SettingsCallbacks *context, uint16_t row) { case SystemMenuItemStationaryToggle: stationary_set_enabled(!stationary_get_enabled()); break; + case SystemMenuItemButtonLock: + prv_button_lock_menu_push(data); + break; case SystemMenuItemShutDown: launcher_task_add_callback(prv_shutdown_cb, 0); break; From 84407e768765e7ccb0f21c989c3c9f734ce22b1c Mon Sep 17 00:00:00 2001 From: Andreas Unterkircher Date: Thu, 30 Jul 2026 21:55:49 +0200 Subject: [PATCH 4/5] tests/fw/shell/normal: add button lock unit test Cover combo detection, configurable hold duration, abort on early release or third button, input swallowing and hint popup while locked, touch pref restoration on unlock, single toggle per continuous hold and the timer-fire-after-release race. Co-Authored-By: Claude Fable 5 Signed-off-by: Andreas Unterkircher --- tests/fw/shell/normal/CMakeLists.txt | 6 + tests/fw/shell/normal/test_button_lock.c | 358 +++++++++++++++++++++++ 2 files changed, 364 insertions(+) create mode 100644 tests/fw/shell/normal/test_button_lock.c diff --git a/tests/fw/shell/normal/CMakeLists.txt b/tests/fw/shell/normal/CMakeLists.txt index af156e6338..a07bdc7635 100644 --- a/tests/fw/shell/normal/CMakeLists.txt +++ b/tests/fw/shell/normal/CMakeLists.txt @@ -29,3 +29,9 @@ pbl_clar_test(test_battery_ui_fsm_percent DEFINES CONFIG_BATTERY_WARNING_FIRST_PERCENT=12 CONFIG_BATTERY_WARNING_SECOND_PERCENT=8 OVERRIDES dummy_board ) + +pbl_clar_test(test_button_lock + SOURCES + src/fw/shell/normal/button_lock.c + DEFINES CONFIG_TOUCH +) diff --git a/tests/fw/shell/normal/test_button_lock.c b/tests/fw/shell/normal/test_button_lock.c new file mode 100644 index 0000000000..46489b8c04 --- /dev/null +++ b/tests/fw/shell/normal/test_button_lock.c @@ -0,0 +1,358 @@ +/* SPDX-FileCopyrightText: 2026 Core Devices LLC */ +/* SPDX-License-Identifier: Apache-2.0 */ + +#include "shell/normal/button_lock.h" + +#include "applib/ui/dialogs/dialog.h" +#include "applib/ui/dialogs/simple_dialog.h" +#include "kernel/events.h" +#include "kernel/ui/modals/modal_manager.h" + +#include "clar.h" + +// Stubs +/////////////////////////////////////////////////////////////////////////////// +#include "stubs_logging.h" +#include "stubs_passert.h" + +#include "fake_new_timer.h" + +// Fakes +/////////////////////////////////////////////////////////////////////////////// + +static uint32_t s_pref_hold_ms; + +uint32_t shell_prefs_get_button_lock_hold_ms(void) { + return s_pref_hold_ms; +} + +static bool s_watchface_running; + +bool app_manager_is_watchface_running(void) { + return s_watchface_running; +} + +static int s_num_cancel_force_quit_calls; + +void launcher_cancel_force_quit(void) { + s_num_cancel_force_quit_calls++; +} + +static int s_num_watchface_reset_calls; + +void watchface_reset_click_manager(void) { + s_num_watchface_reset_calls++; +} + +static CallbackEventCallback s_kernel_cb; +static void *s_kernel_cb_data; + +void launcher_task_add_callback(CallbackEventCallback callback, void *data) { + s_kernel_cb = callback; + s_kernel_cb_data = data; +} + +static bool s_touch_enabled = true; +static bool s_touch_pref_enabled = true; + +void touch_service_set_globally_enabled(bool enabled) { + s_touch_enabled = enabled; +} + +bool touch_is_globally_enabled(void) { + return s_touch_pref_enabled; +} + +static int s_num_short_pulses; +static int s_num_double_pulses; + +void vibes_short_pulse(void) { + s_num_short_pulses++; +} + +void vibes_double_pulse(void) { + s_num_double_pulses++; +} + +static int s_num_dialogs_created; +static int s_num_dialogs_popped; +static SimpleDialog s_dialog_storage; +static DialogCallbacks s_dialog_callbacks; +static const char *s_last_dialog_text; + +SimpleDialog *simple_dialog_create(const char *dialog_name) { + s_num_dialogs_created++; + return &s_dialog_storage; +} + +Dialog *simple_dialog_get_dialog(SimpleDialog *simple_dialog) { + return &simple_dialog->dialog; +} + +void dialog_set_text(Dialog *dialog, const char *text) { + s_last_dialog_text = text; +} + +void dialog_set_timeout(Dialog *dialog, uint32_t timeout) { +} + +void dialog_set_callbacks(Dialog *dialog, const DialogCallbacks *callbacks, + void *callback_context) { + s_dialog_callbacks = *callbacks; +} + +void simple_dialog_push(SimpleDialog *simple_dialog, WindowStack *window_stack) { +} + +void dialog_pop(Dialog *dialog) { + s_num_dialogs_popped++; +} + +WindowStack *modal_manager_get_window_stack(ModalPriority priority) { + return NULL; +} + +const char *i18n_get(const char *string, const void *owner) { + return string; +} + +void i18n_free(const char *string, const void *owner) { +} + +// Helpers +/////////////////////////////////////////////////////////////////////////////// + +static bool prv_press(ButtonId id) { + PebbleEvent e = { + .type = PEBBLE_BUTTON_DOWN_EVENT, + .button.button_id = id, + }; + return button_lock_handle_button_event(&e); +} + +static bool prv_release(ButtonId id) { + PebbleEvent e = { + .type = PEBBLE_BUTTON_UP_EVENT, + .button.button_id = id, + }; + return button_lock_handle_button_event(&e); +} + +static void prv_invoke_kernel_cb(void) { + cl_assert(s_kernel_cb != NULL); + CallbackEventCallback cb = s_kernel_cb; + s_kernel_cb = NULL; + cb(s_kernel_cb_data); +} + +//! Hold the combo, fire the hold timer and run the posted KernelMain callback. +static void prv_toggle_lock(void) { + prv_press(BUTTON_ID_BACK); + prv_press(BUTTON_ID_DOWN); + stub_new_timer_invoke(1 /* num_to_invoke */); + prv_invoke_kernel_cb(); + prv_release(BUTTON_ID_BACK); + prv_release(BUTTON_ID_DOWN); +} + +// Tests +/////////////////////////////////////////////////////////////////////////////// + +void test_button_lock__initialize(void) { + static bool s_timer_created; + if (!s_timer_created) { + button_lock_init(); + s_timer_created = true; + } + + s_pref_hold_ms = 2000; + s_watchface_running = true; + + // Unwind state a previous test may have left behind. + for (ButtonId id = 0; id < NUM_BUTTONS; id++) { + prv_release(id); + } + if (button_lock_is_locked()) { + prv_toggle_lock(); + } + + s_num_cancel_force_quit_calls = 0; + s_num_watchface_reset_calls = 0; + s_kernel_cb = NULL; + s_touch_enabled = true; + s_touch_pref_enabled = true; + s_num_short_pulses = 0; + s_num_double_pulses = 0; + s_num_dialogs_created = 0; + s_num_dialogs_popped = 0; + s_last_dialog_text = NULL; +} + +void test_button_lock__cleanup(void) { +} + +void test_button_lock__pref_disabled_is_inert(void) { + s_pref_hold_ms = 0; + const int num_timer_starts_before = s_num_new_timer_start_calls; + + cl_assert(!prv_press(BUTTON_ID_BACK)); + cl_assert(!prv_press(BUTTON_ID_DOWN)); + cl_assert_equal_i(s_num_new_timer_start_calls, num_timer_starts_before); + cl_assert(!prv_release(BUTTON_ID_BACK)); + cl_assert(!prv_release(BUTTON_ID_DOWN)); + cl_assert(!button_lock_is_locked()); +} + +void test_button_lock__lock_engages_after_hold(void) { + cl_assert(!prv_press(BUTTON_ID_BACK)); + cl_assert(prv_press(BUTTON_ID_DOWN)); + + cl_assert_equal_i(s_num_cancel_force_quit_calls, 1); + cl_assert_equal_i(s_num_watchface_reset_calls, 1); + TimerID timer = stub_new_timer_get_next(); + cl_assert(stub_new_timer_is_scheduled(timer)); + cl_assert_equal_i(stub_new_timer_timeout(timer), 2000); + + stub_new_timer_invoke(1); + prv_invoke_kernel_cb(); + + cl_assert(button_lock_is_locked()); + cl_assert_equal_i(s_num_short_pulses, 1); + cl_assert(!s_touch_enabled); + cl_assert_equal_s(s_last_dialog_text, "Buttons Locked"); + + // The first button's DOWN was delivered, so its UP must be too. + cl_assert(!prv_release(BUTTON_ID_BACK)); + cl_assert(prv_release(BUTTON_ID_DOWN)); +} + +void test_button_lock__configurable_hold_duration(void) { + s_pref_hold_ms = 5000; + + prv_press(BUTTON_ID_BACK); + prv_press(BUTTON_ID_DOWN); + cl_assert_equal_i(stub_new_timer_timeout(stub_new_timer_get_next()), 5000); + prv_release(BUTTON_ID_BACK); + prv_release(BUTTON_ID_DOWN); +} + +void test_button_lock__release_before_timeout_aborts(void) { + prv_press(BUTTON_ID_BACK); + prv_press(BUTTON_ID_DOWN); + TimerID timer = stub_new_timer_get_next(); + + cl_assert(!prv_release(BUTTON_ID_BACK)); + cl_assert(!stub_new_timer_is_scheduled(timer)); + // The second button's DOWN was swallowed, so its UP must be too. + cl_assert(prv_release(BUTTON_ID_DOWN)); + cl_assert(!button_lock_is_locked()); +} + +void test_button_lock__third_button_cancels_pending(void) { + prv_press(BUTTON_ID_BACK); + prv_press(BUTTON_ID_DOWN); + TimerID timer = stub_new_timer_get_next(); + + cl_assert(!prv_press(BUTTON_ID_SELECT)); + cl_assert(!stub_new_timer_is_scheduled(timer)); + + prv_release(BUTTON_ID_SELECT); + prv_release(BUTTON_ID_BACK); + prv_release(BUTTON_ID_DOWN); + cl_assert(!button_lock_is_locked()); +} + +void test_button_lock__locked_swallows_input_and_hints(void) { + prv_toggle_lock(); + cl_assert(button_lock_is_locked()); + + s_num_dialogs_created = 0; + cl_assert(prv_press(BUTTON_ID_SELECT)); + cl_assert(prv_release(BUTTON_ID_SELECT)); + cl_assert_equal_i(s_num_dialogs_created, 1); + cl_assert_equal_s(s_last_dialog_text, "Hold Back + Down to unlock"); + + // Hint popup is not re-created while still on screen. + cl_assert(prv_press(BUTTON_ID_UP)); + cl_assert(prv_release(BUTTON_ID_UP)); + cl_assert_equal_i(s_num_dialogs_created, 1); + + // Once it unloaded, another press shows it again. + s_dialog_callbacks.unload(NULL); + cl_assert(prv_press(BUTTON_ID_UP)); + cl_assert(prv_release(BUTTON_ID_UP)); + cl_assert_equal_i(s_num_dialogs_created, 2); +} + +void test_button_lock__unlock_restores_touch_pref(void) { + prv_toggle_lock(); + cl_assert(button_lock_is_locked()); + cl_assert(!s_touch_enabled); + + s_touch_pref_enabled = false; + prv_toggle_lock(); + cl_assert(!button_lock_is_locked()); + cl_assert_equal_i(s_num_double_pulses, 1); + // Touch comes back to the persisted pref, not blindly on. + cl_assert(!s_touch_enabled); + + prv_toggle_lock(); + s_touch_pref_enabled = true; + prv_toggle_lock(); + cl_assert(s_touch_enabled); +} + +void test_button_lock__continuous_hold_toggles_once(void) { + prv_press(BUTTON_ID_BACK); + prv_press(BUTTON_ID_DOWN); + stub_new_timer_invoke(1); + prv_invoke_kernel_cb(); + cl_assert(button_lock_is_locked()); + + // Still holding: the timer must not be re-armed. + cl_assert(!stub_new_timer_is_scheduled(stub_new_timer_get_next())); + + prv_release(BUTTON_ID_BACK); + prv_release(BUTTON_ID_DOWN); + cl_assert(button_lock_is_locked()); +} + +void test_button_lock__no_watchface_reset_in_app(void) { + s_watchface_running = false; + + prv_press(BUTTON_ID_BACK); + prv_press(BUTTON_ID_DOWN); + cl_assert_equal_i(s_num_watchface_reset_calls, 0); + cl_assert_equal_i(s_num_cancel_force_quit_calls, 1); + prv_release(BUTTON_ID_BACK); + prv_release(BUTTON_ID_DOWN); +} + +void test_button_lock__timer_fire_after_release_race(void) { + prv_press(BUTTON_ID_BACK); + prv_press(BUTTON_ID_DOWN); + + // Timer fires, but the combo is released before KernelMain runs the + // posted callback: the toggle must not happen. + stub_new_timer_invoke(1); + prv_release(BUTTON_ID_BACK); + prv_release(BUTTON_ID_DOWN); + prv_invoke_kernel_cb(); + + cl_assert(!button_lock_is_locked()); +} + +void test_button_lock__unlock_while_hint_visible_pops_it(void) { + prv_toggle_lock(); + + s_num_dialogs_created = 0; + prv_press(BUTTON_ID_SELECT); + prv_release(BUTTON_ID_SELECT); + cl_assert_equal_i(s_num_dialogs_created, 1); + + s_num_dialogs_popped = 0; + prv_toggle_lock(); + cl_assert(!button_lock_is_locked()); + cl_assert_equal_i(s_num_dialogs_popped, 1); + cl_assert_equal_s(s_last_dialog_text, "Buttons Unlocked"); +} From f980a6eb9ba943c92d9427612aab6295bb83bc9f Mon Sep 17 00:00:00 2001 From: Andreas Unterkircher Date: Fri, 31 Jul 2026 19:37:16 +0200 Subject: [PATCH 5/5] fw/shell/normal: raise button lock popups above notifications With the buttons locked, an incoming notification (push message, calendar alert, ...) stays on top of the watchface and pressing a button gives no visible reaction - neither the "Hold Back + Down to unlock" hint nor, after performing the unlock combo, the "Buttons Unlocked" confirmation. Only the vibe hints that the lock reacted at all. Cause: the button lock popups were pushed on the ModalPriorityGeneric window stack. Modal windows composite by priority (Discreet < Generic < Phone < Notification < Alert < Voice < Critical < Alarm), and notification windows live at ModalPriorityNotification, so the lock dialogs were created correctly but sat invisibly underneath the notification until their 1.8 s timeout popped them again. Fix: push the popups at ModalPriorityAlert instead. The priority was chosen deliberately: - Above Notification and Phone, so the hint and lock/unlock feedback are visible in exactly the scenarios where the lock's behavior is otherwise inexplicable (notification or incoming call on screen). - Same level as the crash and low-battery dialogs: within one stack the newest window is on top, so a lock toast briefly overlays them and reveals them again when it times out - nothing is dismissed or lost. - Deliberately below Critical (BT pairing requests) and Alarm (alarm ring): those must never be masked by a lock toast, and going higher would buy nothing - a higher-priority window simply covers our dialog, which times out silently underneath. In critical-battery mode the modal floor is raised to ModalPriorityAlarm anyway, so no popup of ours shows there regardless of this choice. Co-Authored-By: Claude Fable 5 Signed-off-by: Andreas Unterkircher --- src/fw/shell/normal/button_lock.c | 4 +++- tests/fw/shell/normal/test_button_lock.c | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/fw/shell/normal/button_lock.c b/src/fw/shell/normal/button_lock.c index bc9d5f7850..51eaa54b64 100644 --- a/src/fw/shell/normal/button_lock.c +++ b/src/fw/shell/normal/button_lock.c @@ -55,7 +55,9 @@ static SimpleDialog *prv_push_popup(const char *text, const DialogCallbacks *cal dialog_set_callbacks(dialog, callbacks, NULL); } i18n_free(msg, dialog); - simple_dialog_push(simple_dialog, modal_manager_get_window_stack(ModalPriorityGeneric)); + // Alert: above notifications/calls so the feedback is visible over them, but + // below BT pairing and alarms, which must never be hidden by a lock toast. + simple_dialog_push(simple_dialog, modal_manager_get_window_stack(ModalPriorityAlert)); return simple_dialog; } diff --git a/tests/fw/shell/normal/test_button_lock.c b/tests/fw/shell/normal/test_button_lock.c index 46489b8c04..16f8fa1271 100644 --- a/tests/fw/shell/normal/test_button_lock.c +++ b/tests/fw/shell/normal/test_button_lock.c @@ -108,7 +108,10 @@ void dialog_pop(Dialog *dialog) { s_num_dialogs_popped++; } +static ModalPriority s_last_dialog_priority; + WindowStack *modal_manager_get_window_stack(ModalPriority priority) { + s_last_dialog_priority = priority; return NULL; } @@ -186,6 +189,7 @@ void test_button_lock__initialize(void) { s_num_dialogs_created = 0; s_num_dialogs_popped = 0; s_last_dialog_text = NULL; + s_last_dialog_priority = ModalPriorityInvalid; } void test_button_lock__cleanup(void) { @@ -220,6 +224,8 @@ void test_button_lock__lock_engages_after_hold(void) { cl_assert_equal_i(s_num_short_pulses, 1); cl_assert(!s_touch_enabled); cl_assert_equal_s(s_last_dialog_text, "Buttons Locked"); + // Popups must outrank notifications/calls, but stay below pairing/alarms. + cl_assert_equal_i(s_last_dialog_priority, ModalPriorityAlert); // The first button's DOWN was delivered, so its UP must be too. cl_assert(!prv_release(BUTTON_ID_BACK));