diff --git a/docs/solo_features/message_screen/message_screen.md b/docs/solo_features/message_screen/message_screen.md index 77088cab..8b7067ec 100644 --- a/docs/solo_features/message_screen/message_screen.md +++ b/docs/solo_features/message_screen/message_screen.md @@ -138,17 +138,23 @@ In the **Rooms** list the context menu instead offers: ### Adding / editing a channel -Joining a new community channel, or creating one to share with others, no longer needs the phone app. The **Channels** list ends with a **"+ Add channel"** row — press **Enter** on it to open the form, or use **Edit** from the context menu above to change an existing channel's name or secret. +Joining a new community channel, or creating one to share with others, no longer needs the phone app. The **Channels** list ends with a **"+ Add channel"** row — press **Enter** on it to pick a channel type, or use **Edit** from the context menu above to change an existing channel's name or secret (Edit skips the type picker and opens the Name/Secret form directly). -| Field | Notes | -| ------ | ---------------------------------------------------------------------------------------------- | -| Name | Up to 31 characters | -| Secret | **LEFT/RIGHT** toggles between two entry modes; **Enter** opens the keyboard for whichever is selected | +**+ Add channel** first asks which type of channel to create — the same three types the phone app offers: -- **Passphrase** (default) — type any text; the device hashes it down to the channel's 16-byte secret. Easiest to agree on verbally, the same idea as a room password — two people who type the same passphrase end up on the same channel. -- **Hex key** — type the exact 32-hex-character secret (the format used by channel QR codes, see [QR Codes](../../qr_codes.md)), for joining a channel whose precise secret you were given rather than agreeing on a new passphrase. An all-zero secret (`00…0`) is rejected ("Invalid secret") — that value is reserved internally to mark an empty channel slot. +- **Public** — instantly re-adds the well-known default public channel (no fields to fill in). Useful if it was deleted and you want it back without remembering its key. +- **Hashtag** — type a topic name (e.g. `test`); the channel's name and secret are both derived from it (name becomes `#test`, secret is the first 16 bytes of `sha256("#test")`). A topic-based public group chat — anyone who types the same topic elsewhere ends up on the same channel — separate from the default Public channel. +- **Private** — the manual Name + Secret form: -Select **[Save]** to commit. The secret can't be redisplayed once saved (only the derived key is kept) — editing it later means typing a new passphrase or hex key, the same as re-logging into a room with a new password. + | Field | Notes | + | ------ | ---------------------------------------------------------------------------------------------- | + | Name | Up to 31 characters | + | Secret | **LEFT/RIGHT** toggles between two entry modes; **Enter** opens the keyboard for whichever is selected | + + - **Passphrase** (default) — type any text; the device hashes it down to the channel's 16-byte secret. Easiest to agree on verbally, the same idea as a room password — two people who type the same passphrase end up on the same channel. + - **Hex key** — type the exact 32-hex-character secret (the format used by channel QR codes, see [QR Codes](../../qr_codes.md)), for joining a channel whose precise secret you were given rather than agreeing on a new passphrase. An all-zero secret (`00…0`) is rejected ("Invalid secret") — that value is reserved internally to mark an empty channel slot. + + Select **[Save]** to commit. The secret can't be redisplayed once saved (only the derived key is kept) — editing it later means typing a new passphrase or hex key, the same as re-logging into a room with a new password. --- diff --git a/examples/companion_radio/ui-new/ChannelsView.h b/examples/companion_radio/ui-new/ChannelsView.h index c92ef5d3..23314a9f 100644 --- a/examples/companion_radio/ui-new/ChannelsView.h +++ b/examples/companion_radio/ui-new/ChannelsView.h @@ -3,7 +3,22 @@ // Owned by MessagesScreen, which delegates render()/handleInput() to it while // active() — the same relationship WaypointsView has with TrailScreen. // -// The Secret field toggles between two entry modes with LEFT/RIGHT: +// "+ Add channel" starts with a Type picker (Public/Hashtag/Private), matching +// the phone app's channel-type trichotomy (docs/companion_protocol.md's +// "Channel Types") instead of exposing the raw Name+Secret form directly: +// - Public: one tap re-adds the well-known default channel (same secret +// MyMesh.cpp pre-configures at first boot) — no fields to fill in. +// - Hashtag: type just a topic name; the channel name and secret are both +// derived from it ("#topic", secret = first 16 bytes of sha256("#topic")). +// A topic-based public group chat, discoverable without knowing the hash +// convention exists. +// - Private: today's manual Name + Secret form (see below) — a channel only +// those you share the secret with can join. +// Editing an existing channel (openEdit()) skips the Type picker and goes +// straight to the Name+Secret form — there's no ambiguity to resolve there. +// +// The Secret field (Private, and internally for Public/Hashtag) toggles +// between two entry modes with LEFT/RIGHT: // - Passphrase (default): any text, SHA-256'd down to 16 bytes — the same // primitive BaseChatMesh::addChannel()/setChannel() already use to derive // a channel's routing hash, just applied here to a typed phrase instead of @@ -19,17 +34,20 @@ class ChannelsView { UITask* _task; - enum Mode : uint8_t { OFF, ADD, EDIT }; + enum Mode : uint8_t { OFF, TYPE_PICK, ADD, ADD_HASHTAG, EDIT }; uint8_t _mode = OFF; int _idx = -1; // channel slot being added/edited + int _type_sel = 0; // TYPE_PICK cursor: 0=Public 1=Hashtag 2=Private + char _name[32] = ""; bool _hex_mode = false; // false = passphrase, true = 32-hex-char raw key char _secret_text[33] = ""; // passphrase or hex string typed so far + char _topic[31] = ""; // ADD_HASHTAG: topic without the leading '#' - int _sel = 0; // 0=Name 1=Secret 2=[Save] + int _sel = 0; // 0=Name 1=Secret 2=[Save] (ADD_HASHTAG: 0=Topic 1=[Save]) bool _kb_active = false; - int _kb_field = -1; // 0=Name, 1=Secret + int _kb_field = -1; // 0=Name, 1=Secret, 2=Topic KeyboardWidget& kb() { return _task->keyboard(); } @@ -39,31 +57,36 @@ class ChannelsView { _kb_active = true; } + // Parses exactly 32 hex chars into a 16-byte secret (zero-padded to 32 for + // ChannelDetails.secret's full field width). Rejects malformed hex and the + // all-zero sentinel setChannelLocal() reads as "slot deleted" (see + // isAllZero() in MyMesh.cpp) -- saving one would pass, then immediately + // trigger onChannelRemoved() on this very slot, silently discarding the + // channel and its bot/notif/favourite state. Shared by deriveSecret()'s + // hex-mode branch and the Public quick-add (a fixed, known-good constant, + // but parsed through the same path rather than duplicated). + static bool hexToSecret(const char* hex32, uint8_t out[32]) { + if (strlen(hex32) != 32) return false; + uint8_t tmp[16]; + for (int i = 0; i < 16; i++) { + char byte_str[3] = { hex32[i * 2], hex32[i * 2 + 1], 0 }; + char* end = nullptr; + long v = strtol(byte_str, &end, 16); + if (*end != 0) return false; + tmp[i] = (uint8_t)v; + } + bool all_zero = true; + for (int i = 0; i < 16 && all_zero; i++) if (tmp[i]) all_zero = false; + if (all_zero) return false; + memset(out, 0, 32); + memcpy(out, tmp, 16); + return true; + } + // Turn the typed Secret field into a 16-byte channel secret. Returns false // (and leaves out[] untouched) if the field can't be parsed as configured. bool deriveSecret(uint8_t out[32]) const { - if (_hex_mode) { - if (strlen(_secret_text) != 32) return false; - uint8_t tmp[16]; - for (int i = 0; i < 16; i++) { - char byte_str[3] = { _secret_text[i * 2], _secret_text[i * 2 + 1], 0 }; - char* end = nullptr; - long v = strtol(byte_str, &end, 16); - if (*end != 0) return false; - tmp[i] = (uint8_t)v; - } - // An all-zero 16-byte secret is the sentinel setChannelLocal() reads as - // "slot deleted" (see isAllZero() in MyMesh.cpp) -- saving one here would - // pass, then immediately trigger onChannelRemoved() on this very slot, - // silently discarding the channel and its bot/notif/favourite state. - // Reject it up front rather than let that self-delete happen invisibly. - bool all_zero = true; - for (int i = 0; i < 16 && all_zero; i++) if (tmp[i]) all_zero = false; - if (all_zero) return false; - memset(out, 0, 32); - memcpy(out, tmp, 16); - return true; - } + if (_hex_mode) return hexToSecret(_secret_text, out); if (_secret_text[0] == '\0') return false; // blank passphrase not allowed uint8_t digest[32]; mesh::Utils::sha256(digest, sizeof(digest), (const uint8_t*)_secret_text, (int)strlen(_secret_text)); @@ -72,19 +95,48 @@ class ChannelsView { return true; } + bool saveChannel(const char* name, const uint8_t secret[32]) { + ChannelDetails ch; + memset(&ch, 0, sizeof(ch)); + strncpy(ch.name, name, sizeof(ch.name) - 1); + memcpy(ch.channel.secret, secret, sizeof(ch.channel.secret)); + return the_mesh.setChannelLocal((uint8_t)_idx, ch); + } + + // Type picker's Public row: no fields to fill in, so this commits directly + // instead of dropping into the Name+Secret form -- the well-known channel's + // name and secret are both fixed. Hex confirmed via + // `python3 -c "import base64; print(base64.b64decode('izOH6cXN6mrJ5e26oRXNcg==').hex())"` + // to match MyMesh.cpp's PUBLIC_GROUP_PSK (base64) and + // docs/companion_protocol.md's documented public channel key (hex). + void commitPublic() { + static const char PUBLIC_SECRET_HEX[] = "8b3387e9c5cdea6ac9e5edbaa115cd72"; + uint8_t secret[32]; + hexToSecret(PUBLIC_SECRET_HEX, secret); // fixed, known-good constant -- can't fail + if (saveChannel("Public", secret)) _task->showAlert("Channel added", 1000); + else _task->showAlert("Save failed", 1200); + _mode = OFF; + } + void commit() { + if (_mode == ADD_HASHTAG) { + if (_topic[0] == '\0') { _task->showAlert("Topic required", 1200); return; } + // "#topic" as both the channel's display name and the passphrase fed to + // sha256 -- the exact "Hashtag Channels" convention in + // docs/companion_protocol.md, just synthesized instead of hand-typed. + snprintf(_name, sizeof(_name), "#%s", _topic); + strncpy(_secret_text, _name, sizeof(_secret_text) - 1); + _secret_text[sizeof(_secret_text) - 1] = '\0'; + _hex_mode = false; + } if (_name[0] == '\0') { _task->showAlert("Name required", 1200); return; } uint8_t secret[32]; if (!deriveSecret(secret)) { _task->showAlert(_hex_mode ? "Invalid secret" : "Secret required", 1400); return; } - ChannelDetails ch; - memset(&ch, 0, sizeof(ch)); - strncpy(ch.name, _name, sizeof(ch.name) - 1); - memcpy(ch.channel.secret, secret, sizeof(ch.channel.secret)); - if (the_mesh.setChannelLocal((uint8_t)_idx, ch)) { - _task->showAlert(_mode == ADD ? "Channel added" : "Channel updated", 1000); + if (saveChannel(_name, secret)) { + _task->showAlert((_mode == ADD || _mode == ADD_HASHTAG) ? "Channel added" : "Channel updated", 1000); _mode = OFF; } else { _task->showAlert("Save failed", 1200); @@ -95,14 +147,17 @@ public: explicit ChannelsView(UITask* task) : _task(task) {} bool active() const { return _mode != OFF || _kb_active; } - void reset() { _mode = OFF; _kb_active = false; _kb_field = -1; } + void reset() { _mode = OFF; _kb_active = false; _kb_field = -1; _type_sel = 0; _topic[0] = '\0'; } // idx = the slot to write on Save (first free slot for Add, existing slot - // for Edit). Edit pre-fills Name; the Secret can't be redisplayed (only the - // derived key is stored), so editing it means typing a new passphrase/hex — - // same as re-logging into a room with a new password. + // for Edit). Starts at the Type picker (Public/Hashtag/Private) -- see + // handleInput()'s TYPE_PICK branch for what each option does next. void openAdd(int idx) { - _mode = ADD; _idx = idx; + _mode = TYPE_PICK; _idx = idx; _type_sel = 0; + } + // Private option's form: today's manual Name + Secret entry, unchanged. + void openPrivate() { + _mode = ADD; _name[0] = '\0'; _secret_text[0] = '\0'; _hex_mode = false; _sel = 0; } void openEdit(int idx, const char* existing_name) { @@ -116,9 +171,37 @@ public: display.setColor(DisplayDriver::LIGHT); if (_kb_active) return kb().render(display); - display.drawCenteredHeader(_mode == ADD ? "ADD CHANNEL" : "EDIT CHANNEL"); const int top = display.listStart(); const int step = display.lineStep(); + + if (_mode == TYPE_PICK) { + display.drawCenteredHeader("ADD CHANNEL"); + static const char* TYPE_LABELS[3] = { "Public", "Hashtag", "Private" }; + for (int i = 0; i < 3; i++) { + int y = top + i * step; + display.drawSelectionRow(0, y - 1, display.width(), step - 1, i == _type_sel); + display.drawTextEllipsized(2, y, display.width() - 4, TYPE_LABELS[i]); + display.setColor(DisplayDriver::LIGHT); + } + return 1000; + } + + if (_mode == ADD_HASHTAG) { + display.drawCenteredHeader("ADD CHANNEL"); + for (int i = 0; i < 2; i++) { + int y = top + i * step; + bool sel = (i == _sel); + display.drawSelectionRow(0, y - 1, display.width(), step - 1, sel); + char row[40]; + if (i == 0) snprintf(row, sizeof(row), "Topic: %s", _topic[0] ? _topic : "(none)"); + else snprintf(row, sizeof(row), "[Save]"); + display.drawTextEllipsized(2, y, display.width() - 4, row); + display.setColor(DisplayDriver::LIGHT); + } + return 1000; + } + + display.drawCenteredHeader(_mode == ADD ? "ADD CHANNEL" : "EDIT CHANNEL"); for (int i = 0; i < 3; i++) { int y = top + i * step; bool sel = (i == _sel); @@ -140,14 +223,39 @@ public: if (_kb_active) { auto r = kb().handleInput(c); if (r == KeyboardWidget::DONE) { - if (_kb_field == 0) { strncpy(_name, kb().buf, sizeof(_name) - 1); _name[sizeof(_name) - 1] = '\0'; } - else { strncpy(_secret_text, kb().buf, sizeof(_secret_text) - 1); _secret_text[sizeof(_secret_text) - 1] = '\0'; } + if (_kb_field == 0) { strncpy(_name, kb().buf, sizeof(_name) - 1); _name[sizeof(_name) - 1] = '\0'; } + else if (_kb_field == 1) { strncpy(_secret_text, kb().buf, sizeof(_secret_text) - 1); _secret_text[sizeof(_secret_text) - 1] = '\0'; } + else { strncpy(_topic, kb().buf, sizeof(_topic) - 1); _topic[sizeof(_topic) - 1] = '\0'; } _kb_active = false; _kb_field = -1; } else if (r == KeyboardWidget::CANCELLED) { _kb_active = false; _kb_field = -1; } return true; } + + if (_mode == TYPE_PICK) { + if (c == KEY_CANCEL) { _mode = OFF; return true; } + if (c == KEY_UP) { _type_sel = (_type_sel > 0) ? _type_sel - 1 : 2; return true; } + if (c == KEY_DOWN) { _type_sel = (_type_sel < 2) ? _type_sel + 1 : 0; return true; } + if (c == KEY_ENTER) { + if (_type_sel == 0) commitPublic(); + else if (_type_sel == 1) { _mode = ADD_HASHTAG; _sel = 0; _topic[0] = '\0'; } + else openPrivate(); + } + return true; + } + + if (_mode == ADD_HASHTAG) { + if (c == KEY_CANCEL) { _mode = OFF; return true; } + if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : 1; return true; } + if (c == KEY_DOWN) { _sel = (_sel < 1) ? _sel + 1 : 0; return true; } + if (c == KEY_ENTER) { + if (_sel == 0) { _kb_field = 2; openKb(_topic, sizeof(_topic) - 1); } + else commit(); + } + return true; + } + if (c == KEY_CANCEL) { _mode = OFF; return true; } if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : 2; return true; } if (c == KEY_DOWN) { _sel = (_sel < 2) ? _sel + 1 : 0; return true; } diff --git a/release-notes.md b/release-notes.md index 5d5d3257..1da14945 100644 --- a/release-notes.md +++ b/release-notes.md @@ -3,7 +3,7 @@ ### What's new - **Nodes is now the single node hub.** "Nearby Nodes" is renamed **Nodes** and gains on-device contact management — no phone app needed: **Hold Enter** on a row opens **Add contact** (for a scanned-but-unknown node), **Favourite/Unfavourite** (pins to the first free Favourites Dial slot, shown as a star in the list), and **Delete contact** (confirm first, defaults to Cancel). The filter strip (**LEFT/RIGHT**) is now a visible, wraparound tab carousel instead of a hidden mode. The old read-only **Recent adverts** home page is retired — its passively-heard nodes now simply show up in the Nodes list (All filter) — so there's one list instead of two. -- **On-device channel management.** Messages › Channels gets a **"+ Add channel"** row and per-channel **Edit**/**Delete** — create or join a channel by typing either a passphrase (hashed to the channel secret, same idea as a room password) or the exact 32-hex-character key from a channel QR code. No phone app required. +- **On-device channel management.** Messages › Channels gets a **"+ Add channel"** row and per-channel **Edit**/**Delete** — no phone app required. Adding a channel now starts with a **type picker**, matching the phone app: **Public** (re-adds the well-known default channel, no typing needed), **Hashtag** (type just a topic name, e.g. `test` — the channel name and secret are both derived from it, same convention as the app's `#topic` channels), or **Private** (manual Name + Secret, typing either a passphrase hashed to the channel secret, or the exact 32-hex-character key from a channel QR code). - **Tools › Admin — administer a remote repeater/room from the device.** Log into a node you have admin rights on (same self-healing saved-password handshake as room logins) and browse its settings in category tabs — **System** (name, owner info), **Radio** (Frequency/Bandwidth/Spreading factor/Coding rate/TX power), **Routing** (Repeat/Advert interval/Flood advert interval/Max hops), **Actions** (reboot, send advert, sync clock, …) — plus a free-text **Custom command…** escape hatch (with **{}**-key command-name completion) for anything not covered. Radio and Routing fields use a type-appropriate editor instead of raw text — a digit-cursor for Frequency (the same widget Settings' own Radio screen uses locally), discrete-set stepping for Bandwidth/Spreading factor/Coding rate, plain number steppers for the rest, and an ON/OFF toggle for Repeat — adjusting a value costs no mesh traffic until you actually confirm it with **Enter** (**Cancel** sends nothing). Reachable either from **Tools › Admin** (opens the same Nodes list to pick a target) or directly via a repeater/room's own **Hold Enter › Admin** action. - **Auto-Reply Bot gains a third target: room servers**, alongside DM and Channel, with its own trigger/reply/commands — the screen is redesigned as a circular tab carousel (**Direct/Channel/Room/Other**, **LEFT/RIGHT** to switch, **UP/DOWN** within a tab) instead of one long list. Each target's **Enable** and **Commands** toggle is now fully independent (they used to secretly share the DM tab's setting for Channel/Room too). New `{name}`/`{hops}` reply placeholders, a DM allow-list (all chat contacts or favourites only), and a dedicated stepper for Quiet Hours. - **Keyboard: accent popup, selectable main script, better editing.** Holding **Enter** on a Latin letter with accented variants (`a c d e i l n o r s t u y z`) now opens a one-row popup of that letter's accents — e.g. holding `a` offers `á à â ã ä å ą` — covering every European Latin-diacritic letter (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic, …) from one popup instead of a separate page per language. **Settings › Keyboard** splits **Alphabet** into **Main** and **Additional**: Main picks which script (Latin/Cyrillic/Greek) the keyboard opens on by default, Additional picks the second one reached via the **#@/abc** cycle key — so a Cyrillic or Greek typist can make their own script the default instead of always landing on Latin first. Shift is now **one-shot** by default (capitalises just the next letter; **Hold Enter** on Shift toggles the old sticky caps-lock back on), **Hold Enter** on Backspace clears the whole field, and **UP** from the top letter row enters a cursor-positioning mode (**LEFT/RIGHT** move; **UP/DOWN** jump to start/end, then continue on to the special row / letter grid if pressed again once already there) so you can edit or insert anywhere in what you've typed, not just at the end.