Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/fw/apps/system/settings/system.c
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ typedef enum {
SystemMenuItemInformation,
SystemMenuItemCertification,
SystemMenuItemStationaryToggle,
SystemMenuItemButtonLock,
SystemMenuItemDebugging,
SystemMenuItemShutDown,
SystemMenuItemFactoryReset,
Expand All @@ -158,11 +159,52 @@ 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.
Expand Down Expand Up @@ -1301,6 +1343,9 @@ static void prv_draw_row_cb(SettingsCallbacks *context, GContext *ctx,
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:
Expand Down Expand Up @@ -1331,6 +1376,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;
Expand Down
10 changes: 9 additions & 1 deletion src/fw/kernel/event_loop.c
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,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"
Expand Down Expand Up @@ -182,12 +183,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
Expand Down Expand Up @@ -227,6 +229,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;
Expand Down
172 changes: 172 additions & 0 deletions src/fw/shell/normal/button_lock.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/* 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 <pbl/logging/logging.h>

#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);
// 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;
}

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;
}
25 changes: 25 additions & 0 deletions src/fw/shell/normal/button_lock.h
Original file line number Diff line number Diff line change
@@ -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);
34 changes: 33 additions & 1 deletion src/fw/shell/normal/prefs.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -320,6 +321,10 @@ 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"

//! Hold duration for the button lock combo; 0 disables the feature.
static uint32_t s_button_lock_hold_ms = 0;

static bool s_menu_scroll_wrap_around = false;
static MenuScrollVibeBehavior s_menu_scroll_vibe_behavior = MenuScrollNoVibe;
Expand Down Expand Up @@ -458,7 +463,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) {
Expand Down Expand Up @@ -862,6 +871,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;
Expand Down Expand Up @@ -2130,6 +2154,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;
}
Expand Down
1 change: 1 addition & 0 deletions src/fw/shell/normal/prefs_values.h.inc
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,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)
Loading