diff --git a/docs/solo_features/screen_lock/screen_lock.md b/docs/solo_features/screen_lock/screen_lock.md index 656a34c8..c6d1ec13 100644 --- a/docs/solo_features/screen_lock/screen_lock.md +++ b/docs/solo_features/screen_lock/screen_lock.md @@ -63,3 +63,23 @@ Fully autonomous, independent of Auto-lock and of any key combo: - **Magnet near (cover closed)** — locks and blanks the display immediately, no wake grace. - **Magnet away (cover opened)** — unlocks and wakes the display right away. + +--- + +### Lock PIN + +**Settings › Display › Lock PIN** asks for a PIN before the screen unlocks, +also right after power-up, and when the magnet cover opens. + +- Enter on the row opens a number pad: type the PIN (at least 4 characters), + confirm with ✓, then type it again. The pad's keyboard key switches to the + normal keyboard for a PIN with letters. +- Unlocking (Back + Enter three times, or opening the cover) shows the pad + with the input masked. A wrong PIN says how many tries are left; after 5 in + a row, entry pauses for 30 seconds. +- Enter on the row again removes the PIN. + +The PIN is kept as a salted SHA-256 hash, never as the PIN itself. It locks +the screen only: messages still arrive and the phone app still connects. +On the Wio Tracker L2 it's **Settings › Display & power › Screen PIN** +(digits only). diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 4c1469f9..20e00373 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -636,6 +636,12 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no if (_prefs.quiet_from > 23) _prefs.quiet_from = 22; if (_prefs.quiet_to > 23) _prefs.quiet_to = 7; + // lock_screen_password / _salt: a salted SHA-256 of the screen PIN, all + // zeros = no PIN. An older file's sentinel bytes landing here are cleared + // below on the mismatch. + rd(_prefs.lock_screen_password, sizeof(_prefs.lock_screen_password)); + rd(_prefs.lock_screen_password_salt, sizeof(_prefs.lock_screen_password_salt)); + // Schema sentinel: bumped on layout changes. Mismatch means an older file // (or a different schema); rd() and the clamps above already keep every // field within its valid range regardless, so we just log it here — @@ -645,12 +651,18 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no if (sentinel != NodePrefs::SCHEMA_SENTINEL) { MESH_DEBUG_PRINTLN("prefs schema sentinel mismatch: got 0x%08X, expected 0x%08X — re-saving on next change", (unsigned)sentinel, (unsigned)NodePrefs::SCHEMA_SENTINEL); + // 0xC0DE0030 → 0xC0DE0031: the lock-screen PIN appended; from an older + // file it holds its sentinel's bytes, which would read as a PIN nobody knows. + if (sentinel < 0xC0DE0031) { + memset(_prefs.lock_screen_password, 0, sizeof(_prefs.lock_screen_password)); + memset(_prefs.lock_screen_password_salt, 0, sizeof(_prefs.lock_screen_password_salt)); + } // 0xC0DE002F → 0xC0DE0030: quiet hours appended; from an older file they // hold its sentinel's bytes, so start off, 22:00-07:00. if (sentinel < 0xC0DE0030) { _prefs.quiet_hours = 0; _prefs.quiet_from = 22; _prefs.quiet_to = 7; } // 0xC0DE002E → 0xC0DE002F: display_brightness_pct appended; from an older // file it holds a stray sentinel byte (0x2E = "46 %"), so start from the level. - _prefs.display_brightness_pct = 0; + if (sentinel < 0xC0DE002F) _prefs.display_brightness_pct = 0; // 0xC0DE002A (v1.27) → 0xC0DE002B: repeat_extra_scope_mask + ch_scope_idx // appended. Unlike the range-clamped fields, these can't be left with whatever // stray bytes rd() picked up from a pre-0x2B file's own sentinel tail: @@ -843,6 +855,8 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.quiet_hours, sizeof(_prefs.quiet_hours)); file.write((uint8_t *)&_prefs.quiet_from, sizeof(_prefs.quiet_from)); file.write((uint8_t *)&_prefs.quiet_to, sizeof(_prefs.quiet_to)); + file.write((uint8_t *)_prefs.lock_screen_password, sizeof(_prefs.lock_screen_password)); + file.write((uint8_t *)_prefs.lock_screen_password_salt, sizeof(_prefs.lock_screen_password_salt)); // Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is // the one we check: once the flash fills, writes return 0, so a good diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 1d2eb7fa..84ce91ce 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -548,6 +548,14 @@ struct NodePrefs { // persisted to file // ── Custom messages ──────────────────────────────────────────────────── char custom_msgs[10][140]; // user-defined quick messages (supports {loc}, {time}) + // ── Screen lock PIN (0xC0DE0031) ─────────────────────────────────────── + // SHA-256 of salt + PIN (see ScreenLock.h); all zeros = no PIN. It locks the + // screen only: the radio and the app link keep working. + static const uint8_t LOCK_HASH_LEN = 32; + static const uint8_t LOCK_SALT_LEN = 16; + uint8_t lock_screen_password[LOCK_HASH_LEN]; + uint8_t lock_screen_password_salt[LOCK_SALT_LEN]; + // Single source of truth for the live-share option tables (shared by the Map // UI labels and the auto-send engine in UITask). static const uint8_t LOC_SHARE_MOVE_COUNT = 4; @@ -635,7 +643,7 @@ struct NodePrefs { // persisted to file // repeat_* fields) instead of at the tail, which shifted every field after // them by 25 bytes when loading an older file. Never released, but a dev // build wrote it, so the number must not be reused for anything else. - static const uint32_t SCHEMA_SENTINEL = 0xC0DE0030; + static const uint32_t SCHEMA_SENTINEL = 0xC0DE0031; // Bit-index for each home page. Used by page_order (entries store bit+1) and // by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage @@ -819,7 +827,8 @@ static inline bool inQuietHours(const NodePrefs& p, uint32_t utc) { // quiet_hours / quiet_from / quiet_to (0xC0DE0030) added 8 bytes, not 3 (next // to buzzer_auto, rounded up to the alignment) -- sizeof 2832, confirmed via real // WioTrackerL1_companion_solo_dual (nRF52/ARM) and L2 (ESP32) builds. -static_assert(sizeof(NodePrefs) == 2832, +// lock_screen_password / _salt (0xC0DE0031) added 48 bytes -- sizeof 2880. +static_assert(sizeof(NodePrefs) == 2880, "NodePrefs layout changed — sync DataStore save/load + clamp, bump " "SCHEMA_SENTINEL, then update this size (see steps above)."); diff --git a/examples/companion_radio/ui-core/ScreenLock.h b/examples/companion_radio/ui-core/ScreenLock.h new file mode 100644 index 00000000..82dc0f7e --- /dev/null +++ b/examples/companion_radio/ui-core/ScreenLock.h @@ -0,0 +1,75 @@ +#pragma once +// The screen lock's PIN, for every frontend: stored in NodePrefs as a salted +// SHA-256 (lock_screen_password / _salt), never as the PIN itself. It locks +// the screen only -- the radio and the app link keep working. + +#include +#include +#include +#include "../NodePrefs.h" + +namespace screenlock { + +static const uint8_t TRIES = 5; // misses before a pause +static const uint32_t PAUSE_MS = 30000; + +// Any byte set: a digest can start with a zero byte. +static bool isSet(const NodePrefs& p) { + for (uint8_t b : p.lock_screen_password) if (b) return true; + return false; +} + +static void clear(NodePrefs& p) { + memset(p.lock_screen_password, 0, sizeof(p.lock_screen_password)); + memset(p.lock_screen_password_salt, 0, sizeof(p.lock_screen_password_salt)); +} + +// A new PIN under a fresh salt; "" clears it. The caller saves the prefs. +static void set(NodePrefs& p, const char* pin, mesh::RNG* rng) { + if (!pin || !pin[0]) { clear(p); return; } + if (rng) { + rng->random(p.lock_screen_password_salt, sizeof(p.lock_screen_password_salt)); + } else { + uint32_t t = micros() ^ (millis() << 16); + memcpy(p.lock_screen_password_salt, &t, sizeof(t)); + } + mesh::Utils::sha256(p.lock_screen_password, sizeof(p.lock_screen_password), + p.lock_screen_password_salt, sizeof(p.lock_screen_password_salt), + (const uint8_t*)pin, (int)strlen(pin)); +} + +static bool check(const NodePrefs& p, const char* pin) { + if (!pin) return false; + uint8_t digest[NodePrefs::LOCK_HASH_LEN]; + mesh::Utils::sha256(digest, sizeof(digest), + p.lock_screen_password_salt, sizeof(p.lock_screen_password_salt), + (const uint8_t*)pin, (int)strlen(pin)); + return memcmp(digest, p.lock_screen_password, sizeof(digest)) == 0; +} + +// Wrong tries: TRIES misses in a row, then PAUSE_MS before the next one. +struct Attempts { + uint8_t fails = 0; + uint32_t block_until = 0; // millis(); 0 = not blocked + + bool blocked() { + if (block_until && (int32_t)(block_until - millis()) > 0) return true; + block_until = 0; + return false; + } + uint32_t secondsLeft() const { + int32_t left = (int32_t)(block_until - millis()); + return block_until && left > 0 ? (left + 999) / 1000 : 0; + } + void ok() { fails = 0; block_until = 0; } + // A miss; true when it starts the pause. + bool miss() { + if (++fails < TRIES) return false; + fails = 0; + block_until = (millis() + PAUSE_MS) | 1; + return true; + } + uint8_t left() const { return TRIES - fails; } +}; + +} // namespace screenlock diff --git a/examples/companion_radio/ui-lvgl/DeviceScreen.h b/examples/companion_radio/ui-lvgl/DeviceScreen.h index 3cd412d6..24b0b6a2 100644 --- a/examples/companion_radio/ui-lvgl/DeviceScreen.h +++ b/examples/companion_radio/ui-lvgl/DeviceScreen.h @@ -4,7 +4,7 @@ // - lock screen (Settings > Display & power > Lock screen, NodePrefs::auto_lock): // once the screen turns off, waking shows a clock card and nothing reacts // until "slide to unlock" -- against touches in a pocket; with a screen PIN -// (NVS, lvport::loadPin) the card always comes up, also after a reboot, and +// (NodePrefs, ui-core/ScreenLock.h) the card always comes up, also after a reboot, and // asks for the PIN on a keypad instead of the slider; // - favourites dial (Home > Favourites, NodePrefs::favourite_contacts): six // slots holding a contact, room or channel; tap opens it, hold changes / @@ -24,8 +24,6 @@ static lv_obj_t* s_lock_slider = nullptr; static lv_obj_t* s_pin_dots = nullptr; // lock card / setup popup: one dot per digit typed static lv_obj_t* s_pin_msg = nullptr; // "Wrong PIN" / "Try again in 30 s" / setup step static const uint8_t PIN_MIN = 4, PIN_MAX = 8; -static const uint8_t PIN_TRIES = 5; // misses before a pause -static const uint32_t PIN_PAUSE_MS = 30000; static const char* const PIN_MAP[] = { "1", "2", "3", "\n", "4", "5", "6", "\n", "7", "8", "9", "\n", LV_SYMBOL_BACKSPACE, "0", LV_SYMBOL_OK, "" }; @@ -174,7 +172,7 @@ void UITask::lockScreen() { lv_obj_set_flex_align(s_lock, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_top(s_lock, 18, 0); lv_obj_set_style_pad_row(s_lock, 4, 0); - if (_pin[0]) { // compact card: the time and unread on one line, the keypad below + if (pinSet()) { // compact card: the time and unread on one line, the keypad below lv_obj_set_style_pad_top(s_lock, 4, 0); lv_obj_set_style_pad_row(s_lock, 2, 0); lv_obj_t* top = lv_obj_create(s_lock); @@ -297,9 +295,9 @@ void UITask::refreshLock() { if (unread > 0) lv_label_set_text_fmt(s_lock_unread, LV_SYMBOL_ENVELOPE " %d", unread); else lv_label_set_text(s_lock_unread, ""); if (!s_pin_msg) return; - int32_t left = (int32_t)(_pin_block_until - millis()); - if (_pin_block_until && left > 0) lv_label_set_text_fmt(s_pin_msg, "Too many tries - wait %ld s", (long)((left + 999) / 1000)); - else if (_pin_block_until) { _pin_block_until = 0; lv_label_set_text(s_pin_msg, "Enter PIN"); } + bool was = _pin_tries.block_until != 0; + if (_pin_tries.blocked()) lv_label_set_text_fmt(s_pin_msg, "Too many tries - wait %lu s", (unsigned long)_pin_tries.secondsLeft()); + else if (was) lv_label_set_text(s_pin_msg, "Enter PIN"); else if (!lv_label_get_text(s_pin_msg)[0]) lv_label_set_text(s_pin_msg, "Enter PIN"); return; } @@ -309,28 +307,28 @@ void UITask::refreshLock() { // ── Screen PIN ──────────────────────────────────────────────────────────────── -// Lock card keypad: the PIN unlocks as soon as it's complete (OK checks a -// shorter entry); five misses pause entry for 30 s. +// Lock card keypad: the PIN unlocks as soon as it matches (from 4 digits on; +// only its hash is kept, so not its length); OK or a full 8 digits that +// don't match count a miss, and five misses pause entry for 30 s. void UITask::pinKey(const char* key) { using namespace devview; - if (!s_lock || !_pin[0]) return; - if (_pin_block_until && (int32_t)(_pin_block_until - millis()) > 0) return; + if (!s_lock || !pinSet()) return; + if (_pin_tries.blocked()) return; bool ok = pinEdit(_pin_entry, key); size_t n = strlen(_pin_entry); - if (!ok && n < strlen(_pin)) { + if (n >= PIN_MIN && screenlock::check(*_prefs, _pin_entry)) { _pin_tries.ok(); unlockScreen(); return; } + if (!ok && n < PIN_MAX) { if (n && s_pin_msg) lv_label_set_text(s_pin_msg, "Enter PIN"); return; } if (!n) return; - if (!strcmp(_pin_entry, _pin)) { _pin_fails = 0; unlockScreen(); return; } _pin_entry[0] = '\0'; showDots(_pin_entry); - if (++_pin_fails >= PIN_TRIES) { - _pin_fails = 0; - _pin_block_until = (millis() + PIN_PAUSE_MS) | 1; + if (_pin_tries.miss()) { refreshLock(); } else if (s_pin_msg) { - lv_label_set_text_fmt(s_pin_msg, "Wrong PIN - %d tr%s left", PIN_TRIES - _pin_fails, PIN_TRIES - _pin_fails == 1 ? "y" : "ies"); + int left = _pin_tries.left(); + lv_label_set_text_fmt(s_pin_msg, "Wrong PIN - %d tr%s left", left, left == 1 ? "y" : "ies"); } } @@ -339,10 +337,10 @@ void UITask::pinKey(const char* key) { void UITask::pinSetupPopup() { using namespace devview; _pin_entry[0] = _pin_new[0] = '\0'; - lv_obj_t* panel = navPopupPanel(_pin[0] ? "Change PIN" : "Set screen PIN", true); + lv_obj_t* panel = navPopupPanel(pinSet() ? "Change PIN" : "Set screen PIN", true); lv_obj_set_flex_align(panel, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_row(panel, 2, 0); - if (_pin[0]) { // next to the close button + if (pinSet()) { // next to the close button lv_obj_t* l; headerButton(lv_obj_get_child(panel, 0), LV_SYMBOL_TRASH " Remove", onPinRemove, 44, &l); lv_obj_set_style_text_color(l, lv_color_hex(theme::FAIL), 0); @@ -369,8 +367,10 @@ void UITask::pinSetupKey(const char* key) { lv_label_set_text(s_pin_msg, "Didn't match - new PIN again"); return; } - strcpy(_pin, _pin_new); - lvport::savePin(_pin); + if (_prefs) { + screenlock::set(*_prefs, _pin_new, the_mesh.getRNG()); + the_mesh.savePrefs(); + } _pin_new[0] = _pin_entry[0] = '\0'; navClosePopup(); showToast("PIN set - asked each time the screen wakes", 3000); @@ -378,8 +378,10 @@ void UITask::pinSetupKey(const char* key) { } void UITask::pinRemove() { - _pin[0] = '\0'; - lvport::savePin(""); + if (_prefs) { + screenlock::clear(*_prefs); + the_mesh.savePrefs(); + } navClosePopup(); showToast("PIN removed"); pinRowRefresh(); diff --git a/examples/companion_radio/ui-lvgl/LvglPort.h b/examples/companion_radio/ui-lvgl/LvglPort.h index d688269d..2ea27e07 100644 --- a/examples/companion_radio/ui-lvgl/LvglPort.h +++ b/examples/companion_radio/ui-lvgl/LvglPort.h @@ -680,6 +680,7 @@ static int getU8(const char* ns, const char* key, int def) { return getInt(ns, static void putU8(const char* ns, const char* key, int v) { putInt(ns, key, v); } static void getStr(const char* ns, const char* key, char* out, size_t n) { Kv* k = find(ns, key, false); snprintf(out, n, "%s", k ? k->val : ""); } static void putStr(const char* ns, const char* key, const char* v) { if (Kv* k = find(ns, key, true)) snprintf(k->val, sizeof(k->val), "%s", v); } +static void remove(const char* ns, const char* key) { if (Kv* k = find(ns, key, false)) k->val[0] = '\0'; } } // namespace nvs static bool s_swallow = false; @@ -848,8 +849,12 @@ static bool vectorOn() { return nvs::getBool("mc_ui", "vector", false); } static void setVectorOn(bool on) { nvs::putBool("mc_ui", "vector", on); } // Screen-lock PIN (Settings > Display & power > Screen PIN): digits, "" = none. -static void loadPin(char* out, size_t n) { nvs::getStr("mc_lock", "pin", out, n); } -static void savePin(const char* pin) { nvs::putStr("mc_lock", "pin", pin); } +// The screen PIN as older builds kept it (plain text): read once and removed; +// it lives hashed in NodePrefs now (ui-core/ScreenLock.h). +static void takeOldPin(char* out, size_t n) { + nvs::getStr("mc_lock", "pin", out, n); + if (out[0]) nvs::remove("mc_lock", "pin"); +} // Accent colour (Settings > Display & power): an index into theme::ACCENTS. static int loadAccent() { return nvs::getU8("mc_ui", "accent", 0); } diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index eab5bbd8..3bd16e3e 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -898,8 +898,15 @@ void UITask::begin(DisplayDriver* display_drv, SensorManager* sensors, NodePrefs buildStatusBar(); applyDisplayPrefs(); // a slider percentage overrides the level main.cpp set showHome(); - lvport::loadPin(_pin, sizeof(_pin)); - if (_pin[0]) lockScreen(); // a reboot doesn't get round the PIN + // Before the PIN moved into NodePrefs it was kept in NVS in plain text. + char old_pin[9]; + lvport::takeOldPin(old_pin, sizeof(old_pin)); + if (old_pin[0] && _prefs && !pinSet()) { + screenlock::set(*_prefs, old_pin, the_mesh.getRNG()); + the_mesh.savePrefs(); + } + memset(old_pin, 0, sizeof(old_pin)); + if (pinSet()) lockScreen(); // a reboot doesn't get round the PIN showSplash(); // over both; fades out by itself } @@ -1175,7 +1182,7 @@ void UITask::sleep() { if (_display) _display->turnOff(); prefsSaveSoon(0); // nothing to stall now lvport::powerSave(true, _tap_wake); - if ((_prefs && _prefs->auto_lock) || _pin[0]) lockScreen(); // Lock screen, or a screen PIN + if ((_prefs && _prefs->auto_lock) || pinSet()) lockScreen(); // Lock screen, or a screen PIN } void UITask::wake() { @@ -3212,7 +3219,7 @@ void UITask::buildSchemaSettings() { lv_obj_add_event_cb(tw, onTapWake, LV_EVENT_VALUE_CHANGED, NULL); g = group(body, "LOCK"); schemaRow(g, SETTING(auto_lock)); - listRow(g, "Screen PIN", _pin[0] ? "On - asked when the screen wakes" : "Off", onPinSetup, NULL); + listRow(g, "Screen PIN", pinSet() ? "On - asked when the screen wakes" : "Off", onPinSetup, NULL); accentRow(group(body, "LOOK")); // DeviceScreen.h return; } diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 97cb109b..944e3cc8 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -16,6 +16,7 @@ #include "../AbstractUITask.h" #include "../NodePrefs.h" #include "../ui-core/UiCoreHost.h" +#include "../ui-core/ScreenLock.h" class UiCore; class NearbyModel; @@ -458,13 +459,12 @@ private: uint16_t _batt_mv = 0; // smoothed, read every 8 s: the status bar and the low-battery shutdown uint32_t _next_batt_ms = 0; lv_obj_t* _prune_lbl = nullptr; - // Screen PIN (DeviceScreen.h); stored in NVS (lvport::loadPin) - char _pin[9] = ""; // "" = no PIN + // Screen PIN (DeviceScreen.h): a salted hash in NodePrefs (ui-core/ScreenLock.h) + bool pinSet() const { return _prefs && screenlock::isSet(*_prefs); } bool _tap_wake = true; // a touch wakes the dark screen (NVS) char _pin_entry[9] = ""; // digits typed so far char _pin_new[9] = ""; // setup: the first entry, waiting for its confirmation - uint8_t _pin_fails = 0; - uint32_t _pin_block_until = 0; // millis() until which entry is refused after 5 misses + screenlock::Attempts _pin_tries; // Open conversation (SCR_THREAD) bool _thread_is_channel = false; diff --git a/examples/companion_radio/ui-new/KeyboardWidget.h b/examples/companion_radio/ui-new/KeyboardWidget.h index 1a0ad1ba..bbc57b0b 100644 --- a/examples/companion_radio/ui-new/KeyboardWidget.h +++ b/examples/companion_radio/ui-new/KeyboardWidget.h @@ -44,6 +44,17 @@ static const char* const KB_T9_GROUPS[KB_PAGES][9] = { { "@#&", "*()", "-_+", "=/\\", ":;'\"", "<>[]", "{}|~", "^$%`", ",." }, // page 1 — symbols }; +// PIN input layout: Classic number pad +static const int KB_PIN_ROWS = 4; +static const int KB_PIN_COLS = 3; +static const int KB_PIN_SPECIAL = 3; // backspace, abc kb switch & submit +static const char KB_PIN_DIGITS[KB_PIN_ROWS][KB_PIN_COLS] = { + {'1','2','3'}, + {'4','5','6'}, + {'7','8','9'}, + {' ','0',' '}, +}; + // Non-Latin keyboard scripts. NodePrefs::keyboard_main_alphabet/ // keyboard_alt_alphabet (Settings > Keyboard's Main/Additional rows) pick // which script occupies page 0 (the keyboard's default/opening page) and @@ -250,6 +261,12 @@ struct KeyboardWidget { bool accent_active = false; // true while the Hold-Enter accent popup is open int accent_group = -1; // index into KB_ACCENT_VARIANTS for the held cell's base letter int accent_sel = 0; // selected variant within that group + bool pin_kb_mask_enabled = false; // Whether pin keyboard should mask input + // The number pad (beginPin) instead of the user's ABC / T9 layout; its ABC + // key drops back to that layout for a password with letters. + bool pin_mode = false; + const char* pin_prompt = nullptr; // e.g. "New PIN", shown in the preview; kept past the ABC switch + static const int PIN_MAX_LEN = 16; int row, col; int page; // see totalPages()/scriptAt()/pageIsSymbols() below bool caps; @@ -289,6 +306,7 @@ struct KeyboardWidget { // to ABC and mainScript()/altScript() default to Latin-only. NodePrefs* prefs = nullptr; bool isT9() const { return prefs && prefs->keyboard_type == 1; } + bool isPin() const { return pin_mode; } // Which script occupies page 0 (the keyboard's default/opening page) and // which occupies page 1 (reached by the #@/abc cycle key) -- Settings > // Keyboard's Main/Additional rows. Additional equal to Main collapses to no @@ -310,8 +328,8 @@ struct KeyboardWidget { // lowercase regardless of Shift. bool t9_caps = false; - int gridRows() const { return isT9() ? KB_T9_ROWS : KB_ROWS_CHAR; } - int gridCols() const { return isT9() ? KB_T9_COLS : KB_COLS_CHAR; } + int gridRows() const { return isPin() ? KB_PIN_ROWS : (isT9() ? KB_T9_ROWS : KB_ROWS_CHAR); } + int gridCols() const { return isPin() ? KB_PIN_COLS : (isT9() ? KB_T9_COLS : KB_COLS_CHAR); } // ── Page model ──────────────────────────────────────────────────────────── // Logical page order: 0 = mainScript(), [1 = altScript(), if it differs], @@ -390,6 +408,9 @@ struct KeyboardWidget { page = 0; caps = false; caps_lock = false; + pin_kb_mask_enabled = false; + pin_mode = false; + pin_prompt = nullptr; t9_cell = -1; t9_cycle = 0; _ph_menu.active = false; @@ -402,6 +423,14 @@ struct KeyboardWidget { addPlaceholder("{time}"); } + // Open keyboard in PIN mode + void beginPin(const char* initial = "", int max = PIN_MAX_LEN, bool mask = false, const char* prompt = nullptr) { + begin(initial, max); + pin_mode = true; + pin_prompt = prompt; + pin_kb_mask_enabled = mask; // Mask input of number field + } + // Insert one UTF-8 codepoint (a grid cell's own glyph, or a picked accent // variant) at cursor_pos, applying Shift/caps-lock the same way every cell // commit does. Shared by the plain-Latin-cell commit below and the accent @@ -568,12 +597,29 @@ struct KeyboardWidget { } else { linebuf[0] = '\0'; } + // Mask input when pin_kb_mask is enabled + if (pin_kb_mask_enabled) { + char masked[KB_PREVIEW_BYTES + 2]; + int mi = 0; + int blen = (int)strlen(linebuf); + for (int bi = 0; bi < blen; ) { + int u = kbUtf8CharBytesAt(linebuf, bi, blen); + masked[mi++] = (u == 1 && linebuf[bi] == '_') ? '_' : '*'; + bi += u; + } + masked[mi] = '\0'; + strncpy(linebuf, masked, sizeof(linebuf)); + } char linebuf_t[KB_PREVIEW_BYTES + 2]; display.translateUTF8ToBlocks(linebuf_t, linebuf, sizeof(linebuf_t)); display.setCursor(0, pl * lh); display.print(linebuf_t); ps = pe; } + if (pin_prompt) { // what the PIN is for, bottom-right of the preview (alerts don't show over the keyboard) + display.setCursor(display.width() - display.getTextWidth(pin_prompt), (prev_lines - 1) * lh); + display.print(pin_prompt); + } display.fillRect(0, sep_y, display.width(), display.sepH()); // Cursor-positioning mode: LEFT/RIGHT/UP/DOWN now drive the text cursor @@ -592,6 +638,41 @@ struct KeyboardWidget { return 50; } + // PIN mode + if (isPin()) { + const int s = miniIconScale(display); + for (int r = 0; r < rows; r++) { + int y = chars_y + r * cell_h; + for (int c = 0; c < cols; c++) { + bool sel = (row == r && col == c); + int cx = c * cell_w; + display.drawSelectionRow(cx, y - 1, cell_w - 1, cell_h, sel); + char ch = KB_PIN_DIGITS[r][c]; + if (ch == ' ') continue; // blank cells beside 0 + char ch_buf[2]; + ch_buf[0] = ch; + ch_buf[1] = '\0'; + int tw = display.getTextWidth(ch_buf); + display.setCursor(cx + (cell_w - tw) / 2, y); + display.print(ch_buf); + } + } + // special row with backspace, abc kb switch & submit + const int psw = display.width() / KB_PIN_SPECIAL; + const int icy = spec_y + (cell_h - lh) / 2; + for (int i = 0; i < KB_PIN_SPECIAL; i++) { + bool sel = (row == rows && col == i); + int sx = i * psw; + display.drawSelectionRow(sx, spec_y - 1, psw - 1, cell_h, sel); + const MiniIcon& ic = (i == 0) ? ICON_BACKSPACE + : (i == 1) ? ICON_KEYBOARD + : ICON_CHECK; + int ix = sx + (psw - ic.w * s) / 2; + miniIconDraw(display, ix, icy, ic); + } + return 50; + } + // Compact mode (Settings > Keyboard's "Ext. KB" row): an external-keyboard // typist never looks at the letter grid or special-row icons, so skip // drawing them entirely -- no status line either, since nothing it could @@ -755,7 +836,7 @@ struct KeyboardWidget { // just fine -- so this only checks that no other exclusive input mode // (popup/cursor-move) is already in progress, same as inPlainGridState(). bool openAccentFor(char base) { - if (!isVisible() || _ph_menu.active || cursor_mode || accent_active) return false; + if (!isVisible() || isPin() || _ph_menu.active || cursor_mode || accent_active) return false; int gi = findAccentGroup(base); if (gi < 0) return false; accent_active = true; @@ -872,6 +953,70 @@ struct KeyboardWidget { return NONE; } + // PIN mode: a dedicated numeric keypad + if (isPin()) { + const int rows = gridRows(); + const int cols = gridCols(); + if (c == KEY_CONTEXT_MENU) { + if (row == rows && col == 0) { // Backspace + len = 0; buf[0] = '\0'; + cursor_pos = 0; + t9_cell = -1; + } + return NONE; + } + if (c == KEY_UP) { // Navigation + row = (row > 0) ? row - 1 : rows; + return NONE; + } + if (c == KEY_DOWN) { + row = (row < rows) ? row + 1 : 0; + return NONE; + } + if (c == KEY_LEFT) { + int max_col = (row == rows) ? KB_PIN_SPECIAL - 1 : cols - 1; + col = (col > 0) ? col - 1 : max_col; + return NONE; + } + if (c == KEY_RIGHT) { + int max_col = (row == rows) ? KB_PIN_SPECIAL - 1 : cols - 1; + col = (col < max_col) ? col + 1 : 0; + return NONE; + } + if (c == KEY_ENTER) { + if (row < rows) { + char d = KB_PIN_DIGITS[row][col]; + if (d == ' ') return NONE; // ignore blanks + if (len < max_len) { // Digit + memmove(buf + cursor_pos + 1, buf + cursor_pos, len - cursor_pos); + buf[cursor_pos] = d; + len++; cursor_pos++; + buf[len] = '\0'; + } + return NONE; + } + // special row + if (col == 0) { + t9_cell = -1; + if (cursor_pos > 0) { + int n = kbUtf8LastCharBytes(buf, cursor_pos); + memmove(buf + cursor_pos - n, buf + cursor_pos, len - cursor_pos); + len -= n; cursor_pos -= n; + buf[len] = '\0'; + } + } else if (col == 1) { + pin_mode = false; // ABC switch: the user's own layout + row = col = 0; + page = 0; + t9_cell = -1; + } else { + return DONE; // Submit + } + return NONE; + } + return NONE; + } + const int rows = gridRows(); const int cols = gridCols(); diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 3185143a..a2ccf4a8 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -70,7 +70,9 @@ class SettingsScreen : public UIScreen { SCHEMA_MESSAGES, MSG_SLOT_0, MSG_SLOT_1, MSG_SLOT_2, MSG_SLOT_3, MSG_SLOT_4, MSG_SLOT_5, MSG_SLOT_6, MSG_SLOT_7, MSG_SLOT_8, MSG_SLOT_9, - Count + Count, + // Not walked from the enum: placed right after the schema's "Lock screen" row. + LOCK_PIN }; // A schema setting's row: SCHEMA_ITEM + its index in settings::ALL. static const int SCHEMA_ITEM = 128; @@ -161,8 +163,11 @@ class SettingsScreen : public UIScreen { _sec_count[cur] = 0; } else if (cur >= 0 && isSchemaGroup(i)) { for (int k = 0; k < settings::COUNT && _sec_count[cur] < MAX_PER_SEC; k++) - if (schemaIn(i, settings::ALL[k].section) && schemaShown(settings::ALL[k])) + if (schemaIn(i, settings::ALL[k].section) && schemaShown(settings::ALL[k])) { _sec_items[cur][_sec_count[cur]++] = (uint8_t)(SCHEMA_ITEM + k); + if (settings::ALL[k].offset == offsetof(NodePrefs, auto_lock) && _sec_count[cur] < MAX_PER_SEC) + _sec_items[cur][_sec_count[cur]++] = (uint8_t)LOCK_PIN; + } } else if (cur >= 0 && _sec_count[cur] < MAX_PER_SEC) { _sec_items[cur][_sec_count[cur]++] = (uint8_t)i; } @@ -213,6 +218,11 @@ class SettingsScreen : public UIScreen { // A schema setting: its short label, the value in the value column (a // switch as ON / OFF, brightness and volume as bars). A value too long for // the column starts further left, clear of the label, else scrolls. + void beginPinEntry(const char* prompt) { + _kb->beginPin("", KeyboardWidget::PIN_MAX_LEN, false, prompt); + _kb->clearPlaceholders(); // a PIN is literal, not a template message + } + int renderSchema(DisplayDriver& display, const settings::Setting& st, NodePrefs* p, int y, bool sel) { const char* label = settings::shortLabel(st); display.print(label); @@ -545,6 +555,10 @@ class SettingsScreen : public UIScreen { int r = display.drawTextEllipsized(vx, y, display.width() - vx - _reserve, sl.name(sl.default_idx), sel); if (sel && r > 0) mq_delay = r; + } else if (item == LOCK_PIN) { + display.print("Lock PIN"); + display.setCursor(valCol(display), y); + display.print(_task->passwordLockEnabled() ? "ON" : "OFF"); } else if (item == DEVICE_NAME) { display.print("Name"); int vx = valCol(display); @@ -620,6 +634,8 @@ class SettingsScreen : public UIScreen { // Keyboard state for editing message slots int _edit_slot = -1; // -1 = not editing, 0..9 = slot being edited bool _edit_name = false; // editing DEVICE_NAME via the keyboard + bool _edit_lock_pass = false; // setting the screen PIN via the keyboard + char _lock_pass_first[KeyboardWidget::PIN_MAX_LEN + 1] = ""; // the first entry, awaiting its repeat KeyboardWidget* _kb; // Scope list management (SCOPE_NAME row -> a full-screen add/rename/ @@ -691,6 +707,8 @@ public: _scope_action_menu.active = false; _scope_delete_confirm_active = false; _prune_confirm.active = false; + _edit_lock_pass = false; + _lock_pass_first[0] = '\0'; resetList(); _editor.freq.active = false; } @@ -698,7 +716,7 @@ public: int render(DisplayDriver& display) override { display.setTextSize(1); - if (_edit_slot >= 0 || _edit_name || _scope_rename_idx != -2 || _picker.saving) { + if (_edit_slot >= 0 || _edit_name || _edit_lock_pass || _scope_rename_idx != -2 || _picker.saving) { return _kb->render(display); } @@ -766,6 +784,36 @@ public: return true; } + // Keyboard editing mode for the lock-screen password + if (_edit_lock_pass) { + auto res = _kb->handleInput(c); + if (res == KeyboardWidget::DONE) { + // Entered twice: a typo here would lock the owner out. + if (strlen(_kb->buf) < 4) { + _kb->pin_prompt = "Min 4"; + } else if (!_lock_pass_first[0]) { + strncpy(_lock_pass_first, _kb->buf, sizeof(_lock_pass_first) - 1); + _lock_pass_first[sizeof(_lock_pass_first) - 1] = '\0'; + beginPinEntry("Again"); + } else if (strcmp(_lock_pass_first, _kb->buf) != 0) { + _lock_pass_first[0] = '\0'; + beginPinEntry("No match"); + } else { + if (p) { + _task->setNodeLockPassword(_kb->buf); + the_mesh.savePrefs(); // now, not on leaving: a PIN must survive a power-off right after + } + _edit_lock_pass = false; + _lock_pass_first[0] = '\0'; + _task->showAlert("PIN set", 900); + } + } else if (res == KeyboardWidget::CANCELLED) { + _edit_lock_pass = false; + _lock_pass_first[0] = '\0'; + } + return true; + } + // Keyboard editing mode for adding/renaming a scope-list entry if (_scope_rename_idx != -2) { auto res = _kb->handleInput(c); @@ -974,6 +1022,19 @@ public: _dirty = true; return true; } + // Lock PIN: Enter sets one, or clears the one set + if (_selected == LOCK_PIN && p && enter) { + if (_task->passwordLockEnabled()) { + _task->setNodeLockPassword(""); + the_mesh.savePrefs(); + _task->showAlert("PIN cleared", 900); + } else { + _edit_lock_pass = true; + _lock_pass_first[0] = '\0'; + beginPinEntry("New PIN"); + } + return true; + } if (_selected == DEVICE_NAME && p && enter) { _edit_name = true; _kb->begin(the_mesh.getNodeName(), (int)sizeof(p->node_name) - 1); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 925cd15c..88d86db2 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1464,6 +1464,13 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no } #endif + // Lock device on boot if password is enabled to prevent bypassing it by resetting device + if (passwordLockEnabled()) { + _locked = true; + // Add BOOT_SCREEN_MILLIS to make sure splash screen still shows + _lock_wake_until = millis() + BOOT_SCREEN_MILLIS + 5000; + } + #if defined(PIN_USER_BTN) user_btn.begin(); #endif @@ -1537,6 +1544,8 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no applyRotation(); applyFullRefreshInterval(); applyAllGpioModes(); // restore persisted pin modes to hardware before any UI/bot use + // Locked above (a PIN, or the cover closed) before Home existed to be told. + if (_locked) syncLockToHome(); setCurrScreen(splash); } @@ -1924,6 +1933,89 @@ void UITask::syncLockToHome() { if (home) static_cast(home)->setLocked(_locked); } +bool UITask::passwordLockEnabled() const { + return _node_prefs && screenlock::isSet(*_node_prefs); +} + +void UITask::setNodeLockPassword(const char* plain) { + if (_node_prefs) screenlock::set(*_node_prefs, plain, the_mesh.getRNG()); +} + +void UITask::beginUnlockPrompt() { + _unlock_kb = true; // Track that keyboard is visible and is waiting for input + _kb.beginPin("", KeyboardWidget::PIN_MAX_LEN, true, "PIN"); // masked + _kb.clearPlaceholders(); + _lock_wake_until = millis() + 5000; // keep the display on while typing + _next_refresh = 0; +} + +void UITask::cancelUnlockPrompt() { + _unlock_kb = false; + _kb.pin_mode = false; + _kb.buf[0] = '\0'; // clear input + _kb.len = 0; + _kb.cursor_pos = 0; + _next_refresh = 100; +} + +void UITask::handleUnlockKey(char c) { + auto res = _kb.handleInput(c); + if (res == KeyboardWidget::DONE && _pin_tries.blocked()) { + char msg[32]; + snprintf(msg, sizeof(msg), "Wait %lu s", (unsigned long)_pin_tries.secondsLeft()); + showAlert(msg, 1200); + } else if (res == KeyboardWidget::DONE) { // Process input on submit + if (_node_prefs && screenlock::check(*_node_prefs, _kb.buf)) { + // Match: Unlock + _pin_tries.ok(); + _unlock_kb = false; + _kb.pin_mode = false; + _locked = false; + if (_display && !_display->isOn()) _display->turnOn(); + uint32_t aoff = autoOffMillis(); + if (aoff > 0) _auto_off = millis() + aoff; + syncLockToHome(); + } else { + // Invalid: Clear input and reset keyboard; TRIES misses pause the entry + char msg[32]; + if (_pin_tries.miss()) snprintf(msg, sizeof(msg), "Wait %lu s", (unsigned long)(screenlock::PAUSE_MS / 1000)); + else snprintf(msg, sizeof(msg), "Wrong PIN, %u left", (unsigned)_pin_tries.left()); + showAlert(msg, 1200); + _kb.len = 0; + _kb.buf[0] = '\0'; + _kb.cursor_pos = 0; + _kb.page = _kb.row = _kb.col = 0; + _kb.caps = _kb.caps_lock = false; + _next_refresh = 0; + } + } else if (res == KeyboardWidget::CANCELLED) { + cancelUnlockPrompt(); + } + _lock_wake_until = millis() + 5000; // keep the display on while typing +} + +void UITask::toggleLock() { + if (_unlock_kb) { + cancelUnlockPrompt(); + return; + } + if (_locked) { + if (passwordLockEnabled()) { // Prompt for password + beginUnlockPrompt(); + } else { // ...or unlock instantly + _locked = false; + if (_display && !_display->isOn()) _display->turnOn(); + uint32_t aoff = autoOffMillis(); + if (aoff > 0) _auto_off = millis() + aoff; + } + } else { // Device is currently unlocked -> lock it + _locked = true; + _lock_wake_until = millis() + 2000; // Briefly show lockscreen before blanking + } + syncLockToHome(); + _next_refresh = 0; +} + bool UITask::savePrefsIfDirty(bool& dirty) { if (!dirty) return false; the_mesh.savePrefs(); @@ -2209,15 +2301,7 @@ void UITask::pollCardKB() { // two-key combo, so it doesn't need the physical combo's extra 3x // repetition to guard against accidental triggering. if (_display && !_display->isOn()) _display->turnOn(); - _locked = !_locked; - if (_locked) { - _lock_wake_until = millis() + 2000; - } else { - uint32_t aoff = autoOffMillis(); - if (aoff > 0) _auto_off = millis() + aoff; - } - syncLockToHome(); - _next_refresh = 0; + toggleLock(); return; } else if (raw >= 0x80 && raw <= 0xAF) { // Fn+ -- open its accent popup char base = CARDKB_FN_BASE[raw - 0x80]; @@ -2271,6 +2355,7 @@ void UITask::pollHallSensor() { _hall_magnet_present = present; if (present) { // cover closed + cancelUnlockPrompt(); // a cover can't be over the password prompt _locked = true; syncLockToHome(); _lock_wake_until = 0; @@ -2279,11 +2364,17 @@ void UITask::pollHallSensor() { digitalWrite(PIN_LED, LOW); // same as the auto-off path -- one less thing lit under a closed cover #endif } else { // cover opened - _locked = false; - syncLockToHome(); - if (_display && !_display->isOn()) _display->turnOn(); - uint32_t aoff = autoOffMillis(); - if (aoff > 0) _auto_off = millis() + aoff; + if (passwordLockEnabled()) { // Redirect flow to usual unlock prompt when password is enabled + _locked = true; + if (_display) _display->turnOn(); + beginUnlockPrompt(); + } else { + _locked = false; + syncLockToHome(); + if (_display && !_display->isOn()) _display->turnOn(); + uint32_t aoff = autoOffMillis(); + if (aoff > 0) _auto_off = millis() + aoff; + } } _next_refresh = 0; #endif @@ -2296,7 +2387,7 @@ void UITask::loop() { uint8_t joy_rot = _node_prefs ? _node_prefs->joystick_rotation : JOYSTICK_ROTATION; int ev = user_btn.check(); if (ev == BUTTON_EVENT_CLICK) { - if (back_btn.isPressed()) { + if (back_btn.isPressed() && !_unlock_kb) { // Enter clicked while Back is held — lock/unlock sequence if (_display && !_display->isOn()) { _display->turnOn(); // turn on display so hints are visible @@ -2309,18 +2400,15 @@ void UITask::loop() { if (_lock_seq_count >= 3) { _lock_seq_count = 0; _lock_seq_used = true; // suppress Back release click - _locked = !_locked; - if (_locked) { - _lock_wake_until = millis() + 2000; - } else { - if (_display && !_display->isOn()) _display->turnOn(); - uint32_t aoff = autoOffMillis(); - if (aoff > 0) _auto_off = millis() + aoff; - } - syncLockToHome(); + toggleLock(); } // eat the Enter — don't pass to curr } else { + // While the password keyboard is open, Back+Enter is just typing + if (_unlock_kb) { + _lock_seq_count = 0; + _lock_seq_ms = 0; + } enqueueKey(checkDisplayOn(KEY_ENTER)); } } else if (ev == BUTTON_EVENT_LONG_PRESS) { @@ -2510,7 +2598,12 @@ void UITask::loop() { } if (_kq_head != _kq_tail) { - if (!_locked && curr) { + if (_unlock_kb) { + // Lock-screen password keyboard: Consume every key press + char k; + while (dequeueKey(k)) handleUnlockKey(k); + _next_refresh = 100; // redraw immediately after key press + } else if (!_locked && curr) { // Apply the whole queued burst, then redraw once — N taps captured during // a blocking refresh become N navigation steps at the cost of one refresh. char k; @@ -2539,16 +2632,34 @@ void UITask::loop() { if (_display != NULL && _display->isOn()) { - if (_locked && (int32_t)(millis() - _lock_wake_until) >= 0) { + // Lock-screen password prompt + if (_locked && _unlock_kb && (int32_t)(millis() - _lock_wake_until) >= 0) { + cancelUnlockPrompt(); // Cancel unlock attempt on idle + _next_refresh = 0; + } + if (_locked && !_unlock_kb && (int32_t)(millis() - _lock_wake_until) >= 0) { _display->turnOff(); + } else if (_locked && _unlock_kb && millis() >= _next_refresh) { + // While the prompt is up the password keyboard replaces the lockscreen view + _display->startFrame(); + _kb.beginFrame(); + int delay_millis = _kb.render(*_display); + if (millis() < _alert_expiry) renderAlertOverlay(); // "Wrong PIN", and a ringing alarm + _display->endFrame(); + _next_refresh = millis() + delay_millis; } else if (_locked && millis() >= _next_refresh && home) { _display->startFrame(); - home->render(*_display); + if (curr && curr != home && (millis() - ui_started_at < BOOT_SCREEN_MILLIS)) { + // Boot splash is still up on a boot-locked device + _next_refresh = millis() + curr->render(*_display); + } else { + home->render(*_display); + _next_refresh = millis() + Features::LOCKSCREEN_REFRESH_MS; + } // Alert overlay on top — without this a ringing alarm on a locked device // played its melody against a screen that never said what was ringing. if (millis() < _alert_expiry) renderAlertOverlay(); _display->endFrame(); - _next_refresh = millis() + Features::LOCKSCREEN_REFRESH_MS; } else if (!_locked && millis() >= _next_refresh && curr) { _display->startFrame(); _kb.beginFrame(); @@ -2588,6 +2699,7 @@ void UITask::loop() { digitalWrite(PIN_LED, LOW); // turn off status LED with display to save power #endif if (_node_prefs && _node_prefs->auto_lock) { + cancelUnlockPrompt(); // idle-lock isn't a password prompt _locked = true; _lock_wake_until = 0; syncLockToHome(); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index e1cdeab6..31436607 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -23,6 +23,7 @@ #include "../AbstractUITask.h" #include "../NodePrefs.h" #include "../Trail.h" +#include "../ui-core/ScreenLock.h" // Optional M5Stack CardKB (I2C keyboard, addr 0x5F). CARDKB_I2C names which // TwoWire it lives on -- set at file scope (not inside the class body, and @@ -65,6 +66,9 @@ class UITask : public UITaskBase, public UiCoreHost { int _lock_seq_count; // Enter presses while Back held (lock/unlock sequence) unsigned long _lock_seq_ms; // millis() of last lock-sequence press (for timeout) bool _lock_seq_used; // true = suppress next back_btn CLICK (post-sequence release) + // True while the lock screen shows the on-screen keyboard and waits for submission + bool _unlock_kb = false; + screenlock::Attempts _pin_tries; char _alert[80]; char _notif_mel_buf[220]; // persistent RTTTL buffer for custom notification melodies // Persistent RTTTL buffer for the bot !buzz command (see botBuzz()) -- sized @@ -225,6 +229,13 @@ private: // dedicated lock-screen code path in loop(). void syncLockToHome(); + // Handles (un)locking and requesting password input when one is set + void toggleLock(); + void beginUnlockPrompt(); + void cancelUnlockPrompt(); + // Handles shortcuts during lockscreen password input + void handleUnlockKey(char c); + // Centred alert overlay (the showAlert() box). Wraps long text to up to // three lines inside the box instead of letting it overflow the border. // Shared by the normal render path and the lock screen (so a ringing @@ -232,6 +243,9 @@ private: void renderAlertOverlay(); public: + // A new screen PIN (see ui-core/ScreenLock.h); "" clears it. The caller saves the prefs. + void setNodeLockPassword(const char* plain); + bool passwordLockEnabled() const; UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : UITaskBase(board, serial), _display(NULL), _sensors(NULL), _node_prefs(NULL) { next_batt_chck = _next_refresh = 0; @@ -411,7 +425,7 @@ public: #endif } - bool isBuzzerQuiet() { + bool isBuzzerQuiet() { #ifdef PIN_BUZZER return buzzer.isQuiet(); #else diff --git a/examples/companion_radio/ui-new/icons.h b/examples/companion_radio/ui-new/icons.h index 8a05e853..89813de4 100644 --- a/examples/companion_radio/ui-new/icons.h +++ b/examples/companion_radio/ui-new/icons.h @@ -451,6 +451,11 @@ MINI_ICON(ICON_MAP_TARGET, 5, // ⚑ flag on a pole — the active Locator/N packRow("#....")); // Keyboard special-key glyphs. +MINI_ICON(ICON_KEYBOARD, 7, // PIN keyboard to ABC keyboard switch icon + packRow("#.#.#.#"), + packRow("#######"), + packRow("#.#.#.#"), + packRow("#######")); MINI_ICON(ICON_SHIFT, 7, // ⇧ caps packRow("...#..."), packRow("..###.."),