From 4d941c5ca62dd777796f52739f7d472f2bc29d1e Mon Sep 17 00:00:00 2001 From: vicliu Date: Mon, 2 Feb 2026 03:46:17 +0800 Subject: [PATCH] fix: ui (#4) * feat: join team * fix: ui --- docs/team/IMPLEMENTATION_CHECKLIST.md | 85 - docs/team/persist.md | 601 ++++ docs/team/team_chat_protocol.md | 167 + docs/team/uiux.md | 488 ++- src/app/app_context.cpp | 98 + src/app/app_context.h | 5 + src/board/LilyGoKeyboard.cpp | 3 +- src/board/TLoRaPagerBoard.cpp | 156 +- src/board/TLoRaPagerBoard.h | 34 +- src/board/nfc_include.h | 10 +- src/chat/infra/meshcore/meshcore_adapter.h | 1 + src/chat/infra/meshtastic/mt_adapter.cpp | 24 +- src/chat/infra/meshtastic/mt_adapter.h | 24 +- src/chat/ports/i_mesh_adapter.h | 64 + src/chat/usecase/chat_service.cpp | 3 +- src/gps/domain/gps_state.h | 6 + src/gps/infra/hal_gps_adapter.cpp | 30 + src/gps/infra/hal_gps_adapter.h | 6 + src/gps/ports/i_gps_hw.h | 6 + src/gps/usecase/gps_service.cpp | 23 +- src/hal/hal_gps.cpp | 30 + src/hal/hal_gps.h | 6 + src/main.cpp | 175 +- src/sys/event_bus.h | 60 + src/team/domain/team_events.h | 31 + src/team/domain/team_types.h | 1 + src/team/infra/event/team_event_bus_sink.cpp | 25 + src/team/infra/event/team_event_bus_sink.h | 5 + src/team/infra/nfc/team_nfc.cpp | 1078 ++++++ src/team/infra/nfc/team_nfc.h | 59 + src/team/ports/i_team_event_sink.h | 5 + src/team/protocol/team_chat.cpp | 250 ++ src/team/protocol/team_chat.h | 77 + src/team/protocol/team_mgmt.cpp | 104 + src/team/protocol/team_mgmt.h | 45 +- src/team/protocol/team_portnum.h | 1 + src/team/usecase/team_controller.cpp | 82 + src/team/usecase/team_controller.h | 26 + src/team/usecase/team_service.cpp | 767 ++++- src/team/usecase/team_service.h | 30 + src/ui/LV_Helper_v9.cpp | 62 + src/ui/app_screen.h | 12 + .../screens/chat/chat_compose_components.cpp | 81 + src/ui/screens/chat/chat_compose_components.h | 3 + src/ui/screens/chat/chat_compose_input.cpp | 3 + src/ui/screens/chat/chat_compose_layout.cpp | 4 +- src/ui/screens/chat/chat_compose_layout.h | 2 + src/ui/screens/chat/chat_compose_styles.cpp | 6 + .../chat/chat_conversation_components.cpp | 277 +- .../chat/chat_conversation_components.h | 59 +- .../screens/chat/chat_conversation_input.cpp | 70 +- src/ui/screens/chat/chat_conversation_input.h | 14 +- .../screens/chat/chat_conversation_layout.cpp | 18 +- .../screens/chat/chat_conversation_layout.h | 6 +- .../chat/chat_message_list_components.cpp | 307 +- .../chat/chat_message_list_components.h | 70 +- .../screens/chat/chat_message_list_input.cpp | 241 +- src/ui/screens/chat/chat_message_list_input.h | 33 +- .../screens/chat/chat_message_list_layout.cpp | 16 +- .../screens/chat/chat_message_list_layout.h | 1 + .../contacts/contacts_page_components.cpp | 680 +++- .../screens/contacts/contacts_page_input.cpp | 48 +- .../screens/contacts/contacts_page_layout.cpp | 9 + src/ui/screens/contacts/contacts_state.h | 9 +- src/ui/screens/gps/gps_page_components.cpp | 5 + src/ui/screens/gps/gps_page_input.cpp | 7 +- src/ui/screens/gps/gps_page_layout.cpp | 22 +- src/ui/screens/gps/gps_page_layout.h | 6 + src/ui/screens/gps/gps_page_lifetime.cpp | 7 + src/ui/screens/gps/gps_page_map.cpp | 596 +++- src/ui/screens/gps/gps_page_map.h | 6 + src/ui/screens/gps/gps_page_styles.cpp | 1 + src/ui/screens/gps/gps_state.h | 20 + .../settings/settings_page_components.cpp | 3 +- src/ui/screens/team/team_page_components.cpp | 2996 ++++++++++++++++- src/ui/screens/team/team_page_components.h | 7 + src/ui/screens/team/team_page_input.cpp | 24 +- src/ui/screens/team/team_page_layout.cpp | 112 +- src/ui/screens/team/team_page_layout.h | 5 +- src/ui/screens/team/team_page_styles.cpp | 83 +- src/ui/screens/team/team_page_styles.h | 9 +- src/ui/screens/team/team_state.h | 144 +- src/ui/screens/team/team_ui_store.cpp | 1489 ++++++++ src/ui/screens/team/team_ui_store.h | 129 + .../tracker/tracker_page_components.cpp | 4 +- .../screens/tracker/tracker_page_layout.cpp | 19 +- src/ui/ui_chat.cpp | 16 +- src/ui/ui_common.cpp | 82 + src/ui/ui_common.h | 5 + src/ui/ui_contacts.cpp | 46 +- src/ui/ui_controller.cpp | 480 ++- src/ui/ui_controller.h | 17 +- src/ui/ui_gps.cpp | 18 +- src/ui/ui_team.cpp | 6 + src/ui/ui_team.h | 12 + src/ui/ui_usb.cpp | 224 +- src/ui/widgets/map/map_tiles.cpp | 17 +- .../lilygo_tlora_pager/envs/tlora_pager.ini | 8 +- .../lilygo_tlora_pager/rfal_custom_config.h | 7 + .../st25r3916_custom_config.h | 4 + 100 files changed, 12276 insertions(+), 1082 deletions(-) delete mode 100644 docs/team/IMPLEMENTATION_CHECKLIST.md create mode 100644 docs/team/persist.md create mode 100644 docs/team/team_chat_protocol.md create mode 100644 src/team/infra/nfc/team_nfc.cpp create mode 100644 src/team/infra/nfc/team_nfc.h create mode 100644 src/team/protocol/team_chat.cpp create mode 100644 src/team/protocol/team_chat.h create mode 100644 src/ui/app_screen.h create mode 100644 src/ui/screens/team/team_ui_store.cpp create mode 100644 src/ui/screens/team/team_ui_store.h create mode 100644 variants/lilygo_tlora_pager/rfal_custom_config.h create mode 100644 variants/lilygo_tlora_pager/st25r3916_custom_config.h diff --git a/docs/team/IMPLEMENTATION_CHECKLIST.md b/docs/team/IMPLEMENTATION_CHECKLIST.md deleted file mode 100644 index d6ff051e..00000000 --- a/docs/team/IMPLEMENTATION_CHECKLIST.md +++ /dev/null @@ -1,85 +0,0 @@ -# Team 实现清单(按你的架构分层) - -目标:参考现有 chat 的分层方式,在 Team 功能上采用同样的分层结构,并显式对齐「解释即架构」的开发思想。以下清单按层拆解,便于逐项落地与验收。 - -## 1) Domain(纯模型层) - -- team/domain/team_types.h - - 基础类型:TeamId, TeamKey, TeamRole, TeamState, TeamMember, TeamParams - - 事件类型枚举:TeamEventType - - 事件载体:TeamEvent(包含 event_id/team_id/ts/sender_id/key_id/payload) -- team/domain/team_model.h/.cpp - - 状态机:Idle/Create/Active/Rotate/Disband - - 成员可见性/last_seen/health - - 位置降采样策略 - - 复盘事件生成(纯内存结构,不涉及存储) - -## 2) Ports(接口层) - -- team/ports/i_team_store.h - - 读写 PersistentTeamState(单一对象) - - load/save/erase -- team/ports/i_team_log_store.h - - 追加写 TeamEvent(LogRecord) - - appendEvent/appendSnapshot(snapshot 可选) -- team/ports/i_team_mesh_adapter.h - - sendAppData(portnum, payload, len, dest, want_ack) - - pollIncomingData 返回 MeshIncomingData -- team/ports/i_team_crypto.h - - kdf, aeadEncrypt, aeadDecrypt - -## 3) Usecase(用例层) - -- team/usecase/team_service.h/.cpp - - createTeam/joinTeam/acceptJoin/confirmJoin - - rotateKey/disband/leave - - processIncoming:消费 TEAM_*_APP 数据包 - - 严格遵守持久化时机(成功跃迁后写入,解散开始即删除) - - 事件落盘(LogStore)与 EventBus 通知 - -## 4) Infra(基础设施层) - -- 存储 - - team/infra/store/team_flash_store.*:NVS/Preferences 单 blob(含 magic/version/length/crc) - - team/infra/store/team_log_store.*:SD append-only LogRecord(AEAD + CRC) -- Meshtastic 适配 - - team/infra/meshtastic/team_mt_adapter.* 或复用 MtAdapter 新增 pollIncomingData/sendAppData - - 解析 meshtastic_Data 的 portnum,将非 TEXT 的 payload 入队 - - 定义 TEAM_*_APP 自定义 portnum(基于 PortNum_PRIVATE_APP + n) -- 密码学 - - team/infra/crypto/team_crypto_*:KDF + AEAD(与 TeamEncrypted 结构匹配) - -## 5) AppContext 与任务流 - -- app/app_context.* - - 新增 TeamModel, TeamService, TeamStore, TeamLogStore - - AppContext::update 调用 team_service_->processIncoming -- sys/event_bus.h - - 新增 Team 相关事件类型(状态变化、成员变化、指令、诊断) - -## 6) 协议与编码 - -- TeamEncrypted 统一 Envelope(version/team_id/key_id/nonce/ciphertext/aad_flags) -- TEAM_MGMT_APP - - TEAM_ADVERTISE, JOIN_REQUEST, JOIN_ACCEPT, JOIN_CONFIRM, TEAM_STATUS -- TEAM_POSITION_APP / TEAM_WAYPOINT_APP - - 明文 payload 为 meshtastic Position/Waypoint(再用 TeamEncrypted AEAD) - -## 7) 持久化与日志 - -- PersistentTeamState 单对象 - - identity + secrets + channel + params + log_* -- 启动恢复 - - magic/crc 失败 -> erase -> Idle -- LogRecord - - AEAD(LogKey) + trailer_crc32 - - 关键事件即时 flush - -## 8) MVP 优先级(建议) - -1. TeamService + TeamModel(状态机) -2. Meshtastic adapter 支持 pollIncomingData/sendAppData -3. TEAM_ADVERTISE / JOIN_REQUEST / JOIN_ACCEPT / TEAM_POSITION_APP -4. NVS 持久化单对象 -5. SD LogRecord 追加写(无 snapshot) -6. JOIN_CONFIRM / TEAM_STATUS / ROTATE diff --git a/docs/team/persist.md b/docs/team/persist.md new file mode 100644 index 00000000..7aab95ad --- /dev/null +++ b/docs/team/persist.md @@ -0,0 +1,601 @@ +# Pager Team Core|SD-only 持久化方案(完整版) + +## 0. 目标与三条原则 + +### 目标 + +* **简单**:文件少、格式少、逻辑少 +* **可信**:断电可恢复、日志不会写坏全局 +* **资源消耗低**:避免高频随机写;控制日志增长 + +### 核心原则 + +* **SD-only**(不磨损 flash) +* **append-only**(日志只追加,不回写不 seek) +* **无每条 CRC**:靠 `magic + len` 判定完整记录;**尾巴不完整直接丢弃** +* **“意图 vs 事实”分离**:UI 触发 **Intent**;落盘写入 **Committed Fact** + +--- + +## 1. 目录结构(定稿) + +```text +/team/ + current.txt # 当前队伍目录名,一行文本 + current.tmp # 原子写临时文件 + T_A7K3/ # team_id 的短码/hash(稳定目录名) + keys.bin # 当前 team_psk(可选,单独持久化) + snapshot.bin # 当前世界快照(低频原子写) + snapshot.tmp # 原子写临时文件 + events.log # 关键事件日志(追加写,sync 依据) + posring.log # 位置 ring(固定大小,覆盖最旧) + chatlog.log # 聊天日志(滚动上限) +``` + +### current.txt 格式 + +* 内容示例:`T_A7K3\n` +* 原子写:写 `current.tmp` → flush → rename 覆盖 `current.txt` + +--- + +## 2. 数据模型分层(必须坚持的边界) + +### 2.1 关键事件(Key Events) + +**改变团队结构 / epoch / waypoint 的事实**,必须: + +* 写入 `events.log` +* 带 `event_seq`(单调递增) +* 作为 SYNC 的依据 + +**必须写入**的关键事件类型: + +* `TeamCreated` +* `MemberAccepted` +* `MemberKicked` +* `LeaderTransferred` +* `EpochRotated` + (Waypoint 可选,后续加同样走 key event) + +### 2.2 高频态势(Non-key) + +* Presence:心跳/在线状态(不落盘) +* Position:落 `posring.log`(ring + 节流) +* Chat:落 `chatlog.log`(滚动上限) + +> 关键事件 = “世界规则变化”;位置/聊天 = “现场噪声与记录”。 + +--- + +## 3. event_seq:权威来源与落盘规则(答疑合并) + +### 3.1 event_seq 谁维护? + +✅ **Leader 维护并分配 event_seq**(推荐在 TeamCore/Usecase 层维护;TeamService 保持 IO 职责) + +* Leader 产生 key event 时:`event_seq = last_event_seq + 1` +* Member 只接受带 seq 的 key event,并校验连续性 + +### 3.2 UI 触发还是接收后落盘? + +✅ **两者都有,但落盘只能写“已提交事实(Committed Fact)”** + +* UI 触发的是 **Intent**(踢人/转让/轮换等请求) +* 只有当 Leader / Member **确认提交**后,才写入 `events.log` + +具体: + +* **Leader**:UI Intent → 校验权限/状态 → Commit(分配 seq)→ append events.log → apply 内存状态 → 发送消息(失败靠后续 SYNC 修复) +* **Member**:收到 leader 的 key event → 校验 team_id/epoch/权限/seq 连续性 → append events.log → apply + +> 避免出现“UI 点了但网络没发出去/没人承认,日志却写死了”的分裂。 + +--- + +## 4. 文件格式(team_id 统一 8 字节,无 CRC) + +通用约定:Little-endian、紧凑写、不依赖 struct padding。 + +--- + +### 4.1 TeamId + +* `team_id`:`uint64_t`(8 bytes) +* 目录名建议:`T_` + base32(team_id)(或短 hash),保证 FAT 兼容 + +--- + +### 4.2 snapshot.bin(当前世界快照) + +#### Header(固定) + +```c +SnapshotHeaderV1 { + char magic[4] = "TMS1"; + uint8 version = 1; + uint8 flags; // bit0: in_team + uint16 reserved; + + uint32 updated_ts; // 写快照时间(秒) + uint64 team_id; // 8B + uint32 epoch; // 当前 epoch + uint32 last_event_seq; // 已应用到的最后 seq + + uint32 self_node_id; + uint32 leader_node_id; + + uint8 self_role; // 0 None, 1 Member, 2 Leader + uint8 reserved2[3]; + + uint16 member_count; + uint16 reserved3; +} +``` + +#### 成员表(重复 member_count 次,变长) + +```c +MemberRecV1 { + uint32 node_id; + uint8 role; // 1 Member, 2 Leader + uint8 flags; // bit0: has_name + uint16 name_len; // 建议 <= 24 + char name[name_len]; // UTF-8 +} +``` + +#### 原子写 + +写 `snapshot.tmp` → flush → rename 覆盖 `snapshot.bin` + +#### 写入节流(低消耗) + +* 每 **10 条** key event 或 **60 秒**写一次(取先到) +* epoch rotate / kick / transfer / join 成功 / 离队:**强制写一次** + +--- + +### 4.3 events.log(关键事件日志:真相源 + SYNC 依据) + +#### Record header(固定) + +```c +EventRecHeaderV1 { + char magic[2] = "EV"; + uint8 version = 1; + uint8 type; // KeyEventType + + uint32 event_seq; // 单调递增 + uint32 ts; // 秒 + + uint16 payload_len; + uint16 reserved; +} +payload[payload_len] +``` + +#### 无 CRC 的可信规则 + +扫描时若: + +* magic/version 不匹配 → 停止(最简单策略) +* 文件剩余长度 < header + payload_len → 停止(尾部半条丢弃) + +因为只 append,停止点之前的数据可信。 + +#### KeyEventType + +* `1 TeamCreated` +* `2 MemberAccepted` +* `3 MemberKicked` +* `4 LeaderTransferred` +* `5 EpochRotated` + +#### Payload 定义 + +**TeamCreated** + +```c +uint64 team_id +uint32 leader_node_id +uint32 epoch // 初始 1 +``` + +**MemberAccepted** + +```c +uint32 member_node_id +uint8 role // 默认 1 Member +uint8 reserved[3] +``` + +**MemberKicked** + +```c +uint32 member_node_id +``` + +**LeaderTransferred** + +```c +uint32 new_leader_node_id +``` + +**EpochRotated** + +```c +uint32 new_epoch +``` + +> payload 尽量固定字段、少字符串:更省空间,更低 IO。 + +--- + +### 4.3.1 keys.bin(team_psk 独立持久化,避免重启后 keys 未就绪) + +> snapshot 不保存密钥,密钥单独落盘。 +> 仅保存当前生效的 team_psk + key_id(epoch),不做历史。 + +#### 格式(固定) + +```c +TeamKeysFileV1 { + char magic[4] = "TMK1"; + uint8 version = 1; + uint8 psk_len; // 16 + uint16 reserved; + + uint64 team_id; + uint32 key_id; // epoch/key_id + uint8 psk[16]; // team_psk +} +``` + +#### 原子写 + +写 `keys.tmp` → flush → rename 覆盖 `keys.bin` + +--- + +### 4.4 posring.log(位置 ring,高频但节流) + +#### Header(固定) + +```c +PosRingHeaderV1 { + char magic[4] = "PSR1"; + uint8 version = 1; + uint8 reserved1[3]; + + uint32 data_capacity; // data 区大小(固定) + uint32 write_offset; // 下一条写入位置 + uint32 rec_size; // 固定 = sizeof(PosRecV1) + uint32 reserved2; +} +data[data_capacity] +``` + +#### Record(固定) + +```c +PosRecV1 { + uint16 magic = 0x5053; // 'PS' + uint8 ver = 1; + uint8 flags; + + uint32 ts; + uint32 member_id; + int32 lat_e7; + int32 lon_e7; + + int16 alt_m; + uint16 speed_dmps; +} +``` + +#### 写入节流(必须) + +* 每成员 15–30 秒最多写一条,或位移 > 20m 才写 +* ring 满了覆盖最旧,文件大小恒定 + +--- + +### 4.5 chatlog.log(聊天追加写 + 上限滚动) + +#### Record(变长) + +```c +ChatRecHeaderV1 { + char magic[2] = "CH"; + uint8 version = 1; + uint8 flags; // bit0 incoming/outgoing + + uint32 ts; + uint32 peer_id; + + uint16 text_len; + uint16 reserved; +} +text[text_len] // UTF-8 +``` + +#### Team chat????????V2, TEAM_CHAT_APP? +???? TeamChat ?????????? V2 ?? chatlog.log? + +```c +ChatRecHeaderV2 { + char magic[2] = "CH"; + uint8 version = 2; + uint8 flags; // bit0 incoming/outgoing + + uint32 ts; + uint32 peer_id; // sender node_id + + uint8 msg_type; // 1=Text 2=Location 3=Command + uint8 reserved1[3]; + + uint16 payload_len; + uint16 reserved2; +} +payload[payload_len] // decoded TeamChat payload +``` +``` + +#### 上限策略(简单) + +* 上限:**256KB** 或 **1000 条**(二选一) +* 超限: + + * rename `chatlog.log` → `chatlog.old`(可选) + * 新建空 `chatlog.log` + +--- + +## 5. 启动恢复流程(有 current.txt,最快) + +1. 读 `/team/current.txt` + +* 不存在/空 → 当前无队伍 + +2. 打开 `/team//snapshot.bin` + +* 若不存在 → 从空状态开始(但通常 join 后会写一次) + +3. 增量回放 `events.log` + +* 从 `snapshot.last_event_seq + 1` 开始顺序扫描应用 +* 遇到尾部不完整 → 停止 + +4. UI Ready + +--- + +## 6. SYNC 流程(依赖 event_seq) + +* Presence(不落盘)携带:`team_id, epoch, last_event_seq` +* 发现对方 `last_event_seq > my_last_event_seq`: + + * 发送 `SYNC_REQ(from_seq = my_last_event_seq + 1)` +* 对方从 `events.log` 读 seq 范围(最多 N 条)回 `SYNC_RSP` +* 本地 apply + append(对已存在 seq 可跳过),更新 snapshot + +--- + +## 7. event_seq + commit 流程(ASCII 状态/时序图) + +### 7.1 Leader:UI Intent → Commit → Log → Broadcast + +```text +UI TeamCore/Usecase TeamStore(SD) TeamService(IO) Mesh + | intentKick(target) | | | | + |------------------------------>| check(role==Leader) | | | + | | build KeyEvent(Kicked) | | | + | | seq = last_seq + 1 | | | + | |----------------------------->| append events.log(EV,seq)| | + | |<-----------------------------| ok | | + | | apply to in-mem state | | | + | | maybe snapshot throttle | | | + | |------------------------------>| save snapshot.tmp->bin | | + | |<-----------------------------| ok | | + | |----------------------------------------------------------->| sendKick(seq,...) | + | | |------------------->| + | | | (may fail) | + | | NOTE: even if send fails, state is committed; | | + | | later Status/SYNC will repair delivery | | +``` + +### 7.2 Member:RX KeyEvent → Verify seq → Log → Apply(缺 seq 触发 SYNC) + +```text +Mesh TeamService(IO) TeamCore/Usecase TeamStore(SD) + | RX Kick(seq,...) | | | + |------------------------>| decode/decrypt | | + | | sink_.onTeamKick(...) ---->| verify team_id/epoch | + | | | verify seq == last+1 ? | + | | | NO -> request SYNC | + | | | YES -> commit apply | + | | |------------------------> append events.log + | | |<------------------------ ok + | | | apply in-mem state + | | | maybe snapshot throttle + | | |------------------------> save snapshot atomic + | | |<------------------------ ok +``` + +### 7.3 seq 不连续时(Member 触发 SYNC) + +```text +Member Core Mesh Leader Core + | see seq gap (expected=41 got=45) | + |---------------- SYNC_REQ(from=41) --------------------------->| + | | read events.log seq>=41 + |<---------------- SYNC_RSP(events 41..45) ---------------------| + | apply + append + snapshot | +``` + +--- + +## 8. 工程接入点(与 TeamService / ITeamEventSink 对齐) + +你现在 `TeamService::processIncoming()` 会把消息解包后丢给 `sink_.onTeamXxx(event)`。 + +推荐最小改动: + +* `sink_` 的实现(TeamCore/Usecase)负责: + + * 权限判断 + * seq 分配(leader) + * seq 连续性检查(member) + * 调用 `TeamStore.append_key_event(...)` 写 `events.log` + * 更新内存状态 + * 触发 snapshot(节流) +* `TeamService` 仅做 IO:decode/encode/send,不直接写 SD + +--- + +## 9. PosRing / ChatLog 接入点(答疑合并入规范) + +### 9.1 posring.log:从接收写还是从发送写? +组队模式下 GPS 从 posring.log 渲染队员位置(包含自己)。 + +✅ 定稿:**两边都写,并统一走同一个节流器** + +* **收到 TeamPosition(队友)**:写入 `posring.log`(队友态势来源) +* **本机 GPS fix(自己)**:也写入 `posring.log`(重启后仍能立即看到自己最后位置/轨迹片段) + +原因: + +* 只写接收 → 自己历史为空 +* 只写发送 → 队友态势重启后为空 +* posring 是“事实流缓存”,不参与一致性:不用 event_seq,不用严格去重 + 只需做到:每成员节流 + ts 最新覆盖 UI + +**推荐接入(最干净)**:只在 **TeamCore(sink 实现)**里写 posring + +* `sink_.onTeamPosition(event)`:decode → `posring_append_throttled(from_id, pos, ts)` +* 本机 GPS 更新处:`TeamCore::onLocalPositionFix(fix)` → `posring_append_throttled(self_id, pos, ts)` → 再决定是否 `TeamService.sendPosition(...)` + +### 9.2 chatlog.log:记录 Mesh chat(Team channel)还是仅 Team 协议? +决策(v0.1):队伍聊天使用 TEAM_CHAT_APP;TeamChat 消息按 V2 写入 chatlog.log。 +地图交互:收到消息只弹系统通知;弹窗由 Chat 会话中选中地图标注后触发,显示该位置瓦片地图的裁剪图。 +不要把 TEAM_MGMT_APP 记录到 chatlog.log。 + +先分清三种消息域: + +1. **Meshtastic 普通聊天**(你现有 chat 模块) +2. **Team 管理消息(TEAM_MGMT_APP)**:Join/Kick/Rotate/Status…(不是聊天) +3. **队内聊天**(可复用 Meshtastic text,也可未来自定义 Team chat port) + +✅ 定稿:**chatlog.log 只记录用户可见的“聊天文本”,不记录管理消息** + +* ❌ 不记录 `TEAM_MGMT_APP` 到 chatlog(它们属于 `events.log` / diagnostics) +* ✅ 记录“队内聊天文本” + + * 推荐走 **路线 A**:记录 **Meshtastic text 中属于当前 Team channel 的消息** + +路线 A(推荐):记录 Meshtastic 文本聊天(Team channel) + +* 优点:立刻兼容现有生态(安卓/ATAK/其他节点) +* 缺点:聊天与 Team 语义解耦(可接受) + +路线 B(后续版本):Team 专用 chat 协议(TEAM_CHAT_APP / TEAM_MGMT.Chat) + +* 优点:聊天与 team_id/epoch 绑定、成员可控 +* 缺点:需要新增协议与兼容处理 + +**chatlog 的接入点(工程)** + +* 因为 TeamService 不处理 text chat,所以 chatlog 应在 **chat 模块**接入: + + * ChatService/ChatUsecase 收到/发送文本时: + + * 若 `channel_id == current_team_channel` → `chatlog_append(...)` +* TeamCore 只需要提供“当前 team channel_id” + +--- + +## 10. 缺口补齐总结(当前约束) + +* team_id:✅ 8 字节(uint64) +* 关键事件必须落盘:✅ events.log + type + payload 完整定义 +* event_seq:✅ leader 权威维护;snapshot 记录 last_seq;member 检查连续性;缺口走 SYNC +* Intent vs Fact:✅ UI 触发 Intent;落盘只写 Committed Fact +* posring/chatlog:✅ 接入点与记录范围明确(pos 双写、chat 记 team channel 文本) + +--- + +# Team Position(对齐 Meshtastic POSITION_APP) + +## 1) 设计目标 + +* **对齐 Meshtastic**:payload 直接使用 `meshtastic_Position` protobuf +* **生态兼容**:与 Meshtastic POSITION_APP 解码一致 +* **扩展性**:保留 Meshtastic 字段的自然扩展空间 + +--- + +## 2) Wire Payload + +* **类型**:`meshtastic_Position`(protobuf) +* **端口**:`meshtastic_PortNum_POSITION_APP` +* **编码**:nanopb / pb_encode + +> 说明:不再使用自定义布局。Position payload 与 Meshtastic 完全一致。 + +--- + +## 3) 取值规则(最小集) + +* `latitude_i` / `longitude_i`:E7(int32) +* `location_source`:默认 `LOC_INTERNAL` +* `sats_in_view`:有则填 +* `timestamp`:有有效 epoch 秒则填 + +其余字段按需填充,保持与 Meshtastic 语义一致。 + +--- + +# 5) 编码/解码接入点(对齐你现有 TeamService + sink) + +## 5.1 发送端(本机 GPS 更新) + +**推荐路径**(TeamCore 层做事): + +1. `TeamCore::onLocalPositionFix(fix)` +2. 生成 `meshtastic_Position` payload(protobuf) +3. **先** `posring_append_throttled(self_id, decoded_pos)`(同一个点落盘) +4. 再调用 `TeamService.sendPosition(payload, team_channel)` + +> 你问过“posring 从发送写还是接收写”:这里就是“发送也写”。 + +## 5.2 接收端(TEAM_POSITION_APP) + +你现在流程已经有: + +* `TeamService::processIncoming()` 解密 → `sink_.onTeamPosition(event{ctx, plain})` + +在 `sink` 的实现(TeamCore)里: + +1. decode `meshtastic_Position` → 得到标准化字段 +2. `posring_append_throttled(from_id, pos)`(队友点落盘) +3. 更新内存 `last_seen`(presence/online 状态) + +--- + +# 6) posring.log 落盘字段对齐建议 + +你现有 `PosRecV1` 足够用(ts、member_id、lat/lon、alt、speed)。 +`course` 和 `vbat`: + +* **可以不落盘**(UI 大多用轨迹方向估计就够了;vbat 在 topbar 也不一定要显示队友) +* 需要时再出 `PosRecV2`(加 2 字节 course、2 字节 vbat,不难) + +--- + +# 7) 版本与兼容规则 + +* payload 必须能解码为 `meshtastic_Position`,否则丢弃(或统计 error) +* 不做自定义版本字段检查(以 Meshtastic 协议为准) + + diff --git a/docs/team/team_chat_protocol.md b/docs/team/team_chat_protocol.md new file mode 100644 index 00000000..0f7a95a9 --- /dev/null +++ b/docs/team/team_chat_protocol.md @@ -0,0 +1,167 @@ +# Team Chat 协议方案(方案 C,v0.1) + +本文件描述“队伍聊天”协议的最小可实施方案,用于承载结构化消息: +Text / Location / Command,并与 Team 安全域绑定,支持地图与态势联动。 + +--- + +## 1. 目标与范围 + +- 目标:让队伍聊天成为“可解析、可执行”的结构化消息流。 +- 覆盖媒体类型: + - Text:普通文本。 + - Location:位置分享,可在 Chat/GPS 地图渲染。 + - Command:指令类消息,可触发队伍行动提示与地图标注。 +- 安全:仅队伍成员可解密,不依赖 Meshtastic 普通聊天频道。 + +非目标: +- 不替换现有的普通聊天/广播消息。 +- 不实现复杂可靠传输(v0.1 先做 best-effort)。 + +--- + +## 2. 端口与加密 + +- 新增端口: + - `TEAM_CHAT_APP = 303`(在 `src/team/protocol/team_portnum.h` 中定义) +- 加密方式: + - 复用 `TeamEncrypted` envelope(`team_wire.h`)。 + - 从 `team_psk` 派生 `team_chat` key。 + - 建议在 `TeamService::setKeysFromPsk()` 中新增: + - `deriveKey(psk, "team_chat", keys.chat_key)` + +这样可实现“队伍聊天协议层隔离”,非队伍成员无法解密。 + +--- + +## 3. Payload 结构(v0.1) + +### 3.1 通用头部 + +使用简洁 TLV 或固定头 + 变长 payload。建议固定头: + +``` +struct TeamChatHeader { + uint8_t version; // =1 + uint8_t type; // 1=Text 2=Location 3=Command + uint16_t flags; // 预留 + uint32_t msg_id; // 本地生成,防重放/去重 + uint32_t ts; // 发送时间(unix) + uint32_t from; // 发送者 node_id +}; +``` + +### 3.2 Text + +``` +struct TeamChatText { + // UTF-8 text bytes + bytes text; +}; +``` + +### 3.3 Location + +``` +struct TeamChatLocation { + int32_t lat_e7; + int32_t lon_e7; + int16_t alt_m; // optional, 0=unknown + uint16_t acc_m; // optional, 0=unknown + uint32_t ts; // optional, 0=use header.ts + uint8_t source; // 0=GPS 1=Manual 2=CommandTarget + bytes label; // optional short label +}; +``` + +### 3.4 Command + +v0.1 仅做最小指令集,不做撤销/过期语义。 + +``` +enum TeamCommandType : uint8_t { + RallyTo = 1, // 集结到目标点 + MoveTo = 2, // 前往目标点 + Hold = 3 // 原地待命 +}; + +struct TeamChatCommand { + uint8_t cmd_type; + int32_t lat_e7; // 可选:用于 Rally/Move + int32_t lon_e7; + uint16_t radius_m; // 可选:集合半径 + uint8_t priority; // 0=normal 1=high + bytes note; // 可选:简短备注 +}; +``` + +--- + +## 4. 发送与接收流程 + +### 4.1 发送 + +- UI 产生 `Text/Location/Command`。 +- 编码为 `TeamChatHeader + payload`。 +- 通过 `TeamService::sendTeamChat()`: + - 使用 `keys.chat_key` 加密封装为 `TeamEncrypted`。 + - 通过 `TEAM_CHAT_APP` 发送(`mesh_.sendAppData(...)`)。 + +### 4.2 接收 + +- `TeamService::processIncoming()` 新增 `TEAM_CHAT_APP` 分支: + - 解密 `TeamEncrypted`。 + - 解析 `TeamChatHeader`。 + - 触发 `TeamChatEvent`(新增 EventBus 类型)。 +- UI 接收到 `TeamChatEvent`: + - 追加到 `team_ui_chatlog`(新增类型字段)。 + - 若是 Location/Command,同步更新地图/GPS 页标注。 + +--- + +## 5. 与现有代码的改动点 + +最小改动清单(v0.1): + +1. **协议与端口** + - `src/team/protocol/team_portnum.h` 新增 `TEAM_CHAT_APP = 303` + - 新增 `team_chat.h/.cpp`(编码/解码) + +2. **密钥派生** + - `TeamKeys` 增加 `chat_key` + - `TeamService::setKeysFromPsk()` 中派生 `"team_chat"` + +3. **TeamService** + - 新增 `sendTeamChat(...)` + - `processIncoming()` 处理 `TEAM_CHAT_APP` + +4. **事件总线** + - `sys/event_bus.h` 新增 `TeamChatEvent` + +5. **UI / 存储** + - `team_ui_chatlog_append()` 扩展为结构化消息(type + payload) + - `Contacts/Team` 聊天页改用 TeamChat 数据源渲染卡片 + +--- + +## 6. 兼容性与过渡 + +- 保留现有普通聊天: + - Primary/Secondary 继续用 TEXT_MESSAGE_APP +- Team Chat 作为独立通道: + - 不影响普通聊天历史与通知 + - 可以逐步替换 Team 页的会话来源 + +--- + +## 7. v0.1 已确定事项 +- ACK/送达:不需要(尽力而为)。 +- Command 撤销/过期:不支持。 +- 地图交互:收到消息只弹系统通知;弹窗由 Chat 会话中选中地图标注后触发,显示该位置瓦片地图的裁剪图。 +- 组队模式 GPS:从 posring.log 渲染队员位置。 + +## 8. 版本策略 + +- `TeamChatHeader.version` = 1 +- 后续扩展通过 `flags` 或新 `type` 兼容 + diff --git a/docs/team/uiux.md b/docs/team/uiux.md index 87963fe1..95834713 100644 --- a/docs/team/uiux.md +++ b/docs/team/uiux.md @@ -2,6 +2,7 @@ > 设备:**2.33-inch 横屏 222×480** > 约束:**固定 TopBar(Back / Title / Battery)** +> 原则:**Radio + NFC 并行**;NFC 只在对应页面启用(省电/避免误触) --- @@ -33,9 +34,9 @@ │ You are not in a team │ │ │ │ • No shared map │ -│ • No team awareness │ +│ • No team awareness │ │ │ -│ Create or join a team │ +│ Create or join a team │ │ │ ├──────────────────────────────────────────────┤ │ [ Create Team ] [ Join Team ] │ @@ -52,11 +53,11 @@ ┌──────────────────────────────────────────────┐ │ < Back Team Status 🔋 │ ├──────────────────────────────────────────────┤ -│ Team: ALPHA-7 │ +│ Team: ALPHA-7 (ID: A7K3) │ │ Role: Member │ -│ Members: 5 │ -│ Online: 3 │ -│ Security: OK (Round 5) │ +│ Members: 5 Online: 3 │ +│ Security: OK (Epoch 5) │ +│ Sync: OK (Last event 128) │ │ │ ├──────────────────────────────────────────────┤ │ Team Health │ @@ -70,7 +71,7 @@ └──────────────────────────────────────────────┘ ``` -> 这是 **判断“队伍是否还可信”** 的页面 +> 这是 **判断“队伍是否还可信 / 是否对齐”** 的页面 > 不是管理页 --- @@ -83,22 +84,47 @@ ┌──────────────────────────────────────────────┐ │ < Back Team · Leader 🔋 │ ├──────────────────────────────────────────────┤ -│ Team: ALPHA-7 │ -│ Members: 3 Online: 2 │ -│ Security Round: 5 │ +│ Team: ALPHA-7 (ID: A7K3) │ +│ Members: 3 Online: 2 │ +│ Epoch: 5 Sync: OK (128) │ │ │ ├──────────────────────────────────────────────┤ -│ Members │ +│ Requests: 2 pending > Open │ (仅 Leader 且有请求时显示) │ ────────────────────────────────────────── │ -│ ● You (Leader) Online │ -│ ● Tom Online │ -│ ○ Jerry Last seen 2m ago │ +│ Members │ +│ ● You (Leader) Online │ +│ ● Tom Online │ +│ ○ Jerry Last seen 2m ago │ │ │ ├──────────────────────────────────────────────┤ │ [ Invite ] [ Manage ] [ Leave ] │ └──────────────────────────────────────────────┘ ``` +> **Requests 收件箱** 用来替代 “弹窗可能错过”的问题 +> 重要:户外场景下用户经常在地图/聊天页,不一定看得到 popup + +--- + +## A3b. Join Request(Leader 弹窗) + +**Title:`Join request`** + +``` +┌──────────────────────────────────────────────┐ +│ < Back Join request 🔋 │ +├──────────────────────────────────────────────┤ +│ Tom wants to join │ +│ │ +├──────────────────────────────────────────────┤ +│ [ Accept ] [ Reject ] │ +└──────────────────────────────────────────────┘ +``` + +**规则(v0.1)** +* Leader 一次只处理 **1 个 pending join**,其余排队/稍后重试 + (避免同时 join 导致 rotate / key_dist 混乱) + --- ## A4. Invite 页面(Leader) @@ -109,22 +135,62 @@ ┌──────────────────────────────────────────────┐ │ < Back Invite 🔋 │ ├──────────────────────────────────────────────┤ -│ Invite Code │ +│ Mode: Radio │ +│ Team: ALPHA-7 (ID: A7K3) │ │ │ -│ J4K – 9Q2 │ +│ Invite Code │ +│ J4K9Q2 │ +│ Time left: 08:45 │ │ │ -│ Expires in: 08:45 │ -│ │ -│ Nearby devices can join │ +│ Nearby devices can request to join │ │ │ ├──────────────────────────────────────────────┤ -│ [ Refresh Code ] [ Stop Invite ] │ +│ [ Stop Invite ] [ Switch Mode ] │ └──────────────────────────────────────────────┘ ``` +> `Refresh Code` 属于异常处理路径,v0.1 可放在长按/菜单里,不占主按钮位 + --- -## A5. Join Team(扫描 / 输入邀请码) +### Invite Code 格式(v0.1) + +* 6 位连续字符(无分隔符) +* 字符集:`ABCDEFGHJKLMNPQRSTUVWXYZ23456789`(不含 I/O/1/0) +* 默认有效期:9 分钟 +* **绑定规则**:Invite Code 必须和 `team_id_short (A7K3)` 一起校验 + (Invite Code 不是全局唯一,只在 team 上下文里成立) + +--- + +## A4b. Invite via NFC(Leader) + +**Title:`Invite via NFC`** + +``` +┌──────────────────────────────────────────────┐ +│ < Back Invite via NFC 🔋 │ +├──────────────────────────────────────────────┤ +│ Mode: NFC │ +│ Team: ALPHA-7 (ID: A7K3) │ +│ │ +│ Invite Code │ +│ J4K9Q2 │ +│ Time left: 08:45 │ +│ │ +│ Tap another device to share key │ +│ Invite code protects the NFC key │ +│ │ +├──────────────────────────────────────────────┤ +│ [ Start NFC ] [ Stop Invite ] │ +└──────────────────────────────────────────────┘ +``` + +> 进入此页面才开启 NFC(轮询/卡模拟),退出即关闭以省电/避免误触。 + +--- + +## A5. Join Team(选择方式) **Title:`Join Team`** @@ -134,13 +200,64 @@ ├──────────────────────────────────────────────┤ │ Nearby Teams │ │ ────────────────────────────────────────── │ -│ ALPHA-7 Signal: ▮▮▮▯ │ -│ BETA-3 Signal: ▮▮▯▯ │ +│ ALPHA-7 (A7K3) Signal: ▮▮▮▯ [ Join ] │ +│ BETA-3 (B3Q1) Signal: ▮▮▯▯ [ Join ] │ │ │ -│ Or enter invite code │ +│ Other options │ +│ • Enter Invite Code (Radio) │ +│ • Tap to join (NFC, recommended) │ │ │ ├──────────────────────────────────────────────┤ -│ [ Enter Code ] [ Refresh ] │ +│ [ Enter Invite Code ] [ Join via NFC ] │ +│ [ Refresh ] │ +└──────────────────────────────────────────────┘ +``` + +> “Nearby Team + Join” 强调这是“直接申请加入该队” +> “Enter Invite Code” 明确它是 Radio 模式的邀请码匹配 + +--- + +## A5b. Join via NFC(Member) + +**Title:`Join via NFC`** + +``` +┌──────────────────────────────────────────────┐ +│ < Back Join via NFC 🔋 │ +├──────────────────────────────────────────────┤ +│ │ +│ Hold device near leader/device │ +│ Scanning... 08s │ +│ │ +│ NFC is on only during this screen │ +│ │ +├──────────────────────────────────────────────┤ +│ [ Cancel ] │ +└──────────────────────────────────────────────┘ +``` + +> NFC 读取成功后:自动跳转到 “Enter Code” 输入页(用于解密 NFC key,见协议 D2b) + +--- + +## A5c. Enter Invite Code(Radio/NFC 共用) + +**Title:`Enter Code`** + +``` +┌──────────────────────────────────────────────┐ +│ < Back Enter Code 🔋 │ +├──────────────────────────────────────────────┤ +│ Team ID (if known): A7K3 │ +│ │ +│ Code: _ _ _ _ _ _ │ +│ │ +│ • Radio: used to request join │ +│ • NFC: used to decrypt the shared key │ +│ │ +├──────────────────────────────────────────────┤ +│ [ Cancel ] [ Confirm ] │ └──────────────────────────────────────────────┘ ``` @@ -155,11 +272,11 @@ │ < Back Join Request 🔋 │ ├──────────────────────────────────────────────┤ │ │ -│ Request sent to ALPHA-7 │ +│ Request sent to ALPHA-7 (A7K3) │ │ │ -│ Waiting for approval... │ +│ Waiting for approval... │ │ │ -│ This may take a moment │ +│ This may take a moment │ │ │ ├──────────────────────────────────────────────┤ │ [ Cancel ] [ Retry ] │ @@ -176,13 +293,13 @@ ┌──────────────────────────────────────────────┐ │ < Back Members 🔋 │ ├──────────────────────────────────────────────┤ -│ ● You (Leader) │ +│ ● You (Leader) │ │ │ -│ ● Tom │ -│ > Select │ +│ ● Tom │ +│ > Select │ │ │ -│ ○ Jerry │ -│ > Select │ +│ ○ Jerry │ +│ > Select │ │ │ └──────────────────────────────────────────────┘ ``` @@ -197,17 +314,16 @@ ┌──────────────────────────────────────────────┐ │ < Back Member: Jerry 🔋 │ ├──────────────────────────────────────────────┤ -│ Status: Last seen 2m ago │ -│ Role: Member │ +│ Status: Last seen 2m ago │ +│ Role: Member │ │ │ -│ Device: Pager │ -│ │ -│ Capability: │ -│ • Position │ -│ • Waypoint │ +│ Device: Pager │ +│ Capability: │ +│ • Position │ +│ • Waypoint │ │ │ ├──────────────────────────────────────────────┤ -│ [ Kick ] [ Transfer Leader ] │ +│ [ Kick ] [ Transfer Leader ] │ └──────────────────────────────────────────────┘ ``` @@ -221,20 +337,36 @@ ┌──────────────────────────────────────────────┐ │ < Back Kick Member 🔋 │ ├──────────────────────────────────────────────┤ -│ Remove Jerry from team? │ +│ Remove Jerry from team? │ │ │ -│ This will update the security round. │ -│ Jerry will no longer receive │ -│ team messages or waypoints. │ +│ This will update the security round (epoch). │ +│ Jerry will no longer receive team updates. │ │ │ ├──────────────────────────────────────────────┤ -│ [ Cancel ] [ Confirm Kick ] │ +│ [ Cancel ] [ Confirm Kick ] │ └──────────────────────────────────────────────┘ ``` --- -## A10. 被踢 / 失效状态页(Member) +## A9b. Leave 确认弹窗 + +``` +┌──────────────────────────────────────────────┐ +│ < Back Leave team? 🔋 │ +├──────────────────────────────────────────────┤ +│ This clears local keys. │ +│ │ +├──────────────────────────────────────────────┤ +│ [ Cancel ] [ Leave ] │ +└──────────────────────────────────────────────┘ +``` + +> Leave 需要二次确认,避免误触导致本地密钥被清空 + +--- + +## A10. Access Lost(Member:被踢 / 失效 / 不一致) **Title:`Team`** @@ -242,15 +374,21 @@ ┌──────────────────────────────────────────────┐ │ < Back Team 🔋 │ ├──────────────────────────────────────────────┤ -│ You are no longer in this team │ +│ Access lost │ │ │ -│ Access to team data revoked │ +│ Reason: [ Revoked | Out-of-sync | Unknown ] │ +│ │ +│ • Revoked: removed by leader │ +│ • Out-of-sync: team updated, sync required │ │ │ ├──────────────────────────────────────────────┤ -│ [ Join Another Team ] [ OK ] │ +│ [ Try Sync ] [ Join Another Team ] [ OK ]│ └──────────────────────────────────────────────┘ ``` +> 关键:区分“被踢” vs “密钥/epoch 不一致”,减少误判 +> `Try Sync` 只在 Out-of-sync 时启用 + --- # B. 页面流转说明(UI 状态机) @@ -277,6 +415,12 @@ | v [ Join Team ] + | + +--> [ Join via NFC ] -> (read ok) -> [ Enter Code ] -> [ Join Pending ] + | + +--> [ Enter Invite Code ] ---------> [ Join Pending ] + | + +--> [ Nearby Teams -> Join ] ------> [ Join Pending ] | v [ Join Pending ] @@ -294,18 +438,17 @@ [ Team Home ] | v - [ Invite ] + [ Invite ] <--> [ Invite via NFC ] | - +-- member joins --> [ Join Request popup ] + +-- member joins --> [ Requests (Inbox) ] | - +-- accept --> epoch rotate - | | - | v - | [ Team Status ] + +-- accept --> epoch rotate --> [ Team Status ] | +-- reject ``` +> Join Request 不依赖 popup;popup 只是“提示”,Inbox 才是可靠入口。 + --- ## B4. 踢人流程(Leader) @@ -331,22 +474,38 @@ ## C1. 协议消息类型(最小集) -| 类型 | 用途 | -| -------------------- | ------- | -| `TEAM_INVITE` | 广播邀请码 | -| `TEAM_JOIN_REQ` | 申请加入 | -| `TEAM_JOIN_DECISION` | 同意 / 拒绝 | -| `TEAM_KICK` | 踢人事件 | -| `TEAM_EPOCH_ROTATE` | 宣告轮次更新 | -| `TEAM_KEY_DIST` | 新密钥分发 | -| `TEAM_PRESENCE` | 在线状态 | -| `TEAM_POS` | 位置 | -| `TEAM_SYNC_REQ` | 补齐请求 | -| `TEAM_SYNC_RSP` | 补齐响应 | +| 类型 | 用途 | +| -------------------- | --------------------------------- | +| `TEAM_INVITE` | 广播邀请码(Plain) | +| `TEAM_JOIN_REQ` | 申请加入(Plain) | +| `TEAM_JOIN_DECISION` | 同意/拒绝(Plain) | +| `TEAM_KEY_DIST` | 新密钥分发(对新成员 Plain 定向 / 对旧成员可加密或定向) | +| `TEAM_KICK` | 踢人事件(加密,已在 team 内的成员可读) | +| `TEAM_EPOCH_ROTATE` | 宣告轮次更新(通常作为 KeyEvent 记录并可广播提示) | +| `TEAM_PRESENCE` | 在线状态(加密) | +| `TEAM_POS` | 位置(加密) | +| `TEAM_SYNC_REQ` | 补齐请求(加密) | +| `TEAM_SYNC_RSP` | 补齐响应(加密) | + +> v0.1 约束:**新成员在拿到 key 前必须能完成 Join Handshake** +> 所以 `INVITE/JOIN_REQ/JOIN_DECISION/KEY_DIST(for newcomer)` 必须是 Plain 可解。 --- -## C2. TeamEnvelope(所有 Team 包统一外壳) +## C2. 字段命名定稿:epoch / event_seq / msg_id + +为了避免实现踩雷,明确: + +* `epoch`:**密钥轮次**(加解密 key 选择) +* `event_seq`:**关键事件序号**(仅 Key Events 与 Sync 使用,单调递增) +* `msg_id`:**普通包去重标识**(可选,v0.1 可不做) + +--- + +## C3. TeamEnvelope(所有 Team 包统一外壳,v0.1) + +> `event_seq` 只在关键事件或 sync 承载事件时出现; +> Presence/Pos 不要求连续 seq。 ``` TeamEnvelope { @@ -354,72 +513,146 @@ TeamEnvelope { epoch type sender_id - seq timestamp - auth + msg_id? // optional, v0.1 may omit + auth // AEAD tag or MAC (depends on encrypt/plain) payload } ``` --- +## C4. Key Events(写入 events.log 的“结构真相”) + +v0.1 必须记录的关键事件: + +* `TeamCreated(event_seq=1)` +* `MemberAccepted(event_seq++)` +* `MemberKicked(event_seq++)` +* `LeaderTransferred(event_seq++)` +* `EpochRotated(event_seq++)` + +> Key Events 是 Sync 的依据;Presence/Pos/Chat 不属于 Key Events。 + +--- + # D. 协议流转(和 UI 的对应关系) ## D1. Create Team * 本地生成 `team_id` * `epoch = 1` -* 自己 = leader -* 生成 `team_key(1)` +* self = leader +* 生成 `team_key(epoch=1)` +* 追加 key event:`TeamCreated(event_seq=1)` +* 写 snapshot + events.log --- -## D2. Invite / Join +## D2. Invite / Join(Radio) -**Invite** +**Invite(Radio, Plain)** -* Leader 周期发 `TEAM_INVITE` +* Leader 周期发 `TEAM_INVITE(team_id_short, invite_code, expires_at)` -**Join** +**Join(Radio, Plain → KeyDist)** -1. Member → `TEAM_JOIN_REQ` -2. Leader → `TEAM_JOIN_DECISION(ACCEPT)` -3. Leader → `epoch++` -4. Leader → `TEAM_KEY_DIST(epoch)` -5. 双方进入新 epoch +1. Member → `TEAM_JOIN_REQ(team_id_short, invite_code, member_pub/cap...)`(Plain) +2. Leader → `TEAM_JOIN_DECISION(ACCEPT/REJECT, team_id, leader_id)`(Plain) +3. Leader:写入 `MemberAccepted`(event_seq++) +4. Leader:`epoch++` 并写入 `EpochRotated`(event_seq++) +5. Leader → `TEAM_KEY_DIST(epoch, key)` **定向给新成员(Plain)** +6. Leader → `TEAM_KEY_DIST(epoch, key)` 分发给旧成员(可定向/可加密,策略实现) +7. 全员进入新 epoch,开始加密 Presence/Pos/WP + +--- + +## D2b. Invite / Join(NFC) + +**NFC 目标**:让新成员无需空口接收 KeyDist 也能拿到 key(更可靠/更快),同时避免明文泄露。 + +### NFC Payload(建议以 NDEF 自定义记录承载) + +* `magic/version` +* `team_id` +* `epoch`(当前或即将生效的 epoch) +* `team_id_short`(A7K3) +* `expires_at` +* `salt + nonce` +* `ciphertext(team_key)`(用 Invite Code 派生密钥加密) +* `tag` + +### 加密建议(v0.1) + +* KDF:PBKDF2-HMAC-SHA256(迭代 10k) +* AEAD:AES-GCM + +### 流程 + +1. Leader 在 `Invite via NFC` 页开启卡模拟(仅页面内开启) +2. Member 在 `Join via NFC` 页轮询读取 payload +3. Member 自动进入 `Enter Code`,输入 Invite Code 解密得到 `team_key` +4. Member 可直接进入 `Join Pending` 发送 `TEAM_JOIN_REQ`(Plain) +5. Leader Accept 后仍需 **记录 Key Events + epoch rotate** +6. Leader 对旧成员分发新 epoch key(KeyDist) +7. 对新成员:可以选择 + + * A) 不发 KeyDist(因为 NFC 已提供),只发 Decision + * B) 仍发定向 KeyDist(作为冗余保障) + +> v0.1 推荐:A 为主,B 可作为可选兼容/容错开关 --- ## D3. Kick -1. Leader → `TEAM_KICK(target)` -2. Leader → `epoch++` -3. Leader → `TEAM_KEY_DIST(epoch)` -4. 被踢成员解密失败 → UI 切到 A10 +1. Leader:写入 `MemberKicked(event_seq++)` +2. Leader:`epoch++`,写入 `EpochRotated(event_seq++)` +3. Leader → `TEAM_KICK(target)`(加密,成员可读) +4. Leader → `TEAM_KEY_DIST(new_epoch)` 分发给剩余成员 +5. 被踢成员:后续解密失败 / 收到明确 Kick → UI 切到 A10,Reason=Revoked --- -## D4. Presence & Health +## D4. Presence & Health(Status 页字段来源) -* 所有成员周期发 `TEAM_PRESENCE` -* Status 页字段来自: +* 全员周期发 `TEAM_PRESENCE(team_id, epoch, last_event_seq, battery, caps...)`(加密) +* Status 页来自: - * last_seen_ts - * leader presence - * epoch 一致性 + * `last_seen_ts`(presence 更新) + * `leader presence` + * `epoch 一致性` + * `last_event_seq 一致性` + * `sync 状态`(是否落后/是否补齐) --- -## D5. Sync(补齐) +## D5. Sync(补齐 Key Events) -1. 发现 `event_seq` 落后 -2. → `TEAM_SYNC_REQ(from_seq)` -3. → `TEAM_SYNC_RSP(events...)` -4. 重放事件,UI 刷新 +1. 发现对方 `last_event_seq > my_last_event_seq` +2. → `TEAM_SYNC_REQ(from_event_seq = my_last_event_seq + 1)` +3. → `TEAM_SYNC_RSP(events...)`(携带一组 Key Events,每条含 event_seq) +4. 本地重放事件、append events.log、刷新 snapshot +5. 安全态从 WARN → OK --- -下面我把前面那套 **Pager Team Core v0.1(入队/踢人/角色 + 状态页流转 + 协议流转)**,直接“落”到你工程里:**模块清单(按你 domain/usecase/ports/infra/ui 的风格)+ 状态机伪代码**。我会尽量贴近你 Trail Mate 的架构习惯(纯 domain、usecase 编排、ports 抽象、infra 适配 Meshtastic、UI 独立状态)。 +# E. v0.1 额外约束(写进文档的“实现护栏”) + +## E1. 明文/密文边界(避免新人永远解不开) + +* Plain 必须包含:`INVITE / JOIN_REQ / JOIN_DECISION / KEY_DIST(for newcomer)` +* 加密从“新 epoch 生效后”开始:`PRESENCE / POS / WP / SYNC` 等 + +## E2. 并发 Join 处理策略(Leader) + +* Leader 同时只处理 **1 个 pending join** +* 其余排队显示在 `Requests` 页面,成员端可 Retry + +## E3. Invite Code 绑定 team 上下文 + +* Invite Code 校验必须结合 `team_id_short` 或 `team_id` +* NFC payload 也带 `team_id_short`,用于防“撞码误入队” --- @@ -862,6 +1095,9 @@ void TeamService::onJoinReq(const JoinReq& req, uint32_t from) { if (clock_.now() > invite_.expire_ts) return; if (req.code != invite_.invite_code) return; // v0.1:简单匹配 + // 先请求 NodeInfo(获取公钥,便于定向发送) + transport_.requestNodeInfo(from, /*want_response*/ true); + // 让 UI 弹窗:Accept / Reject ui_->promptJoinRequest(from, /*name*/ req.name); // Accept 的动作走 uiAcceptJoin(from) @@ -909,7 +1145,53 @@ void TeamService::distributeEpochKeyToMembers() { --- -## 4.2.8 Member:收到 ACCEPT + KEY_DIST +## 4.2.8 NFC Key Exchange(Invite Code 加密 PSK) + +**目标**:NFC payload 不含明文 `team_psk`,读卡后需输入 Invite Code 解密,降低明文泄露风险。 +支持 **Radio / NFC 双模式**: + +* Radio:现有 Invite Code 广播 + Join/Accept + KeyDist(对新人) +* NFC:NFC 传递加密 PSK + Join/Accept,**不再给新人发送 KeyDist** + * 仍需给**已有成员**发送 KeyDist(因为会 rotate epoch) + +**Payload(NDEF / TLV)** + +* `magic/version` +* `team_id` +* `key_id` +* `expires_at` +* `salt`(随机) +* `nonce` +* `ciphertext(team_psk)` +* `tag` + +**加密规则** + +* KDF:`PBKDF2-HMAC-SHA256`,迭代 `10k` +* 对称算法:`AES-GCM` +* Invite Code:6 位连续字符(无分隔符) + +**Member 流程(复用 Enter Code UI)** + +1. 读取 NFC payload → 检查 `expires_at`(默认 9 分钟有效期) +2. 弹出 Enter Code(复用现有输入) +3. KDF → AES-GCM 解密 → 得到 `team_psk` +4. `setKeysFromPsk(team_id, key_id, team_psk)` → 继续走 Join/Accept + +**Leader 侧关键点(方案 A)** + +* 仍执行 **Epoch Rotate**(成员变化必须记录) +* **不再给新人发送 KeyDist**(新人已通过 NFC 获得 key) +* **继续给已有成员发送 KeyDist**(确保他们更新到新 key) + +**NFC 扫描窗口** + +* 进入 “Join via NFC” / “Invite via NFC” 页面时才开启 NFC +* 退出页面立即关闭 NFC(省电 & ST25R3916 无电容自检) + +--- + +## 4.2.9 Member:收到 ACCEPT + KEY_DIST ```cpp void TeamService::onJoinDecision(const JoinDecision& d) { @@ -940,7 +1222,7 @@ void TeamService::onKeyDist(const KeyDist& kd) { --- -## 4.2.9 Kick Flow(Leader → 全队) +## 4.2.10 Kick Flow(Leader → 全队) ```cpp void TeamService::uiKickMember(MemberId target) { @@ -983,7 +1265,7 @@ void TeamService::onKick(const Kick& k) { --- -## 4.2.10 Presence & Health(状态页数据来源) +## 4.2.11 Presence & Health(状态页数据来源) ```cpp void TeamService::broadcastPresenceIfDue() { @@ -1017,7 +1299,7 @@ void TeamService::onPresence(const Presence& p) { --- -## 4.2.11 Sync(补齐关键事件) +## 4.2.12 Sync(补齐关键事件) ```cpp void TeamService::requestSyncFrom(MemberId peer, uint32_t from_seq) { @@ -1078,4 +1360,4 @@ void TeamService::onSyncRsp(const SyncRsp& rsp) { 3. `ports/i_team_transport.h`(send / sendTo / onPacket) 4. `usecase/team_service.cpp`(先只做 create + invite 广播 + presence) 5. `ui/screens/team/team_state.h` + `team_pages.cpp`(StatusNotInTeam / StatusInTeam 两页先跑起来) -6. 再加 join/kick/sync \ No newline at end of file +6. 再加 join/kick/sync diff --git a/src/app/app_context.cpp b/src/app/app_context.cpp index 769dcba5..3142195a 100644 --- a/src/app/app_context.cpp +++ b/src/app/app_context.cpp @@ -7,8 +7,13 @@ #include "../chat/infra/protocol_factory.h" #include "../gps/usecase/gps_service.h" #include "../sys/event_bus.h" +#include "../ui/ui_team.h" #include "../ui/widgets/system_notification.h" #include "../ui/ui_common.h" +#include "../team/protocol/team_chat.h" +#ifdef USING_ST25R3916 +#include "../team/infra/nfc/team_nfc.h" +#endif #include "app_tasks.h" #include @@ -137,6 +142,13 @@ void AppContext::update() ui_controller_->update(); } +#ifdef USING_ST25R3916 + if (team::nfc::is_share_active()) + { + team::nfc::poll_share(); + } +#endif + // Process events sys::Event* event = nullptr; while (sys::EventBus::subscribe(&event, 0)) @@ -170,6 +182,72 @@ void AppContext::update() ui::SystemNotification::show(msg_event->text, 3000); break; } + case sys::EventType::TeamChat: + { + sys::TeamChatEvent* team_event = (sys::TeamChatEvent*)event; + if (board_) + { + board_->vibrator(); + } + std::string notice = "Team: "; + const auto& msg = team_event->data.msg; + if (msg.header.type == team::proto::TeamChatType::Text) + { + std::string text(msg.payload.begin(), msg.payload.end()); + if (text.size() > 48) + { + text = text.substr(0, 45) + "..."; + } + notice += text; + } + else if (msg.header.type == team::proto::TeamChatType::Location) + { + team::proto::TeamChatLocation loc; + if (team::proto::decodeTeamChatLocation(msg.payload.data(), msg.payload.size(), &loc) && + !loc.label.empty()) + { + notice += "Location: " + loc.label; + } + else + { + notice += "Location"; + } + } + else if (msg.header.type == team::proto::TeamChatType::Command) + { + team::proto::TeamChatCommand cmd; + if (team::proto::decodeTeamChatCommand(msg.payload.data(), msg.payload.size(), &cmd)) + { + const char* name = "Command"; + switch (cmd.cmd_type) + { + case team::proto::TeamCommandType::RallyTo: + name = "RallyTo"; + break; + case team::proto::TeamCommandType::MoveTo: + name = "MoveTo"; + break; + case team::proto::TeamCommandType::Hold: + name = "Hold"; + break; + default: + break; + } + notice += "Command: "; + notice += name; + } + else + { + notice += "Command"; + } + } + else + { + notice += "Message"; + } + ui::SystemNotification::show(notice.c_str(), 3000); + break; + } case sys::EventType::ChatSendResult: { sys::ChatSendResultEvent* result_event = (sys::ChatSendResultEvent*)event; @@ -274,6 +352,26 @@ void AppContext::update() } // Forward event to UI controller if it exists + if (event->type == sys::EventType::TeamAdvertise || + event->type == sys::EventType::TeamJoinRequest || + event->type == sys::EventType::TeamJoinAccept || + event->type == sys::EventType::TeamJoinConfirm || + event->type == sys::EventType::TeamJoinDecision || + event->type == sys::EventType::TeamKick || + event->type == sys::EventType::TeamTransferLeader || + event->type == sys::EventType::TeamKeyDist || + event->type == sys::EventType::TeamStatus || + event->type == sys::EventType::TeamPosition || + event->type == sys::EventType::TeamWaypoint || + event->type == sys::EventType::TeamChat || + event->type == sys::EventType::TeamError || + event->type == sys::EventType::SystemTick) + { + ui_team_handle_event(event); + delete event; + continue; + } + if (ui_controller_) { ui_controller_->onChatEvent(event); diff --git a/src/app/app_context.h b/src/app/app_context.h index 51efd17f..6e60c80a 100644 --- a/src/app/app_context.h +++ b/src/app/app_context.h @@ -100,6 +100,11 @@ class AppContext return config_; } + chat::NodeId getSelfNodeId() const + { + return mesh_adapter_ ? mesh_adapter_->getNodeId() : 0; + } + void saveConfig() { config_.save(preferences_); diff --git a/src/board/LilyGoKeyboard.cpp b/src/board/LilyGoKeyboard.cpp index e082709a..f58d03d1 100644 --- a/src/board/LilyGoKeyboard.cpp +++ b/src/board/LilyGoKeyboard.cpp @@ -433,10 +433,9 @@ char LilyGoKeyboard::handleSpaceAndNullChar(char keyVal, char& lastKeyVal, bool& } } // 无符号键的配置:上一个键为空字符则当前转换为空格 - else if (lastKeyVal == '\0') + else if (lastKeyVal == '\0' && keyVal == '\0' && pressed) { keyVal = ' '; - pressed = true; } } #endif diff --git a/src/board/TLoRaPagerBoard.cpp b/src/board/TLoRaPagerBoard.cpp index 63308f84..c2464510 100644 --- a/src/board/TLoRaPagerBoard.cpp +++ b/src/board/TLoRaPagerBoard.cpp @@ -9,6 +9,7 @@ #include #include +#include #include "display/drivers/ST7796.h" #include "pins_arduino.h" @@ -321,7 +322,7 @@ uint32_t TLoRaPagerBoard::begin(uint32_t disable_hw_init) log_d("SPI bus initialized (SCK=%d, MISO=%d, MOSI=%d)", LORA_SCK, LORA_MISO, LORA_MOSI); // Configure NFC interrupt pin - pinMode(NFC_INT, INPUT); + pinMode(NFC_INT, INPUT_PULLUP); // Initialize RTC (PCF85063) - optional if (!(disable_hw_init & NO_HW_RTC)) @@ -452,8 +453,12 @@ uint32_t TLoRaPagerBoard::begin(uint32_t disable_hw_init) log_d("Board initialization complete. Hardware online: 0x%08X", devices_probe); Serial.printf("[TLoRaPagerBoard::begin] ===== HARDWARE INITIALIZATION COMPLETE =====\n"); Serial.printf("[TLoRaPagerBoard::begin] devices_probe=0x%08X\n", devices_probe); - Serial.printf("[TLoRaPagerBoard::begin] GPS online: %s (HW_GPS_ONLINE=0x%08X)\n", - (devices_probe & HW_GPS_ONLINE) ? "YES" : "NO", HW_GPS_ONLINE); + const char* gps_state = + (devices_probe & HW_GPS_ONLINE) ? "YES" : + ((disable_hw_init & NO_HW_GPS) ? "SKIPPED" : "DEFERRED"); + Serial.printf("[TLoRaPagerBoard::begin] GPS online: %s\n", gps_state); + Serial.printf("[TLoRaPagerBoard::begin] NFC online: %s (HW_NFC_ONLINE=0x%08X)\n", + (devices_probe & HW_NFC_ONLINE) ? "YES" : "NO", HW_NFC_ONLINE); return devices_probe; } @@ -573,6 +578,7 @@ bool TLoRaPagerBoard::initNFC() { #ifdef USING_ST25R3916 bool res = false; + ReturnCode rc = ERR_NONE; log_d("Init NFC"); // Enable NFC power before initialization @@ -580,16 +586,20 @@ bool TLoRaPagerBoard::initNFC() delay(10); // Wait for power to stabilize // Initialize NFC reader - res = NFCReader.rfalNfcInitialize() == ST_ERR_NONE; + rc = NFCReader.rfalNfcInitialize(); + res = (rc == ERR_NONE); if (!res) { - log_e("Failed to find NFC Reader"); + log_e("Failed to find NFC Reader (rc=%d)", rc); + Serial.printf("[TLoRaPagerBoard::initNFC] NFC init failed rc=%d\n", rc); powerControl(POWER_NFC, false); } else { log_d("Initializing NFC Reader succeeded"); + Serial.printf("[TLoRaPagerBoard::initNFC] NFC init ok\n"); devices_probe |= HW_NFC_ONLINE; + detachInterrupt(NFC_INT); // Turn off NFC power after initialization (will be enabled when needed) powerControl(POWER_NFC, false); } @@ -780,7 +790,8 @@ void TLoRaPagerBoard::vibrator() log_d("[vibrator] Enabling power and starting vibration (effect=%d)...", _haptic_effects); powerControl(POWER_HAPTIC_DRIVER, true); drv.setWaveform(0, _haptic_effects); - drv.setWaveform(1, 0); + drv.setWaveform(1, _haptic_effects); + drv.setWaveform(2, 0); drv.run(); log_d("[vibrator] Vibration started, setting up stop timer..."); @@ -876,7 +887,7 @@ int TLoRaPagerBoard::getKeyChar(char* c) } #ifdef USING_ST25R3916 -bool TLoRaPagerBoard::startNFCDiscovery(uint8_t techs2Find, uint16_t totalDuration) +bool TLoRaPagerBoard::startNFCDiscovery(uint16_t techs2Find, uint16_t totalDuration) { if (!(devices_probe & HW_NFC_ONLINE)) { @@ -889,26 +900,49 @@ bool TLoRaPagerBoard::startNFCDiscovery(uint8_t techs2Find, uint16_t totalDurati delay(10); // Wait for power to stabilize // Reinitialize NFC reader - if (NFCReader.rfalNfcInitialize() != ST_ERR_NONE) + ReturnCode rc = NFCReader.rfalNfcInitialize(); + if (rc != ERR_NONE) { log_e("Failed to reinitialize NFC"); + Serial.printf("[TLoRaPagerBoard::startNFCDiscovery] rfalNfcInitialize rc=%d\n", rc); powerControl(POWER_NFC, false); return false; } + detachInterrupt(NFC_INT); // Setup discovery parameters rfalNfcDiscoverParam discover_params; + rfalNfcDefaultDiscParams(&discover_params); discover_params.devLimit = 1; discover_params.techs2Find = techs2Find; - discover_params.GBLen = RFAL_NFCDEP_GB_MAX_LEN; discover_params.notifyCb = nullptr; // Can be set by user if needed discover_params.totalDuration = totalDuration; - discover_params.wakeupEnabled = false; + + if (techs2Find & RFAL_NFC_LISTEN_TECH_A) + { + static bool nfcid_init = false; + static uint8_t nfcid[RFAL_NFCID1_TRIPLE_LEN] = { 0 }; + if (!nfcid_init) + { + nfcid[0] = static_cast(random(1, 255)); + nfcid[1] = static_cast(random(0, 256)); + nfcid[2] = static_cast(random(0, 256)); + nfcid[3] = static_cast(random(0, 256)); + nfcid_init = true; + } + discover_params.lmConfigPA.nfcidLen = RFAL_LM_NFCID_LEN_04; + memcpy(discover_params.lmConfigPA.nfcid, nfcid, sizeof(nfcid)); + discover_params.lmConfigPA.SENS_RES[0] = 0x04; + discover_params.lmConfigPA.SENS_RES[1] = 0x00; + discover_params.lmConfigPA.SEL_RES = RFAL_NFCA_SEL_RES_CONF_T4T; + } // Start discovery - if (NFCReader.rfalNfcDiscover(&discover_params) != ST_ERR_NONE) + rc = NFCReader.rfalNfcDiscover(&discover_params); + if (rc != ERR_NONE) { log_e("Failed to start NFC discovery"); + Serial.printf("[TLoRaPagerBoard::startNFCDiscovery] rfalNfcDiscover rc=%d\n", rc); powerControl(POWER_NFC, false); return false; } @@ -925,13 +959,27 @@ void TLoRaPagerBoard::stopNFCDiscovery() } // Deactivate NFC - NFCReader.rfalNfcDeactivate(true); + NFCReader.rfalNfcDeactivate(RFAL_NFC_DEACTIVATE_IDLE); // Turn off NFC power powerControl(POWER_NFC, false); log_d("NFC discovery stopped"); } + +void TLoRaPagerBoard::pollNfcIrq() +{ + if (!nfc) + { + return; + } + RfalRfClass* rf = nfc->getRfalRf(); + if (!rf) + { + return; + } + static_cast(rf)->st25r3916CheckForReceivedInterrupts(); +} #endif bool TLoRaPagerBoard::initGPS() @@ -998,6 +1046,88 @@ void TLoRaPagerBoard::pushColors(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t LilyGoDispArduinoSPI::pushColors(x1, y1, x2, y2, color); } +int TLoRaPagerBoard::transmitRadio(const uint8_t* data, size_t len) +{ + if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(50))) + { + int rc = radio_.transmit(data, len); + LilyGoDispArduinoSPI::unlock(); + return rc; + } + return RADIOLIB_ERR_SPI_WRITE_FAILED; +} + +int TLoRaPagerBoard::startRadioReceive() +{ + if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(50))) + { + int rc = radio_.startReceive(); + LilyGoDispArduinoSPI::unlock(); + return rc; + } + return RADIOLIB_ERR_SPI_WRITE_FAILED; +} + +uint32_t TLoRaPagerBoard::getRadioIrqFlags() +{ + if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(20))) + { + uint32_t flags = radio_.getIrqFlags(); + LilyGoDispArduinoSPI::unlock(); + return flags; + } + return 0; +} + +int TLoRaPagerBoard::getRadioPacketLength(bool update) +{ + if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(20))) + { + int len = static_cast(radio_.getPacketLength(update)); + LilyGoDispArduinoSPI::unlock(); + return len; + } + return 0; +} + +int TLoRaPagerBoard::readRadioData(uint8_t* buf, size_t len) +{ + if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(50))) + { + int rc = radio_.readData(buf, len); + LilyGoDispArduinoSPI::unlock(); + return rc; + } + return RADIOLIB_ERR_SPI_WRITE_FAILED; +} + +void TLoRaPagerBoard::clearRadioIrqFlags(uint32_t flags) +{ + if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(20))) + { + radio_.clearIrqFlags(flags); + LilyGoDispArduinoSPI::unlock(); + } +} + +void TLoRaPagerBoard::configureLoraRadio(float freq_mhz, float bw_khz, uint8_t sf, uint8_t cr_denom, + int8_t tx_power, uint16_t preamble_len, uint8_t sync_word, + uint8_t crc_len) +{ + if (LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(100))) + { + radio_.setFrequency(freq_mhz); + radio_.setBandwidth(bw_khz); + radio_.setSpreadingFactor(sf); + radio_.setCodingRate(cr_denom); + radio_.setOutputPower(tx_power); + radio_.setPreambleLength(preamble_len); + radio_.setSyncWord(sync_word); + radio_.setCRC(crc_len); + LilyGoDispArduinoSPI::unlock(); + } +} + bool TLoRaPagerBoard::hasEncoder() { return true; @@ -1068,7 +1198,7 @@ bool TLoRaPagerBoard::syncTimeFromGPS(uint32_t gps_task_interval_ms) day < 1 || day > 31 || hour >= 24 || minute >= 60 || second >= 60) { - Serial.printf("[TLoRaPagerBoard::syncTimeFromGPS] Invalid GPS time values: %04d-%02d-%02d %02d:%02d:%02d\n", + GPS_BOARD_LOG("[TLoRaPagerBoard::syncTimeFromGPS] Invalid GPS time values: %04d-%02d-%02d %02d:%02d:%02d\n", year, month, day, hour, minute, second); return false; } diff --git a/src/board/TLoRaPagerBoard.h b/src/board/TLoRaPagerBoard.h index 350d8383..bf8ed894 100644 --- a/src/board/TLoRaPagerBoard.h +++ b/src/board/TLoRaPagerBoard.h @@ -232,13 +232,18 @@ class TLoRaPagerBoard : public BoardBase, * @param totalDuration Total discovery duration in ms * @return true if successful, false otherwise */ - bool startNFCDiscovery(uint8_t techs2Find = RFAL_NFC_POLL_TECH_A, uint16_t totalDuration = 1000); + bool startNFCDiscovery(uint16_t techs2Find = RFAL_NFC_POLL_TECH_A, uint16_t totalDuration = 1000); /** * @brief Stop NFC discovery mode */ void stopNFCDiscovery(); + /** + * @brief Poll NFC interrupt registers (IRQ line free) + */ + void pollNfcIrq(); + /** * @brief Check if NFC is ready * @return true if NFC is initialized and online @@ -269,26 +274,15 @@ class TLoRaPagerBoard : public BoardBase, * @brief Check if LoRa is initialized and online */ bool isRadioOnline() const override { return isHardwareOnline(HW_RADIO_ONLINE); } - int transmitRadio(const uint8_t* data, size_t len) override { return radio_.transmit(data, len); } - int startRadioReceive() override { return radio_.startReceive(); } - uint32_t getRadioIrqFlags() override { return radio_.getIrqFlags(); } - int getRadioPacketLength(bool update) override { return static_cast(radio_.getPacketLength(update)); } - int readRadioData(uint8_t* buf, size_t len) override { return radio_.readData(buf, len); } - void clearRadioIrqFlags(uint32_t flags) override { radio_.clearIrqFlags(flags); } + int transmitRadio(const uint8_t* data, size_t len) override; + int startRadioReceive() override; + uint32_t getRadioIrqFlags() override; + int getRadioPacketLength(bool update) override; + int readRadioData(uint8_t* buf, size_t len) override; + void clearRadioIrqFlags(uint32_t flags) override; void configureLoraRadio(float freq_mhz, float bw_khz, uint8_t sf, uint8_t cr_denom, int8_t tx_power, uint16_t preamble_len, uint8_t sync_word, - uint8_t crc_len) override - { - auto& radio = radio_; - radio.setFrequency(freq_mhz); - radio.setBandwidth(bw_khz); - radio.setSpreadingFactor(sf); - radio.setCodingRate(cr_denom); - radio.setOutputPower(tx_power); - radio.setPreambleLength(preamble_len); - radio.setSyncWord(sync_word); - radio.setCRC(crc_len); - } + uint8_t crc_len) override; // GpsBoard void setGPSOnline(bool online) override { setGPSOnlineInternal(online); } @@ -435,7 +429,7 @@ class TLoRaPagerBoard : public BoardBase, bool isUsbPresent_bestEffort(); uint32_t devices_probe = 0; ///< Hardware detection status bitmask - uint8_t _haptic_effects = 100; ///< Default haptic effect (very strong buzz for message notification) + uint8_t _haptic_effects = 15; ///< Default haptic effect (strong buzz for message notification) }; extern TLoRaPagerBoard& instance; diff --git a/src/board/nfc_include.h b/src/board/nfc_include.h index 3f7e787e..7987e5f2 100644 --- a/src/board/nfc_include.h +++ b/src/board/nfc_include.h @@ -10,6 +10,9 @@ #pragma once #ifdef USING_ST25R3916 +#ifdef BR +#undef BR +#endif #include #include #include @@ -17,9 +20,8 @@ #include #include #include -#include -#include #include +#include #include #include #include @@ -27,7 +29,9 @@ #include #include #include -#include +#ifdef rfalRunBlocking +#undef rfalRunBlocking +#endif #include #include #include diff --git a/src/chat/infra/meshcore/meshcore_adapter.h b/src/chat/infra/meshcore/meshcore_adapter.h index a59cf887..846580c8 100644 --- a/src/chat/infra/meshcore/meshcore_adapter.h +++ b/src/chat/infra/meshcore/meshcore_adapter.h @@ -50,6 +50,7 @@ class MeshCoreAdapter : public IMeshAdapter void applyConfig(const MeshConfig& config) override; bool isReady() const override; + NodeId getNodeId() const override { return 0; } /** * @brief Poll for incoming raw packet data diff --git a/src/chat/infra/meshtastic/mt_adapter.cpp b/src/chat/infra/meshtastic/mt_adapter.cpp index 1fcadaba..e93d5240 100644 --- a/src/chat/infra/meshtastic/mt_adapter.cpp +++ b/src/chat/infra/meshtastic/mt_adapter.cpp @@ -98,6 +98,8 @@ static const char* portName(uint32_t portnum) return "TEAM_POS"; case team::proto::TEAM_WAYPOINT_APP: return "TEAM_WP"; + case team::proto::TEAM_CHAT_APP: + return "TEAM_CHAT"; default: return "UNKNOWN"; } @@ -809,6 +811,26 @@ bool MtAdapter::pollIncomingData(MeshIncomingData* out) return true; } +bool MtAdapter::requestNodeInfo(NodeId dest, bool want_response) +{ + if (!ready_) + { + return false; + } + uint32_t target = (dest == 0) ? 0xFFFFFFFF : dest; + return sendNodeInfoTo(target, want_response); +} + +bool MtAdapter::isPkiReady() const +{ + return pki_ready_; +} + +bool MtAdapter::hasPkiKey(NodeId dest) const +{ + return node_public_keys_.find(dest) != node_public_keys_.end(); +} + void MtAdapter::applyConfig(const MeshConfig& config) { config_ = config; @@ -2767,4 +2789,4 @@ bool MtAdapter::sendRoutingError(uint32_t dest, uint32_t request_id, uint8_t cha } } // namespace meshtastic -} // namespace chat \ No newline at end of file +} // namespace chat diff --git a/src/chat/infra/meshtastic/mt_adapter.h b/src/chat/infra/meshtastic/mt_adapter.h index 67ff71be..4f45d302 100644 --- a/src/chat/infra/meshtastic/mt_adapter.h +++ b/src/chat/infra/meshtastic/mt_adapter.h @@ -40,8 +40,14 @@ class MtAdapter : public chat::IMeshAdapter const uint8_t* payload, size_t len, NodeId dest = 0, bool want_ack = false) override; bool pollIncomingData(MeshIncomingData* out) override; + bool requestNodeInfo(NodeId dest, bool want_response) override; + bool startKeyVerification(NodeId node_id) override; + bool submitKeyVerificationNumber(NodeId node_id, uint64_t nonce, uint32_t number) override; + bool isPkiReady() const override; + bool hasPkiKey(NodeId dest) const override; void applyConfig(const MeshConfig& config) override; bool isReady() const override; + NodeId getNodeId() const override { return node_id_; } /** * @brief Poll for incoming raw packet data @@ -69,22 +75,6 @@ class MtAdapter : public chat::IMeshAdapter */ void processSendQueue() override; - /** - * @brief Submit key verification number for ongoing PKI verification - * @param node_id Remote node id - * @param nonce Verification nonce - * @param number Security number - * @return true if processed - */ - bool submitKeyVerificationNumber(NodeId node_id, uint64_t nonce, uint32_t number); - - /** - * @brief Start PKI key verification with a remote node - * @param node_id Remote node id - * @return true if sent - */ - bool startKeyVerification(NodeId node_id); - private: LoraBoard& board_; MeshConfig config_; @@ -203,4 +193,4 @@ class MtAdapter : public chat::IMeshAdapter }; } // namespace meshtastic -} // namespace chat \ No newline at end of file +} // namespace chat diff --git a/src/chat/ports/i_mesh_adapter.h b/src/chat/ports/i_mesh_adapter.h index 82c4e297..f44571d6 100644 --- a/src/chat/ports/i_mesh_adapter.h +++ b/src/chat/ports/i_mesh_adapter.h @@ -57,6 +57,70 @@ class IMeshAdapter */ virtual bool pollIncomingData(MeshIncomingData* out) = 0; + /** + * @brief Request NodeInfo from a specific node (if supported) + * @param dest Destination node (0 for broadcast) + * @param want_response Request response if supported + * @return true if request queued + */ + virtual bool requestNodeInfo(NodeId dest, bool want_response) + { + (void)dest; + (void)want_response; + return false; + } + + /** + * @brief Start PKI key verification with a remote node (if supported) + * @param dest Destination node + * @return true if started + */ + virtual bool startKeyVerification(NodeId dest) + { + (void)dest; + return false; + } + + /** + * @brief Submit PKI verification number (if supported) + * @param dest Destination node + * @param nonce Verification nonce + * @param number Security number + * @return true if accepted + */ + virtual bool submitKeyVerificationNumber(NodeId dest, uint64_t nonce, uint32_t number) + { + (void)dest; + (void)nonce; + (void)number; + return false; + } + + /** + * @brief Get local node ID (if supported) + */ + virtual NodeId getNodeId() const + { + return 0; + } + + /** + * @brief Check if PKI is ready (if supported) + */ + virtual bool isPkiReady() const + { + return false; + } + + /** + * @brief Check if PKI public key for node is known (if supported) + */ + virtual bool hasPkiKey(NodeId dest) const + { + (void)dest; + return false; + } + /** * @brief Apply mesh configuration * @param config Configuration to apply diff --git a/src/chat/usecase/chat_service.cpp b/src/chat/usecase/chat_service.cpp index 2dab2ed5..692449ae 100644 --- a/src/chat/usecase/chat_service.cpp +++ b/src/chat/usecase/chat_service.cpp @@ -120,7 +120,8 @@ void ChatService::processIncoming() msg.peer = incoming.from; } msg.msg_id = incoming.msg_id; - msg.timestamp = incoming.timestamp ? incoming.timestamp : now_message_timestamp(); + // Use local receive time to avoid sender clock skew. + msg.timestamp = now_message_timestamp(); msg.text = incoming.text; msg.status = MessageStatus::Incoming; diff --git a/src/gps/domain/gps_state.h b/src/gps/domain/gps_state.h index 42619f1e..83c12ac2 100644 --- a/src/gps/domain/gps_state.h +++ b/src/gps/domain/gps_state.h @@ -9,8 +9,14 @@ struct GpsState { double lat = 0.0; double lng = 0.0; + double alt_m = 0.0; + double speed_mps = 0.0; + double course_deg = 0.0; uint8_t satellites = 0; bool valid = false; + bool has_alt = false; + bool has_speed = false; + bool has_course = false; uint32_t age = 0; }; diff --git a/src/gps/infra/hal_gps_adapter.cpp b/src/gps/infra/hal_gps_adapter.cpp index d4d3accc..2c31286e 100644 --- a/src/gps/infra/hal_gps_adapter.cpp +++ b/src/gps/infra/hal_gps_adapter.cpp @@ -49,6 +49,36 @@ double HalGpsAdapter::longitude() const return hal_gps_.longitude(); } +bool HalGpsAdapter::hasAltitude() const +{ + return hal_gps_.hasAltitude(); +} + +double HalGpsAdapter::altitude() const +{ + return hal_gps_.altitude(); +} + +bool HalGpsAdapter::hasSpeed() const +{ + return hal_gps_.hasSpeed(); +} + +double HalGpsAdapter::speed() const +{ + return hal_gps_.speed(); +} + +bool HalGpsAdapter::hasCourse() const +{ + return hal_gps_.hasCourse(); +} + +double HalGpsAdapter::course() const +{ + return hal_gps_.course(); +} + uint8_t HalGpsAdapter::satellites() const { return hal_gps_.satellites(); diff --git a/src/gps/infra/hal_gps_adapter.h b/src/gps/infra/hal_gps_adapter.h index e8a5e275..600e12a5 100644 --- a/src/gps/infra/hal_gps_adapter.h +++ b/src/gps/infra/hal_gps_adapter.h @@ -20,6 +20,12 @@ class HalGpsAdapter : public IGpsHardware bool hasFix() const override; double latitude() const override; double longitude() const override; + bool hasAltitude() const override; + double altitude() const override; + bool hasSpeed() const override; + double speed() const override; + bool hasCourse() const override; + double course() const override; uint8_t satellites() const override; bool syncTime(uint32_t gps_task_interval_ms) override; diff --git a/src/gps/ports/i_gps_hw.h b/src/gps/ports/i_gps_hw.h index ddba6880..a89acdf2 100644 --- a/src/gps/ports/i_gps_hw.h +++ b/src/gps/ports/i_gps_hw.h @@ -17,6 +17,12 @@ class IGpsHardware virtual bool hasFix() const = 0; virtual double latitude() const = 0; virtual double longitude() const = 0; + virtual bool hasAltitude() const = 0; + virtual double altitude() const = 0; + virtual bool hasSpeed() const = 0; + virtual double speed() const = 0; + virtual bool hasCourse() const = 0; + virtual double course() const = 0; virtual uint8_t satellites() const = 0; virtual bool syncTime(uint32_t gps_task_interval_ms) = 0; }; diff --git a/src/gps/usecase/gps_service.cpp b/src/gps/usecase/gps_service.cpp index e5797785..353edbf8 100644 --- a/src/gps/usecase/gps_service.cpp +++ b/src/gps/usecase/gps_service.cpp @@ -78,6 +78,8 @@ void GpsService::begin(GpsBoard& gps_board, MotionBoard& motion_board, } motion_control_enabled_ = motion_policy_.begin(motion_adapter_, motion_config_); + // Force GPS always-on: do not suspend or gate by motion policy. + motion_control_enabled_ = false; if (motion_control_enabled_ && gps_task_handle_ != nullptr) { @@ -303,6 +305,18 @@ void GpsService::gpsTask(void* pvParameters) { service->gps_state_.lat = service->gps_adapter_.latitude(); service->gps_state_.lng = service->gps_adapter_.longitude(); + service->gps_state_.has_alt = service->gps_adapter_.hasAltitude(); + service->gps_state_.alt_m = service->gps_state_.has_alt + ? service->gps_adapter_.altitude() + : 0.0; + service->gps_state_.has_speed = service->gps_adapter_.hasSpeed(); + service->gps_state_.speed_mps = service->gps_state_.has_speed + ? service->gps_adapter_.speed() + : 0.0; + service->gps_state_.has_course = service->gps_adapter_.hasCourse(); + service->gps_state_.course_deg = service->gps_state_.has_course + ? service->gps_adapter_.course() + : 0.0; service->gps_state_.satellites = sat_count; service->gps_state_.valid = true; service->gps_last_update_time_ = millis(); @@ -323,6 +337,12 @@ void GpsService::gpsTask(void* pvParameters) else { service->gps_state_.valid = false; + service->gps_state_.has_alt = false; + service->gps_state_.has_speed = false; + service->gps_state_.has_course = false; + service->gps_state_.alt_m = 0.0; + service->gps_state_.speed_mps = 0.0; + service->gps_state_.course_deg = 0.0; if (was_valid) { GPS_TASK_LOG("[GPS Task] *** FIX LOST *** (loop %lu)\n", loop_count); @@ -427,7 +447,8 @@ void GpsService::setGPSPowerState(bool enable) } gps_adapter_.powerOn(); gps_powered_ = true; - gps_adapter_.init(); + bool init_ok = gps_adapter_.init(); + Serial.printf("[GPS] init: %s\n", init_ok ? "OK" : "FAIL"); setCollectionInterval(kGpsSampleIntervalMs); if (gps_task_handle_ != nullptr) { diff --git a/src/hal/hal_gps.cpp b/src/hal/hal_gps.cpp index 2f2ad66b..f9b3104a 100644 --- a/src/hal/hal_gps.cpp +++ b/src/hal/hal_gps.cpp @@ -72,6 +72,36 @@ double HalGps::longitude() const return board_ != nullptr ? board_->getGPS().location.lng() : 0.0; } +bool HalGps::hasAltitude() const +{ + return board_ != nullptr && board_->getGPS().altitude.isValid(); +} + +double HalGps::altitude() const +{ + return board_ != nullptr ? board_->getGPS().altitude.meters() : 0.0; +} + +bool HalGps::hasSpeed() const +{ + return board_ != nullptr && board_->getGPS().speed.isValid(); +} + +double HalGps::speed() const +{ + return board_ != nullptr ? board_->getGPS().speed.mps() : 0.0; +} + +bool HalGps::hasCourse() const +{ + return board_ != nullptr && board_->getGPS().course.isValid(); +} + +double HalGps::course() const +{ + return board_ != nullptr ? board_->getGPS().course.deg() : 0.0; +} + uint8_t HalGps::satellites() const { return board_ != nullptr ? board_->getGPS().satellites.value() : 0; diff --git a/src/hal/hal_gps.h b/src/hal/hal_gps.h index 52ca78da..503d271a 100644 --- a/src/hal/hal_gps.h +++ b/src/hal/hal_gps.h @@ -18,6 +18,12 @@ class HalGps bool hasFix() const; double latitude() const; double longitude() const; + bool hasAltitude() const; + double altitude() const; + bool hasSpeed() const; + double speed() const; + bool hasCourse() const; + double course() const; uint8_t satellites() const; bool syncTime(uint32_t gps_task_interval_ms); diff --git a/src/main.cpp b/src/main.cpp index 69cdcbf1..2a820b46 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -15,6 +15,7 @@ #include "app/app_context.h" #include "display/DisplayConfig.h" #include "ui/assets/images.h" +#include "ui/app_screen.h" #include "ui/ui_common.h" #include "ui/widgets/system_notification.h" @@ -78,6 +79,7 @@ bool isScreenSleepDisabled(); // Factory-style menu structure (global for ui_*.cpp access) lv_obj_t* main_screen = nullptr; lv_obj_t* menu_panel = nullptr; +lv_obj_t* app_panel = nullptr; lv_group_t* menu_g = nullptr; lv_group_t* app_g = nullptr; lv_obj_t* desc_label = nullptr; @@ -108,86 +110,63 @@ bool format_menu_time(char* out, size_t out_len) } // App function types (like factory example) -typedef void (*app_func_t)(lv_obj_t* parent); +class FunctionAppScreen : public AppScreen { +public: + FunctionAppScreen(const char* name, + const lv_image_dsc_t* icon, + void (*enter)(lv_obj_t*), + void (*exit)(lv_obj_t*)) + : name_(name), icon_(icon), enter_(enter), exit_(exit) {} -typedef struct -{ - app_func_t setup_func_cb; - app_func_t exit_func_cb; - void* user_data; -} app_t; + const char* name() const override { return name_; } + const lv_image_dsc_t* icon() const override { return icon_; } -static app_t* s_active_app = nullptr; + void enter(lv_obj_t* parent) override + { + if (enter_) { + enter_(parent); + } + } -// App entry functions (implemented in ui_*.cpp files, global scope) + void exit(lv_obj_t* parent) override + { + if (exit_) { + exit_(parent); + } + } -app_t ui_gps_main = { - .setup_func_cb = ui_gps_enter, - .exit_func_cb = ui_gps_exit, - .user_data = nullptr, -}; - -app_t ui_chat_main = { - .setup_func_cb = ui_chat_enter, - .exit_func_cb = ui_chat_exit, - .user_data = nullptr, -}; - -app_t ui_contacts_main = { - .setup_func_cb = ui_contacts_enter, - .exit_func_cb = ui_contacts_exit, - .user_data = nullptr, -}; - -app_t ui_team_main = { - .setup_func_cb = ui_team_enter, - .exit_func_cb = ui_team_exit, - .user_data = nullptr, -}; - -app_t ui_tracker_main = { - .setup_func_cb = ui_tracker_enter, - .exit_func_cb = ui_tracker_exit, - .user_data = nullptr, -}; - -app_t ui_setting_main = { - .setup_func_cb = ui_setting_enter, - .exit_func_cb = ui_setting_exit, - .user_data = nullptr, +private: + const char* name_; + const lv_image_dsc_t* icon_; + void (*enter_)(lv_obj_t*); + void (*exit_)(lv_obj_t*); }; // Shutdown app - directly triggers system shutdown static void ui_shutdown_enter(lv_obj_t* parent) { + (void)parent; // Directly trigger software shutdown without confirmation dialog // The main menu access already implies user intent board.softwareShutdown(); } -app_t ui_shutdown_main = { - .setup_func_cb = ui_shutdown_enter, - .exit_func_cb = nullptr, - .user_data = nullptr, -}; +static FunctionAppScreen s_gps_app("GPS", &gps_icon, ui_gps_enter, ui_gps_exit); +static FunctionAppScreen s_tracker_app("Tracker", &tracker_icon, ui_tracker_enter, ui_tracker_exit); +static FunctionAppScreen s_chat_app("Chat", &Chat, ui_chat_enter, ui_chat_exit); +static FunctionAppScreen s_contacts_app("Contacts", &contact, ui_contacts_enter, ui_contacts_exit); +static FunctionAppScreen s_team_app("Team", &team_icon, ui_team_enter, ui_team_exit); +static FunctionAppScreen s_setting_app("Setting", &Setting, ui_setting_enter, ui_setting_exit); +static FunctionAppScreen s_shutdown_app("Shutdown", &shutdown, ui_shutdown_enter, nullptr); #ifdef ARDUINO_USB_MODE -app_t ui_usb_main = { - .setup_func_cb = ui_usb_enter, - .exit_func_cb = ui_usb_exit, - .user_data = nullptr, -}; -#endif - -#ifdef ARDUINO_USB_MODE -const char* kAppNames[8] = {"GPS", "Tracker", "Chat", "Contacts", "Team", "USB Mass Storage", "Setting", "Shutdown"}; -const lv_image_dsc_t* kAppImages[8] = {&gps_icon, &tracker_icon, &Chat, &contact, &team_icon, &img_usb, &Setting, &shutdown}; -app_t* kAppFuncs[8] = {&ui_gps_main, &ui_tracker_main, &ui_chat_main, &ui_contacts_main, &ui_team_main, &ui_usb_main, &ui_setting_main, &ui_shutdown_main}; +static FunctionAppScreen s_usb_app("USB Mass Storage", &img_usb, ui_usb_enter, ui_usb_exit); +static AppScreen* kAppScreens[] = {&s_gps_app, &s_tracker_app, &s_chat_app, &s_contacts_app, + &s_team_app, &s_usb_app, &s_setting_app, &s_shutdown_app}; #define NUM_APPS 8 #else -const char* kAppNames[7] = {"GPS", "Tracker", "Chat", "Contacts", "Team", "Setting", "Shutdown"}; -const lv_image_dsc_t* kAppImages[7] = {&gps_icon, &tracker_icon, &Chat, &contact, &team_icon, &Setting, &shutdown}; -app_t* kAppFuncs[7] = {&ui_gps_main, &ui_tracker_main, &ui_chat_main, &ui_contacts_main, &ui_team_main, &ui_setting_main, &ui_shutdown_main}; +static AppScreen* kAppScreens[] = {&s_gps_app, &s_tracker_app, &s_chat_app, &s_contacts_app, + &s_team_app, &s_setting_app, &s_shutdown_app}; #define NUM_APPS 7 #endif @@ -216,8 +195,11 @@ static void btn_event_cb(lv_event_t* e) } } -static void create_app(lv_obj_t* parent, const char* name, const lv_image_dsc_t* img, app_t* app_fun) +static void create_app(lv_obj_t* parent, AppScreen* app) { + const char* name = app ? app->name() : ""; + const lv_image_dsc_t* img = app ? app->icon() : nullptr; + lv_obj_t* btn = lv_btn_create(parent); lv_coord_t w = 150; lv_coord_t h = LV_PCT(100); @@ -248,23 +230,17 @@ static void create_app(lv_obj_t* parent, const char* name, const lv_image_dsc_t* btn, [](lv_event_t* e) { lv_event_code_t c = lv_event_get_code(e); - app_t *func_cb = (app_t *)lv_event_get_user_data(e); + auto* target_app = static_cast(lv_event_get_user_data(e)); lv_obj_t *parent = lv_obj_get_child(main_screen, 1); if (lv_obj_has_flag(main_screen, LV_OBJ_FLAG_HIDDEN)) { return; } if (c == LV_EVENT_CLICKED) { set_default_group(app_g); - if (s_active_app && s_active_app != func_cb && s_active_app->exit_func_cb) { - (*s_active_app->exit_func_cb)(parent); - } - if (func_cb->setup_func_cb) { - (*func_cb->setup_func_cb)(parent); - } - s_active_app = func_cb; + ui_switch_to_app(target_app, parent); menu_hidden(); } }, - LV_EVENT_CLICKED, app_fun); + LV_EVENT_CLICKED, app); } void menu_name_label_event_cb(lv_event_t* e) @@ -283,11 +259,6 @@ void menu_name_label_event_cb(lv_event_t* e) // App entry functions are implemented in ui_gps.cpp, ui_chat.cpp, ui_setting.cpp } // namespace -void ui_clear_active_app() -{ - s_active_app = nullptr; -} - // GPS data access - now provided by TLoRaPagerBoard // GPS data collection task is now in TLoRaPagerBoard class @@ -635,7 +606,12 @@ void setup() /* Create two views for switching menus and app UI */ menu_panel = lv_tileview_add_tile(main_screen, 0, 0, LV_DIR_HOR); - lv_tileview_add_tile(main_screen, 0, 1, LV_DIR_HOR); + app_panel = lv_tileview_add_tile(main_screen, 0, 1, LV_DIR_HOR); + if (app_panel) + { + lv_obj_set_style_bg_color(app_panel, lv_color_white(), 0); + lv_obj_set_style_bg_opa(app_panel, LV_OPA_COVER, 0); + } lv_obj_set_scrollbar_mode(main_screen, LV_SCROLLBAR_MODE_OFF); lv_obj_remove_flag(main_screen, LV_OBJ_FLAG_SCROLLABLE); @@ -702,7 +678,7 @@ void setup() /* Add applications */ for (int i = 0; i < NUM_APPS; ++i) { - create_app(panel, kAppNames[i], kAppImages[i], kAppFuncs[i]); + create_app(panel, kAppScreens[i]); lv_group_add_obj(menu_g, lv_obj_get_child(panel, i)); } @@ -910,13 +886,21 @@ extern bool ui_usb_is_active(); void loop() { + static uint32_t last_lvgl_ms = 0; + constexpr uint32_t kLvglIntervalMs = 20; + uint32_t now_ms = millis(); + #ifdef ARDUINO_USB_MODE // If USB mode is active, run USB loop (like Launcher's loop() function) // This ensures USB tasks get CPU time and prevents other tasks from interfering if (ui_usb_is_active()) { // Process LVGL for USB mode - lv_timer_handler(); + if (now_ms - last_lvgl_ms >= kLvglIntervalMs) + { + last_lvgl_ms = now_ms; + lv_timer_handler(); + } // Yield to allow USB and other critical tasks to run // This is critical for USB stability (like Launcher's yield()) @@ -939,7 +923,6 @@ void loop() #if MAIN_TIMING_DEBUG static uint32_t last_loop_ms = 0; static uint32_t loop_count = 0; - uint32_t now_ms = millis(); // Record loop() call interval if (last_loop_ms > 0) @@ -952,21 +935,33 @@ void loop() } last_loop_ms = now_ms; loop_count++; - - uint32_t t_before = millis(); #endif // Normal processing only when NOT in low power mode - lv_timer_handler(); + bool run_lvgl = (now_ms - last_lvgl_ms >= kLvglIntervalMs); +#if MAIN_TIMING_DEBUG + uint32_t t_before = 0; +#endif + if (run_lvgl) + { + last_lvgl_ms = now_ms; +#if MAIN_TIMING_DEBUG + t_before = millis(); +#endif + lv_timer_handler(); + } #if MAIN_TIMING_DEBUG - uint32_t t_after = millis(); - uint32_t handler_duration = t_after - t_before; - - // Log lv_timer_handler() execution time - if (handler_duration > 10) + if (run_lvgl) { - Serial.printf("[MAIN] lv_timer_handler() took %lu ms\n", handler_duration); + uint32_t t_after = millis(); + uint32_t handler_duration = t_after - t_before; + + // Log lv_timer_handler() execution time + if (handler_duration > 10) + { + Serial.printf("[MAIN] lv_timer_handler() took %lu ms\n", handler_duration); + } } #endif diff --git a/src/sys/event_bus.h b/src/sys/event_bus.h index d03ffaa4..b9310b64 100644 --- a/src/sys/event_bus.h +++ b/src/sys/event_bus.h @@ -35,9 +35,14 @@ enum class EventType TeamJoinRequest, // Team join request received TeamJoinAccept, // Team join accept received TeamJoinConfirm, // Team join confirm received + TeamJoinDecision, // Team join decision received + TeamKick, // Team kick received + TeamTransferLeader, // Team transfer leader received + TeamKeyDist, // Team key distribution received TeamStatus, // Team status received TeamPosition, // Team position received TeamWaypoint, // Team waypoint received + TeamChat, // Team chat received TeamError, // Team protocol error InputEvent, // Input event (keyboard/rotary) SystemTick // System tick (for periodic tasks) @@ -246,6 +251,50 @@ struct TeamJoinConfirmEvent : public Event : Event(EventType::TeamJoinConfirm), data(evt) {} }; +/** + * @brief Team join decision event + */ +struct TeamJoinDecisionEvent : public Event +{ + team::TeamJoinDecisionEvent data; + + explicit TeamJoinDecisionEvent(const team::TeamJoinDecisionEvent& evt) + : Event(EventType::TeamJoinDecision), data(evt) {} +}; + +/** + * @brief Team kick event + */ +struct TeamKickEvent : public Event +{ + team::TeamKickEvent data; + + explicit TeamKickEvent(const team::TeamKickEvent& evt) + : Event(EventType::TeamKick), data(evt) {} +}; + +/** + * @brief Team transfer leader event + */ +struct TeamTransferLeaderEvent : public Event +{ + team::TeamTransferLeaderEvent data; + + explicit TeamTransferLeaderEvent(const team::TeamTransferLeaderEvent& evt) + : Event(EventType::TeamTransferLeader), data(evt) {} +}; + +/** + * @brief Team key distribution event + */ +struct TeamKeyDistEvent : public Event +{ + team::TeamKeyDistEvent data; + + explicit TeamKeyDistEvent(const team::TeamKeyDistEvent& evt) + : Event(EventType::TeamKeyDist), data(evt) {} +}; + /** * @brief Team status event */ @@ -279,6 +328,17 @@ struct TeamWaypointEvent : public Event : Event(EventType::TeamWaypoint), data(evt) {} }; +/** + * @brief Team chat event + */ +struct TeamChatEvent : public Event +{ + team::TeamChatEvent data; + + explicit TeamChatEvent(const team::TeamChatEvent& evt) + : Event(EventType::TeamChat), data(evt) {} +}; + /** * @brief Team error event */ diff --git a/src/team/domain/team_events.h b/src/team/domain/team_events.h index 05cf7c28..6e19bbb5 100644 --- a/src/team/domain/team_events.h +++ b/src/team/domain/team_events.h @@ -1,6 +1,7 @@ #pragma once #include "team_types.h" +#include "../protocol/team_chat.h" #include "../protocol/team_mgmt.h" #include #include @@ -40,6 +41,30 @@ struct TeamJoinConfirmEvent team::proto::TeamJoinConfirm msg; }; +struct TeamJoinDecisionEvent +{ + TeamEventContext ctx; + team::proto::TeamJoinDecision msg; +}; + +struct TeamKickEvent +{ + TeamEventContext ctx; + team::proto::TeamKick msg; +}; + +struct TeamTransferLeaderEvent +{ + TeamEventContext ctx; + team::proto::TeamTransferLeader msg; +}; + +struct TeamKeyDistEvent +{ + TeamEventContext ctx; + team::proto::TeamKeyDist msg; +}; + struct TeamStatusEvent { TeamEventContext ctx; @@ -58,6 +83,12 @@ struct TeamWaypointEvent std::vector payload; }; +struct TeamChatEvent +{ + TeamEventContext ctx; + team::proto::TeamChatMessage msg; +}; + enum class TeamProtocolError { DecryptFail, diff --git a/src/team/domain/team_types.h b/src/team/domain/team_types.h index c6ed7454..1e4c5090 100644 --- a/src/team/domain/team_types.h +++ b/src/team/domain/team_types.h @@ -18,6 +18,7 @@ struct TeamKeys std::array mgmt_key{}; std::array pos_key{}; std::array wp_key{}; + std::array chat_key{}; bool valid = false; }; diff --git a/src/team/infra/event/team_event_bus_sink.cpp b/src/team/infra/event/team_event_bus_sink.cpp index 55fec37c..d779bb37 100644 --- a/src/team/infra/event/team_event_bus_sink.cpp +++ b/src/team/infra/event/team_event_bus_sink.cpp @@ -25,6 +25,26 @@ void TeamEventBusSink::onTeamJoinConfirm(const TeamJoinConfirmEvent& event) sys::EventBus::publish(new sys::TeamJoinConfirmEvent(event), 0); } +void TeamEventBusSink::onTeamJoinDecision(const TeamJoinDecisionEvent& event) +{ + sys::EventBus::publish(new sys::TeamJoinDecisionEvent(event), 0); +} + +void TeamEventBusSink::onTeamKick(const TeamKickEvent& event) +{ + sys::EventBus::publish(new sys::TeamKickEvent(event), 0); +} + +void TeamEventBusSink::onTeamTransferLeader(const TeamTransferLeaderEvent& event) +{ + sys::EventBus::publish(new sys::TeamTransferLeaderEvent(event), 0); +} + +void TeamEventBusSink::onTeamKeyDist(const TeamKeyDistEvent& event) +{ + sys::EventBus::publish(new sys::TeamKeyDistEvent(event), 0); +} + void TeamEventBusSink::onTeamStatus(const TeamStatusEvent& event) { sys::EventBus::publish(new sys::TeamStatusEvent(event), 0); @@ -40,6 +60,11 @@ void TeamEventBusSink::onTeamWaypoint(const TeamWaypointEvent& event) sys::EventBus::publish(new sys::TeamWaypointEvent(event), 0); } +void TeamEventBusSink::onTeamChat(const TeamChatEvent& event) +{ + sys::EventBus::publish(new sys::TeamChatEvent(event), 0); +} + void TeamEventBusSink::onTeamError(const TeamErrorEvent& event) { sys::EventBus::publish(new sys::TeamErrorEvent(event), 0); diff --git a/src/team/infra/event/team_event_bus_sink.h b/src/team/infra/event/team_event_bus_sink.h index d8bebc02..a0f000e8 100644 --- a/src/team/infra/event/team_event_bus_sink.h +++ b/src/team/infra/event/team_event_bus_sink.h @@ -12,9 +12,14 @@ class TeamEventBusSink : public team::ITeamEventSink void onTeamJoinRequest(const TeamJoinRequestEvent& event) override; void onTeamJoinAccept(const TeamJoinAcceptEvent& event) override; void onTeamJoinConfirm(const TeamJoinConfirmEvent& event) override; + void onTeamJoinDecision(const TeamJoinDecisionEvent& event) override; + void onTeamKick(const TeamKickEvent& event) override; + void onTeamTransferLeader(const TeamTransferLeaderEvent& event) override; + void onTeamKeyDist(const TeamKeyDistEvent& event) override; void onTeamStatus(const TeamStatusEvent& event) override; void onTeamPosition(const TeamPositionEvent& event) override; void onTeamWaypoint(const TeamWaypointEvent& event) override; + void onTeamChat(const TeamChatEvent& event) override; void onTeamError(const TeamErrorEvent& event) override; }; diff --git a/src/team/infra/nfc/team_nfc.cpp b/src/team/infra/nfc/team_nfc.cpp new file mode 100644 index 00000000..b6caebc0 --- /dev/null +++ b/src/team/infra/nfc/team_nfc.cpp @@ -0,0 +1,1078 @@ +/** + * @file team_nfc.cpp + * @brief NFC payload + key exchange helpers (Invite Code protected) + */ + +#include "team_nfc.h" + +#include +#include +#include +#include +#include + +#ifdef USING_ST25R3916 +#include "board/TLoRaPagerBoard.h" +#include "board/nfc_include.h" +#endif + +namespace team::nfc +{ +namespace +{ +constexpr uint8_t kMagic[4] = { 'T', 'N', 'F', '1' }; +constexpr size_t kDerivedKeyLen = 16; +constexpr uint32_t kPbkdf2Iterations = 10000; +constexpr size_t kHeaderSize = 4 + 1 + team::proto::kTeamIdSize + 4 + 4 + kNfcSaltSize + kNfcNonceSize; +constexpr char kMimeType[] = "application/vnd.trailmate.teamkey"; +constexpr uint8_t kT4tCcFileId[2] = { 0xE1, 0x03 }; +constexpr uint8_t kT4tNdefFileId[2] = { 0xE1, 0x04 }; +constexpr size_t kT4tCcFileLen = 15; + +#ifndef TEAM_NFC_LOG_ENABLE +#define TEAM_NFC_LOG_ENABLE 1 +#endif + +#ifndef TEAM_NFC_LOG_SENSITIVE +#define TEAM_NFC_LOG_SENSITIVE 1 +#endif + +#if TEAM_NFC_LOG_ENABLE +#define TEAM_NFC_LOG(...) Serial.printf(__VA_ARGS__) +#else +#define TEAM_NFC_LOG(...) +#endif + +void log_hex(const char* label, const uint8_t* data, size_t len) +{ +#if TEAM_NFC_LOG_ENABLE + if (!label) + { + label = ""; + } + TEAM_NFC_LOG("[NFC] %s (%u): ", label, static_cast(len)); + for (size_t i = 0; i < len; ++i) + { + TEAM_NFC_LOG("%02X", data ? data[i] : 0); + } + TEAM_NFC_LOG("\n"); +#else + (void)label; + (void)data; + (void)len; +#endif +} + +void write_u32_le(std::vector& out, uint32_t v) +{ + out.push_back(static_cast(v & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + out.push_back(static_cast((v >> 16) & 0xFF)); + out.push_back(static_cast((v >> 24) & 0xFF)); +} + +bool read_u32_le(const uint8_t* data, size_t len, size_t& offset, uint32_t& out) +{ + if (!data || offset + 4 > len) + { + return false; + } + out = static_cast(data[offset]) | + (static_cast(data[offset + 1]) << 8) | + (static_cast(data[offset + 2]) << 16) | + (static_cast(data[offset + 3]) << 24); + offset += 4; + return true; +} + +void fill_random(uint8_t* out, size_t len) +{ + for (size_t i = 0; i < len; ++i) + { + out[i] = static_cast(random(0, 256)); + } +} + +void hmac_sha256(const uint8_t* key, size_t key_len, + const uint8_t* data, size_t data_len, + uint8_t out[32]) +{ + uint8_t key_block[64]; + memset(key_block, 0, sizeof(key_block)); + if (key_len > sizeof(key_block)) + { + SHA256 hash; + hash.reset(); + hash.update(key, key_len); + hash.finalize(key_block, sizeof(key_block)); + } + else + { + memcpy(key_block, key, key_len); + } + + uint8_t o_key_pad[64]; + uint8_t i_key_pad[64]; + for (size_t i = 0; i < sizeof(key_block); ++i) + { + o_key_pad[i] = static_cast(key_block[i] ^ 0x5c); + i_key_pad[i] = static_cast(key_block[i] ^ 0x36); + } + + uint8_t inner[32]; + SHA256 hash; + hash.reset(); + hash.update(i_key_pad, sizeof(i_key_pad)); + hash.update(data, data_len); + hash.finalize(inner, sizeof(inner)); + + hash.reset(); + hash.update(o_key_pad, sizeof(o_key_pad)); + hash.update(inner, sizeof(inner)); + hash.finalize(out, 32); +} + +bool pbkdf2_hmac_sha256(const uint8_t* password, size_t password_len, + const uint8_t* salt, size_t salt_len, + uint32_t iterations, + uint8_t* out, size_t out_len) +{ + if (!password || !salt || !out || out_len == 0 || out_len > 32 || iterations == 0) + { + return false; + } + + uint8_t block[32]; + uint8_t u[32]; + uint8_t salt_block[64]; + if (salt_len + 4 > sizeof(salt_block)) + { + return false; + } + + memcpy(salt_block, salt, salt_len); + salt_block[salt_len + 0] = 0; + salt_block[salt_len + 1] = 0; + salt_block[salt_len + 2] = 0; + salt_block[salt_len + 3] = 1; + + hmac_sha256(password, password_len, salt_block, salt_len + 4, u); + memcpy(block, u, sizeof(block)); + + for (uint32_t i = 1; i < iterations; ++i) + { + hmac_sha256(password, password_len, u, sizeof(u), u); + for (size_t j = 0; j < sizeof(block); ++j) + { + block[j] ^= u[j]; + } + } + + memcpy(out, block, out_len); + return true; +} + +bool aes_gcm_encrypt(const uint8_t* key, size_t key_len, + const uint8_t* nonce, size_t nonce_len, + const uint8_t* aad, size_t aad_len, + const uint8_t* plain, size_t plain_len, + uint8_t* out_cipher, + uint8_t* out_tag, size_t tag_len) +{ + if (!key || !nonce || !plain || !out_cipher || !out_tag) + { + return false; + } + + GCM gcm; + if (!gcm.setKey(key, key_len)) + { + return false; + } + if (!gcm.setIV(nonce, nonce_len)) + { + return false; + } + if (aad && aad_len > 0) + { + gcm.addAuthData(aad, aad_len); + } + if (plain_len > 0) + { + gcm.encrypt(out_cipher, plain, plain_len); + } + gcm.computeTag(out_tag, tag_len); + return true; +} + +bool aes_gcm_decrypt(const uint8_t* key, size_t key_len, + const uint8_t* nonce, size_t nonce_len, + const uint8_t* aad, size_t aad_len, + const uint8_t* cipher, size_t cipher_len, + const uint8_t* tag, size_t tag_len, + uint8_t* out_plain) +{ + if (!key || !nonce || !cipher || !tag || !out_plain) + { + return false; + } + + GCM gcm; + if (!gcm.setKey(key, key_len)) + { + return false; + } + if (!gcm.setIV(nonce, nonce_len)) + { + return false; + } + if (aad && aad_len > 0) + { + gcm.addAuthData(aad, aad_len); + } + if (cipher_len > 0) + { + gcm.decrypt(out_plain, cipher, cipher_len); + } + return gcm.checkTag(tag, tag_len); +} + +void build_aad(const Payload& payload, std::vector& out) +{ + out.clear(); + out.reserve(kHeaderSize); + out.insert(out.end(), kMagic, kMagic + sizeof(kMagic)); + out.push_back(kNfcPayloadVersion); + out.insert(out.end(), payload.team_id.begin(), payload.team_id.end()); + write_u32_le(out, payload.key_id); + write_u32_le(out, payload.expires_at); + out.insert(out.end(), payload.salt.begin(), payload.salt.end()); + out.insert(out.end(), payload.nonce.begin(), payload.nonce.end()); +} + +bool nfc_available() +{ +#ifdef USING_ST25R3916 + TLoRaPagerBoard* board = TLoRaPagerBoard::getInstance(); + return board && board->isNFCReady() && board->nfc; +#else + return false; +#endif +} + +bool write_ndef_message(const std::vector& payload) +{ +#ifdef USING_ST25R3916 + TEAM_NFC_LOG("[NFC] write_ndef_message payload_len=%u\n", static_cast(payload.size())); + if (!nfc_available()) + { + TEAM_NFC_LOG("[NFC] write_ndef_message nfc_not_available\n"); + return false; + } + + TLoRaPagerBoard* board = TLoRaPagerBoard::getInstance(); + rfalNfcDevice* dev = nullptr; + if (board->nfc->rfalNfcGetActiveDevice(&dev) != ERR_NONE || !dev) + { + TEAM_NFC_LOG("[NFC] write_ndef_message no_active_device\n"); + return false; + } + + ndefConstBuffer8 type_buf{ reinterpret_cast(kMimeType), + static_cast(sizeof(kMimeType) - 1) }; + ndefConstBuffer payload_buf{ payload.data(), static_cast(payload.size()) }; + + NdefClass ndef(board->nfc); + if (ndef.ndefPollerContextInitializationWrapper(dev) != ERR_NONE) + { + TEAM_NFC_LOG("[NFC] write_ndef_message ctx_init_failed\n"); + return false; + } + if (ndef.ndefPollerNdefDetectWrapper(nullptr) != ERR_NONE) + { + TEAM_NFC_LOG("[NFC] write_ndef_message ndef_detect_failed\n"); + return false; + } + ndefMessage message{}; + ndefMessageInit(&message); + + ndefRecord record{}; + if (ndefRecordInit(&record, NDEF_TNF_MEDIA_TYPE, &type_buf, nullptr, &payload_buf) != ERR_NONE) + { + TEAM_NFC_LOG("[NFC] write_ndef_message record_init_failed\n"); + return false; + } + if (ndefMessageAppend(&message, &record) != ERR_NONE) + { + TEAM_NFC_LOG("[NFC] write_ndef_message message_append_failed\n"); + return false; + } + + uint8_t raw_buf[256]; + ndefBuffer raw{ raw_buf, sizeof(raw_buf) }; + if (ndefMessageEncode(&message, &raw) != ERR_NONE) + { + TEAM_NFC_LOG("[NFC] write_ndef_message encode_failed\n"); + return false; + } + + // NOTE: This writes to a physical tag (poller mode). Card emulation still needs + // a listen-mode responder; see TODO in start_share(). + bool ok = (ndef.ndefPollerWriteRawMessageWrapper(raw.buffer, raw.length) == ERR_NONE); + TEAM_NFC_LOG("[NFC] write_ndef_message write_raw %s\n", ok ? "ok" : "fail"); + return ok; +#else + (void)payload; + return false; +#endif +} + +bool read_ndef_message(std::vector& out_payload) +{ +#ifdef USING_ST25R3916 + TEAM_NFC_LOG("[NFC] read_ndef_message start\n"); + if (!nfc_available()) + { + TEAM_NFC_LOG("[NFC] read_ndef_message nfc_not_available\n"); + return false; + } + + TLoRaPagerBoard* board = TLoRaPagerBoard::getInstance(); + rfalNfcDevice* dev = nullptr; + if (board->nfc->rfalNfcGetActiveDevice(&dev) != ERR_NONE || !dev) + { + TEAM_NFC_LOG("[NFC] read_ndef_message no_active_device\n"); + return false; + } + + NdefClass ndef(board->nfc); + if (ndef.ndefPollerContextInitializationWrapper(dev) != ERR_NONE) + { + TEAM_NFC_LOG("[NFC] read_ndef_message ctx_init_failed\n"); + return false; + } + ndefInfo info{}; + if (ndef.ndefPollerNdefDetectWrapper(&info) != ERR_NONE) + { + TEAM_NFC_LOG("[NFC] read_ndef_message ndef_detect_failed\n"); + return false; + } + + uint8_t raw_buf[256]; + uint32_t rcvd_len = 0; + if (ndef.ndefPollerReadRawMessageWrapper(raw_buf, sizeof(raw_buf), &rcvd_len, false) != ERR_NONE || rcvd_len == 0) + { + TEAM_NFC_LOG("[NFC] read_ndef_message read_raw_failed\n"); + return false; + } + TEAM_NFC_LOG("[NFC] read_ndef_message raw_len=%u\n", static_cast(rcvd_len)); + + ndefMessage message{}; + ndefMessageInit(&message); + ndefConstBuffer msg_buf{ raw_buf, rcvd_len }; + if (ndefMessageDecode(&msg_buf, &message) != ERR_NONE) + { + TEAM_NFC_LOG("[NFC] read_ndef_message decode_failed\n"); + return false; + } + + static const uint8_t kMimeType[] = "application/vnd.trailmate.teamkey"; + ndefConstBuffer8 type_buf{ kMimeType, static_cast(sizeof(kMimeType) - 1) }; + + for (ndefRecord* rec = ndefMessageGetFirstRecord(&message); rec; rec = ndefMessageGetNextRecord(rec)) + { + if (!ndefRecordTypeMatch(rec, NDEF_TNF_MEDIA_TYPE, &type_buf)) + { + continue; + } + ndefConstBuffer payload_buf{}; + if (ndefRecordGetPayload(rec, &payload_buf) != ERR_NONE) + { + TEAM_NFC_LOG("[NFC] read_ndef_message payload_parse_failed\n"); + continue; + } + if (payload_buf.buffer && payload_buf.length > 0) + { + out_payload.assign(payload_buf.buffer, payload_buf.buffer + payload_buf.length); + TEAM_NFC_LOG("[NFC] read_ndef_message payload_len=%u\n", static_cast(payload_buf.length)); + return true; + } + } + TEAM_NFC_LOG("[NFC] read_ndef_message no_payload\n"); + return false; +#else + (void)out_payload; + return false; +#endif +} + +bool s_scan_active = false; +bool s_share_active = false; +std::vector s_share_payload; +uint32_t s_scan_deadline_ms = 0; + +enum class T4tFile : uint8_t +{ + None, + Cc, + Ndef +}; + +enum class ShareState : uint8_t +{ + Idle, + WaitingForCmd, + SendingResp +}; + +ShareState s_share_state = ShareState::Idle; +T4tFile s_selected_file = T4tFile::None; +std::vector s_ndef_file; +std::array s_cc_file{}; +std::vector s_share_response; +uint8_t* s_share_rx = nullptr; +uint16_t* s_share_rx_len = nullptr; +rfalNfcState s_last_nfc_state = RFAL_NFC_STATE_NOTINIT; + +void reset_share_exchange() +{ + s_share_state = ShareState::Idle; + s_selected_file = T4tFile::None; + s_share_response.clear(); + s_share_rx = nullptr; + s_share_rx_len = nullptr; + s_last_nfc_state = RFAL_NFC_STATE_NOTINIT; +} + +bool build_t4t_files(const std::vector& payload) +{ + if (payload.empty()) + { + TEAM_NFC_LOG("[NFC] build_t4t_files empty_payload\n"); + return false; + } + const size_t type_len = strlen(kMimeType); + if (type_len > 255 || payload.size() > 255) + { + TEAM_NFC_LOG("[NFC] build_t4t_files oversized type_len=%u payload_len=%u\n", + static_cast(type_len), static_cast(payload.size())); + return false; + } + + const size_t msg_len = 1 + 1 + 1 + type_len + payload.size(); + if (msg_len > 0xFFFF) + { + TEAM_NFC_LOG("[NFC] build_t4t_files msg_len_overflow=%u\n", static_cast(msg_len)); + return false; + } + + s_ndef_file.clear(); + s_ndef_file.reserve(2 + msg_len); + s_ndef_file.push_back(static_cast((msg_len >> 8) & 0xFF)); + s_ndef_file.push_back(static_cast(msg_len & 0xFF)); + s_ndef_file.push_back(0xD2); // MB=1, ME=1, SR=1, TNF=0x02 (MIME) + s_ndef_file.push_back(static_cast(type_len)); + s_ndef_file.push_back(static_cast(payload.size())); + s_ndef_file.insert(s_ndef_file.end(), kMimeType, kMimeType + type_len); + s_ndef_file.insert(s_ndef_file.end(), payload.begin(), payload.end()); + + const uint16_t ndef_file_size = static_cast(s_ndef_file.size()); + s_cc_file = { 0x00, 0x0F, 0x20, 0x00, 0xFF, 0x00, 0xFF, + 0x04, 0x06, kT4tNdefFileId[0], kT4tNdefFileId[1], + static_cast((ndef_file_size >> 8) & 0xFF), + static_cast(ndef_file_size & 0xFF), + 0x00, 0xFF }; + TEAM_NFC_LOG("[NFC] build_t4t_files ok ndef_file_size=%u\n", + static_cast(ndef_file_size)); + log_hex("cc_file", s_cc_file.data(), s_cc_file.size()); + return true; +} + +void append_status(std::vector& out, uint16_t status) +{ + out.push_back(static_cast((status >> 8) & 0xFF)); + out.push_back(static_cast(status & 0xFF)); +} + +void set_status(std::vector& out, uint16_t status) +{ + out.clear(); + append_status(out, status); +} + +bool select_file_by_id(const uint8_t* data, size_t len) +{ + if (!data || len != 2) + { + return false; + } + if (data[0] == kT4tCcFileId[0] && data[1] == kT4tCcFileId[1]) + { + s_selected_file = T4tFile::Cc; + return true; + } + if (data[0] == kT4tNdefFileId[0] && data[1] == kT4tNdefFileId[1]) + { + s_selected_file = T4tFile::Ndef; + return true; + } + return false; +} + +void handle_apdu(const uint8_t* apdu, size_t len, std::vector& response) +{ + response.clear(); + if (!apdu || len < 4) + { + TEAM_NFC_LOG("[NFC] apdu invalid len=%u\n", static_cast(len)); + set_status(response, 0x6700); + return; + } + + const uint8_t ins = apdu[1]; + const uint8_t p1 = apdu[2]; + const uint8_t p2 = apdu[3]; + TEAM_NFC_LOG("[NFC] apdu ins=0x%02X p1=0x%02X p2=0x%02X len=%u\n", + ins, p1, p2, static_cast(len)); + + if (ins == 0xA4) + { + if (len < 5) + { + set_status(response, 0x6700); + return; + } + const uint8_t lc = apdu[4]; + if (len < static_cast(5 + lc)) + { + set_status(response, 0x6700); + return; + } + const uint8_t* data = apdu + 5; + if (p1 == 0x04) + { + static const uint8_t kAidV2[] = { 0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01 }; + static const uint8_t kAidV1[] = { 0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x00 }; + bool match_v2 = (lc == sizeof(kAidV2)) && (memcmp(data, kAidV2, sizeof(kAidV2)) == 0); + bool match_v1 = (lc == sizeof(kAidV1)) && (memcmp(data, kAidV1, sizeof(kAidV1)) == 0); + if (!match_v2 && !match_v1) + { + TEAM_NFC_LOG("[NFC] apdu select AID not_found lc=%u\n", static_cast(lc)); + set_status(response, 0x6A82); + return; + } + s_selected_file = T4tFile::None; + if (p2 == 0x00) + { + const uint8_t* aid = match_v2 ? kAidV2 : kAidV1; + response.push_back(0x6F); + response.push_back(static_cast(2 + sizeof(kAidV2))); + response.push_back(0x84); + response.push_back(static_cast(sizeof(kAidV2))); + response.insert(response.end(), aid, aid + sizeof(kAidV2)); + } + append_status(response, 0x9000); + TEAM_NFC_LOG("[NFC] apdu select AID ok\n"); + return; + } + if (p1 == 0x00) + { + if (lc != 2) + { + TEAM_NFC_LOG("[NFC] apdu select file bad_lc=%u\n", static_cast(lc)); + set_status(response, 0x6700); + return; + } + if (!select_file_by_id(data, lc)) + { + TEAM_NFC_LOG("[NFC] apdu select file not_found\n"); + set_status(response, 0x6A82); + return; + } + append_status(response, 0x9000); + TEAM_NFC_LOG("[NFC] apdu select file ok\n"); + return; + } + + set_status(response, 0x6A86); + return; + } + + if (ins == 0xB0) + { + if (len < 5) + { + set_status(response, 0x6700); + return; + } + const uint16_t offset = (static_cast(p1) << 8) | p2; + uint16_t le = apdu[4]; + if (le == 0) + { + le = 256; + } + + const uint8_t* file_data = nullptr; + size_t file_len = 0; + if (s_selected_file == T4tFile::Cc) + { + file_data = s_cc_file.data(); + file_len = s_cc_file.size(); + } + else if (s_selected_file == T4tFile::Ndef) + { + file_data = s_ndef_file.data(); + file_len = s_ndef_file.size(); + } + else + { + TEAM_NFC_LOG("[NFC] apdu read no_file_selected\n"); + set_status(response, 0x6985); + return; + } + + if (offset >= file_len) + { + TEAM_NFC_LOG("[NFC] apdu read offset_oob offset=%u file_len=%u\n", + static_cast(offset), static_cast(file_len)); + set_status(response, 0x6B00); + return; + } + const size_t remaining = file_len - offset; + const size_t to_copy = (static_cast(le) < remaining) ? static_cast(le) : remaining; + response.insert(response.end(), file_data + offset, file_data + offset + to_copy); + append_status(response, 0x9000); + TEAM_NFC_LOG("[NFC] apdu read offset=%u le=%u copied=%u\n", + static_cast(offset), static_cast(le), static_cast(to_copy)); + return; + } + + if (ins == 0xD6) + { + TEAM_NFC_LOG("[NFC] apdu update rejected (read-only)\n"); + set_status(response, 0x6982); + return; + } + + TEAM_NFC_LOG("[NFC] apdu unsupported ins=0x%02X\n", ins); + set_status(response, 0x6D00); +} +} // namespace + +bool encode_payload(const Payload& payload, std::vector& out) +{ + TEAM_NFC_LOG("[NFC] encode_payload team_id_len=%u key_id=%u expires_at=%u\n", + static_cast(payload.team_id.size()), + payload.key_id, + payload.expires_at); + log_hex("team_id", payload.team_id.data(), payload.team_id.size()); + log_hex("salt", payload.salt.data(), payload.salt.size()); + log_hex("nonce", payload.nonce.data(), payload.nonce.size()); + log_hex("cipher", payload.cipher.data(), payload.cipher.size()); + log_hex("tag", payload.tag.data(), payload.tag.size()); + + out.clear(); + out.reserve(kHeaderSize + team::proto::kTeamChannelPskSize + kNfcTagSize); + out.insert(out.end(), kMagic, kMagic + sizeof(kMagic)); + out.push_back(kNfcPayloadVersion); + out.insert(out.end(), payload.team_id.begin(), payload.team_id.end()); + write_u32_le(out, payload.key_id); + write_u32_le(out, payload.expires_at); + out.insert(out.end(), payload.salt.begin(), payload.salt.end()); + out.insert(out.end(), payload.nonce.begin(), payload.nonce.end()); + out.insert(out.end(), payload.cipher.begin(), payload.cipher.end()); + out.insert(out.end(), payload.tag.begin(), payload.tag.end()); + return true; +} + +bool decode_payload(const uint8_t* data, size_t len, Payload* out) +{ + TEAM_NFC_LOG("[NFC] decode_payload len=%u\n", static_cast(len)); + if (!data || !out) + { + TEAM_NFC_LOG("[NFC] decode_payload invalid_args\n"); + return false; + } + const size_t expected = kHeaderSize + team::proto::kTeamChannelPskSize + kNfcTagSize; + if (len < expected) + { + TEAM_NFC_LOG("[NFC] decode_payload too_short expected=%u\n", static_cast(expected)); + return false; + } + if (memcmp(data, kMagic, sizeof(kMagic)) != 0) + { + TEAM_NFC_LOG("[NFC] decode_payload bad_magic\n"); + return false; + } + size_t offset = sizeof(kMagic); + uint8_t version = data[offset++]; + if (version != kNfcPayloadVersion) + { + TEAM_NFC_LOG("[NFC] decode_payload bad_version=%u\n", static_cast(version)); + return false; + } + if (offset + team::proto::kTeamIdSize > len) + { + return false; + } + memcpy(out->team_id.data(), data + offset, team::proto::kTeamIdSize); + offset += team::proto::kTeamIdSize; + if (!read_u32_le(data, len, offset, out->key_id)) + { + TEAM_NFC_LOG("[NFC] decode_payload key_id_failed\n"); + return false; + } + if (!read_u32_le(data, len, offset, out->expires_at)) + { + TEAM_NFC_LOG("[NFC] decode_payload expires_failed\n"); + return false; + } + if (offset + kNfcSaltSize + kNfcNonceSize + team::proto::kTeamChannelPskSize + kNfcTagSize > len) + { + return false; + } + memcpy(out->salt.data(), data + offset, kNfcSaltSize); + offset += kNfcSaltSize; + memcpy(out->nonce.data(), data + offset, kNfcNonceSize); + offset += kNfcNonceSize; + memcpy(out->cipher.data(), data + offset, team::proto::kTeamChannelPskSize); + offset += team::proto::kTeamChannelPskSize; + memcpy(out->tag.data(), data + offset, kNfcTagSize); + TEAM_NFC_LOG("[NFC] decode_payload ok key_id=%u expires_at=%u\n", out->key_id, out->expires_at); + log_hex("team_id", out->team_id.data(), out->team_id.size()); + log_hex("salt", out->salt.data(), out->salt.size()); + log_hex("nonce", out->nonce.data(), out->nonce.size()); + log_hex("cipher", out->cipher.data(), out->cipher.size()); + log_hex("tag", out->tag.data(), out->tag.size()); + return true; +} + +bool build_payload(const TeamId& team_id, + uint32_t key_id, + uint32_t expires_at, + const uint8_t* psk, + size_t psk_len, + const std::string& invite_code, + std::vector& out) +{ + TEAM_NFC_LOG("[NFC] build_payload key_id=%u expires_at=%u psk_len=%u\n", + key_id, expires_at, static_cast(psk_len)); +#if TEAM_NFC_LOG_SENSITIVE + TEAM_NFC_LOG("[NFC] build_payload invite_code=%s\n", invite_code.c_str()); + log_hex("psk", psk, psk_len); +#endif + if (!psk || psk_len != team::proto::kTeamChannelPskSize || invite_code.empty()) + { + TEAM_NFC_LOG("[NFC] build_payload invalid_args\n"); + return false; + } + + Payload payload; + payload.team_id = team_id; + payload.key_id = key_id; + payload.expires_at = expires_at; + fill_random(payload.salt.data(), payload.salt.size()); + fill_random(payload.nonce.data(), payload.nonce.size()); + + uint8_t key[kDerivedKeyLen]; + if (!pbkdf2_hmac_sha256(reinterpret_cast(invite_code.data()), + invite_code.size(), + payload.salt.data(), + payload.salt.size(), + kPbkdf2Iterations, + key, sizeof(key))) + { + TEAM_NFC_LOG("[NFC] build_payload kdf_failed\n"); + return false; + } +#if TEAM_NFC_LOG_SENSITIVE + log_hex("kdf_key", key, sizeof(key)); +#endif + + std::vector aad; + build_aad(payload, aad); + if (!aes_gcm_encrypt(key, sizeof(key), + payload.nonce.data(), payload.nonce.size(), + aad.data(), aad.size(), + psk, psk_len, + payload.cipher.data(), + payload.tag.data(), payload.tag.size())) + { + TEAM_NFC_LOG("[NFC] build_payload gcm_encrypt_failed\n"); + return false; + } + + return encode_payload(payload, out); +} + +bool decrypt_payload(const Payload& payload, + const std::string& invite_code, + std::array& out_psk) +{ + TEAM_NFC_LOG("[NFC] decrypt_payload key_id=%u expires_at=%u\n", payload.key_id, payload.expires_at); +#if TEAM_NFC_LOG_SENSITIVE + TEAM_NFC_LOG("[NFC] decrypt_payload invite_code=%s\n", invite_code.c_str()); +#endif + if (invite_code.empty()) + { + TEAM_NFC_LOG("[NFC] decrypt_payload empty_invite_code\n"); + return false; + } + uint8_t key[kDerivedKeyLen]; + if (!pbkdf2_hmac_sha256(reinterpret_cast(invite_code.data()), + invite_code.size(), + payload.salt.data(), + payload.salt.size(), + kPbkdf2Iterations, + key, sizeof(key))) + { + TEAM_NFC_LOG("[NFC] decrypt_payload kdf_failed\n"); + return false; + } +#if TEAM_NFC_LOG_SENSITIVE + log_hex("kdf_key", key, sizeof(key)); +#endif + std::vector aad; + build_aad(payload, aad); + bool ok = aes_gcm_decrypt(key, sizeof(key), + payload.nonce.data(), payload.nonce.size(), + aad.data(), aad.size(), + payload.cipher.data(), payload.cipher.size(), + payload.tag.data(), payload.tag.size(), + out_psk.data()); +#if TEAM_NFC_LOG_SENSITIVE + if (ok) + { + log_hex("psk", out_psk.data(), out_psk.size()); + } +#endif + TEAM_NFC_LOG("[NFC] decrypt_payload %s\n", ok ? "ok" : "fail"); + return ok; +} + +bool start_share(const std::vector& payload) +{ + TEAM_NFC_LOG("[NFC] start_share payload_len=%u\n", static_cast(payload.size())); + s_share_payload = payload; + s_share_active = false; + reset_share_exchange(); + if (!build_t4t_files(payload)) + { + TEAM_NFC_LOG("[NFC] start_share build_t4t_files_failed\n"); + return false; + } + +#ifdef USING_ST25R3916 + if (!nfc_available()) + { + TEAM_NFC_LOG("[NFC] start_share nfc_not_available\n"); + return false; + } + + TLoRaPagerBoard* board = TLoRaPagerBoard::getInstance(); + // Start listen mode for NFC-A (card emulation). APDU responses are + // handled in poll_share(). + if (!board->startNFCDiscovery(RFAL_NFC_LISTEN_TECH_A, 60000)) + { + TEAM_NFC_LOG("[NFC] start_share listen_start_failed\n"); + return false; + } + s_share_active = true; + TEAM_NFC_LOG("[NFC] start_share ok\n"); + return true; +#else + (void)payload; + return false; +#endif +} + +void stop_share() +{ + TEAM_NFC_LOG("[NFC] stop_share\n"); + s_share_active = false; + s_share_payload.clear(); + reset_share_exchange(); + s_ndef_file.clear(); +#ifdef USING_ST25R3916 + if (nfc_available()) + { + TLoRaPagerBoard::getInstance()->stopNFCDiscovery(); + } +#endif +} + +void poll_share() +{ +#ifdef USING_ST25R3916 + if (!s_share_active || !nfc_available()) + { + return; + } + + TLoRaPagerBoard* board = TLoRaPagerBoard::getInstance(); + if (!board || !board->nfc) + { + return; + } + + if (board->LilyGoDispArduinoSPI::lock(pdMS_TO_TICKS(2))) + { + board->pollNfcIrq(); + board->nfc->rfalNfcWorker(); + board->LilyGoDispArduinoSPI::unlock(); + } + + rfalNfcState state = board->nfc->rfalNfcGetState(); + if (state != s_last_nfc_state) + { + TEAM_NFC_LOG("[NFC] poll_share state=%d\n", static_cast(state)); + s_last_nfc_state = state; + } + if (state < RFAL_NFC_STATE_ACTIVATED) + { + return; + } + + if (s_share_state == ShareState::Idle) + { + ReturnCode err = board->nfc->rfalNfcDataExchangeStart(nullptr, 0, &s_share_rx, &s_share_rx_len, RFAL_FWT_NONE); + if (err == ERR_NONE) + { + s_share_state = ShareState::WaitingForCmd; + TEAM_NFC_LOG("[NFC] poll_share wait_for_cmd\n"); + } + else + { + TEAM_NFC_LOG("[NFC] poll_share start_wait_failed err=%d\n", err); + } + return; + } + + ReturnCode err = board->nfc->rfalNfcDataExchangeGetStatus(); + if (err == ERR_BUSY) + { + return; + } + if (err == ERR_SLEEP_REQ || err == ERR_LINK_LOSS) + { + TEAM_NFC_LOG("[NFC] poll_share link_sleep err=%d\n", err); + reset_share_exchange(); + return; + } + if (err != ERR_NONE) + { + TEAM_NFC_LOG("[NFC] poll_share exchange_err=%d\n", err); + reset_share_exchange(); + return; + } + + if (s_share_state == ShareState::WaitingForCmd) + { + size_t cmd_len = s_share_rx_len ? static_cast(*s_share_rx_len) : 0; + TEAM_NFC_LOG("[NFC] poll_share cmd_len=%u\n", static_cast(cmd_len)); + if (cmd_len > 0) + { + log_hex("apdu", s_share_rx, cmd_len); + } + handle_apdu(s_share_rx, cmd_len, s_share_response); + err = board->nfc->rfalNfcDataExchangeStart(s_share_response.data(), + static_cast(s_share_response.size()), + &s_share_rx, &s_share_rx_len, + RFAL_FWT_NONE); + if (err == ERR_NONE) + { + s_share_state = ShareState::SendingResp; + TEAM_NFC_LOG("[NFC] poll_share resp_len=%u\n", static_cast(s_share_response.size())); + log_hex("rapdu", s_share_response.data(), s_share_response.size()); + } + else + { + TEAM_NFC_LOG("[NFC] poll_share send_resp_failed err=%d\n", err); + reset_share_exchange(); + } + return; + } + + if (s_share_state == ShareState::SendingResp) + { + TEAM_NFC_LOG("[NFC] poll_share resp_sent\n"); + s_share_state = ShareState::Idle; + } +#else + (void)0; +#endif +} + +bool start_scan(uint16_t duration_ms) +{ + s_scan_active = false; + s_scan_deadline_ms = 0; + +#ifdef USING_ST25R3916 + TEAM_NFC_LOG("[NFC] start_scan duration_ms=%u\n", static_cast(duration_ms)); + if (!nfc_available()) + { + TEAM_NFC_LOG("[NFC] start_scan nfc_not_available\n"); + return false; + } + TLoRaPagerBoard* board = TLoRaPagerBoard::getInstance(); + if (!board->startNFCDiscovery(RFAL_NFC_POLL_TECH_A, duration_ms)) + { + TEAM_NFC_LOG("[NFC] start_scan discovery_failed\n"); + return false; + } + s_scan_active = true; + s_scan_deadline_ms = millis() + duration_ms; + TEAM_NFC_LOG("[NFC] start_scan ok deadline_ms=%u\n", static_cast(s_scan_deadline_ms)); + return true; +#else + (void)duration_ms; + return false; +#endif +} + +void stop_scan() +{ + TEAM_NFC_LOG("[NFC] stop_scan\n"); + s_scan_active = false; + s_scan_deadline_ms = 0; +#ifdef USING_ST25R3916 + if (nfc_available()) + { + TLoRaPagerBoard::getInstance()->stopNFCDiscovery(); + } +#endif +} + +bool poll_scan(std::vector& out_payload) +{ + if (!s_scan_active) + { + return false; + } + if (s_scan_deadline_ms != 0 && millis() > s_scan_deadline_ms) + { + TEAM_NFC_LOG("[NFC] poll_scan deadline_reached\n"); + stop_scan(); + return false; + } + if (read_ndef_message(out_payload)) + { + TEAM_NFC_LOG("[NFC] poll_scan payload_len=%u\n", static_cast(out_payload.size())); + stop_scan(); + return true; + } + return false; +} + +bool is_scan_active() +{ + return s_scan_active; +} + +bool is_share_active() +{ + return s_share_active; +} + +} // namespace team::nfc diff --git a/src/team/infra/nfc/team_nfc.h b/src/team/infra/nfc/team_nfc.h new file mode 100644 index 00000000..c28bb504 --- /dev/null +++ b/src/team/infra/nfc/team_nfc.h @@ -0,0 +1,59 @@ +/** + * @file team_nfc.h + * @brief NFC payload + key exchange helpers (Invite Code protected) + */ + +#pragma once + +#include "../../domain/team_types.h" +#include "../../protocol/team_mgmt.h" +#include +#include +#include +#include +#include + +namespace team::nfc +{ + +constexpr uint8_t kNfcPayloadVersion = 1; +constexpr size_t kNfcSaltSize = 8; +constexpr size_t kNfcNonceSize = 12; +constexpr size_t kNfcTagSize = 16; + +struct Payload +{ + TeamId team_id{}; + uint32_t key_id = 0; + uint32_t expires_at = 0; + std::array salt{}; + std::array nonce{}; + std::array cipher{}; + std::array tag{}; +}; + +bool encode_payload(const Payload& payload, std::vector& out); +bool decode_payload(const uint8_t* data, size_t len, Payload* out); + +bool build_payload(const TeamId& team_id, + uint32_t key_id, + uint32_t expires_at, + const uint8_t* psk, + size_t psk_len, + const std::string& invite_code, + std::vector& out); + +bool decrypt_payload(const Payload& payload, + const std::string& invite_code, + std::array& out_psk); + +bool start_share(const std::vector& payload); +void stop_share(); +void poll_share(); +bool start_scan(uint16_t duration_ms); +void stop_scan(); +bool poll_scan(std::vector& out_payload); +bool is_scan_active(); +bool is_share_active(); + +} // namespace team::nfc diff --git a/src/team/ports/i_team_event_sink.h b/src/team/ports/i_team_event_sink.h index 4edf5881..4d94f37d 100644 --- a/src/team/ports/i_team_event_sink.h +++ b/src/team/ports/i_team_event_sink.h @@ -14,9 +14,14 @@ class ITeamEventSink virtual void onTeamJoinRequest(const TeamJoinRequestEvent& event) = 0; virtual void onTeamJoinAccept(const TeamJoinAcceptEvent& event) = 0; virtual void onTeamJoinConfirm(const TeamJoinConfirmEvent& event) = 0; + virtual void onTeamJoinDecision(const TeamJoinDecisionEvent& event) = 0; + virtual void onTeamKick(const TeamKickEvent& event) = 0; + virtual void onTeamTransferLeader(const TeamTransferLeaderEvent& event) = 0; + virtual void onTeamKeyDist(const TeamKeyDistEvent& event) = 0; virtual void onTeamStatus(const TeamStatusEvent& event) = 0; virtual void onTeamPosition(const TeamPositionEvent& event) = 0; virtual void onTeamWaypoint(const TeamWaypointEvent& event) = 0; + virtual void onTeamChat(const TeamChatEvent& event) = 0; virtual void onTeamError(const TeamErrorEvent& event) = 0; }; diff --git a/src/team/protocol/team_chat.cpp b/src/team/protocol/team_chat.cpp new file mode 100644 index 00000000..1d48dcc0 --- /dev/null +++ b/src/team/protocol/team_chat.cpp @@ -0,0 +1,250 @@ +/** + * @file team_chat.cpp + * @brief Team chat protocol encoding/decoding + */ + +#include "team_chat.h" + +namespace team::proto +{ +namespace +{ +void write_u16_le(std::vector& out, uint16_t v) +{ + out.push_back(static_cast(v & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); +} + +void write_u32_le(std::vector& out, uint32_t v) +{ + out.push_back(static_cast(v & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + out.push_back(static_cast((v >> 16) & 0xFF)); + out.push_back(static_cast((v >> 24) & 0xFF)); +} + +void write_i32_le(std::vector& out, int32_t v) +{ + write_u32_le(out, static_cast(v)); +} + +void write_i16_le(std::vector& out, int16_t v) +{ + write_u16_le(out, static_cast(v)); +} + +bool read_u16_le(const uint8_t* data, size_t len, size_t& off, uint16_t& out) +{ + if (off + 2 > len) + { + return false; + } + out = static_cast(data[off]) | + (static_cast(data[off + 1]) << 8); + off += 2; + return true; +} + +bool read_u32_le(const uint8_t* data, size_t len, size_t& off, uint32_t& out) +{ + if (off + 4 > len) + { + return false; + } + out = static_cast(data[off]) | + (static_cast(data[off + 1]) << 8) | + (static_cast(data[off + 2]) << 16) | + (static_cast(data[off + 3]) << 24); + off += 4; + return true; +} + +bool read_i32_le(const uint8_t* data, size_t len, size_t& off, int32_t& out) +{ + uint32_t tmp = 0; + if (!read_u32_le(data, len, off, tmp)) + { + return false; + } + out = static_cast(tmp); + return true; +} + +bool read_i16_le(const uint8_t* data, size_t len, size_t& off, int16_t& out) +{ + uint16_t tmp = 0; + if (!read_u16_le(data, len, off, tmp)) + { + return false; + } + out = static_cast(tmp); + return true; +} +} // namespace + +bool encodeTeamChatMessage(const TeamChatMessage& msg, std::vector& out) +{ + out.clear(); + out.reserve(1 + 1 + 2 + 4 + 4 + 4 + msg.payload.size()); + out.push_back(msg.header.version); + out.push_back(static_cast(msg.header.type)); + write_u16_le(out, msg.header.flags); + write_u32_le(out, msg.header.msg_id); + write_u32_le(out, msg.header.ts); + write_u32_le(out, msg.header.from); + out.insert(out.end(), msg.payload.begin(), msg.payload.end()); + return true; +} + +bool decodeTeamChatMessage(const uint8_t* data, size_t len, TeamChatMessage* out) +{ + if (!data || !out || len < 1 + 1 + 2 + 4 + 4 + 4) + { + return false; + } + size_t off = 0; + TeamChatHeader header{}; + header.version = data[off++]; + header.type = static_cast(data[off++]); + if (!read_u16_le(data, len, off, header.flags)) + { + return false; + } + if (!read_u32_le(data, len, off, header.msg_id)) + { + return false; + } + if (!read_u32_le(data, len, off, header.ts)) + { + return false; + } + if (!read_u32_le(data, len, off, header.from)) + { + return false; + } + out->header = header; + out->payload.assign(data + off, data + len); + return true; +} + +bool encodeTeamChatLocation(const TeamChatLocation& loc, std::vector& out) +{ + out.clear(); + out.reserve(4 + 4 + 2 + 2 + 4 + 1 + 2 + loc.label.size()); + write_i32_le(out, loc.lat_e7); + write_i32_le(out, loc.lon_e7); + write_i16_le(out, loc.alt_m); + write_u16_le(out, loc.acc_m); + write_u32_le(out, loc.ts); + out.push_back(loc.source); + uint16_t label_len = static_cast(loc.label.size()); + write_u16_le(out, label_len); + if (label_len > 0) + { + out.insert(out.end(), loc.label.begin(), loc.label.end()); + } + return true; +} + +bool decodeTeamChatLocation(const uint8_t* data, size_t len, TeamChatLocation* out) +{ + if (!data || !out) + { + return false; + } + size_t off = 0; + if (!read_i32_le(data, len, off, out->lat_e7)) + { + return false; + } + if (!read_i32_le(data, len, off, out->lon_e7)) + { + return false; + } + if (!read_i16_le(data, len, off, out->alt_m)) + { + return false; + } + if (!read_u16_le(data, len, off, out->acc_m)) + { + return false; + } + if (!read_u32_le(data, len, off, out->ts)) + { + return false; + } + if (off + 1 > len) + { + return false; + } + out->source = data[off++]; + uint16_t label_len = 0; + if (!read_u16_le(data, len, off, label_len)) + { + return false; + } + if (off + label_len > len) + { + return false; + } + out->label.assign(reinterpret_cast(data + off), label_len); + return true; +} + +bool encodeTeamChatCommand(const TeamChatCommand& cmd, std::vector& out) +{ + out.clear(); + out.reserve(1 + 4 + 4 + 2 + 1 + 2 + cmd.note.size()); + out.push_back(static_cast(cmd.cmd_type)); + write_i32_le(out, cmd.lat_e7); + write_i32_le(out, cmd.lon_e7); + write_u16_le(out, cmd.radius_m); + out.push_back(cmd.priority); + uint16_t note_len = static_cast(cmd.note.size()); + write_u16_le(out, note_len); + if (note_len > 0) + { + out.insert(out.end(), cmd.note.begin(), cmd.note.end()); + } + return true; +} + +bool decodeTeamChatCommand(const uint8_t* data, size_t len, TeamChatCommand* out) +{ + if (!data || !out || len < 1) + { + return false; + } + size_t off = 0; + out->cmd_type = static_cast(data[off++]); + if (!read_i32_le(data, len, off, out->lat_e7)) + { + return false; + } + if (!read_i32_le(data, len, off, out->lon_e7)) + { + return false; + } + if (!read_u16_le(data, len, off, out->radius_m)) + { + return false; + } + if (off + 1 > len) + { + return false; + } + out->priority = data[off++]; + uint16_t note_len = 0; + if (!read_u16_le(data, len, off, note_len)) + { + return false; + } + if (off + note_len > len) + { + return false; + } + out->note.assign(reinterpret_cast(data + off), note_len); + return true; +} + +} // namespace team::proto diff --git a/src/team/protocol/team_chat.h b/src/team/protocol/team_chat.h new file mode 100644 index 00000000..d7609b8c --- /dev/null +++ b/src/team/protocol/team_chat.h @@ -0,0 +1,77 @@ +/** + * @file team_chat.h + * @brief Team chat protocol payloads + */ + +#pragma once + +#include +#include +#include + +namespace team::proto +{ + +constexpr uint8_t kTeamChatVersion = 1; + +enum class TeamChatType : uint8_t +{ + Text = 1, + Location = 2, + Command = 3 +}; + +struct TeamChatHeader +{ + uint8_t version = kTeamChatVersion; + TeamChatType type = TeamChatType::Text; + uint16_t flags = 0; + uint32_t msg_id = 0; + uint32_t ts = 0; + uint32_t from = 0; +}; + +struct TeamChatMessage +{ + TeamChatHeader header{}; + std::vector payload; +}; + +enum class TeamCommandType : uint8_t +{ + RallyTo = 1, + MoveTo = 2, + Hold = 3 +}; + +struct TeamChatLocation +{ + int32_t lat_e7 = 0; + int32_t lon_e7 = 0; + int16_t alt_m = 0; + uint16_t acc_m = 0; + uint32_t ts = 0; + uint8_t source = 0; + std::string label; +}; + +struct TeamChatCommand +{ + TeamCommandType cmd_type = TeamCommandType::RallyTo; + int32_t lat_e7 = 0; + int32_t lon_e7 = 0; + uint16_t radius_m = 0; + uint8_t priority = 0; + std::string note; +}; + +bool encodeTeamChatMessage(const TeamChatMessage& msg, std::vector& out); +bool decodeTeamChatMessage(const uint8_t* data, size_t len, TeamChatMessage* out); + +bool encodeTeamChatLocation(const TeamChatLocation& loc, std::vector& out); +bool decodeTeamChatLocation(const uint8_t* data, size_t len, TeamChatLocation* out); + +bool encodeTeamChatCommand(const TeamChatCommand& cmd, std::vector& out); +bool decodeTeamChatCommand(const uint8_t* data, size_t len, TeamChatCommand* out); + +} // namespace team::proto diff --git a/src/team/protocol/team_mgmt.cpp b/src/team/protocol/team_mgmt.cpp index b6f76458..fe070a03 100644 --- a/src/team/protocol/team_mgmt.cpp +++ b/src/team/protocol/team_mgmt.cpp @@ -282,8 +282,13 @@ bool encodeTeamJoinAccept(const TeamJoinAccept& input, std::vector& out uint16_t flags = 0; if (input.params.has_params) flags |= 0x01; + if (input.has_team_id) flags |= 0x02; writer.putU16(flags); if (!encodeTeamParams(input.params, writer)) return false; + if (input.has_team_id) + { + writer.putBytes(input.team_id.data(), input.team_id.size()); + } return true; } @@ -305,7 +310,9 @@ bool decodeTeamJoinAccept(const uint8_t* data, size_t len, TeamJoinAccept* out) if (!reader.getU16(&flags)) return false; out->params.has_params = (flags & 0x01) != 0; + out->has_team_id = (flags & 0x02) != 0; if (out->params.has_params && !decodeTeamParams(reader, &out->params)) return false; + if (out->has_team_id && !reader.getBytes(out->team_id.data(), out->team_id.size())) return false; return true; } @@ -346,6 +353,103 @@ bool decodeTeamJoinConfirm(const uint8_t* data, size_t len, TeamJoinConfirm* out return true; } +bool encodeTeamJoinDecision(const TeamJoinDecision& input, std::vector& out) +{ + out.clear(); + ByteWriter writer(out); + writer.putU8(input.accept ? 1 : 0); + uint16_t flags = 0; + if (input.has_reason) flags |= 0x01; + writer.putU16(flags); + if (input.has_reason) writer.putU32(input.reason); + return true; +} + +bool decodeTeamJoinDecision(const uint8_t* data, size_t len, TeamJoinDecision* out) +{ + if (!data || !out) + { + return false; + } + ByteReader reader(data, len); + uint16_t flags = 0; + uint8_t accept = 0; + if (!reader.getU8(&accept)) return false; + out->accept = (accept != 0); + if (!reader.getU16(&flags)) return false; + out->has_reason = (flags & 0x01) != 0; + if (out->has_reason && !reader.getU32(&out->reason)) return false; + return true; +} + +bool encodeTeamKick(const TeamKick& input, std::vector& out) +{ + out.clear(); + ByteWriter writer(out); + writer.putU32(input.target); + return true; +} + +bool decodeTeamKick(const uint8_t* data, size_t len, TeamKick* out) +{ + if (!data || !out) + { + return false; + } + ByteReader reader(data, len); + if (!reader.getU32(&out->target)) return false; + return true; +} + +bool encodeTeamTransferLeader(const TeamTransferLeader& input, std::vector& out) +{ + out.clear(); + ByteWriter writer(out); + writer.putU32(input.target); + return true; +} + +bool decodeTeamTransferLeader(const uint8_t* data, size_t len, TeamTransferLeader* out) +{ + if (!data || !out) + { + return false; + } + ByteReader reader(data, len); + if (!reader.getU32(&out->target)) return false; + return true; +} + +bool encodeTeamKeyDist(const TeamKeyDist& input, std::vector& out) +{ + if (input.channel_psk_len > input.channel_psk.size()) + { + return false; + } + out.clear(); + ByteWriter writer(out); + writer.putBytes(input.team_id.data(), input.team_id.size()); + writer.putU32(input.key_id); + writer.putU8(input.channel_psk_len); + writer.putBytes(input.channel_psk.data(), input.channel_psk_len); + return true; +} + +bool decodeTeamKeyDist(const uint8_t* data, size_t len, TeamKeyDist* out) +{ + if (!data || !out) + { + return false; + } + ByteReader reader(data, len); + if (!reader.getBytes(out->team_id.data(), out->team_id.size())) return false; + if (!reader.getU32(&out->key_id)) return false; + if (!reader.getU8(&out->channel_psk_len)) return false; + if (out->channel_psk_len > out->channel_psk.size()) return false; + if (!reader.getBytes(out->channel_psk.data(), out->channel_psk_len)) return false; + return true; +} + bool encodeTeamStatus(const TeamStatus& input, std::vector& out) { out.clear(); diff --git a/src/team/protocol/team_mgmt.h b/src/team/protocol/team_mgmt.h index 7f9508a1..860aec48 100644 --- a/src/team/protocol/team_mgmt.h +++ b/src/team/protocol/team_mgmt.h @@ -22,7 +22,11 @@ enum class TeamMgmtType : uint8_t Status = 5, Rotate = 6, Leave = 7, - Disband = 8 + Disband = 8, + JoinDecision = 9, + Kick = 10, + TransferLeader = 11, + KeyDist = 12 }; struct TeamParams @@ -58,11 +62,13 @@ struct TeamJoinRequest struct TeamJoinAccept { + std::array team_id{}; uint8_t channel_index = 0; std::array channel_psk{}; uint8_t channel_psk_len = 0; uint32_t key_id = 0; TeamParams params; + bool has_team_id = false; }; struct TeamJoinConfirm @@ -74,6 +80,31 @@ struct TeamJoinConfirm bool has_battery = false; }; +struct TeamJoinDecision +{ + bool accept = false; + uint32_t reason = 0; + bool has_reason = false; +}; + +struct TeamKick +{ + uint32_t target = 0; +}; + +struct TeamTransferLeader +{ + uint32_t target = 0; +}; + +struct TeamKeyDist +{ + std::array team_id{}; + uint32_t key_id = 0; + std::array channel_psk{}; + uint8_t channel_psk_len = 0; +}; + struct TeamStatus { std::array member_list_hash{}; @@ -101,6 +132,18 @@ bool decodeTeamJoinAccept(const uint8_t* data, size_t len, TeamJoinAccept* out); bool encodeTeamJoinConfirm(const TeamJoinConfirm& input, std::vector& out); bool decodeTeamJoinConfirm(const uint8_t* data, size_t len, TeamJoinConfirm* out); +bool encodeTeamJoinDecision(const TeamJoinDecision& input, std::vector& out); +bool decodeTeamJoinDecision(const uint8_t* data, size_t len, TeamJoinDecision* out); + +bool encodeTeamKick(const TeamKick& input, std::vector& out); +bool decodeTeamKick(const uint8_t* data, size_t len, TeamKick* out); + +bool encodeTeamTransferLeader(const TeamTransferLeader& input, std::vector& out); +bool decodeTeamTransferLeader(const uint8_t* data, size_t len, TeamTransferLeader* out); + +bool encodeTeamKeyDist(const TeamKeyDist& input, std::vector& out); +bool decodeTeamKeyDist(const uint8_t* data, size_t len, TeamKeyDist* out); + bool encodeTeamStatus(const TeamStatus& input, std::vector& out); bool decodeTeamStatus(const uint8_t* data, size_t len, TeamStatus* out); diff --git a/src/team/protocol/team_portnum.h b/src/team/protocol/team_portnum.h index 9c3bd073..3a1fe762 100644 --- a/src/team/protocol/team_portnum.h +++ b/src/team/protocol/team_portnum.h @@ -8,5 +8,6 @@ namespace team::proto constexpr uint32_t TEAM_MGMT_APP = 300; constexpr uint32_t TEAM_POSITION_APP = 301; constexpr uint32_t TEAM_WAYPOINT_APP = 302; +constexpr uint32_t TEAM_CHAT_APP = 303; } // namespace team::proto diff --git a/src/team/usecase/team_controller.cpp b/src/team/usecase/team_controller.cpp index 76670910..5e0bfd5c 100644 --- a/src/team/usecase/team_controller.cpp +++ b/src/team/usecase/team_controller.cpp @@ -19,6 +19,12 @@ void TeamController::clearKeys() state_ = TeamUiState::Idle; } +bool TeamController::setKeysFromPsk(const TeamId& team_id, uint32_t key_id, + const uint8_t* psk, size_t psk_len) +{ + return service_.setKeysFromPsk(team_id, key_id, psk, psk_len); +} + bool TeamController::onCreateTeam(const team::proto::TeamAdvertise& advertise, chat::ChannelId channel) { @@ -30,6 +36,13 @@ bool TeamController::onCreateTeam(const team::proto::TeamAdvertise& advertise, return ok; } +bool TeamController::onAdvertise(const team::proto::TeamAdvertise& advertise, + chat::ChannelId channel, chat::NodeId dest) +{ + (void)dest; + return service_.sendAdvertise(advertise, channel); +} + bool TeamController::onJoinTeam(const team::proto::TeamJoinRequest& join_request, chat::ChannelId channel, chat::NodeId dest) { @@ -63,6 +76,75 @@ bool TeamController::onConfirmJoin(const team::proto::TeamJoinConfirm& confirm, return ok; } +bool TeamController::onJoinDecision(const team::proto::TeamJoinDecision& decision, + chat::ChannelId channel, chat::NodeId dest) +{ + return service_.sendJoinDecision(decision, channel, dest); +} + +bool TeamController::onKick(const team::proto::TeamKick& kick, + chat::ChannelId channel, chat::NodeId dest) +{ + return service_.sendKick(kick, channel, dest); +} + +bool TeamController::onTransferLeader(const team::proto::TeamTransferLeader& transfer, + chat::ChannelId channel, chat::NodeId dest) +{ + return service_.sendTransferLeader(transfer, channel, dest); +} + +bool TeamController::onKeyDist(const team::proto::TeamKeyDist& msg, + chat::ChannelId channel, chat::NodeId dest) +{ + return service_.sendKeyDist(msg, channel, dest); +} + +bool TeamController::onKeyDistPlain(const team::proto::TeamKeyDist& msg, + chat::ChannelId channel, chat::NodeId dest) +{ + return service_.sendKeyDistPlain(msg, channel, dest); +} + +bool TeamController::onStatus(const team::proto::TeamStatus& status, + chat::ChannelId channel, chat::NodeId dest) +{ + return service_.sendStatus(status, channel, dest); +} + +bool TeamController::onStatusPlain(const team::proto::TeamStatus& status, + chat::ChannelId channel, chat::NodeId dest) +{ + return service_.sendStatusPlain(status, channel, dest); +} + +bool TeamController::onPosition(const std::vector& payload, + chat::ChannelId channel) +{ + return service_.sendPosition(payload, channel); +} + +bool TeamController::onChat(const team::proto::TeamChatMessage& msg, + chat::ChannelId channel) +{ + return service_.sendChat(msg, channel); +} + +bool TeamController::requestNodeInfo(chat::NodeId dest, bool want_response) +{ + return service_.requestNodeInfo(dest, want_response); +} + +bool TeamController::startPkiVerification(chat::NodeId dest) +{ + return service_.startPkiVerification(dest); +} + +bool TeamController::submitPkiNumber(chat::NodeId dest, uint64_t nonce, uint32_t number) +{ + return service_.submitPkiNumber(dest, nonce, number); +} + void TeamController::resetUiState() { state_ = TeamUiState::Idle; diff --git a/src/team/usecase/team_controller.h b/src/team/usecase/team_controller.h index 6e45fc20..2a828422 100644 --- a/src/team/usecase/team_controller.h +++ b/src/team/usecase/team_controller.h @@ -22,15 +22,41 @@ class TeamController void setKeys(const TeamKeys& keys); void clearKeys(); + bool setKeysFromPsk(const TeamId& team_id, uint32_t key_id, + const uint8_t* psk, size_t psk_len); bool onCreateTeam(const team::proto::TeamAdvertise& advertise, chat::ChannelId channel); + bool onAdvertise(const team::proto::TeamAdvertise& advertise, + chat::ChannelId channel, chat::NodeId dest = 0); bool onJoinTeam(const team::proto::TeamJoinRequest& join_request, chat::ChannelId channel, chat::NodeId dest = 0); bool onAcceptJoin(const team::proto::TeamJoinAccept& accept, chat::ChannelId channel, chat::NodeId dest); bool onConfirmJoin(const team::proto::TeamJoinConfirm& confirm, chat::ChannelId channel, chat::NodeId dest = 0); + bool onJoinDecision(const team::proto::TeamJoinDecision& decision, + chat::ChannelId channel, chat::NodeId dest); + bool onKick(const team::proto::TeamKick& kick, + chat::ChannelId channel, chat::NodeId dest = 0); + bool onTransferLeader(const team::proto::TeamTransferLeader& transfer, + chat::ChannelId channel, chat::NodeId dest = 0); + bool onKeyDist(const team::proto::TeamKeyDist& msg, + chat::ChannelId channel, chat::NodeId dest); + bool onKeyDistPlain(const team::proto::TeamKeyDist& msg, + chat::ChannelId channel, chat::NodeId dest); + bool onStatus(const team::proto::TeamStatus& status, + chat::ChannelId channel, chat::NodeId dest = 0); + bool onStatusPlain(const team::proto::TeamStatus& status, + chat::ChannelId channel, chat::NodeId dest = 0); + bool onPosition(const std::vector& payload, + chat::ChannelId channel); + bool onChat(const team::proto::TeamChatMessage& msg, + chat::ChannelId channel); + bool requestNodeInfo(chat::NodeId dest, bool want_response); + bool startPkiVerification(chat::NodeId dest); + bool submitPkiNumber(chat::NodeId dest, uint64_t nonce, uint32_t number); + TeamService::SendError getLastSendError() const { return service_.getLastSendError(); } TeamUiState getState() const { return state_; } void resetUiState(); diff --git a/src/team/usecase/team_service.cpp b/src/team/usecase/team_service.cpp index 1feb36d1..fd6018a1 100644 --- a/src/team/usecase/team_service.cpp +++ b/src/team/usecase/team_service.cpp @@ -5,12 +5,257 @@ #include "../protocol/team_portnum.h" #include "../protocol/team_wire.h" #include +#include namespace team { namespace { +#define TEAM_LOG_ENABLE 1 +#if TEAM_LOG_ENABLE +#define TEAM_LOG(...) Serial.printf(__VA_ARGS__) +#else +#define TEAM_LOG(...) +#endif + +std::string toHex(const uint8_t* data, size_t len, size_t max_len = 64) +{ + if (!data || len == 0) + { + return {}; + } + size_t capped = (len > max_len) ? max_len : len; + static const char* kHex = "0123456789ABCDEF"; + std::string out; + out.reserve(capped * 2); + for (size_t i = 0; i < capped; ++i) + { + uint8_t b = data[i]; + out.push_back(kHex[b >> 4]); + out.push_back(kHex[b & 0x0F]); + } + if (capped < len) + { + out.append(".."); + } + return out; +} + +template +std::string hexFromArray(const std::array& data) +{ + return toHex(data.data(), data.size(), data.size()); +} + +const char* mgmtTypeName(team::proto::TeamMgmtType type) +{ + switch (type) + { + case team::proto::TeamMgmtType::Advertise: + return "Advertise"; + case team::proto::TeamMgmtType::JoinRequest: + return "JoinRequest"; + case team::proto::TeamMgmtType::JoinAccept: + return "JoinAccept"; + case team::proto::TeamMgmtType::JoinConfirm: + return "JoinConfirm"; + case team::proto::TeamMgmtType::Status: + return "Status"; + case team::proto::TeamMgmtType::Rotate: + return "Rotate"; + case team::proto::TeamMgmtType::Leave: + return "Leave"; + case team::proto::TeamMgmtType::Disband: + return "Disband"; + case team::proto::TeamMgmtType::JoinDecision: + return "JoinDecision"; + case team::proto::TeamMgmtType::Kick: + return "Kick"; + case team::proto::TeamMgmtType::TransferLeader: + return "TransferLeader"; + case team::proto::TeamMgmtType::KeyDist: + return "KeyDist"; + default: + return "Unknown"; + } +} + +const char* teamPortName(uint32_t portnum) +{ + switch (portnum) + { + case team::proto::TEAM_MGMT_APP: + return "TEAM_MGMT"; + case team::proto::TEAM_POSITION_APP: + return "TEAM_POS"; + case team::proto::TEAM_WAYPOINT_APP: + return "TEAM_WP"; + case team::proto::TEAM_CHAT_APP: + return "TEAM_CHAT"; + default: + return "TEAM_OTHER"; + } +} + +const char* teamErrorName(team::TeamProtocolError err) +{ + switch (err) + { + case team::TeamProtocolError::DecryptFail: + return "DecryptFail"; + case team::TeamProtocolError::DecodeFail: + return "DecodeFail"; + case team::TeamProtocolError::KeyMismatch: + return "KeyMismatch"; + case team::TeamProtocolError::UnknownVersion: + return "UnknownVersion"; + default: + return "UnknownError"; + } +} + +void logTeamEncrypted(const char* dir, + const chat::MeshIncomingData& data, + const team::proto::TeamEncrypted& envelope, + const std::vector* plain, + const std::vector* wire, + const char* result) +{ + std::string team_id_hex = hexFromArray(envelope.team_id); + std::string nonce_hex = hexFromArray(envelope.nonce); + std::string cipher_hex = toHex(envelope.ciphertext.data(), + envelope.ciphertext.size(), + envelope.ciphertext.size()); + TEAM_LOG("[TEAM] %s %s %s ver=%u flags=0x%02X key_id=%lu team_id=%s nonce=%s cipher_len=%u cipher_hex=%s\n", + dir, + teamPortName(data.portnum), + result ? result : "result", + envelope.version, + envelope.aad_flags, + static_cast(envelope.key_id), + team_id_hex.c_str(), + nonce_hex.c_str(), + static_cast(envelope.ciphertext.size()), + cipher_hex.c_str()); + if (plain) + { + std::string plain_hex = toHex(plain->data(), plain->size(), plain->size()); + TEAM_LOG("[TEAM] %s %s plain_len=%u plain_hex=%s\n", + dir, + teamPortName(data.portnum), + static_cast(plain->size()), + plain_hex.c_str()); + } + if (wire) + { + std::string wire_hex = toHex(wire->data(), wire->size(), wire->size()); + TEAM_LOG("[TEAM] %s %s wire_len=%u wire_hex=%s\n", + dir, + teamPortName(data.portnum), + static_cast(wire->size()), + wire_hex.c_str()); + } +} + +void logTeamAdvertise(const team::proto::TeamAdvertise& msg, const char* dir) +{ + TEAM_LOG("[TEAM] %s Advertise team_id=%s has_join_hint=%u join_hint=0x%08lX has_channel_index=%u channel_index=%u has_expires_at=%u expires_at=%llu nonce=%llu\n", + dir, + hexFromArray(msg.team_id).c_str(), + msg.has_join_hint ? 1 : 0, + static_cast(msg.join_hint), + msg.has_channel_index ? 1 : 0, + static_cast(msg.channel_index), + msg.has_expires_at ? 1 : 0, + static_cast(msg.expires_at), + static_cast(msg.nonce)); +} + +void logTeamJoinRequest(const team::proto::TeamJoinRequest& msg, const char* dir) +{ + std::string pub_hex = toHex(msg.member_pub.data(), msg.member_pub_len, msg.member_pub_len); + TEAM_LOG("[TEAM] %s JoinRequest team_id=%s has_pub=%u pub_len=%u pub_hex=%s has_cap=%u cap=0x%08lX nonce=%llu\n", + dir, + hexFromArray(msg.team_id).c_str(), + msg.has_member_pub ? 1 : 0, + static_cast(msg.member_pub_len), + pub_hex.c_str(), + msg.has_capabilities ? 1 : 0, + static_cast(msg.capabilities), + static_cast(msg.nonce)); +} + +void logTeamJoinAccept(const team::proto::TeamJoinAccept& msg, const char* dir) +{ + std::string psk_hex = toHex(msg.channel_psk.data(), msg.channel_psk_len, msg.channel_psk_len); + TEAM_LOG("[TEAM] %s JoinAccept has_team_id=%u team_id=%s channel_index=%u psk_len=%u psk_hex=%s key_id=%lu params_has=%u pos_ms=%lu precision=%u flags=0x%08lX\n", + dir, + msg.has_team_id ? 1 : 0, + hexFromArray(msg.team_id).c_str(), + static_cast(msg.channel_index), + static_cast(msg.channel_psk_len), + psk_hex.c_str(), + static_cast(msg.key_id), + msg.params.has_params ? 1 : 0, + static_cast(msg.params.position_interval_ms), + static_cast(msg.params.precision_level), + static_cast(msg.params.flags)); +} + +void logTeamJoinConfirm(const team::proto::TeamJoinConfirm& msg, const char* dir) +{ + TEAM_LOG("[TEAM] %s JoinConfirm ok=%u has_cap=%u cap=0x%08lX has_battery=%u battery=%u\n", + dir, + msg.ok ? 1 : 0, + msg.has_capabilities ? 1 : 0, + static_cast(msg.capabilities), + msg.has_battery ? 1 : 0, + static_cast(msg.battery)); +} + +void logTeamJoinDecision(const team::proto::TeamJoinDecision& msg, const char* dir) +{ + TEAM_LOG("[TEAM] %s JoinDecision accept=%u has_reason=%u reason=%lu\n", + dir, + msg.accept ? 1 : 0, + msg.has_reason ? 1 : 0, + static_cast(msg.reason)); +} + +void logTeamKick(const team::proto::TeamKick& msg, const char* dir) +{ + TEAM_LOG("[TEAM] %s Kick target=%08lX\n", dir, static_cast(msg.target)); +} + +void logTeamTransferLeader(const team::proto::TeamTransferLeader& msg, const char* dir) +{ + TEAM_LOG("[TEAM] %s TransferLeader target=%08lX\n", dir, static_cast(msg.target)); +} + +void logTeamKeyDist(const team::proto::TeamKeyDist& msg, const char* dir) +{ + std::string psk_hex = toHex(msg.channel_psk.data(), msg.channel_psk_len, msg.channel_psk_len); + TEAM_LOG("[TEAM] %s KeyDist team_id=%s key_id=%lu psk_len=%u psk_hex=%s\n", + dir, + hexFromArray(msg.team_id).c_str(), + static_cast(msg.key_id), + static_cast(msg.channel_psk_len), + psk_hex.c_str()); +} + +void logTeamStatus(const team::proto::TeamStatus& msg, const char* dir) +{ + TEAM_LOG("[TEAM] %s Status key_id=%lu member_hash=%s params_has=%u pos_ms=%lu precision=%u flags=0x%08lX\n", + dir, + static_cast(msg.key_id), + hexFromArray(msg.member_list_hash).c_str(), + msg.params.has_params ? 1 : 0, + static_cast(msg.params.position_interval_ms), + static_cast(msg.params.precision_level), + static_cast(msg.params.flags)); +} + std::vector buildAad(const team::proto::TeamEncrypted& envelope) { std::vector aad; @@ -81,6 +326,43 @@ void TeamService::clearKeys() keys_ = TeamKeys{}; } +bool TeamService::setKeysFromPsk(const TeamId& team_id, uint32_t key_id, + const uint8_t* psk, size_t psk_len) +{ + if (!psk || psk_len == 0) + { + return false; + } + + TeamKeys keys{}; + keys.team_id = team_id; + keys.key_id = key_id; + + if (!crypto_.deriveKey(psk, psk_len, "team_mgmt", + keys.mgmt_key.data(), keys.mgmt_key.size())) + { + return false; + } + if (!crypto_.deriveKey(psk, psk_len, "team_pos", + keys.pos_key.data(), keys.pos_key.size())) + { + return false; + } + if (!crypto_.deriveKey(psk, psk_len, "team_wp", + keys.wp_key.data(), keys.wp_key.size())) + { + return false; + } + if (!crypto_.deriveKey(psk, psk_len, "team_chat", + keys.chat_key.data(), keys.chat_key.size())) + { + return false; + } + keys.valid = true; + keys_ = keys; + return true; +} + void TeamService::processIncoming() { chat::MeshIncomingData data; @@ -88,6 +370,12 @@ void TeamService::processIncoming() { if (data.portnum == team::proto::TEAM_MGMT_APP) { + std::string rx_raw_hex = toHex(data.payload.data(), data.payload.size(), data.payload.size()); + TEAM_LOG("[TEAM] RX TEAM_MGMT raw from=%08lX len=%u hex=%s\n", + static_cast(data.from), + static_cast(data.payload.size()), + rx_raw_hex.c_str()); + team::proto::TeamEncrypted envelope; std::vector plain; bool decoded_encrypted = @@ -100,18 +388,34 @@ void TeamService::processIncoming() if (decoded_encrypted) { + logTeamEncrypted("RX", data, envelope, &plain, nullptr, "decrypt-ok"); if (!team::proto::decodeTeamMgmtMessage( plain.data(), plain.size(), &version, &type, payload)) { + std::string plain_hex = toHex(plain.data(), plain.size(), plain.size()); + TEAM_LOG("[TEAM] RX TEAM_MGMT decode fail (encrypted) len=%u hex=%s\n", + static_cast(plain.size()), + plain_hex.c_str()); emitError(data, TeamProtocolError::DecodeFail, &envelope); continue; } if (version != team::proto::kTeamMgmtVersion) { + std::string plain_hex = toHex(plain.data(), plain.size(), plain.size()); + TEAM_LOG("[TEAM] RX TEAM_MGMT bad version (encrypted) ver=%u len=%u hex=%s\n", + static_cast(version), + static_cast(plain.size()), + plain_hex.c_str()); emitError(data, TeamProtocolError::UnknownVersion, &envelope); continue; } + std::string payload_hex = toHex(payload.data(), payload.size(), payload.size()); + TEAM_LOG("[TEAM] RX TEAM_MGMT encrypted ver=%u type=%s payload_len=%u payload_hex=%s\n", + static_cast(version), + mgmtTypeName(type), + static_cast(payload.size()), + payload_hex.c_str()); } else { @@ -119,12 +423,25 @@ void TeamService::processIncoming() data.payload.data(), data.payload.size(), &version, &type, payload)) { + TEAM_LOG("[TEAM] RX TEAM_MGMT plain decode fail len=%u hex=%s\n", + static_cast(data.payload.size()), + rx_raw_hex.c_str()); continue; } if (version != team::proto::kTeamMgmtVersion) { + TEAM_LOG("[TEAM] RX TEAM_MGMT plain bad version ver=%u len=%u hex=%s\n", + static_cast(version), + static_cast(data.payload.size()), + rx_raw_hex.c_str()); continue; } + std::string payload_hex = toHex(payload.data(), payload.size(), payload.size()); + TEAM_LOG("[TEAM] RX TEAM_MGMT plain ver=%u type=%s payload_len=%u payload_hex=%s\n", + static_cast(version), + mgmtTypeName(type), + static_cast(payload.size()), + payload_hex.c_str()); } switch (type) @@ -138,6 +455,7 @@ void TeamService::processIncoming() decoded_encrypted ? &envelope : nullptr); break; } + logTeamAdvertise(msg, "RX"); TeamAdvertiseEvent event{makeContext(data, msg.team_id), msg}; sink_.onTeamAdvertise(event); break; @@ -151,16 +469,13 @@ void TeamService::processIncoming() decoded_encrypted ? &envelope : nullptr); break; } + logTeamJoinRequest(msg, "RX"); TeamJoinRequestEvent event{makeContext(data, msg.team_id), msg}; sink_.onTeamJoinRequest(event); break; } case team::proto::TeamMgmtType::JoinAccept: { - if (!decoded_encrypted) - { - break; - } team::proto::TeamJoinAccept msg; if (!team::proto::decodeTeamJoinAccept(payload.data(), payload.size(), &msg)) { @@ -168,8 +483,20 @@ void TeamService::processIncoming() decoded_encrypted ? &envelope : nullptr); break; } - TeamJoinAcceptEvent event{makeContext(data, decoded_encrypted ? &envelope : nullptr), msg}; + logTeamJoinAccept(msg, "RX"); + team::TeamEventContext ctx = makeContext(data, decoded_encrypted ? &envelope : nullptr); + if (msg.has_team_id) + { + ctx.team_id = msg.team_id; + } + TeamJoinAcceptEvent event{ctx, msg}; sink_.onTeamJoinAccept(event); + + if (msg.channel_psk_len > 0 && msg.has_team_id && msg.key_id != 0) + { + setKeysFromPsk(msg.team_id, msg.key_id, + msg.channel_psk.data(), msg.channel_psk_len); + } break; } case team::proto::TeamMgmtType::JoinConfirm: @@ -187,14 +514,81 @@ void TeamService::processIncoming() } TeamJoinConfirmEvent event{makeContext(data, decoded_encrypted ? &envelope : nullptr), msg}; sink_.onTeamJoinConfirm(event); + logTeamJoinConfirm(msg, "RX"); break; } - case team::proto::TeamMgmtType::Status: + case team::proto::TeamMgmtType::JoinDecision: + { + team::proto::TeamJoinDecision msg; + if (!team::proto::decodeTeamJoinDecision(payload.data(), payload.size(), &msg)) + { + emitError(data, TeamProtocolError::DecodeFail, + decoded_encrypted ? &envelope : nullptr); + break; + } + logTeamJoinDecision(msg, "RX"); + TeamJoinDecisionEvent event{makeContext(data, decoded_encrypted ? &envelope : nullptr), msg}; + sink_.onTeamJoinDecision(event); + break; + } + case team::proto::TeamMgmtType::Kick: { if (!decoded_encrypted) { break; } + team::proto::TeamKick msg; + if (!team::proto::decodeTeamKick(payload.data(), payload.size(), &msg)) + { + emitError(data, TeamProtocolError::DecodeFail, + decoded_encrypted ? &envelope : nullptr); + break; + } + logTeamKick(msg, "RX"); + TeamKickEvent event{makeContext(data, decoded_encrypted ? &envelope : nullptr), msg}; + sink_.onTeamKick(event); + break; + } + case team::proto::TeamMgmtType::TransferLeader: + { + if (!decoded_encrypted) + { + break; + } + team::proto::TeamTransferLeader msg; + if (!team::proto::decodeTeamTransferLeader(payload.data(), payload.size(), &msg)) + { + emitError(data, TeamProtocolError::DecodeFail, + decoded_encrypted ? &envelope : nullptr); + break; + } + logTeamTransferLeader(msg, "RX"); + TeamTransferLeaderEvent event{makeContext(data, decoded_encrypted ? &envelope : nullptr), msg}; + sink_.onTeamTransferLeader(event); + break; + } + case team::proto::TeamMgmtType::KeyDist: + { + team::proto::TeamKeyDist msg; + if (!team::proto::decodeTeamKeyDist(payload.data(), payload.size(), &msg)) + { + emitError(data, TeamProtocolError::DecodeFail, + decoded_encrypted ? &envelope : nullptr); + break; + } + logTeamKeyDist(msg, "RX"); + TeamKeyDistEvent event{makeContext(data, decoded_encrypted ? &envelope : nullptr), msg}; + sink_.onTeamKeyDist(event); + + if (msg.channel_psk_len > 0 && msg.key_id != 0) + { + setKeysFromPsk(msg.team_id, msg.key_id, + msg.channel_psk.data(), msg.channel_psk_len); + } + break; + } + case team::proto::TeamMgmtType::Status: + { team::proto::TeamStatus msg; if (!team::proto::decodeTeamStatus(payload.data(), payload.size(), &msg)) { @@ -202,6 +596,7 @@ void TeamService::processIncoming() decoded_encrypted ? &envelope : nullptr); break; } + logTeamStatus(msg, "RX"); TeamStatusEvent event{makeContext(data, decoded_encrypted ? &envelope : nullptr), msg}; sink_.onTeamStatus(event); break; @@ -212,6 +607,11 @@ void TeamService::processIncoming() } else if (data.portnum == team::proto::TEAM_POSITION_APP) { + std::string rx_raw_hex = toHex(data.payload.data(), data.payload.size(), data.payload.size()); + TEAM_LOG("[TEAM] RX TEAM_POS raw from=%08lX len=%u hex=%s\n", + static_cast(data.from), + static_cast(data.payload.size()), + rx_raw_hex.c_str()); team::proto::TeamEncrypted envelope; std::vector plain; if (!decodeEncryptedPayload(data, keys_.pos_key.data(), keys_.pos_key.size(), @@ -220,11 +620,17 @@ void TeamService::processIncoming() continue; } + logTeamEncrypted("RX", data, envelope, &plain, nullptr, "decrypt-ok"); TeamPositionEvent event{makeContext(data, &envelope), plain}; sink_.onTeamPosition(event); } else if (data.portnum == team::proto::TEAM_WAYPOINT_APP) { + std::string rx_raw_hex = toHex(data.payload.data(), data.payload.size(), data.payload.size()); + TEAM_LOG("[TEAM] RX TEAM_WP raw from=%08lX len=%u hex=%s\n", + static_cast(data.from), + static_cast(data.payload.size()), + rx_raw_hex.c_str()); team::proto::TeamEncrypted envelope; std::vector plain; if (!decodeEncryptedPayload(data, keys_.wp_key.data(), keys_.wp_key.size(), @@ -233,9 +639,40 @@ void TeamService::processIncoming() continue; } + logTeamEncrypted("RX", data, envelope, &plain, nullptr, "decrypt-ok"); TeamWaypointEvent event{makeContext(data, &envelope), plain}; sink_.onTeamWaypoint(event); } + else if (data.portnum == team::proto::TEAM_CHAT_APP) + { + std::string rx_raw_hex = toHex(data.payload.data(), data.payload.size(), data.payload.size()); + TEAM_LOG("[TEAM] RX TEAM_CHAT raw from=%08lX len=%u hex=%s\n", + static_cast(data.from), + static_cast(data.payload.size()), + rx_raw_hex.c_str()); + team::proto::TeamEncrypted envelope; + std::vector plain; + if (!decodeEncryptedPayload(data, keys_.chat_key.data(), keys_.chat_key.size(), + &envelope, plain, true)) + { + continue; + } + + logTeamEncrypted("RX", data, envelope, &plain, nullptr, "decrypt-ok"); + team::proto::TeamChatMessage msg; + if (!team::proto::decodeTeamChatMessage(plain.data(), plain.size(), &msg)) + { + emitError(data, TeamProtocolError::DecodeFail, &envelope); + continue; + } + if (msg.header.version != team::proto::kTeamChatVersion) + { + emitError(data, TeamProtocolError::UnknownVersion, &envelope); + continue; + } + TeamChatEvent event{makeContext(data, &envelope), msg}; + sink_.onTeamChat(event); + } } } @@ -247,6 +684,11 @@ bool TeamService::sendAdvertise(const team::proto::TeamAdvertise& msg, { return false; } + logTeamAdvertise(msg, "TX"); + std::string payload_hex = toHex(payload.data(), payload.size(), payload.size()); + TEAM_LOG("[TEAM] TX TEAM_MGMT Advertise payload_len=%u payload_hex=%s\n", + static_cast(payload.size()), + payload_hex.c_str()); return sendMgmtPlain(team::proto::TeamMgmtType::Advertise, payload, channel, 0); } @@ -258,6 +700,11 @@ bool TeamService::sendJoinRequest(const team::proto::TeamJoinRequest& msg, { return false; } + logTeamJoinRequest(msg, "TX"); + std::string payload_hex = toHex(payload.data(), payload.size(), payload.size()); + TEAM_LOG("[TEAM] TX TEAM_MGMT JoinRequest payload_len=%u payload_hex=%s\n", + static_cast(payload.size()), + payload_hex.c_str()); return sendMgmtPlain(team::proto::TeamMgmtType::JoinRequest, payload, channel, dest); } @@ -269,7 +716,12 @@ bool TeamService::sendJoinAccept(const team::proto::TeamJoinAccept& msg, { return false; } - return sendMgmtEncrypted(team::proto::TeamMgmtType::JoinAccept, payload, channel, dest); + logTeamJoinAccept(msg, "TX"); + std::string payload_hex = toHex(payload.data(), payload.size(), payload.size()); + TEAM_LOG("[TEAM] TX TEAM_MGMT JoinAccept payload_len=%u payload_hex=%s\n", + static_cast(payload.size()), + payload_hex.c_str()); + return sendMgmtPlain(team::proto::TeamMgmtType::JoinAccept, payload, channel, dest); } bool TeamService::sendJoinConfirm(const team::proto::TeamJoinConfirm& msg, @@ -280,9 +732,94 @@ bool TeamService::sendJoinConfirm(const team::proto::TeamJoinConfirm& msg, { return false; } + logTeamJoinConfirm(msg, "TX"); + std::string payload_hex = toHex(payload.data(), payload.size(), payload.size()); + TEAM_LOG("[TEAM] TX TEAM_MGMT JoinConfirm payload_len=%u payload_hex=%s\n", + static_cast(payload.size()), + payload_hex.c_str()); return sendMgmtEncrypted(team::proto::TeamMgmtType::JoinConfirm, payload, channel, dest); } +bool TeamService::sendJoinDecision(const team::proto::TeamJoinDecision& msg, + chat::ChannelId channel, chat::NodeId dest) +{ + std::vector payload; + if (!team::proto::encodeTeamJoinDecision(msg, payload)) + { + return false; + } + logTeamJoinDecision(msg, "TX"); + std::string payload_hex = toHex(payload.data(), payload.size(), payload.size()); + TEAM_LOG("[TEAM] TX TEAM_MGMT JoinDecision payload_len=%u payload_hex=%s\n", + static_cast(payload.size()), + payload_hex.c_str()); + return sendMgmtPlain(team::proto::TeamMgmtType::JoinDecision, payload, channel, dest); +} + +bool TeamService::sendKick(const team::proto::TeamKick& msg, + chat::ChannelId channel, chat::NodeId dest) +{ + std::vector payload; + if (!team::proto::encodeTeamKick(msg, payload)) + { + return false; + } + logTeamKick(msg, "TX"); + std::string payload_hex = toHex(payload.data(), payload.size(), payload.size()); + TEAM_LOG("[TEAM] TX TEAM_MGMT Kick payload_len=%u payload_hex=%s\n", + static_cast(payload.size()), + payload_hex.c_str()); + return sendMgmtEncrypted(team::proto::TeamMgmtType::Kick, payload, channel, dest); +} + +bool TeamService::sendTransferLeader(const team::proto::TeamTransferLeader& msg, + chat::ChannelId channel, chat::NodeId dest) +{ + std::vector payload; + if (!team::proto::encodeTeamTransferLeader(msg, payload)) + { + return false; + } + logTeamTransferLeader(msg, "TX"); + std::string payload_hex = toHex(payload.data(), payload.size(), payload.size()); + TEAM_LOG("[TEAM] TX TEAM_MGMT TransferLeader payload_len=%u payload_hex=%s\n", + static_cast(payload.size()), + payload_hex.c_str()); + return sendMgmtEncrypted(team::proto::TeamMgmtType::TransferLeader, payload, channel, dest); +} + +bool TeamService::sendKeyDist(const team::proto::TeamKeyDist& msg, + chat::ChannelId channel, chat::NodeId dest) +{ + std::vector payload; + if (!team::proto::encodeTeamKeyDist(msg, payload)) + { + return false; + } + logTeamKeyDist(msg, "TX"); + std::string payload_hex = toHex(payload.data(), payload.size(), payload.size()); + TEAM_LOG("[TEAM] TX TEAM_MGMT KeyDist payload_len=%u payload_hex=%s\n", + static_cast(payload.size()), + payload_hex.c_str()); + return sendMgmtEncrypted(team::proto::TeamMgmtType::KeyDist, payload, channel, dest); +} + +bool TeamService::sendKeyDistPlain(const team::proto::TeamKeyDist& msg, + chat::ChannelId channel, chat::NodeId dest) +{ + std::vector payload; + if (!team::proto::encodeTeamKeyDist(msg, payload)) + { + return false; + } + logTeamKeyDist(msg, "TX"); + std::string payload_hex = toHex(payload.data(), payload.size(), payload.size()); + TEAM_LOG("[TEAM] TX TEAM_MGMT KeyDist (plain) payload_len=%u payload_hex=%s\n", + static_cast(payload.size()), + payload_hex.c_str()); + return sendMgmtPlain(team::proto::TeamMgmtType::KeyDist, payload, channel, dest); +} + bool TeamService::sendStatus(const team::proto::TeamStatus& msg, chat::ChannelId channel, chat::NodeId dest) { @@ -291,14 +828,36 @@ bool TeamService::sendStatus(const team::proto::TeamStatus& msg, { return false; } + logTeamStatus(msg, "TX"); + std::string payload_hex = toHex(payload.data(), payload.size(), payload.size()); + TEAM_LOG("[TEAM] TX TEAM_MGMT Status payload_len=%u payload_hex=%s\n", + static_cast(payload.size()), + payload_hex.c_str()); return sendMgmtEncrypted(team::proto::TeamMgmtType::Status, payload, channel, dest); } +bool TeamService::sendStatusPlain(const team::proto::TeamStatus& msg, + chat::ChannelId channel, chat::NodeId dest) +{ + std::vector payload; + if (!team::proto::encodeTeamStatus(msg, payload)) + { + return false; + } + logTeamStatus(msg, "TX"); + std::string payload_hex = toHex(payload.data(), payload.size(), payload.size()); + TEAM_LOG("[TEAM] TX TEAM_MGMT Status (plain) payload_len=%u payload_hex=%s\n", + static_cast(payload.size()), + payload_hex.c_str()); + return sendMgmtPlain(team::proto::TeamMgmtType::Status, payload, channel, dest); +} + bool TeamService::sendPosition(const std::vector& payload, chat::ChannelId channel) { if (!keys_.valid) { + TEAM_LOG("[TEAM] TX TEAM_POS keys not ready\n"); return false; } @@ -307,10 +866,29 @@ bool TeamService::sendPosition(const std::vector& payload, if (!encodeEncryptedPayload(payload, keys_.pos_key.data(), keys_.pos_key.size(), &envelope, wire)) { + TEAM_LOG("[TEAM] TX TEAM_POS encrypt fail plain_len=%u\n", + static_cast(payload.size())); return false; } - return mesh_.sendAppData(channel, team::proto::TEAM_POSITION_APP, - wire.data(), wire.size(), 0, false); + std::string plain_hex = toHex(payload.data(), payload.size(), payload.size()); + TEAM_LOG("[TEAM] TX TEAM_POS plain_len=%u plain_hex=%s\n", + static_cast(payload.size()), + plain_hex.c_str()); + std::string wire_hex = toHex(wire.data(), wire.size(), wire.size()); + TEAM_LOG("[TEAM] TX TEAM_POS wire_len=%u wire_hex=%s\n", + static_cast(wire.size()), + wire_hex.c_str()); + chat::MeshIncomingData dummy; + dummy.portnum = team::proto::TEAM_POSITION_APP; + logTeamEncrypted("TX", dummy, envelope, nullptr, nullptr, "encrypt-ok"); + if (!mesh_.sendAppData(channel, team::proto::TEAM_POSITION_APP, + wire.data(), wire.size(), 0, false)) + { + TEAM_LOG("[TEAM] TX TEAM_POS send fail wire_len=%u\n", + static_cast(wire.size())); + return false; + } + return true; } bool TeamService::sendWaypoint(const std::vector& payload, @@ -318,6 +896,7 @@ bool TeamService::sendWaypoint(const std::vector& payload, { if (!keys_.valid) { + TEAM_LOG("[TEAM] TX TEAM_WP keys not ready\n"); return false; } @@ -326,10 +905,96 @@ bool TeamService::sendWaypoint(const std::vector& payload, if (!encodeEncryptedPayload(payload, keys_.wp_key.data(), keys_.wp_key.size(), &envelope, wire)) { + TEAM_LOG("[TEAM] TX TEAM_WP encrypt fail plain_len=%u\n", + static_cast(payload.size())); return false; } - return mesh_.sendAppData(channel, team::proto::TEAM_WAYPOINT_APP, - wire.data(), wire.size(), 0, false); + std::string plain_hex = toHex(payload.data(), payload.size(), payload.size()); + TEAM_LOG("[TEAM] TX TEAM_WP plain_len=%u plain_hex=%s\n", + static_cast(payload.size()), + plain_hex.c_str()); + std::string wire_hex = toHex(wire.data(), wire.size(), wire.size()); + TEAM_LOG("[TEAM] TX TEAM_WP wire_len=%u wire_hex=%s\n", + static_cast(wire.size()), + wire_hex.c_str()); + chat::MeshIncomingData dummy; + dummy.portnum = team::proto::TEAM_WAYPOINT_APP; + logTeamEncrypted("TX", dummy, envelope, nullptr, nullptr, "encrypt-ok"); + if (!mesh_.sendAppData(channel, team::proto::TEAM_WAYPOINT_APP, + wire.data(), wire.size(), 0, false)) + { + TEAM_LOG("[TEAM] TX TEAM_WP send fail wire_len=%u\n", + static_cast(wire.size())); + return false; + } + return true; +} + +bool TeamService::sendChat(const team::proto::TeamChatMessage& msg, + chat::ChannelId channel) +{ + if (!keys_.valid) + { + last_send_error_ = SendError::KeysNotReady; + TEAM_LOG("[TEAM] TX TEAM_CHAT keys not ready\n"); + return false; + } + + std::vector plain; + if (!team::proto::encodeTeamChatMessage(msg, plain)) + { + last_send_error_ = SendError::EncodeFail; + TEAM_LOG("[TEAM] TX TEAM_CHAT encode fail\n"); + return false; + } + + team::proto::TeamEncrypted envelope; + std::vector wire; + if (!encodeEncryptedPayload(plain, keys_.chat_key.data(), keys_.chat_key.size(), + &envelope, wire)) + { + last_send_error_ = SendError::EncryptFail; + TEAM_LOG("[TEAM] TX TEAM_CHAT encrypt fail plain_len=%u\n", + static_cast(plain.size())); + return false; + } + + std::string plain_hex = toHex(plain.data(), plain.size(), plain.size()); + TEAM_LOG("[TEAM] TX TEAM_CHAT plain_len=%u plain_hex=%s\n", + static_cast(plain.size()), + plain_hex.c_str()); + std::string wire_hex = toHex(wire.data(), wire.size(), wire.size()); + TEAM_LOG("[TEAM] TX TEAM_CHAT wire_len=%u wire_hex=%s\n", + static_cast(wire.size()), + wire_hex.c_str()); + chat::MeshIncomingData dummy; + dummy.portnum = team::proto::TEAM_CHAT_APP; + logTeamEncrypted("TX", dummy, envelope, nullptr, nullptr, "encrypt-ok"); + if (!mesh_.sendAppData(channel, team::proto::TEAM_CHAT_APP, + wire.data(), wire.size(), 0, false)) + { + last_send_error_ = SendError::MeshSendFail; + TEAM_LOG("[TEAM] TX TEAM_CHAT send fail wire_len=%u\n", + static_cast(wire.size())); + return false; + } + last_send_error_ = SendError::None; + return true; +} + +bool TeamService::requestNodeInfo(chat::NodeId dest, bool want_response) +{ + return mesh_.requestNodeInfo(dest, want_response); +} + +bool TeamService::startPkiVerification(chat::NodeId dest) +{ + return mesh_.startKeyVerification(dest); +} + +bool TeamService::submitPkiNumber(chat::NodeId dest, uint64_t nonce, uint32_t number) +{ + return mesh_.submitKeyVerificationNumber(dest, nonce, number); } bool TeamService::decodeEncryptedPayload(const chat::MeshIncomingData& data, @@ -348,6 +1013,12 @@ bool TeamService::decodeEncryptedPayload(const chat::MeshIncomingData& data, { if (emit_errors) { + std::string raw_hex = toHex(data.payload.data(), data.payload.size(), data.payload.size()); + TEAM_LOG("[TEAM] RX %s decode fail from=%08lX len=%u hex=%s\n", + teamPortName(data.portnum), + static_cast(data.from), + static_cast(data.payload.size()), + raw_hex.c_str()); emitError(data, TeamProtocolError::DecodeFail, nullptr); } return false; @@ -356,6 +1027,10 @@ bool TeamService::decodeEncryptedPayload(const chat::MeshIncomingData& data, { if (emit_errors) { + TEAM_LOG("[TEAM] RX %s bad version=%u from=%08lX\n", + teamPortName(data.portnum), + static_cast(envelope->version), + static_cast(data.from)); emitError(data, TeamProtocolError::UnknownVersion, envelope); } return false; @@ -366,6 +1041,10 @@ bool TeamService::decodeEncryptedPayload(const chat::MeshIncomingData& data, { if (emit_errors) { + TEAM_LOG("[TEAM] RX %s key mismatch from=%08lX env_key_id=%lu\n", + teamPortName(data.portnum), + static_cast(data.from), + static_cast(envelope->key_id)); emitError(data, TeamProtocolError::KeyMismatch, envelope); } return false; @@ -380,6 +1059,10 @@ bool TeamService::decodeEncryptedPayload(const chat::MeshIncomingData& data, { if (emit_errors) { + TEAM_LOG("[TEAM] RX %s decrypt fail from=%08lX key_id=%lu\n", + teamPortName(data.portnum), + static_cast(data.from), + static_cast(envelope->key_id)); emitError(data, TeamProtocolError::DecryptFail, envelope); } return false; @@ -423,10 +1106,32 @@ bool TeamService::sendMgmtPlain(const team::proto::TeamMgmtType type, std::vector wire; if (!team::proto::encodeTeamMgmtMessage(type, payload, wire)) { + last_send_error_ = SendError::EncodeFail; + TEAM_LOG("[TEAM] TX TEAM_MGMT encode fail type=%s payload_len=%u\n", + mgmtTypeName(type), + static_cast(payload.size())); return false; } - return mesh_.sendAppData(channel, team::proto::TEAM_MGMT_APP, - wire.data(), wire.size(), dest, false); + std::string wire_hex = toHex(wire.data(), wire.size(), wire.size()); + TEAM_LOG("[TEAM] TX TEAM_MGMT plain type=%s ch=%u dest=%08lX wire_len=%u wire_hex=%s\n", + mgmtTypeName(type), + static_cast(channel), + static_cast(dest), + static_cast(wire.size()), + wire_hex.c_str()); + if (!mesh_.sendAppData(channel, team::proto::TEAM_MGMT_APP, + wire.data(), wire.size(), dest, false)) + { + last_send_error_ = SendError::MeshSendFail; + TEAM_LOG("[TEAM] TX TEAM_MGMT send fail type=%s ch=%u dest=%08lX wire_len=%u\n", + mgmtTypeName(type), + static_cast(channel), + static_cast(dest), + static_cast(wire.size())); + return false; + } + last_send_error_ = SendError::None; + return true; } bool TeamService::sendMgmtEncrypted(const team::proto::TeamMgmtType type, @@ -435,12 +1140,19 @@ bool TeamService::sendMgmtEncrypted(const team::proto::TeamMgmtType type, { if (!keys_.valid) { + last_send_error_ = SendError::KeysNotReady; + TEAM_LOG("[TEAM] TX TEAM_MGMT encrypt fail type=%s keys_not_ready\n", + mgmtTypeName(type)); return false; } std::vector mgmt_wire; if (!team::proto::encodeTeamMgmtMessage(type, payload, mgmt_wire)) { + last_send_error_ = SendError::EncodeFail; + TEAM_LOG("[TEAM] TX TEAM_MGMT encode fail type=%s payload_len=%u\n", + mgmtTypeName(type), + static_cast(payload.size())); return false; } @@ -449,11 +1161,30 @@ bool TeamService::sendMgmtEncrypted(const team::proto::TeamMgmtType type, if (!encodeEncryptedPayload(mgmt_wire, keys_.mgmt_key.data(), keys_.mgmt_key.size(), &envelope, wire)) { + last_send_error_ = SendError::EncryptFail; + TEAM_LOG("[TEAM] TX TEAM_MGMT encrypt fail type=%s payload_len=%u\n", + mgmtTypeName(type), + static_cast(payload.size())); return false; } - return mesh_.sendAppData(channel, team::proto::TEAM_MGMT_APP, - wire.data(), wire.size(), dest, false); + chat::MeshIncomingData dummy; + dummy.portnum = team::proto::TEAM_MGMT_APP; + logTeamEncrypted("TX", dummy, envelope, &mgmt_wire, &wire, "encrypt-ok"); + + if (!mesh_.sendAppData(channel, team::proto::TEAM_MGMT_APP, + wire.data(), wire.size(), dest, false)) + { + last_send_error_ = SendError::MeshSendFail; + TEAM_LOG("[TEAM] TX TEAM_MGMT send fail type=%s ch=%u dest=%08lX wire_len=%u\n", + mgmtTypeName(type), + static_cast(channel), + static_cast(dest), + static_cast(wire.size())); + return false; + } + last_send_error_ = SendError::None; + return true; } void TeamService::emitError(const chat::MeshIncomingData& data, @@ -464,6 +1195,12 @@ void TeamService::emitError(const chat::MeshIncomingData& data, event.ctx = makeContext(data, envelope); event.error = error; event.portnum = data.portnum; + TEAM_LOG("[TEAM] RX error port=%s from=%08lX err=%s team_id=%s key_id=%lu\n", + teamPortName(data.portnum), + static_cast(data.from), + teamErrorName(error), + hexFromArray(event.ctx.team_id).c_str(), + static_cast(event.ctx.key_id)); sink_.onTeamError(event); } diff --git a/src/team/usecase/team_service.h b/src/team/usecase/team_service.h index 32eed16c..2ce0910c 100644 --- a/src/team/usecase/team_service.h +++ b/src/team/usecase/team_service.h @@ -2,6 +2,7 @@ #include "../../chat/ports/i_mesh_adapter.h" #include "../domain/team_types.h" +#include "../protocol/team_chat.h" #include "../ports/i_team_crypto.h" #include "../ports/i_team_event_sink.h" #include @@ -12,12 +13,22 @@ namespace team class TeamService { public: + enum class SendError + { + None, + KeysNotReady, + EncodeFail, + EncryptFail, + MeshSendFail + }; TeamService(team::ITeamCrypto& crypto, chat::IMeshAdapter& mesh, team::ITeamEventSink& sink); void setKeys(const TeamKeys& keys); void clearKeys(); + bool setKeysFromPsk(const TeamId& team_id, uint32_t key_id, + const uint8_t* psk, size_t psk_len); void processIncoming(); @@ -29,18 +40,37 @@ class TeamService chat::ChannelId channel, chat::NodeId dest); bool sendJoinConfirm(const team::proto::TeamJoinConfirm& msg, chat::ChannelId channel, chat::NodeId dest = 0); + bool sendJoinDecision(const team::proto::TeamJoinDecision& msg, + chat::ChannelId channel, chat::NodeId dest); + bool sendKick(const team::proto::TeamKick& msg, + chat::ChannelId channel, chat::NodeId dest = 0); + bool sendTransferLeader(const team::proto::TeamTransferLeader& msg, + chat::ChannelId channel, chat::NodeId dest = 0); + bool sendKeyDist(const team::proto::TeamKeyDist& msg, + chat::ChannelId channel, chat::NodeId dest); + bool sendKeyDistPlain(const team::proto::TeamKeyDist& msg, + chat::ChannelId channel, chat::NodeId dest); bool sendStatus(const team::proto::TeamStatus& msg, chat::ChannelId channel, chat::NodeId dest = 0); + bool sendStatusPlain(const team::proto::TeamStatus& msg, + chat::ChannelId channel, chat::NodeId dest = 0); bool sendPosition(const std::vector& payload, chat::ChannelId channel); bool sendWaypoint(const std::vector& payload, chat::ChannelId channel); + bool sendChat(const team::proto::TeamChatMessage& msg, + chat::ChannelId channel); + bool requestNodeInfo(chat::NodeId dest, bool want_response); + bool startPkiVerification(chat::NodeId dest); + bool submitPkiNumber(chat::NodeId dest, uint64_t nonce, uint32_t number); + SendError getLastSendError() const { return last_send_error_; } private: team::ITeamCrypto& crypto_; chat::IMeshAdapter& mesh_; team::ITeamEventSink& sink_; TeamKeys keys_{}; + SendError last_send_error_ = SendError::None; bool decodeEncryptedPayload(const chat::MeshIncomingData& data, const uint8_t* key, size_t key_len, diff --git a/src/ui/LV_Helper_v9.cpp b/src/ui/LV_Helper_v9.cpp index cecc4597..66ba875e 100644 --- a/src/ui/LV_Helper_v9.cpp +++ b/src/ui/LV_Helper_v9.cpp @@ -12,6 +12,12 @@ #if LVGL_VERSION_MAJOR == 9 +// Test toggles (set to 0 to disable) +#define LV_TEST_FORCE_DMA_BUF 1 +#define LV_TEST_FORCE_DMA_FULL_SIZE 0 +#define LV_TEST_FLUSH_LOG 1 +#define LV_TEST_FLUSH_SAMPLE 1 + static lv_display_t* disp_drv; static lv_draw_buf_t draw_buf; static lv_indev_t* indev_touch; @@ -27,6 +33,14 @@ static lv_color16_t* buf1 = nullptr; static void disp_flush(lv_display_t* disp_drv, const lv_area_t* area, uint8_t* color_p) { +#if LV_TEST_FLUSH_LOG + static uint32_t s_flush_count = 0; + static uint32_t s_flush_last_ms = 0; + static lv_area_t s_last_area = {0, 0, 0, 0}; +#if LV_TEST_FLUSH_SAMPLE + static uint32_t s_zero_streak = 0; +#endif +#endif size_t len = lv_area_get_size(area); uint32_t w = lv_area_get_width(area); uint32_t h = lv_area_get_height(area); @@ -39,6 +53,47 @@ static void disp_flush(lv_display_t* disp_drv, const lv_area_t* area, uint8_t* c plane->pushColors(area->x1, area->y1, w, h, (uint16_t*)color_p); lv_display_flush_ready(disp_drv); + +#if LV_TEST_FLUSH_LOG +#if LV_TEST_FLUSH_SAMPLE + bool all_zero_sample = false; + if (color_p && len > 0) { + const uint16_t* pix = reinterpret_cast(color_p); + size_t step = len / 8; + if (step == 0) step = 1; + all_zero_sample = true; + for (size_t i = 0, idx = 0; i < 8 && idx < len; ++i, idx += step) { + if (pix[idx] != 0) { + all_zero_sample = false; + break; + } + } + } + if (all_zero_sample) { + s_zero_streak++; + } else { + s_zero_streak = 0; + } +#endif + s_flush_count++; + s_last_area = *area; + uint32_t now_ms = millis(); + if (now_ms - s_flush_last_ms >= 1000) { + lv_obj_t* scr = lv_screen_active(); + unsigned child_cnt = scr ? (unsigned)lv_obj_get_child_cnt(scr) : 0; + Serial.printf("[LVGL] flush/s=%lu last_area=%d,%d-%d,%d children=%u", + (unsigned long)s_flush_count, + (int)s_last_area.x1, (int)s_last_area.y1, + (int)s_last_area.x2, (int)s_last_area.y2, + child_cnt); +#if LV_TEST_FLUSH_SAMPLE + Serial.printf(" zero_streak=%lu", (unsigned long)s_zero_streak); +#endif + Serial.printf("\n"); + s_flush_count = 0; + s_flush_last_ms = now_ms; + } +#endif } #ifdef USING_INPUT_DEV_TOUCHPAD @@ -191,12 +246,19 @@ void beginLvglHelper(LilyGo_Display& board, bool debug) // Allocate display buffers // Use DMA-capable memory if board supports DMA, otherwise use PSRAM bool useDMA = board.useDMA(); +#if LV_TEST_FORCE_DMA_BUF + useDMA = true; +#endif size_t lv_buffer_size = board.width() * board.height() * sizeof(lv_color16_t); if (useDMA) { // For DMA, use smaller buffer (1/6 of screen size) and DMA-capable memory +#if LV_TEST_FORCE_DMA_FULL_SIZE + // keep full size for testing if memory allows +#else lv_buffer_size = (board.width() * board.height() / 6) * sizeof(lv_color16_t); +#endif buf = (lv_color16_t*)heap_caps_malloc(lv_buffer_size, MALLOC_CAP_DMA); buf1 = (lv_color16_t*)heap_caps_malloc(lv_buffer_size, MALLOC_CAP_DMA); log_d("Using DMA buffers, size: %d bytes each", lv_buffer_size); diff --git a/src/ui/app_screen.h b/src/ui/app_screen.h new file mode 100644 index 00000000..30ba1727 --- /dev/null +++ b/src/ui/app_screen.h @@ -0,0 +1,12 @@ +#pragma once + +#include "lvgl.h" + +class AppScreen { +public: + virtual ~AppScreen() = default; + virtual const char* name() const = 0; + virtual const lv_image_dsc_t* icon() const = 0; + virtual void enter(lv_obj_t* parent) = 0; + virtual void exit(lv_obj_t* parent) = 0; +}; diff --git a/src/ui/screens/chat/chat_compose_components.cpp b/src/ui/screens/chat/chat_compose_components.cpp index 1ac5559c..74652304 100644 --- a/src/ui/screens/chat/chat_compose_components.cpp +++ b/src/ui/screens/chat/chat_compose_components.cpp @@ -66,6 +66,7 @@ struct ChatComposeScreen::Impl ActionIntent intent = ActionIntent::Send; }; ActionContext send_ctx; + ActionContext position_ctx; ActionContext cancel_ctx; lv_timer_t* send_timer = nullptr; uint32_t send_start_ms = 0; @@ -85,6 +86,32 @@ static void set_btn_label_white(lv_obj_t* btn) } } +static void set_btn_label_text(lv_obj_t* btn, const char* text) +{ + if (!btn || !text) return; + lv_obj_t* child = lv_obj_get_child(btn, 0); + if (child && lv_obj_check_type(child, &lv_label_class)) + { + lv_label_set_text(child, text); + } +} + +static void fit_btn_to_label(lv_obj_t* btn, int pad_lr) +{ + if (!btn) return; + lv_obj_t* child = lv_obj_get_child(btn, 0); + if (!child || !lv_obj_check_type(child, &lv_label_class)) return; + lv_obj_update_layout(child); + int label_w = lv_obj_get_width(child); + if (label_w <= 0) return; + int target_w = label_w + pad_lr * 2; + int cur_w = lv_obj_get_width(btn); + if (target_w > cur_w) + { + lv_obj_set_width(btn, target_w); + } +} + void ChatComposeScreen::setEnabled(bool enabled) { if (!impl_) return; @@ -95,6 +122,7 @@ void ChatComposeScreen::setEnabled(bool enabled) }; set(impl_->w.send_btn); + set(impl_->w.position_btn); set(impl_->w.cancel_btn); set(impl_->w.textarea); if (impl_->w.top_bar.back_btn) set(impl_->w.top_bar.back_btn); @@ -251,13 +279,21 @@ ChatComposeScreen::ChatComposeScreen(lv_obj_t* parent, chat::ConversationId conv impl_->send_ctx.screen = this; impl_->send_ctx.intent = ActionIntent::Send; + impl_->position_ctx.screen = this; + impl_->position_ctx.intent = ActionIntent::Position; impl_->cancel_ctx.screen = this; impl_->cancel_ctx.intent = ActionIntent::Cancel; lv_obj_add_event_cb(impl_->w.send_btn, on_action_click, LV_EVENT_CLICKED, &impl_->send_ctx); + lv_obj_add_event_cb(impl_->w.position_btn, on_action_click, LV_EVENT_CLICKED, &impl_->position_ctx); lv_obj_add_event_cb(impl_->w.cancel_btn, on_action_click, LV_EVENT_CLICKED, &impl_->cancel_ctx); set_btn_label_white(impl_->w.send_btn); + set_btn_label_white(impl_->w.position_btn); set_btn_label_white(impl_->w.cancel_btn); + if (impl_->w.position_btn) + { + lv_obj_add_flag(impl_->w.position_btn, LV_OBJ_FLAG_HIDDEN); + } init_topbar(); @@ -314,6 +350,51 @@ void ChatComposeScreen::setHeaderText(const char* title, const char* status) if (status) ::ui::widgets::top_bar_set_right_text(impl_->w.top_bar, status); } +void ChatComposeScreen::setActionLabels(const char* send_label, const char* cancel_label) +{ + if (!impl_) return; + if (send_label) + { + set_btn_label_text(impl_->w.send_btn, send_label); + fit_btn_to_label(impl_->w.send_btn, 8); + } + if (cancel_label) + { + set_btn_label_text(impl_->w.cancel_btn, cancel_label); + fit_btn_to_label(impl_->w.cancel_btn, 8); + } +} + +void ChatComposeScreen::setPositionButton(const char* label, bool visible) +{ + if (!impl_ || !impl_->w.position_btn) return; + if (label) + { + set_btn_label_text(impl_->w.position_btn, label); + fit_btn_to_label(impl_->w.position_btn, 8); + } + if (visible) + { + lv_obj_clear_flag(impl_->w.position_btn, LV_OBJ_FLAG_HIDDEN); + } + else + { + lv_obj_add_flag(impl_->w.position_btn, LV_OBJ_FLAG_HIDDEN); + } + + if (lv_group_t* g = lv_group_get_default()) + { + if (visible) + { + lv_group_add_obj(g, impl_->w.position_btn); + } + else + { + lv_group_remove_obj(impl_->w.position_btn); + } + } +} + std::string ChatComposeScreen::getText() const { if (!impl_ || !impl_->w.textarea) return ""; diff --git a/src/ui/screens/chat/chat_compose_components.h b/src/ui/screens/chat/chat_compose_components.h index 05353a64..d45a35a4 100644 --- a/src/ui/screens/chat/chat_compose_components.h +++ b/src/ui/screens/chat/chat_compose_components.h @@ -22,6 +22,7 @@ public: enum class ActionIntent { Send, + Position, Cancel }; @@ -29,6 +30,8 @@ public: ~ChatComposeScreen(); void setHeaderText(const char* title, const char* status = nullptr); + void setActionLabels(const char* send_label, const char* cancel_label); + void setPositionButton(const char* label, bool visible); std::string getText() const; void clearText(); diff --git a/src/ui/screens/chat/chat_compose_input.cpp b/src/ui/screens/chat/chat_compose_input.cpp index 58b793aa..e4bb928e 100644 --- a/src/ui/screens/chat/chat_compose_input.cpp +++ b/src/ui/screens/chat/chat_compose_input.cpp @@ -6,6 +6,9 @@ void setup_default_group_focus(const layout::Widgets& w) { if (lv_group_t* g = lv_group_get_default()) { lv_group_add_obj(g, w.textarea); lv_group_add_obj(g, w.send_btn); + if (w.position_btn && !lv_obj_has_flag(w.position_btn, LV_OBJ_FLAG_HIDDEN)) { + lv_group_add_obj(g, w.position_btn); + } lv_group_add_obj(g, w.cancel_btn); lv_group_focus_obj(w.textarea); } diff --git a/src/ui/screens/chat/chat_compose_layout.cpp b/src/ui/screens/chat/chat_compose_layout.cpp index 08c57152..5bc6c060 100644 --- a/src/ui/screens/chat/chat_compose_layout.cpp +++ b/src/ui/screens/chat/chat_compose_layout.cpp @@ -75,6 +75,9 @@ void create(lv_obj_t* parent, const Spec& spec, Widgets& w) { w.send_btn = create_btn_with_label(w.action_bar, spec.send_w, spec.btn_h, "Send"); lv_obj_set_style_pad_right(w.send_btn, spec.btn_gap, 0); + w.position_btn = create_btn_with_label(w.action_bar, spec.position_w, spec.btn_h, "Position"); + lv_obj_set_style_pad_right(w.position_btn, spec.btn_gap, 0); + w.cancel_btn = create_btn_with_label(w.action_bar, spec.cancel_w, spec.btn_h, "Cancel"); lv_obj_t* spacer = lv_obj_create(w.action_bar); @@ -87,4 +90,3 @@ void create(lv_obj_t* parent, const Spec& spec, Widgets& w) { } } // namespace chat::ui::compose::layout - diff --git a/src/ui/screens/chat/chat_compose_layout.h b/src/ui/screens/chat/chat_compose_layout.h index d7861ded..ea1e5929 100644 --- a/src/ui/screens/chat/chat_compose_layout.h +++ b/src/ui/screens/chat/chat_compose_layout.h @@ -14,6 +14,7 @@ struct Spec { int btn_h = 22; int send_w = 70; + int position_w = 80; int cancel_w = 80; int btn_gap = 10; }; @@ -25,6 +26,7 @@ struct Widgets { lv_obj_t* action_bar = nullptr; lv_obj_t* send_btn = nullptr; + lv_obj_t* position_btn = nullptr; lv_obj_t* cancel_btn = nullptr; lv_obj_t* len_label = nullptr; diff --git a/src/ui/screens/chat/chat_compose_styles.cpp b/src/ui/screens/chat/chat_compose_styles.cpp index c6f3a26e..10775e0b 100644 --- a/src/ui/screens/chat/chat_compose_styles.cpp +++ b/src/ui/screens/chat/chat_compose_styles.cpp @@ -71,6 +71,12 @@ void apply_all(const layout::Widgets& w) { lv_obj_add_style(w.send_btn, &s_btn_basic, LV_PART_MAIN); lv_obj_add_style(w.send_btn, &s_btn_focused, LV_STATE_FOCUSED); + if (w.position_btn) + { + lv_obj_add_style(w.position_btn, &s_btn_basic, LV_PART_MAIN); + lv_obj_add_style(w.position_btn, &s_btn_focused, LV_STATE_FOCUSED); + } + lv_obj_add_style(w.cancel_btn, &s_btn_basic, LV_PART_MAIN); lv_obj_add_style(w.cancel_btn, &s_btn_focused, LV_STATE_FOCUSED); diff --git a/src/ui/screens/chat/chat_conversation_components.cpp b/src/ui/screens/chat/chat_conversation_components.cpp index e02cb0cd..6665ea68 100644 --- a/src/ui/screens/chat/chat_conversation_components.cpp +++ b/src/ui/screens/chat/chat_conversation_components.cpp @@ -11,6 +11,7 @@ #include "chat_conversation_input.h" #include +#include #include #include @@ -59,40 +60,47 @@ static void format_message_time(char* out, size_t out_len, uint32_t ts) } uint32_t diff = now_secs - ts; - if (diff >= kSecondsPerYear) { - uint32_t years = diff / kSecondsPerYear; - if (years == 0) years = 1; - snprintf(out, out_len, "%uy ago", static_cast(years)); - return; - } - if (diff >= kSecondsPerMonth) { - uint32_t months = diff / kSecondsPerMonth; - if (months == 0) months = 1; - snprintf(out, out_len, "%um ago", static_cast(months)); - return; - } - if (diff >= kSecondsPerDay) { - uint32_t days = diff / kSecondsPerDay; - if (days == 0) days = 1; - snprintf(out, out_len, "%ud ago", static_cast(days)); - return; - } - if (ts_is_epoch) { - time_t t = ui_apply_timezone_offset(static_cast(ts)); - struct tm* info = gmtime(&t); - if (info) { - strftime(out, out_len, "%H:%M:%S", info); - } else { - snprintf(out, out_len, "--"); + if (!ts_is_epoch) { + if (diff < 60U) { + snprintf(out, out_len, "now"); + return; } + if (diff < 3600U) { + snprintf(out, out_len, "%um", static_cast(diff / 60U)); + return; + } + if (diff < kSecondsPerDay) { + snprintf(out, out_len, "%uh", static_cast(diff / 3600U)); + return; + } + if (diff < kSecondsPerMonth) { + snprintf(out, out_len, "%ud", static_cast(diff / kSecondsPerDay)); + return; + } + if (diff < kSecondsPerYear) { + snprintf(out, out_len, "%umo", static_cast(diff / kSecondsPerMonth)); + return; + } + snprintf(out, out_len, "%uy", static_cast(diff / kSecondsPerYear)); return; } - format_hms(out, out_len, ts % kSecondsPerDay); + + time_t t = ui_apply_timezone_offset(static_cast(ts)); + struct tm* info = gmtime(&t); + if (info) { + strftime(out, out_len, "%H:%M", info); + } else { + snprintf(out, out_len, "--"); + } } ChatConversationScreen::ChatConversationScreen(lv_obj_t* parent, chat::ConversationId conv) : conv_(conv) { + guard_ = new LifetimeGuard(); + guard_->alive = true; + guard_->pending_async = 0; + lv_obj_t* active = lv_screen_active(); if (!active) { Serial.printf("[ChatConversation] WARNING: lv_screen_active() is null\n"); @@ -118,11 +126,18 @@ ChatConversationScreen::ChatConversationScreen(lv_obj_t* parent, chat::Conversat chat::ui::conversation::styles::apply_reply_label(w.reply_label); // ----- Top bar (existing widget, unchanged behavior) ----- - ::ui::widgets::top_bar_init(top_bar_, w.topbar_host); + ::ui::widgets::top_bar_init(top_bar_, container_); const char* title = (conv_.peer == 0) ? "Broadcast" : "Direct"; ::ui::widgets::top_bar_set_title(top_bar_, title); ::ui::widgets::top_bar_set_right_text(top_bar_, ""); ::ui::widgets::top_bar_set_back_callback(top_bar_, handle_back, this); + if (top_bar_.container) { + lv_obj_move_to_index(top_bar_.container, 0); + } + + if (container_) { + lv_obj_add_event_cb(container_, on_root_deleted, LV_EVENT_DELETE, this); + } if (container_ && !lv_obj_is_valid(container_)) { Serial.printf("[ChatConversation] WARNING: container invalid\n"); @@ -132,22 +147,33 @@ ChatConversationScreen::ChatConversationScreen(lv_obj_t* parent, chat::Conversat } // ----- Event (unchanged) ----- - lv_obj_add_event_cb(reply_btn_, action_event_cb, LV_EVENT_CLICKED, this); + reply_ctx_.screen = this; + reply_ctx_.intent = ActionIntent::Reply; + lv_obj_add_event_cb(reply_btn_, action_event_cb, LV_EVENT_CLICKED, &reply_ctx_); // ----- Input layer (explicit, v0 no-op) ----- - chat::ui::conversation::input::init(this); + chat::ui::conversation::input::init(this, &input_binding_); } ChatConversationScreen::~ChatConversationScreen() { - chat::ui::conversation::input::cleanup(); - if (container_) { + if (container_ && lv_obj_is_valid(container_)) { lv_obj_del(container_); } + if (guard_) { + guard_->alive = false; + if (guard_->pending_async == 0) { + delete guard_; + } + guard_ = nullptr; + } } void ChatConversationScreen::addMessage(const chat::ChatMessage& msg) { + if (!guard_ || !guard_->alive || !msg_list_ || !lv_obj_is_valid(msg_list_)) { + return; + } if (messages_.size() >= MAX_DISPLAY_MESSAGES) { MessageItem& oldest = messages_[0]; if (oldest.container) { @@ -162,6 +188,9 @@ void ChatConversationScreen::addMessage(const chat::ChatMessage& msg) void ChatConversationScreen::clearMessages() { + if (!guard_ || !guard_->alive) { + return; + } for (auto& item : messages_) { if (item.container) { lv_obj_del(item.container); @@ -172,19 +201,25 @@ void ChatConversationScreen::clearMessages() void ChatConversationScreen::scrollToBottom() { - if (msg_list_) { + if (guard_ && guard_->alive && msg_list_) { lv_obj_scroll_to_y(msg_list_, LV_COORD_MAX, LV_ANIM_OFF); } } -void ChatConversationScreen::setActionCallback(void (*cb)(bool compose, void*), void* user_data) +void ChatConversationScreen::setActionCallback(void (*cb)(ActionIntent intent, void*), void* user_data) { + if (!guard_ || !guard_->alive) { + return; + } action_cb_ = cb; action_cb_user_data_ = user_data; } void ChatConversationScreen::setHeaderText(const char* title, const char* status) { + if (!guard_ || !guard_->alive) { + return; + } ::ui::widgets::top_bar_set_title(top_bar_, title); if (status != nullptr) { ::ui::widgets::top_bar_set_right_text(top_bar_, status); @@ -193,17 +228,26 @@ void ChatConversationScreen::setHeaderText(const char* title, const char* status void ChatConversationScreen::updateBatteryFromBoard() { + if (!guard_ || !guard_->alive) { + return; + } ui_update_top_bar_battery(top_bar_); } void ChatConversationScreen::setBackCallback(void (*cb)(void*), void* user_data) { + if (!guard_ || !guard_->alive) { + return; + } back_cb_ = cb; back_cb_user_data_ = user_data; } void ChatConversationScreen::createMessageItem(const chat::ChatMessage& msg) { + if (!guard_ || !guard_->alive || !msg_list_) { + return; + } MessageItem item; item.msg = msg; @@ -278,24 +322,173 @@ void ChatConversationScreen::createMessageItem(const chat::ChatMessage& msg) void ChatConversationScreen::action_event_cb(lv_event_t* e) { - ChatConversationScreen* screen = - static_cast(lv_event_get_user_data(e)); - if (!screen || !screen->action_cb_) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (!ctx || !ctx->screen) { return; } + ChatConversationScreen* screen = ctx->screen; + if (!screen->guard_ || !screen->guard_->alive) { + return; + } + screen->schedule_action_async(ctx->intent); +} - // Behavior parity: only Reply remains -> compose=false - (void)lv_event_get_target(e); - bool compose = false; - screen->action_cb_(compose, screen->action_cb_user_data_); +void ChatConversationScreen::async_action_cb(void* user_data) +{ + auto* payload = static_cast(user_data); + if (!payload) { + return; + } + LifetimeGuard* guard = payload->guard; + if (guard && guard->alive && payload->action_cb) { + payload->action_cb(payload->intent, payload->user_data); + } + if (guard && guard->pending_async > 0) { + guard->pending_async--; + if (!guard->alive && guard->pending_async == 0) { + delete guard; + } + } + delete payload; +} + +void ChatConversationScreen::on_root_deleted(lv_event_t* e) +{ + auto* screen = static_cast(lv_event_get_user_data(e)); + if (!screen) { + return; + } + screen->handle_root_deleted(); } void ChatConversationScreen::handle_back(void* user_data) { ChatConversationScreen* screen = static_cast(user_data); - if (screen != nullptr && screen->back_cb_ != nullptr) { - screen->back_cb_(screen->back_cb_user_data_); + if (!screen || !screen->guard_ || !screen->guard_->alive) { + return; } + screen->schedule_back_async(); +} + +void ChatConversationScreen::async_back_cb(void* user_data) +{ + auto* payload = static_cast(user_data); + if (!payload) { + return; + } + LifetimeGuard* guard = payload->guard; + if (guard && guard->alive && payload->back_cb) { + payload->back_cb(payload->user_data); + } + if (guard && guard->pending_async > 0) { + guard->pending_async--; + if (!guard->alive && guard->pending_async == 0) { + delete guard; + } + } + delete payload; +} + +lv_timer_t* ChatConversationScreen::add_timer(lv_timer_cb_t cb, + uint32_t period_ms, + void* user_data, + TimerDomain domain) +{ + if (!guard_ || !guard_->alive) { + return nullptr; + } + lv_timer_t* timer = lv_timer_create(cb, period_ms, user_data); + if (timer) { + TimerEntry entry; + entry.timer = timer; + entry.domain = domain; + timers_.push_back(entry); + } + return timer; +} + +void ChatConversationScreen::clear_timers(TimerDomain domain) +{ + if (timers_.empty()) { + return; + } + for (auto& entry : timers_) { + if (entry.timer && entry.domain == domain) { + lv_timer_del(entry.timer); + entry.timer = nullptr; + } + } + timers_.erase( + std::remove_if(timers_.begin(), timers_.end(), + [](const TimerEntry& entry) { return entry.timer == nullptr; }), + timers_.end()); +} + +void ChatConversationScreen::clear_all_timers() +{ + for (auto& entry : timers_) { + if (entry.timer) { + lv_timer_del(entry.timer); + entry.timer = nullptr; + } + } + timers_.clear(); +} + +void ChatConversationScreen::handle_root_deleted() +{ + if ((!guard_ || !guard_->alive) && container_ == nullptr && msg_list_ == nullptr) { + return; + } + + if (guard_) { + guard_->alive = false; + } + action_cb_ = nullptr; + action_cb_user_data_ = nullptr; + back_cb_ = nullptr; + back_cb_user_data_ = nullptr; + reply_ctx_.screen = nullptr; + + chat::ui::conversation::input::cleanup(&input_binding_); + clear_all_timers(); + + if (top_bar_.back_btn) { + ::ui::widgets::top_bar_set_back_callback(top_bar_, nullptr, nullptr); + } + + container_ = nullptr; + msg_list_ = nullptr; + action_bar_ = nullptr; + reply_btn_ = nullptr; + compose_btn_ = nullptr; +} + +void ChatConversationScreen::schedule_action_async(ActionIntent intent) +{ + if (!guard_ || !guard_->alive || !action_cb_) { + return; + } + auto* payload = new ActionPayload(); + payload->guard = guard_; + payload->action_cb = action_cb_; + payload->user_data = action_cb_user_data_; + payload->intent = intent; + guard_->pending_async++; + lv_async_call(async_action_cb, payload); +} + +void ChatConversationScreen::schedule_back_async() +{ + if (!guard_ || !guard_->alive || !back_cb_) { + return; + } + auto* payload = new BackPayload(); + payload->guard = guard_; + payload->back_cb = back_cb_; + payload->user_data = back_cb_user_data_; + guard_->pending_async++; + lv_async_call(async_back_cb, payload); } } // namespace ui diff --git a/src/ui/screens/chat/chat_conversation_components.h b/src/ui/screens/chat/chat_conversation_components.h index f9ee061a..8b180a31 100644 --- a/src/ui/screens/chat/chat_conversation_components.h +++ b/src/ui/screens/chat/chat_conversation_components.h @@ -7,6 +7,7 @@ #include "lvgl.h" #include "../../chat/domain/chat_types.h" #include "../../widgets/top_bar.h" +#include "chat_conversation_input.h" #include namespace chat { @@ -14,6 +15,10 @@ namespace ui { class ChatConversationScreen { public: + enum class ActionIntent { + Reply + }; + ChatConversationScreen(lv_obj_t* parent, chat::ConversationId conv); ~ChatConversationScreen(); @@ -21,7 +26,8 @@ public: void clearMessages(); void scrollToBottom(); - void setActionCallback(void (*cb)(bool compose, void*), void* user_data); + void setActionCallback(void (*cb)(ActionIntent intent, void*), void* user_data); + bool isAlive() const { return guard_ && guard_->alive; } lv_obj_t* getObj() const { return container_; } lv_obj_t* getMsgList() const { return msg_list_; } @@ -35,6 +41,39 @@ public: void setBackCallback(void (*cb)(void*), void* user_data); private: + enum class TimerDomain { + ScreenGeneral, + Input + }; + + struct TimerEntry { + lv_timer_t* timer = nullptr; + TimerDomain domain = TimerDomain::ScreenGeneral; + }; + + struct LifetimeGuard { + bool alive = false; + int pending_async = 0; + }; + + struct ActionContext { + ChatConversationScreen* screen = nullptr; + ActionIntent intent = ActionIntent::Reply; + }; + + struct ActionPayload { + LifetimeGuard* guard = nullptr; + void (*action_cb)(ActionIntent intent, void*) = nullptr; + void* user_data = nullptr; + ActionIntent intent = ActionIntent::Reply; + }; + + struct BackPayload { + LifetimeGuard* guard = nullptr; + void (*back_cb)(void*) = nullptr; + void* user_data = nullptr; + }; + lv_obj_t* container_ = nullptr; ::ui::widgets::TopBar top_bar_{}; lv_obj_t* msg_list_ = nullptr; @@ -43,7 +82,7 @@ private: lv_obj_t* compose_btn_ = nullptr; // kept for compatibility (not created in v0) chat::ConversationId conv_{}; - void (*action_cb_)(bool compose, void*) = nullptr; + void (*action_cb_)(ActionIntent intent, void*) = nullptr; void* action_cb_user_data_ = nullptr; void (*back_cb_)(void*) = nullptr; @@ -60,10 +99,26 @@ private: std::vector messages_; static constexpr size_t MAX_DISPLAY_MESSAGES = 100; + LifetimeGuard* guard_ = nullptr; + std::vector timers_; + conversation::input::Binding input_binding_{}; + ActionContext reply_ctx_{}; + void createMessageItem(const chat::ChatMessage& msg); static void action_event_cb(lv_event_t* e); + static void async_action_cb(void* user_data); + static void async_back_cb(void* user_data); + static void on_root_deleted(lv_event_t* e); static void handle_back(void* user_data); + + lv_timer_t* add_timer(lv_timer_cb_t cb, uint32_t period_ms, void* user_data, TimerDomain domain); + void clear_timers(TimerDomain domain); + void clear_all_timers(); + void handle_root_deleted(); + + void schedule_action_async(ActionIntent intent); + void schedule_back_async(); }; } // namespace ui diff --git a/src/ui/screens/chat/chat_conversation_input.cpp b/src/ui/screens/chat/chat_conversation_input.cpp index d7fb272c..960c47f4 100644 --- a/src/ui/screens/chat/chat_conversation_input.cpp +++ b/src/ui/screens/chat/chat_conversation_input.cpp @@ -11,8 +11,6 @@ namespace chat::ui::conversation::input { -static ChatConversationScreen* s_screen = nullptr; - namespace { constexpr int kEncoderKeyRotateUp = 19; constexpr int kEncoderKeyRotateDown = 20; @@ -22,7 +20,7 @@ constexpr lv_coord_t kScrollStep = 24; static void on_msg_list_key(lv_event_t* e) { auto* screen = static_cast(lv_event_get_user_data(e)); - if (!screen) return; + if (!screen || !screen->isAlive()) return; uint32_t key = lv_event_get_key(e); @@ -52,33 +50,63 @@ static void on_msg_list_key(lv_event_t* e) } } -void init(ChatConversationScreen* screen) +void init(ChatConversationScreen* screen, Binding* binding) { - s_screen = screen; - if (!s_screen) { + if (!binding) { + return; + } + binding->bound = false; + binding->msg_list = screen ? screen->getMsgList() : nullptr; + binding->reply_btn = screen ? screen->getReplyBtn() : nullptr; + binding->back_btn = screen ? screen->getBackBtn() : nullptr; + binding->group = lv_group_get_default(); + + if (!screen) { CHAT_CONV_INPUT_LOG("[ChatConversationInput] init (no screen)\n"); return; } - if (lv_group_t* g = lv_group_get_default()) { - if (lv_obj_t* msg_list = s_screen->getMsgList()) { - lv_group_add_obj(g, msg_list); - lv_group_focus_obj(msg_list); - lv_group_set_editing(g, true); - lv_obj_add_event_cb(msg_list, on_msg_list_key, LV_EVENT_KEY, s_screen); - } - if (lv_obj_t* reply_btn = s_screen->getReplyBtn()) { - lv_group_add_obj(g, reply_btn); - } - if (lv_obj_t* back_btn = s_screen->getBackBtn()) { - lv_group_add_obj(g, back_btn); - } + if (!binding->group) { + CHAT_CONV_INPUT_LOG("[ChatConversationInput] init (no group)\n"); + return; } + + if (binding->msg_list) { + lv_group_add_obj(binding->group, binding->msg_list); + lv_group_focus_obj(binding->msg_list); + lv_group_set_editing(binding->group, true); + lv_obj_add_event_cb(binding->msg_list, on_msg_list_key, LV_EVENT_KEY, screen); + } + if (binding->reply_btn) { + lv_group_add_obj(binding->group, binding->reply_btn); + } + if (binding->back_btn) { + lv_group_add_obj(binding->group, binding->back_btn); + } + binding->bound = true; CHAT_CONV_INPUT_LOG("[ChatConversationInput] init (group focus msg list)\n"); } -void cleanup() +void cleanup(Binding* binding) { - s_screen = nullptr; + if (!binding || !binding->bound) { + return; + } + if (binding->group) { + if (binding->msg_list) { + lv_group_remove_obj(binding->msg_list); + } + if (binding->reply_btn) { + lv_group_remove_obj(binding->reply_btn); + } + if (binding->back_btn) { + lv_group_remove_obj(binding->back_btn); + } + } + binding->msg_list = nullptr; + binding->reply_btn = nullptr; + binding->back_btn = nullptr; + binding->group = nullptr; + binding->bound = false; CHAT_CONV_INPUT_LOG("[ChatConversationInput] cleanup\n"); } diff --git a/src/ui/screens/chat/chat_conversation_input.h b/src/ui/screens/chat/chat_conversation_input.h index 35fdf679..09391cbb 100644 --- a/src/ui/screens/chat/chat_conversation_input.h +++ b/src/ui/screens/chat/chat_conversation_input.h @@ -1,13 +1,23 @@ #pragma once +#include "lvgl.h" + namespace chat::ui { class ChatConversationScreen; namespace conversation::input { -void init(ChatConversationScreen* screen); -void cleanup(); +struct Binding { + lv_obj_t* msg_list = nullptr; + lv_obj_t* reply_btn = nullptr; + lv_obj_t* back_btn = nullptr; + lv_group_t* group = nullptr; + bool bound = false; +}; + +void init(ChatConversationScreen* screen, Binding* binding); +void cleanup(Binding* binding); } // namespace conversation::input } // namespace chat::ui diff --git a/src/ui/screens/chat/chat_conversation_layout.cpp b/src/ui/screens/chat/chat_conversation_layout.cpp index fc995052..09380c1a 100644 --- a/src/ui/screens/chat/chat_conversation_layout.cpp +++ b/src/ui/screens/chat/chat_conversation_layout.cpp @@ -2,15 +2,14 @@ * @file chat_conversation_layout.cpp * @brief Layout (structure only) for ChatConversationScreen * - * UI Wireframe / Layout Tree (TopBarHost version) + * UI Wireframe / Layout Tree * -------------------------------------------------------------------- * * Root Container (COLUMN, full screen) * * ┌───────────────────────────────────────────────────────────────────┐ - * │ TopBarHost (fixed/content height, non-scrollable) │ + * │ TopBar widget (fixed height) │ * │ ┌─────────────────────────────────────────────────────────────┐ │ - * │ │ TopBar widget (created INSIDE TopBarHost) │ │ * │ │ < Back (Title) (Status/...) │ │ * │ └─────────────────────────────────────────────────────────────┘ │ * │ │ @@ -30,8 +29,7 @@ * * Tree view: * Root(COL) - * ├─ TopBarHost(COL/none, fixed/content) - * │ └─ TopBar(widget) // created by top_bar_init(top_bar_, TopBarHost) + * ├─ TopBar(widget) // created by top_bar_init(top_bar_, root) * ├─ MsgList(COL, scroll V, grow=1) * │ └─ MsgRow*(repeat, ROW, full) * │ └─ Bubble(COL, content) -> TextLabel(WRAP) @@ -40,7 +38,6 @@ * Notes: * - Structure/layout only: create objects, set size/flex/align/flags. * - Visual style (colors/radius/padding) lives in styles.*. - * - TopBar MUST be parented to TopBarHost to preserve layout order. */ #include @@ -65,15 +62,6 @@ ConversationWidgets create_conversation_base(lv_obj_t* parent) lv_obj_set_style_pad_row(w.root, 0, 0); make_non_scrollable(w.root); - // NEW: topbar host (must be the first child in root) - w.topbar_host = lv_obj_create(w.root); - lv_obj_set_size(w.topbar_host, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_grow(w.topbar_host, 0); - lv_obj_set_flex_flow(w.topbar_host, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_all(w.topbar_host, 0, 0); - lv_obj_set_style_pad_row(w.topbar_host, 0, 0); - make_non_scrollable(w.topbar_host); - // Msg list (scrollable, grow=1) w.msg_list = lv_obj_create(w.root); lv_obj_set_width(w.msg_list, LV_PCT(100)); diff --git a/src/ui/screens/chat/chat_conversation_layout.h b/src/ui/screens/chat/chat_conversation_layout.h index afd8e405..7c6010e6 100644 --- a/src/ui/screens/chat/chat_conversation_layout.h +++ b/src/ui/screens/chat/chat_conversation_layout.h @@ -8,9 +8,6 @@ namespace chat::ui::layout { struct ConversationWidgets { lv_obj_t* root = nullptr; - // NEW: host container for TopBar widget, guaranteed to be the 1st row in root(COL) - lv_obj_t* topbar_host = nullptr; - lv_obj_t* msg_list = nullptr; lv_obj_t* action_bar = nullptr; lv_obj_t* reply_btn = nullptr; @@ -18,8 +15,7 @@ struct ConversationWidgets { }; /** - * Root(Column): TopBarHost(fixed/content) + MsgList(grow=1) + ActionBar(fixed height) - * Note: TopBar widget MUST be created with parent = topbar_host, not root. + * Root(Column): TopBar(widget) + MsgList(grow=1) + ActionBar(fixed height) */ ConversationWidgets create_conversation_base(lv_obj_t* parent); diff --git a/src/ui/screens/chat/chat_message_list_components.cpp b/src/ui/screens/chat/chat_message_list_components.cpp index e7e6d587..96869e40 100644 --- a/src/ui/screens/chat/chat_message_list_components.cpp +++ b/src/ui/screens/chat/chat_message_list_components.cpp @@ -10,6 +10,7 @@ #include "chat_message_list_styles.h" #include "chat_message_list_input.h" +#include #include #include #include @@ -19,12 +20,46 @@ namespace chat { namespace ui { // ---- small helper (logic only, unchanged) ---- +namespace { +constexpr uint32_t kMinValidEpochSeconds = 1577836800U; // 2020-01-01 +constexpr uint32_t kSecondsPerDay = 24U * 60U * 60U; +constexpr uint32_t kSecondsPerMonth = 30U * kSecondsPerDay; +constexpr uint32_t kSecondsPerYear = 365U * kSecondsPerDay; + +static bool is_valid_epoch_ts(uint32_t ts) +{ + return ts >= kMinValidEpochSeconds; +} +} // namespace + static void format_time_hhmm(char out[16], uint32_t ts) { if (ts == 0) { snprintf(out, 16, "--:--"); return; } + if (!is_valid_epoch_ts(ts)) { + uint32_t now_epoch = static_cast(time(nullptr)); + uint32_t now_secs = is_valid_epoch_ts(now_epoch) ? now_epoch : static_cast(millis() / 1000U); + if (now_secs < ts) { + now_secs = ts; + } + uint32_t diff = now_secs - ts; + if (diff < 60U) { + snprintf(out, 16, "now"); + } else if (diff < 3600U) { + snprintf(out, 16, "%um", static_cast(diff / 60U)); + } else if (diff < kSecondsPerDay) { + snprintf(out, 16, "%uh", static_cast(diff / 3600U)); + } else if (diff < kSecondsPerMonth) { + snprintf(out, 16, "%ud", static_cast(diff / kSecondsPerDay)); + } else if (diff < kSecondsPerYear) { + snprintf(out, 16, "%umo", static_cast(diff / kSecondsPerMonth)); + } else { + snprintf(out, 16, "%uy", static_cast(diff / kSecondsPerYear)); + } + return; + } time_t t = ui_apply_timezone_offset(static_cast(ts)); struct tm* info = gmtime(&t); if (info) { @@ -46,6 +81,14 @@ static std::string truncate_preview(const std::string& text) return out; } +static bool is_team_conversation(const chat::ConversationId& conv) +{ + constexpr uint8_t kTeamChatChannelRaw = 2; + constexpr chat::ChannelId kTeamChatChannel = + static_cast(kTeamChatChannelRaw); + return conv.channel == kTeamChatChannel && conv.peer == 0; +} + // ------------------------------------------------ ChatMessageListScreen::ChatMessageListScreen(lv_obj_t* parent) @@ -57,11 +100,13 @@ ChatMessageListScreen::ChatMessageListScreen(lv_obj_t* parent) list_back_btn_(nullptr), selected_index_(0), filter_mode_(FilterMode::Direct), - select_cb_(nullptr), - select_cb_user_data_(nullptr), - back_cb_(nullptr), - back_cb_user_data_(nullptr) + action_cb_(nullptr), + action_cb_user_data_(nullptr) { + guard_ = new LifetimeGuard(); + guard_->alive = true; + guard_->pending_async = 0; + lv_obj_t* active = lv_screen_active(); if (!active) { Serial.printf("[ChatMessageList] WARNING: lv_screen_active() is null\n"); @@ -79,6 +124,7 @@ ChatMessageListScreen::ChatMessageListScreen(lv_obj_t* parent) list_panel_ = w.list_panel; direct_btn_ = w.direct_btn; broadcast_btn_ = w.broadcast_btn; + team_btn_ = w.team_btn; // ---------- Styles ---------- chat::ui::message_list::styles::apply_root_container(container_); @@ -86,6 +132,7 @@ ChatMessageListScreen::ChatMessageListScreen(lv_obj_t* parent) chat::ui::message_list::styles::apply_panel(list_panel_); if (direct_btn_) chat::ui::message_list::styles::apply_filter_btn(direct_btn_); if (broadcast_btn_) chat::ui::message_list::styles::apply_filter_btn(broadcast_btn_); + if (team_btn_) chat::ui::message_list::styles::apply_filter_btn(team_btn_); // ---------- Top bar (existing widget, unchanged) ---------- ::ui::widgets::top_bar_init(top_bar_, container_); @@ -96,6 +143,10 @@ ChatMessageListScreen::ChatMessageListScreen(lv_obj_t* parent) lv_obj_move_to_index(top_bar_.container, 0); } + if (container_) { + lv_obj_add_event_cb(container_, on_root_deleted, LV_EVENT_DELETE, this); + } + // ---------- Filter events ---------- if (direct_btn_) { lv_obj_add_event_cb(direct_btn_, filter_focus_cb, LV_EVENT_FOCUSED, this); @@ -105,6 +156,10 @@ ChatMessageListScreen::ChatMessageListScreen(lv_obj_t* parent) lv_obj_add_event_cb(broadcast_btn_, filter_focus_cb, LV_EVENT_FOCUSED, this); lv_obj_add_event_cb(broadcast_btn_, filter_click_cb, LV_EVENT_CLICKED, this); } + if (team_btn_) { + lv_obj_add_event_cb(team_btn_, filter_focus_cb, LV_EVENT_FOCUSED, this); + lv_obj_add_event_cb(team_btn_, filter_click_cb, LV_EVENT_CLICKED, this); + } updateFilterHighlight(); if (container_ && !lv_obj_is_valid(container_)) { @@ -117,34 +172,57 @@ ChatMessageListScreen::ChatMessageListScreen(lv_obj_t* parent) set_default_group(prev_group); // ---------- Input layer ---------- - chat::ui::message_list::input::init(this); + chat::ui::message_list::input::init(this, &input_binding_); } ChatMessageListScreen::~ChatMessageListScreen() { - // Explicit input cleanup (no behavior change) - chat::ui::message_list::input::cleanup(); - - // Same destruction behavior as original code - for (auto& item : items_) { - if (item.btn) { - lv_obj_del(item.btn); - } + if (container_ && lv_obj_is_valid(container_)) { + lv_obj_del(container_); } - if (container_) { - lv_obj_del(container_); + if (guard_) { + guard_->alive = false; + guard_->owner_dead = true; + if (guard_->pending_async == 0) { + delete guard_; + } + guard_ = nullptr; } } void ChatMessageListScreen::setConversations(const std::vector& convs) { + if (!guard_ || !guard_->alive) { + return; + } convs_ = convs; + bool has_team = false; + for (const auto& conv : convs_) { + if (is_team_conversation(conv.id)) { + has_team = true; + break; + } + } + if (team_btn_) { + if (has_team) { + lv_obj_clear_flag(team_btn_, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(team_btn_, LV_OBJ_FLAG_HIDDEN); + } + } + if (!has_team && filter_mode_ == FilterMode::Team) { + filter_mode_ = FilterMode::Broadcast; + } + updateFilterHighlight(); rebuildList(); } void ChatMessageListScreen::setSelected(int index) { + if (!guard_ || !guard_->alive) { + return; + } if (index >= 0 && index < static_cast(items_.size()) && items_[index].btn != nullptr) { selected_index_ = index; @@ -153,6 +231,9 @@ void ChatMessageListScreen::setSelected(int index) void ChatMessageListScreen::setSelectedConversation(const chat::ConversationId& conv) { + if (!guard_ || !guard_->alive) { + return; + } for (size_t i = 0; i < items_.size(); ++i) { if (items_[i].conv == conv) { setSelected(static_cast(i)); @@ -163,6 +244,9 @@ void ChatMessageListScreen::setSelectedConversation(const chat::ConversationId& chat::ConversationId ChatMessageListScreen::getSelectedConversation() const { + if (!guard_ || !guard_->alive) { + return chat::ConversationId(); + } if (selected_index_ >= 0 && selected_index_ < static_cast(items_.size())) { return items_[selected_index_].conv; @@ -172,36 +256,42 @@ chat::ConversationId ChatMessageListScreen::getSelectedConversation() const lv_obj_t* ChatMessageListScreen::getItemButton(size_t index) const { + if (!guard_ || !guard_->alive) { + return nullptr; + } if (index >= items_.size()) { return nullptr; } return items_[index].btn; } -void ChatMessageListScreen::setChannelSelectCallback( - void (*cb)(chat::ChannelId, void*), +void ChatMessageListScreen::setActionCallback( + void (*cb)(ActionIntent intent, const chat::ConversationId& conv, void*), void* user_data) { - select_cb_ = cb; - select_cb_user_data_ = user_data; + if (!guard_ || !guard_->alive) { + return; + } + action_cb_ = cb; + action_cb_user_data_ = user_data; } void ChatMessageListScreen::updateBatteryFromBoard() { + if (!guard_ || !guard_->alive) { + return; + } ui_update_top_bar_battery(top_bar_); } -void ChatMessageListScreen::setBackCallback(void (*cb)(void*), void* user_data) -{ - back_cb_ = cb; - back_cb_user_data_ = user_data; -} - // ------------------------------------------------ // Core logic: rebuild list (behavior unchanged) // ------------------------------------------------ void ChatMessageListScreen::rebuildList() { + if (!guard_ || !guard_->alive || !list_panel_ || !lv_obj_is_valid(list_panel_)) { + return; + } // Same behavior: clear and rebuild lv_obj_clean(list_panel_); items_.clear(); @@ -210,6 +300,12 @@ void ChatMessageListScreen::rebuildList() std::vector filtered; filtered.reserve(convs_.size()); for (const auto& conv : convs_) { + if (is_team_conversation(conv.id)) { + if (filter_mode_ == FilterMode::Team) { + filtered.push_back(conv); + } + continue; + } if (filter_mode_ == FilterMode::Direct && conv.id.peer != 0) { filtered.push_back(conv); } else if (filter_mode_ == FilterMode::Broadcast && conv.id.peer == 0) { @@ -283,14 +379,14 @@ void ChatMessageListScreen::rebuildList() setSelected(0); } - chat::ui::message_list::input::on_ui_refreshed(); + chat::ui::message_list::input::on_ui_refreshed(&input_binding_); } void ChatMessageListScreen::item_event_cb(lv_event_t* e) { auto* screen = static_cast(lv_event_get_user_data(e)); - if (!screen) { + if (!screen || !screen->guard_ || !screen->guard_->alive) { return; } @@ -298,11 +394,8 @@ void ChatMessageListScreen::item_event_cb(lv_event_t* e) for (size_t i = 0; i < screen->items_.size(); i++) { if (screen->items_[i].btn == btn) { screen->setSelected(static_cast(i)); - if (screen->select_cb_ != nullptr) { - screen->select_cb_( - screen->items_[i].conv.channel, - screen->select_cb_user_data_); - } + screen->schedule_action_async(ActionIntent::SelectConversation, + screen->items_[i].conv); break; } } @@ -312,17 +405,17 @@ void ChatMessageListScreen::list_back_event_cb(lv_event_t* e) { auto* screen = static_cast(lv_event_get_user_data(e)); - if (!screen) { + if (!screen || !screen->guard_ || !screen->guard_->alive) { return; } - chat::ui::message_list::input::focus_filter(); + chat::ui::message_list::input::focus_filter(&screen->input_binding_); } void ChatMessageListScreen::filter_focus_cb(lv_event_t* e) { auto* screen = static_cast(lv_event_get_user_data(e)); - if (!screen) { + if (!screen || !screen->guard_ || !screen->guard_->alive) { return; } lv_obj_t* tgt = static_cast(lv_event_get_target(e)); @@ -330,6 +423,8 @@ void ChatMessageListScreen::filter_focus_cb(lv_event_t* e) screen->setFilterMode(FilterMode::Direct); } else if (tgt == screen->broadcast_btn_) { screen->setFilterMode(FilterMode::Broadcast); + } else if (tgt == screen->team_btn_) { + screen->setFilterMode(FilterMode::Team); } } @@ -337,7 +432,7 @@ void ChatMessageListScreen::filter_click_cb(lv_event_t* e) { auto* screen = static_cast(lv_event_get_user_data(e)); - if (!screen) { + if (!screen || !screen->guard_ || !screen->guard_->alive) { return; } lv_obj_t* tgt = static_cast(lv_event_get_target(e)); @@ -345,8 +440,10 @@ void ChatMessageListScreen::filter_click_cb(lv_event_t* e) screen->setFilterMode(FilterMode::Direct); } else if (tgt == screen->broadcast_btn_) { screen->setFilterMode(FilterMode::Broadcast); + } else if (tgt == screen->team_btn_) { + screen->setFilterMode(FilterMode::Team); } - chat::ui::message_list::input::focus_list(); + chat::ui::message_list::input::focus_list(&screen->input_binding_); } void ChatMessageListScreen::updateFilterHighlight() @@ -356,15 +453,23 @@ void ChatMessageListScreen::updateFilterHighlight() } lv_obj_clear_state(direct_btn_, LV_STATE_CHECKED); lv_obj_clear_state(broadcast_btn_, LV_STATE_CHECKED); + if (team_btn_) { + lv_obj_clear_state(team_btn_, LV_STATE_CHECKED); + } if (filter_mode_ == FilterMode::Direct) { lv_obj_add_state(direct_btn_, LV_STATE_CHECKED); - } else { + } else if (filter_mode_ == FilterMode::Broadcast) { lv_obj_add_state(broadcast_btn_, LV_STATE_CHECKED); + } else if (team_btn_) { + lv_obj_add_state(team_btn_, LV_STATE_CHECKED); } } void ChatMessageListScreen::setFilterMode(FilterMode mode) { + if (!guard_ || !guard_->alive) { + return; + } if (filter_mode_ == mode) { return; } @@ -377,9 +482,131 @@ void ChatMessageListScreen::setFilterMode(FilterMode mode) void ChatMessageListScreen::handle_back(void* user_data) { auto* screen = static_cast(user_data); - if (screen && screen->back_cb_ != nullptr) { - screen->back_cb_(screen->back_cb_user_data_); + if (!screen || !screen->guard_ || !screen->guard_->alive) { + return; } + screen->schedule_action_async(ActionIntent::Back, chat::ConversationId()); +} + +void ChatMessageListScreen::async_action_cb(void* user_data) +{ + auto* payload = static_cast(user_data); + if (!payload) { + return; + } + LifetimeGuard* guard = payload->guard; + if (guard && guard->alive && payload->action_cb) { + payload->action_cb(payload->intent, payload->conv, payload->user_data); + } + if (guard && guard->pending_async > 0) { + guard->pending_async--; + if (guard->owner_dead && !guard->alive && guard->pending_async == 0) { + delete guard; + } + } + delete payload; +} + +void ChatMessageListScreen::on_root_deleted(lv_event_t* e) +{ + auto* screen = static_cast(lv_event_get_user_data(e)); + if (!screen) { + return; + } + screen->handle_root_deleted(); +} + +void ChatMessageListScreen::handle_root_deleted() +{ + if ((!guard_ || !guard_->alive) && container_ == nullptr) { + return; + } + + if (guard_) { + guard_->alive = false; + } + action_cb_ = nullptr; + action_cb_user_data_ = nullptr; + + chat::ui::message_list::input::cleanup(&input_binding_); + clear_all_timers(); + + if (top_bar_.back_btn) { + ::ui::widgets::top_bar_set_back_callback(top_bar_, nullptr, nullptr); + } + + items_.clear(); + convs_.clear(); + + container_ = nullptr; + filter_panel_ = nullptr; + list_panel_ = nullptr; + direct_btn_ = nullptr; + broadcast_btn_ = nullptr; + list_back_btn_ = nullptr; + +} + +void ChatMessageListScreen::schedule_action_async(ActionIntent intent, + const chat::ConversationId& conv) +{ + if (!guard_ || !guard_->alive || !action_cb_) { + return; + } + auto* payload = new ActionPayload(); + payload->guard = guard_; + payload->action_cb = action_cb_; + payload->user_data = action_cb_user_data_; + payload->intent = intent; + payload->conv = conv; + guard_->pending_async++; + lv_async_call(async_action_cb, payload); +} + +lv_timer_t* ChatMessageListScreen::add_timer(lv_timer_cb_t cb, + uint32_t period_ms, + void* user_data, + TimerDomain domain) +{ + if (!guard_ || !guard_->alive) { + return nullptr; + } + lv_timer_t* timer = lv_timer_create(cb, period_ms, user_data); + if (timer) { + TimerEntry entry; + entry.timer = timer; + entry.domain = domain; + timers_.push_back(entry); + } + return timer; +} + +void ChatMessageListScreen::clear_timers(TimerDomain domain) +{ + if (timers_.empty()) { + return; + } + for (auto& entry : timers_) { + if (entry.timer && entry.domain == domain) { + lv_timer_del(entry.timer); + entry.timer = nullptr; + } + } + timers_.erase( + std::remove_if(timers_.begin(), timers_.end(), + [](const TimerEntry& entry) { return entry.timer == nullptr; }), + timers_.end()); +} + +void ChatMessageListScreen::clear_all_timers() +{ + for (auto& entry : timers_) { + if (entry.timer) { + lv_timer_del(entry.timer); + entry.timer = nullptr; + } + } + timers_.clear(); } } // namespace ui diff --git a/src/ui/screens/chat/chat_message_list_components.h b/src/ui/screens/chat/chat_message_list_components.h index faf52c72..31c5459f 100644 --- a/src/ui/screens/chat/chat_message_list_components.h +++ b/src/ui/screens/chat/chat_message_list_components.h @@ -8,6 +8,7 @@ #include "lvgl.h" #include "../../chat/domain/chat_types.h" #include "../../widgets/top_bar.h" +#include "chat_message_list_input.h" #include #include @@ -16,6 +17,11 @@ namespace ui { class ChatMessageListScreen { public: + enum class ActionIntent { + SelectConversation, + Back + }; + explicit ChatMessageListScreen(lv_obj_t* parent); ~ChatMessageListScreen(); @@ -25,9 +31,6 @@ public: // Update status text (battery) from board state void updateBatteryFromBoard(); - // Set back button callback - void setBackCallback(void (*cb)(void*), void* user_data); - // Set selected item index void setSelected(int index); void setSelectedConversation(const chat::ConversationId& conv); @@ -35,13 +38,19 @@ public: // Get selected conversation chat::ConversationId getSelectedConversation() const; - // Set callback for selecting a channel (conversation) - void setChannelSelectCallback(void (*cb)(chat::ChannelId, void*), void* user_data); + // Set callback for user actions (select/back) + void setActionCallback(void (*cb)(ActionIntent intent, + const chat::ConversationId& conv, + void*), + void* user_data); + + bool isAlive() const { return guard_ && guard_->alive; } // Get LVGL root object lv_obj_t* getObj() const { return container_; } lv_obj_t* getDirectButton() const { return direct_btn_; } lv_obj_t* getBroadcastButton() const { return broadcast_btn_; } + lv_obj_t* getTeamButton() const { return team_btn_; } lv_obj_t* getBackButton() const { return top_bar_.back_btn; } lv_obj_t* getListBackButton() const { return list_back_btn_; } size_t getItemCount() const { return items_.size(); } @@ -49,9 +58,36 @@ public: int getSelectedIndex() const { return selected_index_; } private: + enum class TimerDomain { + ScreenGeneral, + Input + }; + + struct TimerEntry { + lv_timer_t* timer = nullptr; + TimerDomain domain = TimerDomain::ScreenGeneral; + }; + + struct LifetimeGuard { + bool alive = false; + int pending_async = 0; + bool owner_dead = false; + }; + + struct ActionPayload { + LifetimeGuard* guard = nullptr; + void (*action_cb)(ActionIntent intent, + const chat::ConversationId& conv, + void*) = nullptr; + void* user_data = nullptr; + ActionIntent intent = ActionIntent::SelectConversation; + chat::ConversationId conv{}; + }; + enum class FilterMode { Direct, - Broadcast + Broadcast, + Team }; lv_obj_t* container_ = nullptr; @@ -60,16 +96,16 @@ private: lv_obj_t* list_panel_ = nullptr; lv_obj_t* direct_btn_ = nullptr; lv_obj_t* broadcast_btn_ = nullptr; + lv_obj_t* team_btn_ = nullptr; lv_obj_t* list_back_btn_ = nullptr; int selected_index_ = 0; FilterMode filter_mode_ = FilterMode::Direct; - void (*select_cb_)(chat::ChannelId, void*) = nullptr; - void* select_cb_user_data_ = nullptr; - - void (*back_cb_)(void*) = nullptr; - void* back_cb_user_data_ = nullptr; + void (*action_cb_)(ActionIntent intent, + const chat::ConversationId& conv, + void*) = nullptr; + void* action_cb_user_data_ = nullptr; struct MessageItem { chat::ConversationId conv{}; @@ -84,6 +120,10 @@ private: std::vector items_; std::vector convs_; + LifetimeGuard* guard_ = nullptr; + std::vector timers_; + message_list::input::Binding input_binding_{}; + void rebuildList(); void updateFilterHighlight(); void setFilterMode(FilterMode mode); @@ -92,7 +132,15 @@ private: static void list_back_event_cb(lv_event_t* e); static void filter_focus_cb(lv_event_t* e); static void filter_click_cb(lv_event_t* e); + static void async_action_cb(void* user_data); + static void on_root_deleted(lv_event_t* e); static void handle_back(void* user_data); + + void handle_root_deleted(); + void schedule_action_async(ActionIntent intent, const chat::ConversationId& conv); + lv_timer_t* add_timer(lv_timer_cb_t cb, uint32_t period_ms, void* user_data, TimerDomain domain); + void clear_timers(TimerDomain domain); + void clear_all_timers(); }; } // namespace ui diff --git a/src/ui/screens/chat/chat_message_list_input.cpp b/src/ui/screens/chat/chat_message_list_input.cpp index 9bdaae84..144d21be 100644 --- a/src/ui/screens/chat/chat_message_list_input.cpp +++ b/src/ui/screens/chat/chat_message_list_input.cpp @@ -25,16 +25,6 @@ namespace chat::ui::message_list::input { namespace { -enum class FocusColumn { - Filter = 0, - List = 1 -}; - -static ChatMessageListScreen* s_screen = nullptr; -static lv_group_t* s_group = nullptr; -static lv_group_t* s_prev_group = nullptr; -static FocusColumn s_col = FocusColumn::Filter; - static bool is_encoder_active() { lv_indev_t* indev = lv_indev_get_act(); @@ -47,150 +37,178 @@ static void group_clear_all(lv_group_t* g) lv_group_remove_all_objs(g); } -static void focus_first_valid(lv_obj_t* obj) +static bool screen_alive(const Binding* binding) { - if (!s_group || !obj || !lv_obj_is_valid(obj)) return; + return binding && binding->screen && binding->screen->isAlive(); +} + +static void focus_first_valid(Binding* binding, lv_obj_t* obj) +{ + if (!binding || !binding->group || !obj || !lv_obj_is_valid(obj)) return; lv_group_focus_obj(obj); } -static void group_add_if_valid(lv_obj_t* obj) +static void group_add_if_valid(Binding* binding, lv_obj_t* obj) { - if (!s_group || !obj || !lv_obj_is_valid(obj)) return; - lv_group_add_obj(s_group, obj); + if (!binding || !binding->group || !obj || !lv_obj_is_valid(obj)) return; + lv_group_add_obj(binding->group, obj); } -static void clear_list_focus_states() +static bool is_visible(lv_obj_t* obj) { - if (!s_screen) return; + return obj && lv_obj_is_valid(obj) && !lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN); +} + +static void clear_list_focus_states(Binding* binding) +{ + if (!binding || !binding->screen) return; lv_state_t clear_mask = (lv_state_t)(LV_STATE_FOCUSED | LV_STATE_FOCUS_KEY); - size_t count = s_screen->getItemCount(); + size_t count = binding->screen->getItemCount(); for (size_t i = 0; i < count; ++i) { - if (lv_obj_t* btn = s_screen->getItemButton(i)) { + if (lv_obj_t* btn = binding->screen->getItemButton(i)) { if (lv_obj_is_valid(btn)) { lv_obj_clear_state(btn, clear_mask); } } } - if (lv_obj_t* back = s_screen->getListBackButton()) { + if (lv_obj_t* back = binding->screen->getListBackButton()) { if (lv_obj_is_valid(back)) { lv_obj_clear_state(back, clear_mask); } } } -static void bind_filter_column() +static void bind_filter_column(Binding* binding) { - if (!s_group || !s_screen) return; - lv_group_focus_freeze(s_group, true); - group_clear_all(s_group); - clear_list_focus_states(); + if (!binding || !binding->group || !binding->screen) return; + lv_group_focus_freeze(binding->group, true); + group_clear_all(binding->group); + clear_list_focus_states(binding); - if (lv_obj_t* back = s_screen->getBackButton()) { - group_add_if_valid(back); + if (lv_obj_t* back = binding->screen->getBackButton()) { + group_add_if_valid(binding, back); } - if (lv_obj_t* direct = s_screen->getDirectButton()) { - group_add_if_valid(direct); + if (lv_obj_t* direct = binding->screen->getDirectButton()) { + group_add_if_valid(binding, direct); } - if (lv_obj_t* broadcast = s_screen->getBroadcastButton()) { - group_add_if_valid(broadcast); + if (lv_obj_t* broadcast = binding->screen->getBroadcastButton()) { + group_add_if_valid(binding, broadcast); + } + if (lv_obj_t* team = binding->screen->getTeamButton()) { + if (is_visible(team)) { + group_add_if_valid(binding, team); + } } - lv_group_focus_freeze(s_group, false); + lv_group_focus_freeze(binding->group, false); - if (lv_obj_t* direct = s_screen->getDirectButton()) { + if (lv_obj_t* direct = binding->screen->getDirectButton()) { if (lv_obj_has_state(direct, LV_STATE_CHECKED)) { - focus_first_valid(direct); + focus_first_valid(binding, direct); return; } } - if (lv_obj_t* broadcast = s_screen->getBroadcastButton()) { + if (lv_obj_t* broadcast = binding->screen->getBroadcastButton()) { if (lv_obj_has_state(broadcast, LV_STATE_CHECKED)) { - focus_first_valid(broadcast); + focus_first_valid(binding, broadcast); + return; + } + } + if (lv_obj_t* team = binding->screen->getTeamButton()) { + if (is_visible(team) && lv_obj_has_state(team, LV_STATE_CHECKED)) { + focus_first_valid(binding, team); return; } } - if (lv_obj_t* direct = s_screen->getDirectButton()) { - focus_first_valid(direct); - } else if (lv_obj_t* broadcast = s_screen->getBroadcastButton()) { - focus_first_valid(broadcast); - } else if (lv_obj_t* back = s_screen->getBackButton()) { - focus_first_valid(back); + if (lv_obj_t* direct = binding->screen->getDirectButton()) { + focus_first_valid(binding, direct); + } else if (lv_obj_t* broadcast = binding->screen->getBroadcastButton()) { + focus_first_valid(binding, broadcast); + } else if (lv_obj_t* team = binding->screen->getTeamButton()) { + if (is_visible(team)) { + focus_first_valid(binding, team); + } + } else if (lv_obj_t* back = binding->screen->getBackButton()) { + focus_first_valid(binding, back); } } -static void bind_list_column() +static void bind_list_column(Binding* binding) { - if (!s_group || !s_screen) return; - lv_group_focus_freeze(s_group, true); - group_clear_all(s_group); + if (!binding || !binding->group || !binding->screen) return; + lv_group_focus_freeze(binding->group, true); + group_clear_all(binding->group); - size_t count = s_screen->getItemCount(); + size_t count = binding->screen->getItemCount(); for (size_t i = 0; i < count; ++i) { - if (lv_obj_t* btn = s_screen->getItemButton(i)) { - group_add_if_valid(btn); + if (lv_obj_t* btn = binding->screen->getItemButton(i)) { + group_add_if_valid(binding, btn); } } - if (lv_obj_t* back = s_screen->getListBackButton()) { - group_add_if_valid(back); + if (lv_obj_t* back = binding->screen->getListBackButton()) { + group_add_if_valid(binding, back); } - lv_group_focus_freeze(s_group, false); + lv_group_focus_freeze(binding->group, false); if (count > 0) { - int selected = s_screen->getSelectedIndex(); + int selected = binding->screen->getSelectedIndex(); if (selected >= 0 && static_cast(selected) < count) { - focus_first_valid(s_screen->getItemButton(static_cast(selected))); + focus_first_valid(binding, binding->screen->getItemButton(static_cast(selected))); } else { - focus_first_valid(s_screen->getItemButton(0)); + focus_first_valid(binding, binding->screen->getItemButton(0)); } - } else if (lv_obj_t* back = s_screen->getListBackButton()) { - focus_first_valid(back); + } else if (lv_obj_t* back = binding->screen->getListBackButton()) { + focus_first_valid(binding, back); } else { - s_col = FocusColumn::Filter; - bind_filter_column(); + binding->col = FocusColumn::Filter; + bind_filter_column(binding); } } -static void rebind_by_column() +static void rebind_by_column(Binding* binding) { - if (s_col == FocusColumn::Filter) { - bind_filter_column(); + if (!binding) return; + if (binding->col == FocusColumn::Filter) { + bind_filter_column(binding); } else { - bind_list_column(); + bind_list_column(binding); } } static void root_key_event_cb(lv_event_t* e) { if (!is_encoder_active()) return; + auto* binding = static_cast(lv_event_get_user_data(e)); + if (!screen_alive(binding)) return; uint32_t key = lv_event_get_key(e); if (key == LV_KEY_ESC || key == LV_KEY_BACKSPACE) { - s_col = FocusColumn::Filter; - rebind_by_column(); + binding->col = FocusColumn::Filter; + rebind_by_column(binding); return; } if (key != LV_KEY_ENTER) return; - lv_obj_t* focused = s_group ? lv_group_get_focused(s_group) : nullptr; - if (!focused || !s_screen) return; + lv_obj_t* focused = binding->group ? lv_group_get_focused(binding->group) : nullptr; + if (!focused || !binding->screen) return; - if (s_col == FocusColumn::Filter) { - if (focused == s_screen->getBackButton()) { + if (binding->col == FocusColumn::Filter) { + if (focused == binding->screen->getBackButton()) { lv_obj_send_event(focused, LV_EVENT_CLICKED, nullptr); return; } - s_col = FocusColumn::List; - rebind_by_column(); + binding->col = FocusColumn::List; + rebind_by_column(binding); return; } - if (s_col == FocusColumn::List) { - if (focused == s_screen->getListBackButton()) { - s_col = FocusColumn::Filter; - rebind_by_column(); + if (binding->col == FocusColumn::List) { + if (focused == binding->screen->getListBackButton()) { + binding->col = FocusColumn::Filter; + rebind_by_column(binding); return; } } @@ -198,59 +216,68 @@ static void root_key_event_cb(lv_event_t* e) } // namespace -void init(ChatMessageListScreen* screen) +void init(ChatMessageListScreen* screen, Binding* binding) { - s_screen = screen; - if (s_group) { - cleanup(); + if (!binding) { + return; + } + if (binding->bound) { + cleanup(binding); } - s_group = lv_group_create(); - s_prev_group = lv_group_get_default(); - set_default_group(nullptr); - s_col = FocusColumn::Filter; - rebind_by_column(); - set_default_group(s_group); - if (s_screen && s_screen->getObj()) { - lv_obj_add_event_cb(s_screen->getObj(), root_key_event_cb, LV_EVENT_KEY, nullptr); + binding->screen = screen; + binding->group = lv_group_create(); + binding->prev_group = lv_group_get_default(); + set_default_group(nullptr); + binding->col = FocusColumn::Filter; + rebind_by_column(binding); + set_default_group(binding->group); + + if (binding->screen && binding->screen->getObj()) { + lv_obj_add_event_cb(binding->screen->getObj(), root_key_event_cb, LV_EVENT_KEY, binding); } + binding->bound = true; CHAT_INPUT_LOG("[ChatMessageListInput] init\n"); } -void cleanup() +void cleanup(Binding* binding) { - s_screen = nullptr; - if (s_group) { + if (!binding || !binding->bound) { + return; + } + binding->screen = nullptr; + if (binding->group) { set_default_group(nullptr); - lv_group_del(s_group); - s_group = nullptr; + lv_group_del(binding->group); + binding->group = nullptr; } - if (s_prev_group) { - set_default_group(s_prev_group); + if (binding->prev_group) { + set_default_group(binding->prev_group); } - s_prev_group = nullptr; + binding->prev_group = nullptr; + binding->bound = false; CHAT_INPUT_LOG("[ChatMessageListInput] cleanup\n"); } -void on_ui_refreshed() +void on_ui_refreshed(Binding* binding) { - if (!s_group) return; - rebind_by_column(); + if (!binding || !binding->group || !screen_alive(binding)) return; + rebind_by_column(binding); } -void focus_filter() +void focus_filter(Binding* binding) { - if (!s_group) return; - s_col = FocusColumn::Filter; - rebind_by_column(); + if (!binding || !binding->group || !screen_alive(binding)) return; + binding->col = FocusColumn::Filter; + rebind_by_column(binding); } -void focus_list() +void focus_list(Binding* binding) { - if (!s_group) return; - s_col = FocusColumn::List; - rebind_by_column(); + if (!binding || !binding->group || !screen_alive(binding)) return; + binding->col = FocusColumn::List; + rebind_by_column(binding); } -} // namespace chat::ui::input +} // namespace chat::ui::message_list::input diff --git a/src/ui/screens/chat/chat_message_list_input.h b/src/ui/screens/chat/chat_message_list_input.h index 97bfa5a3..7db702cd 100644 --- a/src/ui/screens/chat/chat_message_list_input.h +++ b/src/ui/screens/chat/chat_message_list_input.h @@ -9,36 +9,43 @@ #pragma once +#include "lvgl.h" + namespace chat::ui { class ChatMessageListScreen; namespace message_list::input { +enum class FocusColumn { + Filter = 0, + List = 1 +}; + +struct Binding { + ChatMessageListScreen* screen = nullptr; + lv_group_t* group = nullptr; + lv_group_t* prev_group = nullptr; + FocusColumn col = FocusColumn::Filter; + bool bound = false; +}; + /** * @brief Initialize input handling for the screen - * - * Behavior today: - * - No additional event handlers are installed. - * - No focus policy is changed. - * - This function only establishes an explicit extension point. */ -void init(ChatMessageListScreen* screen); +void init(ChatMessageListScreen* screen, Binding* binding); /** * @brief Clean up input handling - * - * Behavior today: - * - No-op (placeholder for future unregistration if needed). */ -void cleanup(); +void cleanup(Binding* binding); /** * @brief Rebind focus after UI rebuild */ -void on_ui_refreshed(); -void focus_filter(); -void focus_list(); +void on_ui_refreshed(Binding* binding); +void focus_filter(Binding* binding); +void focus_list(Binding* binding); } // namespace message_list::input } // namespace chat::ui diff --git a/src/ui/screens/chat/chat_message_list_layout.cpp b/src/ui/screens/chat/chat_message_list_layout.cpp index e0ecba71..84a8411c 100644 --- a/src/ui/screens/chat/chat_message_list_layout.cpp +++ b/src/ui/screens/chat/chat_message_list_layout.cpp @@ -99,7 +99,10 @@ static lv_obj_t* create_content(lv_obj_t* parent) return content; } -static lv_obj_t* create_filter_panel(lv_obj_t* parent, lv_obj_t** direct_btn, lv_obj_t** broadcast_btn) +static lv_obj_t* create_filter_panel(lv_obj_t* parent, + lv_obj_t** direct_btn, + lv_obj_t** broadcast_btn, + lv_obj_t** team_btn) { lv_obj_t* panel = lv_obj_create(parent); lv_obj_set_width(panel, kFilterPanelWidth); @@ -123,8 +126,17 @@ static lv_obj_t* create_filter_panel(lv_obj_t* parent, lv_obj_t** direct_btn, lv lv_label_set_text(broadcast_label, "Broadcast"); lv_obj_center(broadcast_label); + lv_obj_t* team = lv_btn_create(panel); + lv_obj_set_size(team, LV_PCT(100), kButtonHeight); + make_non_scrollable(team); + lv_obj_t* team_label = lv_label_create(team); + lv_label_set_text(team_label, "Team"); + lv_obj_center(team_label); + lv_obj_add_flag(team, LV_OBJ_FLAG_HIDDEN); + if (direct_btn) *direct_btn = direct; if (broadcast_btn) *broadcast_btn = broadcast; + if (team_btn) *team_btn = team; return panel; } @@ -152,7 +164,7 @@ MessageListLayout create_layout(lv_obj_t* parent) MessageListLayout w{}; w.root = create_root(parent); w.content = create_content(w.root); - w.filter_panel = create_filter_panel(w.content, &w.direct_btn, &w.broadcast_btn); + w.filter_panel = create_filter_panel(w.content, &w.direct_btn, &w.broadcast_btn, &w.team_btn); w.list_panel = create_list_panel(w.content); return w; } diff --git a/src/ui/screens/chat/chat_message_list_layout.h b/src/ui/screens/chat/chat_message_list_layout.h index 89d7e9d6..7f125436 100644 --- a/src/ui/screens/chat/chat_message_list_layout.h +++ b/src/ui/screens/chat/chat_message_list_layout.h @@ -11,6 +11,7 @@ struct MessageListLayout { lv_obj_t* list_panel; lv_obj_t* direct_btn; lv_obj_t* broadcast_btn; + lv_obj_t* team_btn; }; // root/page diff --git a/src/ui/screens/contacts/contacts_page_components.cpp b/src/ui/screens/contacts/contacts_page_components.cpp index 57a50a92..6b60d096 100644 --- a/src/ui/screens/contacts/contacts_page_components.cpp +++ b/src/ui/screens/contacts/contacts_page_components.cpp @@ -14,11 +14,21 @@ #include "../../widgets/ime/ime_widget.h" #include "../../../chat/usecase/contact_service.h" #include "../../../chat/usecase/chat_service.h" +#include "../../../app/app_context.h" +#include "../../../gps/gps_service_api.h" #include "../chat/chat_compose_components.h" +#include "../chat/chat_conversation_components.h" +#include "../team/team_state.h" +#include "../team/team_ui_store.h" +#include "../../../team/protocol/team_chat.h" +#include "meshtastic/mesh.pb.h" +#include "pb_encode.h" +#include "../../widgets/system_notification.h" #include #include #include +#include #include #define CONTACTS_DEBUG 0 @@ -41,6 +51,13 @@ static lv_group_t* s_compose_prev_group = nullptr; static uint32_t s_compose_peer_id = 0; static chat::ChannelId s_compose_channel = chat::ChannelId::PRIMARY; static bool s_refreshing_ui = false; +static lv_group_t* s_conv_group = nullptr; +static lv_group_t* s_conv_prev_group = nullptr; +static bool s_compose_from_conversation = false; +static bool s_compose_is_team = false; +static std::string s_last_sent_text; +static uint32_t s_last_sent_ts = 0; +static uint32_t s_team_msg_id = 1; // --- Forward declarations (same behavior as original) --- static void contacts_btn_event_handler(lv_event_t* e); @@ -77,6 +94,12 @@ static void close_chat_compose(); static void on_compose_action(chat::ui::ChatComposeScreen::ActionIntent intent, void* user_data); static void on_compose_back(void* user_data); static void on_compose_send_done(bool ok, bool timeout, void* user_data); +static void open_team_conversation(); +static void close_team_conversation(); +static void refresh_team_conversation(); +static void on_team_conversation_action(chat::ui::ChatConversationScreen::ActionIntent intent, void* user_data); +static void on_team_conversation_back(void* user_data); +static void send_team_position(); // Forward declaration - actual implementation moved to ui_contacts.cpp // to avoid library compilation issues with Arduino framework dependencies @@ -144,6 +167,110 @@ static std::string format_snr(float snr) return std::string(buf); } +static const char* team_command_name(team::proto::TeamCommandType type) +{ + switch (type) { + case team::proto::TeamCommandType::RallyTo: + return "RallyTo"; + case team::proto::TeamCommandType::MoveTo: + return "MoveTo"; + case team::proto::TeamCommandType::Hold: + return "Hold"; + default: + return "Command"; + } +} + +static std::string truncate_text(const std::string& text, size_t max_len) +{ + if (text.size() <= max_len) { + return text; + } + if (max_len <= 3) { + return text.substr(0, max_len); + } + return text.substr(0, max_len - 3) + "..."; +} + +static std::string format_team_chat_entry(const team::ui::TeamChatLogEntry& entry) +{ + if (entry.type == team::proto::TeamChatType::Text) { + std::string text(entry.payload.begin(), entry.payload.end()); + return truncate_text(text, 160); + } + if (entry.type == team::proto::TeamChatType::Location) { + team::proto::TeamChatLocation loc; + if (team::proto::decodeTeamChatLocation(entry.payload.data(), entry.payload.size(), &loc)) { + double lat = static_cast(loc.lat_e7) / 1e7; + double lon = static_cast(loc.lon_e7) / 1e7; + char buf[128]; + if (!loc.label.empty()) { + snprintf(buf, sizeof(buf), "Location: %s %.5f, %.5f", loc.label.c_str(), lat, lon); + } else { + snprintf(buf, sizeof(buf), "Location: %.5f, %.5f", lat, lon); + } + return std::string(buf); + } + return "Location"; + } + if (entry.type == team::proto::TeamChatType::Command) { + team::proto::TeamChatCommand cmd; + if (team::proto::decodeTeamChatCommand(entry.payload.data(), entry.payload.size(), &cmd)) { + const char* name = team_command_name(cmd.cmd_type); + double lat = static_cast(cmd.lat_e7) / 1e7; + double lon = static_cast(cmd.lon_e7) / 1e7; + char buf[160]; + if (cmd.lat_e7 != 0 || cmd.lon_e7 != 0) { + if (!cmd.note.empty()) { + snprintf(buf, sizeof(buf), "Command: %s %.5f, %.5f %s", + name, lat, lon, cmd.note.c_str()); + } else { + snprintf(buf, sizeof(buf), "Command: %s %.5f, %.5f", + name, lat, lon); + } + } else if (!cmd.note.empty()) { + snprintf(buf, sizeof(buf), "Command: %s %s", name, cmd.note.c_str()); + } else { + snprintf(buf, sizeof(buf), "Command: %s", name); + } + return std::string(buf); + } + return "Command"; + } + return "Message"; +} + +static void refresh_team_state_from_store() +{ + if (team::ui::g_team_state.in_team && team::ui::g_team_state.has_team_id) + { + return; + } + team::ui::TeamUiSnapshot snap; + if (!team::ui::team_ui_get_store().load(snap)) + { + return; + } + team::ui::g_team_state.in_team = snap.in_team; + team::ui::g_team_state.has_team_id = snap.has_team_id; + team::ui::g_team_state.team_id = snap.team_id; + team::ui::g_team_state.team_name = snap.team_name; + team::ui::g_team_state.has_team_psk = snap.has_team_psk; + team::ui::g_team_state.security_round = snap.security_round; + if (snap.has_team_psk) + { + team::ui::g_team_state.team_psk = snap.team_psk; + } + team::ui::g_team_state.members = snap.members; +} + +static bool is_team_available() +{ + refresh_team_state_from_store(); + // Team chat should be reachable once we know a team_id (e.g. after receiving TEAM_CHAT). + return team::ui::g_team_state.has_team_id; +} + // ---------------- Panel creation (public API) ---------------- void create_filter_panel(lv_obj_t* parent) @@ -168,11 +295,9 @@ void create_filter_panel(lv_obj_t* parent) lv_obj_add_event_cb(g_contacts_state.broadcast_btn, on_filter_focused, LV_EVENT_FOCUSED, nullptr); lv_obj_add_event_cb(g_contacts_state.broadcast_btn, on_filter_clicked, LV_EVENT_CLICKED, nullptr); } - - // TopBar back button must be reachable by rotary and also respond to press - // (Press should exit page, not enter List column) - if (g_contacts_state.top_bar.back_btn) { - lv_obj_add_event_cb(g_contacts_state.top_bar.back_btn, on_filter_clicked, LV_EVENT_CLICKED, nullptr); + if (g_contacts_state.team_btn) { + lv_obj_add_event_cb(g_contacts_state.team_btn, on_filter_focused, LV_EVENT_FOCUSED, nullptr); + lv_obj_add_event_cb(g_contacts_state.team_btn, on_filter_clicked, LV_EVENT_CLICKED, nullptr); } // Keep highlight consistent with mode using CHECKED state @@ -181,13 +306,18 @@ void create_filter_panel(lv_obj_t* parent) lv_obj_clear_state(g_contacts_state.contacts_btn, LV_STATE_CHECKED); lv_obj_clear_state(g_contacts_state.nearby_btn, LV_STATE_CHECKED); lv_obj_clear_state(g_contacts_state.broadcast_btn, LV_STATE_CHECKED); + if (g_contacts_state.team_btn) { + lv_obj_clear_state(g_contacts_state.team_btn, LV_STATE_CHECKED); + } if (g_contacts_state.current_mode == ContactsMode::Contacts) { lv_obj_add_state(g_contacts_state.contacts_btn, LV_STATE_CHECKED); } else if (g_contacts_state.current_mode == ContactsMode::Nearby) { lv_obj_add_state(g_contacts_state.nearby_btn, LV_STATE_CHECKED); - } else { + } else if (g_contacts_state.current_mode == ContactsMode::Broadcast) { lv_obj_add_state(g_contacts_state.broadcast_btn, LV_STATE_CHECKED); + } else if (g_contacts_state.team_btn) { + lv_obj_add_state(g_contacts_state.team_btn, LV_STATE_CHECKED); } } } @@ -234,12 +364,14 @@ static void on_filter_focused(lv_event_t* e) if (tgt == g_contacts_state.contacts_btn) new_mode = ContactsMode::Contacts; else if (tgt == g_contacts_state.nearby_btn) new_mode = ContactsMode::Nearby; else if (tgt == g_contacts_state.broadcast_btn) new_mode = ContactsMode::Broadcast; + else if (tgt == g_contacts_state.team_btn) new_mode = ContactsMode::Team; else return; if (new_mode != g_contacts_state.current_mode) { g_contacts_state.current_mode = new_mode; g_contacts_state.current_page = 0; g_contacts_state.selected_index = -1; + refresh_contacts_data(); refresh_ui(); // 旋转到另一个按钮,就刷新第二列 } } @@ -248,17 +380,12 @@ static void on_filter_clicked(lv_event_t* e) { lv_obj_t* tgt = (lv_obj_t*)lv_event_get_target(e); - // Press on topbar back: let topbar handle exit (its own CLICKED callback) - if (tgt == g_contacts_state.top_bar.back_btn) { - lv_obj_send_event(tgt, LV_EVENT_CLICKED, nullptr); - return; - } - // Press on Contacts/Nearby: move focus to List column contacts_focus_to_list(); } + static void on_list_item_clicked(lv_event_t* e) { lv_obj_t* item = (lv_obj_t*)lv_event_get_target(e); @@ -306,7 +433,8 @@ static void on_back_clicked(lv_event_t* /*e*/) static const chat::contacts::NodeInfo* get_selected_node() { - if (g_contacts_state.current_mode == ContactsMode::Broadcast) { + if (g_contacts_state.current_mode == ContactsMode::Broadcast || + g_contacts_state.current_mode == ContactsMode::Team) { return nullptr; } if (g_contacts_state.selected_index < 0) { @@ -592,8 +720,16 @@ static void open_chat_compose() if (g_contacts_state.compose_screen) { return; } + if (!g_contacts_state.conversation_screen) { + s_compose_from_conversation = false; + } const auto* node = get_selected_node(); - if (g_contacts_state.current_mode != ContactsMode::Broadcast && !node) { + if (g_contacts_state.current_mode != ContactsMode::Broadcast && + g_contacts_state.current_mode != ContactsMode::Team && + !node) { + return; + } + if (g_contacts_state.current_mode == ContactsMode::Team && !is_team_available()) { return; } @@ -620,6 +756,12 @@ static void open_chat_compose() channel = get_selected_channel(); peer_id = 0; title = "Broadcast"; + } else if (g_contacts_state.current_mode == ContactsMode::Team) { + channel = chat::ChannelId::PRIMARY; + peer_id = 0; + title = team::ui::g_team_state.team_name.empty() + ? "Team" + : team::ui::g_team_state.team_name; } else { channel = chat::ChannelId::PRIMARY; peer_id = node->node_id; @@ -652,12 +794,26 @@ static void open_chat_compose() g_contacts_state.compose_screen->setHeaderText(title.c_str(), "RSSI --"); s_compose_peer_id = peer_id; s_compose_channel = channel; - - if (g_contacts_state.root) { - lv_obj_add_flag(g_contacts_state.root, LV_OBJ_FLAG_HIDDEN); + s_compose_is_team = (g_contacts_state.current_mode == ContactsMode::Team); + if (s_compose_is_team) { + g_contacts_state.compose_screen->setActionLabels("Send", "Cancel"); + g_contacts_state.compose_screen->setPositionButton("Position", true); + } else { + g_contacts_state.compose_screen->setPositionButton(nullptr, false); } - if (g_contacts_state.refresh_timer) { - lv_timer_pause(g_contacts_state.refresh_timer); + + if (s_compose_from_conversation && g_contacts_state.conversation_screen) { + lv_obj_add_flag(g_contacts_state.conversation_screen->getObj(), LV_OBJ_FLAG_HIDDEN); + if (g_contacts_state.conversation_timer) { + lv_timer_pause(g_contacts_state.conversation_timer); + } + } else { + if (g_contacts_state.root) { + lv_obj_add_flag(g_contacts_state.root, LV_OBJ_FLAG_HIDDEN); + } + if (g_contacts_state.refresh_timer) { + lv_timer_pause(g_contacts_state.refresh_timer); + } } } @@ -675,40 +831,168 @@ static void close_chat_compose() g_contacts_state.compose_screen = nullptr; s_compose_peer_id = 0; s_compose_channel = chat::ChannelId::PRIMARY; + s_compose_is_team = false; - if (g_contacts_state.root) { - lv_obj_clear_flag(g_contacts_state.root, LV_OBJ_FLAG_HIDDEN); + if (s_compose_from_conversation && g_contacts_state.conversation_screen) { + lv_obj_clear_flag(g_contacts_state.conversation_screen->getObj(), LV_OBJ_FLAG_HIDDEN); + if (g_contacts_state.conversation_timer) { + lv_timer_resume(g_contacts_state.conversation_timer); + } + s_compose_from_conversation = false; + s_compose_prev_group = nullptr; + if (s_conv_group) { + set_default_group(s_conv_group); + } + } else { + if (g_contacts_state.root) { + lv_obj_clear_flag(g_contacts_state.root, LV_OBJ_FLAG_HIDDEN); + } + if (g_contacts_state.refresh_timer) { + lv_timer_resume(g_contacts_state.refresh_timer); + } + lv_group_t* contacts_group = contacts_input_get_group(); + if (contacts_group) { + set_default_group(contacts_group); + } else if (s_compose_prev_group) { + set_default_group(s_compose_prev_group); + } + s_compose_prev_group = nullptr; + contacts_focus_to_list(); + refresh_ui(); } - if (g_contacts_state.refresh_timer) { - lv_timer_resume(g_contacts_state.refresh_timer); - } - - lv_group_t* contacts_group = contacts_input_get_group(); - if (contacts_group) { - set_default_group(contacts_group); - } else if (s_compose_prev_group) { - set_default_group(s_compose_prev_group); - } - s_compose_prev_group = nullptr; - contacts_focus_to_list(); - refresh_ui(); } static void on_compose_action(chat::ui::ChatComposeScreen::ActionIntent intent, void* /*user_data*/) { - if (intent == chat::ui::ChatComposeScreen::ActionIntent::Send && - g_contacts_state.compose_screen && g_contacts_state.chat_service) { + if ((intent == chat::ui::ChatComposeScreen::ActionIntent::Send || + intent == chat::ui::ChatComposeScreen::ActionIntent::Position) && + g_contacts_state.compose_screen) { + if (s_compose_is_team) { + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + if (!controller || !is_team_available()) { + ::ui::SystemNotification::show("Team chat send failed", 2000); + close_chat_compose(); + return; + } + if (!team::ui::g_team_state.has_team_psk) { + ::ui::SystemNotification::show("Team keys not ready", 2000); + close_chat_compose(); + return; + } + if (!controller->setKeysFromPsk(team::ui::g_team_state.team_id, + team::ui::g_team_state.security_round, + team::ui::g_team_state.team_psk.data(), + team::ui::g_team_state.team_psk.size())) { + ::ui::SystemNotification::show("Team keys not ready", 2000); + close_chat_compose(); + return; + } + uint32_t ts = static_cast(time(nullptr)); + if (ts < 1577836800U) { + ts = static_cast(millis() / 1000); + } + + if (intent == chat::ui::ChatComposeScreen::ActionIntent::Position) { + std::string label = g_contacts_state.compose_screen->getText(); + gps::GpsState gps_state = gps::gps_get_data(); + if (!gps_state.valid) { + ::ui::SystemNotification::show("No GPS fix", 2000); + return; + } + + team::proto::TeamChatLocation loc; + loc.lat_e7 = static_cast(gps_state.lat * 1e7); + loc.lon_e7 = static_cast(gps_state.lng * 1e7); + if (gps_state.has_alt) { + double alt = gps_state.alt_m; + if (alt > 32767.0) alt = 32767.0; + if (alt < -32768.0) alt = -32768.0; + loc.alt_m = static_cast(lround(alt)); + } + loc.ts = ts; + if (!label.empty()) { + loc.label = label; + } + + std::vector payload; + if (!team::proto::encodeTeamChatLocation(loc, payload)) { + ::ui::SystemNotification::show("Team location encode failed", 2000); + close_chat_compose(); + return; + } + + team::proto::TeamChatMessage msg; + msg.header.type = team::proto::TeamChatType::Location; + msg.header.ts = ts; + msg.header.msg_id = s_team_msg_id++; + if (s_team_msg_id == 0) { + s_team_msg_id = 1; + } + msg.payload = payload; + + bool ok = controller->onChat(msg, chat::ChannelId::PRIMARY); + if (ok) { + team::ui::team_ui_chatlog_append_structured( + team::ui::g_team_state.team_id, + 0, + false, + ts, + team::proto::TeamChatType::Location, + payload); + } else { + ::ui::SystemNotification::show("Team chat send failed", 2000); + } + } else { + std::string text = g_contacts_state.compose_screen->getText(); + if (text.empty()) { + close_chat_compose(); + return; + } + team::proto::TeamChatMessage msg; + msg.header.type = team::proto::TeamChatType::Text; + msg.header.ts = ts; + msg.header.msg_id = s_team_msg_id++; + if (s_team_msg_id == 0) { + s_team_msg_id = 1; + } + msg.payload.assign(text.begin(), text.end()); + + bool ok = controller->onChat(msg, chat::ChannelId::PRIMARY); + if (ok) { + team::ui::team_ui_chatlog_append_structured( + team::ui::g_team_state.team_id, + 0, + false, + ts, + team::proto::TeamChatType::Text, + msg.payload); + } else { + ::ui::SystemNotification::show("Team chat send failed", 2000); + } + } + close_chat_compose(); + if (g_contacts_state.conversation_screen) { + refresh_team_conversation(); + } + return; + } + std::string text = g_contacts_state.compose_screen->getText(); if (!text.empty()) { - chat::MessageId msg_id = g_contacts_state.chat_service->sendText( - s_compose_channel, text, s_compose_peer_id - ); - g_contacts_state.compose_screen->beginSend( - g_contacts_state.chat_service, - msg_id, - on_compose_send_done, - nullptr); - return; + if (g_contacts_state.chat_service) { + s_last_sent_text = text; + s_last_sent_ts = static_cast(time(nullptr)); + chat::MessageId msg_id = g_contacts_state.chat_service->sendText( + s_compose_channel, text, s_compose_peer_id + ); + g_contacts_state.compose_screen->beginSend( + g_contacts_state.chat_service, + msg_id, + on_compose_send_done, + nullptr); + return; + } } } close_chat_compose(); @@ -719,9 +1003,229 @@ static void on_compose_back(void* /*user_data*/) close_chat_compose(); } -static void on_compose_send_done(bool /*ok*/, bool /*timeout*/, void* /*user_data*/) +static void on_compose_send_done(bool ok, bool /*timeout*/, void* /*user_data*/) { + if (ok && s_compose_is_team && + team::ui::g_team_state.has_team_id && + !s_last_sent_text.empty()) { + uint32_t ts = s_last_sent_ts; + if (ts == 0) { + ts = static_cast(time(nullptr)); + } + team::ui::team_ui_chatlog_append(team::ui::g_team_state.team_id, + 0, + false, + ts, + s_last_sent_text); + } close_chat_compose(); + s_last_sent_text.clear(); + s_last_sent_ts = 0; + if (g_contacts_state.conversation_screen) { + refresh_team_conversation(); + } +} + +static void refresh_team_conversation() +{ + if (!g_contacts_state.conversation_screen || !is_team_available()) { + return; + } + g_contacts_state.conversation_screen->clearMessages(); + + std::vector entries; + if (team::ui::team_ui_chatlog_load_recent(team::ui::g_team_state.team_id, 50, entries)) { + for (const auto& entry : entries) { + chat::ChatMessage msg; + msg.channel = chat::ChannelId::PRIMARY; + msg.peer = 0; + msg.from = entry.incoming ? entry.peer_id : 0; + msg.timestamp = entry.ts; + msg.text = format_team_chat_entry(entry); + msg.status = entry.incoming ? chat::MessageStatus::Incoming : chat::MessageStatus::Sent; + g_contacts_state.conversation_screen->addMessage(msg); + } + } + g_contacts_state.conversation_screen->scrollToBottom(); +} + +static void on_team_conversation_action(chat::ui::ChatConversationScreen::ActionIntent intent, void* /*user_data*/) +{ + if (intent == chat::ui::ChatConversationScreen::ActionIntent::Reply) { + s_compose_from_conversation = true; + open_chat_compose(); + } +} + +static void on_team_conversation_back(void* /*user_data*/) +{ + close_team_conversation(); +} + +static void open_team_conversation() +{ + if (g_contacts_state.conversation_screen) { + return; + } + if (!is_team_available()) { + return; + } + + lv_obj_t* parent = g_contacts_state.root + ? lv_obj_get_parent(g_contacts_state.root) + : lv_screen_active(); + if (lv_obj_t* chat_parent = ui_chat_get_container()) { + if (lv_obj_is_valid(chat_parent)) { + parent = chat_parent; + } + } + + s_conv_prev_group = lv_group_get_default(); + if (!s_conv_group) { + s_conv_group = lv_group_create(); + } + lv_group_remove_all_objs(s_conv_group); + set_default_group(s_conv_group); + + chat::ConversationId conv(chat::ChannelId::PRIMARY, 0); + g_contacts_state.conversation_screen = new chat::ui::ChatConversationScreen(parent, conv); + g_contacts_state.conversation_screen->setActionCallback(on_team_conversation_action, nullptr); + g_contacts_state.conversation_screen->setBackCallback(on_team_conversation_back, nullptr); + + const char* title = team::ui::g_team_state.team_name.empty() + ? "Team" + : team::ui::g_team_state.team_name.c_str(); + g_contacts_state.conversation_screen->setHeaderText(title, nullptr); + g_contacts_state.conversation_screen->updateBatteryFromBoard(); + refresh_team_conversation(); + + if (g_contacts_state.root) { + lv_obj_add_flag(g_contacts_state.root, LV_OBJ_FLAG_HIDDEN); + } + if (g_contacts_state.refresh_timer) { + lv_timer_pause(g_contacts_state.refresh_timer); + } + if (!g_contacts_state.conversation_timer) { + g_contacts_state.conversation_timer = lv_timer_create([](lv_timer_t* timer) { + (void)timer; + refresh_team_conversation(); + }, 1000, nullptr); + lv_timer_set_repeat_count(g_contacts_state.conversation_timer, -1); + } else { + lv_timer_resume(g_contacts_state.conversation_timer); + } +} + +static void close_team_conversation() +{ + if (g_contacts_state.conversation_timer) { + lv_timer_pause(g_contacts_state.conversation_timer); + } + if (g_contacts_state.conversation_screen) { + delete g_contacts_state.conversation_screen; + g_contacts_state.conversation_screen = nullptr; + } + + if (g_contacts_state.root) { + lv_obj_clear_flag(g_contacts_state.root, LV_OBJ_FLAG_HIDDEN); + } + if (g_contacts_state.refresh_timer) { + lv_timer_resume(g_contacts_state.refresh_timer); + } + + if (s_conv_prev_group) { + set_default_group(s_conv_prev_group); + } else if (lv_group_t* contacts_group = contacts_input_get_group()) { + set_default_group(contacts_group); + } + s_conv_prev_group = nullptr; + contacts_focus_to_list(); + refresh_ui(); +} + +static void send_team_position() +{ + if (!is_team_available()) { + return; + } + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + if (!controller) { + return; + } + + gps::GpsState gps_state = gps::gps_get_data(); + if (!gps_state.valid) { + CONTACTS_LOG("[Contacts] team position: no gps fix\n"); + return; + } + + int32_t lat_e7 = static_cast(gps_state.lat * 1e7); + int32_t lon_e7 = static_cast(gps_state.lng * 1e7); + uint32_t ts = static_cast(time(nullptr)); + + meshtastic_Position pos = meshtastic_Position_init_zero; + pos.has_latitude_i = true; + pos.latitude_i = lat_e7; + pos.has_longitude_i = true; + pos.longitude_i = lon_e7; + pos.location_source = meshtastic_Position_LocSource_LOC_INTERNAL; + if (gps_state.has_alt) { + pos.has_altitude = true; + pos.altitude = static_cast(lround(gps_state.alt_m)); + pos.altitude_source = meshtastic_Position_AltSource_ALT_INTERNAL; + } + if (gps_state.has_speed) { + pos.has_ground_speed = true; + pos.ground_speed = static_cast(lround(gps_state.speed_mps)); + } + if (gps_state.has_course) { + double course = gps_state.course_deg; + if (course < 0.0) course = 0.0; + uint32_t cdeg = static_cast(lround(course * 100.0)); + if (cdeg >= 36000U) cdeg = 35999U; + pos.has_ground_track = true; + pos.ground_track = cdeg; + } + if (gps_state.satellites > 0) { + pos.sats_in_view = gps_state.satellites; + } + if (ts >= 1577836800U) { + pos.timestamp = ts; + } + + uint8_t buf[128]; + pb_ostream_t stream = pb_ostream_from_buffer(buf, sizeof(buf)); + if (!pb_encode(&stream, meshtastic_Position_fields, &pos)) { + CONTACTS_LOG("[Contacts] team position: encode fail\n"); + return; + } + std::vector payload(buf, buf + stream.bytes_written); + + bool ok = controller->onPosition(payload, chat::ChannelId::PRIMARY); + if (ok) { + int16_t alt_m = gps_state.has_alt + ? static_cast(lround(gps_state.alt_m)) + : 0; + if (gps_state.has_alt) { + if (gps_state.alt_m > 32767.0) alt_m = 32767; + else if (gps_state.alt_m < -32768.0) alt_m = -32768; + } + uint16_t speed_dmps = 0; + if (gps_state.has_speed) { + double dmps = gps_state.speed_mps * 10.0; + if (dmps < 0.0) dmps = 0.0; + if (dmps > 65535.0) dmps = 65535.0; + speed_dmps = static_cast(lround(dmps)); + } + team::ui::team_ui_posring_append(team::ui::g_team_state.team_id, + 0, + lat_e7, + lon_e7, + alt_m, + speed_dmps, + ts); + } } static void on_add_edit_save_clicked(lv_event_t* /*e*/) @@ -824,6 +1328,7 @@ static void ensure_action_buttons() if (g_contacts_state.current_mode != g_contacts_state.last_action_mode) { lv_obj_clean(g_contacts_state.action_panel); g_contacts_state.chat_btn = nullptr; + g_contacts_state.position_btn = nullptr; g_contacts_state.edit_btn = nullptr; g_contacts_state.del_btn = nullptr; g_contacts_state.add_btn = nullptr; @@ -864,6 +1369,31 @@ static void ensure_action_buttons() ); } + /* ---------- Position ---------- */ + if (!g_contacts_state.position_btn) { + g_contacts_state.position_btn = + lv_btn_create(g_contacts_state.action_panel); + lv_obj_set_size( + g_contacts_state.position_btn, + kButtonWidth, kButtonHeight + ); + contacts::ui::style::apply_btn_basic( + g_contacts_state.position_btn + ); + lv_obj_set_style_bg_color(g_contacts_state.position_btn, lv_color_hex(0xF1B65A), LV_PART_MAIN); + + lv_obj_t* l = lv_label_create(g_contacts_state.position_btn); + lv_label_set_text(l, "Position"); + lv_obj_center(l); + + lv_obj_add_event_cb( + g_contacts_state.position_btn, + on_action_clicked, + LV_EVENT_CLICKED, + nullptr + ); + } + /* ---------- Edit ---------- */ if (!g_contacts_state.edit_btn) { g_contacts_state.edit_btn = @@ -997,6 +1527,7 @@ static void ensure_action_buttons() lv_obj_clear_flag(g_contacts_state.del_btn, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag (g_contacts_state.add_btn, LV_OBJ_FLAG_HIDDEN); lv_obj_clear_flag(g_contacts_state.info_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag (g_contacts_state.position_btn, LV_OBJ_FLAG_HIDDEN); lv_obj_set_style_bg_color(g_contacts_state.chat_btn, lv_color_hex(0xEBA341), LV_PART_MAIN); lv_obj_set_style_bg_color(g_contacts_state.edit_btn, lv_color_hex(0xF1B65A), LV_PART_MAIN); lv_obj_set_style_bg_color(g_contacts_state.del_btn, lv_color_hex(0xEBA341), LV_PART_MAIN); @@ -1007,15 +1538,26 @@ static void ensure_action_buttons() lv_obj_add_flag (g_contacts_state.del_btn, LV_OBJ_FLAG_HIDDEN); lv_obj_clear_flag(g_contacts_state.add_btn, LV_OBJ_FLAG_HIDDEN); lv_obj_clear_flag(g_contacts_state.info_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag (g_contacts_state.position_btn, LV_OBJ_FLAG_HIDDEN); lv_obj_set_style_bg_color(g_contacts_state.chat_btn, lv_color_hex(0xEBA341), LV_PART_MAIN); lv_obj_set_style_bg_color(g_contacts_state.add_btn, lv_color_hex(0xF1B65A), LV_PART_MAIN); lv_obj_set_style_bg_color(g_contacts_state.info_btn, lv_color_hex(0xEBA341), LV_PART_MAIN); lv_obj_set_style_bg_color(g_contacts_state.action_back_btn, lv_color_hex(0xF1B65A), LV_PART_MAIN); + } else if (g_contacts_state.current_mode == ContactsMode::Team) { + lv_obj_add_flag (g_contacts_state.edit_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag (g_contacts_state.del_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag (g_contacts_state.add_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag (g_contacts_state.info_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(g_contacts_state.position_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_set_style_bg_color(g_contacts_state.chat_btn, lv_color_hex(0xEBA341), LV_PART_MAIN); + lv_obj_set_style_bg_color(g_contacts_state.position_btn, lv_color_hex(0xF1B65A), LV_PART_MAIN); + lv_obj_set_style_bg_color(g_contacts_state.action_back_btn, lv_color_hex(0xEBA341), LV_PART_MAIN); } else { lv_obj_add_flag (g_contacts_state.edit_btn, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag (g_contacts_state.del_btn, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag (g_contacts_state.add_btn, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag (g_contacts_state.info_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag (g_contacts_state.position_btn, LV_OBJ_FLAG_HIDDEN); lv_obj_set_style_bg_color(g_contacts_state.chat_btn, lv_color_hex(0xEBA341), LV_PART_MAIN); lv_obj_set_style_bg_color(g_contacts_state.action_back_btn, lv_color_hex(0xF1B65A), LV_PART_MAIN); } @@ -1028,7 +1570,11 @@ static void ensure_action_buttons() set_enabled(g_contacts_state.action_back_btn, true); set_enabled(g_contacts_state.chat_btn, has_sel); - if (g_contacts_state.current_mode != ContactsMode::Broadcast) { + if (g_contacts_state.current_mode == ContactsMode::Team) { + set_enabled(g_contacts_state.position_btn, has_sel); + } + if (g_contacts_state.current_mode != ContactsMode::Broadcast && + g_contacts_state.current_mode != ContactsMode::Team) { set_enabled(g_contacts_state.info_btn, has_sel); } @@ -1049,6 +1595,10 @@ static void on_action_clicked(lv_event_t* e) open_chat_compose(); return; } + if (tgt == g_contacts_state.position_btn) { + send_team_position(); + return; + } if (tgt == g_contacts_state.info_btn) { open_info_modal(); return; @@ -1107,6 +1657,20 @@ void refresh_ui() lv_obj_clear_flag(g_contacts_state.sub_container, LV_OBJ_FLAG_SCROLLABLE); } + bool team_available = is_team_available(); + if (g_contacts_state.team_btn) { + if (team_available) { + lv_obj_clear_flag(g_contacts_state.team_btn, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(g_contacts_state.team_btn, LV_OBJ_FLAG_HIDDEN); + } + } + if (!team_available && g_contacts_state.current_mode == ContactsMode::Team) { + g_contacts_state.current_mode = ContactsMode::Contacts; + g_contacts_state.current_page = 0; + g_contacts_state.selected_index = -1; + } + // Log nearby nodes if in nearby mode (unchanged) if (g_contacts_state.current_mode == ContactsMode::Nearby) { CONTACTS_LOG("[Contacts] Nearby mode: %zu nodes\n", g_contacts_state.nearby_list.size()); @@ -1130,11 +1694,24 @@ void refresh_ui() // Choose list by mode (unchanged) std::vector broadcast_list; + std::vector team_list; const std::vector* current_list = nullptr; if (g_contacts_state.current_mode == ContactsMode::Contacts) { current_list = &g_contacts_state.contacts_list; } else if (g_contacts_state.current_mode == ContactsMode::Nearby) { current_list = &g_contacts_state.nearby_list; + } else if (g_contacts_state.current_mode == ContactsMode::Team) { + chat::contacts::NodeInfo team_node{}; + team_node.node_id = 0; + team_node.last_seen = 0; + team_node.snr = 0.0f; + team_node.is_contact = false; + team_node.protocol = chat::contacts::NodeProtocolType::Unknown; + team_node.display_name = team::ui::g_team_state.team_name.empty() + ? "Team" + : team::ui::g_team_state.team_name; + team_list.push_back(team_node); + current_list = &team_list; } else { chat::contacts::NodeInfo primary{}; primary.display_name = "Primary"; @@ -1177,6 +1754,8 @@ void refresh_ui() status_text = format_time_status(node.last_seen); } else if (g_contacts_state.current_mode == ContactsMode::Nearby) { status_text = format_snr(node.snr); + } else if (g_contacts_state.current_mode == ContactsMode::Team) { + status_text = "Team"; } else { status_text = "Channel"; } @@ -1250,13 +1829,18 @@ void refresh_ui() lv_obj_clear_state(g_contacts_state.contacts_btn, LV_STATE_CHECKED); lv_obj_clear_state(g_contacts_state.nearby_btn, LV_STATE_CHECKED); lv_obj_clear_state(g_contacts_state.broadcast_btn, LV_STATE_CHECKED); + if (g_contacts_state.team_btn) { + lv_obj_clear_state(g_contacts_state.team_btn, LV_STATE_CHECKED); + } if (g_contacts_state.current_mode == ContactsMode::Contacts) { lv_obj_add_state(g_contacts_state.contacts_btn, LV_STATE_CHECKED); } else if (g_contacts_state.current_mode == ContactsMode::Nearby) { lv_obj_add_state(g_contacts_state.nearby_btn, LV_STATE_CHECKED); - } else { + } else if (g_contacts_state.current_mode == ContactsMode::Broadcast) { lv_obj_add_state(g_contacts_state.broadcast_btn, LV_STATE_CHECKED); + } else if (g_contacts_state.team_btn) { + lv_obj_add_state(g_contacts_state.team_btn, LV_STATE_CHECKED); } } diff --git a/src/ui/screens/contacts/contacts_page_input.cpp b/src/ui/screens/contacts/contacts_page_input.cpp index ea0beaba..96926894 100644 --- a/src/ui/screens/contacts/contacts_page_input.cpp +++ b/src/ui/screens/contacts/contacts_page_input.cpp @@ -57,12 +57,18 @@ static void clear_action_focus_states() lv_state_t clear_mask = (lv_state_t)(LV_STATE_FOCUSED | LV_STATE_FOCUS_KEY | LV_STATE_EDITED); if (g_contacts_state.action_back_btn) lv_obj_clear_state(g_contacts_state.action_back_btn, clear_mask); if (g_contacts_state.chat_btn) lv_obj_clear_state(g_contacts_state.chat_btn, clear_mask); + if (g_contacts_state.position_btn) lv_obj_clear_state(g_contacts_state.position_btn, clear_mask); if (g_contacts_state.edit_btn) lv_obj_clear_state(g_contacts_state.edit_btn, clear_mask); if (g_contacts_state.del_btn) lv_obj_clear_state(g_contacts_state.del_btn, clear_mask); if (g_contacts_state.add_btn) lv_obj_clear_state(g_contacts_state.add_btn, clear_mask); if (g_contacts_state.info_btn) lv_obj_clear_state(g_contacts_state.info_btn, clear_mask); } +static bool is_visible(lv_obj_t* obj) +{ + return obj && !lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN); +} + static void focus_first_valid(lv_obj_t* obj) { if (!s_group || !obj) return; @@ -81,9 +87,10 @@ static void bind_filter_column(bool keep_mode_focus) } // ② Filter buttons - if (g_contacts_state.contacts_btn) lv_group_add_obj(s_group, g_contacts_state.contacts_btn); - if (g_contacts_state.nearby_btn) lv_group_add_obj(s_group, g_contacts_state.nearby_btn); - if (g_contacts_state.broadcast_btn) lv_group_add_obj(s_group, g_contacts_state.broadcast_btn); + if (is_visible(g_contacts_state.contacts_btn)) lv_group_add_obj(s_group, g_contacts_state.contacts_btn); + if (is_visible(g_contacts_state.nearby_btn)) lv_group_add_obj(s_group, g_contacts_state.nearby_btn); + if (is_visible(g_contacts_state.broadcast_btn)) lv_group_add_obj(s_group, g_contacts_state.broadcast_btn); + if (is_visible(g_contacts_state.team_btn)) lv_group_add_obj(s_group, g_contacts_state.team_btn); // Focus preference: // - keep_mode_focus: focus current mode button (better for rotate-to-switch-mode UX) @@ -93,31 +100,39 @@ static void bind_filter_column(bool keep_mode_focus) focus_first_valid(g_contacts_state.contacts_btn); } else if (g_contacts_state.current_mode == ContactsMode::Nearby && g_contacts_state.nearby_btn) { focus_first_valid(g_contacts_state.nearby_btn); - } else if (g_contacts_state.current_mode == ContactsMode::Broadcast && g_contacts_state.broadcast_btn) { + } else if (g_contacts_state.current_mode == ContactsMode::Broadcast && is_visible(g_contacts_state.broadcast_btn)) { focus_first_valid(g_contacts_state.broadcast_btn); + } else if (g_contacts_state.current_mode == ContactsMode::Team && is_visible(g_contacts_state.team_btn)) { + focus_first_valid(g_contacts_state.team_btn); } else if (g_contacts_state.top_bar.back_btn) { focus_first_valid(g_contacts_state.top_bar.back_btn); - } else if (g_contacts_state.contacts_btn) { + } else if (is_visible(g_contacts_state.contacts_btn)) { focus_first_valid(g_contacts_state.contacts_btn); - } else if (g_contacts_state.nearby_btn) { + } else if (is_visible(g_contacts_state.nearby_btn)) { focus_first_valid(g_contacts_state.nearby_btn); - } else if (g_contacts_state.broadcast_btn) { + } else if (is_visible(g_contacts_state.broadcast_btn)) { focus_first_valid(g_contacts_state.broadcast_btn); + } else if (is_visible(g_contacts_state.team_btn)) { + focus_first_valid(g_contacts_state.team_btn); } } else { // first entry: prefer current mode button - if (g_contacts_state.current_mode == ContactsMode::Contacts && g_contacts_state.contacts_btn) { + if (g_contacts_state.current_mode == ContactsMode::Contacts && is_visible(g_contacts_state.contacts_btn)) { focus_first_valid(g_contacts_state.contacts_btn); - } else if (g_contacts_state.current_mode == ContactsMode::Nearby && g_contacts_state.nearby_btn) { + } else if (g_contacts_state.current_mode == ContactsMode::Nearby && is_visible(g_contacts_state.nearby_btn)) { focus_first_valid(g_contacts_state.nearby_btn); - } else if (g_contacts_state.current_mode == ContactsMode::Broadcast && g_contacts_state.broadcast_btn) { + } else if (g_contacts_state.current_mode == ContactsMode::Broadcast && is_visible(g_contacts_state.broadcast_btn)) { focus_first_valid(g_contacts_state.broadcast_btn); - } else if (g_contacts_state.contacts_btn) { + } else if (g_contacts_state.current_mode == ContactsMode::Team && is_visible(g_contacts_state.team_btn)) { + focus_first_valid(g_contacts_state.team_btn); + } else if (is_visible(g_contacts_state.contacts_btn)) { focus_first_valid(g_contacts_state.contacts_btn); - } else if (g_contacts_state.nearby_btn) { + } else if (is_visible(g_contacts_state.nearby_btn)) { focus_first_valid(g_contacts_state.nearby_btn); - } else if (g_contacts_state.broadcast_btn) { + } else if (is_visible(g_contacts_state.broadcast_btn)) { focus_first_valid(g_contacts_state.broadcast_btn); + } else if (is_visible(g_contacts_state.team_btn)) { + focus_first_valid(g_contacts_state.team_btn); } else if (g_contacts_state.top_bar.back_btn) { focus_first_valid(g_contacts_state.top_bar.back_btn); } @@ -169,9 +184,12 @@ static void bind_action_column() if (g_contacts_state.del_btn) { lv_group_add_obj(s_group, g_contacts_state.del_btn); any = true; } } else if (g_contacts_state.current_mode == ContactsMode::Nearby) { if (g_contacts_state.add_btn) { lv_group_add_obj(s_group, g_contacts_state.add_btn); any = true; } + } else if (g_contacts_state.current_mode == ContactsMode::Team) { + if (g_contacts_state.position_btn) { lv_group_add_obj(s_group, g_contacts_state.position_btn); any = true; } } - if (g_contacts_state.current_mode != ContactsMode::Broadcast) { + if (g_contacts_state.current_mode != ContactsMode::Broadcast && + g_contacts_state.current_mode != ContactsMode::Team) { if (g_contacts_state.info_btn) { lv_group_add_obj(s_group, g_contacts_state.info_btn); any = true; } } @@ -198,6 +216,8 @@ static void bind_action_column() // Default focus to the first action button (Chat) instead of Back if (g_contacts_state.chat_btn) { focus_first_valid(g_contacts_state.chat_btn); + } else if (g_contacts_state.position_btn) { + focus_first_valid(g_contacts_state.position_btn); } else if (g_contacts_state.info_btn) { focus_first_valid(g_contacts_state.info_btn); } else if (g_contacts_state.action_back_btn) { diff --git a/src/ui/screens/contacts/contacts_page_layout.cpp b/src/ui/screens/contacts/contacts_page_layout.cpp index 59215a5a..0059a4e7 100644 --- a/src/ui/screens/contacts/contacts_page_layout.cpp +++ b/src/ui/screens/contacts/contacts_page_layout.cpp @@ -226,6 +226,15 @@ void create_filter_panel(lv_obj_t* parent) lv_obj_t* broadcast_label = lv_label_create(g_contacts_state.broadcast_btn); lv_label_set_text(broadcast_label, "Broadcast"); lv_obj_center(broadcast_label); + + g_contacts_state.team_btn = lv_btn_create(g_contacts_state.filter_panel); + make_non_scrollable(g_contacts_state.team_btn); + style::apply_btn_filter(g_contacts_state.team_btn); + lv_obj_set_size(g_contacts_state.team_btn, LV_PCT(100), kButtonHeight); + lv_obj_t* team_label = lv_label_create(g_contacts_state.team_btn); + lv_label_set_text(team_label, "Team"); + lv_obj_center(team_label); + lv_obj_add_flag(g_contacts_state.team_btn, LV_OBJ_FLAG_HIDDEN); } void create_list_panel(lv_obj_t* parent) diff --git a/src/ui/screens/contacts/contacts_state.h b/src/ui/screens/contacts/contacts_state.h index 834b206a..e9f5c2b0 100644 --- a/src/ui/screens/contacts/contacts_state.h +++ b/src/ui/screens/contacts/contacts_state.h @@ -17,6 +17,7 @@ namespace contacts { } // namespace contacts namespace ui { class ChatComposeScreen; + class ChatConversationScreen; } // namespace ui class ChatService; } // namespace chat @@ -33,7 +34,8 @@ namespace ui { enum class ContactsMode { Contacts, // Show contacts (nodes with nicknames) Nearby, // Show nearby nodes (nodes without nicknames) - Broadcast // Show broadcast channels + Broadcast, // Show broadcast channels + Team // Show team (if joined) }; struct ContactsPageState { @@ -47,6 +49,7 @@ struct ContactsPageState { lv_obj_t* contacts_btn = nullptr; lv_obj_t* nearby_btn = nullptr; lv_obj_t* broadcast_btn = nullptr; + lv_obj_t* team_btn = nullptr; // Second column: Node list lv_obj_t* list_panel = nullptr; @@ -60,6 +63,7 @@ struct ContactsPageState { // Third column: Action buttons lv_obj_t* action_panel = nullptr; lv_obj_t* chat_btn = nullptr; + lv_obj_t* position_btn = nullptr; lv_obj_t* edit_btn = nullptr; lv_obj_t* del_btn = nullptr; lv_obj_t* add_btn = nullptr; @@ -94,12 +98,15 @@ struct ContactsPageState { // Compose screen (Chat button) chat::ui::ChatComposeScreen* compose_screen = nullptr; ::ui::widgets::ImeWidget* compose_ime = nullptr; + chat::ui::ChatConversationScreen* conversation_screen = nullptr; + lv_timer_t* conversation_timer = nullptr; // Services (owned by AppContext) chat::contacts::ContactService* contact_service = nullptr; chat::ChatService* chat_service = nullptr; bool initialized = false; + bool exiting = false; }; extern ContactsPageState g_contacts_state; diff --git a/src/ui/screens/gps/gps_page_components.cpp b/src/ui/screens/gps/gps_page_components.cpp index 6867c765..a499c25f 100644 --- a/src/ui/screens/gps/gps_page_components.cpp +++ b/src/ui/screens/gps/gps_page_components.cpp @@ -361,6 +361,11 @@ void fix_ui_elements_position() lv_obj_move_foreground(g_gps_state.panel); } + if (g_gps_state.member_panel != NULL) { + lv_obj_align(g_gps_state.member_panel, LV_ALIGN_TOP_LEFT, 0, 3); + lv_obj_move_foreground(g_gps_state.member_panel); + } + if (g_gps_state.resolution_label != NULL) { lv_obj_align(g_gps_state.resolution_label, LV_ALIGN_BOTTOM_LEFT, 10, -10); lv_obj_move_foreground(g_gps_state.resolution_label); diff --git a/src/ui/screens/gps/gps_page_input.cpp b/src/ui/screens/gps/gps_page_input.cpp index 0ba1a795..4f285b94 100644 --- a/src/ui/screens/gps/gps_page_input.cpp +++ b/src/ui/screens/gps/gps_page_input.cpp @@ -470,10 +470,9 @@ static void action_back_exit() { static void action_back_exit_async(void* /*user_data*/) { - GPS_LOG("[GPS][BACK] action_back_exit_async: calling ui_gps_exit + menu_show (alive=%d exiting=%d root=%p)\n", g_gps_state.alive, g_gps_state.exiting, g_gps_state.root); - extern void ui_gps_exit(lv_obj_t* parent); - ui_gps_exit(nullptr); - menu_show(); + GPS_LOG("[GPS][BACK] action_back_exit_async: requesting exit to menu (alive=%d exiting=%d root=%p)\n", + g_gps_state.alive, g_gps_state.exiting, g_gps_state.root); + ui_request_exit_to_menu(); } static void action_zoom_open_popup() { diff --git a/src/ui/screens/gps/gps_page_layout.cpp b/src/ui/screens/gps/gps_page_layout.cpp index e251581d..5bbedeba 100644 --- a/src/ui/screens/gps/gps_page_layout.cpp +++ b/src/ui/screens/gps/gps_page_layout.cpp @@ -21,6 +21,7 @@ namespace gps::ui::layout { * [pan_v_label] * [tracker_btn] * [tracker_label] + * [member_panel: top-left column] * * Tree View * root @@ -28,12 +29,13 @@ namespace gps::ui::layout { * `- content * `- map * |- resolution_label - * `- panel - * |- zoom_btn - * |- pos_btn - * |- pan_h_btn - * |- pan_v_btn - * `- tracker_btn + * |- panel + * | |- zoom_btn + * | |- pos_btn + * | |- pan_h_btn + * | |- pan_v_btn + * | `- tracker_btn + * `- member_panel */ void create(lv_obj_t* parent, const Spec& spec, Widgets& w) { @@ -78,6 +80,14 @@ void create(lv_obj_t* parent, const Spec& spec, Widgets& w) lv_obj_set_scrollbar_mode(w.panel, LV_SCROLLBAR_MODE_OFF); lv_obj_align(w.panel, LV_ALIGN_TOP_RIGHT, 0, spec.panel_top_offset); + w.member_panel = lv_obj_create(w.map); + lv_obj_set_size(w.member_panel, spec.member_panel_width, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(w.member_panel, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(w.member_panel, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_clear_flag(w.member_panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scrollbar_mode(w.member_panel, LV_SCROLLBAR_MODE_OFF); + lv_obj_align(w.member_panel, LV_ALIGN_TOP_LEFT, spec.member_panel_left_offset, spec.member_panel_top_offset); + auto create_control = [&](lv_obj_t*& btn, lv_obj_t*& label, const char* text) { btn = lv_btn_create(w.panel); lv_obj_set_size(btn, spec.control_btn_w, spec.control_btn_h); diff --git a/src/ui/screens/gps/gps_page_layout.h b/src/ui/screens/gps/gps_page_layout.h index 74c1ea6f..5845a9cc 100644 --- a/src/ui/screens/gps/gps_page_layout.h +++ b/src/ui/screens/gps/gps_page_layout.h @@ -9,6 +9,11 @@ struct Spec { int panel_width = 85; int panel_top_offset = 3; int panel_row_gap = 3; + int member_panel_width = 90; + int member_panel_top_offset = 3; + int member_panel_left_offset = 0; + int member_btn_w = 86; + int member_btn_h = 28; int resolution_pad = 4; int resolution_x = 10; @@ -27,6 +32,7 @@ struct Widgets { lv_obj_t* resolution_label = nullptr; lv_obj_t* panel = nullptr; + lv_obj_t* member_panel = nullptr; lv_obj_t* zoom_btn = nullptr; lv_obj_t* zoom_label = nullptr; lv_obj_t* pos_btn = nullptr; diff --git a/src/ui/screens/gps/gps_page_lifetime.cpp b/src/ui/screens/gps/gps_page_lifetime.cpp index 5629740d..22ff29c0 100644 --- a/src/ui/screens/gps/gps_page_lifetime.cpp +++ b/src/ui/screens/gps/gps_page_lifetime.cpp @@ -44,6 +44,10 @@ void detach_group_objs() remove_if(g_gps_state.tracker_btn); remove_if(g_gps_state.pan_h_indicator); remove_if(g_gps_state.pan_v_indicator); + for (auto* btn : g_gps_state.member_btns) + { + remove_if(btn); + } } void on_root_deleted(lv_event_t* e) @@ -83,6 +87,9 @@ void on_root_deleted(lv_event_t* e) g_gps_state.header = nullptr; g_gps_state.page = nullptr; g_gps_state.panel = nullptr; + g_gps_state.member_panel = nullptr; + g_gps_state.member_btns.clear(); + g_gps_state.member_btn_ids.clear(); g_gps_state.zoom = nullptr; g_gps_state.pos = nullptr; g_gps_state.pan_h = nullptr; diff --git a/src/ui/screens/gps/gps_page_map.cpp b/src/ui/screens/gps/gps_page_map.cpp index 57163787..609fa9c2 100644 --- a/src/ui/screens/gps/gps_page_map.cpp +++ b/src/ui/screens/gps/gps_page_map.cpp @@ -2,12 +2,18 @@ #include "gps_page_lifetime.h" #include "gps_state.h" #include "gps_page_components.h" +#include "gps_page_styles.h" #include "../../widgets/map/map_tiles.h" #include "gps_constants.h" #include "../../gps/gps_hw_status.h" +#include "../team/team_ui_store.h" +#include "../team/team_state.h" +#include "../../../app/app_context.h" #include "../../ui_common.h" #include "lvgl.h" #include +#include +#include #include #include @@ -59,6 +65,220 @@ static double approx_distance_m(double lat1, double lng1, double lat2, double ln return sqrt(x * x + y * y) * kEarthRadiusM; } +namespace +{ +constexpr int kTeamMarkerSize = 10; +constexpr int kTeamMarkerLabelWidth = 44; +constexpr int kTeamMarkerLabelOffsetX = 6; +constexpr int kTeamMarkerLabelOffsetY = 0; +constexpr uint32_t kTeamMarkerColor = 0x00AEEF; +constexpr uint32_t kTeamMarkerBorder = 0xFFFFFF; +constexpr uint32_t kTeamMarkerRefreshMs = 1000; +constexpr uint32_t kMemberPanelRefreshMs = 2000; +constexpr uint32_t kInvalidMemberId = 0xFFFFFFFFu; + +uint32_t hash_member_list(const std::vector& members) +{ + uint32_t h = 2166136261u; + auto mix = [&](uint32_t v) { + h ^= v; + h *= 16777619u; + }; + for (const auto& m : members) + { + uint32_t node_id = m.node_id; + if (node_id == 0) + { + node_id = app::AppContext::getInstance().getSelfNodeId(); + } + mix(node_id); + mix(static_cast(team::ui::team_color_index_from_node_id(node_id))); + for (char c : m.name) + { + mix(static_cast(c)); + } + } + return h; +} + +void ensure_member_colors(std::vector& members) +{ + for (auto& m : members) + { + uint32_t node_id = m.node_id; + if (node_id == 0) + { + node_id = app::AppContext::getInstance().getSelfNodeId(); + } + m.color_index = team::ui::team_color_index_from_node_id(node_id); + } +} + +bool load_team_data(team::TeamId& out_id, std::vector& out_members) +{ + if (team::ui::g_team_state.in_team && team::ui::g_team_state.has_team_id) + { + out_id = team::ui::g_team_state.team_id; + out_members = team::ui::g_team_state.members; + ensure_member_colors(out_members); + return true; + } + + team::ui::TeamUiSnapshot snap; + if (team::ui::team_ui_get_store().load(snap) && snap.in_team && snap.has_team_id) + { + out_id = snap.team_id; + out_members = snap.members; + ensure_member_colors(out_members); + return true; + } + return false; +} + +bool member_exists(const std::vector& members, uint32_t member_id) +{ + return std::find_if(members.begin(), members.end(), + [&](const team::ui::TeamMemberUi& m) { + return m.node_id == member_id; + }) != members.end(); +} + +const team::ui::TeamMemberUi* find_member(const std::vector& members, uint32_t member_id) +{ + auto it = std::find_if(members.begin(), members.end(), + [&](const team::ui::TeamMemberUi& m) { + return m.node_id == member_id; + }); + if (it == members.end()) + { + return nullptr; + } + return &(*it); +} + +uint32_t resolve_member_color(const std::vector& members, uint32_t member_id) +{ + auto it = std::find_if(members.begin(), members.end(), + [&](const team::ui::TeamMemberUi& m) { + return m.node_id == member_id; + }); + if (it == members.end()) + { + return kTeamMarkerColor; + } + if (it->color_index >= team::ui::kTeamMaxMembers) + { + return kTeamMarkerColor; + } + return team::ui::team_color_from_index(it->color_index); +} + +lv_color_t marker_text_color(uint32_t color) +{ + uint8_t r = static_cast((color >> 16) & 0xFF); + uint8_t g = static_cast((color >> 8) & 0xFF); + uint8_t b = static_cast(color & 0xFF); + uint32_t lum = (static_cast(r) * 299 + + static_cast(g) * 587 + + static_cast(b) * 114) / 1000; + return (lum > 160) ? lv_color_black() : lv_color_white(); +} + +std::string resolve_member_label(const std::vector& members, uint32_t member_id) +{ + std::string contact_name = app::AppContext::getInstance().getContactService().getContactName(member_id); + if (!contact_name.empty()) + { + return contact_name; + } + const team::ui::TeamMemberUi* member = find_member(members, member_id); + if (member && !member->name.empty()) + { + return member->name; + } + char fallback[8]; + snprintf(fallback, sizeof(fallback), "%04lX", static_cast(member_id & 0xFFFFu)); + return std::string(fallback); +} + +std::string resolve_member_label(uint32_t member_id, const std::string& member_name) +{ + std::string contact_name = app::AppContext::getInstance().getContactService().getContactName(member_id); + if (!contact_name.empty()) + { + return contact_name; + } + if (!member_name.empty()) + { + return member_name; + } + char fallback[8]; + snprintf(fallback, sizeof(fallback), "%04lX", static_cast(member_id & 0xFFFFu)); + return std::string(fallback); +} + +lv_obj_t* create_team_marker_obj(uint32_t color) +{ + if (!g_gps_state.map) + { + return nullptr; + } + lv_obj_t* obj = lv_obj_create(g_gps_state.map); + lv_obj_set_size(obj, kTeamMarkerSize, kTeamMarkerSize); + lv_obj_set_style_bg_color(obj, lv_color_hex(color), LV_PART_MAIN); + lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_border_color(obj, lv_color_hex(kTeamMarkerBorder), LV_PART_MAIN); + lv_obj_set_style_border_width(obj, 1, LV_PART_MAIN); + lv_obj_set_style_radius(obj, LV_RADIUS_CIRCLE, LV_PART_MAIN); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + return obj; +} + +lv_obj_t* create_team_marker_label(const std::string& text, uint32_t color) +{ + if (!g_gps_state.map) + { + return nullptr; + } + lv_obj_t* label = lv_label_create(g_gps_state.map); + lv_label_set_text(label, text.c_str()); + lv_label_set_long_mode(label, LV_LABEL_LONG_DOT); + lv_obj_set_width(label, kTeamMarkerLabelWidth); + lv_obj_set_style_bg_opa(label, LV_OPA_70, 0); + lv_obj_set_style_bg_color(label, lv_color_hex(color), 0); + lv_obj_set_style_border_width(label, 0, 0); + lv_obj_set_style_pad_hor(label, 3, 0); + lv_obj_set_style_pad_ver(label, 1, 0); + lv_obj_set_style_radius(label, 4, 0); + lv_obj_set_style_text_color(label, marker_text_color(color), 0); + lv_obj_clear_flag(label, LV_OBJ_FLAG_SCROLLABLE); + return label; +} + +void update_team_marker_label(lv_obj_t* label, const std::string& text, uint32_t color) +{ + if (!label) + { + return; + } + lv_label_set_text(label, text.c_str()); + lv_obj_set_style_bg_color(label, lv_color_hex(color), 0); + lv_obj_set_style_text_color(label, marker_text_color(color), 0); +} + +int find_team_marker_index(uint32_t member_id) +{ + for (size_t i = 0; i < g_gps_state.team_markers.size(); ++i) + { + if (g_gps_state.team_markers[i].member_id == member_id) + { + return static_cast(i); + } + } + return -1; +} +} // namespace + void update_resolution_display() { if (!is_alive() || g_gps_state.resolution_label == NULL) { @@ -200,6 +420,7 @@ void update_map_tiles(bool lightweight) if (g_gps_state.gps_marker != NULL) { update_gps_marker_position(); } + update_team_marker_positions(); } lv_obj_invalidate(g_gps_state.map); @@ -275,6 +496,375 @@ void hide_gps_marker() } } +void clear_team_markers() +{ + for (auto& marker : g_gps_state.team_markers) + { + if (marker.obj) + { + lv_obj_del(marker.obj); + marker.obj = nullptr; + } + if (marker.label) + { + lv_obj_del(marker.label); + marker.label = nullptr; + } + } + g_gps_state.team_markers.clear(); +} + +static void clear_member_panel_buttons() +{ + if (g_gps_state.app_group) + { + for (auto* btn : g_gps_state.member_btns) + { + if (btn) + { + lv_group_remove_obj(btn); + } + } + } + g_gps_state.member_btns.clear(); + g_gps_state.member_btn_ids.clear(); + if (g_gps_state.member_panel) + { + lv_obj_clean(g_gps_state.member_panel); + } +} + +static void update_member_button_states() +{ + for (size_t i = 0; i < g_gps_state.member_btns.size(); ++i) + { + lv_obj_t* btn = g_gps_state.member_btns[i]; + if (!btn) + { + continue; + } + bool selected = (g_gps_state.member_btn_ids[i] == g_gps_state.selected_member_id); + lv_obj_set_style_outline_width(btn, selected ? 2 : 0, LV_PART_MAIN); + lv_obj_set_style_outline_color(btn, lv_color_hex(kTeamMarkerBorder), LV_PART_MAIN); + } +} + +static void select_member(uint32_t member_id) +{ + if (member_id == 0) + { + return; + } + g_gps_state.selected_member_id = member_id; + g_gps_state.team_marker_last_ms = 0; + clear_team_markers(); + update_member_button_states(); +} + +static void member_btn_event_cb(lv_event_t* e) +{ + if (!is_alive()) + { + return; + } + lv_event_code_t code = lv_event_get_code(e); + if (code != LV_EVENT_CLICKED && code != LV_EVENT_KEY) + { + return; + } + if (code == LV_EVENT_KEY) + { + lv_key_t key = static_cast(lv_event_get_key(e)); + if (key != LV_KEY_ENTER) + { + return; + } + } + extern void updateUserActivity(); + updateUserActivity(); + uint32_t member_id = static_cast(reinterpret_cast(lv_event_get_user_data(e))); + if (member_id == 0 || member_id == kInvalidMemberId) + { + return; + } + select_member(member_id); + refresh_team_markers_from_posring(); +} + +static lv_obj_t* create_member_button(const team::ui::TeamMemberUi& member, uint32_t color) +{ + if (!g_gps_state.member_panel) + { + return nullptr; + } + lv_obj_t* btn = lv_btn_create(g_gps_state.member_panel); + lv_obj_set_width(btn, LV_PCT(100)); + lv_obj_set_height(btn, 28); + lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE); + gps::ui::styles::apply_control_button(btn); + lv_obj_set_style_pad_all(btn, 0, LV_PART_MAIN); + + lv_obj_t* dot = lv_obj_create(btn); + lv_obj_set_size(dot, 8, 8); + lv_obj_set_style_bg_color(dot, lv_color_hex(color), LV_PART_MAIN); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_border_width(dot, 1, LV_PART_MAIN); + lv_obj_set_style_border_color(dot, lv_color_hex(kTeamMarkerBorder), LV_PART_MAIN); + lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, LV_PART_MAIN); + lv_obj_align(dot, LV_ALIGN_LEFT_MID, 3, 0); + + lv_obj_t* label = lv_label_create(btn); + lv_label_set_long_mode(label, LV_LABEL_LONG_DOT); + std::string label_text = resolve_member_label(member.node_id, member.name); + lv_label_set_text(label, label_text.c_str()); + gps::ui::styles::apply_control_button_label(label); + lv_obj_set_width(label, LV_PCT(100)); + lv_obj_set_style_pad_left(label, 16, 0); + lv_obj_set_style_pad_right(label, 6, 0); + lv_obj_align(label, LV_ALIGN_LEFT_MID, 0, 0); + + return btn; +} + +void refresh_member_panel(bool force) +{ + if (!is_alive() || !g_gps_state.member_panel) + { + return; + } + uint32_t now_ms = millis(); + if (!force && (now_ms - g_gps_state.member_panel_last_ms) < kMemberPanelRefreshMs) + { + return; + } + g_gps_state.member_panel_last_ms = now_ms; + + team::TeamId team_id{}; + std::vector members; + if (!load_team_data(team_id, members) || members.empty()) + { + if (g_gps_state.member_panel) + { + lv_obj_add_flag(g_gps_state.member_panel, LV_OBJ_FLAG_HIDDEN); + } + if (!g_gps_state.member_btns.empty() || g_gps_state.member_list_hash != 0) + { + clear_member_panel_buttons(); + g_gps_state.member_list_hash = 0; + g_gps_state.selected_member_id = kInvalidMemberId; + clear_team_markers(); + } + return; + } + (void)team_id; + lv_obj_clear_flag(g_gps_state.member_panel, LV_OBJ_FLAG_HIDDEN); + + uint32_t hash = hash_member_list(members); + bool rebuild = force || + hash != g_gps_state.member_list_hash || + members.size() != g_gps_state.member_btns.size(); + + if (rebuild) + { + clear_member_panel_buttons(); + g_gps_state.member_btn_ids.reserve(members.size()); + g_gps_state.member_btns.reserve(members.size()); + for (const auto& m : members) + { + uint32_t color = resolve_member_color(members, m.node_id); + lv_obj_t* btn = create_member_button(m, color); + if (!btn) + { + continue; + } + void* user_data = reinterpret_cast(static_cast(m.node_id)); + lv_obj_add_event_cb(btn, member_btn_event_cb, LV_EVENT_CLICKED, user_data); + lv_obj_add_event_cb(btn, member_btn_event_cb, LV_EVENT_KEY, user_data); + if (g_gps_state.app_group) + { + lv_group_add_obj(g_gps_state.app_group, btn); + } + g_gps_state.member_btns.push_back(btn); + g_gps_state.member_btn_ids.push_back(m.node_id); + } + g_gps_state.member_list_hash = hash; + fix_ui_elements_position(); + } + + if (!member_exists(members, g_gps_state.selected_member_id)) + { + g_gps_state.selected_member_id = kInvalidMemberId; + clear_team_markers(); + } + + update_member_button_states(); +} + +void update_team_marker_positions() +{ + if (!is_alive() || g_gps_state.map == NULL) + { + return; + } + if (!g_gps_state.tile_ctx.anchor || !g_gps_state.tile_ctx.anchor->valid) + { + for (auto& marker : g_gps_state.team_markers) + { + if (marker.obj) + { + lv_obj_add_flag(marker.obj, LV_OBJ_FLAG_HIDDEN); + } + if (marker.label) + { + lv_obj_add_flag(marker.label, LV_OBJ_FLAG_HIDDEN); + } + } + return; + } + for (auto& marker : g_gps_state.team_markers) + { + if (!marker.obj) + { + continue; + } + double lat = static_cast(marker.lat_e7) / 1e7; + double lng = static_cast(marker.lon_e7) / 1e7; + int screen_x = 0; + int screen_y = 0; + if (gps_screen_pos(g_gps_state.tile_ctx, lat, lng, screen_x, screen_y)) + { + lv_obj_set_pos(marker.obj, + screen_x - kTeamMarkerSize / 2, + screen_y - kTeamMarkerSize / 2); + lv_obj_clear_flag(marker.obj, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(marker.obj); + if (marker.label) + { + lv_obj_update_layout(marker.label); + int label_h = lv_obj_get_height(marker.label); + int label_x = screen_x + kTeamMarkerSize / 2 + kTeamMarkerLabelOffsetX; + int label_y = screen_y - (label_h / 2) + kTeamMarkerLabelOffsetY; + lv_obj_set_pos(marker.label, label_x, label_y); + lv_obj_clear_flag(marker.label, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(marker.label); + } + } + else + { + lv_obj_add_flag(marker.obj, LV_OBJ_FLAG_HIDDEN); + if (marker.label) + { + lv_obj_add_flag(marker.label, LV_OBJ_FLAG_HIDDEN); + } + } + } +} + +void refresh_team_markers_from_posring() +{ + if (!is_alive() || g_gps_state.map == NULL) + { + return; + } + if (g_gps_state.selected_member_id == kInvalidMemberId) + { + clear_team_markers(); + return; + } + uint32_t now_ms = millis(); + if (now_ms - g_gps_state.team_marker_last_ms < kTeamMarkerRefreshMs) + { + return; + } + g_gps_state.team_marker_last_ms = now_ms; + + team::TeamId team_id{}; + std::vector members; + if (!load_team_data(team_id, members)) + { + clear_team_markers(); + return; + } + if (!member_exists(members, g_gps_state.selected_member_id)) + { + clear_team_markers(); + return; + } + std::string label_text = resolve_member_label(members, g_gps_state.selected_member_id); + + std::vector samples; + if (!team::ui::team_ui_posring_load_latest(team_id, samples)) + { + clear_team_markers(); + return; + } + auto sample_it = std::find_if(samples.begin(), samples.end(), + [&](const team::ui::TeamPosSample& s) { + return s.member_id == g_gps_state.selected_member_id; + }); + if (sample_it == samples.end()) + { + clear_team_markers(); + return; + } + + for (auto it = g_gps_state.team_markers.begin(); it != g_gps_state.team_markers.end(); ) + { + if (it->member_id != g_gps_state.selected_member_id) + { + if (it->obj) + { + lv_obj_del(it->obj); + } + if (it->label) + { + lv_obj_del(it->label); + } + it = g_gps_state.team_markers.erase(it); + continue; + } + ++it; + } + + uint32_t color = resolve_member_color(members, g_gps_state.selected_member_id); + int idx = find_team_marker_index(g_gps_state.selected_member_id); + if (idx < 0) + { + GPSPageState::TeamMarker marker; + marker.member_id = sample_it->member_id; + marker.lat_e7 = sample_it->lat_e7; + marker.lon_e7 = sample_it->lon_e7; + marker.ts = sample_it->ts; + marker.color = color; + marker.obj = create_team_marker_obj(color); + marker.label = create_team_marker_label(label_text, color); + g_gps_state.team_markers.push_back(marker); + } + else + { + auto& marker = g_gps_state.team_markers[idx]; + marker.lat_e7 = sample_it->lat_e7; + marker.lon_e7 = sample_it->lon_e7; + marker.ts = sample_it->ts; + if (!marker.obj) + { + marker.obj = create_team_marker_obj(color); + } + if (!marker.label) + { + marker.label = create_team_marker_label(label_text, color); + } + update_team_marker_label(marker.label, label_text, color); + if (marker.color != color && marker.obj) + { + lv_obj_set_style_bg_color(marker.obj, lv_color_hex(color), LV_PART_MAIN); + marker.color = color; + } + } + + update_team_marker_positions(); +} + void tick_loader() { if (!is_alive()) { @@ -290,12 +880,6 @@ void tick_loader() ::tile_loader_step(g_gps_state.tile_ctx); - static uint32_t last_fix_ui_ms = 0; - uint32_t now_ms = millis(); - if (now_ms - last_fix_ui_ms > 100) { - fix_ui_elements_position(); - last_fix_ui_ms = now_ms; - } } diff --git a/src/ui/screens/gps/gps_page_map.h b/src/ui/screens/gps/gps_page_map.h index bad728b9..26b95791 100644 --- a/src/ui/screens/gps/gps_page_map.h +++ b/src/ui/screens/gps/gps_page_map.h @@ -12,6 +12,12 @@ void update_gps_marker_position(); void create_gps_marker(); void hide_gps_marker(); +// Team markers (posring) +void refresh_team_markers_from_posring(); +void update_team_marker_positions(); +void clear_team_markers(); +void refresh_member_panel(bool force = false); + // UI status and title updates (moved from presenter) void update_title_and_status(); void update_resolution_display(); diff --git a/src/ui/screens/gps/gps_page_styles.cpp b/src/ui/screens/gps/gps_page_styles.cpp index c4468c18..242c1ec2 100644 --- a/src/ui/screens/gps/gps_page_styles.cpp +++ b/src/ui/screens/gps/gps_page_styles.cpp @@ -361,6 +361,7 @@ void apply_all(const layout::Widgets& w, const layout::Spec& spec) if (w.map) lv_obj_add_style(w.map, &s_map_black, LV_PART_MAIN); apply_panel(w.panel, spec); + apply_panel(w.member_panel, spec); apply_resolution_label(w.resolution_label, spec); apply_control_button(w.zoom_btn); diff --git a/src/ui/screens/gps/gps_state.h b/src/ui/screens/gps/gps_state.h index adb93643..c09952d3 100644 --- a/src/ui/screens/gps/gps_state.h +++ b/src/ui/screens/gps/gps_state.h @@ -34,6 +34,7 @@ struct GPSPageState { lv_obj_t *map = nullptr; lv_obj_t *resolution_label = nullptr; // Resolution display label (bottom-left) lv_obj_t *panel = nullptr; + lv_obj_t *member_panel = nullptr; lv_obj_t *zoom = nullptr; lv_obj_t *pos = nullptr; lv_obj_t *pan_h = nullptr; // Horizontal pan button @@ -92,6 +93,25 @@ struct GPSPageState { std::string tracker_file{}; std::vector tracker_points; std::vector tracker_screen_points; + + std::vector member_btns; + std::vector member_btn_ids; + uint32_t member_list_hash = 0; + uint32_t member_panel_last_ms = 0; + uint32_t selected_member_id = 0xFFFFFFFFu; + + struct TeamMarker + { + uint32_t member_id = 0; + int32_t lat_e7 = 0; + int32_t lon_e7 = 0; + uint32_t ts = 0; + uint32_t color = 0; + lv_obj_t* obj = nullptr; + lv_obj_t* label = nullptr; + }; + std::vector team_markers; + uint32_t team_marker_last_ms = 0; // pan button editing state (for toggle behavior) - DEPRECATED, use edit_mode instead bool pan_h_editing = false; // Horizontal pan button in editing mode (rotary scrolls map) diff --git a/src/ui/screens/settings/settings_page_components.cpp b/src/ui/screens/settings/settings_page_components.cpp index ebec2ecc..3d1cbfaa 100644 --- a/src/ui/screens/settings/settings_page_components.cpp +++ b/src/ui/screens/settings/settings_page_components.cpp @@ -1062,8 +1062,7 @@ static void on_list_back_clicked(lv_event_t* /*e*/) static void settings_back_cb(void* /*user_data*/) { - destroy(); - menu_show(); + ui_request_exit_to_menu(); } } // namespace diff --git a/src/ui/screens/team/team_page_components.cpp b/src/ui/screens/team/team_page_components.cpp index 8d406613..2799d088 100644 --- a/src/ui/screens/team/team_page_components.cpp +++ b/src/ui/screens/team/team_page_components.cpp @@ -8,9 +8,23 @@ #include "team_page_styles.h" #include "team_page_input.h" #include "team_state.h" +#include "team_ui_store.h" #include "../../ui_common.h" #include "../../../app/app_context.h" #include "../../../team/protocol/team_mgmt.h" +#include "../../../team/usecase/team_service.h" +#include "../../../team/infra/nfc/team_nfc.h" +#include "../../../sys/event_bus.h" +#include "../../widgets/system_notification.h" +#include "meshtastic/mesh.pb.h" +#include "pb_decode.h" + +#include +#include +#include +#include +#include +#include namespace team { @@ -18,6 +32,203 @@ namespace ui { namespace { +constexpr int kActionBtnHeight = 32; +constexpr int kActionBtnWidth2 = 170; +constexpr int kActionBtnWidth3 = 140; +constexpr int kListItemHeight = 32; +constexpr int kInviteTtlSec = 9 * 60; +constexpr uint8_t kKeyDistMaxRetries = 3; +constexpr uint32_t kKeyDistRetryIntervalSec = 5; +constexpr uint32_t kJoinPendingTimeoutSec = 30; +constexpr uint32_t kNfcScanDurationSec = 10; +constexpr size_t kInviteCodeLen = 6; +bool s_state_loaded = false; + +uint32_t now_secs(); +void stop_nfc_share(); +void stop_nfc_scan(); +void handle_join_enter_code(lv_event_t*); +std::string generate_invite_code(); +bool is_team_ui_active(); +std::string resolve_node_name(uint32_t node_id); +bool send_invite_advertise(bool regenerate_code); +uint32_t hash_invite_code(const std::string& code); +uint32_t resolve_leader_node_id(); + +uint64_t team_id_to_u64(const TeamId& id) +{ + uint64_t value = 0; + for (size_t i = 0; i < id.size(); ++i) + { + value |= (static_cast(id[i]) << (8 * i)); + } + return value; +} + +void write_u32_le(std::vector& out, uint32_t v) +{ + out.push_back(static_cast(v & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + out.push_back(static_cast((v >> 16) & 0xFF)); + out.push_back(static_cast((v >> 24) & 0xFF)); +} + +void write_u64_le(std::vector& out, uint64_t v) +{ + for (int i = 0; i < 8; ++i) + { + out.push_back(static_cast((v >> (8 * i)) & 0xFF)); + } +} + +bool append_key_event(TeamKeyEventType type, const std::vector& payload) +{ + if (!g_team_state.has_team_id) + { + return false; + } + uint32_t seq = g_team_state.last_event_seq + 1; + if (!team_ui_append_key_event(g_team_state.team_id, + type, + seq, + now_secs(), + payload.data(), + payload.size())) + { + return false; + } + g_team_state.last_event_seq = seq; + return true; +} + +void notify_send_failed(const char* action, bool needs_keys) +{ + const char* msg = "Send failed"; + if (needs_keys) + { + msg = "Send failed (keys not ready)"; + } + if (action && action[0]) + { + char buf[64]; + snprintf(buf, sizeof(buf), "%s: %s", action, msg); + ::ui::SystemNotification::show(buf, 2000); + return; + } + ::ui::SystemNotification::show(msg, 2000); +} + +void notify_send_failed_detail(const char* action, team::TeamService::SendError err) +{ + const char* reason = "send failed"; + switch (err) + { + case team::TeamService::SendError::KeysNotReady: + reason = "keys not ready"; + break; + case team::TeamService::SendError::EncodeFail: + reason = "encode failed"; + break; + case team::TeamService::SendError::EncryptFail: + reason = "encrypt failed"; + break; + case team::TeamService::SendError::MeshSendFail: + reason = "queue full"; + break; + default: + break; + } + char buf[64]; + snprintf(buf, sizeof(buf), "%s: %s", action ? action : "Send", reason); + ::ui::SystemNotification::show(buf, 2000); +} + +struct KeyDistPending +{ + uint32_t node_id = 0; + uint32_t key_id = 0; + uint8_t attempts = 0; + uint32_t next_retry_s = 0; +}; + +std::vector s_keydist_pending; + +void add_keydist_pending(uint32_t node_id, uint32_t key_id) +{ + for (auto& item : s_keydist_pending) + { + if (item.node_id == node_id && item.key_id == key_id) + { + return; + } + } + KeyDistPending item; + item.node_id = node_id; + item.key_id = key_id; + item.attempts = 0; + item.next_retry_s = now_secs() + kKeyDistRetryIntervalSec; + s_keydist_pending.push_back(item); +} + +void mark_keydist_confirmed(uint32_t node_id, uint32_t key_id) +{ + s_keydist_pending.erase( + std::remove_if(s_keydist_pending.begin(), s_keydist_pending.end(), + [&](const KeyDistPending& item) { + return item.node_id == node_id && item.key_id == key_id; + }), + s_keydist_pending.end()); +} + +void process_keydist_retries() +{ + if (s_keydist_pending.empty()) + { + return; + } + if (!g_team_state.has_team_psk || !g_team_state.has_team_id) + { + return; + } + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + uint32_t now = now_secs(); + if (!controller) + { + return; + } + + for (auto it = s_keydist_pending.begin(); it != s_keydist_pending.end(); ) + { + if (now < it->next_retry_s) + { + ++it; + continue; + } + if (it->attempts >= kKeyDistMaxRetries) + { + notify_send_failed("KeyDist", false); + it = s_keydist_pending.erase(it); + continue; + } + + team::proto::TeamKeyDist kd{}; + kd.team_id = g_team_state.team_id; + kd.key_id = it->key_id; + kd.channel_psk_len = static_cast(g_team_state.team_psk.size()); + kd.channel_psk = g_team_state.team_psk; + + bool ok = controller->onKeyDistPlain(kd, chat::ChannelId::PRIMARY, it->node_id); + if (!ok) + { + notify_send_failed_detail("KeyDist", controller->getLastSendError()); + } + it->attempts += 1; + it->next_retry_s = now + kKeyDistRetryIntervalSec; + ++it; + } +} + void update_top_bar_title(const char* title) { if (!title) @@ -37,37 +248,1998 @@ void reset_team_ui_state() } } -void top_bar_back(void*) +void enter_kicked_out_state() { - reset_team_ui_state(); - team_page_destroy(); - menu_show(); + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + if (controller) + { + controller->clearKeys(); + } + g_team_state.in_team = false; + g_team_state.pending_join = false; + g_team_state.pending_join_started_s = 0; + g_team_state.kicked_out = true; + g_team_state.self_is_leader = false; + g_team_state.last_event_seq = 0; + g_team_state.members.clear(); + g_team_state.has_team_id = false; + g_team_state.team_name.clear(); + g_team_state.security_round = 0; + g_team_state.has_team_psk = false; + g_team_state.waiting_new_keys = false; + g_team_state.invite_mode = TeamInviteMode::Radio; + g_team_state.invite_mode = TeamInviteMode::Radio; + g_team_state.has_join_target = false; + g_team_state.has_nfc_next_psk = false; + g_team_state.nfc_next_key_id = 0; + g_team_state.nfc_payload.clear(); + g_team_state.has_nfc_payload = false; + stop_nfc_share(); + stop_nfc_scan(); + s_keydist_pending.clear(); + g_team_state.page = TeamPage::KickedOut; + g_team_state.nav_stack.clear(); } -void label_set_text(lv_obj_t* btn, const char* text) +void clear_focusables() { + g_team_state.focusables.clear(); + g_team_state.default_focus = nullptr; +} + +void register_focus(lv_obj_t* obj, bool is_default = false) +{ + if (!obj) + { + return; + } + g_team_state.focusables.push_back(obj); + if (is_default || !g_team_state.default_focus) + { + g_team_state.default_focus = obj; + } +} + +void clear_content() +{ + if (g_team_state.body) + { + lv_obj_clean(g_team_state.body); + } + if (g_team_state.actions) + { + lv_obj_clean(g_team_state.actions); + } + g_team_state.list_items.clear(); + for (auto& btn : g_team_state.action_btns) + { + btn = nullptr; + } + for (auto& label : g_team_state.action_labels) + { + label = nullptr; + } + g_team_state.detail_label = nullptr; + g_team_state.invite_code_textarea = nullptr; + clear_focusables(); +} + +void modal_prepare_group() +{ + if (!g_team_state.modal_group) + { + g_team_state.modal_group = lv_group_create(); + } + lv_group_remove_all_objs(g_team_state.modal_group); + g_team_state.prev_group = lv_group_get_default(); + set_default_group(g_team_state.modal_group); +} + +void modal_restore_group() +{ + lv_group_t* restore = g_team_state.prev_group; + if (!restore) + { + restore = g_team_state.group; + } + if (restore) + { + set_default_group(restore); + } + g_team_state.prev_group = nullptr; +} + +lv_obj_t* create_modal_root(int width, int height) +{ + lv_obj_t* screen = lv_screen_active(); + lv_coord_t screen_w = lv_obj_get_width(screen); + lv_coord_t screen_h = lv_obj_get_height(screen); + + lv_obj_t* bg = lv_obj_create(screen); + lv_obj_set_size(bg, screen_w, screen_h); + lv_obj_set_pos(bg, 0, 0); + lv_obj_set_style_bg_color(bg, lv_color_black(), LV_PART_MAIN); + lv_obj_set_style_bg_opa(bg, LV_OPA_50, LV_PART_MAIN); + lv_obj_set_style_border_width(bg, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(bg, 0, LV_PART_MAIN); + lv_obj_clear_flag(bg, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(bg, LV_OBJ_FLAG_CLICKABLE); + + lv_obj_t* win = lv_obj_create(bg); + lv_obj_set_size(win, width, height); + lv_obj_center(win); + lv_obj_set_style_bg_color(win, lv_color_white(), LV_PART_MAIN); + lv_obj_set_style_bg_opa(win, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_border_width(win, 2, LV_PART_MAIN); + lv_obj_set_style_border_color(win, lv_color_hex(0x333333), LV_PART_MAIN); + lv_obj_set_style_radius(win, 8, LV_PART_MAIN); + lv_obj_set_style_pad_all(win, 8, LV_PART_MAIN); + lv_obj_clear_flag(win, LV_OBJ_FLAG_SCROLLABLE); + + return bg; +} + +void close_join_request_modal() +{ + if (g_team_state.join_request_modal) + { + lv_obj_del(g_team_state.join_request_modal); + g_team_state.join_request_modal = nullptr; + } + g_team_state.pending_join_node_id = 0; + g_team_state.pending_join_name.clear(); + modal_restore_group(); +} + +void close_leave_confirm_modal() +{ + if (g_team_state.leave_confirm_modal) + { + lv_obj_del(g_team_state.leave_confirm_modal); + g_team_state.leave_confirm_modal = nullptr; + } + modal_restore_group(); +} + +uint32_t now_secs() +{ + return static_cast(millis() / 1000); +} + +void ensure_invite_code() +{ + if (g_team_state.invite_code.empty()) + { + g_team_state.invite_code = generate_invite_code(); + } + if (g_team_state.invite_expires_s == 0) + { + g_team_state.invite_expires_s = kInviteTtlSec; + } +} + +bool send_invite_advertise(bool regenerate_code) +{ + if (regenerate_code) + { + g_team_state.invite_code = generate_invite_code(); + g_team_state.invite_expires_s = kInviteTtlSec; + } + ensure_invite_code(); + if (!g_team_state.has_team_id) + { + notify_send_failed("Invite", false); + return false; + } + + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + if (!controller || g_team_state.invite_mode != TeamInviteMode::Radio) + { + return false; + } + + team::proto::TeamAdvertise adv{}; + adv.team_id = g_team_state.team_id; + adv.has_join_hint = true; + adv.join_hint = hash_invite_code(g_team_state.invite_code); + adv.has_expires_at = true; + adv.expires_at = now_secs() + g_team_state.invite_expires_s; + adv.nonce = random(0, 0xFFFFFFFF); + if (!controller->onAdvertise(adv, chat::ChannelId::PRIMARY, 0)) + { + notify_send_failed("Invite", false); + return false; + } + + g_team_state.last_update_s = now_secs(); + return true; +} + +bool ensure_nfc_share_payload() +{ + if (!g_team_state.has_team_id) + { + return false; + } + ensure_invite_code(); + uint32_t next_key_id = g_team_state.security_round ? (g_team_state.security_round + 1) : 1; + std::array next_psk{}; + for (size_t i = 0; i < next_psk.size(); ++i) + { + next_psk[i] = static_cast(random(0, 256)); + } + + std::vector payload; + uint32_t expires_at = now_secs() + g_team_state.invite_expires_s; + if (!team::nfc::build_payload(g_team_state.team_id, + next_key_id, + expires_at, + next_psk.data(), + next_psk.size(), + g_team_state.invite_code, + payload)) + { + return false; + } + + g_team_state.nfc_next_key_id = next_key_id; + g_team_state.nfc_next_psk = next_psk; + g_team_state.has_nfc_next_psk = true; + g_team_state.nfc_payload = payload; + g_team_state.has_nfc_payload = true; + return true; +} + +void stop_nfc_share() +{ + if (g_team_state.nfc_share_active) + { + team::nfc::stop_share(); + } + g_team_state.nfc_share_active = false; + g_team_state.has_nfc_next_psk = false; + g_team_state.nfc_next_key_id = 0; +} + +bool start_nfc_share() +{ + if (!ensure_nfc_share_payload()) + { + return false; + } + if (!team::nfc::start_share(g_team_state.nfc_payload)) + { + return false; + } + g_team_state.nfc_share_active = true; + return true; +} + +void stop_nfc_scan() +{ + if (g_team_state.nfc_scan_active) + { + team::nfc::stop_scan(); + } + g_team_state.nfc_scan_active = false; + g_team_state.nfc_scan_started_s = 0; +} + +bool start_nfc_scan() +{ + if (!team::nfc::start_scan(static_cast(kNfcScanDurationSec * 1000))) + { + return false; + } + g_team_state.nfc_scan_active = true; + g_team_state.nfc_scan_started_s = now_secs(); + return true; +} + +int online_count() +{ + int count = 0; + uint32_t now = now_secs(); + for (auto& m : g_team_state.members) + { + if (m.last_seen_s > 0 && (now - m.last_seen_s) <= 120) + { + m.online = true; + } + else + { + m.online = false; + } + if (m.online) + { + ++count; + } + } + return count; +} + +lv_obj_t* add_label(lv_obj_t* parent, const char* text, bool section = false, bool meta = false) +{ + lv_obj_t* label = lv_label_create(parent); + lv_label_set_text(label, text); + lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP); + lv_obj_set_width(label, LV_PCT(100)); + if (section) + { + style::apply_section_label(label); + } + else if (meta) + { + style::apply_meta_label(label); + } + return label; +} + +lv_obj_t* create_action_button(const char* text, int width, lv_event_cb_t cb) +{ + lv_obj_t* btn = lv_btn_create(g_team_state.actions); + lv_obj_set_size(btn, width, kActionBtnHeight); + style::apply_button_secondary(btn); lv_obj_t* label = lv_label_create(btn); lv_label_set_text(label, text); lv_obj_center(label); + lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, nullptr); + return btn; +} + +lv_obj_t* create_list_item(const char* left, const char* right) +{ + lv_obj_t* btn = lv_btn_create(g_team_state.body); + lv_obj_set_size(btn, LV_PCT(100), kListItemHeight); + lv_obj_set_flex_flow(btn, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(btn, LV_FLEX_ALIGN_SPACE_BETWEEN, + LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + style::apply_list_item(btn); + + lv_obj_t* left_label = lv_label_create(btn); + lv_label_set_text(left_label, left); + lv_obj_set_width(left_label, LV_PCT(70)); + lv_label_set_long_mode(left_label, LV_LABEL_LONG_CLIP); + + lv_obj_t* right_label = lv_label_create(btn); + lv_label_set_text(right_label, right); + lv_obj_set_width(right_label, LV_PCT(30)); + lv_label_set_long_mode(right_label, LV_LABEL_LONG_CLIP); + lv_obj_set_style_text_align(right_label, LV_TEXT_ALIGN_RIGHT, 0); + + g_team_state.list_items.push_back(btn); + return btn; +} + +std::string format_signal(uint8_t bars) +{ + if (bars > 4) + { + bars = 4; + } + char buf[32]; + snprintf(buf, sizeof(buf), "%s %u/4", LV_SYMBOL_BARS, static_cast(bars)); + return std::string(buf); +} + + +std::string format_invite_code(const std::string& code) +{ + if (code.empty()) + { + return "--"; + } + return code; +} + +uint32_t hash_invite_code(const std::string& code) +{ + uint32_t h = 2166136261u; + for (char c : code) + { + h ^= static_cast(c); + h *= 16777619u; + } + return h; +} + +std::string generate_invite_code() +{ + static const char kAlphabet[] = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + constexpr size_t kAlphaLen = sizeof(kAlphabet) - 1; + auto pick = []() { + return kAlphabet[random(0, kAlphaLen)]; + }; + std::string code; + code.reserve(6); + for (int i = 0; i < 6; ++i) + { + code.push_back(pick()); + } + return code; +} + +std::string normalize_invite_code(const std::string& raw) +{ + std::string out; + out.reserve(raw.size()); + for (char c : raw) + { + if (c == '-' || c == ' ' || c == '\t' || c == '\n' || c == '\r') + { + continue; + } + if (c >= 'a' && c <= 'z') + { + c = static_cast(c - 'a' + 'A'); + } + out.push_back(c); + } + return out; +} + +std::string format_last_seen(uint32_t last_seen_s) +{ + if (last_seen_s == 0) + { + return "Last seen --"; + } + uint32_t now = now_secs(); + if (now <= last_seen_s) + { + return "Online"; + } + uint32_t age = now - last_seen_s; + if (age <= 120) + { + return "Online"; + } + if (age < 3600) + { + char buf[24]; + snprintf(buf, sizeof(buf), "Last seen %um ago", (unsigned)(age / 60)); + return std::string(buf); + } + char buf[24]; + snprintf(buf, sizeof(buf), "Last seen %uh ago", (unsigned)(age / 3600)); + return std::string(buf); +} + +std::string format_last_update(uint32_t last_update_s) +{ + if (last_update_s == 0) + { + return "Last update --"; + } + uint32_t now = now_secs(); + if (now <= last_update_s) + { + return "Last update 0s ago"; + } + uint32_t age = now - last_update_s; + char buf[32]; + snprintf(buf, sizeof(buf), "Last update %us ago", (unsigned)age); + return std::string(buf); +} + +std::string format_team_name_from_id(const TeamId& id) +{ + char buf[16]; + snprintf(buf, sizeof(buf), "TEAM-%02X%02X", id[0], id[1]); + return std::string(buf); +} + +std::string current_team_name() +{ + if (!g_team_state.team_name.empty()) + { + return g_team_state.team_name; + } + if (g_team_state.has_team_id) + { + return format_team_name_from_id(g_team_state.team_id); + } + return "Unknown"; +} + +TeamId generate_team_id() +{ + TeamId id{}; + for (size_t i = 0; i < id.size(); ++i) + { + id[i] = static_cast(random(0, 256)); + } + return id; +} + +std::string resolve_node_name(uint32_t node_id) +{ + app::AppContext& app_ctx = app::AppContext::getInstance(); + std::string name = app_ctx.getContactService().getContactName(node_id); + if (!name.empty()) + { + return name; + } + char fallback[16]; + snprintf(fallback, sizeof(fallback), "%08lX", static_cast(node_id)); + return std::string(fallback); +} + +int find_member_index(uint32_t node_id) +{ + for (size_t i = 0; i < g_team_state.members.size(); ++i) + { + if (g_team_state.members[i].node_id == node_id) + { + return static_cast(i); + } + } + return -1; +} + +uint32_t resolve_leader_node_id() +{ + for (const auto& m : g_team_state.members) + { + if (m.leader) + { + return m.node_id; + } + } + return 0; +} + +void assign_member_color(TeamMemberUi& member) +{ + uint32_t node_id = member.node_id; + if (node_id == 0) + { + node_id = app::AppContext::getInstance().getSelfNodeId(); + } + member.color_index = team_color_index_from_node_id(node_id); +} + +void ensure_member_colors() +{ + for (auto& m : g_team_state.members) + { + uint32_t node_id = m.node_id; + if (node_id == 0) + { + node_id = app::AppContext::getInstance().getSelfNodeId(); + } + m.color_index = team_color_index_from_node_id(node_id); + } +} + +void touch_member(uint32_t node_id, uint32_t last_seen_s) +{ + int idx = find_member_index(node_id); + if (idx < 0) + { + TeamMemberUi info; + info.node_id = node_id; + info.name = resolve_node_name(node_id); + info.last_seen_s = last_seen_s; + assign_member_color(info); + g_team_state.members.push_back(info); + return; + } + g_team_state.members[idx].last_seen_s = last_seen_s; + g_team_state.members[idx].name = resolve_node_name(node_id); +} + +int find_nearby_team(const TeamId& id) +{ + for (size_t i = 0; i < g_team_state.nearby_teams.size(); ++i) + { + if (g_team_state.nearby_teams[i].team_id == id) + { + return static_cast(i); + } + } + return -1; +} + +TeamUiSnapshot snapshot_from_state() +{ + TeamUiSnapshot snap; + snap.in_team = g_team_state.in_team; + snap.pending_join = g_team_state.pending_join; + snap.pending_join_started_s = g_team_state.pending_join_started_s; + snap.kicked_out = g_team_state.kicked_out; + snap.self_is_leader = g_team_state.self_is_leader; + snap.last_event_seq = g_team_state.last_event_seq; + snap.team_id = g_team_state.team_id; + snap.has_team_id = g_team_state.has_team_id; + snap.join_target_id = g_team_state.join_target_id; + snap.has_join_target = g_team_state.has_join_target; + snap.team_name = g_team_state.team_name; + snap.security_round = g_team_state.security_round; + snap.invite_code = g_team_state.invite_code; + snap.invite_expires_s = g_team_state.invite_expires_s; + snap.last_update_s = g_team_state.last_update_s; + snap.team_psk = g_team_state.team_psk; + snap.has_team_psk = g_team_state.has_team_psk; + snap.members = g_team_state.members; + snap.nearby_teams = g_team_state.nearby_teams; + return snap; +} + +void apply_snapshot(const TeamUiSnapshot& snap) +{ + g_team_state.in_team = snap.in_team; + g_team_state.pending_join = snap.pending_join; + g_team_state.pending_join_started_s = snap.pending_join_started_s; + g_team_state.kicked_out = snap.kicked_out; + g_team_state.self_is_leader = snap.self_is_leader; + g_team_state.last_event_seq = snap.last_event_seq; + g_team_state.team_id = snap.team_id; + g_team_state.has_team_id = snap.has_team_id; + g_team_state.join_target_id = snap.join_target_id; + g_team_state.has_join_target = snap.has_join_target; + g_team_state.team_name = snap.team_name; + g_team_state.security_round = snap.security_round; + g_team_state.invite_code = snap.invite_code; + g_team_state.invite_expires_s = snap.invite_expires_s; + g_team_state.last_update_s = snap.last_update_s; + g_team_state.team_psk = snap.team_psk; + g_team_state.has_team_psk = snap.has_team_psk; + g_team_state.members = snap.members; + g_team_state.nearby_teams = snap.nearby_teams; + ensure_member_colors(); +} + +bool is_team_ui_active() +{ + return g_team_state.root && lv_obj_is_valid(g_team_state.root); +} + +void load_state_from_store() +{ + if (s_state_loaded) + { + return; + } + TeamUiSnapshot snap; + if (team_ui_get_store().load(snap)) + { + apply_snapshot(snap); + } + s_state_loaded = true; +} + +void save_state_to_store() +{ + TeamUiSnapshot snap = snapshot_from_state(); + team_ui_get_store().save(snap); +} + +void update_team_name_from_id(const TeamId& id) +{ + g_team_state.team_name = format_team_name_from_id(id); +} + +void handle_team_advertise(const team::TeamAdvertiseEvent& ev) +{ + int idx = find_nearby_team(ev.msg.team_id); + NearbyTeamUi info; + info.team_id = ev.msg.team_id; + info.name = format_team_name_from_id(ev.msg.team_id); + info.signal_bars = 0; + info.last_seen_s = ev.ctx.timestamp; + if (ev.msg.has_join_hint) + { + info.join_hint = ev.msg.join_hint; + info.has_join_hint = true; + } + if (idx < 0) + { + g_team_state.nearby_teams.push_back(info); + } + else + { + g_team_state.nearby_teams[idx] = info; + } + g_team_state.last_update_s = ev.ctx.timestamp; +} + +void handle_team_join_request(const team::TeamJoinRequestEvent& ev) +{ + if (!g_team_state.in_team || !g_team_state.self_is_leader) + { + return; + } + if (find_member_index(ev.ctx.from) >= 0) + { + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + if (!controller || !g_team_state.has_team_psk || + !g_team_state.has_team_id || g_team_state.security_round == 0) + { + notify_send_failed("KeyDist", true); + return; + } + team::proto::TeamKeyDist kd{}; + kd.team_id = g_team_state.team_id; + kd.key_id = g_team_state.security_round; + kd.channel_psk_len = static_cast(g_team_state.team_psk.size()); + kd.channel_psk = g_team_state.team_psk; + if (!controller->onKeyDistPlain(kd, chat::ChannelId::PRIMARY, ev.ctx.from)) + { + notify_send_failed_detail("KeyDist", controller->getLastSendError()); + } + else + { + add_keydist_pending(ev.ctx.from, kd.key_id); + ::ui::SystemNotification::show("KeyDist resent", 2000); + } + return; + } + { + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + if (controller) + { + controller->requestNodeInfo(ev.ctx.from, true); + } + } + if (!is_team_ui_active()) + { + g_team_state.pending_join_node_id = ev.ctx.from; + g_team_state.pending_join_name = resolve_node_name(ev.ctx.from); + return; + } + if (g_team_state.join_request_modal) + { + return; + } + g_team_state.pending_join_node_id = ev.ctx.from; + g_team_state.pending_join_name = resolve_node_name(ev.ctx.from); + + modal_prepare_group(); + g_team_state.join_request_modal = create_modal_root(280, 150); + lv_obj_t* win = lv_obj_get_child(g_team_state.join_request_modal, 0); + + std::string title = "Join request"; + lv_obj_t* title_label = lv_label_create(win); + lv_label_set_text(title_label, title.c_str()); + lv_obj_align(title_label, LV_ALIGN_TOP_MID, 0, 0); + + std::string name_line = g_team_state.pending_join_name + " wants to join"; + lv_obj_t* name_label = lv_label_create(win); + lv_label_set_text(name_label, name_line.c_str()); + lv_obj_align(name_label, LV_ALIGN_TOP_MID, 0, 26); + + lv_obj_t* btn_row = lv_obj_create(win); + lv_obj_set_size(btn_row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_align(btn_row, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_set_flex_flow(btn_row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(btn_row, LV_FLEX_ALIGN_SPACE_EVENLY, + LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(btn_row, 0, LV_PART_MAIN); + lv_obj_set_style_bg_opa(btn_row, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_border_width(btn_row, 0, LV_PART_MAIN); + lv_obj_clear_flag(btn_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* accept_btn = lv_btn_create(btn_row); + lv_obj_set_size(accept_btn, 90, 32); + style::apply_button_secondary(accept_btn); + lv_obj_t* accept_label = lv_label_create(accept_btn); + lv_label_set_text(accept_label, "Accept"); + lv_obj_center(accept_label); + lv_obj_add_event_cb(accept_btn, [](lv_event_t*) { + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + if (controller) + { + controller->requestNodeInfo(g_team_state.pending_join_node_id, true); + team::proto::TeamJoinDecision decision; + decision.accept = true; + bool decision_ok = controller->onJoinDecision(decision, chat::ChannelId::PRIMARY, + g_team_state.pending_join_node_id); + if (!decision_ok) + { + notify_send_failed("Decision", false); + } + + team::proto::TeamJoinAccept accept{}; + bool use_nfc = (g_team_state.invite_mode == TeamInviteMode::Nfc) && + g_team_state.has_nfc_next_psk; + uint32_t new_key_id = 0; + std::array new_psk{}; + if (use_nfc) + { + new_key_id = g_team_state.nfc_next_key_id; + new_psk = g_team_state.nfc_next_psk; + } + else + { + uint32_t old_key_id = g_team_state.security_round; + if (old_key_id == 0) + { + old_key_id = 1; + } + new_key_id = old_key_id + 1; + for (size_t i = 0; i < new_psk.size(); ++i) + { + new_psk[i] = static_cast(random(0, 256)); + } + } + + team::proto::TeamKeyDist kd{}; + kd.team_id = g_team_state.team_id; + kd.key_id = new_key_id; + kd.channel_psk_len = static_cast(new_psk.size()); + kd.channel_psk = new_psk; + + // Send KeyDist encrypted with OLD keys before rotating locally. + for (const auto& m : g_team_state.members) + { + if (m.node_id == 0) + { + continue; + } + if (use_nfc && m.node_id == g_team_state.pending_join_node_id) + { + continue; + } + if (!controller->onKeyDist(kd, chat::ChannelId::PRIMARY, m.node_id)) + { + notify_send_failed_detail("KeyDist", controller->getLastSendError()); + } + add_keydist_pending(m.node_id, new_key_id); + } + if (!use_nfc) + { + if (!controller->onKeyDistPlain(kd, chat::ChannelId::PRIMARY, + g_team_state.pending_join_node_id)) + { + notify_send_failed_detail("KeyDist", controller->getLastSendError()); + } + add_keydist_pending(g_team_state.pending_join_node_id, new_key_id); + } + + g_team_state.security_round = new_key_id; + g_team_state.team_psk = new_psk; + g_team_state.has_team_psk = true; + g_team_state.waiting_new_keys = false; + g_team_state.has_nfc_next_psk = false; + g_team_state.nfc_next_key_id = 0; + team_ui_save_keys_now(g_team_state.team_id, + g_team_state.security_round, + g_team_state.team_psk); + { + std::vector payload; + write_u32_le(payload, g_team_state.pending_join_node_id); + payload.push_back(1); + payload.push_back(0); + payload.push_back(0); + payload.push_back(0); + append_key_event(TeamKeyEventType::MemberAccepted, payload); + } + { + std::vector payload; + write_u32_le(payload, g_team_state.security_round); + append_key_event(TeamKeyEventType::EpochRotated, payload); + } + if (!controller->setKeysFromPsk(g_team_state.team_id, + g_team_state.security_round, + g_team_state.team_psk.data(), + g_team_state.team_psk.size())) + { + notify_send_failed("Keys", true); + } + + accept.key_id = g_team_state.security_round; + if (g_team_state.has_team_id) + { + accept.team_id = g_team_state.team_id; + accept.has_team_id = true; + } + if (!use_nfc) + { + accept.channel_psk_len = static_cast(g_team_state.team_psk.size()); + accept.channel_psk = g_team_state.team_psk; + } + bool accept_ok = controller->onAcceptJoin(accept, chat::ChannelId::PRIMARY, + g_team_state.pending_join_node_id); + if (!accept_ok) + { + notify_send_failed("JoinAccept", false); + } + + team::proto::TeamStatus status{}; + status.key_id = g_team_state.security_round; + if (!controller->onStatus(status, chat::ChannelId::PRIMARY, 0)) + { + notify_send_failed("Status", true); + } + if (!controller->onStatusPlain(status, chat::ChannelId::PRIMARY, 0)) + { + notify_send_failed("Status", false); + } + if (use_nfc) + { + stop_nfc_share(); + } + } + touch_member(g_team_state.pending_join_node_id, now_secs()); + g_team_state.last_update_s = now_secs(); + save_state_to_store(); + close_join_request_modal(); + }, LV_EVENT_CLICKED, nullptr); + + lv_obj_t* reject_btn = lv_btn_create(btn_row); + lv_obj_set_size(reject_btn, 90, 32); + style::apply_button_secondary(reject_btn); + lv_obj_t* reject_label = lv_label_create(reject_btn); + lv_label_set_text(reject_label, "Reject"); + lv_obj_center(reject_label); + lv_obj_add_event_cb(reject_btn, [](lv_event_t*) { + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + if (controller) + { + controller->requestNodeInfo(g_team_state.pending_join_node_id, true); + team::proto::TeamJoinDecision decision; + decision.accept = false; + bool decision_ok = controller->onJoinDecision(decision, chat::ChannelId::PRIMARY, + g_team_state.pending_join_node_id); + if (!decision_ok) + { + notify_send_failed("Decision", false); + } + } + close_join_request_modal(); + }, LV_EVENT_CLICKED, nullptr); + + lv_group_add_obj(g_team_state.modal_group, accept_btn); + lv_group_add_obj(g_team_state.modal_group, reject_btn); + lv_group_focus_obj(accept_btn); +} + +void handle_team_join_accept(const team::TeamJoinAcceptEvent& ev) +{ + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + if (controller) + { + controller->resetUiState(); + } + g_team_state.in_team = true; + g_team_state.pending_join = false; + g_team_state.pending_join_started_s = 0; + g_team_state.kicked_out = false; + g_team_state.self_is_leader = false; + if (ev.msg.has_team_id) + { + g_team_state.team_id = ev.msg.team_id; + g_team_state.has_team_id = true; + update_team_name_from_id(ev.msg.team_id); + } + else if (!g_team_state.has_team_id) + { + g_team_state.team_id = ev.ctx.team_id; + g_team_state.has_team_id = true; + update_team_name_from_id(ev.ctx.team_id); + } + g_team_state.has_join_target = false; + if (ev.ctx.key_id != 0) + { + g_team_state.security_round = ev.ctx.key_id; + } + if (ev.msg.key_id != 0) + { + g_team_state.security_round = ev.msg.key_id; + } + g_team_state.members.clear(); + TeamMemberUi self; + self.node_id = 0; + self.name = "You"; + self.leader = false; + self.last_seen_s = now_secs(); + assign_member_color(self); + g_team_state.members.push_back(self); + if (ev.ctx.from != 0) + { + int idx = find_member_index(ev.ctx.from); + if (idx < 0) + { + TeamMemberUi leader; + leader.node_id = ev.ctx.from; + leader.name = resolve_node_name(ev.ctx.from); + leader.leader = true; + leader.last_seen_s = ev.ctx.timestamp ? ev.ctx.timestamp : now_secs(); + assign_member_color(leader); + g_team_state.members.push_back(leader); + } + else + { + g_team_state.members[idx].leader = true; + g_team_state.members[idx].last_seen_s = ev.ctx.timestamp ? ev.ctx.timestamp : now_secs(); + g_team_state.members[idx].name = resolve_node_name(ev.ctx.from); + } + } + g_team_state.last_update_s = ev.ctx.timestamp; + g_team_state.page = TeamPage::StatusInTeam; + g_team_state.nav_stack.clear(); + g_team_state.waiting_new_keys = false; + + { + std::vector payload; + write_u32_le(payload, 0); + payload.push_back(1); + payload.push_back(0); + payload.push_back(0); + payload.push_back(0); + append_key_event(TeamKeyEventType::MemberAccepted, payload); + } + if (g_team_state.security_round != 0) + { + std::vector payload; + write_u32_le(payload, g_team_state.security_round); + append_key_event(TeamKeyEventType::EpochRotated, payload); + } + if (controller && ev.msg.channel_psk_len > 0 && g_team_state.has_team_id) + { + g_team_state.team_psk = ev.msg.channel_psk; + g_team_state.has_team_psk = true; + controller->setKeysFromPsk(g_team_state.team_id, + g_team_state.security_round, + ev.msg.channel_psk.data(), + ev.msg.channel_psk_len); + team_ui_save_keys_now(g_team_state.team_id, + g_team_state.security_round, + g_team_state.team_psk); + } + + if (controller) + { + team::proto::TeamJoinConfirm confirm{}; + confirm.ok = true; + if (!controller->onConfirmJoin(confirm, chat::ChannelId::PRIMARY, 0)) + { + notify_send_failed("JoinConfirm", true); + } + } +} + +void handle_team_join_confirm(const team::TeamJoinConfirmEvent& ev) +{ + if (!g_team_state.in_team) + { + return; + } + if (ev.msg.ok) + { + touch_member(ev.ctx.from, ev.ctx.timestamp); + g_team_state.last_update_s = ev.ctx.timestamp; + } +} + +void handle_team_join_decision(const team::TeamJoinDecisionEvent& ev) +{ + if (ev.msg.accept) + { + return; + } + { + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + if (controller) + { + controller->resetUiState(); + } + } + if (g_team_state.pending_join) + { + g_team_state.pending_join = false; + g_team_state.pending_join_started_s = 0; + g_team_state.has_join_target = false; + g_team_state.page = TeamPage::JoinTeam; + g_team_state.nav_stack.clear(); + ::ui::SystemNotification::show("Join rejected", 2000); + } +} + +void handle_team_error(const team::TeamErrorEvent& ev) +{ + if (!g_team_state.in_team || g_team_state.self_is_leader) + { + return; + } + if (g_team_state.has_team_id && ev.ctx.team_id != g_team_state.team_id) + { + return; + } + if (ev.error == team::TeamProtocolError::DecryptFail || + ev.error == team::TeamProtocolError::KeyMismatch) + { + if (!g_team_state.waiting_new_keys) + { + ::ui::SystemNotification::show("Team keys mismatch", 2000); + } + g_team_state.waiting_new_keys = true; + } +} + +void handle_team_status(const team::TeamStatusEvent& ev) +{ + if (g_team_state.has_team_id && ev.ctx.team_id != g_team_state.team_id) + { + return; + } + uint32_t prev_round = g_team_state.security_round; + if (!g_team_state.has_team_id) + { + g_team_state.team_id = ev.ctx.team_id; + g_team_state.has_team_id = true; + update_team_name_from_id(ev.ctx.team_id); + } + if (ev.ctx.key_id != 0) + { + g_team_state.security_round = ev.ctx.key_id; + } + if (ev.msg.key_id != 0) + { + if (ev.msg.key_id > g_team_state.security_round) + { + g_team_state.waiting_new_keys = true; + } + else if (ev.msg.key_id == g_team_state.security_round) + { + g_team_state.waiting_new_keys = false; + } + if (ev.ctx.from != 0) + { + mark_keydist_confirmed(ev.ctx.from, ev.msg.key_id); + } + } + if (ev.msg.key_id != 0 && ev.msg.key_id > prev_round) + { + std::vector payload; + write_u32_le(payload, ev.msg.key_id); + append_key_event(TeamKeyEventType::EpochRotated, payload); + } + g_team_state.last_update_s = ev.ctx.timestamp; +} + +void handle_team_position(const team::TeamPositionEvent& ev) +{ + if (g_team_state.has_team_id && ev.ctx.team_id != g_team_state.team_id) + { + return; + } + if (!g_team_state.has_team_id) + { + g_team_state.team_id = ev.ctx.team_id; + g_team_state.has_team_id = true; + update_team_name_from_id(ev.ctx.team_id); + } + touch_member(ev.ctx.from, ev.ctx.timestamp); + if (ev.ctx.key_id != 0 && ev.ctx.from != 0) + { + mark_keydist_confirmed(ev.ctx.from, ev.ctx.key_id); + } + if (!ev.payload.empty()) + { + meshtastic_Position pos = meshtastic_Position_init_zero; + pb_istream_t stream = pb_istream_from_buffer(ev.payload.data(), ev.payload.size()); + if (pb_decode(&stream, meshtastic_Position_fields, &pos)) + { + if (pos.has_latitude_i && pos.has_longitude_i) + { + int32_t lat_e7 = pos.latitude_i; + int32_t lon_e7 = pos.longitude_i; + int16_t alt_m = 0; + if (pos.has_altitude) + { + if (pos.altitude > 32767) alt_m = 32767; + else if (pos.altitude < -32768) alt_m = -32768; + else alt_m = static_cast(pos.altitude); + } + uint16_t speed_dmps = 0; + if (pos.has_ground_speed) + { + double dmps = static_cast(pos.ground_speed) * 10.0; + if (dmps < 0.0) dmps = 0.0; + if (dmps > 65535.0) dmps = 65535.0; + speed_dmps = static_cast(lround(dmps)); + } + uint32_t ts = pos.timestamp != 0 ? pos.timestamp : ev.ctx.timestamp; + if (ts == 0) + { + ts = now_secs(); + } + team_ui_posring_append(g_team_state.team_id, + ev.ctx.from, + lat_e7, + lon_e7, + alt_m, + speed_dmps, + ts); + } + } + } + g_team_state.last_update_s = ev.ctx.timestamp; +} + +void handle_team_waypoint(const team::TeamWaypointEvent& ev) +{ + if (g_team_state.has_team_id && ev.ctx.team_id != g_team_state.team_id) + { + return; + } + if (!g_team_state.has_team_id) + { + g_team_state.team_id = ev.ctx.team_id; + g_team_state.has_team_id = true; + update_team_name_from_id(ev.ctx.team_id); + } + touch_member(ev.ctx.from, ev.ctx.timestamp); + if (ev.ctx.key_id != 0 && ev.ctx.from != 0) + { + mark_keydist_confirmed(ev.ctx.from, ev.ctx.key_id); + } + g_team_state.last_update_s = ev.ctx.timestamp; +} + +void handle_team_chat(const team::TeamChatEvent& ev) +{ + if (g_team_state.has_team_id && ev.ctx.team_id != g_team_state.team_id) + { + return; + } + if (!g_team_state.has_team_id) + { + g_team_state.team_id = ev.ctx.team_id; + g_team_state.has_team_id = true; + update_team_name_from_id(ev.ctx.team_id); + } + uint32_t from_id = ev.msg.header.from != 0 ? ev.msg.header.from : ev.ctx.from; + touch_member(from_id, ev.ctx.timestamp); + if (ev.ctx.key_id != 0 && from_id != 0) + { + mark_keydist_confirmed(from_id, ev.ctx.key_id); + } + uint32_t ts = ev.msg.header.ts != 0 ? ev.msg.header.ts : ev.ctx.timestamp; + if (ts == 0) + { + ts = now_secs(); + } + team_ui_chatlog_append_structured(g_team_state.team_id, + from_id, + true, + ts, + ev.msg.header.type, + ev.msg.payload); + if (ev.msg.header.type == team::proto::TeamChatType::Location) + { + team::proto::TeamChatLocation loc; + if (team::proto::decodeTeamChatLocation(ev.msg.payload.data(), + ev.msg.payload.size(), + &loc)) + { + uint32_t pos_ts = loc.ts != 0 ? loc.ts : ts; + team_ui_posring_append(g_team_state.team_id, + from_id, + loc.lat_e7, + loc.lon_e7, + loc.alt_m, + 0, + pos_ts); + } + } + g_team_state.last_update_s = ev.ctx.timestamp; +} + +void handle_team_kick(const team::TeamKickEvent& ev) +{ + uint32_t target = ev.msg.target; + int idx = find_member_index(target); + if (idx >= 0) + { + g_team_state.members.erase(g_team_state.members.begin() + idx); + } + { + std::vector payload; + write_u32_le(payload, target); + append_key_event(TeamKeyEventType::MemberKicked, payload); + } + g_team_state.security_round += 1; + if (g_team_state.security_round != 0) + { + std::vector payload; + write_u32_le(payload, g_team_state.security_round); + append_key_event(TeamKeyEventType::EpochRotated, payload); + } + + if (target == 0) + { + enter_kicked_out_state(); + } +} + +void handle_team_transfer_leader(const team::TeamTransferLeaderEvent& ev) +{ + uint32_t target = ev.msg.target; + { + std::vector payload; + write_u32_le(payload, target); + append_key_event(TeamKeyEventType::LeaderTransferred, payload); + } + for (auto& m : g_team_state.members) + { + m.leader = false; + } + int idx = find_member_index(target); + if (idx < 0) + { + TeamMemberUi info; + info.node_id = target; + info.name = resolve_node_name(target); + info.leader = true; + info.last_seen_s = now_secs(); + g_team_state.members.push_back(info); + } + else + { + g_team_state.members[idx].leader = true; + } + g_team_state.self_is_leader = (target == 0); +} + +void handle_team_key_dist(const team::TeamKeyDistEvent& ev) +{ + g_team_state.team_id = ev.msg.team_id; + g_team_state.has_team_id = true; + update_team_name_from_id(ev.msg.team_id); + if (ev.msg.key_id != 0) + { + g_team_state.security_round = ev.msg.key_id; + } + if (ev.msg.channel_psk_len > 0) + { + g_team_state.team_psk = ev.msg.channel_psk; + g_team_state.has_team_psk = true; + team_ui_save_keys_now(g_team_state.team_id, + g_team_state.security_round, + g_team_state.team_psk); + } + g_team_state.waiting_new_keys = false; + g_team_state.last_update_s = ev.ctx.timestamp; + + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + if (controller && g_team_state.has_team_psk && g_team_state.has_team_id) + { + controller->setKeysFromPsk(g_team_state.team_id, + g_team_state.security_round, + g_team_state.team_psk.data(), + g_team_state.team_psk.size()); + } +} + +void nav_to(TeamPage page, bool push = true); +void nav_back(); +void render_page(); +void handle_page_transition(TeamPage next_page); + +void top_bar_back(void*) +{ + nav_back(); +} + +void nav_to(TeamPage page, bool push) +{ + if (push && g_team_state.page != page) + { + g_team_state.nav_stack.push_back(g_team_state.page); + } + handle_page_transition(page); + g_team_state.page = page; + render_page(); +} + +void nav_back() +{ + if (!g_team_state.nav_stack.empty()) + { + TeamPage next_page = g_team_state.nav_stack.back(); + g_team_state.nav_stack.pop_back(); + handle_page_transition(next_page); + g_team_state.page = next_page; + render_page(); + return; + } + ui_request_exit_to_menu(); +} + +void nav_reset(TeamPage page) +{ + g_team_state.nav_stack.clear(); + handle_page_transition(page); + g_team_state.page = page; + render_page(); +} + +void handle_page_transition(TeamPage next_page) +{ + if (g_team_state.page == TeamPage::JoinNfc && next_page != TeamPage::JoinNfc) + { + stop_nfc_scan(); + } + if (g_team_state.page == TeamPage::InviteNfc && next_page != TeamPage::InviteNfc) + { + stop_nfc_share(); + } + + if (next_page == TeamPage::JoinNfc && !g_team_state.nfc_scan_active) + { + g_team_state.has_nfc_payload = false; + g_team_state.nfc_payload.clear(); + if (!start_nfc_scan()) + { + ::ui::SystemNotification::show("NFC scan failed", 2000); + } + } } void handle_create(lv_event_t*) { app::AppContext& app_ctx = app::AppContext::getInstance(); team::TeamController* controller = app_ctx.getTeamController(); - if (!controller) + + g_team_state.in_team = true; + g_team_state.pending_join = false; + g_team_state.pending_join_started_s = 0; + g_team_state.kicked_out = false; + g_team_state.self_is_leader = true; + g_team_state.members.clear(); + if (!g_team_state.has_team_id) { - return; + g_team_state.team_id = generate_team_id(); + g_team_state.has_team_id = true; + update_team_name_from_id(g_team_state.team_id); } - team::proto::TeamAdvertise advertise; - controller->onCreateTeam(advertise, chat::ChannelId::PRIMARY); - g_team_state.title_override = "Create Team"; - update_top_bar_title(g_team_state.title_override); - team_page_refresh(); + if (g_team_state.security_round == 0) + { + g_team_state.security_round = 1; + } + if (!g_team_state.has_team_psk) + { + for (size_t i = 0; i < g_team_state.team_psk.size(); ++i) + { + g_team_state.team_psk[i] = static_cast(random(0, 256)); + } + g_team_state.has_team_psk = true; + } + + if (controller) + { + team::proto::TeamAdvertise advertise{}; + advertise.team_id = g_team_state.team_id; + advertise.nonce = random(0, 0xFFFFFFFF); + if (!controller->onCreateTeam(advertise, chat::ChannelId::PRIMARY)) + { + notify_send_failed("Create", false); + } + } + + if (g_team_state.has_team_id) + { + std::vector payload; + write_u64_le(payload, team_id_to_u64(g_team_state.team_id)); + write_u32_le(payload, 0); + write_u32_le(payload, g_team_state.security_round); + append_key_event(TeamKeyEventType::TeamCreated, payload); + } + if (controller) + { + if (!controller->setKeysFromPsk(g_team_state.team_id, + g_team_state.security_round, + g_team_state.team_psk.data(), + g_team_state.team_psk.size())) + { + notify_send_failed("Keys", true); + } + } + team_ui_save_keys_now(g_team_state.team_id, + g_team_state.security_round, + g_team_state.team_psk); + TeamMemberUi self; + self.node_id = 0; + self.name = "You"; + self.leader = true; + self.last_seen_s = now_secs(); + assign_member_color(self); + g_team_state.members.push_back(self); + g_team_state.last_update_s = now_secs(); + save_state_to_store(); + nav_reset(TeamPage::StatusInTeam); } void handle_join(lv_event_t*) { + nav_to(TeamPage::JoinTeam); +} + +void handle_join_nfc(lv_event_t*) +{ + nav_to(TeamPage::JoinNfc); +} + +void handle_view_team(lv_event_t*) +{ + nav_to(TeamPage::TeamHome); +} + +void handle_invite(lv_event_t*) +{ + if (!g_team_state.self_is_leader) + { + ::ui::SystemNotification::show("Only leader can invite", 2000); + return; + } + ensure_invite_code(); + g_team_state.invite_mode = TeamInviteMode::Radio; + send_invite_advertise(false); + nav_to(TeamPage::Invite); +} + +void handle_invite_switch_mode(lv_event_t*) +{ + if (!g_team_state.self_is_leader) + { + ::ui::SystemNotification::show("Only leader can invite", 2000); + return; + } + if (g_team_state.invite_mode == TeamInviteMode::Radio) + { + g_team_state.invite_mode = TeamInviteMode::Nfc; + nav_to(TeamPage::InviteNfc); + } + else + { + g_team_state.invite_mode = TeamInviteMode::Radio; + stop_nfc_share(); + g_team_state.has_nfc_next_psk = false; + g_team_state.nfc_next_key_id = 0; + g_team_state.nfc_payload.clear(); + g_team_state.has_nfc_payload = false; + nav_to(TeamPage::Invite); + } +} + +void handle_invite_start_nfc(lv_event_t*) +{ + g_team_state.invite_mode = TeamInviteMode::Nfc; + if (!start_nfc_share()) + { + ::ui::SystemNotification::show("NFC start failed", 2000); + return; + } + render_page(); +} + +void perform_leave() +{ + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + if (controller) + { + controller->clearKeys(); + } + g_team_state.in_team = false; + g_team_state.pending_join = false; + g_team_state.pending_join_started_s = 0; + g_team_state.kicked_out = false; + g_team_state.self_is_leader = false; + g_team_state.last_event_seq = 0; + g_team_state.members.clear(); + g_team_state.has_team_id = false; + g_team_state.team_name.clear(); + g_team_state.security_round = 0; + g_team_state.has_team_psk = false; + g_team_state.waiting_new_keys = false; + g_team_state.has_nfc_next_psk = false; + g_team_state.nfc_next_key_id = 0; + g_team_state.nfc_payload.clear(); + g_team_state.has_nfc_payload = false; + stop_nfc_share(); + stop_nfc_scan(); + s_keydist_pending.clear(); + save_state_to_store(); + nav_reset(TeamPage::StatusNotInTeam); +} + +void handle_leave(lv_event_t*) +{ + if (g_team_state.leave_confirm_modal) + { + return; + } + modal_prepare_group(); + g_team_state.leave_confirm_modal = create_modal_root(260, 140); + lv_obj_t* win = lv_obj_get_child(g_team_state.leave_confirm_modal, 0); + + lv_obj_t* title_label = lv_label_create(win); + lv_label_set_text(title_label, "Leave team?"); + lv_obj_align(title_label, LV_ALIGN_TOP_MID, 0, 0); + + lv_obj_t* desc_label = lv_label_create(win); + lv_label_set_text(desc_label, "This clears local keys."); + lv_obj_align(desc_label, LV_ALIGN_TOP_MID, 0, 28); + + lv_obj_t* btn_row = lv_obj_create(win); + lv_obj_set_size(btn_row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_align(btn_row, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_set_flex_flow(btn_row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(btn_row, LV_FLEX_ALIGN_SPACE_EVENLY, + LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(btn_row, 0, LV_PART_MAIN); + lv_obj_set_style_bg_opa(btn_row, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_border_width(btn_row, 0, LV_PART_MAIN); + lv_obj_clear_flag(btn_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* cancel_btn = lv_btn_create(btn_row); + lv_obj_set_size(cancel_btn, 90, 32); + style::apply_button_secondary(cancel_btn); + lv_obj_t* cancel_label = lv_label_create(cancel_btn); + lv_label_set_text(cancel_label, "Cancel"); + lv_obj_center(cancel_label); + lv_obj_add_event_cb(cancel_btn, [](lv_event_t*) { + close_leave_confirm_modal(); + }, LV_EVENT_CLICKED, nullptr); + + lv_obj_t* leave_btn = lv_btn_create(btn_row); + lv_obj_set_size(leave_btn, 90, 32); + style::apply_button_secondary(leave_btn); + lv_obj_t* leave_label = lv_label_create(leave_btn); + lv_label_set_text(leave_label, "Leave"); + lv_obj_center(leave_label); + lv_obj_add_event_cb(leave_btn, [](lv_event_t*) { + close_leave_confirm_modal(); + perform_leave(); + }, LV_EVENT_CLICKED, nullptr); + + lv_group_add_obj(g_team_state.modal_group, cancel_btn); + lv_group_add_obj(g_team_state.modal_group, leave_btn); + lv_group_focus_obj(cancel_btn); +} + +void handle_manage(lv_event_t*) +{ + if (!g_team_state.self_is_leader) + { + ::ui::SystemNotification::show("Only leader can manage", 2000); + return; + } + nav_to(TeamPage::Members); +} + +void handle_member_clicked(lv_event_t* e) +{ + lv_obj_t* item = (lv_obj_t*)lv_event_get_target(e); + int index = (int)(intptr_t)lv_obj_get_user_data(item); + g_team_state.selected_member_index = index; + nav_to(TeamPage::MemberDetail); +} + +void handle_kick(lv_event_t*) +{ + nav_to(TeamPage::KickConfirm); +} + +void handle_kick_confirm(lv_event_t*) +{ + int idx = g_team_state.selected_member_index; + if (idx >= 0 && idx < (int)g_team_state.members.size()) + { + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + if (controller) + { + team::proto::TeamKick kick{}; + kick.target = g_team_state.members[idx].node_id; + if (!controller->onKick(kick, chat::ChannelId::PRIMARY, 0)) + { + notify_send_failed("Kick", true); + } + uint32_t old_key_id = g_team_state.security_round; + if (old_key_id == 0) + { + old_key_id = 1; + } + uint32_t new_key_id = old_key_id + 1; + + std::array new_psk{}; + for (size_t i = 0; i < new_psk.size(); ++i) + { + new_psk[i] = static_cast(random(0, 256)); + } + + team::proto::TeamKeyDist kd{}; + kd.team_id = g_team_state.team_id; + kd.key_id = new_key_id; + kd.channel_psk_len = static_cast(new_psk.size()); + kd.channel_psk = new_psk; + + for (const auto& m : g_team_state.members) + { + if (m.node_id == 0 || m.node_id == kick.target) + { + continue; + } + if (!controller->onKeyDist(kd, chat::ChannelId::PRIMARY, m.node_id)) + { + notify_send_failed_detail("KeyDist", controller->getLastSendError()); + } + add_keydist_pending(m.node_id, new_key_id); + } + + g_team_state.security_round = new_key_id; + g_team_state.team_psk = new_psk; + g_team_state.has_team_psk = true; + g_team_state.waiting_new_keys = false; + if (!controller->setKeysFromPsk(g_team_state.team_id, + g_team_state.security_round, + g_team_state.team_psk.data(), + g_team_state.team_psk.size())) + { + notify_send_failed("Keys", true); + } + + team::proto::TeamStatus status{}; + status.key_id = g_team_state.security_round; + if (!controller->onStatus(status, chat::ChannelId::PRIMARY, 0)) + { + notify_send_failed("Status", true); + } + if (!controller->onStatusPlain(status, chat::ChannelId::PRIMARY, 0)) + { + notify_send_failed("Status", false); + } + } + g_team_state.members.erase(g_team_state.members.begin() + idx); + g_team_state.selected_member_index = -1; + } + save_state_to_store(); + nav_reset(TeamPage::StatusInTeam); +} + +void handle_kick_cancel(lv_event_t*) +{ + nav_back(); +} + +void handle_transfer_leader(lv_event_t*) +{ + int idx = g_team_state.selected_member_index; + if (idx >= 0 && idx < (int)g_team_state.members.size()) + { + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + if (controller) + { + team::proto::TeamTransferLeader transfer{}; + transfer.target = g_team_state.members[idx].node_id; + if (!controller->onTransferLeader(transfer, chat::ChannelId::PRIMARY, 0)) + { + notify_send_failed("Transfer", true); + } + } + for (auto& m : g_team_state.members) + { + m.leader = false; + } + g_team_state.members[idx].leader = true; + g_team_state.self_is_leader = false; + { + std::vector payload; + write_u32_le(payload, g_team_state.members[idx].node_id); + append_key_event(TeamKeyEventType::LeaderTransferred, payload); + } + } + save_state_to_store(); + nav_reset(TeamPage::TeamHome); +} + +void handle_invite_refresh(lv_event_t*) +{ + if (!g_team_state.has_team_id) + { + g_team_state.team_id = generate_team_id(); + g_team_state.has_team_id = true; + update_team_name_from_id(g_team_state.team_id); + } + if (!g_team_state.has_team_psk) + { + for (size_t i = 0; i < g_team_state.team_psk.size(); ++i) + { + g_team_state.team_psk[i] = static_cast(random(0, 256)); + } + g_team_state.has_team_psk = true; + } + + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + if (controller && g_team_state.invite_mode == TeamInviteMode::Radio) + { + send_invite_advertise(true); + } + if (g_team_state.invite_mode == TeamInviteMode::Nfc) + { + if (!ensure_nfc_share_payload()) + { + ::ui::SystemNotification::show("NFC payload failed", 2000); + } + if (g_team_state.nfc_share_active) + { + stop_nfc_share(); + if (!start_nfc_share()) + { + ::ui::SystemNotification::show("NFC start failed", 2000); + } + } + } + g_team_state.last_update_s = now_secs(); + save_state_to_store(); + render_page(); +} + +void handle_invite_stop(lv_event_t*) +{ + g_team_state.invite_code.clear(); + g_team_state.invite_expires_s = 0; + g_team_state.invite_mode = TeamInviteMode::Radio; + g_team_state.has_nfc_next_psk = false; + g_team_state.nfc_next_key_id = 0; + g_team_state.nfc_payload.clear(); + g_team_state.has_nfc_payload = false; + stop_nfc_share(); + save_state_to_store(); + nav_reset(TeamPage::TeamHome); +} + +void handle_enter_code_open(lv_event_t*) +{ + nav_to(TeamPage::EnterCode); +} + +void handle_enter_code_cancel(lv_event_t*) +{ + g_team_state.has_nfc_payload = false; + g_team_state.nfc_payload.clear(); + nav_back(); +} + +void handle_enter_code_confirm(lv_event_t*) +{ + if (!g_team_state.invite_code_textarea) + { + return; + } + const char* raw = lv_textarea_get_text(g_team_state.invite_code_textarea); + std::string code = raw ? normalize_invite_code(raw) : std::string(); + if (code.size() != kInviteCodeLen) + { + ::ui::SystemNotification::show("Invalid code", 2000); + return; + } + + if (g_team_state.has_nfc_payload) + { + team::nfc::Payload payload{}; + if (!team::nfc::decode_payload(g_team_state.nfc_payload.data(), + g_team_state.nfc_payload.size(), + &payload)) + { + ::ui::SystemNotification::show("Bad NFC payload", 2000); + return; + } + if (payload.expires_at != 0 && now_secs() > payload.expires_at) + { + ::ui::SystemNotification::show("NFC invite expired", 2000); + return; + } + std::array psk{}; + if (!team::nfc::decrypt_payload(payload, code, psk)) + { + ::ui::SystemNotification::show("Decrypt failed", 2000); + return; + } + + g_team_state.team_id = payload.team_id; + g_team_state.has_team_id = true; + update_team_name_from_id(payload.team_id); + g_team_state.security_round = payload.key_id; + g_team_state.team_psk = psk; + g_team_state.has_team_psk = true; + g_team_state.waiting_new_keys = false; + g_team_state.join_target_id = payload.team_id; + g_team_state.has_join_target = true; + g_team_state.has_nfc_payload = false; + g_team_state.nfc_payload.clear(); + + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + if (controller) + { + if (!controller->setKeysFromPsk(g_team_state.team_id, + g_team_state.security_round, + g_team_state.team_psk.data(), + g_team_state.team_psk.size())) + { + notify_send_failed("Keys", true); + } + } + } + + handle_join_enter_code(nullptr); +} + +void handle_join_enter_code(lv_event_t*) +{ + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + bool ok = false; + if (controller) + { + team::proto::TeamJoinRequest join_request; + if (g_team_state.has_join_target) + { + join_request.team_id = g_team_state.join_target_id; + } + else if (g_team_state.has_team_id) + { + join_request.team_id = g_team_state.team_id; + } + join_request.nonce = random(0, 0xFFFFFFFF); + ok = controller->onJoinTeam(join_request, chat::ChannelId::PRIMARY, 0); + if (!ok) + { + notify_send_failed("Join", false); + } + } + if (ok) + { + g_team_state.pending_join = true; + g_team_state.pending_join_started_s = now_secs(); + g_team_state.has_join_target = false; + g_team_state.last_update_s = now_secs(); + save_state_to_store(); + nav_to(TeamPage::JoinPending); + } +} + +void handle_join_nearby(lv_event_t* e) +{ + lv_obj_t* item = (lv_obj_t*)lv_event_get_target(e); + int index = (int)(intptr_t)lv_obj_get_user_data(item); + if (index >= 0 && index < (int)g_team_state.nearby_teams.size()) + { + g_team_state.team_name = g_team_state.nearby_teams[index].name; + g_team_state.join_target_id = g_team_state.nearby_teams[index].team_id; + g_team_state.has_join_target = true; + } + handle_join_enter_code(nullptr); +} + +void handle_join_refresh(lv_event_t*) +{ + render_page(); +} + +void handle_request_keydist(lv_event_t*) +{ + if (!g_team_state.in_team || g_team_state.self_is_leader || !g_team_state.has_team_id) + { + return; + } app::AppContext& app_ctx = app::AppContext::getInstance(); team::TeamController* controller = app_ctx.getTeamController(); if (!controller) @@ -75,17 +2247,580 @@ void handle_join(lv_event_t*) return; } team::proto::TeamJoinRequest join_request; - controller->onJoinTeam(join_request, chat::ChannelId::PRIMARY, 0); - g_team_state.title_override = "Join Team"; - update_top_bar_title(g_team_state.title_override); - team_page_refresh(); + join_request.team_id = g_team_state.team_id; + join_request.nonce = random(0, 0xFFFFFFFF); + bool ok = controller->onJoinTeam(join_request, chat::ChannelId::PRIMARY, 0); + if (!ok) + { + notify_send_failed("KeyDist", false); + return; + } + ::ui::SystemNotification::show("KeyDist requested", 2000); } -void handle_placeholder(lv_event_t*) +void handle_join_cancel(lv_event_t*) { - team_page_refresh(); + g_team_state.pending_join = false; + g_team_state.pending_join_started_s = 0; + { + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + if (controller) + { + controller->resetUiState(); + } + } + stop_nfc_scan(); + save_state_to_store(); + nav_back(); } + +void handle_join_retry(lv_event_t*) +{ + handle_join_enter_code(nullptr); +} + +void handle_kicked_join(lv_event_t*) +{ + g_team_state.kicked_out = false; + save_state_to_store(); + nav_reset(TeamPage::JoinTeam); +} + +void handle_kicked_ok(lv_event_t*) +{ + g_team_state.kicked_out = false; + save_state_to_store(); + nav_reset(TeamPage::StatusNotInTeam); +} + +void render_status_not_in_team() +{ + update_top_bar_title("Team"); + + add_label(g_team_state.body, "You are not in a team", true, false); + add_label(g_team_state.body, "• No shared map\n• No team awareness", false, true); + add_label(g_team_state.body, "Create or join a team", false, false); + + int btn_width = kActionBtnWidth2; + g_team_state.action_btns[0] = create_action_button("Create Team", btn_width, handle_create); + g_team_state.action_btns[1] = create_action_button("Join Team", btn_width, handle_join); + register_focus(g_team_state.action_btns[0], true); + register_focus(g_team_state.action_btns[1]); +} + +void render_status_in_team() +{ + update_top_bar_title("Team Status"); + + char line[64]; + std::string team_name = current_team_name(); + snprintf(line, sizeof(line), "Team: %s", team_name.c_str()); + add_label(g_team_state.body, line, true, false); + snprintf(line, sizeof(line), "Role: %s", g_team_state.self_is_leader ? "Leader" : "Member"); + add_label(g_team_state.body, line, false, true); + snprintf(line, sizeof(line), "Members: %u", (unsigned)g_team_state.members.size()); + add_label(g_team_state.body, line, false, true); + snprintf(line, sizeof(line), "Online: %u", (unsigned)online_count()); + add_label(g_team_state.body, line, false, true); + if (g_team_state.security_round == 0) + { + snprintf(line, sizeof(line), "KeyId: --"); + } + else + { + snprintf(line, sizeof(line), "KeyId: %u", (unsigned)g_team_state.security_round); + } + add_label(g_team_state.body, line, false, true); + if (g_team_state.security_round == 0) + { + snprintf(line, sizeof(line), "Security: OK (Round --)"); + } + else + { + snprintf(line, sizeof(line), "Security: OK (Round %u)", (unsigned)g_team_state.security_round); + } + add_label(g_team_state.body, line, false, true); + if (g_team_state.waiting_new_keys) + { + add_label(g_team_state.body, "Waiting for new keys...", false, true); + } + + add_label(g_team_state.body, "Team Health", true, false); + std::string last_update = format_last_update(g_team_state.last_update_s); + std::string health = std::string("● Leader online\n") + + "● " + last_update + "\n" + + "○ 1 member stale"; + add_label(g_team_state.body, health.c_str(), false, true); + + int width = kActionBtnWidth3; + g_team_state.action_btns[0] = create_action_button("View Team", width, handle_view_team); + if (g_team_state.self_is_leader) + { + g_team_state.action_btns[1] = create_action_button("Invite", width, handle_invite); + } + else + { + g_team_state.action_btns[1] = create_action_button("Resend Keys", width, handle_request_keydist); + } + g_team_state.action_btns[2] = create_action_button("Leave", width, handle_leave); + bool keys_ready = g_team_state.has_team_psk && g_team_state.has_team_id && g_team_state.security_round > 0; + register_focus(g_team_state.action_btns[0], true); + register_focus(g_team_state.action_btns[1]); + register_focus(g_team_state.action_btns[2]); +} + +void render_team_home() +{ + update_top_bar_title(g_team_state.self_is_leader ? "Team · Leader" : "Team · Member"); + + char line[64]; + std::string team_name = current_team_name(); + snprintf(line, sizeof(line), "Team: %s", team_name.c_str()); + add_label(g_team_state.body, line, true, false); + snprintf(line, sizeof(line), "Members: %u Online: %u", + (unsigned)g_team_state.members.size(), (unsigned)online_count()); + add_label(g_team_state.body, line, false, true); + if (g_team_state.security_round == 0) + { + snprintf(line, sizeof(line), "Security Round: --"); + } + else + { + snprintf(line, sizeof(line), "Security Round: %u", (unsigned)g_team_state.security_round); + } + add_label(g_team_state.body, line, false, true); + + add_label(g_team_state.body, "Members", true, false); + if (g_team_state.members.empty()) + { + add_label(g_team_state.body, "No members yet", false, true); + } + else + { + lv_obj_t* row = lv_obj_create(g_team_state.body); + lv_obj_set_width(row, LV_PCT(100)); + lv_obj_set_height(row, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_style_pad_column(row, 4, 0); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + + for (const auto& m : g_team_state.members) + { + lv_obj_t* label = lv_label_create(row); + lv_label_set_text(label, m.name.c_str()); + lv_label_set_long_mode(label, LV_LABEL_LONG_CLIP); + lv_obj_set_width(label, LV_PCT(24)); + lv_obj_set_style_bg_opa(label, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(label, lv_color_hex(team_color_from_index(m.color_index)), 0); + lv_obj_set_style_pad_hor(label, 4, 0); + lv_obj_set_style_pad_ver(label, 3, 0); + lv_obj_set_style_radius(label, 6, 0); + lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); + if (m.color_index == 3) + { + lv_obj_set_style_text_color(label, lv_color_black(), 0); + } + else + { + lv_obj_set_style_text_color(label, lv_color_white(), 0); + } + } + } + + int width = kActionBtnWidth3; + g_team_state.action_btns[0] = create_action_button("Invite", width, handle_invite); + g_team_state.action_btns[1] = create_action_button("Manage", width, handle_manage); + g_team_state.action_btns[2] = create_action_button("Leave", width, handle_leave); + register_focus(g_team_state.action_btns[0], g_team_state.default_focus == nullptr); + register_focus(g_team_state.action_btns[1]); + register_focus(g_team_state.action_btns[2]); +} + +void render_invite() +{ + update_top_bar_title("Invite"); + + g_team_state.invite_mode = TeamInviteMode::Radio; + ensure_invite_code(); + char line[64]; + add_label(g_team_state.body, "Mode: Radio", false, true); + std::string team_name = current_team_name(); + snprintf(line, sizeof(line), "Team: %s", team_name.c_str()); + add_label(g_team_state.body, line, true, false); + add_label(g_team_state.body, "Invite Code", false, true); + std::string pretty_code = format_invite_code(g_team_state.invite_code); + add_label(g_team_state.body, pretty_code.c_str(), false, false); + + if (g_team_state.invite_expires_s == 0) + { + snprintf(line, sizeof(line), "Time left: --:--"); + } + else + { + unsigned minutes = g_team_state.invite_expires_s / 60; + unsigned seconds = g_team_state.invite_expires_s % 60; + snprintf(line, sizeof(line), "Time left: %02u:%02u", minutes, seconds); + } + add_label(g_team_state.body, line, false, true); + add_label(g_team_state.body, "Nearby devices can request to join", false, true); + + int width = kActionBtnWidth2; + g_team_state.action_btns[0] = create_action_button("Stop Invite", width, handle_invite_stop); + g_team_state.action_btns[1] = create_action_button("Switch Mode", width, handle_invite_switch_mode); + register_focus(g_team_state.action_btns[0], true); + register_focus(g_team_state.action_btns[1]); +} + +void render_invite_nfc() +{ + update_top_bar_title("Invite via NFC"); + + g_team_state.invite_mode = TeamInviteMode::Nfc; + ensure_invite_code(); + char line[64]; + add_label(g_team_state.body, "Mode: NFC", false, true); + std::string team_name = current_team_name(); + snprintf(line, sizeof(line), "Team: %s", team_name.c_str()); + add_label(g_team_state.body, line, true, false); + add_label(g_team_state.body, "Invite Code", false, true); + std::string pretty_code = format_invite_code(g_team_state.invite_code); + add_label(g_team_state.body, pretty_code.c_str(), false, false); + + if (g_team_state.invite_expires_s == 0) + { + snprintf(line, sizeof(line), "Time left: --:--"); + } + else + { + unsigned minutes = g_team_state.invite_expires_s / 60; + unsigned seconds = g_team_state.invite_expires_s % 60; + snprintf(line, sizeof(line), "Time left: %02u:%02u", minutes, seconds); + } + add_label(g_team_state.body, line, false, true); + add_label(g_team_state.body, "Tap another device to share key", false, true); + add_label(g_team_state.body, "Invite code protects the NFC key", false, true); + if (g_team_state.nfc_share_active) + { + add_label(g_team_state.body, "NFC is active", false, true); + } + + int width = kActionBtnWidth2; + g_team_state.action_btns[0] = create_action_button("Start NFC", width, handle_invite_start_nfc); + g_team_state.action_btns[1] = create_action_button("Stop Invite", width, handle_invite_stop); + register_focus(g_team_state.action_btns[0], true); + register_focus(g_team_state.action_btns[1]); +} + +void render_join_team() +{ + update_top_bar_title("Join Team"); + + add_label(g_team_state.body, "Nearby Teams", true, false); + if (g_team_state.nearby_teams.empty()) + { + add_label(g_team_state.body, "No nearby teams", false, true); + } + for (size_t i = 0; i < g_team_state.nearby_teams.size(); ++i) + { + const auto& team = g_team_state.nearby_teams[i]; + std::string right = format_signal(team.signal_bars); + lv_obj_t* item = create_list_item(team.name.c_str(), right.c_str()); + lv_obj_set_user_data(item, (void*)(intptr_t)i); + lv_obj_add_event_cb(item, handle_join_nearby, LV_EVENT_CLICKED, nullptr); + register_focus(item, g_team_state.default_focus == nullptr); + } + + add_label(g_team_state.body, "Other options", false, true); + add_label(g_team_state.body, "• Enter Invite Code (Radio)", false, true); + add_label(g_team_state.body, "• Tap to join (NFC)", false, true); + + int width = kActionBtnWidth3; + g_team_state.action_btns[0] = create_action_button("Enter Invite Code", width, handle_enter_code_open); + g_team_state.action_btns[1] = create_action_button("Join via NFC", width, handle_join_nfc); + g_team_state.action_btns[2] = create_action_button("Refresh", width, handle_join_refresh); + register_focus(g_team_state.action_btns[0], g_team_state.default_focus == nullptr); + register_focus(g_team_state.action_btns[1]); + register_focus(g_team_state.action_btns[2]); +} + +void render_join_nfc() +{ + update_top_bar_title("Join via NFC"); + + add_label(g_team_state.body, "Hold device near leader/device", false, true); + uint32_t now = now_secs(); + uint32_t elapsed = (g_team_state.nfc_scan_started_s == 0) ? 0 : (now - g_team_state.nfc_scan_started_s); + uint32_t remaining = (elapsed >= kNfcScanDurationSec) ? 0 : (kNfcScanDurationSec - elapsed); + char line[32]; + snprintf(line, sizeof(line), "Scanning... %us", (unsigned)remaining); + add_label(g_team_state.body, line, false, true); + add_label(g_team_state.body, "NFC is on only during this screen", false, true); + + int width = kActionBtnWidth2; + g_team_state.action_btns[0] = create_action_button("Cancel", width, handle_join_cancel); + register_focus(g_team_state.action_btns[0], true); +} + +void render_enter_code() +{ + update_top_bar_title("Enter Code"); + + add_label(g_team_state.body, "Code:", true, false); + lv_obj_t* textarea = lv_textarea_create(g_team_state.body); + lv_textarea_set_one_line(textarea, true); + lv_textarea_set_max_length(textarea, static_cast(kInviteCodeLen)); + lv_textarea_set_accepted_chars(textarea, "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"); + lv_obj_set_width(textarea, LV_PCT(100)); + lv_textarea_set_placeholder_text(textarea, "______"); + g_team_state.invite_code_textarea = textarea; + register_focus(textarea, true); + + add_label(g_team_state.body, "Radio: request join", false, true); + add_label(g_team_state.body, "NFC: decrypt shared key", false, true); + + int width = kActionBtnWidth2; + g_team_state.action_btns[0] = create_action_button("Cancel", width, handle_enter_code_cancel); + g_team_state.action_btns[1] = create_action_button("Confirm", width, handle_enter_code_confirm); + register_focus(g_team_state.action_btns[0]); + register_focus(g_team_state.action_btns[1]); +} + +void render_join_pending() +{ + update_top_bar_title("Join Request"); + + char line[64]; + std::string team_name = current_team_name(); + const char* target = team_name.c_str(); + if (g_team_state.has_join_target) + { + static std::string name; + name = format_team_name_from_id(g_team_state.join_target_id); + target = name.c_str(); + } + snprintf(line, sizeof(line), "Request sent to %s", target); + add_label(g_team_state.body, line, true, false); + add_label(g_team_state.body, "Waiting for approval...", false, true); + add_label(g_team_state.body, "This may take a moment", false, true); + + int width = kActionBtnWidth2; + g_team_state.action_btns[0] = create_action_button("Cancel", width, handle_join_cancel); + g_team_state.action_btns[1] = create_action_button("Retry", width, handle_join_retry); + register_focus(g_team_state.action_btns[0], true); + register_focus(g_team_state.action_btns[1]); +} + +void render_members() +{ + update_top_bar_title("Members"); + + if (g_team_state.members.empty()) + { + add_label(g_team_state.body, "No members yet", false, true); + } + for (size_t i = 0; i < g_team_state.members.size(); ++i) + { + const auto& m = g_team_state.members[i]; + const char* dot = m.online ? "● " : "○ "; + std::string left = std::string(dot) + m.name; + if (m.leader) + { + left += " (Leader)"; + } + lv_obj_t* item = create_list_item(left.c_str(), "Select"); + lv_obj_set_user_data(item, (void*)(intptr_t)i); + lv_obj_add_event_cb(item, handle_member_clicked, LV_EVENT_CLICKED, nullptr); + register_focus(item, g_team_state.default_focus == nullptr); + } +} + +void render_member_detail() +{ + if (g_team_state.selected_member_index < 0 || + g_team_state.selected_member_index >= (int)g_team_state.members.size()) + { + nav_to(TeamPage::Members, false); + return; + } + + const auto& member = g_team_state.members[g_team_state.selected_member_index]; + std::string title = "Member: " + member.name; + update_top_bar_title(title.c_str()); + + std::string status = member.online ? "Online" : format_last_seen(member.last_seen_s); + std::string role = member.leader ? "Leader" : "Member"; + char line[64]; + snprintf(line, sizeof(line), "Status: %s", status.c_str()); + add_label(g_team_state.body, line, true, false); + snprintf(line, sizeof(line), "Role: %s", role.c_str()); + add_label(g_team_state.body, line, false, true); + add_label(g_team_state.body, "Device: Pager", false, true); + add_label(g_team_state.body, "Capability:", true, false); + add_label(g_team_state.body, "- Position\n- Waypoint", false, true); + + int width = kActionBtnWidth2; + g_team_state.action_btns[0] = create_action_button("Kick", width, handle_kick); + g_team_state.action_btns[1] = create_action_button("Transfer Leader", width, handle_transfer_leader); + bool keys_ready = g_team_state.has_team_psk && g_team_state.has_team_id && g_team_state.security_round > 0; + if (!keys_ready || g_team_state.waiting_new_keys) + { + lv_obj_add_state(g_team_state.action_btns[0], LV_STATE_DISABLED); + lv_obj_add_state(g_team_state.action_btns[1], LV_STATE_DISABLED); + } + register_focus(g_team_state.action_btns[0], true); + register_focus(g_team_state.action_btns[1]); +} + +void render_kick_confirm() +{ + update_top_bar_title("Kick Member"); + + std::string name = "member"; + if (g_team_state.selected_member_index >= 0 && + g_team_state.selected_member_index < (int)g_team_state.members.size()) + { + name = g_team_state.members[g_team_state.selected_member_index].name; + } + + std::string line = "Remove " + name + " from team?"; + add_label(g_team_state.body, line.c_str(), true, false); + add_label(g_team_state.body, + "This will update the security round.\n" + "The member will no longer receive\n" + "team messages or waypoints.", + false, true); + + int width = kActionBtnWidth2; + g_team_state.action_btns[0] = create_action_button("Cancel", width, handle_kick_cancel); + g_team_state.action_btns[1] = create_action_button("Confirm Kick", width, handle_kick_confirm); + register_focus(g_team_state.action_btns[0], true); + register_focus(g_team_state.action_btns[1]); +} + +void render_kicked_out() +{ + update_top_bar_title("Team"); + + add_label(g_team_state.body, "You are no longer in this team", true, false); + add_label(g_team_state.body, "Access to team data revoked", false, true); + + int width = kActionBtnWidth2; + g_team_state.action_btns[0] = create_action_button("Join Another Team", width, handle_kicked_join); + g_team_state.action_btns[1] = create_action_button("OK", width, handle_kicked_ok); + register_focus(g_team_state.action_btns[0], true); + register_focus(g_team_state.action_btns[1]); +} + +void render_page() +{ + clear_content(); + + switch (g_team_state.page) + { + case TeamPage::StatusNotInTeam: + render_status_not_in_team(); + break; + case TeamPage::StatusInTeam: + render_status_in_team(); + break; + case TeamPage::TeamHome: + render_team_home(); + break; + case TeamPage::Invite: + render_invite(); + break; + case TeamPage::InviteNfc: + render_invite_nfc(); + break; + case TeamPage::JoinTeam: + render_join_team(); + break; + case TeamPage::JoinNfc: + render_join_nfc(); + break; + case TeamPage::EnterCode: + render_enter_code(); + break; + case TeamPage::JoinPending: + render_join_pending(); + break; + case TeamPage::Members: + render_members(); + break; + case TeamPage::MemberDetail: + render_member_detail(); + break; + case TeamPage::KickConfirm: + render_kick_confirm(); + break; + case TeamPage::KickedOut: + render_kicked_out(); + break; + default: + render_status_not_in_team(); + break; + } + + ui_update_top_bar_battery(g_team_state.top_bar_widget); + refresh_team_input(); +} + +void sync_from_controller() +{ + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + team::TeamUiState state = controller ? controller->getState() : team::TeamUiState::Idle; + + if (state == team::TeamUiState::Idle) + { + if (g_team_state.pending_join) + { + g_team_state.pending_join = false; + g_team_state.pending_join_started_s = 0; + g_team_state.nav_stack.clear(); + if (!g_team_state.in_team) + { + g_team_state.page = TeamPage::StatusNotInTeam; + } + } + return; + } + + if (state == team::TeamUiState::PendingJoin) + { + g_team_state.pending_join = true; + if (g_team_state.pending_join_started_s == 0) + { + g_team_state.pending_join_started_s = now_secs(); + } + if (g_team_state.page != TeamPage::JoinPending) + { + g_team_state.page = TeamPage::JoinPending; + g_team_state.nav_stack.clear(); + } + return; + } + + if (state == team::TeamUiState::Active) + { + g_team_state.in_team = true; + g_team_state.pending_join = false; + g_team_state.pending_join_started_s = 0; + if (g_team_state.page == TeamPage::StatusNotInTeam || + g_team_state.page == TeamPage::JoinPending) + { + g_team_state.page = TeamPage::StatusInTeam; + g_team_state.nav_stack.clear(); + } + } +} } // namespace void team_page_create(lv_obj_t* parent) @@ -97,28 +2832,36 @@ void team_page_create(lv_obj_t* parent) } style::init_once(); + load_state_from_store(); + + if (g_team_state.kicked_out) + { + g_team_state.page = TeamPage::KickedOut; + } + else if (g_team_state.pending_join) + { + g_team_state.page = TeamPage::JoinPending; + } + else if (g_team_state.in_team) + { + g_team_state.page = TeamPage::StatusInTeam; + } + else + { + g_team_state.page = TeamPage::StatusNotInTeam; + } g_team_state.root = layout::create_root(parent); g_team_state.header = layout::create_header(g_team_state.root); g_team_state.content = layout::create_content(g_team_state.root); - g_team_state.state_container = layout::create_state_container(g_team_state.content); - g_team_state.idle_container = layout::create_idle_container(g_team_state.state_container); - g_team_state.active_container = layout::create_active_container(g_team_state.state_container); + g_team_state.body = layout::create_body(g_team_state.content); + g_team_state.actions = layout::create_actions(g_team_state.content); style::apply_root(g_team_state.root); style::apply_header(g_team_state.header); style::apply_content(g_team_state.content); - style::apply_state_container(g_team_state.state_container); - style::apply_idle_container(g_team_state.idle_container); - style::apply_active_container(g_team_state.active_container); - style::apply_status_label(g_team_state.idle_status_label); - style::apply_status_label(g_team_state.active_status_label); - - style::apply_button_primary(g_team_state.create_btn); - style::apply_button_secondary(g_team_state.join_btn); - style::apply_button_secondary(g_team_state.share_btn); - style::apply_button_secondary(g_team_state.leave_btn); - style::apply_button_secondary(g_team_state.disband_btn); + style::apply_body(g_team_state.body); + style::apply_actions(g_team_state.actions); ::ui::widgets::TopBarConfig cfg; cfg.height = ::ui::widgets::kTopBarHeight; @@ -127,18 +2870,6 @@ void team_page_create(lv_obj_t* parent) ::ui::widgets::top_bar_set_back_callback(g_team_state.top_bar_widget, top_bar_back, nullptr); ui_update_top_bar_battery(g_team_state.top_bar_widget); - label_set_text(g_team_state.create_btn, "Create Team"); - label_set_text(g_team_state.join_btn, "Join Team"); - label_set_text(g_team_state.share_btn, "Share Team"); - label_set_text(g_team_state.leave_btn, "Leave Team"); - label_set_text(g_team_state.disband_btn, "Disband Team"); - - lv_obj_add_event_cb(g_team_state.create_btn, handle_create, LV_EVENT_CLICKED, nullptr); - lv_obj_add_event_cb(g_team_state.join_btn, handle_join, LV_EVENT_CLICKED, nullptr); - lv_obj_add_event_cb(g_team_state.share_btn, handle_placeholder, LV_EVENT_CLICKED, nullptr); - lv_obj_add_event_cb(g_team_state.leave_btn, handle_placeholder, LV_EVENT_CLICKED, nullptr); - lv_obj_add_event_cb(g_team_state.disband_btn, handle_placeholder, LV_EVENT_CLICKED, nullptr); - init_team_input(); team_page_refresh(); } @@ -147,58 +2878,164 @@ void team_page_destroy() { cleanup_team_input(); + close_join_request_modal(); + close_leave_confirm_modal(); + stop_nfc_share(); + stop_nfc_scan(); + if (g_team_state.modal_group) + { + lv_group_del(g_team_state.modal_group); + g_team_state.modal_group = nullptr; + } + s_keydist_pending.clear(); + if (g_team_state.root) { lv_obj_del(g_team_state.root); g_team_state.root = nullptr; } g_team_state = TeamPageState{}; + s_state_loaded = false; } void team_page_refresh() { - app::AppContext& app_ctx = app::AppContext::getInstance(); - team::TeamController* controller = app_ctx.getTeamController(); - team::TeamUiState state = controller ? controller->getState() : team::TeamUiState::Idle; - const char* title = "Team"; + sync_from_controller(); + render_page(); +} - bool idle = (state == team::TeamUiState::Idle); - - if (idle) +bool team_page_handle_event(sys::Event* event) +{ + if (!event) { - lv_obj_clear_flag(g_team_state.idle_container, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(g_team_state.idle_container, LV_OBJ_FLAG_IGNORE_LAYOUT); - lv_obj_add_flag(g_team_state.active_container, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(g_team_state.active_container, LV_OBJ_FLAG_IGNORE_LAYOUT); + return false; } - else + load_state_from_store(); + + bool changed = false; + + switch (event->type) { - lv_obj_add_flag(g_team_state.idle_container, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(g_team_state.idle_container, LV_OBJ_FLAG_IGNORE_LAYOUT); - lv_obj_clear_flag(g_team_state.active_container, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(g_team_state.active_container, LV_OBJ_FLAG_IGNORE_LAYOUT); + case sys::EventType::TeamAdvertise: + handle_team_advertise(static_cast(event)->data); + changed = true; + break; + case sys::EventType::TeamJoinRequest: + handle_team_join_request(static_cast(event)->data); + break; + case sys::EventType::TeamJoinAccept: + handle_team_join_accept(static_cast(event)->data); + changed = true; + break; + case sys::EventType::TeamJoinConfirm: + handle_team_join_confirm(static_cast(event)->data); + changed = true; + break; + case sys::EventType::TeamJoinDecision: + handle_team_join_decision(static_cast(event)->data); + changed = true; + break; + case sys::EventType::TeamKick: + handle_team_kick(static_cast(event)->data); + changed = true; + break; + case sys::EventType::TeamTransferLeader: + handle_team_transfer_leader(static_cast(event)->data); + changed = true; + break; + case sys::EventType::TeamKeyDist: + handle_team_key_dist(static_cast(event)->data); + changed = true; + break; + case sys::EventType::SystemTick: + { + process_keydist_retries(); + if (g_team_state.nfc_share_active) + { + team::nfc::poll_share(); + } + if (g_team_state.nfc_scan_active) + { + std::vector payload; + if (team::nfc::poll_scan(payload)) + { + g_team_state.nfc_scan_active = false; + g_team_state.nfc_payload = std::move(payload); + g_team_state.has_nfc_payload = true; + g_team_state.page = TeamPage::EnterCode; + g_team_state.nav_stack.clear(); + changed = true; + } + else if (g_team_state.nfc_scan_started_s != 0) + { + uint32_t now = now_secs(); + if ((now - g_team_state.nfc_scan_started_s) >= kNfcScanDurationSec) + { + stop_nfc_scan(); + ::ui::SystemNotification::show("NFC scan timed out", 2000); + if (g_team_state.page == TeamPage::JoinNfc) + { + g_team_state.page = TeamPage::JoinTeam; + g_team_state.nav_stack.clear(); + changed = true; + } + } + } + if (g_team_state.page == TeamPage::JoinNfc) + { + changed = true; + } + } + if (g_team_state.pending_join && g_team_state.pending_join_started_s != 0) + { + uint32_t now = now_secs(); + if ((now - g_team_state.pending_join_started_s) >= kJoinPendingTimeoutSec) + { + g_team_state.pending_join = false; + g_team_state.pending_join_started_s = 0; + g_team_state.has_join_target = false; + g_team_state.page = TeamPage::JoinTeam; + g_team_state.nav_stack.clear(); + ::ui::SystemNotification::show("Join timed out", 2000); + changed = true; + } + } + break; + } + case sys::EventType::TeamStatus: + handle_team_status(static_cast(event)->data); + changed = true; + break; + case sys::EventType::TeamPosition: + handle_team_position(static_cast(event)->data); + changed = true; + break; + case sys::EventType::TeamWaypoint: + handle_team_waypoint(static_cast(event)->data); + changed = true; + break; + case sys::EventType::TeamChat: + handle_team_chat(static_cast(event)->data); + changed = true; + break; + case sys::EventType::TeamError: + handle_team_error(static_cast(event)->data); + changed = true; + break; + default: + break; } - if (idle) + if (changed) { - lv_label_set_text(g_team_state.idle_status_label, "You are not in a Team"); - } - else - { - lv_label_set_text(g_team_state.active_status_label, "Team Active"); + save_state_to_store(); + if (is_team_ui_active()) + { + team_page_refresh(); + } } - if (state == team::TeamUiState::PendingJoin) - { - title = "Join Team"; - } - else if (state == team::TeamUiState::Active && g_team_state.title_override) - { - title = g_team_state.title_override; - } - update_top_bar_title(title); - - refresh_team_input(); + return true; } } // namespace ui @@ -218,3 +3055,8 @@ void team_page_refresh() { team::ui::team_page_refresh(); } + +bool team_page_handle_event(sys::Event* event) +{ + return team::ui::team_page_handle_event(event); +} diff --git a/src/ui/screens/team/team_page_components.h b/src/ui/screens/team/team_page_components.h index 3ef7666e..158086b2 100644 --- a/src/ui/screens/team/team_page_components.h +++ b/src/ui/screens/team/team_page_components.h @@ -6,7 +6,14 @@ #pragma once #include "lvgl.h" +#include "team_ui_store.h" + +namespace sys +{ +struct Event; +} void team_page_create(lv_obj_t* parent); void team_page_destroy(); void team_page_refresh(); +bool team_page_handle_event(sys::Event* event); diff --git a/src/ui/screens/team/team_page_input.cpp b/src/ui/screens/team/team_page_input.cpp index aa8b5903..2d61cc10 100644 --- a/src/ui/screens/team/team_page_input.cpp +++ b/src/ui/screens/team/team_page_input.cpp @@ -63,28 +63,14 @@ void refresh_team_input() lv_group_add_obj(s_group, g_team_state.top_bar_widget.back_btn); } - app::AppContext& app_ctx = app::AppContext::getInstance(); - team::TeamController* controller = app_ctx.getTeamController(); - team::TeamUiState state = controller ? controller->getState() : team::TeamUiState::Idle; - - if (state == team::TeamUiState::Idle) + for (lv_obj_t* obj : g_team_state.focusables) { - add_if(g_team_state.create_btn); - add_if(g_team_state.join_btn); - if (g_team_state.create_btn) - { - lv_group_focus_obj(g_team_state.create_btn); - } + add_if(obj); } - else + + if (g_team_state.default_focus) { - add_if(g_team_state.share_btn); - add_if(g_team_state.leave_btn); - add_if(g_team_state.disband_btn); - if (g_team_state.share_btn) - { - lv_group_focus_obj(g_team_state.share_btn); - } + lv_group_focus_obj(g_team_state.default_focus); } } diff --git a/src/ui/screens/team/team_page_layout.cpp b/src/ui/screens/team/team_page_layout.cpp index 6a173a5c..f0264f20 100644 --- a/src/ui/screens/team/team_page_layout.cpp +++ b/src/ui/screens/team/team_page_layout.cpp @@ -4,26 +4,16 @@ * * Root (COLUMN) * ├─ Header (TopBar) - * └─ Content (flex-grow = 1) - * └─ StateContainer (COLUMN, centered) - * ├─ IdleContainer - * │ ├─ IdleStatusLabel - * │ ├─ CreateBtn - * │ └─ JoinBtn - * └─ ActiveContainer - * ├─ ActiveStatusLabel - * ├─ ViewMapBtn - * ├─ ShareBtn - * ├─ LeaveBtn - * └─ DisbandBtn + * └─ Content (COLUMN, flex-grow = 1) + * ├─ Body (flex-grow = 1) + * └─ Actions (row) * * Tree view: * Root(COL) * ├─ Header * └─ Content(COL) - * └─ StateContainer(COL) - * ├─ IdleContainer(COL) -> IdleStatusLabel, CreateBtn, JoinBtn - * └─ ActiveContainer(COL) -> ActiveStatusLabel, ViewMapBtn, ShareBtn, LeaveBtn, DisbandBtn + * ├─ Body(COL) + * └─ Actions(ROW) * * ASCII wireframe (layout only): * +----------------------------------------------------------+ @@ -31,23 +21,10 @@ * +----------------------------------------------------------+ * | | * | +----------------------------------------------------+ | - * | | StateContainer (column, centered) | | - * | | | | - * | | +------------------------------+ | | - * | | | IdleContainer (column) | | | - * | | | [StatusLabel] | | | - * | | | [CreateBtn] | | | - * | | | [JoinBtn] | | | - * | | +------------------------------+ | | - * | | | | - * | | +------------------------------+ | | - * | | | ActiveContainer (column) | | | - * | | | [StatusLabel] | | | - * | | | [ViewMapBtn] | | | - * | | | [ShareBtn] | | | - * | | | [LeaveBtn] | | | - * | | | [DisbandBtn] | | | - * | | +------------------------------+ | | + * | | Body (column, scrollable if needed) | | + * | +----------------------------------------------------+ | + * | | Actions (row) | | + * | | [Action 1] [Action 2] [Action 3] | | * | +----------------------------------------------------+ | * | | * +----------------------------------------------------------+ */ @@ -58,7 +35,7 @@ */ #include "team_page_layout.h" -#include "team_state.h" +#include "../../widgets/top_bar.h" namespace team { @@ -69,9 +46,6 @@ namespace layout namespace { -constexpr int kButtonWidth = 220; -constexpr int kButtonHeight = 32; - void make_non_scrollable(lv_obj_t* obj) { lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); @@ -107,62 +81,28 @@ lv_obj_t* create_content(lv_obj_t* root) return content; } -lv_obj_t* create_state_container(lv_obj_t* content) +lv_obj_t* create_body(lv_obj_t* content) { - lv_obj_t* container = lv_obj_create(content); - lv_obj_set_size(container, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(container, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(container, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); - make_non_scrollable(container); - return container; + lv_obj_t* body = lv_obj_create(content); + lv_obj_set_size(body, LV_PCT(100), 0); + lv_obj_set_flex_grow(body, 1); + lv_obj_set_flex_flow(body, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(body, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); + lv_obj_set_scrollbar_mode(body, LV_SCROLLBAR_MODE_OFF); + return body; } -lv_obj_t* create_idle_container(lv_obj_t* parent) +lv_obj_t* create_actions(lv_obj_t* content) { - lv_obj_t* container = lv_obj_create(parent); - lv_obj_set_width(container, LV_PCT(100)); - lv_obj_set_height(container, LV_SIZE_CONTENT); - lv_obj_set_flex_flow(container, LV_FLEX_FLOW_COLUMN); - make_non_scrollable(container); - - g_team_state.idle_status_label = lv_label_create(container); - g_team_state.create_btn = lv_btn_create(container); - g_team_state.join_btn = lv_btn_create(container); - - lv_obj_set_width(g_team_state.idle_status_label, kButtonWidth); - lv_obj_set_width(g_team_state.create_btn, kButtonWidth); - lv_obj_set_width(g_team_state.join_btn, kButtonWidth); - lv_obj_set_height(g_team_state.create_btn, kButtonHeight); - lv_obj_set_height(g_team_state.join_btn, kButtonHeight); - - return container; -} - -lv_obj_t* create_active_container(lv_obj_t* parent) -{ - lv_obj_t* container = lv_obj_create(parent); - lv_obj_set_width(container, LV_PCT(100)); - lv_obj_set_height(container, LV_SIZE_CONTENT); - lv_obj_set_flex_flow(container, LV_FLEX_FLOW_COLUMN); - make_non_scrollable(container); - - g_team_state.active_status_label = lv_label_create(container); - g_team_state.share_btn = lv_btn_create(container); - g_team_state.leave_btn = lv_btn_create(container); - g_team_state.disband_btn = lv_btn_create(container); - - lv_obj_set_width(g_team_state.active_status_label, kButtonWidth); - lv_obj_set_width(g_team_state.share_btn, kButtonWidth); - lv_obj_set_width(g_team_state.leave_btn, kButtonWidth); - lv_obj_set_width(g_team_state.disband_btn, kButtonWidth); - lv_obj_set_height(g_team_state.share_btn, kButtonHeight); - lv_obj_set_height(g_team_state.leave_btn, kButtonHeight); - lv_obj_set_height(g_team_state.disband_btn, kButtonHeight); - - return container; + lv_obj_t* actions = lv_obj_create(content); + lv_obj_set_size(actions, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(actions, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(actions, LV_FLEX_ALIGN_SPACE_EVENLY, + LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + make_non_scrollable(actions); + return actions; } } // namespace layout } // namespace ui } // namespace team - diff --git a/src/ui/screens/team/team_page_layout.h b/src/ui/screens/team/team_page_layout.h index 48469dce..f05db07b 100644 --- a/src/ui/screens/team/team_page_layout.h +++ b/src/ui/screens/team/team_page_layout.h @@ -17,9 +17,8 @@ namespace layout lv_obj_t* create_root(lv_obj_t* parent); lv_obj_t* create_header(lv_obj_t* root); lv_obj_t* create_content(lv_obj_t* root); -lv_obj_t* create_state_container(lv_obj_t* content); -lv_obj_t* create_idle_container(lv_obj_t* parent); -lv_obj_t* create_active_container(lv_obj_t* parent); +lv_obj_t* create_body(lv_obj_t* content); +lv_obj_t* create_actions(lv_obj_t* content); } // namespace layout } // namespace ui diff --git a/src/ui/screens/team/team_page_styles.cpp b/src/ui/screens/team/team_page_styles.cpp index 8afbe497..9d27e9f7 100644 --- a/src/ui/screens/team/team_page_styles.cpp +++ b/src/ui/screens/team/team_page_styles.cpp @@ -17,13 +17,18 @@ static constexpr uint32_t kBtnBg = 0xF4C77A; static constexpr uint32_t kBtnBgFoc = 0xEBA341; static constexpr uint32_t kBtnBorder = 0xEBA341; static constexpr uint32_t kTextMain = 0x202020; +static constexpr uint32_t kTextSub = 0x4A4A4A; +static constexpr uint32_t kListBg = 0xFFFFFF; +static constexpr uint32_t kListBorder = 0xD2D2D2; static lv_style_t s_root; static lv_style_t s_header; static lv_style_t s_content; -static lv_style_t s_state_container; -static lv_style_t s_section; -static lv_style_t s_status; +static lv_style_t s_body; +static lv_style_t s_actions; +static lv_style_t s_section_label; +static lv_style_t s_meta_label; +static lv_style_t s_list_item; static lv_style_t s_btn_basic; static lv_style_t s_btn_focused; static bool s_inited = false; @@ -38,7 +43,8 @@ void init_once() s_inited = true; lv_style_init(&s_root); - lv_style_set_bg_opa(&s_root, LV_OPA_TRANSP); + lv_style_set_bg_color(&s_root, lv_color_white()); + lv_style_set_bg_opa(&s_root, LV_OPA_COVER); lv_style_set_border_width(&s_root, 0); lv_style_set_pad_all(&s_root, 0); @@ -49,28 +55,43 @@ void init_once() lv_style_set_pad_all(&s_header, 0); lv_style_init(&s_content); - lv_style_set_bg_opa(&s_content, LV_OPA_TRANSP); + lv_style_set_bg_color(&s_content, lv_color_white()); + lv_style_set_bg_opa(&s_content, LV_OPA_COVER); lv_style_set_border_width(&s_content, 0); lv_style_set_pad_all(&s_content, 0); lv_style_set_pad_top(&s_content, 3); - lv_style_init(&s_state_container); - lv_style_set_bg_opa(&s_state_container, LV_OPA_TRANSP); - lv_style_set_border_width(&s_state_container, 0); - lv_style_set_pad_all(&s_state_container, 0); - lv_style_set_pad_row(&s_state_container, 3); - lv_style_set_pad_column(&s_state_container, 0); + lv_style_init(&s_body); + lv_style_set_bg_color(&s_body, lv_color_white()); + lv_style_set_bg_opa(&s_body, LV_OPA_COVER); + lv_style_set_border_width(&s_body, 0); + lv_style_set_pad_all(&s_body, 6); + lv_style_set_pad_row(&s_body, 6); + lv_style_set_pad_column(&s_body, 0); - lv_style_init(&s_section); - lv_style_set_bg_opa(&s_section, LV_OPA_TRANSP); - lv_style_set_border_width(&s_section, 0); - lv_style_set_pad_all(&s_section, 0); - lv_style_set_pad_row(&s_section, 3); - lv_style_set_pad_column(&s_section, 0); + lv_style_init(&s_actions); + lv_style_set_bg_color(&s_actions, lv_color_white()); + lv_style_set_bg_opa(&s_actions, LV_OPA_COVER); + lv_style_set_border_width(&s_actions, 0); + lv_style_set_pad_all(&s_actions, 4); + lv_style_set_pad_row(&s_actions, 0); + lv_style_set_pad_column(&s_actions, 6); - lv_style_init(&s_status); - lv_style_set_text_color(&s_status, lv_color_hex(kTextMain)); - lv_style_set_text_align(&s_status, LV_TEXT_ALIGN_CENTER); + lv_style_init(&s_section_label); + lv_style_set_text_color(&s_section_label, lv_color_hex(kTextMain)); + lv_style_set_text_align(&s_section_label, LV_TEXT_ALIGN_LEFT); + + lv_style_init(&s_meta_label); + lv_style_set_text_color(&s_meta_label, lv_color_hex(kTextSub)); + lv_style_set_text_align(&s_meta_label, LV_TEXT_ALIGN_LEFT); + + lv_style_init(&s_list_item); + lv_style_set_bg_color(&s_list_item, lv_color_hex(kListBg)); + lv_style_set_bg_opa(&s_list_item, LV_OPA_COVER); + lv_style_set_border_width(&s_list_item, 1); + lv_style_set_border_color(&s_list_item, lv_color_hex(kListBorder)); + lv_style_set_radius(&s_list_item, 8); + lv_style_set_pad_all(&s_list_item, 6); lv_style_init(&s_btn_basic); lv_style_set_bg_color(&s_btn_basic, lv_color_hex(kBtnBg)); @@ -100,24 +121,30 @@ void apply_content(lv_obj_t* obj) lv_obj_add_style(obj, &s_content, 0); } -void apply_state_container(lv_obj_t* obj) +void apply_body(lv_obj_t* obj) { - lv_obj_add_style(obj, &s_state_container, 0); + lv_obj_add_style(obj, &s_body, 0); } -void apply_idle_container(lv_obj_t* obj) +void apply_actions(lv_obj_t* obj) { - lv_obj_add_style(obj, &s_section, 0); + lv_obj_add_style(obj, &s_actions, 0); } -void apply_active_container(lv_obj_t* obj) +void apply_section_label(lv_obj_t* obj) { - lv_obj_add_style(obj, &s_section, 0); + lv_obj_add_style(obj, &s_section_label, 0); } -void apply_status_label(lv_obj_t* obj) +void apply_meta_label(lv_obj_t* obj) { - lv_obj_add_style(obj, &s_status, 0); + lv_obj_add_style(obj, &s_meta_label, 0); +} + +void apply_list_item(lv_obj_t* obj) +{ + lv_obj_add_style(obj, &s_list_item, LV_PART_MAIN); + lv_obj_add_style(obj, &s_btn_focused, LV_PART_MAIN | LV_STATE_FOCUSED); } void apply_button_primary(lv_obj_t* obj) diff --git a/src/ui/screens/team/team_page_styles.h b/src/ui/screens/team/team_page_styles.h index e6b29a0e..e49a3dc4 100644 --- a/src/ui/screens/team/team_page_styles.h +++ b/src/ui/screens/team/team_page_styles.h @@ -19,10 +19,11 @@ void init_once(); void apply_root(lv_obj_t* obj); void apply_header(lv_obj_t* obj); void apply_content(lv_obj_t* obj); -void apply_state_container(lv_obj_t* obj); -void apply_idle_container(lv_obj_t* obj); -void apply_active_container(lv_obj_t* obj); -void apply_status_label(lv_obj_t* obj); +void apply_body(lv_obj_t* obj); +void apply_actions(lv_obj_t* obj); +void apply_section_label(lv_obj_t* obj); +void apply_meta_label(lv_obj_t* obj); +void apply_list_item(lv_obj_t* obj); void apply_button_primary(lv_obj_t* obj); void apply_button_secondary(lv_obj_t* obj); diff --git a/src/ui/screens/team/team_state.h b/src/ui/screens/team/team_state.h index 44879cea..057bb235 100644 --- a/src/ui/screens/team/team_state.h +++ b/src/ui/screens/team/team_state.h @@ -7,35 +7,153 @@ #include "lvgl.h" #include "../../widgets/top_bar.h" +#include "../../../team/domain/team_types.h" +#include "../../../team/protocol/team_mgmt.h" +#include +#include +#include namespace team { namespace ui { +constexpr size_t kTeamMaxMembers = 4; +constexpr uint8_t kTeamColorUnassigned = 0xFF; +static constexpr uint32_t kTeamMemberColors[kTeamMaxMembers] = { + 0xFF3B30, // red + 0x34C759, // green + 0x007AFF, // blue + 0xFFCC00 // yellow +}; + +inline uint32_t team_color_from_index(uint8_t index) +{ + if (index >= kTeamMaxMembers) + { + return kTeamMemberColors[0]; + } + return kTeamMemberColors[index]; +} + +inline uint8_t team_color_index_from_node_id(uint32_t node_id) +{ + uint32_t h = node_id; + h ^= h >> 16; + h *= 0x7feb352d; + h ^= h >> 15; + h *= 0x846ca68b; + h ^= h >> 16; + return static_cast(h % kTeamMaxMembers); +} + +enum class TeamPage +{ + StatusNotInTeam, + StatusInTeam, + TeamHome, + Invite, + InviteNfc, + JoinTeam, + JoinNfc, + EnterCode, + JoinPending, + Members, + MemberDetail, + KickConfirm, + KickedOut +}; + +enum class TeamInviteMode +{ + Radio, + Nfc +}; + +struct TeamMemberUi +{ + uint32_t node_id = 0; + std::string name; + bool online = false; + bool leader = false; + uint32_t last_seen_s = 0; + uint8_t color_index = kTeamColorUnassigned; +}; + +struct NearbyTeamUi +{ + TeamId team_id{}; + std::string name; + uint8_t signal_bars = 0; + uint32_t last_seen_s = 0; + uint32_t join_hint = 0; + bool has_join_hint = false; +}; + struct TeamPageState { lv_obj_t* root = nullptr; - lv_obj_t* page = nullptr; + lv_obj_t* page_obj = nullptr; lv_obj_t* header = nullptr; lv_obj_t* content = nullptr; + lv_obj_t* body = nullptr; + lv_obj_t* actions = nullptr; - lv_obj_t* state_container = nullptr; - lv_obj_t* idle_container = nullptr; - lv_obj_t* active_container = nullptr; + lv_obj_t* action_btns[3] = { nullptr, nullptr, nullptr }; + lv_obj_t* action_labels[3] = { nullptr, nullptr, nullptr }; + lv_obj_t* detail_label = nullptr; - lv_obj_t* idle_status_label = nullptr; - lv_obj_t* create_btn = nullptr; - lv_obj_t* join_btn = nullptr; - - lv_obj_t* active_status_label = nullptr; - lv_obj_t* share_btn = nullptr; - lv_obj_t* leave_btn = nullptr; - lv_obj_t* disband_btn = nullptr; - const char* title_override = nullptr; + std::vector list_items; + std::vector focusables; + std::vector nav_stack; + lv_obj_t* default_focus = nullptr; ::ui::widgets::TopBar top_bar_widget; lv_group_t* group = nullptr; + lv_group_t* modal_group = nullptr; + lv_group_t* prev_group = nullptr; + lv_obj_t* join_request_modal = nullptr; + lv_obj_t* leave_confirm_modal = nullptr; + + TeamPage page = TeamPage::StatusNotInTeam; + int selected_member_index = -1; + TeamInviteMode invite_mode = TeamInviteMode::Radio; + + bool in_team = false; + bool pending_join = false; + bool kicked_out = false; + bool self_is_leader = false; + uint32_t last_event_seq = 0; + + TeamId team_id{}; + bool has_team_id = false; + TeamId join_target_id{}; + bool has_join_target = false; + + std::string team_name; + uint32_t security_round = 0; + std::string invite_code; + uint32_t invite_expires_s = 0; + uint32_t last_update_s = 0; + std::array team_psk{}; + bool has_team_psk = false; + bool waiting_new_keys = false; + uint32_t pending_join_started_s = 0; + std::array nfc_next_psk{}; + bool has_nfc_next_psk = false; + uint32_t nfc_next_key_id = 0; + std::vector nfc_payload; + bool has_nfc_payload = false; + bool nfc_share_active = false; + bool nfc_scan_active = false; + uint32_t nfc_scan_started_s = 0; + lv_obj_t* invite_code_textarea = nullptr; + + std::vector members; + std::vector nearby_teams; + + uint32_t pending_join_node_id = 0; + std::string pending_join_name; }; extern TeamPageState g_team_state; diff --git a/src/ui/screens/team/team_ui_store.cpp b/src/ui/screens/team/team_ui_store.cpp new file mode 100644 index 00000000..3cf2ea6f --- /dev/null +++ b/src/ui/screens/team/team_ui_store.cpp @@ -0,0 +1,1489 @@ +/** + * @file team_ui_store.cpp + * @brief Team UI snapshot store (SD-only, persist.md format) + */ + +#include "team_ui_store.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace team +{ +namespace ui +{ + +bool TeamUiStoreStub::has_snapshot_ = false; +TeamUiSnapshot TeamUiStoreStub::snapshot_{}; + +namespace +{ +constexpr const char* kBaseDir = "/team"; +constexpr const char* kCurrentPath = "/team/current.txt"; +constexpr const char* kCurrentTmpPath = "/team/current.tmp"; +constexpr const char* kSnapshotName = "snapshot.bin"; +constexpr const char* kSnapshotTmpName = "snapshot.tmp"; +constexpr const char* kEventsName = "events.log"; +constexpr const char* kKeysName = "keys.bin"; +constexpr const char* kKeysTmpName = "keys.tmp"; +constexpr const char* kPosringName = "posring.log"; +constexpr const char* kChatlogName = "chatlog.log"; +constexpr const char* kChatlogOldName = "chatlog.old"; + +constexpr uint16_t kSnapshotVersion = 1; +constexpr uint16_t kEventVersion = 1; +constexpr uint8_t kKeysVersion = 1; +constexpr uint8_t kPosringVersion = 1; +constexpr uint8_t kChatlogVersionV1 = 1; +constexpr uint8_t kChatlogVersionV2 = 2; + +constexpr uint8_t kRoleNone = 0; +constexpr uint8_t kRoleMember = 1; +constexpr uint8_t kRoleLeader = 2; + +constexpr uint32_t kPosRecSize = 28; +constexpr uint32_t kPosRingCapacity = kPosRecSize * 512; +constexpr uint32_t kPosHeaderSize = 24; +constexpr uint32_t kPosMinIntervalSec = 15; +constexpr uint32_t kPosMaxIntervalSec = 30; +constexpr float kPosMinDistanceM = 20.0f; + +constexpr size_t kChatlogMaxBytes = 256 * 1024; + +uint32_t now_secs() +{ + return static_cast(millis() / 1000); +} + +uint64_t team_id_to_u64(const TeamId& id) +{ + uint64_t value = 0; + for (size_t i = 0; i < id.size(); ++i) + { + value |= (static_cast(id[i]) << (8 * i)); + } + return value; +} + +TeamId team_id_from_u64(uint64_t value) +{ + TeamId id{}; + for (size_t i = 0; i < id.size(); ++i) + { + id[i] = static_cast((value >> (8 * i)) & 0xFF); + } + return id; +} + +std::string base32_from_u64(uint64_t value) +{ + static const char* kAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + char buf[13]; + for (int i = 12; i >= 0; --i) + { + buf[i] = kAlphabet[value & 0x1F]; + value >>= 5; + } + return std::string(buf, 13); +} + +std::string team_dir_from_id(const TeamId& id) +{ + std::string full = base32_from_u64(team_id_to_u64(id)); + size_t first = full.find_first_not_of('A'); + if (first == std::string::npos) + { + full = "AAAA"; + } + else + { + full = full.substr(first); + } + if (full.size() > 10) + { + full = full.substr(full.size() - 10); + } + if (full.size() < 4) + { + full = std::string(4 - full.size(), 'A') + full; + } + return std::string("T_") + full; +} + +bool ensure_dir(const char* path) +{ + if (SD.exists(path)) + { + return true; + } + return SD.mkdir(path); +} + +bool read_current_dir(std::string& out_dir) +{ + if (SD.cardType() == CARD_NONE || !SD.exists(kCurrentPath)) + { + return false; + } + File f = SD.open(kCurrentPath, FILE_READ); + if (!f) + { + return false; + } + String line = f.readStringUntil('\n'); + f.close(); + line.trim(); + if (line.length() == 0) + { + return false; + } + out_dir = std::string(line.c_str()); + return true; +} + +bool write_current_dir(const std::string& dir) +{ + if (SD.cardType() == CARD_NONE) + { + return false; + } + if (!ensure_dir(kBaseDir)) + { + return false; + } + File f = SD.open(kCurrentTmpPath, FILE_WRITE); + if (!f) + { + return false; + } + f.print(dir.c_str()); + f.print("\n"); + f.flush(); + f.close(); + if (SD.exists(kCurrentPath)) + { + SD.remove(kCurrentPath); + } + return SD.rename(kCurrentTmpPath, kCurrentPath); +} + +bool clear_current_dir() +{ + if (SD.cardType() == CARD_NONE) + { + return false; + } + if (SD.exists(kCurrentPath)) + { + SD.remove(kCurrentPath); + } + if (SD.exists(kCurrentTmpPath)) + { + SD.remove(kCurrentTmpPath); + } + return true; +} + +bool ensure_team_dir_for_id(const TeamId& id, std::string& out_dir_path) +{ + if (SD.cardType() == CARD_NONE) + { + return false; + } + if (!ensure_dir(kBaseDir)) + { + return false; + } + std::string dir = team_dir_from_id(id); + out_dir_path = std::string(kBaseDir) + "/" + dir; + if (!ensure_dir(out_dir_path.c_str())) + { + return false; + } + write_current_dir(dir); + return true; +} + +void write_u8(File& f, uint8_t v) +{ + f.write(&v, 1); +} + +void write_u16(File& f, uint16_t v) +{ + uint8_t b[2] = { + static_cast(v & 0xFF), + static_cast((v >> 8) & 0xFF) + }; + f.write(b, 2); +} + +void write_u32(File& f, uint32_t v) +{ + uint8_t b[4]; + for (int i = 0; i < 4; ++i) + { + b[i] = static_cast((v >> (8 * i)) & 0xFF); + } + f.write(b, 4); +} + +void write_u64(File& f, uint64_t v) +{ + uint8_t b[8]; + for (int i = 0; i < 8; ++i) + { + b[i] = static_cast((v >> (8 * i)) & 0xFF); + } + f.write(b, 8); +} + +bool read_u8(const std::vector& buf, size_t& off, uint8_t& out) +{ + if (off + 1 > buf.size()) + { + return false; + } + out = buf[off]; + off += 1; + return true; +} + +bool read_u16(const std::vector& buf, size_t& off, uint16_t& out) +{ + if (off + 2 > buf.size()) + { + return false; + } + out = static_cast(buf[off]) | + (static_cast(buf[off + 1]) << 8); + off += 2; + return true; +} + +bool read_u32(const std::vector& buf, size_t& off, uint32_t& out) +{ + if (off + 4 > buf.size()) + { + return false; + } + out = static_cast(buf[off]) | + (static_cast(buf[off + 1]) << 8) | + (static_cast(buf[off + 2]) << 16) | + (static_cast(buf[off + 3]) << 24); + off += 4; + return true; +} + +bool read_u64(const std::vector& buf, size_t& off, uint64_t& out) +{ + if (off + 8 > buf.size()) + { + return false; + } + uint64_t v = 0; + for (int i = 0; i < 8; ++i) + { + v |= (static_cast(buf[off + i]) << (8 * i)); + } + out = v; + off += 8; + return true; +} + +int find_member_index(const TeamUiSnapshot& snap, uint32_t node_id) +{ + for (size_t i = 0; i < snap.members.size(); ++i) + { + if (snap.members[i].node_id == node_id) + { + return static_cast(i); + } + } + return -1; +} + +void apply_key_event(TeamUiSnapshot& snap, TeamKeyEventType type, const std::vector& payload) +{ + size_t off = 0; + if (type == TeamKeyEventType::TeamCreated) + { + uint64_t team_id = 0; + uint32_t leader_id = 0; + uint32_t epoch = 0; + if (!read_u64(payload, off, team_id) || + !read_u32(payload, off, leader_id) || + !read_u32(payload, off, epoch)) + { + return; + } + snap.team_id = team_id_from_u64(team_id); + snap.has_team_id = true; + snap.in_team = true; + snap.security_round = epoch; + snap.self_is_leader = (leader_id == 0); + if (find_member_index(snap, leader_id) < 0) + { + TeamMemberUi leader; + leader.node_id = leader_id; + leader.leader = true; + snap.members.push_back(leader); + } + } + else if (type == TeamKeyEventType::MemberAccepted) + { + uint32_t member_id = 0; + uint8_t role = 0; + if (!read_u32(payload, off, member_id) || + !read_u8(payload, off, role)) + { + return; + } + int idx = find_member_index(snap, member_id); + if (idx < 0) + { + TeamMemberUi m; + m.node_id = member_id; + snap.members.push_back(m); + idx = static_cast(snap.members.size() - 1); + } + snap.members[idx].leader = (role == kRoleLeader); + if (member_id == 0) + { + snap.self_is_leader = (role == kRoleLeader); + snap.in_team = true; + } + } + else if (type == TeamKeyEventType::MemberKicked) + { + uint32_t member_id = 0; + if (!read_u32(payload, off, member_id)) + { + return; + } + int idx = find_member_index(snap, member_id); + if (idx >= 0) + { + snap.members.erase(snap.members.begin() + idx); + } + if (member_id == 0) + { + snap.in_team = false; + snap.self_is_leader = false; + } + } + else if (type == TeamKeyEventType::LeaderTransferred) + { + uint32_t leader_id = 0; + if (!read_u32(payload, off, leader_id)) + { + return; + } + for (auto& m : snap.members) + { + m.leader = false; + } + int idx = find_member_index(snap, leader_id); + if (idx < 0) + { + TeamMemberUi leader; + leader.node_id = leader_id; + leader.leader = true; + snap.members.push_back(leader); + } + else + { + snap.members[idx].leader = true; + } + snap.self_is_leader = (leader_id == 0); + } + else if (type == TeamKeyEventType::EpochRotated) + { + uint32_t epoch = 0; + if (!read_u32(payload, off, epoch)) + { + return; + } + snap.security_round = epoch; + } +} + +bool load_snapshot_from_path(const std::string& snapshot_path, TeamUiSnapshot& out) +{ + if (!SD.exists(snapshot_path.c_str())) + { + return false; + } + File f = SD.open(snapshot_path.c_str(), FILE_READ); + if (!f) + { + return false; + } + size_t file_size = f.size(); + std::vector buf(file_size); + bool ok = (f.read(buf.data(), buf.size()) == buf.size()); + f.close(); + if (!ok || buf.size() < 32) + { + return false; + } + + size_t off = 0; + if (off + 4 > buf.size()) + { + return false; + } + if (std::memcmp(buf.data(), "TMS1", 4) != 0) + { + return false; + } + off += 4; + + uint8_t version = 0; + uint8_t flags = 0; + uint16_t reserved = 0; + uint32_t updated_ts = 0; + uint64_t team_id = 0; + uint32_t epoch = 0; + uint32_t last_event_seq = 0; + uint32_t self_node_id = 0; + uint32_t leader_node_id = 0; + uint8_t self_role = 0; + uint16_t member_count = 0; + uint16_t reserved3 = 0; + + if (!read_u8(buf, off, version) || + !read_u8(buf, off, flags) || + !read_u16(buf, off, reserved) || + !read_u32(buf, off, updated_ts) || + !read_u64(buf, off, team_id) || + !read_u32(buf, off, epoch) || + !read_u32(buf, off, last_event_seq) || + !read_u32(buf, off, self_node_id) || + !read_u32(buf, off, leader_node_id) || + !read_u8(buf, off, self_role)) + { + return false; + } + if (off + 3 > buf.size()) + { + return false; + } + off += 3; + if (!read_u16(buf, off, member_count) || + !read_u16(buf, off, reserved3)) + { + return false; + } + + if (version != kSnapshotVersion) + { + return false; + } + + out.in_team = (flags & 0x01) != 0; + out.has_team_id = (team_id != 0); + out.team_id = team_id_from_u64(team_id); + out.security_round = epoch; + out.last_event_seq = last_event_seq; + out.self_is_leader = (self_role == kRoleLeader); + out.members.clear(); + + for (uint16_t i = 0; i < member_count; ++i) + { + uint32_t node_id = 0; + uint8_t role = 0; + uint8_t mflags = 0; + uint16_t name_len = 0; + if (!read_u32(buf, off, node_id) || + !read_u8(buf, off, role) || + !read_u8(buf, off, mflags) || + !read_u16(buf, off, name_len)) + { + return false; + } + if (off + name_len > buf.size()) + { + return false; + } + std::string name; + if (name_len > 0) + { + name.assign(reinterpret_cast(buf.data() + off), name_len); + } + off += name_len; + + TeamMemberUi m; + m.node_id = node_id; + m.name = name; + m.leader = (role == kRoleLeader); + out.members.push_back(m); + } + + (void)updated_ts; + (void)self_node_id; + (void)leader_node_id; + return true; +} + +bool save_snapshot_to_path(const std::string& dir_path, const TeamUiSnapshot& in) +{ + if (SD.cardType() == CARD_NONE) + { + return false; + } + if (!ensure_dir(kBaseDir)) + { + return false; + } + if (!ensure_dir(dir_path.c_str())) + { + return false; + } + + std::string tmp_path = dir_path + "/" + kSnapshotTmpName; + std::string out_path = dir_path + "/" + kSnapshotName; + + File f = SD.open(tmp_path.c_str(), FILE_WRITE); + if (!f) + { + return false; + } + + f.write(reinterpret_cast("TMS1"), 4); + write_u8(f, kSnapshotVersion); + uint8_t flags = in.in_team ? 0x01 : 0x00; + write_u8(f, flags); + write_u16(f, 0); + write_u32(f, now_secs()); + write_u64(f, in.has_team_id ? team_id_to_u64(in.team_id) : 0); + write_u32(f, in.security_round); + write_u32(f, in.last_event_seq); + write_u32(f, 0); + uint32_t leader_node_id = 0; + for (const auto& m : in.members) + { + if (m.leader) + { + leader_node_id = m.node_id; + break; + } + } + write_u32(f, leader_node_id); + write_u8(f, in.self_is_leader ? kRoleLeader : (in.in_team ? kRoleMember : kRoleNone)); + write_u8(f, 0); + write_u8(f, 0); + write_u8(f, 0); + uint16_t member_count = static_cast(in.members.size()); + write_u16(f, member_count); + write_u16(f, 0); + + for (const auto& m : in.members) + { + write_u32(f, m.node_id); + write_u8(f, m.leader ? kRoleLeader : kRoleMember); + uint16_t name_len = 0; + uint8_t name_flag = 0; + std::string name = m.name; + if (!name.empty()) + { + if (name.size() > 24) + { + name.resize(24); + } + name_len = static_cast(name.size()); + name_flag = 0x01; + } + write_u8(f, name_flag); + write_u16(f, name_len); + if (name_len > 0) + { + f.write(reinterpret_cast(name.data()), name_len); + } + } + + f.flush(); + f.close(); + + std::string out_path_c = out_path; + if (SD.exists(out_path_c.c_str())) + { + SD.remove(out_path_c.c_str()); + } + return SD.rename(tmp_path.c_str(), out_path_c.c_str()); +} + +bool load_keys_from_path(const std::string& keys_path, TeamUiSnapshot& out) +{ + if (!SD.exists(keys_path.c_str())) + { + return false; + } + File f = SD.open(keys_path.c_str(), FILE_READ); + if (!f) + { + return false; + } + size_t file_size = f.size(); + std::vector buf(file_size); + bool ok = (f.read(buf.data(), buf.size()) == buf.size()); + f.close(); + if (!ok || buf.size() < 22) + { + return false; + } + if (std::memcmp(buf.data(), "TMK1", 4) != 0) + { + return false; + } + size_t off = 4; + uint8_t version = 0; + uint8_t psk_len = 0; + uint16_t reserved = 0; + uint64_t team_id = 0; + uint32_t key_id = 0; + if (!read_u8(buf, off, version) || + !read_u8(buf, off, psk_len) || + !read_u16(buf, off, reserved) || + !read_u64(buf, off, team_id) || + !read_u32(buf, off, key_id)) + { + return false; + } + if (version != kKeysVersion || psk_len == 0 || + off + psk_len > buf.size() || + psk_len > out.team_psk.size()) + { + return false; + } + out.team_id = team_id_from_u64(team_id); + out.has_team_id = (team_id != 0); + out.security_round = key_id; + std::fill(out.team_psk.begin(), out.team_psk.end(), 0); + std::memcpy(out.team_psk.data(), buf.data() + off, psk_len); + out.has_team_psk = true; + return true; +} + +bool save_keys_to_path(const std::string& dir_path, const TeamUiSnapshot& in) +{ + if (!in.has_team_id || !in.has_team_psk) + { + return false; + } + std::string tmp_path = dir_path + "/" + kKeysTmpName; + std::string out_path = dir_path + "/" + kKeysName; + File f = SD.open(tmp_path.c_str(), FILE_WRITE); + if (!f) + { + return false; + } + f.write(reinterpret_cast("TMK1"), 4); + write_u8(f, kKeysVersion); + write_u8(f, static_cast(in.team_psk.size())); + write_u16(f, 0); + write_u64(f, team_id_to_u64(in.team_id)); + write_u32(f, in.security_round); + f.write(in.team_psk.data(), in.team_psk.size()); + f.flush(); + f.close(); + if (SD.exists(out_path.c_str())) + { + SD.remove(out_path.c_str()); + } + return SD.rename(tmp_path.c_str(), out_path.c_str()); +} + +bool load_events_apply(const std::string& events_path, TeamUiSnapshot& out) +{ + if (!SD.exists(events_path.c_str())) + { + return false; + } + File f = SD.open(events_path.c_str(), FILE_READ); + if (!f) + { + return false; + } + size_t file_size = f.size(); + std::vector buf(file_size); + bool ok = (f.read(buf.data(), buf.size()) == buf.size()); + f.close(); + if (!ok) + { + return false; + } + + size_t off = 0; + uint32_t last_seq = out.last_event_seq; + bool applied = false; + + while (off + 12 <= buf.size()) + { + if (buf[off] != 'E' || buf[off + 1] != 'V') + { + break; + } + off += 2; + uint8_t version = 0; + uint8_t type = 0; + uint32_t seq = 0; + uint32_t ts = 0; + uint16_t payload_len = 0; + uint16_t reserved = 0; + if (!read_u8(buf, off, version) || + !read_u8(buf, off, type) || + !read_u32(buf, off, seq) || + !read_u32(buf, off, ts) || + !read_u16(buf, off, payload_len) || + !read_u16(buf, off, reserved)) + { + break; + } + if (version != kEventVersion) + { + break; + } + if (off + payload_len > buf.size()) + { + break; + } + if (seq > last_seq) + { + std::vector payload; + if (payload_len > 0) + { + payload.assign(buf.begin() + off, buf.begin() + off + payload_len); + } + apply_key_event(out, static_cast(type), payload); + out.last_event_seq = seq; + applied = true; + } + off += payload_len; + (void)ts; + (void)reserved; + } + return applied; +} + +bool append_event(const TeamId& team_id, TeamKeyEventType type, + uint32_t event_seq, uint32_t ts, + const uint8_t* payload, size_t len) +{ + if (SD.cardType() == CARD_NONE) + { + return false; + } + std::string dir_path; + if (!ensure_team_dir_for_id(team_id, dir_path)) + { + return false; + } + + std::string events_path = dir_path + "/" + kEventsName; + File f = SD.open(events_path.c_str(), FILE_APPEND); + if (!f) + { + return false; + } + f.write(reinterpret_cast("EV"), 2); + write_u8(f, kEventVersion); + write_u8(f, static_cast(type)); + write_u32(f, event_seq); + write_u32(f, ts); + write_u16(f, static_cast(len)); + write_u16(f, 0); + if (payload && len > 0) + { + f.write(payload, len); + } + f.flush(); + f.close(); + return true; +} + +struct PosThrottleState +{ + uint32_t member_id = 0; + uint32_t ts = 0; + int32_t lat_e7 = 0; + int32_t lon_e7 = 0; +}; + +std::vector s_pos_throttle; + +bool should_write_pos(uint32_t member_id, int32_t lat_e7, int32_t lon_e7, uint32_t ts) +{ + for (auto& item : s_pos_throttle) + { + if (item.member_id != member_id) + { + continue; + } + uint32_t dt = (ts > item.ts) ? (ts - item.ts) : 0; + if (dt >= kPosMaxIntervalSec) + { + item.ts = ts; + item.lat_e7 = lat_e7; + item.lon_e7 = lon_e7; + return true; + } + if (dt < kPosMinIntervalSec) + { + return false; + } + float dlat = (lat_e7 - item.lat_e7) / 1e7f; + float dlon = (lon_e7 - item.lon_e7) / 1e7f; + float meters_per_deg = 111320.0f; + float lat_m = dlat * meters_per_deg; + float lon_m = dlon * meters_per_deg; + float dist = std::sqrt(lat_m * lat_m + lon_m * lon_m); + if (dist < kPosMinDistanceM) + { + return false; + } + item.ts = ts; + item.lat_e7 = lat_e7; + item.lon_e7 = lon_e7; + return true; + } + PosThrottleState state; + state.member_id = member_id; + state.ts = ts; + state.lat_e7 = lat_e7; + state.lon_e7 = lon_e7; + s_pos_throttle.push_back(state); + return true; +} + +bool init_posring(File& f) +{ + f.seek(0); + f.write(reinterpret_cast("PSR1"), 4); + write_u8(f, kPosringVersion); + write_u8(f, 0); + write_u8(f, 0); + write_u8(f, 0); + write_u32(f, kPosRingCapacity); + write_u32(f, 0); + write_u32(f, kPosRecSize); + write_u32(f, 0); + f.flush(); + return true; +} + +bool read_posring_header(File& f, uint32_t& write_offset) +{ + f.seek(0); + uint8_t header[kPosHeaderSize]; + if (f.read(header, sizeof(header)) != sizeof(header)) + { + return false; + } + if (std::memcmp(header, "PSR1", 4) != 0 || header[4] != kPosringVersion) + { + return false; + } + uint32_t data_capacity = static_cast(header[8]) | + (static_cast(header[9]) << 8) | + (static_cast(header[10]) << 16) | + (static_cast(header[11]) << 24); + uint32_t offset = static_cast(header[12]) | + (static_cast(header[13]) << 8) | + (static_cast(header[14]) << 16) | + (static_cast(header[15]) << 24); + uint32_t rec_size = static_cast(header[16]) | + (static_cast(header[17]) << 8) | + (static_cast(header[18]) << 16) | + (static_cast(header[19]) << 24); + if (data_capacity != kPosRingCapacity || rec_size != kPosRecSize) + { + return false; + } + if (offset >= data_capacity) + { + offset = 0; + } + write_offset = offset; + return true; +} + +bool write_posring_header(File& f, uint32_t write_offset) +{ + f.seek(0); + f.write(reinterpret_cast("PSR1"), 4); + write_u8(f, kPosringVersion); + write_u8(f, 0); + write_u8(f, 0); + write_u8(f, 0); + write_u32(f, kPosRingCapacity); + write_u32(f, write_offset); + write_u32(f, kPosRecSize); + write_u32(f, 0); + f.flush(); + return true; +} + +class TeamUiStorePersisted : public ITeamUiStore +{ + public: + bool load(TeamUiSnapshot& out) override + { + if (SD.cardType() == CARD_NONE) + { + return false; + } + + TeamUiSnapshot snap; + std::string dir; + bool has_current = read_current_dir(dir); + bool loaded = false; + if (has_current) + { + std::string dir_path = std::string(kBaseDir) + "/" + dir; + std::string snapshot_path = dir_path + "/" + kSnapshotName; + if (load_snapshot_from_path(snapshot_path, snap)) + { + loaded = true; + } + std::string keys_path = dir_path + "/" + kKeysName; + if (load_keys_from_path(keys_path, snap)) + { + loaded = true; + } + std::string events_path = dir_path + "/" + kEventsName; + if (load_events_apply(events_path, snap)) + { + loaded = true; + } + } + + if (!loaded) + { + return false; + } + + snap.pending_join = false; + snap.pending_join_started_s = 0; + snap.kicked_out = false; + snap.has_join_target = false; + if (!snap.has_team_psk) + { + std::fill(snap.team_psk.begin(), snap.team_psk.end(), 0); + } + + out = snap; + return true; + } + + void save(const TeamUiSnapshot& in) override + { + if (SD.cardType() == CARD_NONE) + { + return; + } + if (!in.has_team_id || !in.in_team) + { + clear_current_dir(); + return; + } + + uint32_t now = now_secs(); + bool force_write = (in.in_team != last_snapshot_in_team_) || + (in.self_is_leader != last_snapshot_self_is_leader_) || + (in.security_round != last_snapshot_epoch_) || + (in.has_team_psk != last_snapshot_has_psk_); + bool seq_trigger = (in.last_event_seq >= last_snapshot_seq_ + 10); + bool time_trigger = (now - last_snapshot_ts_ >= 60); + + if (!force_write && !seq_trigger && !time_trigger) + { + return; + } + + std::string dir = team_dir_from_id(in.team_id); + std::string dir_path = std::string(kBaseDir) + "/" + dir; + write_current_dir(dir); + if (save_snapshot_to_path(dir_path, in)) + { + if (in.has_team_psk) + { + save_keys_to_path(dir_path, in); + } + last_snapshot_ts_ = now; + last_snapshot_seq_ = in.last_event_seq; + last_snapshot_in_team_ = in.in_team; + last_snapshot_self_is_leader_ = in.self_is_leader; + last_snapshot_epoch_ = in.security_round; + last_snapshot_has_psk_ = in.has_team_psk; + } + } + + void clear() override + { + clear_current_dir(); + } + + private: + uint32_t last_snapshot_ts_ = 0; + uint32_t last_snapshot_seq_ = 0; + bool last_snapshot_in_team_ = false; + bool last_snapshot_self_is_leader_ = false; + uint32_t last_snapshot_epoch_ = 0; + bool last_snapshot_has_psk_ = false; +}; + +TeamUiStorePersisted s_persisted_store; +} // namespace + +bool TeamUiStoreStub::load(TeamUiSnapshot& out) +{ + if (!has_snapshot_) + { + return false; + } + out = snapshot_; + return true; +} + +void TeamUiStoreStub::save(const TeamUiSnapshot& in) +{ + snapshot_ = in; + has_snapshot_ = true; +} + +void TeamUiStoreStub::clear() +{ + has_snapshot_ = false; +} + +namespace +{ +TeamUiStoreStub s_stub_store; +ITeamUiStore* s_store = &s_persisted_store; +} // namespace + +ITeamUiStore& team_ui_get_store() +{ + return *s_store; +} + +void team_ui_set_store(ITeamUiStore* store) +{ + if (store) + { + s_store = store; + } + else + { + s_store = &s_stub_store; + } +} + +bool team_ui_append_key_event(const TeamId& team_id, + TeamKeyEventType type, + uint32_t event_seq, + uint32_t ts, + const uint8_t* payload, + size_t len) +{ + return append_event(team_id, type, event_seq, ts, payload, len); +} + +bool team_ui_posring_append(const TeamId& team_id, + uint32_t member_id, + int32_t lat_e7, + int32_t lon_e7, + int16_t alt_m, + uint16_t speed_dmps, + uint32_t ts) +{ + if (!should_write_pos(member_id, lat_e7, lon_e7, ts)) + { + return false; + } + + std::string dir_path; + if (!ensure_team_dir_for_id(team_id, dir_path)) + { + return false; + } + + std::string path = dir_path + "/" + kPosringName; + File f = SD.open(path.c_str(), FILE_READ); + bool exists = f; + if (f) + { + f.close(); + } + File rw = SD.open(path.c_str(), FILE_WRITE); + if (!rw) + { + return false; + } + if (!exists) + { + init_posring(rw); + } + + uint32_t write_offset = 0; + if (!read_posring_header(rw, write_offset)) + { + init_posring(rw); + write_offset = 0; + } + + uint32_t data_offset = kPosHeaderSize + write_offset; + rw.seek(data_offset); + write_u16(rw, 0x5053); + write_u8(rw, kPosringVersion); + write_u8(rw, 0); + write_u32(rw, ts); + write_u32(rw, member_id); + write_u32(rw, static_cast(lat_e7)); + write_u32(rw, static_cast(lon_e7)); + write_u16(rw, static_cast(alt_m)); + write_u16(rw, speed_dmps); + + write_offset += kPosRecSize; + if (write_offset >= kPosRingCapacity) + { + write_offset = 0; + } + write_posring_header(rw, write_offset); + rw.close(); + return true; +} + +bool team_ui_posring_load_latest(const TeamId& team_id, + std::vector& out) +{ + out.clear(); + if (SD.cardType() == CARD_NONE) + { + return false; + } + std::string dir = team_dir_from_id(team_id); + std::string dir_path = std::string(kBaseDir) + "/" + dir; + std::string path = dir_path + "/" + kPosringName; + if (!SD.exists(path.c_str())) + { + return false; + } + File f = SD.open(path.c_str(), FILE_READ); + if (!f) + { + return false; + } + uint32_t write_offset = 0; + if (!read_posring_header(f, write_offset)) + { + f.close(); + return false; + } + if (f.size() < kPosHeaderSize + kPosRecSize) + { + f.close(); + return false; + } + size_t data_size = kPosRingCapacity; + if (kPosHeaderSize + data_size > f.size()) + { + data_size = f.size() - kPosHeaderSize; + } + std::vector buf(data_size); + f.seek(kPosHeaderSize); + bool ok = (f.read(buf.data(), buf.size()) == buf.size()); + f.close(); + if (!ok) + { + return false; + } + + for (size_t base = 0; base + kPosRecSize <= buf.size(); base += kPosRecSize) + { + size_t off = base; + uint16_t magic = 0; + uint8_t ver = 0; + uint8_t flags = 0; + uint32_t ts = 0; + uint32_t member_id = 0; + uint32_t lat_u = 0; + uint32_t lon_u = 0; + uint16_t alt_u = 0; + uint16_t speed_u = 0; + if (!read_u16(buf, off, magic) || + !read_u8(buf, off, ver) || + !read_u8(buf, off, flags) || + !read_u32(buf, off, ts) || + !read_u32(buf, off, member_id) || + !read_u32(buf, off, lat_u) || + !read_u32(buf, off, lon_u) || + !read_u16(buf, off, alt_u) || + !read_u16(buf, off, speed_u)) + { + break; + } + if (magic != 0x5053 || ver != kPosringVersion || ts == 0) + { + continue; + } + (void)flags; + TeamPosSample sample; + sample.member_id = member_id; + sample.lat_e7 = static_cast(lat_u); + sample.lon_e7 = static_cast(lon_u); + sample.alt_m = static_cast(alt_u); + sample.speed_dmps = speed_u; + sample.ts = ts; + + auto it = std::find_if(out.begin(), out.end(), + [&](const TeamPosSample& s) { return s.member_id == member_id; }); + if (it == out.end()) + { + out.push_back(sample); + } + else if (sample.ts > it->ts) + { + *it = sample; + } + } + return !out.empty(); +} + +bool team_ui_chatlog_append(const TeamId& team_id, + uint32_t peer_id, + bool incoming, + uint32_t ts, + const std::string& text) +{ + std::vector payload; + payload.assign(text.begin(), text.end()); + return team_ui_chatlog_append_structured(team_id, + peer_id, + incoming, + ts, + team::proto::TeamChatType::Text, + payload); +} + +bool team_ui_chatlog_append_structured(const TeamId& team_id, + uint32_t peer_id, + bool incoming, + uint32_t ts, + team::proto::TeamChatType type, + const std::vector& payload) +{ + std::string dir_path; + if (!ensure_team_dir_for_id(team_id, dir_path)) + { + return false; + } + + std::string path = dir_path + "/" + kChatlogName; + size_t record_len = 2 + 1 + 1 + 4 + 4 + 1 + 3 + 2 + 2 + payload.size(); + if (SD.exists(path.c_str())) + { + File f = SD.open(path.c_str(), FILE_READ); + if (f) + { + size_t size = f.size(); + f.close(); + if (size + record_len > kChatlogMaxBytes) + { + std::string old_path = dir_path + "/" + kChatlogOldName; + if (SD.exists(old_path.c_str())) + { + SD.remove(old_path.c_str()); + } + SD.rename(path.c_str(), old_path.c_str()); + } + } + } + + File out = SD.open(path.c_str(), FILE_APPEND); + if (!out) + { + return false; + } + out.write(reinterpret_cast("CH"), 2); + write_u8(out, kChatlogVersionV2); + write_u8(out, incoming ? 1 : 0); + write_u32(out, ts); + write_u32(out, peer_id); + write_u8(out, static_cast(type)); + write_u8(out, 0); + write_u8(out, 0); + write_u8(out, 0); + uint16_t payload_len = static_cast(payload.size()); + write_u16(out, payload_len); + write_u16(out, 0); + if (payload_len > 0) + { + out.write(payload.data(), payload_len); + } + out.flush(); + out.close(); + return true; +} + +bool team_ui_chatlog_load_recent(const TeamId& team_id, + size_t max_count, + std::vector& out) +{ + out.clear(); + if (SD.cardType() == CARD_NONE) + { + return false; + } + std::string dir_path; + if (!ensure_team_dir_for_id(team_id, dir_path)) + { + return false; + } + std::string path = dir_path + "/" + kChatlogName; + if (!SD.exists(path.c_str())) + { + return false; + } + File f = SD.open(path.c_str(), FILE_READ); + if (!f) + { + return false; + } + size_t file_size = f.size(); + if (file_size == 0) + { + f.close(); + return false; + } + std::vector buf(file_size); + bool ok = (f.read(buf.data(), buf.size()) == buf.size()); + f.close(); + if (!ok) + { + return false; + } + + size_t off = 0; + while (off + 4 <= buf.size()) + { + if (buf[off] != 'C' || buf[off + 1] != 'H') + { + break; + } + off += 2; + uint8_t version = buf[off++]; + uint8_t flags = buf[off++]; + TeamChatLogEntry entry; + entry.incoming = (flags & 0x01) != 0; + + uint32_t ts = 0; + uint32_t peer_id = 0; + uint16_t payload_len = 0; + + if (version == kChatlogVersionV1) + { + if (!read_u32(buf, off, ts) || + !read_u32(buf, off, peer_id)) + { + break; + } + uint16_t text_len = 0; + uint16_t reserved = 0; + if (!read_u16(buf, off, text_len) || + !read_u16(buf, off, reserved)) + { + break; + } + if (off + text_len > buf.size()) + { + break; + } + entry.type = team::proto::TeamChatType::Text; + entry.ts = ts; + entry.peer_id = peer_id; + if (text_len > 0) + { + entry.payload.assign(buf.begin() + off, buf.begin() + off + text_len); + } + off += text_len; + } + else if (version == kChatlogVersionV2) + { + if (!read_u32(buf, off, ts) || + !read_u32(buf, off, peer_id)) + { + break; + } + if (off + 4 > buf.size()) + { + break; + } + entry.type = static_cast(buf[off]); + off += 4; + uint16_t reserved = 0; + if (!read_u16(buf, off, payload_len) || + !read_u16(buf, off, reserved)) + { + break; + } + if (off + payload_len > buf.size()) + { + break; + } + entry.ts = ts; + entry.peer_id = peer_id; + if (payload_len > 0) + { + entry.payload.assign(buf.begin() + off, buf.begin() + off + payload_len); + } + off += payload_len; + } + else + { + break; + } + + if (max_count > 0 && out.size() >= max_count) + { + out.erase(out.begin()); + } + out.push_back(std::move(entry)); + } + return !out.empty(); +} + +bool team_ui_save_keys_now(const TeamId& team_id, + uint32_t key_id, + const std::array& psk) +{ + if (SD.cardType() == CARD_NONE) + { + return false; + } + if (key_id == 0) + { + return false; + } + std::string dir_path; + if (!ensure_team_dir_for_id(team_id, dir_path)) + { + return false; + } + TeamUiSnapshot snap; + snap.team_id = team_id; + snap.has_team_id = true; + snap.security_round = key_id; + snap.team_psk = psk; + snap.has_team_psk = true; + return save_keys_to_path(dir_path, snap); +} + +} // namespace ui +} // namespace team diff --git a/src/ui/screens/team/team_ui_store.h b/src/ui/screens/team/team_ui_store.h new file mode 100644 index 00000000..d3fb1839 --- /dev/null +++ b/src/ui/screens/team/team_ui_store.h @@ -0,0 +1,129 @@ +/** + * @file team_ui_store.h + * @brief Team UI snapshot store interface (stub-ready) + */ + +#pragma once + +#include "team_state.h" +#include "../../../team/protocol/team_chat.h" +#include + +namespace team +{ +namespace ui +{ + +struct TeamUiSnapshot +{ + bool in_team = false; + bool pending_join = false; + uint32_t pending_join_started_s = 0; + bool kicked_out = false; + bool self_is_leader = false; + uint32_t last_event_seq = 0; + + TeamId team_id{}; + bool has_team_id = false; + TeamId join_target_id{}; + bool has_join_target = false; + + std::string team_name; + uint32_t security_round = 0; + std::string invite_code; + uint32_t invite_expires_s = 0; + uint32_t last_update_s = 0; + std::array team_psk{}; + bool has_team_psk = false; + + std::vector members; + std::vector nearby_teams; +}; + +enum class TeamKeyEventType : uint8_t +{ + TeamCreated = 1, + MemberAccepted = 2, + MemberKicked = 3, + LeaderTransferred = 4, + EpochRotated = 5 +}; + +struct TeamChatLogEntry +{ + bool incoming = false; + uint32_t ts = 0; + uint32_t peer_id = 0; + team::proto::TeamChatType type = team::proto::TeamChatType::Text; + std::vector payload; +}; + +struct TeamPosSample +{ + uint32_t member_id = 0; + int32_t lat_e7 = 0; + int32_t lon_e7 = 0; + int16_t alt_m = 0; + uint16_t speed_dmps = 0; + uint32_t ts = 0; +}; + +class ITeamUiStore +{ + public: + virtual ~ITeamUiStore() = default; + virtual bool load(TeamUiSnapshot& out) = 0; + virtual void save(const TeamUiSnapshot& in) = 0; + virtual void clear() = 0; +}; + +// Simple in-memory stub store (acts as fake persistence until real store is wired). +class TeamUiStoreStub : public ITeamUiStore +{ + public: + bool load(TeamUiSnapshot& out) override; + void save(const TeamUiSnapshot& in) override; + void clear() override; + + private: + static bool has_snapshot_; + static TeamUiSnapshot snapshot_; +}; + +ITeamUiStore& team_ui_get_store(); +void team_ui_set_store(ITeamUiStore* store); +bool team_ui_append_key_event(const TeamId& team_id, + TeamKeyEventType type, + uint32_t event_seq, + uint32_t ts, + const uint8_t* payload, + size_t len); +bool team_ui_posring_append(const TeamId& team_id, + uint32_t member_id, + int32_t lat_e7, + int32_t lon_e7, + int16_t alt_m, + uint16_t speed_dmps, + uint32_t ts); +bool team_ui_posring_load_latest(const TeamId& team_id, + std::vector& out); +bool team_ui_chatlog_append(const TeamId& team_id, + uint32_t peer_id, + bool incoming, + uint32_t ts, + const std::string& text); +bool team_ui_chatlog_append_structured(const TeamId& team_id, + uint32_t peer_id, + bool incoming, + uint32_t ts, + team::proto::TeamChatType type, + const std::vector& payload); +bool team_ui_chatlog_load_recent(const TeamId& team_id, + size_t max_count, + std::vector& out); +bool team_ui_save_keys_now(const TeamId& team_id, + uint32_t key_id, + const std::array& psk); + +} // namespace ui +} // namespace team diff --git a/src/ui/screens/tracker/tracker_page_components.cpp b/src/ui/screens/tracker/tracker_page_components.cpp index 7d9aa7ac..b0b83b21 100644 --- a/src/ui/screens/tracker/tracker_page_components.cpp +++ b/src/ui/screens/tracker/tracker_page_components.cpp @@ -20,8 +20,7 @@ namespace { void on_back(void*) { - ui_clear_active_app(); - menu_show(); + ui_request_exit_to_menu(); } void refresh_track_list() @@ -138,4 +137,3 @@ void cleanup_page() } // namespace components } // namespace ui } // namespace tracker - diff --git a/src/ui/screens/tracker/tracker_page_layout.cpp b/src/ui/screens/tracker/tracker_page_layout.cpp index a3eaacfd..1b30fa05 100644 --- a/src/ui/screens/tracker/tracker_page_layout.cpp +++ b/src/ui/screens/tracker/tracker_page_layout.cpp @@ -26,6 +26,13 @@ inline void make_non_scrollable(lv_obj_t* obj) lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_scrollbar_mode(obj, LV_SCROLLBAR_MODE_OFF); } + +inline void apply_base_container_style(lv_obj_t* obj) +{ + lv_obj_set_style_border_width(obj, 0, 0); + lv_obj_set_style_radius(obj, 0, 0); + make_non_scrollable(obj); +} } // namespace lv_obj_t* create_root(lv_obj_t* parent) @@ -35,7 +42,9 @@ lv_obj_t* create_root(lv_obj_t* parent) lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_all(root, 0, 0); lv_obj_set_style_pad_row(root, 4, 0); - make_non_scrollable(root); + lv_obj_set_style_bg_color(root, lv_color_white(), 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + apply_base_container_style(root); return root; } @@ -45,7 +54,9 @@ lv_obj_t* create_header(lv_obj_t* root) lv_obj_set_width(header, LV_PCT(100)); lv_obj_set_height(header, ::ui::widgets::kTopBarHeight); lv_obj_set_style_pad_all(header, 0, 0); - make_non_scrollable(header); + lv_obj_set_style_bg_color(header, lv_color_white(), 0); + lv_obj_set_style_bg_opa(header, LV_OPA_COVER, 0); + apply_base_container_style(header); return header; } @@ -57,6 +68,9 @@ lv_obj_t* create_content(lv_obj_t* root) lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_all(content, 8, 0); lv_obj_set_style_pad_row(content, 6, 0); + lv_obj_set_style_bg_color(content, lv_color_white(), 0); + lv_obj_set_style_bg_opa(content, LV_OPA_COVER, 0); + apply_base_container_style(content); return content; } @@ -78,4 +92,3 @@ lv_obj_t* create_list(lv_obj_t* content) } // namespace layout } // namespace ui } // namespace tracker - diff --git a/src/ui/ui_chat.cpp b/src/ui/ui_chat.cpp index 024e7fe9..2665d672 100644 --- a/src/ui/ui_chat.cpp +++ b/src/ui/ui_chat.cpp @@ -20,14 +20,7 @@ static void back_event_handler(lv_event_t *e) { lv_obj_t *obj = (lv_obj_t *)lv_event_get_target(e); if (obj == chat_container || lv_obj_get_parent(obj) == chat_container) { - // Return to main menu - if (chat_container) { - lv_obj_clean(chat_container); - lv_obj_del(chat_container); - chat_container = NULL; - } - ui_controller.reset(); - menu_show(); + ui_request_exit_to_menu(); } } @@ -87,13 +80,16 @@ void ui_chat_exit(lv_obj_t *parent) Serial.printf("[UI Chat] exit container child count=%u\n", (unsigned)lv_obj_get_child_cnt(chat_container)); } + + // Destroy controller first so child screens can clean up safely. + ui_controller.reset(); + if (chat_container && !lv_obj_is_valid(chat_container)) { chat_container = NULL; } if (chat_container) { - lv_obj_clean(chat_container); lv_obj_del(chat_container); chat_container = NULL; } - ui_controller.reset(); + } diff --git a/src/ui/ui_common.cpp b/src/ui/ui_common.cpp index 36290e2b..c3348e54 100644 --- a/src/ui/ui_common.cpp +++ b/src/ui/ui_common.cpp @@ -24,6 +24,10 @@ constexpr const char* kTimezoneKey = "timezone_offset"; static bool s_tz_loaded = false; static int s_tz_offset_min = 0; +AppScreen* s_active_app = nullptr; +AppScreen* s_pending_exit = nullptr; +lv_timer_t* s_exit_timer = nullptr; +constexpr uint32_t kExitDelayMs = 120; } // namespace void set_default_group(lv_group_t *group) @@ -54,6 +58,84 @@ void menu_show() lv_tileview_set_tile_by_index(main_screen, 0, 0, LV_ANIM_ON); } +AppScreen* ui_get_active_app() +{ + return s_active_app; +} + +void ui_clear_active_app() +{ + s_active_app = nullptr; +} + +void ui_switch_to_app(AppScreen* app, lv_obj_t* parent) +{ + if (s_pending_exit == app) { + s_pending_exit = nullptr; + } + if (s_active_app && s_active_app != app) { + s_active_app->exit(parent); + } + if (app) { + app->enter(parent); + } + s_active_app = app; +} + +void ui_exit_active_app(lv_obj_t* parent) +{ + if (s_active_app) { + s_active_app->exit(parent); + } + s_active_app = nullptr; +} + +namespace { +void exit_to_menu_timer_cb(lv_timer_t* timer) +{ + auto* app = static_cast(timer ? timer->user_data : nullptr); + if (timer) { + lv_timer_del(timer); + } + s_exit_timer = nullptr; + if (!app || s_pending_exit != app) { + return; + } + s_pending_exit = nullptr; + if (main_screen == nullptr) { + app->exit(nullptr); + return; + } + lv_obj_t* parent = lv_obj_get_child(main_screen, 1); + app->exit(parent); + if (menu_g) { + set_default_group(menu_g); + lv_group_set_editing(menu_g, false); + } +} +} // namespace + +void ui_request_exit_to_menu() +{ + AppScreen* app = s_active_app; + menu_show(); + if (app == nullptr) { + return; + } + if (s_pending_exit == app) { + return; + } + s_pending_exit = app; + if (s_exit_timer) { + lv_timer_del(s_exit_timer); + s_exit_timer = nullptr; + } + s_exit_timer = lv_timer_create(exit_to_menu_timer_cb, kExitDelayMs, app); + if (s_exit_timer) { + lv_timer_set_repeat_count(s_exit_timer, 1); + } +} + lv_obj_t *create_menu(lv_obj_t *parent, lv_event_cb_t event_cb) { lv_obj_t *menu = lv_menu_create(parent); diff --git a/src/ui/ui_common.h b/src/ui/ui_common.h index d4dd6a76..19160606 100644 --- a/src/ui/ui_common.h +++ b/src/ui/ui_common.h @@ -7,6 +7,7 @@ #define UI_COMMON_H #include "lvgl.h" +#include "app_screen.h" #include "widgets/top_bar.h" #include @@ -17,6 +18,10 @@ extern lv_group_t *app_g; void menu_show(); void ui_clear_active_app(); +AppScreen* ui_get_active_app(); +void ui_switch_to_app(AppScreen* app, lv_obj_t* parent); +void ui_exit_active_app(lv_obj_t* parent); +void ui_request_exit_to_menu(); void set_default_group(lv_group_t *group); // Menu creation helper (simplified version of factory's create_menu) diff --git a/src/ui/ui_contacts.cpp b/src/ui/ui_contacts.cpp index fe79482f..ad48d4e8 100644 --- a/src/ui/ui_contacts.cpp +++ b/src/ui/ui_contacts.cpp @@ -27,8 +27,22 @@ using namespace contacts::ui; static void contacts_top_bar_back(void* /*user_data*/) { - ui_contacts_exit(nullptr); - menu_show(); + if (g_contacts_state.exiting) { + return; + } + g_contacts_state.exiting = true; + if (g_contacts_state.refresh_timer != nullptr) { + lv_timer_del(g_contacts_state.refresh_timer); + g_contacts_state.refresh_timer = nullptr; + } + if (g_contacts_state.conversation_timer != nullptr) { + lv_timer_del(g_contacts_state.conversation_timer); + g_contacts_state.conversation_timer = nullptr; + } + if (g_contacts_state.root) { + lv_obj_add_flag(g_contacts_state.root, LV_OBJ_FLAG_HIDDEN); + } + ui_request_exit_to_menu(); } void ui_contacts_enter(lv_obj_t* parent) @@ -43,6 +57,7 @@ void ui_contacts_enter(lv_obj_t* parent) // Bind services for Contacts screen usage (avoid heavy includes in components) app::AppContext& app_ctx = app::AppContext::getInstance(); + g_contacts_state.exiting = false; g_contacts_state.contact_service = &app_ctx.getContactService(); g_contacts_state.chat_service = &app_ctx.getChatService(); @@ -65,6 +80,12 @@ void ui_contacts_enter(lv_obj_t* parent) create_filter_panel(content); create_list_panel(content); create_action_panel(content); + + // Reset mode/focus state on every enter + g_contacts_state.current_mode = ContactsMode::Contacts; + g_contacts_state.last_action_mode = ContactsMode::Contacts; + g_contacts_state.current_page = 0; + g_contacts_state.selected_index = -1; // Initialize input handling init_contacts_input(); @@ -73,14 +94,6 @@ void ui_contacts_enter(lv_obj_t* parent) refresh_contacts_data(); refresh_ui(); - // Create refresh timer (update every 5 seconds) - g_contacts_state.refresh_timer = lv_timer_create([](lv_timer_t* timer) { - (void)timer; - refresh_contacts_data(); - refresh_ui(); - }, 5000, nullptr); - lv_timer_set_repeat_count(g_contacts_state.refresh_timer, -1); - g_contacts_state.initialized = true; CONTACTS_LOG("[Contacts] Contacts page initialized\n"); } @@ -91,9 +104,22 @@ void ui_contacts_exit(lv_obj_t* parent) CONTACTS_LOG("[Contacts] Exiting Contacts page\n"); if (g_contacts_state.compose_screen) { + if (g_contacts_state.compose_ime) { + g_contacts_state.compose_ime->detach(); + delete g_contacts_state.compose_ime; + g_contacts_state.compose_ime = nullptr; + } delete g_contacts_state.compose_screen; g_contacts_state.compose_screen = nullptr; } + if (g_contacts_state.conversation_screen) { + delete g_contacts_state.conversation_screen; + g_contacts_state.conversation_screen = nullptr; + } + if (g_contacts_state.conversation_timer != nullptr) { + lv_timer_del(g_contacts_state.conversation_timer); + g_contacts_state.conversation_timer = nullptr; + } if (g_contacts_state.refresh_timer != nullptr) { lv_timer_del(g_contacts_state.refresh_timer); diff --git a/src/ui/ui_controller.cpp b/src/ui/ui_controller.cpp index 215ab2ff..3ecd655d 100644 --- a/src/ui/ui_controller.cpp +++ b/src/ui/ui_controller.cpp @@ -4,9 +4,14 @@ */ #include "ui_controller.h" +#include #include "../../app/app_context.h" +#include "screens/team/team_ui_store.h" +#include "widgets/system_notification.h" #include "../sys/event_bus.h" #include "ui_common.h" +#include "../gps/gps_service_api.h" +#include #include extern bool isScreenSleeping(); @@ -18,21 +23,134 @@ namespace ui namespace { -void handle_channel_click(chat::ChannelId channel, void* user_data) +constexpr uint8_t kTeamChatChannelRaw = 2; +constexpr chat::ChannelId kTeamChatChannel = + static_cast(kTeamChatChannelRaw); +constexpr uint32_t kTeamComposeMsgIdStart = 1; +uint32_t s_team_msg_id = kTeamComposeMsgIdStart; + +chat::ConversationId teamConversationId() { - auto* controller = static_cast(user_data); - if (controller) - { - controller->onChannelClicked(channel); + return chat::ConversationId(kTeamChatChannel, 0); +} + +bool isTeamConversationId(const chat::ConversationId& conv) +{ + return conv.channel == kTeamChatChannel && conv.peer == 0; +} + +const char* team_command_name(team::proto::TeamCommandType type) +{ + switch (type) { + case team::proto::TeamCommandType::RallyTo: + return "RallyTo"; + case team::proto::TeamCommandType::MoveTo: + return "MoveTo"; + case team::proto::TeamCommandType::Hold: + return "Hold"; + default: + return "Command"; } } -void handle_conversation_action(bool compose, void* user_data) +std::string truncate_text(const std::string& text, size_t max_len) +{ + if (text.size() <= max_len) { + return text; + } + if (max_len <= 3) { + return text.substr(0, max_len); + } + return text.substr(0, max_len - 3) + "..."; +} + +std::string format_team_chat_entry(const team::ui::TeamChatLogEntry& entry) +{ + if (entry.type == team::proto::TeamChatType::Text) { + std::string text(entry.payload.begin(), entry.payload.end()); + return truncate_text(text, 160); + } + if (entry.type == team::proto::TeamChatType::Location) { + team::proto::TeamChatLocation loc; + if (team::proto::decodeTeamChatLocation(entry.payload.data(), + entry.payload.size(), + &loc)) { + double lat = static_cast(loc.lat_e7) / 1e7; + double lon = static_cast(loc.lon_e7) / 1e7; + char buf[128]; + if (!loc.label.empty()) { + snprintf(buf, sizeof(buf), "Location: %s %.5f, %.5f", + loc.label.c_str(), lat, lon); + } else { + snprintf(buf, sizeof(buf), "Location: %.5f, %.5f", lat, lon); + } + return std::string(buf); + } + return "Location"; + } + if (entry.type == team::proto::TeamChatType::Command) { + team::proto::TeamChatCommand cmd; + if (team::proto::decodeTeamChatCommand(entry.payload.data(), + entry.payload.size(), + &cmd)) { + const char* name = team_command_name(cmd.cmd_type); + double lat = static_cast(cmd.lat_e7) / 1e7; + double lon = static_cast(cmd.lon_e7) / 1e7; + char buf[160]; + if (cmd.lat_e7 != 0 || cmd.lon_e7 != 0) { + if (!cmd.note.empty()) { + snprintf(buf, sizeof(buf), "Command: %s %.5f, %.5f %s", + name, lat, lon, cmd.note.c_str()); + } else { + snprintf(buf, sizeof(buf), "Command: %s %.5f, %.5f", + name, lat, lon); + } + } else if (!cmd.note.empty()) { + snprintf(buf, sizeof(buf), "Command: %s %s", name, cmd.note.c_str()); + } else { + snprintf(buf, sizeof(buf), "Command: %s", name); + } + return std::string(buf); + } + return "Command"; + } + return "Message"; +} + +std::string team_title_from_snapshot(const team::ui::TeamUiSnapshot& snap) +{ + if (!snap.team_name.empty()) { + return snap.team_name; + } + return "Team"; +} + +void handle_message_list_action(chat::ui::ChatMessageListScreen::ActionIntent intent, + const chat::ConversationId& conv, + void* user_data) +{ + auto* controller = static_cast(user_data); + if (!controller) + { + return; + } + if (intent == chat::ui::ChatMessageListScreen::ActionIntent::SelectConversation) + { + controller->onChannelClicked(conv); + return; + } + if (intent == chat::ui::ChatMessageListScreen::ActionIntent::Back) + { + controller->exitToMenu(); + } +} + +void handle_conversation_action(chat::ui::ChatConversationScreen::ActionIntent intent, void* user_data) { auto* controller = static_cast(user_data); if (controller) { - controller->handleConversationAction(compose); + controller->handleConversationAction(intent); } } @@ -41,7 +159,7 @@ void handle_compose_back(void* user_data) auto* controller = static_cast(user_data); if (controller) { - controller->handleComposeAction(false); + controller->handleComposeAction(chat::ui::ChatComposeScreen::ActionIntent::Cancel); } } @@ -50,17 +168,7 @@ void handle_compose_action(chat::ui::ChatComposeScreen::ActionIntent intent, voi auto* controller = static_cast(user_data); if (controller) { - controller->handleComposeAction( - intent == chat::ui::ChatComposeScreen::ActionIntent::Send); - } -} - -void handle_back(void* user_data) -{ - auto* controller = static_cast(user_data); - if (controller) - { - controller->exitToMenu(); + controller->handleComposeAction(intent); } } @@ -83,6 +191,8 @@ UiController::UiController(lv_obj_t* parent, chat::ChatService& service) UiController::~UiController() { + stopTeamConversationTimer(); + service_.setModelEnabled(false); channel_list_.reset(); conversation_.reset(); cleanupComposeIme(); @@ -101,7 +211,6 @@ void UiController::cleanupComposeIme() void UiController::init() { switchToChannelList(); - refreshUnreadCounts(); } void UiController::update() @@ -116,12 +225,11 @@ void UiController::update() } } -void UiController::onChannelClicked(chat::ChannelId channel) +void UiController::onChannelClicked(chat::ConversationId conv) { - (void)channel; if (channel_list_) { - handleChannelSelected(channel_list_->getSelectedConversation()); + handleChannelSelected(conv); } } @@ -241,6 +349,8 @@ void UiController::onChatEvent(sys::Event* event) void UiController::switchToChannelList() { state_ = State::ChannelList; + stopTeamConversationTimer(); + team_conv_active_ = false; Serial.printf("[UiController] switchToChannelList: parent=%p active=%p sleeping=%d\n", parent_, lv_screen_active(), isScreenSleeping() ? 1 : 0); if (lv_obj_t* active = lv_screen_active()) { @@ -265,8 +375,7 @@ void UiController::switchToChannelList() if (!channel_list_) { channel_list_.reset(new ChatMessageListScreen(parent_)); - channel_list_->setChannelSelectCallback(handle_channel_click, this); - channel_list_->setBackCallback(handle_back, this); + channel_list_->setActionCallback(handle_message_list_action, this); } service_.setModelEnabled(true); @@ -278,6 +387,8 @@ void UiController::switchToConversation(chat::ConversationId conv) state_ = State::Conversation; current_channel_ = conv.channel; current_conv_ = conv; + team_conv_active_ = isTeamConversation(conv); + stopTeamConversationTimer(); Serial.printf("[UiController] switchToConversation: parent=%p active=%p sleeping=%d conv_peer=%08lX\n", parent_, lv_screen_active(), isScreenSleeping() ? 1 : 0, (unsigned long)conv.peer); @@ -306,11 +417,26 @@ void UiController::switchToConversation(chat::ConversationId conv) conversation_->setActionCallback(handle_conversation_action, this); conversation_->setBackCallback(handle_conversation_back, this); } - // 更新标题(优先使用联系人昵称,否则使用short_name) + + if (team_conv_active_) + { + team::ui::TeamUiSnapshot snap; + std::string title = "Team"; + if (team::ui::team_ui_get_store().load(snap)) + { + title = team_title_from_snapshot(snap); + } + conversation_->setHeaderText(title.c_str(), nullptr); + conversation_->updateBatteryFromBoard(); + refreshTeamConversation(); + startTeamConversationTimer(); + return; + } + + // Update header (prefer contact name, else short_name) std::string title = "Broadcast"; if (conv.peer != 0) { - // Try to get contact name first app::AppContext& app_ctx = app::AppContext::getInstance(); std::string contact_name = app_ctx.getContactService().getContactName(conv.peer); if (!contact_name.empty()) @@ -319,7 +445,6 @@ void UiController::switchToConversation(chat::ConversationId conv) } else { - // Fallback to short_name from conversation meta size_t total = 0; auto convs = service_.getConversations(0, 0, &total); for (const auto& c : convs) @@ -335,7 +460,6 @@ void UiController::switchToConversation(chat::ConversationId conv) conversation_->setHeaderText(title.c_str(), nullptr); conversation_->updateBatteryFromBoard(); - // Load recent messages auto messages = service_.getRecentMessages(conv, 50); conversation_->clearMessages(); for (const auto& msg : messages) @@ -343,8 +467,6 @@ void UiController::switchToConversation(chat::ConversationId conv) conversation_->addMessage(msg); } conversation_->scrollToBottom(); - - // Mark as read service_.markConversationRead(conv); } @@ -353,6 +475,8 @@ void UiController::switchToCompose(chat::ConversationId conv) state_ = State::Compose; current_channel_ = conv.channel; current_conv_ = conv; + team_conv_active_ = isTeamConversation(conv); + stopTeamConversationTimer(); Serial.printf("[UiController] switchToCompose: parent=%p active=%p sleeping=%d conv_peer=%08lX\n", parent_, lv_screen_active(), isScreenSleeping() ? 1 : 0, (unsigned long)conv.peer); @@ -400,11 +524,27 @@ void UiController::switchToCompose(chat::ConversationId conv) lv_group_add_obj(g, compose_ime_->focus_obj()); } } - // 更新 Compose 页头:优先使用联系人昵称,否则使用short_name + std::string title = "Broadcast"; + if (team_conv_active_) + { + team::ui::TeamUiSnapshot snap; + if (team::ui::team_ui_get_store().load(snap)) + { + title = team_title_from_snapshot(snap); + } + else + { + title = "Team"; + } + compose_->setHeaderText(title.c_str(), "RSSI --"); + compose_->setActionLabels("Send", "Cancel"); + compose_->setPositionButton("Position", true); + return; + } + if (conv.peer != 0) { - // Try to get contact name first app::AppContext& app_ctx = app::AppContext::getInstance(); std::string contact_name = app_ctx.getContactService().getContactName(conv.peer); if (!contact_name.empty()) @@ -413,7 +553,6 @@ void UiController::switchToCompose(chat::ConversationId conv) } else { - // Fallback to short_name from conversation meta size_t total = 0; auto convs = service_.getConversations(0, 0, &total); for (const auto& c : convs) @@ -427,12 +566,16 @@ void UiController::switchToCompose(chat::ConversationId conv) } } compose_->setHeaderText(title.c_str(), "RSSI --"); + compose_->setPositionButton(nullptr, false); } void UiController::handleChannelSelected(const chat::ConversationId& conv) { switchToConversation(conv); - service_.switchChannel(conv.channel); + if (!isTeamConversation(conv)) + { + service_.switchChannel(conv.channel); + } } void UiController::handleSendMessage(const std::string& text) @@ -441,6 +584,10 @@ void UiController::handleSendMessage(const std::string& text) { return; } + if (team_conv_active_) + { + return; + } service_.sendText(current_channel_, text, current_conv_.peer); } @@ -469,6 +616,30 @@ void UiController::refreshUnreadCounts() } } + team::ui::TeamUiSnapshot team_snap; + if (team::ui::team_ui_get_store().load(team_snap) && team_snap.has_team_id) + { + chat::ConversationMeta team_conv; + team_conv.id = teamConversationId(); + team_conv.name = team_title_from_snapshot(team_snap); + team_conv.preview.clear(); + team_conv.last_timestamp = 0; + team_conv.unread = 0; + + std::vector entries; + if (team::ui::team_ui_chatlog_load_recent(team_snap.team_id, 1, entries)) + { + const auto& entry = entries.back(); + team_conv.preview = format_team_chat_entry(entry); + team_conv.last_timestamp = entry.ts; + } + if (team_conv.preview.empty()) + { + team_conv.preview = "No messages"; + } + convs.insert(convs.begin(), team_conv); + } + channel_list_->setConversations(convs); channel_list_->setSelectedConversation(current_conv_); @@ -476,45 +647,246 @@ void UiController::refreshUnreadCounts() channel_list_->updateBatteryFromBoard(); } -void UiController::handleConversationAction(bool compose) +bool UiController::isTeamConversation(const chat::ConversationId& conv) const { - (void)compose; - switchToCompose(current_conv_); + return isTeamConversationId(conv); } -void UiController::handleComposeAction(bool send) +void UiController::refreshTeamConversation() +{ + if (!conversation_ || !team_conv_active_) + { + return; + } + team::ui::TeamUiSnapshot snap; + if (!team::ui::team_ui_get_store().load(snap) || !snap.has_team_id) + { + return; + } + conversation_->clearMessages(); + + std::vector entries; + if (team::ui::team_ui_chatlog_load_recent(snap.team_id, 50, entries)) + { + for (const auto& entry : entries) + { + chat::ChatMessage msg; + msg.channel = chat::ChannelId::PRIMARY; + msg.peer = 0; + msg.from = entry.incoming ? entry.peer_id : 0; + msg.timestamp = entry.ts; + msg.text = format_team_chat_entry(entry); + msg.status = entry.incoming ? chat::MessageStatus::Incoming : chat::MessageStatus::Sent; + conversation_->addMessage(msg); + } + } + conversation_->scrollToBottom(); +} + +void UiController::startTeamConversationTimer() +{ + if (team_conv_timer_) + { + lv_timer_resume(team_conv_timer_); + return; + } + team_conv_timer_ = lv_timer_create([](lv_timer_t* timer) { + auto* controller = static_cast(timer->user_data); + if (controller) + { + controller->refreshTeamConversation(); + } + }, 1000, this); + if (team_conv_timer_) + { + lv_timer_set_repeat_count(team_conv_timer_, -1); + } +} + +void UiController::stopTeamConversationTimer() +{ + if (!team_conv_timer_) + { + return; + } + lv_timer_del(team_conv_timer_); + team_conv_timer_ = nullptr; +} + +void UiController::handleConversationAction(ChatConversationScreen::ActionIntent intent) +{ + if (intent == ChatConversationScreen::ActionIntent::Reply) { + switchToCompose(current_conv_); + } +} + +void UiController::handleComposeAction(ChatComposeScreen::ActionIntent intent) { if (!compose_) { return; } - if (!send) + if (intent == ChatComposeScreen::ActionIntent::Cancel) { switchToConversation(current_conv_); return; } - std::string text = compose_->getText(); - if (!text.empty()) + + if (team_conv_active_) { - handleSendMessage(text); + team::ui::TeamUiSnapshot snap; + if (!team::ui::team_ui_get_store().load(snap) || !snap.has_team_id) + { + ::ui::SystemNotification::show("Team chat send failed", 2000); + switchToConversation(current_conv_); + return; + } + app::AppContext& app_ctx = app::AppContext::getInstance(); + team::TeamController* controller = app_ctx.getTeamController(); + if (!controller) + { + ::ui::SystemNotification::show("Team chat send failed", 2000); + switchToConversation(current_conv_); + return; + } + if (!snap.has_team_psk) + { + ::ui::SystemNotification::show("Team keys not ready", 2000); + switchToConversation(current_conv_); + return; + } + if (!controller->setKeysFromPsk(snap.team_id, + snap.security_round, + snap.team_psk.data(), + snap.team_psk.size())) + { + ::ui::SystemNotification::show("Team keys not ready", 2000); + switchToConversation(current_conv_); + return; + } + + uint32_t ts = static_cast(time(nullptr)); + if (ts < 1577836800U) + { + ts = static_cast(millis() / 1000); + } + + if (intent == ChatComposeScreen::ActionIntent::Position) + { + std::string label = compose_->getText(); + gps::GpsState gps_state = gps::gps_get_data(); + if (!gps_state.valid) + { + ::ui::SystemNotification::show("No GPS fix", 2000); + return; + } + + team::proto::TeamChatLocation loc; + loc.lat_e7 = static_cast(gps_state.lat * 1e7); + loc.lon_e7 = static_cast(gps_state.lng * 1e7); + if (gps_state.has_alt) + { + double alt = gps_state.alt_m; + if (alt > 32767.0) alt = 32767.0; + if (alt < -32768.0) alt = -32768.0; + loc.alt_m = static_cast(lround(alt)); + } + loc.ts = ts; + if (!label.empty()) + { + loc.label = label; + } + + std::vector payload; + if (!team::proto::encodeTeamChatLocation(loc, payload)) + { + ::ui::SystemNotification::show("Team location encode failed", 2000); + switchToConversation(current_conv_); + return; + } + + team::proto::TeamChatMessage msg; + msg.header.type = team::proto::TeamChatType::Location; + msg.header.ts = ts; + msg.header.msg_id = s_team_msg_id++; + if (s_team_msg_id == 0) + { + s_team_msg_id = kTeamComposeMsgIdStart; + } + msg.payload = payload; + + bool ok = controller->onChat(msg, chat::ChannelId::PRIMARY); + if (ok) + { + team::ui::team_ui_chatlog_append_structured(snap.team_id, + 0, + false, + ts, + team::proto::TeamChatType::Location, + payload); + } + else + { + ::ui::SystemNotification::show("Team chat send failed", 2000); + } + switchToConversation(current_conv_); + return; + } + + std::string text = compose_->getText(); + if (text.empty()) + { + switchToConversation(current_conv_); + return; + } + team::proto::TeamChatMessage msg; + msg.header.type = team::proto::TeamChatType::Text; + msg.header.ts = ts; + msg.header.msg_id = s_team_msg_id++; + if (s_team_msg_id == 0) + { + s_team_msg_id = kTeamComposeMsgIdStart; + } + msg.payload.assign(text.begin(), text.end()); + + bool ok = controller->onChat(msg, chat::ChannelId::PRIMARY); + if (ok) + { + team::ui::team_ui_chatlog_append_structured(snap.team_id, + 0, + false, + ts, + team::proto::TeamChatType::Text, + msg.payload); + } + else + { + ::ui::SystemNotification::show("Team chat send failed", 2000); + } + switchToConversation(current_conv_); + return; + } + + if (intent == ChatComposeScreen::ActionIntent::Send) + { + std::string text = compose_->getText(); + if (!text.empty()) + { + handleSendMessage(text); + } } switchToConversation(current_conv_); } void UiController::exitToMenu() { - // Clean up UI objects - service_.setModelEnabled(false); - cleanupComposeIme(); - compose_.reset(); - conversation_.reset(); - channel_list_.reset(); - if (parent_) - { - lv_obj_del(parent_); - parent_ = nullptr; + if (exiting_) { + return; } - menu_show(); + exiting_ = true; + stopTeamConversationTimer(); + team_conv_active_ = false; + ui_request_exit_to_menu(); } } // namespace ui diff --git a/src/ui/ui_controller.h b/src/ui/ui_controller.h index 314233a1..da2184a8 100644 --- a/src/ui/ui_controller.h +++ b/src/ui/ui_controller.h @@ -61,12 +61,12 @@ public: /** * @brief Handle conversation action (reply/compose) */ - void handleConversationAction(bool compose); + void handleConversationAction(ChatConversationScreen::ActionIntent intent); /** * @brief Handle compose action (send/cancel) */ - void handleComposeAction(bool send); + void handleComposeAction(ChatComposeScreen::ActionIntent intent); /** * @brief Exit chat UI to main menu @@ -88,9 +88,9 @@ public: } /** - * @brief Handle channel click from UI + * @brief Handle conversation selection from UI */ - void onChannelClicked(chat::ChannelId channel); + void onChannelClicked(chat::ConversationId conv); private: lv_obj_t* parent_; @@ -104,7 +104,10 @@ private: chat::ChannelId current_channel_; chat::ConversationId current_conv_; - + bool team_conv_active_ = false; + lv_timer_t* team_conv_timer_ = nullptr; + bool exiting_ = false; + void switchToChannelList(); void switchToConversation(chat::ConversationId conv); void switchToCompose(chat::ConversationId conv); @@ -112,6 +115,10 @@ private: void handleSendMessage(const std::string& text); void refreshUnreadCounts(); void cleanupComposeIme(); + bool isTeamConversation(const chat::ConversationId& conv) const; + void refreshTeamConversation(); + void startTeamConversationTimer(); + void stopTeamConversationTimer(); }; } // namespace ui diff --git a/src/ui/ui_gps.cpp b/src/ui/ui_gps.cpp index e4852fe0..01eb4a0c 100644 --- a/src/ui/ui_gps.cpp +++ b/src/ui/ui_gps.cpp @@ -33,13 +33,6 @@ extern void updateUserActivity(); void ui_gps_exit(lv_obj_t* parent); -static void gps_exit_async(void* /*user_data*/) -{ - GPS_LOG("[GPS][EXIT] gps_exit_async: ui_gps_exit -> menu_show\n"); - ui_gps_exit(nullptr); - menu_show(); -} - static void gps_top_bar_back(void* /*user_data*/) { GPS_LOG("[GPS][BACK] gps_top_bar_back: exiting=%d alive=%d root=%p\n", @@ -50,7 +43,7 @@ static void gps_top_bar_back(void* /*user_data*/) } g_gps_state.exiting = true; GPS_LOG("[GPS][BACK] gps_top_bar_back: scheduling async exit\n"); - lv_async_call(gps_exit_async, nullptr); + ui_request_exit_to_menu(); } GPSPageState g_gps_state; @@ -67,6 +60,7 @@ void assign_layout_widgets(const gps::ui::layout::Widgets& w) g_gps_state.map = w.map; g_gps_state.resolution_label = w.resolution_label; g_gps_state.panel = w.panel; + g_gps_state.member_panel = w.member_panel; g_gps_state.zoom = w.zoom_btn; g_gps_state.pos = w.pos_btn; g_gps_state.pan_h = w.pan_h_btn; @@ -192,6 +186,9 @@ static void gps_update_timer_cb(lv_timer_t* timer) update_map_tiles(false); } + refresh_member_panel(false); + refresh_team_markers_from_posring(); + if (g_gps_state.zoom_modal.is_open()) { tick_gps_update(false); return; @@ -289,6 +286,8 @@ void ui_gps_enter(lv_obj_t* parent) hide_pan_h_indicator(); hide_pan_v_indicator(); + refresh_member_panel(true); + if (app_g != NULL) { lv_group_set_editing(app_g, false); } @@ -355,6 +354,9 @@ void ui_gps_exit(lv_obj_t* parent) remove_if(g_gps_state.tracker_btn); remove_if(g_gps_state.pan_h_indicator); remove_if(g_gps_state.pan_v_indicator); + for (auto* btn : g_gps_state.member_btns) { + remove_if(btn); + } GPS_LOG("[GPS][EXIT] removed objs from group\n"); } diff --git a/src/ui/ui_team.cpp b/src/ui/ui_team.cpp index 8fe3ead1..61783edc 100644 --- a/src/ui/ui_team.cpp +++ b/src/ui/ui_team.cpp @@ -8,6 +8,7 @@ #include "screens/team/team_state.h" #include "ui_common.h" #include "../app/app_context.h" +#include "../sys/event_bus.h" namespace team { @@ -33,3 +34,8 @@ void ui_team_exit(lv_obj_t* parent) } team_page_destroy(); } + +bool ui_team_handle_event(sys::Event* event) +{ + return team_page_handle_event(event); +} diff --git a/src/ui/ui_team.h b/src/ui/ui_team.h index 6351e9e8..02d4e301 100644 --- a/src/ui/ui_team.h +++ b/src/ui/ui_team.h @@ -18,3 +18,15 @@ void ui_team_enter(lv_obj_t* parent); * @param parent Parent object (can be nullptr) */ void ui_team_exit(lv_obj_t* parent); + +namespace sys +{ +struct Event; +} + +/** + * @brief Handle team-related events (updates UI state, may refresh screen) + * @param event Event pointer (ownership stays with caller) + * @return true if handled + */ +bool ui_team_handle_event(sys::Event* event); diff --git a/src/ui/ui_usb.cpp b/src/ui/ui_usb.cpp index 2cea622e..69583226 100644 --- a/src/ui/ui_usb.cpp +++ b/src/ui/ui_usb.cpp @@ -12,6 +12,7 @@ #include "ui_usb.h" #include "ui_common.h" +#include "widgets/top_bar.h" #include "board/BoardBase.h" #include "../gps/gps_service_api.h" #include @@ -28,13 +29,22 @@ extern void enableScreenSleep(); // GPS task handle is now accessed via GpsService static lv_obj_t *status_label = NULL; -static lv_obj_t *menu = NULL; +static lv_obj_t *root = NULL; +static lv_obj_t *content = NULL; +static lv_obj_t *loading_overlay = NULL; +static lv_obj_t *loading_box = NULL; +static lv_timer_t *exit_timer = NULL; +static ui::widgets::TopBar top_bar; static USBMSC msc; static bool should_stop = false; static bool usb_mode_active = false; // Track if USB mode is active +static bool usb_exit_started = false; +static bool usb_stopped = false; // Forward declarations -static void back_event_handler(lv_event_t *e); +static void back_event_handler(void* user_data); +static void show_loading(const char *message); +static void stop_usb_async_cb(lv_timer_t* timer); static void update_status_message(const char *message); static int32_t usbReadCallback(uint32_t lba, uint32_t offset, void *buffer, uint32_t bufsize); static int32_t usbWriteCallback(uint32_t lba, uint32_t offset, uint8_t *buffer, uint32_t bufsize); @@ -133,6 +143,66 @@ static void update_status_message(const char *message) Serial.printf("[USB] %s\n", message); } +static void show_loading(const char *message) +{ + lv_obj_t* top_layer = lv_layer_top(); + if (!top_layer) { + return; + } + + if (loading_overlay) { + lv_obj_del(loading_overlay); + loading_overlay = NULL; + loading_box = NULL; + } + + loading_overlay = lv_obj_create(top_layer); + lv_obj_set_size(loading_overlay, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_bg_color(loading_overlay, lv_color_black(), LV_PART_MAIN); + lv_obj_set_style_bg_opa(loading_overlay, LV_OPA_70, LV_PART_MAIN); + lv_obj_set_style_border_width(loading_overlay, 0, LV_PART_MAIN); + lv_obj_clear_flag(loading_overlay, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(loading_overlay, LV_OBJ_FLAG_CLICKABLE); + + loading_box = lv_obj_create(loading_overlay); + lv_obj_set_size(loading_box, 160, 80); + lv_obj_center(loading_box); + lv_obj_set_style_bg_color(loading_box, lv_color_black(), LV_PART_MAIN); + lv_obj_set_style_bg_opa(loading_box, LV_OPA_90, LV_PART_MAIN); + lv_obj_set_style_border_width(loading_box, 2, LV_PART_MAIN); + lv_obj_set_style_border_color(loading_box, lv_color_white(), LV_PART_MAIN); + lv_obj_set_style_radius(loading_box, 8, LV_PART_MAIN); + lv_obj_clear_flag(loading_box, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* label = lv_label_create(loading_box); + lv_label_set_text(label, message ? message : "Loading..."); + lv_obj_set_style_text_color(label, lv_color_white(), LV_PART_MAIN); + lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); + lv_obj_center(label); +} + +static void stop_usb_async_cb(lv_timer_t* timer) +{ + if (timer) { + lv_timer_del(timer); + } + exit_timer = NULL; + + if (!usb_stopped) { + should_stop = true; + usb_mode_active = false; + msc.end(); + ::enableScreenSleep(); + TaskHandle_t gps_task_handle = gps::gps_get_task_handle(); + if (gps_task_handle != NULL) { + vTaskResume(gps_task_handle); + } + usb_stopped = true; + } + + ui_request_exit_to_menu(); +} + /** * @brief Setup USB Mass Storage * @@ -208,33 +278,22 @@ static void setup_usb_msc() /** * @brief Back button event handler */ -static void back_event_handler(lv_event_t *e) +static void back_event_handler(void* /*user_data*/) { - lv_obj_t *obj = (lv_obj_t *)lv_event_get_target(e); - if (lv_menu_back_button_is_root(menu, obj)) { - // Stop USB MSC - should_stop = true; - usb_mode_active = false; - msc.end(); - - // Re-enable screen sleep now that USB mode is exiting - ::enableScreenSleep(); - - // Resume GPS task now that USB mode is exiting - TaskHandle_t gps_task_handle = gps::gps_get_task_handle(); - if (gps_task_handle != NULL) { - vTaskResume(gps_task_handle); - } - - // Clean up UI - if (status_label) { - status_label = NULL; - } - lv_obj_clean(menu); - lv_obj_del(menu); - menu = NULL; - - menu_show(); + if (usb_exit_started) { + return; + } + usb_exit_started = true; + show_loading("Exiting USB..."); + update_status_message("Stopping USB..."); + + if (exit_timer) { + lv_timer_del(exit_timer); + exit_timer = NULL; + } + exit_timer = lv_timer_create(stop_usb_async_cb, 100, NULL); + if (exit_timer) { + lv_timer_set_repeat_count(exit_timer, 1); } } @@ -243,19 +302,56 @@ static void back_event_handler(lv_event_t *e) */ void ui_usb_enter(lv_obj_t *parent) { + usb_exit_started = false; + usb_stopped = false; + if (exit_timer) { + lv_timer_del(exit_timer); + exit_timer = NULL; + } + + if (root) { + lv_obj_del(root); + root = NULL; + content = NULL; + status_label = NULL; + loading_box = NULL; + if (loading_overlay) { + lv_obj_del(loading_overlay); + loading_overlay = NULL; + } + top_bar = {}; + } + + root = lv_obj_create(parent); + lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_color(root, lv_color_white(), 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(root, 0, 0); + lv_obj_set_style_pad_all(root, 0, 0); + lv_obj_clear_flag(root, LV_OBJ_FLAG_SCROLLABLE); + + ::ui::widgets::top_bar_init(top_bar, root); + ::ui::widgets::top_bar_set_title(top_bar, "USB Mass Storage"); + ::ui::widgets::top_bar_set_back_callback(top_bar, back_event_handler, nullptr); + ui_update_top_bar_battery(top_bar); + + content = lv_obj_create(root); + lv_obj_set_size(content, LV_PCT(100), 0); + lv_obj_set_flex_grow(content, 1); + lv_obj_set_style_bg_opa(content, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(content, 0, 0); + lv_obj_set_style_pad_all(content, 0, 0); + lv_obj_clear_flag(content, LV_OBJ_FLAG_SCROLLABLE); + // Check if SD card is ready if (!board.isCardReady()) { // Show error message - menu = create_menu(parent, back_event_handler); - lv_obj_t *main_page = lv_menu_page_create(menu, "USB Mass Storage"); - - lv_obj_t *error_label = lv_label_create(main_page); + lv_obj_t *error_label = lv_label_create(content); lv_label_set_text(error_label, "SD Card Not Found\nPlease insert SD card"); lv_obj_center(error_label); lv_obj_set_style_text_font(error_label, &lv_font_montserrat_18, LV_PART_MAIN); lv_obj_set_style_text_color(error_label, lv_color_hex(0xFF0000), LV_PART_MAIN); - - lv_menu_set_page(menu, main_page); return; } @@ -271,27 +367,21 @@ void ui_usb_enter(lv_obj_t *parent) vTaskSuspend(gps_task_handle); } - // Create menu - menu = create_menu(parent, back_event_handler); - lv_obj_t *main_page = lv_menu_page_create(menu, "USB Mass Storage"); - // Create status label - status_label = lv_label_create(main_page); + status_label = lv_label_create(content); lv_label_set_text(status_label, "Initializing..."); lv_obj_center(status_label); lv_obj_set_style_text_font(status_label, &lv_font_montserrat_18, LV_PART_MAIN); - lv_obj_set_style_text_color(status_label, lv_color_white(), LV_PART_MAIN); + lv_obj_set_style_text_color(status_label, lv_color_hex(0x202020), LV_PART_MAIN); lv_obj_set_style_text_align(status_label, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); // Create info label - lv_obj_t *info_label = lv_label_create(main_page); + lv_obj_t *info_label = lv_label_create(content); lv_label_set_text(info_label, "Press Back to exit USB mode"); lv_obj_align(info_label, LV_ALIGN_BOTTOM_MID, 0, -20); lv_obj_set_style_text_font(info_label, &lv_font_montserrat_14, LV_PART_MAIN); - lv_obj_set_style_text_color(info_label, lv_color_hex(0xAAAAAA), LV_PART_MAIN); - lv_obj_set_style_text_opa(info_label, LV_OPA_70, LV_PART_MAIN); - - lv_menu_set_page(menu, main_page); + lv_obj_set_style_text_color(info_label, lv_color_hex(0x606060), LV_PART_MAIN); + lv_obj_set_style_text_opa(info_label, LV_OPA_80, LV_PART_MAIN); // Setup USB MSC setup_usb_msc(); @@ -313,31 +403,45 @@ void ui_usb_exit(lv_obj_t *parent) { (void)parent; - // Signal loop to exit - should_stop = true; - usb_mode_active = false; - - if (menu) { + if (exit_timer) { + lv_timer_del(exit_timer); + exit_timer = NULL; + } + + if (!usb_stopped) { + // Signal loop to exit + should_stop = true; + usb_mode_active = false; + // Stop USB MSC (similar to Launcher's destructor) msc.end(); - + // Re-enable screen sleep now that USB mode is exiting ::enableScreenSleep(); - + // Resume GPS task now that USB mode is exiting TaskHandle_t gps_task_handle = gps::gps_get_task_handle(); if (gps_task_handle != NULL) { vTaskResume(gps_task_handle); } - - // Clean up UI - if (status_label) { - status_label = NULL; - } - lv_obj_clean(menu); - lv_obj_del(menu); - menu = NULL; + usb_stopped = true; } + + // Clean up UI + if (status_label) { + status_label = NULL; + } + if (loading_overlay) { + lv_obj_del(loading_overlay); + loading_overlay = NULL; + loading_box = NULL; + } + if (root) { + lv_obj_del(root); + root = NULL; + } + content = NULL; + top_bar = {}; } /** diff --git a/src/ui/widgets/map/map_tiles.cpp b/src/ui/widgets/map/map_tiles.cpp index 797471af..9f4a5636 100644 --- a/src/ui/widgets/map/map_tiles.cpp +++ b/src/ui/widgets/map/map_tiles.cpp @@ -28,6 +28,8 @@ #define TILE_DECODE_CACHE_SIZE 12 static DecodedTileCache g_tile_decode_cache[TILE_DECODE_CACHE_SIZE]; static bool g_tile_cache_initialized = false; +static uint32_t g_cache_full_until_ms = 0; +static uint32_t g_cache_full_log_ms = 0; /** * Initialize tile decode cache @@ -93,7 +95,12 @@ static DecodedTileCache* get_lru_cache_slot() // If all slots are in use, return NULL to avoid use-after-free if (!found_unused || lru_idx == -1) { - GPS_LOG("[GPS] All cache slots are in use, cannot evict safely\n"); + uint32_t now_ms = millis(); + g_cache_full_until_ms = now_ms + 500; + if (now_ms - g_cache_full_log_ms >= 1000) { + GPS_LOG("[GPS] All cache slots are in use, cannot evict safely\n"); + g_cache_full_log_ms = now_ms; + } return NULL; } @@ -506,6 +513,7 @@ static void load_tile_image(TileContext& ctx, MapTile &tile) lv_obj_set_size(tile.img_obj, TILE_SIZE, TILE_SIZE); lv_obj_set_pos(tile.img_obj, screen_x, screen_y); style_tile_obj(tile.img_obj); + lv_obj_move_background(tile.img_obj); if (cached && cached->img_dsc != NULL) { // Use cached decoded image (no PNG decode needed) @@ -866,6 +874,7 @@ static void layout_loaded_tile_objects(TileContext& ctx) lv_obj_set_size(tile.img_obj, TILE_SIZE, TILE_SIZE); lv_obj_set_pos(tile.img_obj, screen_x, screen_y); style_placeholder_label(tile.img_obj); + lv_obj_move_background(tile.img_obj); // Set text content: z/x/y format char coord_text[32]; @@ -1071,6 +1080,12 @@ void tile_loader_step(TileContext& ctx) if (!ctx.map_container || !ctx.tiles) { return; } + if (g_cache_full_until_ms != 0) { + uint32_t now_ms = millis(); + if ((int32_t)(now_ms - g_cache_full_until_ms) < 0) { + return; + } + } // Find visible unloaded tiles with minimum priority // Look for tiles that are visible but don't have PNG loaded yet diff --git a/variants/lilygo_tlora_pager/envs/tlora_pager.ini b/variants/lilygo_tlora_pager/envs/tlora_pager.ini index 89ac43c2..ea022634 100644 --- a/variants/lilygo_tlora_pager/envs/tlora_pager.ini +++ b/variants/lilygo_tlora_pager/envs/tlora_pager.ini @@ -17,8 +17,8 @@ lib_deps = ${arduino_base.lib_deps} adafruit/Adafruit TCA8418 @ 1.0.2 adafruit/Adafruit BusIO @ 1.17.0 - https://github.com/lewisxhe/ST25R3916-fork.git#0c8e00f49d12881d1cff6d6f0879b90ce7ed4033 - https://github.com/lewisxhe/NFC-RFAL-fork.git#7bde4587ea44c36c178a43d083d7a7345c1e4cfe + https://github.com/stm32duino/ST25R3916.git#2.0.2 + https://github.com/vicliu624/NFC-RFAL.git [env:tlora_pager_sx1280] extends = arduino_base @@ -38,5 +38,5 @@ lib_deps = ${arduino_base.lib_deps} adafruit/Adafruit TCA8418 @ 1.0.2 adafruit/Adafruit BusIO @ 1.17.0 - https://github.com/lewisxhe/ST25R3916-fork.git#0c8e00f49d12881d1cff6d6f0879b90ce7ed4033 - https://github.com/lewisxhe/NFC-RFAL-fork.git#7bde4587ea44c36c178a43d083d7a7345c1e4cfe + https://github.com/stm32duino/ST25R3916.git#2.0.2 + https://github.com/vicliu624/NFC-RFAL.git diff --git a/variants/lilygo_tlora_pager/rfal_custom_config.h b/variants/lilygo_tlora_pager/rfal_custom_config.h new file mode 100644 index 00000000..c3882d63 --- /dev/null +++ b/variants/lilygo_tlora_pager/rfal_custom_config.h @@ -0,0 +1,7 @@ +#pragma once + +#include "rfal_default_config.h" + +/* Enable listen (card emulation) for NFC-A */ +#undef RFAL_SUPPORT_MODE_LISTEN_NFCA +#define RFAL_SUPPORT_MODE_LISTEN_NFCA true diff --git a/variants/lilygo_tlora_pager/st25r3916_custom_config.h b/variants/lilygo_tlora_pager/st25r3916_custom_config.h new file mode 100644 index 00000000..06aaaba4 --- /dev/null +++ b/variants/lilygo_tlora_pager/st25r3916_custom_config.h @@ -0,0 +1,4 @@ +#pragma once + +/* Select ST25R3916 variant used on T-LoRa-Pager */ +#define ST25R3916