fix(solo): six fixes from the 2026-07-05 UI audit

- locked device: a ringing alarm/timer was invisible — wakeForAlarm() now
  holds the lock wake window for the whole ring, the lock screen draws the
  alert overlay, and a key-dismissed ring falls back to a 5 s glance
- status bar: A / live-share / trail / repeater icons no longer vanish when
  Bluetooth is off (moved outside the isSerialEnabled() gate)
- alert overlay: long text wraps to up to 3 lines inside the box instead of
  overflowing the border (new UITask::renderAlertOverlay, shared with lock)
- MyMesh: force NUL on contact.name copied from an app frame (unterminated
  name could overrun the AdvertPath strcpy)
- clock tools: ring/timer deadline compares now wrap-safe (signed diff),
  matching the trail/loc-share timers
- messages: channel context menu freezes its target channel at open; fav
  toggle no longer retargets the menu when the fav-only filter drops the row

All three solo envs build green (WioTrackerL1 OLED/Eink, GAT562-30S).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-07-05 16:08:44 +02:00
parent 04978d8ae9
commit 9a71ee14b6
4 changed files with 113 additions and 54 deletions

View File

@@ -211,6 +211,10 @@ void MyMesh::updateContactFromFrame(ContactInfo &contact, uint32_t& last_mod, co
memcpy(contact.out_path, &frame[i], MAX_PATH_SIZE); memcpy(contact.out_path, &frame[i], MAX_PATH_SIZE);
i += MAX_PATH_SIZE; i += MAX_PATH_SIZE;
memcpy(contact.name, &frame[i], 32); memcpy(contact.name, &frame[i], 32);
// The frame comes from the app and nothing guarantees a NUL inside the 32
// bytes; an unterminated name would later overrun strcpy/strlen consumers
// (e.g. the AdvertPath name cache).
contact.name[sizeof(contact.name) - 1] = '\0';
i += 32; i += 32;
memcpy(&contact.last_advert_timestamp, &frame[i], 4); memcpy(&contact.last_advert_timestamp, &frame[i], 4);
i += 4; i += 4;

View File

@@ -53,6 +53,11 @@ class QuickMsgScreen : public UIScreen {
// Context menu (opened by KEY_CONTEXT_MENU in CHANNEL_PICK / CONTACT_PICK / histories) // Context menu (opened by KEY_CONTEXT_MENU in CHANNEL_PICK / CONTACT_PICK / histories)
PopupMenu _ctx_menu; PopupMenu _ctx_menu;
bool _ctx_dirty; bool _ctx_dirty;
// Channel the open channel-context menu acts on, frozen at open time. The
// Fav toggle can remove the highlighted channel from a fav-only list, so
// re-reading _channel_indices[_channel_sel] mid-interaction could silently
// retarget the menu at a different channel.
uint8_t _ctx_ch_idx = 0;
char _ctx_notif_item[22]; char _ctx_notif_item[22];
char _ctx_melody_item[20]; char _ctx_melody_item[20];
char _ctx_pin_item[28]; // "Pin to dial" or "Unpin (slot N)" char _ctx_pin_item[28]; // "Pin to dial" or "Unpin (slot N)"
@@ -1389,7 +1394,7 @@ public:
if (left || right) { if (left || right) {
static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" };
static const char* ML[] = { "global", "M1", "M2" }; static const char* ML[] = { "global", "M1", "M2" };
uint8_t ch_idx = _channel_indices[_channel_sel]; uint8_t ch_idx = _ctx_ch_idx; // frozen at menu open — see declaration
int sel = _ctx_menu.selectedIndex(); int sel = _ctx_menu.selectedIndex();
if (sel == 1) { if (sel == 1) {
uint8_t v = chNotifState(ch_idx); uint8_t v = chNotifState(ch_idx);
@@ -1410,8 +1415,10 @@ public:
bool is_fav = (p2->ch_fav_bitmask & (1ULL << ch_idx)); bool is_fav = (p2->ch_fav_bitmask & (1ULL << ch_idx));
snprintf(_ctx_ch_fav_item, sizeof(_ctx_ch_fav_item), is_fav ? "Fav: yes" : "Fav: no"); snprintf(_ctx_ch_fav_item, sizeof(_ctx_ch_fav_item), is_fav ? "Fav: yes" : "Fav: no");
_ctx_dirty = true; _ctx_dirty = true;
buildChannelList(); // List rebuild is deferred to menu close: with the fav-only
if (_channel_sel >= _num_channels) _channel_sel = _num_channels > 0 ? _num_channels - 1 : 0; // filter on, un-favouriting this channel removes it from the
// list, and rebuilding under the open menu would shift
// _channel_sel onto a different channel mid-interaction.
} }
} }
return true; return true;
@@ -1419,7 +1426,7 @@ public:
} }
auto res = _ctx_menu.handleInput(c); auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED && _num_channels > 0) { if (res == PopupMenu::SELECTED && _num_channels > 0) {
uint8_t ch_idx = _channel_indices[_channel_sel]; uint8_t ch_idx = _ctx_ch_idx; // frozen at menu open — see declaration
int sel = _ctx_menu.selectedIndex(); int sel = _ctx_menu.selectedIndex();
if (sel == 0) { if (sel == 0) {
int cleared = (int)_history.chUnread(ch_idx); int cleared = (int)_history.chUnread(ch_idx);
@@ -1428,7 +1435,12 @@ public:
} }
// sel 1/2/3 already handled by LEFT/RIGHT; ENTER just closes. // sel 1/2/3 already handled by LEFT/RIGHT; ENTER just closes.
} }
if (res != PopupMenu::NONE) _task->savePrefsIfDirty(_ctx_dirty); if (res != PopupMenu::NONE) {
_task->savePrefsIfDirty(_ctx_dirty);
// Apply any Fav change to the visible list now that the menu is done.
buildChannelList();
if (_channel_sel >= _num_channels) _channel_sel = _num_channels > 0 ? _num_channels - 1 : 0;
}
return true; return true;
} }
if (c == KEY_CANCEL) { _phase = MODE_SELECT; return true; } if (c == KEY_CANCEL) { _phase = MODE_SELECT; return true; }
@@ -1453,6 +1465,7 @@ public:
} }
if (c == KEY_CONTEXT_MENU && _num_channels > 0) { if (c == KEY_CONTEXT_MENU && _num_channels > 0) {
uint8_t ch_idx = _channel_indices[_channel_sel]; uint8_t ch_idx = _channel_indices[_channel_sel];
_ctx_ch_idx = ch_idx; // freeze the menu's target channel
static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" };
snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s",
NOTIF_LABELS[chNotifState(ch_idx)]); NOTIF_LABELS[chNotifState(ch_idx)]);

View File

@@ -493,39 +493,46 @@ class HomeScreen : public UIScreen {
else else
drawSlotIcon(display, btX, ind, ind_h, ICON_BLUETOOTH); // plain glyph: available, not linked drawSlotIcon(display, btX, ind, ind_h, ICON_BLUETOOTH); // plain glyph: available, not linked
leftmostX = btX - ind_gap; leftmostX = btX - ind_gap;
} else {
leftmostX = battLeftX - ind_gap; // no BT icon; keep the same first-slot spacing
}
// "A" indicator — left of BT; blinks on OLED, always shown on e-ink // Background-mode indicatorsdeliberately OUTSIDE the isSerialEnabled()
if (_node_prefs && _node_prefs->advert_auto_interval_sec > 0) { // gate above: auto-advert, live share, the trail and the repeater all keep
int aX = leftmostX - ind; // running with Bluetooth switched off, so their at-a-glance cues must not
if (blinkOn()) drawBoxedIcon(display, aX, ind, ind_h, ICON_ADVERT); // vanish with the BT icon.
leftmostX = aX - 1;
}
// Live location sharing active. Same blink convention — another // "A" indicator — blinks on OLED, always shown on e-ink
// "leave it on and forget" broadcast, like auto-advert above. Reuses if (_node_prefs && _node_prefs->advert_auto_interval_sec > 0) {
// the diamond the map uses for a live-tracked contact, so the glyph int aX = leftmostX - ind;
// already means "sharing position" elsewhere in the UI. if (blinkOn()) drawBoxedIcon(display, aX, ind, ind_h, ICON_ADVERT);
if (_node_prefs && _node_prefs->loc_share_enabled) { leftmostX = aX - 1;
int lsX = leftmostX - ind; }
if (blinkOn()) drawBoxedIcon(display, lsX, ind, ind_h, ICON_MAP_CONTACT);
leftmostX = lsX - 1;
}
// GPS trail logging active. Same blink convention. // Live location sharing active. Same blink convention — another
if (_task->trail().isActive()) { // "leave it on and forget" broadcast, like auto-advert above. Reuses
int gX = leftmostX - ind; // the diamond the map uses for a live-tracked contact, so the glyph
if (blinkOn()) drawBoxedIcon(display, gX, ind, ind_h, ICON_TRAIL); // already means "sharing position" elsewhere in the UI.
leftmostX = gX - 1; if (_node_prefs && _node_prefs->loc_share_enabled) {
} int lsX = leftmostX - ind;
if (blinkOn()) drawBoxedIcon(display, lsX, ind, ind_h, ICON_MAP_CONTACT);
leftmostX = lsX - 1;
}
// Repeater relaying. Same blink convention — a "leave it on and forget" // GPS trail logging active. Same blink convention.
// mode, so an at-a-glance cue matters (especially on a Custom profile, if (_task->trail().isActive()) {
// where the device is off your own network while this is shown). int gX = leftmostX - ind;
if (_node_prefs && _node_prefs->client_repeat) { if (blinkOn()) drawBoxedIcon(display, gX, ind, ind_h, ICON_TRAIL);
int rX = leftmostX - ind; leftmostX = gX - 1;
if (blinkOn()) drawBoxedIcon(display, rX, ind, ind_h, ICON_REPEATER); }
leftmostX = rX - 1;
} // Repeater relaying. Same blink convention — a "leave it on and forget"
// mode, so an at-a-glance cue matters (especially on a Custom profile,
// where the device is off your own network while this is shown).
if (_node_prefs && _node_prefs->client_repeat) {
int rX = leftmostX - ind;
if (blinkOn()) drawBoxedIcon(display, rX, ind, ind_h, ICON_REPEATER);
leftmostX = rX - 1;
} }
// GPS fix status — boxed (lit) when the receiver has a valid fix, plain // GPS fix status — boxed (lit) when the receiver has a valid fix, plain
@@ -1528,11 +1535,6 @@ void UITask::gotoRepeaterScreen() { setCurrScreen(repeater_screen); }
void UITask::gotoClockTools() { setCurrScreen(clock_tools); } void UITask::gotoClockTools() { setCurrScreen(clock_tools); }
void UITask::gotoLiveShareScreen() { setCurrScreen(live_share_screen); } void UITask::gotoLiveShareScreen() { setCurrScreen(live_share_screen); }
void UITask::wakeForAlarm() {
if (_display != NULL) _display->turnOn();
_next_refresh = 0; // draw the alert overlay immediately
}
// ── Clock tools engine (alarm / countdown / ring) ─────────────────────────── // ── Clock tools engine (alarm / countdown / ring) ───────────────────────────
// Lives here, not in ClockToolsScreen, so it fires regardless of the current // Lives here, not in ClockToolsScreen, so it fires regardless of the current
// screen. The melody overrides mute (playMelody → buzzer.playForced); the ring // screen. The melody overrides mute (playMelody → buzzer.playForced); the ring
@@ -1541,6 +1543,16 @@ static const char* CLOCK_ALARM_MELODY = "alarm:d=8,o=6,b=125:c,c,c,c,p,
static const uint32_t CLOCK_RING_MS = 60000; static const uint32_t CLOCK_RING_MS = 60000;
static const uint32_t CLOCK_ALARM_CATCHUP_SECS = 6 * 3600; // fire late up to 6 h, else reschedule static const uint32_t CLOCK_ALARM_CATCHUP_SECS = 6 * 3600; // fire late up to 6 h, else reschedule
void UITask::wakeForAlarm() {
if (_display != NULL) _display->turnOn();
// Locked: the lock-screen blanking check (loop()) turns the display straight
// back off once _lock_wake_until is in the past — which it always is by the
// time an alarm fires. Hold the wake window open for the whole ring so the
// lock screen (and its alert overlay) stays visible while ringing.
if (_locked) _lock_wake_until = millis() + CLOCK_RING_MS;
_next_refresh = 0; // draw the alert overlay immediately
}
// Next absolute wall instant matching alarm_hour:alarm_min in local time, // Next absolute wall instant matching alarm_hour:alarm_min in local time,
// strictly after now_wall (an alarm set to the current minute waits a day). // strictly after now_wall (an alarm set to the current minute waits a day).
uint32_t UITask::computeAlarmNextFire(uint32_t now_wall) const { uint32_t UITask::computeAlarmNextFire(uint32_t now_wall) const {
@@ -1590,12 +1602,14 @@ void UITask::evaluateAlarm() {
void UITask::tickClockTools() { void UITask::tickClockTools() {
uint32_t now_ms = millis(); uint32_t now_ms = millis();
// Ring maintenance: repeat the melody until dismissed or the window elapses. // Ring maintenance: repeat the melody until dismissed or the window elapses.
// Signed-difference compares (like the trail/loc-share timers) so deadlines
// landing past the millis() rollover don't read as already elapsed.
if (_ringing) { if (_ringing) {
if (now_ms >= _ring_until_ms) { stopMelody(); _ringing = false; clearAlert(); } if ((int32_t)(now_ms - _ring_until_ms) >= 0) { stopMelody(); _ringing = false; clearAlert(); }
else if (!isMelodyPlaying()) playMelody(CLOCK_ALARM_MELODY); else if (!isMelodyPlaying()) playMelody(CLOCK_ALARM_MELODY);
} }
// Countdown timer (millis — sync-immune). // Countdown timer (millis — sync-immune).
if (_timer_running && now_ms >= _timer_deadline_ms) { if (_timer_running && (int32_t)(now_ms - _timer_deadline_ms) >= 0) {
_timer_running = false; _timer_running = false;
fireClockAlert("Timer done"); fireClockAlert("Timer done");
} }
@@ -1865,6 +1879,30 @@ void UITask::userLedHandler() {
#endif #endif
} }
// Centred alert box. Long text used to be drawn as one drawTextCentered line
// that overflowed the border on both sides (e.g. "GPS on, tracking started"
// is already wider than a 128 px OLED); wrap it to up to three lines inside
// the box instead. Uses the shared wrap scratch (s_wrap_*) — single-threaded
// render path, same contract as the message views.
void UITask::renderAlertOverlay() {
_display->setTextSize(1);
const int lh = _display->getLineHeight();
const int pad = 3;
const int box_w = _display->width() - 8;
const int box_x = 4;
_display->translateUTF8ToBlocks(s_wrap_trans, _alert, sizeof(s_wrap_trans));
int nl = FullscreenMsgView::wrapLines(*_display, s_wrap_trans, box_w - pad * 2, s_wrap_lines, 3);
if (nl < 1) nl = 1;
int box_h = nl * lh + pad * 2;
int box_y = (_display->height() - box_h) / 2;
_display->setColor(DisplayDriver::DARK);
_display->fillRect(box_x, box_y, box_w, box_h);
_display->setColor(DisplayDriver::LIGHT);
_display->drawRect(box_x, box_y, box_w, box_h);
for (int i = 0; i < nl; i++)
_display->drawTextCentered(_display->width() / 2, box_y + pad + i * lh, s_wrap_lines[i]);
}
void UITask::setCurrScreen(UIScreen* c) { void UITask::setCurrScreen(UIScreen* c) {
// Fail safe on a null target: a screen pointer left uninitialised (member // Fail safe on a null target: a screen pointer left uninitialised (member
// declared + navigator wired, but the `new XScreen()` line forgotten in // declared + navigator wired, but the `new XScreen()` line forgotten in
@@ -2117,6 +2155,9 @@ void UITask::loop() {
dismissRing(); dismissRing();
_kq_head = _kq_tail = 0; _kq_head = _kq_tail = 0;
_next_refresh = 0; _next_refresh = 0;
// Locked: wakeForAlarm() held the wake window open for the whole ring;
// once dismissed, fall back to the usual brief lock-screen glance.
if (_locked) _lock_wake_until = millis() + 5000;
} }
if (_kq_head != _kq_tail) { if (_kq_head != _kq_tail) {
@@ -2226,24 +2267,16 @@ void UITask::loop() {
_display->setColor(DisplayDriver::DARK); _display->setColor(DisplayDriver::DARK);
_display->setCursor(hx, hy); _display->setCursor(hx, hy);
_display->print(hint); _display->print(hint);
// 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(); _display->endFrame();
_next_refresh = millis() + Features::LOCKSCREEN_REFRESH_MS; _next_refresh = millis() + Features::LOCKSCREEN_REFRESH_MS;
} else if (!_locked && millis() >= _next_refresh && curr) { } else if (!_locked && millis() >= _next_refresh && curr) {
_display->startFrame(); _display->startFrame();
int delay_millis = curr->render(*_display); int delay_millis = curr->render(*_display);
if (millis() < _alert_expiry) { // alert overlay on top of any screen if (millis() < _alert_expiry) { // alert overlay on top of any screen
_display->setTextSize(1); renderAlertOverlay();
int lh = _display->getLineHeight();
int pad = 3;
int box_h = lh + pad * 2;
int box_w = _display->width() - 8;
int box_x = 4;
int box_y = (_display->height() - box_h) / 2;
_display->setColor(DisplayDriver::DARK);
_display->fillRect(box_x, box_y, box_w, box_h);
_display->setColor(DisplayDriver::LIGHT);
_display->drawRect(box_x, box_y, box_w, box_h);
_display->drawTextCentered(_display->width() / 2, box_y + pad, _alert);
// Keep refreshing the underlying screen at its own cadence (capped at the // Keep refreshing the underlying screen at its own cadence (capped at the
// alert's expiry) so layouts that settle over a frame — e.g. the message- // alert's expiry) so layouts that settle over a frame — e.g. the message-
// history scrollbar reserve — don't stay stuck behind the alert. Unchanged // history scrollbar reserve — don't stay stuck behind the alert. Unchanged

View File

@@ -187,6 +187,12 @@ class UITask : public AbstractUITask {
void setCurrScreen(UIScreen* c); void setCurrScreen(UIScreen* 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
// alarm's label is visible while locked).
void renderAlertOverlay();
public: public:
UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL), _node_prefs(NULL), _dash_lpp(200) { UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL), _node_prefs(NULL), _dash_lpp(200) {
@@ -290,7 +296,10 @@ public:
uint32_t timerRemainingMs() const { uint32_t timerRemainingMs() const {
if (!_timer_running) return 0; if (!_timer_running) return 0;
uint32_t now = millis(); uint32_t now = millis();
return (now >= _timer_deadline_ms) ? 0 : (_timer_deadline_ms - now); // Signed-difference compare so a deadline that lands past the millis()
// rollover (~49.7 days) doesn't read as already elapsed.
if ((int32_t)(now - _timer_deadline_ms) >= 0) return 0;
return _timer_deadline_ms - now;
} }
bool isRinging() const { return _ringing; } bool isRinging() const { return _ringing; }
void dismissRing() { stopMelody(); _ringing = false; clearAlert(); } void dismissRing() { stopMelody(); _ringing = false; clearAlert(); }