diff --git a/apps/linux_sim_shell/CMakeLists.txt b/apps/linux_sim_shell/CMakeLists.txt index a77b044e..0fd56248 100644 --- a/apps/linux_sim_shell/CMakeLists.txt +++ b/apps/linux_sim_shell/CMakeLists.txt @@ -183,6 +183,410 @@ if(BUILD_TESTING) add_test(NAME trailmate_ui_presentation_screen_binding_registry_smoke COMMAND trailmate_ui_presentation_screen_binding_registry_smoke) + add_executable(trailmate_team_presence_snapshot_codec_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_team_presence_and_snapshot_codec.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/team_presence/team_presence_model.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/team_persistence/team_ui_snapshot_codec.cpp") + target_include_directories(trailmate_team_presence_snapshot_codec_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include") + target_compile_features(trailmate_team_presence_snapshot_codec_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_presence_snapshot_codec_smoke + COMMAND trailmate_team_presence_snapshot_codec_smoke) + + add_executable(trailmate_team_mgmt_key_request_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/tests/test_team_mgmt_key_request.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_mgmt.cpp") + target_include_directories(trailmate_team_mgmt_key_request_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_team_mgmt_key_request_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_mgmt_key_request_smoke + COMMAND trailmate_team_mgmt_key_request_smoke) + + add_executable(trailmate_team_app_data_poll_order_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/tests/test_team_app_data_poll_order.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/domain/chat_model.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/usecase/chat_service.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_chat.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_wire.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_waypoint.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_mgmt.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/usecase/team_service.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/src/sys/clock.cpp") + target_include_directories(trailmate_team_app_data_poll_order_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include") + target_compile_features(trailmate_team_app_data_poll_order_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_app_data_poll_order_smoke + COMMAND trailmate_team_app_data_poll_order_smoke) + + add_executable(trailmate_team_page_read_model_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_team_page_read_model.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_read_model.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/team_presence/team_presence_model.cpp") + target_include_directories(trailmate_team_page_read_model_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include") + target_compile_features(trailmate_team_page_read_model_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_page_read_model_smoke + COMMAND trailmate_team_page_read_model_smoke) + + add_executable(trailmate_team_page_state_store_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_team_page_state_store.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_state_store.cpp") + target_include_directories(trailmate_team_page_state_store_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include") + target_compile_features(trailmate_team_page_state_store_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_page_state_store_smoke + COMMAND trailmate_team_page_state_store_smoke) + + add_executable(trailmate_contacts_team_snapshot_source_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_contacts_team_snapshot_source.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/contacts/contacts_team_snapshot_source.cpp") + target_include_directories(trailmate_contacts_team_snapshot_source_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include") + target_compile_features(trailmate_contacts_team_snapshot_source_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_contacts_team_snapshot_source_smoke + COMMAND trailmate_contacts_team_snapshot_source_smoke) + + add_executable(trailmate_team_map_overlay_source_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_team_map_overlay_source.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/presentation_sources/team_map_overlay_source.cpp" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/src/platform/ui/team_ui_store_runtime.cpp" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/src/platform/linux/runtime_paths.cpp") + target_include_directories(trailmate_team_map_overlay_source_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_map_runtime/include" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_presentation/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/include") + target_compile_features(trailmate_team_map_overlay_source_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_map_overlay_source_smoke + COMMAND trailmate_team_map_overlay_source_smoke) + + add_executable(trailmate_team_page_event_reducer_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_team_page_event_reducer.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_event_reducer.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/team_presence/team_presence_model.cpp") + target_include_directories(trailmate_team_page_event_reducer_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_team_page_event_reducer_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_page_event_reducer_smoke + COMMAND trailmate_team_page_event_reducer_smoke) + + add_executable(trailmate_team_page_command_reducer_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_team_page_command_reducer.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_command_reducer.cpp") + target_include_directories(trailmate_team_page_command_reducer_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_team_page_command_reducer_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_page_command_reducer_smoke + COMMAND trailmate_team_page_command_reducer_smoke) + + add_executable(trailmate_team_page_key_event_log_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_team_page_key_event_log.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_key_event_log.cpp") + target_include_directories(trailmate_team_page_key_event_log_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_team_page_key_event_log_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_page_key_event_log_smoke + COMMAND trailmate_team_page_key_event_log_smoke) + + add_executable(trailmate_team_page_runtime_port_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_team_page_runtime_port.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_runtime_port.cpp") + target_include_directories(trailmate_team_page_runtime_port_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_team_page_runtime_port_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_page_runtime_port_smoke + COMMAND trailmate_team_page_runtime_port_smoke) + + add_executable(trailmate_team_page_activity_sink_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_team_page_activity_sink.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_activity_sink.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_chat.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_position.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_track.cpp") + target_include_directories(trailmate_team_page_activity_sink_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_team_page_activity_sink_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_page_activity_sink_smoke + COMMAND trailmate_team_page_activity_sink_smoke) + + add_executable(trailmate_team_page_deferred_dispatch_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_team_page_deferred_dispatch.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_deferred_dispatch.cpp") + target_include_directories(trailmate_team_page_deferred_dispatch_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_team_page_deferred_dispatch_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_page_deferred_dispatch_smoke + COMMAND trailmate_team_page_deferred_dispatch_smoke) + + add_executable(trailmate_team_page_kick_confirm_action_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_team_page_kick_confirm_action.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_kick_confirm_action.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_command_reducer.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_runtime_port.cpp") + target_include_directories(trailmate_team_page_kick_confirm_action_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_team_page_kick_confirm_action_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_page_kick_confirm_action_smoke + COMMAND trailmate_team_page_kick_confirm_action_smoke) + + add_executable(trailmate_team_page_transfer_leader_action_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_team_page_transfer_leader_action.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_transfer_leader_action.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_command_reducer.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_key_event_log.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_runtime_port.cpp") + target_include_directories(trailmate_team_page_transfer_leader_action_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_team_page_transfer_leader_action_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_page_transfer_leader_action_smoke + COMMAND trailmate_team_page_transfer_leader_action_smoke) + + add_executable(trailmate_team_page_create_team_action_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_team_page_create_team_action.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_create_team_action.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_command_reducer.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_key_event_log.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_runtime_port.cpp") + target_include_directories(trailmate_team_page_create_team_action_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_team_page_create_team_action_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_page_create_team_action_smoke + COMMAND trailmate_team_page_create_team_action_smoke) + + add_executable(trailmate_team_page_pairing_command_action_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_team_page_pairing_command_action.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_pairing_command_action.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_command_reducer.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_runtime_port.cpp") + target_include_directories(trailmate_team_page_pairing_command_action_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_team_page_pairing_command_action_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_page_pairing_command_action_smoke + COMMAND trailmate_team_page_pairing_command_action_smoke) + + add_executable(trailmate_team_page_event_effect_sink_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_team_page_event_effect_sink.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_event_effect_sink.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_event_reducer.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_key_event_log.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_runtime_port.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/team_presence/team_presence_model.cpp") + target_include_directories(trailmate_team_page_event_effect_sink_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_team_page_event_effect_sink_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_page_event_effect_sink_smoke + COMMAND trailmate_team_page_event_effect_sink_smoke) + + add_executable(trailmate_team_page_request_keys_action_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_team_page_request_keys_action.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_request_keys_action.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_command_reducer.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_runtime_port.cpp") + target_include_directories(trailmate_team_page_request_keys_action_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_team_page_request_keys_action_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_page_request_keys_action_smoke + COMMAND trailmate_team_page_request_keys_action_smoke) + + add_executable(trailmate_team_page_key_request_action_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_team_page_key_request_action.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_key_request_action.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_runtime_port.cpp") + target_include_directories(trailmate_team_page_key_request_action_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_team_page_key_request_action_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_page_key_request_action_smoke + COMMAND trailmate_team_page_key_request_action_smoke) + + add_executable(trailmate_team_page_flow_controller_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_team_page_flow_controller.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/team/team_page_flow_controller.cpp") + target_include_directories(trailmate_team_page_flow_controller_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include") + target_compile_features(trailmate_team_page_flow_controller_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_page_flow_controller_smoke + COMMAND trailmate_team_page_flow_controller_smoke) + + add_executable(trailmate_team_chat_action_sink_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_team_chat_action_sink.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/presentation_sources/team_chat_action_sink.cpp" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/src/platform/ui/team_ui_store_runtime.cpp" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/src/platform/linux/runtime_paths.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/src/sys/clock.cpp") + target_include_directories(trailmate_team_chat_action_sink_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_presentation/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/include") + target_compile_features(trailmate_team_chat_action_sink_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_chat_action_sink_smoke + COMMAND trailmate_team_chat_action_sink_smoke) + + add_executable(trailmate_team_chat_presentation_source_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_team_chat_presentation_source.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/presentation_sources/team_chat_presentation_source.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/team_presentation/team_rich_payload_projector.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_chat.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_location_marker.cpp" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/src/platform/ui/team_ui_store_runtime.cpp" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/src/platform/linux/runtime_paths.cpp") + target_include_directories(trailmate_team_chat_presentation_source_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_presentation/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/include") + target_compile_features(trailmate_team_chat_presentation_source_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_chat_presentation_source_smoke + COMMAND trailmate_team_chat_presentation_source_smoke) + + add_executable(trailmate_chat_team_workflow_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_chat_team_workflow.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/chat/chat_team_workflow.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_presentation/src/chat/chat_workspace_model.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_location_marker.cpp") + target_include_directories(trailmate_chat_team_workflow_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_presentation/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include") + target_compile_features(trailmate_chat_team_workflow_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_chat_team_workflow_smoke + COMMAND trailmate_chat_team_workflow_smoke) + + add_executable(trailmate_team_action_runtime_sink_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_team_action_runtime_sink.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/team_actions/team_action_runtime_sink.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_chat.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_location_marker.cpp" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/src/platform/ui/team_ui_store_runtime.cpp" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/src/platform/linux/runtime_paths.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/src/sys/clock.cpp") + target_include_directories(trailmate_team_action_runtime_sink_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_presentation/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_team/include" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/include") + target_compile_features(trailmate_team_action_runtime_sink_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_action_runtime_sink_smoke + COMMAND trailmate_team_action_runtime_sink_smoke) + + add_executable(trailmate_team_action_types_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_team_action_types.cpp") + target_include_directories(trailmate_team_action_types_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/include") + target_compile_features(trailmate_team_action_types_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_team_action_types_smoke + COMMAND trailmate_team_action_types_smoke) + foreach(ux_pack_test IN ITEMS screen_registry input_binding_set diff --git a/apps/linux_uconsole_gtk/CMakeLists.txt b/apps/linux_uconsole_gtk/CMakeLists.txt index cb624dfa..d60f856a 100644 --- a/apps/linux_uconsole_gtk/CMakeLists.txt +++ b/apps/linux_uconsole_gtk/CMakeLists.txt @@ -83,6 +83,7 @@ if(TRAIL_MATE_UCONSOLE_BUILD_REAL_GTK_UI AND CMAKE_SYSTEM_NAME STREQUAL "Linux") "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/presentation_sources/legacy_gps_status_source.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/presentation_sources/legacy_map_presentation_source.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/presentation_sources/legacy_map_action_sink.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/team_presence/team_presence_model.cpp" "${TRAIL_MATE_REPO_ROOT}/platform/linux/uconsole/src/uconsole_chat_workspace_model.cpp" "${TRAIL_MATE_REPO_ROOT}/platform/linux/uconsole/src/uconsole_dashboard_model.cpp" "${TRAIL_MATE_REPO_ROOT}/platform/linux/uconsole/src/uconsole_hardware_probe.cpp" diff --git a/cmake/TrailMateLinuxSources.cmake b/cmake/TrailMateLinuxSources.cmake index e52c9419..222919be 100644 --- a/cmake/TrailMateLinuxSources.cmake +++ b/cmake/TrailMateLinuxSources.cmake @@ -329,7 +329,11 @@ set(TRAIL_MATE_LINUX_UI_SHELL_SOURCES "${TRAIL_MATE_UI_KEY_VERIFICATION_RUNTIME_SRC_ROOT}/key_verification_presentation_source.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/presentation_sources/team_chat_presentation_source.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/presentation_sources/team_chat_action_sink.cpp" - "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/team_actions/legacy_team_action_bridge.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/presentation_sources/team_map_overlay_source.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/team_actions/team_action_runtime_sink.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/team_actions/team_runtime_adapters.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/team_persistence/team_ui_snapshot_codec.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/team_presence/team_presence_model.cpp" "${TRAIL_MATE_UI_MAP_RUNTIME_SRC_ROOT}/map_overlay_projection_adapter.cpp" "${TRAIL_MATE_UI_MAP_RUNTIME_SRC_ROOT}/map_overlay_snapshot_source.cpp" "${TRAIL_MATE_UI_MAP_RUNTIME_SRC_ROOT}/map_overlay/map_overlay_projector.cpp" @@ -371,6 +375,7 @@ set(TRAIL_MATE_LINUX_UI_SHELL_SOURCES "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/chat/chat_page_shell.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/chat/chat_protocol_support.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/chat/chat_send_flow.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/chat/chat_team_workflow.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/chat/chat_ui_controller.cpp" "${TRAIL_MATE_UI_PRESENTATION_SRC_ROOT}/menu/menu_model.cpp" "${TRAIL_MATE_UI_PRESENTATION_SRC_ROOT}/screen/screen_binding_registry.cpp" @@ -397,6 +402,7 @@ set(TRAIL_MATE_LINUX_UI_SHELL_SOURCES "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/contacts/contacts_page_runtime.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/contacts/contacts_page_shell.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/contacts/contacts_page_styles.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/contacts/contacts_team_snapshot_source.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/contacts/contacts_state.cpp" # energy sweep "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/energy_sweep/energy_sweep_page_runtime.cpp" @@ -429,13 +435,32 @@ set(TRAIL_MATE_LINUX_UI_SHELL_SOURCES "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/sstv/sstv_page_runtime.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/sstv/sstv_page_shell.cpp" # team + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_activity_adapters.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_activity_sink.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_components.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_command_reducer.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_create_team_action.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_deferred_dispatch.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_deferred_dispatch_adapters.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_event_effect_sink.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_event_reducer.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_flow_controller.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_input.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_key_request_action.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_key_event_log.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_kick_confirm_action.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_layout.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_lvgl_renderer.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_pairing_command_action.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_read_model.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_request_keys_action.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_runtime_adapters.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_runtime_port.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_state_store.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_transfer_leader_action.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_runtime.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_shell.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_page_styles.cpp" - "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/team/team_state.cpp" # tracker "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/tracker/tracker_page_components.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/screens/tracker/tracker_page_input.cpp" diff --git a/docs/audits/CHAT_UI_CONTROLLER_BURNDOWN_AUDIT.md b/docs/audits/CHAT_UI_CONTROLLER_BURNDOWN_AUDIT.md index 467046ab..49c00674 100644 --- a/docs/audits/CHAT_UI_CONTROLLER_BURNDOWN_AUDIT.md +++ b/docs/audits/CHAT_UI_CONTROLLER_BURNDOWN_AUDIT.md @@ -15,7 +15,7 @@ This audit prevents new Chat / Team / key verification ownership from being adde | Textarea input parsing | Compose and verification number inputs are widget concerns | Must not encode runtime payloads or access protocol APIs | | Button callback dispatch | LVGL callbacks still enter the controller | Callback must submit to model/action sink, not own runtime state | | Conversation screen navigation | Existing chat UI state machine remains controller-local | Must not define delivery/key/team ownership | -| Team position picker workflow coordination | Controller starts picker workflow and handles selected/cancel results | Widget lifecycle belongs to `TeamPositionPickerRenderer`; send path submits `TeamActionRequest` | +| Team position picker workflow coordination | Controller starts picker workflow and handles selected/cancel results | Widget lifecycle belongs to `TeamPositionPickerRenderer`; send path delegates through `ChatTeamWorkflow` | | IME attach / detach | Existing LVGL input integration remains widget lifecycle | Does not affect Chat/Team runtime state | ## B. Migrated Runtime Responsibilities @@ -25,7 +25,7 @@ This audit prevents new Chat / Team / key verification ownership from being adde | Delivery read state | `ChatDeliveryReadModel` owned by runtime/composition | Controller must not own or clear the read model | | Delivery event projection | `ChatDeliveryEventProjector` behind `IChatDeliveryEventPort` | Controller may only forward legacy events while event pump is legacy | | Delivery actions | `ChatDeliveryActionService` / `IChatDeliveryActionSink` | Controller must not directly retry/cancel/clear delivery records | -| Team location/command payload encoding | `LegacyTeamActionBridge` / Team action runtime adapter | Controller submits `TeamActionRequest` only | +| Team location/command payload encoding | `ChatTeamWorkflow` / `TeamActionRuntimeSink` / Team action runtime adapter | Controller must not construct `TeamActionRequest` | | Team rich payload display projection | `TeamRichPayloadProjector` / `TeamChatPresentationSource` | Controller consumes `team_chat_model_.snapshot()` rows only | | Key verification state/session | `KeyVerificationModel` plus `LegacyKeyVerificationSource` | Controller selects peer and renders snapshot only | | Key verification submit/trust runtime calls | `LegacyKeyVerificationActionSink` | Controller calls `KeyVerificationModel` actions only | @@ -40,7 +40,7 @@ This audit prevents new Chat / Team / key verification ownership from being adde | `ChatService::processIncoming` | `ChatPageRuntimeEventPump::update()` | App-wide runtime scheduler owns cadence outside page runtime | burned down from controller | | `ChatService::flushStore` | `ChatPageRuntimeEventPump::update()` | App-wide runtime scheduler owns cadence outside page runtime | burned down from controller | | EventBus forwarding | `ChatPageRuntimeFacade` / `ChatPageRuntimeEventPump` | App-wide runtime scheduler owns EventBus routing outside page runtime | burned down from controller | -| Team position picker selection workflow | `onTeamPositionIconSelected(...)` / `onTeamPositionCancel(...)` | Future workflow coordinator or UX pack-specific picker flow owns selection routing | contained | +| Team position picker selection workflow | `ChatTeamWorkflow` plus controller open/close callbacks | Controller only closes picker and requests workflow send | burned down from controller action ownership | | Legacy Team log formatting outside Chat UI | Contacts/GPS/team-page legacy paths | Shared Team presentation projection is reused by those screens | remaining legacy | | Protocol mismatch UX | Reply guard notifications | Protocol support model exposes UI-ready disabled reason | remaining legacy | @@ -55,3 +55,4 @@ This audit prevents new Chat / Team / key verification ownership from being adde - maps Team to DirectPeer or Channel conversation semantics - decodes or formats Team location/command payloads for chat display - creates or stores Team position picker overlay, panel, hint label, group, or icon event contexts +- constructs `TeamActionRequest` or calls `sendTeamAction(...)` directly diff --git a/docs/audits/LEGACY_BURNDOWN_REGISTER.md b/docs/audits/LEGACY_BURNDOWN_REGISTER.md index 4c7de4ca..41a99f15 100644 --- a/docs/audits/LEGACY_BURNDOWN_REGISTER.md +++ b/docs/audits/LEGACY_BURNDOWN_REGISTER.md @@ -18,16 +18,26 @@ A legacy surface may remain temporarily only if it has: | --- | --- | --- | --- | --- | --- | | `LegacyChatDeliveryEventBridge` | `ChatDeliveryEventProjectionAdapter` / `IChatDeliveryEventPort` / `ChatDeliveryEventProjector` | no main runtime callers; deprecated alias headers and legacy alias tests only | remove alias headers after downstream includes of `LegacyChatDeliveryEventBridge` are gone | 9.4 | burned-down to deprecated alias | | `LegacyChatDeliveryActionBridge` | `ChatDeliveryActionPortAdapter` / `IChatDeliveryActionPort` | no main runtime callers; deprecated alias headers and legacy alias tests only | remove alias headers after downstream includes of `LegacyChatDeliveryActionBridge` are gone | 9.4 | burned-down to deprecated alias | -| `LegacyTeamActionBridge` | `TeamActionRequest` / `ITeamActionSink` / Team runtime command port | `ChatUiController::sendTeamLocationWithIcon(...)` submits `LocationMarker` intent; `chat_page_runtime.cpp` owns bridge lifetime | `TeamActionService` replaces the legacy bridge and renderers/controllers only submit `TeamActionRequest` | 7.x | contained | +| `LegacyTeamActionBridge` | `TeamActionRuntimeSink` / `TeamActionRequest` / `ITeamActionSink` / Team runtime command port | none; Chat runtime and Contacts compose own `TeamActionRuntimeSink` instances through composition roots | legacy bridge file/class/test removed and renderers/controllers only submit `TeamActionRequest` | Team legacy burn-down | burned-down | | `LegacyKeyVerificationSource` | `KeyVerificationPresentationSource` / `IKeyVerificationPresentationSource` in `modules/ui_key_verification_runtime` | no main runtime callers; deprecated alias headers and legacy alias tests only | remove alias headers after downstream includes of `LegacyKeyVerificationSource` are gone | 9.5 | burned-down to deprecated alias | | `LegacyKeyVerificationActionSink` | `KeyVerificationActionSink` / `IKeyVerificationActionSink` in `modules/ui_key_verification_runtime` | no main runtime callers; deprecated alias headers and legacy alias tests only | remove alias headers after downstream includes of `LegacyKeyVerificationActionSink` are gone | 9.5 | burned-down to deprecated alias | | `ChatUiController` key verification modal rendering | `KeyVerificationModalRenderer` helper consumes `KeyVerificationSnapshot` | `ChatUiController` opens/closes modal and forwards submit/trust callbacks | Full modal view object owns widget lifecycle and controller keeps only workflow routing | 7.x | burned-down | -| `ChatUiController` Team payload encoding | `LegacyTeamActionBridge` | None expected for send path; controller submits `TeamActionRequest` only | Checker forbids `encodeTeamChatLocation` / `encodeTeamChatCommand` / raw `TeamChatMessage` send encoding in controller | 7.6 | burned-down | +| `ChatUiController` Team payload/action send special case | `ChatTeamWorkflow` / `TeamActionRuntimeSink` | None expected for send path; controller delegates Team text/location actions through workflow | Checker forbids `TeamActionRequest`, `sendTeamAction`, payload encoders, and raw `TeamChatMessage` send encoding in controller | Team legacy burn-down | burned-down | | `ChatUiController` delivery mutation | `ChatDeliveryReadModel` / `ChatDeliveryActionService` | None expected; event pump forwards send-result events to delivery bridge | Checker forbids direct `ChatDeliveryReadModel`, `ChatDeliveryEventProjector`, `ChatDeliveryActionService`, and `LegacyChatDeliveryEventBridge` ownership in controller | 7.6 / 7.7 | burned-down | | `ChatUiController` runtime event pump | `ChatPageRuntimeEventPump` / `ChatPageRuntimeFacade` | None expected; app facade registers runtime facade instead of controller | Checker forbids `ChatUiController::onChatEvent(...)`, `processIncoming()`, `flushStore()`, and key verification source projection calls in controller | 7.7 | burned-down | | `ChatUiController` Team rich payload formatting | `TeamRichPayloadProjector` / `TeamChatPresentationSource` | None in `ChatUiController`; Team chat rows consume projected summaries from `team_chat_model_.snapshot()` | Checker forbids `format_team_chat_entry(...)`, `decodeTeamChatLocation(...)`, `decodeTeamChatCommand(...)`, `TeamChatLocation`, and `TeamChatCommand` in controller | 7.8 | burned-down | | `ChatUiController` Team position picker renderer | `TeamPositionPickerRenderer` | `ChatUiController` calls open/close/updateHint and handles selected/cancel workflow only | UX pack-specific picker replaces shared LVGL renderer or renderer is renamed as official chat picker view | 7.9 | burned-down | -| `MessageRow` Team rich display limitations | `TeamRichPayloadDisplay` / Team row renderer | `TeamChatPresentationSource` projects rich payloads to summary text for current renderer compatibility | Rich Team row/card renderer consumes structured display fields directly | 7.x | contained | +| `TeamPage` create-team command handler | `TeamPageCreateTeamAction` / `TeamPageRuntimePort` / `TeamPageKeyEventLog` | `team_page_components.cpp` adapts generated random bytes, page state, failure notifications, save, and navigation only | Checker requires action seam and forbids create-team runtime/key-event orchestration from returning to controller helpers | Team legacy burn-down | burned-down | +| `TeamPage` pairing command handlers | `TeamPagePairingCommandAction` / `TeamPageRuntimePort` / `TeamPageCommandReducer` | `team_page_components.cpp` passes requested role and translates failure notifications only | Checker requires pairing action seam and forbids role-specific runtime start orchestration in controller helpers | Team legacy burn-down | burned-down | +| `TeamPage` pairing/keydist/status event side effects | `TeamPageEventEffectSink` / runtime/key-log/deferred/notifier seams | `team_page_components.cpp` reduces accepted events and applies returned page/navigation requests | Checker requires event-effect sink and forbids event effect handling from re-owning reducer mutation or LVGL/rendering | Team legacy burn-down | burned-down | +| `TeamPage` request-keys handler | `TeamPageRequestKeysAction` / `TeamPageKeyRequestAction` / Team KeyRequest wire protocol | `team_page_components.cpp` adapts current state and routes outgoing/incoming key requests through action seams | Key refresh request stays outside LVGL handlers and leader-side KeyDist response remains owned by `TeamPageKeyRequestAction` | Team legacy burn-down | burned-down | +| `TeamPage` navigation flow helpers | `TeamPageFlowController` | `team_page_components.cpp` adapts page-local controller context, calls render/transition, and exits to menu | Checker requires flow controller and forbids local nav-stack/pairing-active logic from returning | Team legacy burn-down | burned-down | +| `TeamPage` LVGL render helpers | `TeamPageLvglRenderer` | `team_page_components.cpp` builds renderer input/context/handlers only | Checker forbids page render helper functions, label/button/list helpers, and member chip rendering in the controller | Team legacy burn-down | burned-down | +| `TeamPage` kick-confirm command handler | `TeamPageKickConfirmAction` / `TeamPageRuntimePort` / `TeamPageDeferredDispatchQueue` | `team_page_components.cpp` adapts LVGL/page state, random source, deferred enqueue, notifications, save, and navigation only | Checker forbids Kick/KeyDist construction, direct `reduceKickConfirmed`, local key rotation, and immediate status send orchestration in `handle_kick_confirm` | Team legacy burn-down | burned-down | +| `Hostlink` Team shadow state | `ITeamUiSnapshotStore` / `ui::team_presence` / Team shell event processing | Hostlink still forwards Team wire payloads as `EvAppData`; `EvTeamState` is emitted after Team shell saves canonical snapshot | Checker forbids `s_runtime_team_state`, local member touch/update helpers, and liveness from `member.online` in Hostlink bridge | Team legacy burn-down | burned-down | +| `GPS` Team overlay Team Page state read | `TeamMapOverlaySource` over `ITeamUiSnapshotStore` and Team posring | ESP GPS map overlay still owns page-local marker widgets, but selected-member position data comes from the Team map source seam | Checker forbids `team_state.h`, `g_team_state`, and direct `team_ui_posring_load_latest` reads in GPS/map/dashboard consumers | Team legacy burn-down | burned-down | +| `Contacts` Team Page state read | `ContactsTeamSnapshotSource` over `ITeamUiSnapshotStore` | Contacts composes Team chat/action sinks and reads Team display/id through a Contacts-local projection only | Checker forbids `team_state.h` and `g_team_state` in Contacts page code | Team legacy burn-down | burned-down | +| `Team rich message row structured payload` | `TeamRichPayloadDisplay` / `TeamMessageRichPayload` / Team row renderer | `TeamChatPresentationSource` maps projected rich payloads into structured `MessageRow` fields and renderers consume those fields with text fallback | Checker requires `has_team_rich_payload` / `team_rich_payload` and forbids controller-side Team rich decode/formatting | Team legacy burn-down | burned-down | | `Map tile path/cache legacy runtime` | `MapTileResolver` / `LegacyFilesystemMapTileSource` / map tile cache owner | Platform LVGL map tile runtimes call source/resolver; ESP decoded cache, Linux downloader cache, and uConsole path fields remain contained | Renderer consumes tile refs/source without direct path/cache ownership, and decoded/downloader caches are moved behind stable runtime adapters | 7.10 / 7.x | contained | | `Map tile visible plan in platform renderer` | `MapTileRenderQueue` | Platform map tile runtimes still populate queue from legacy `MapTile` records | Dedicated map runtime computes visible queue and renderer consumes queue rows without mutating plan state | 7.11 / 7.x | contained | | `ESP decoded LVGL tile cache` | `LvglDecodedTileCache` / `IMapTileDecoderCache` | ESP map tile runtime still asks the LVGL cache adapter for decoded handles | Runtime-owned decoder cache is injected into a dedicated map tile renderer | 7.11 / 7.x | contained | @@ -58,6 +68,15 @@ A legacy surface may remain temporarily only if it has: | Runtime event pump in controller | forbidden | | Team rich payload formatting in controller | forbidden | | Team position picker widget refs in controller | forbidden | +| Team Page create-team orchestration in controller | forbidden | +| Team Page pairing command orchestration in controller | forbidden | +| Team Page event-effect ownership in controller | forbidden | +| Team Page flow/navigation ownership in controller | forbidden | +| Team Page LVGL render helper ownership in controller | forbidden | +| Team Page kick-confirm orchestration in controller | forbidden | +| Hostlink Team shadow state | forbidden | +| GPS Team overlay `g_team_state` read | forbidden | +| Contacts Team Page `g_team_state` read | forbidden | | Map tile path policy in viewport/renderer | forbidden | | Map tile render queue boundary | required | | ESP decoded tile cache owner | required | diff --git a/docs/audits/MAP_OVERLAY_ROUTE_TRACKER_OWNERSHIP_AUDIT.md b/docs/audits/MAP_OVERLAY_ROUTE_TRACKER_OWNERSHIP_AUDIT.md index a51a7583..e992ee66 100644 --- a/docs/audits/MAP_OVERLAY_ROUTE_TRACKER_OWNERSHIP_AUDIT.md +++ b/docs/audits/MAP_OVERLAY_ROUTE_TRACKER_OWNERSHIP_AUDIT.md @@ -17,7 +17,7 @@ tile source, or filesystem state directly. | Question | Current owner before 7.12 | 7.12 owner | | --- | --- | --- | | Current GPS marker facts | ESP GPS page/map runtime reads `platform::ui::gps::get_data()`; Linux map workspace reads `LegacyGpsStatusSource` | `LegacyMapOverlaySource` via `IMapOverlayGpsSource` | -| Team member marker facts | ESP GPS page/map runtime reads Team posring/chatlog directly; Linux map workspace had only summary counts | `LegacyMapOverlaySource` via `IMapOverlayTeamSource` | +| Team member marker facts | Team posring reads are contained in `TeamMapOverlaySource`; ESP GPS, Linux GPS, and uConsole dashboard consume that source | `TeamMapOverlaySource` via `IMapOverlayTeamSource` | | Route / tracker / breadcrumb facts | ESP route/tracker overlay code still owns platform-specific drawing state | Explicitly deferred with exit condition | | Measurement state | `LegacyMapPresentationState` / `MapWorkspaceSnapshot::measurement` | Deferred overlay projection until measurement tool runtime is split | | Renderer access to GPS/team | `map_viewport` does not read GPS/team; ESP GPS map page still has contained legacy marker paths | Shared map renderer remains forbidden from reading GPS/team; ESP marker legacy remains contained | @@ -45,9 +45,10 @@ Team member overlay is now expressible as `MapOverlayKind::TeamMember`. - lat/lon - validity -The Linux GPS page runtime wires a legacy team source that reads latest posring -samples at the composition/runtime boundary, then projects them through -`LegacyMapOverlaySource`. +`TeamMapOverlaySource` reads canonical Team snapshot and latest posring samples +at the Team presentation-source seam. Linux GPS runtime wires it into +`MapOverlaySnapshotSource`; ESP GPS and uConsole dashboard consume its fixed +capacity location projection instead of calling posring directly. ## Route / Tracker / Breadcrumb @@ -75,5 +76,6 @@ the compatibility read DTO for active tool and measurement summary. ## Still Contained - ESP route/tracker draw callbacks remain platform-specific contained legacy. -- ESP GPS page marker widgets still draw existing marker objects. +- ESP GPS page marker widgets still draw existing marker objects, but their + selected-member position facts now come from `TeamMapOverlaySource`. - Measurement overlay projection is deferred with an explicit exit condition. diff --git a/docs/audits/PHASE7_12_MAP_OVERLAY_ROUTE_TRACKER_REPORT.md b/docs/audits/PHASE7_12_MAP_OVERLAY_ROUTE_TRACKER_REPORT.md index a25c987e..7bbb8992 100644 --- a/docs/audits/PHASE7_12_MAP_OVERLAY_ROUTE_TRACKER_REPORT.md +++ b/docs/audits/PHASE7_12_MAP_OVERLAY_ROUTE_TRACKER_REPORT.md @@ -8,13 +8,15 @@ | Team member overlay semantic state | Added `MapOverlayProjector::projectTeamMember(...)` | | Renderer-driven runtime reads | Shared map viewport remains free of GPS/team store reads; checker guards the boundary | | Legacy overlay fact gathering | Added `LegacyMapOverlaySource` with `IMapOverlayGpsSource` and `IMapOverlayTeamSource` ports | +| Team location source | Added `TeamMapOverlaySource`; GPS/map/dashboard consumers no longer call Team posring directly | ## Runtime Wiring The Linux GPS page runtime now builds a `MapOverlaySnapshot` through -`LegacyMapOverlaySource` at the runtime/composition boundary. This keeps shared -map rendering on the passive side of the boundary while preserving current -visual behavior. +`MapOverlaySnapshotSource` at the runtime/composition boundary. Team positions +come from `TeamMapOverlaySource`, which is the only UI consumer seam allowed to +read Team posring. This keeps shared map rendering on the passive side of the +boundary while preserving current visual behavior. ## Still Contained diff --git a/docs/audits/PHASE7_6_LEGACY_BURNDOWN_REPORT.md b/docs/audits/PHASE7_6_LEGACY_BURNDOWN_REPORT.md index c6eb2670..b2346fd5 100644 --- a/docs/audits/PHASE7_6_LEGACY_BURNDOWN_REPORT.md +++ b/docs/audits/PHASE7_6_LEGACY_BURNDOWN_REPORT.md @@ -20,11 +20,11 @@ The pass burned down legacy paths already covered by Phase 7 owners and document | Surface | Reason | Exit condition | | --- | --- | --- | -| `LegacyChatDeliveryEventBridge` | EventBus still reaches `ChatUiController` first for chat send result refresh | Runtime event pump forwards `ChatSendResultEvent` directly | -| `LegacyChatDeliveryActionBridge` | No visible retry/cancel/clear-failure message menu exists yet | UI submits `ChatDeliveryActionRequest` directly or bridge is renamed as a formal adapter | -| `LegacyTeamActionBridge` | Team runtime command port still wraps legacy helper APIs | Introduce a non-legacy `TeamActionService` / command port | +| `LegacyChatDeliveryEventBridge` | Closed by later runtime event pump / delivery adapter work | See `LEGACY_BURNDOWN_REGISTER.md` for current status | +| `LegacyChatDeliveryActionBridge` | Closed by later delivery action adapter work | See `LEGACY_BURNDOWN_REGISTER.md` for current status | +| `LegacyTeamActionBridge` | Closed by later Team burn-down; active bridge file/class/test removed | See `LEGACY_BURNDOWN_REGISTER.md` for current owner | | `LegacyKeyVerificationSource` / `LegacyKeyVerificationActionSink` | MeshCore / Meshtastic verification APIs still differ behind adapter | Split or rename protocol-specific adapters | -| `ChatUiController` | Still coordinates legacy LVGL screen, event forwarding, conversation cache, and Team rich text formatting | Future controller thinning moves event pump and rich payload projection out | +| `ChatUiController` Team ownership | Closed by later Team workflow/rich payload burn-down | Controller no longer owns Team payload formatting or Team action send construction | ## Checker Changes @@ -41,6 +41,14 @@ The pass burned down legacy paths already covered by Phase 7 owners and document | Work | Owner direction | | --- | --- | | Event pump extraction | `chat_page_runtime.cpp` or app shell should forward runtime events before controller refresh | -| Team rich payload rendering | Team presentation adapter should project location/command display fields | +| Team rich payload rendering | Closed by `TeamRichPayloadProjector`, `TeamChatPresentationSource`, and `MessageRow::team_rich_payload` | | Key verification modal full view split | A fuller LVGL view object can own modal lifecycle beyond helper functions | | ChatUiController thinning | Conversation cache and store flush should move to runtime/app shell owners | + +## Later Team Closeout + +This Phase 7.6 report is historical. Later Team burn-down passes removed the +active `LegacyTeamActionBridge`, moved Team send workflow through +`TeamActionRuntimeSink`, moved rich Team row data into structured presentation +fields, and closed Team Page command/event/navigation/render seams. Current +Team status is tracked by `LEGACY_BURNDOWN_REGISTER.md`. diff --git a/docs/audits/PHASE7_8_TEAM_RICH_PAYLOAD_BURNDOWN_REPORT.md b/docs/audits/PHASE7_8_TEAM_RICH_PAYLOAD_BURNDOWN_REPORT.md index 07a04368..2023e6ac 100644 --- a/docs/audits/PHASE7_8_TEAM_RICH_PAYLOAD_BURNDOWN_REPORT.md +++ b/docs/audits/PHASE7_8_TEAM_RICH_PAYLOAD_BURNDOWN_REPORT.md @@ -4,10 +4,11 @@ Phase 7.8 moved Team location/command display decoding out of `ChatUiController` and into Team presentation projection. -The first-phase display remains text-compatible with existing `MessageRow` rendering, but the owner is now explicit: +The row remains text-compatible for compact renderers, but `MessageRow` now also +carries structured Team rich payload fields: ```text -TeamChatLogEntry -> TeamRichPayloadProjector -> TeamChatPresentationSource -> MessageRow text +TeamChatLogEntry -> TeamRichPayloadProjector -> TeamChatPresentationSource -> MessageRow::team_rich_payload ``` ## Burned Down @@ -21,14 +22,15 @@ TeamChatLogEntry -> TeamRichPayloadProjector -> TeamChatPresentationSource -> Me | `TeamChatLocation` / `TeamChatCommand` display structs in chat controller | forbidden | | Team chat subtitle/message rich display summary | projected by `TeamRichPayloadProjector` through `TeamChatPresentationSource` | | `TeamRichPayloadProjector` heap formatting temporaries | removed; summary construction uses fixed buffers and bounded copies | +| `MessageRow` summary-only Team rich limitation | removed; rows now carry `has_team_rich_payload` and `team_rich_payload` | ## Still Contained | Surface | Reason | Exit condition | | --- | --- | --- | -| `LegacyTeamActionBridge` | Send-side Team action runtime bridge remains legacy | Introduce or rename a stable Team action service/adapter | +| `LegacyTeamActionBridge` | Closed by later Team burn-down; active bridge file/class/test removed | See `LEGACY_BURNDOWN_REGISTER.md` for current owner | | Team position picker renderer | Picker widget lifecycle is separate from display projection | Resolved by Phase 7.9 with `TeamPositionPickerRenderer` | -| `MessageRow` rich display limitations | Existing renderer only consumes text rows | Future Team row model or renderer supports rich payload display fields | +| Rich Team card styling | Structured row fields exist; visuals can still become richer | UX pack renders Team rich rows with richer card styling | | Other Team rich decode surfaces | Contacts/GPS/team screens have separate legacy display paths | Later phases migrate those screens to shared Team projection | | `TeamChatPresentationSource` vector boundary | Legacy Team store API returns recent logs through `std::vector` | Add a fixed-capacity visitor / bounded iterator API to `TeamUiStore` | @@ -38,6 +40,7 @@ TeamChatLogEntry -> TeamRichPayloadProjector -> TeamChatPresentationSource -> Me - Required `TeamRichPayloadDisplay` and `TeamRichPayloadProjector`. - Required projector smoke test. - Required `TeamChatPresentationSource` to consume `TeamRichPayloadProjector`. +- Required `MessageRow` to carry structured Team rich payload fields. - Forbid Team rich payload decode/format tokens in `ChatUiController`. - Forbid send/action/runtime tokens in `TeamRichPayloadProjector`. - Forbid `std::string`, `std::vector`, `iostream`, `sstream`, and `` in `TeamRichPayloadProjector`. @@ -46,8 +49,17 @@ TeamChatLogEntry -> TeamRichPayloadProjector -> TeamChatPresentationSource -> Me | Work | Direction | | --- | --- | -| Rich Team card UI | Add renderer support for structured `TeamRichPayloadDisplay` fields | +| Rich Team card UI | Improve styling using existing structured row fields | | Team position picker renderer | Resolved by Phase 7.9 without changing Team send ownership | | Contacts/GPS Team payload formatting | Move those legacy display paths to shared Team presentation projection | -| Team action bridge burn-down | Replace or rename `LegacyTeamActionBridge` when runtime service boundary is stable | +| Team action bridge burn-down | Closed by `TeamActionRuntimeSink` / `TeamActionRequest` | | Team store recent-log API | Replace vector-return API with fixed-capacity visitor / bounded iterator for embedded targets | + +## Later Team Closeout + +Subsequent Team burn-down passes removed the active `LegacyTeamActionBridge`, +added structured `MessageRow::team_rich_payload` fields, and moved Team +map/GPS/dashboard position consumption behind `TeamMapOverlaySource`. This +report records the original Phase 7.8 transition; the current Team legacy status +is governed by `LEGACY_BURNDOWN_REGISTER.md` and the Phase 7 runtime ownership +checker. diff --git a/docs/audits/PHASE7_9_TEAM_POSITION_PICKER_BURNDOWN_REPORT.md b/docs/audits/PHASE7_9_TEAM_POSITION_PICKER_BURNDOWN_REPORT.md index 72e4cd7a..d29744c7 100644 --- a/docs/audits/PHASE7_9_TEAM_POSITION_PICKER_BURNDOWN_REPORT.md +++ b/docs/audits/PHASE7_9_TEAM_POSITION_PICKER_BURNDOWN_REPORT.md @@ -25,8 +25,8 @@ This was a renderer / widget lifecycle burn-down. It did not change Team action | Surface | Reason | Exit condition | | --- | --- | --- | -| `sendTeamLocationWithIcon(...)` | Controller still coordinates selected marker workflow | Future Team workflow coordinator or UX pack-specific flow submits `TeamActionRequest` directly | -| `LegacyTeamActionBridge` | Team action send runtime adapter is still legacy | Replace or rename as a stable Team action service/adapter | +| `sendTeamLocationWithIcon(...)` | Closed by later `ChatTeamWorkflow` / Team action sink split | Controller delegates Team action construction and send ownership | +| `LegacyTeamActionBridge` | Closed by later Team burn-down; active bridge file/class/test removed | See `LEGACY_BURNDOWN_REGISTER.md` for current owner | | `TeamPositionPickerRenderer` LVGL specificity | Shared chat UI still uses LVGL widgets | Future UX pack-specific picker replaces the shared LVGL renderer | ## Checker Changes @@ -42,6 +42,13 @@ This was a renderer / widget lifecycle burn-down. It did not change Team action | Work | Direction | | --- | --- | -| Team workflow coordination | Move selection workflow out of `ChatUiController` only if a broader workflow coordinator becomes worthwhile | -| Team action bridge burn-down | Replace or rename `LegacyTeamActionBridge` after Team runtime action service boundary is stable | +| Team workflow coordination | Closed for Team send ownership by `ChatTeamWorkflow` / `TeamActionRuntimeSink` | +| Team action bridge burn-down | Closed by `TeamActionRuntimeSink` / `TeamActionRequest` | | UX Pack picker variant | Allow a target-specific picker renderer to replace the shared LVGL renderer | + +## Later Team Closeout + +Subsequent Team burn-down passes removed the active `LegacyTeamActionBridge` and +moved Chat Team send workflow ownership out of `ChatUiController`. This report +keeps the Phase 7.9 renderer history, but the current Team action status is +tracked in `LEGACY_BURNDOWN_REGISTER.md`. diff --git a/docs/audits/PHASE7_FINAL_LEGACY_SURFACE_MATRIX.md b/docs/audits/PHASE7_FINAL_LEGACY_SURFACE_MATRIX.md index 6b56de3c..369c07c6 100644 --- a/docs/audits/PHASE7_FINAL_LEGACY_SURFACE_MATRIX.md +++ b/docs/audits/PHASE7_FINAL_LEGACY_SURFACE_MATRIX.md @@ -4,17 +4,24 @@ | --- | --- | --- | --- | --- | --- | --- | | Chat delivery send-result bridge | `LegacyChatDeliveryEventBridge` | `ChatDeliveryEventProjector` / `IChatDeliveryEventPort` | contained | EventBus publishes structured delivery events directly | EventBus schema still carries coarse send-result fields | Phase 8 delivery event schema | | Chat delivery action bridge | `LegacyChatDeliveryActionBridge` | `ChatDeliveryActionService` | contained | UI submits `ChatDeliveryActionRequest` to official action sink | Visible message-menu action surface is still compatibility-driven | Phase 8 chat action surface | -| Team action bridge | `LegacyTeamActionBridge` | `TeamActionRequest` / `ITeamActionSink` | contained | `TeamActionService` replaces legacy bridge | Team command runtime port is not yet first-class | Phase 8 Team runtime service | +| Team action bridge | `TeamActionRuntimeSink` | `TeamActionRequest` / `ITeamActionSink` | burned-down | legacy bridge name removed from active code; runtime sink owns Team action sends | Team command runtime port is adapter-backed, not controller-owned | closed in Team legacy burn-down | | Key verification source | `LegacyKeyVerificationSource` | `KeyVerificationModel` / presentation source | contained | Protocol-specific key verification sources are renamed or split | MeshCore/Meshtastic adapters still share compatibility session | Phase 8 key verification adapters | | Key verification sink | `LegacyKeyVerificationActionSink` | `KeyVerificationModel` / action sink | contained | Protocol-specific action sinks replace legacy naming | Existing adapter still hides protocol-specific mesh APIs | Phase 8 key verification adapters | | Chat key verification modal helper | `KeyVerificationModalRenderer` | Dedicated modal view object | burned-down | Full modal object owns widget lifecycle | Controller still coordinates modal workflow | UX Pack / modal runtime pass | -| Team rich message row limitation | `MessageRow` summary text | `TeamRichPayloadDisplay` | contained | Rich Team row/card renderer consumes structured fields | Current message list renderer accepts summary text only | UX Pack Team rows | +| Team rich message row structured payload | `MessageRow` summary fallback | `TeamRichPayloadDisplay` / `TeamMessageRichPayload` | burned-down | `MessageRow` carries structured Team rich payload fields and renderers consume them | Full card styling can still improve UX but no longer blocks ownership | closed in Team legacy burn-down | | Team position picker LVGL renderer | `TeamPositionPickerRenderer` | Shared picker renderer | burned-down | UX pack-specific picker replaces or formalizes renderer | Current renderer is LVGL-specific | UX Pack picker | +| Team Page create/pairing/request command handlers | `team_page_components.cpp` LVGL handlers | `TeamPageCreateTeamAction` / `TeamPagePairingCommandAction` / `TeamPageRequestKeysAction` | burned-down | command actions own create/pairing/request eligibility and runtime orchestration | Page controller still adapts state, notifications, save, and navigation | Team Page controller burn-down | +| Team Page event effects and navigation | `team_page_components.cpp` event handlers/nav helpers | `TeamPageEventEffectSink` / `TeamPageFlowController` | burned-down | event-effect sink and flow controller own effects/nav decisions | Page controller still renders and applies page transition side effects | Team Page controller burn-down | +| Team Page LVGL render helpers | `team_page_components.cpp` render helpers | `TeamPageLvglRenderer` | burned-down | renderer owns page labels/buttons/lists/member detail/kick confirm/kicked out rendering | Controller still composes renderer input/context/handlers | Team Page controller burn-down | +| Team Page kick-confirm command handler | `team_page_components.cpp` LVGL handler | `TeamPageKickConfirmAction` | burned-down | command action owns Kick/KeyDist/key rotation/status orchestration | Page controller still adapts state, notifications, save, and navigation | Team Page controller burn-down | +| Hostlink Team shadow state | `hostlink_bridge_radio.cpp` local runtime Team state | `ITeamUiSnapshotStore` / `ui::team_presence` | burned-down | `EvTeamState` is projected from canonical snapshot after Team shell handles events | Hostlink still owns Hostlink frame enqueue and Team AppData forwarding | Hostlink bridge split | +| GPS Team overlay page-state read | ESP GPS map reads Team Page globals | `TeamMapOverlaySource` over `ITeamUiSnapshotStore` and Team posring | burned-down | ESP GPS map never includes `team_state.h`, reads `g_team_state`, or calls `team_ui_posring_load_latest` directly | GPS marker widgets remain page-local rendering | Phase 8 map overlay renderer | +| Contacts Team Page state read | Contacts page reads Team Page globals | `ContactsTeamSnapshotSource` over `ITeamUiSnapshotStore` | burned-down | Contacts never includes `team_state.h` or reads/writes `g_team_state` | Contacts still owns Contacts page UI workflow and composes Team chat/action sinks | Team consumer closeout | | Map tile source/path lookup | Platform tile runtime | `MapTileResolver` / `LegacyFilesystemMapTileSource` | contained | Dedicated map tile runtime injects source into renderers | Platform tile code still owns loading cadence | Phase 8 map tile runtime | | Map visible tile plan | Platform tile runtime | `MapTileRenderQueue` | contained | Dedicated map runtime computes queue without legacy `MapTile` records | LVGL tile record compatibility still exists | Phase 8 map tile renderer | | ESP decoded LVGL tile cache | `LvglDecodedTileCache` wrapper | `IMapTileDecoderCache` boundary | contained | Decoder cache is injected into a dedicated map tile renderer | LVGL image lifetime is platform-specific | Phase 8 map decoder cache | | Map overlay current position | Legacy GPS source adapter | `LegacyMapOverlaySource` / `MapOverlayProjector` | contained | Renderer consumes `MapOverlaySnapshot` directly for markers | Existing ESP marker widgets are still page-local | Phase 8 map overlay renderer | -| Map overlay team positions | Legacy Team source adapter | `LegacyMapOverlaySource` / `MapOverlayProjector` | contained | Team location source exposes fixed-capacity visitor and renderer consumes overlay rows | Team posring API returns vectors at legacy boundary | Phase 8 Team location source | +| Map overlay team positions | Team map projection source | `TeamMapOverlaySource` / `MapOverlayProjector` | burned-down | GPS/map/dashboard consumers use `TeamMapOverlaySource`; only the Team map source seam reads Team posring | Route/tracker/measurement overlays remain separate deferred items | closed in Team consumer closeout | | Map route/tracker overlay | ESP route/tracker draw callbacks | Deferred overlay presentation source | explicitly deferred | Route/tracker stores expose presentation sources and renderer consumes overlay snapshot rows | Route/tracker stores are still platform/page-specific | Phase 8 route/tracker overlay | | Measurement overlay | `LegacyMapPresentationState` | Deferred overlay projection | explicitly deferred | Measurement state projects as `MeasurementPoint` overlay rows | Measurement tool runtime boundary is not split | Phase 8 measurement tool | | GPS page refresh cadence | `GpsPageRuntimePump` | Runtime scheduler / UI refresh sink | contained | Page-local refresh adapters are replaced by GPS presentation refresh model | ESP GPS page workflow remains large compatibility surface | Phase 8 GPS page runtime | diff --git a/docs/audits/PHASE7_RUNTIME_OWNERSHIP_REGISTER.md b/docs/audits/PHASE7_RUNTIME_OWNERSHIP_REGISTER.md index 8e7ec937..6fe8674e 100644 --- a/docs/audits/PHASE7_RUNTIME_OWNERSHIP_REGISTER.md +++ b/docs/audits/PHASE7_RUNTIME_OWNERSHIP_REGISTER.md @@ -21,11 +21,20 @@ has direct user-visible consequences and a bounded migration path. | Chat retry/cancel/clear failure actions | contained | 7.4 / 7.6 | owned by `ChatDeliveryActionRequest` / `ChatDeliveryActionService`; controller direct action ownership is forbidden | | Map tile/cache ownership | contained | 7.10 | tile path mapping and filesystem availability are owned by `MapTileResolver` / `LegacyFilesystemMapTileSource`; decoded image cache remains contained legacy | | Map tile render queue / decoded cache ownership | contained | 7.11 | visible tile plan is projected into `MapTileRenderQueue`; ESP decoded image cache is wrapped by `LvglDecodedTileCache`; LVGL widget records remain contained legacy | -| Map overlay/route/tracker ownership | contained / explicitly deferred | 7.12 | current GPS and Team overlays project through `MapOverlaySnapshot` / `LegacyMapOverlaySource`; route/tracker overlay sources have exit conditions in final matrix | +| Map overlay/route/tracker ownership | contained / explicitly deferred | 7.12 / Team burn-down | current GPS and Team overlays project through `MapOverlaySnapshot`; Team positions come through `TeamMapOverlaySource`; route/tracker overlay sources have exit conditions in final matrix | | GPS page timers/tasks | contained | 7.13 | refresh cadence is owned by `GpsPageRuntimePump`; LVGL timers are tick hooks only | -| Team location/command action ownership | contained | 7.2 / 7.6 | owned by `TeamActionRequest` / `LegacyTeamActionBridge`; controller send payload encoding is forbidden | -| Team rich payload display ownership | contained | 7.8 | owned by `TeamRichPayloadProjector` / `TeamChatPresentationSource`; controller display decode/format is forbidden | +| Team location/command action ownership | contained | 7.2 / 7.6 / Team burn-down | owned by `ChatTeamWorkflow` / `TeamActionRequest` / `TeamActionRuntimeSink`; controller send payload encoding and action request construction are forbidden | +| Team rich payload display ownership | burned-down | 7.8 / Team burn-down | owned by `TeamRichPayloadProjector` / `TeamChatPresentationSource`; `MessageRow` carries `TeamMessageRichPayload`; controller display decode/format is forbidden | | Team position picker widget lifecycle | burned-down UI surface | 7.9 | owned by `TeamPositionPickerRenderer`; controller only handles selected/cancel workflow | +| Team Page create-team command orchestration | contained | Team burn-down | owned by `TeamPageCreateTeamAction`; page controller adapts state, failure notifications, save, and navigation only | +| Team Page pairing command orchestration | contained | Team burn-down | owned by `TeamPagePairingCommandAction`; page controller adapts role input and failure messages only | +| Team Page pairing/keydist/status event effects | contained | Team burn-down | owned by `TeamPageEventEffectSink`; reducers own state mutation and the sink owns runtime/key-log/deferred/notifier effects | +| Team Page request-keys command | burned-down | Team burn-down | owned by `TeamPageRequestKeysAction` for outgoing `TeamKeyRequest` and `TeamPageKeyRequestAction` for leader-side KeyDist response | +| Team Page navigation flow | contained | Team burn-down | owned by `TeamPageFlowController`; controller renders pages and applies transition side effects only | +| Team Page LVGL rendering | contained | Team burn-down | owned by `TeamPageLvglRenderer`; renderer consumes read-model/page input and callback handlers only | +| Team Page kick-confirm command orchestration | contained | Team burn-down | owned by `TeamPageKickConfirmAction`; page controller adapts state/runtime/random/deferred ports and translates failure effects only | +| Team consumer snapshot/liveness projection | contained | Team burn-down | Hostlink/GPS/map/dashboard consume `ITeamUiSnapshotStore` and `ui::team_presence`; they must not own shadow Team state or infer liveness from roster membership | +| Contacts Team snapshot projection | contained | Team burn-down | owned by `ContactsTeamSnapshotSource`; Contacts must consume canonical `ITeamUiSnapshotStore` and must not read or write Team Page `g_team_state` | | key verification workflow | contained | 7.5 / 7.6 | owned by `KeyVerificationModel` plus legacy source/sink adapters; modal rendering is helper-bounded | ## Phase 7.1 Decision @@ -95,10 +104,11 @@ They are not owned by: Phase 7.2 introduces: +- `ChatTeamWorkflow` - `TeamActionRequest` - `ITeamActionSink` - `ITeamLocationSource` -- `LegacyTeamActionBridge` +- `TeamActionRuntimeSink` - Chat UI submission of location marker intent through the Team action sink Team text remains on the existing `TeamChatActionSink` / @@ -173,7 +183,7 @@ may move those surfaces from contained to burned-down: - `LegacyChatDeliveryEventBridge` - `LegacyChatDeliveryActionBridge` -- `LegacyTeamActionBridge` +- `TeamActionRuntimeSink` - `LegacyKeyVerificationSource` - `LegacyKeyVerificationActionSink` - controller-owned `ChatService::processIncoming` / `flushStore` was removed in Phase 7.7 @@ -224,6 +234,8 @@ Phase 7.8 introduces: - `TeamRichPayloadDisplay` - `TeamRichPayloadProjector` - `TeamChatPresentationSource` consumption of projected rich payload summaries +- `TeamMessageRichPayload` fields on `MessageRow` +- renderer consumption of `has_team_rich_payload` / `team_rich_payload` `ChatUiController` no longer: @@ -232,8 +244,9 @@ Phase 7.8 introduces: - calls `decodeTeamChatCommand(...)` - constructs `TeamChatLocation` / `TeamChatCommand` for display formatting -The current renderer still consumes summary text through `MessageRow`. Rich Team -cards and additional Map overlay visuals are deferred with exit conditions. +`MessageRow::text` remains a fallback summary, but Team location and command +rows now also carry structured kind/title/badge/location/command fields. Full +card styling is UX work; the ownership boundary is no longer blocked on summary-only rows. ## Phase 7.9 Decision @@ -256,9 +269,8 @@ Phase 7.9 introduces: LVGL group, previous group, or icon event contexts. It no longer defines picker LVGL event callbacks or directly creates the picker widget tree. -The controller keeps the selection workflow. `sendTeamLocationWithIcon(...)` -remains only because it submits `TeamActionRequest` through `ITeamActionSink`; -it must not encode Team payloads directly. +The controller keeps only the picker open/close and selected/cancel callbacks. +`ChatTeamWorkflow` owns Team action request construction and failure messages. ## Phase 7.12 Decision @@ -278,9 +290,11 @@ Phase 7.12 introduces: - `IMapOverlayPresentationSource` - `MapOverlayProjector` - `LegacyMapOverlaySource` +- `TeamMapOverlaySource` Current GPS and Team latest location overlays are projected through the overlay -source boundary. Route/tracker and measurement overlays are explicitly deferred +source boundary. Team consumers call `TeamMapOverlaySource` instead of reading +Team posring directly. Route/tracker and measurement overlays are explicitly deferred with removal conditions in `PHASE7_FINAL_LEGACY_SURFACE_MATRIX.md`. ## Phase 7.13 Decision diff --git a/docs/audits/PHASE9_LEGACY_BURNDOWN_REPORT.md b/docs/audits/PHASE9_LEGACY_BURNDOWN_REPORT.md index 86908887..194ebaea 100644 --- a/docs/audits/PHASE9_LEGACY_BURNDOWN_REPORT.md +++ b/docs/audits/PHASE9_LEGACY_BURNDOWN_REPORT.md @@ -51,10 +51,12 @@ forwarding aliases for downstream includes. These code-level legacy adapters remain governed by `docs/audits/LEGACY_BURNDOWN_REGISTER.md`: -- `LegacyTeamActionBridge` - map tile compatibility surfaces - route/tracker overlay fallback surfaces +`LegacyTeamActionBridge` has since been removed from active code by the Team +burn-down and is no longer a remaining Phase 9 target. + ## Next Burn-down Target The next Phase 9 legacy burn-down target should be selected from the remaining diff --git a/docs/audits/TEAM_ACTION_OWNERSHIP_AUDIT.md b/docs/audits/TEAM_ACTION_OWNERSHIP_AUDIT.md index 3d7b4ac7..2c0db6ec 100644 --- a/docs/audits/TEAM_ACTION_OWNERSHIP_AUDIT.md +++ b/docs/audits/TEAM_ACTION_OWNERSHIP_AUDIT.md @@ -70,7 +70,7 @@ Renderer/controller code may collect user input but must not: Team actions are owned by: ```text -TeamActionSink / LegacyTeamActionBridge +TeamActionSink / TeamActionRuntimeSink ``` Team payload encoding is owned by: @@ -88,13 +88,13 @@ TeamChatPresentationSource ## Phase 7.2 Decision Phase 7.2 migrates Team location send behind an explicit Team action sink and -bridge. +runtime sink. The chat UI submits `TeamActionRequest` with marker intent. Current location resolution, Team key setup, payload encoding, Team runtime send, and outgoing -Team log append move behind `LegacyTeamActionBridge` and its runtime ports. +Team log append move behind `TeamActionRuntimeSink` and its runtime ports. -Phase 7.2 defines Team command action types and bridge behavior, but the +Phase 7.2 defines Team command action types and runtime sink behavior, but the current Chat UI command send path remains unsupported until a real command UI surface exists. diff --git a/docs/audits/TEAM_RICH_PAYLOAD_PRESENTATION_AUDIT.md b/docs/audits/TEAM_RICH_PAYLOAD_PRESENTATION_AUDIT.md index 5a0bf527..a11a07fb 100644 --- a/docs/audits/TEAM_RICH_PAYLOAD_PRESENTATION_AUDIT.md +++ b/docs/audits/TEAM_RICH_PAYLOAD_PRESENTATION_AUDIT.md @@ -15,10 +15,10 @@ TeamChatLogEntry / Team payload -> UI-ready Team display row | Question | Answer | Problem | | --- | --- | --- | | Current `format_team_chat_entry(...)` location | Removed from `ChatUiController`; previous duplicate legacy formatter decoded Team log entries in the controller | Controller formatting mixed UI coordination with Team payload decoding | -| Current Team chat presentation formatter | `TeamChatPresentationSource` builds `MessageRow` summaries | Correct owner boundary, but decode/format needed a named projector | +| Current Team chat presentation formatter | `TeamChatPresentationSource` builds `MessageRow` structured rich payload fields plus fallback summaries | Correct owner boundary; decode/format is owned by a named projector | | Current location decode | `TeamRichPayloadProjector` calls `decodeTeamChatLocation(...)` | Decode is now contained in a presentation projector, not the controller | | Current command decode | `TeamRichPayloadProjector` calls `decodeTeamChatCommand(...)` | Decode is now contained in a presentation projector, not the controller | -| Current display text construction | `TeamRichPayloadProjector` fills `TeamRichPayloadDisplay::summary` | UI consumes formatted projection instead of raw payload | +| Current display projection | `TeamRichPayloadProjector` fills `TeamRichPayloadDisplay`; `TeamChatPresentationSource` maps it to `MessageRow::team_rich_payload` | UI consumes formatted projection instead of raw payload | | Current controller behavior | `ChatUiController` consumes `team_chat_model_.snapshot()` rows | Controller does not decode Team location/command display payloads | | Current projector allocation policy | Fixed buffers, bounded copies, and `std::snprintf` | Avoids projector-local `std::string` / `std::vector` heap churn in shared embedded UI code | @@ -54,16 +54,19 @@ Phase 7.8 introduces structured display fields without requiring a rich card UI: | --- | --- | --- | | `kind` | active | identifies text, location, command, or unsupported payload | | `title` | active | short label for future richer rows | -| `summary` | active | first-phase text shown in `MessageRow::text` | +| `summary` | active | fallback text and compact detail | | `badge` | active | lightweight display label | | `location` | active | parsed lat/lon/altitude/icon details | | `command` | active | parsed command kind, coordinate, radius, and priority | -## Temporary Text Projection +## MessageRow Projection -Phase 7.8 still renders Team location and command payloads as summary text inside existing `MessageRow` rows. +Team location and command payloads now carry structured row fields in +`MessageRow::team_rich_payload`. -That is a compatibility projection, not a new Team domain model. +`MessageRow::text` remains populated as fallback text for legacy or compact +renderers. That fallback is a presentation compatibility detail, not a new Team +domain model and not the only render contract. ## Embedded Hardening @@ -82,7 +85,7 @@ Target-local code may use a higher standard when its toolchain and binary budget | Surface | Reason | Exit condition | | --- | --- | --- | -| Rich Team cards | Existing `MessageRow` does not carry a dedicated rich Team card payload | A future Team row/view model exposes rich card fields directly | +| Rich Team card styling | Structured fields exist, but richer visual cards can still be improved | UX pack renders Team rich rows with richer card styling | | Team position picker renderer | Send-side picker widget is unrelated to display projection | Picker rendering is extracted from `ChatUiController` | | Other legacy Team decode surfaces | Contacts/GPS/team-page code still has separate legacy display paths | Later phases migrate those screens to shared Team presentation projection | | `TeamChatPresentationSource` recent log allocation | Legacy `TeamUiStore` returns `std::vector` | `TeamUiStore` exposes a fixed-capacity recent-log visitor / bounded iterator | @@ -94,5 +97,5 @@ Phase 7.8 burns down Team rich payload display decoding from `ChatUiController`. It does not claim every Team payload decode in the repository has been removed. The narrowed guarantee is: ```text -The chat controller no longer formats or decodes Team location/command display payloads. +The chat controller no longer formats or decodes Team location/command display payloads, and Team chat rows no longer rely on summary-only `MessageRow` data. ``` diff --git a/docs/specification/MAP_OVERLAY_ROUTE_TRACKER_RUNTIME_SPEC.md b/docs/specification/MAP_OVERLAY_ROUTE_TRACKER_RUNTIME_SPEC.md index 078896e3..f43e857e 100644 --- a/docs/specification/MAP_OVERLAY_ROUTE_TRACKER_RUNTIME_SPEC.md +++ b/docs/specification/MAP_OVERLAY_ROUTE_TRACKER_RUNTIME_SPEC.md @@ -95,6 +95,26 @@ It must not: The adapter exists to contain legacy GPS/team fact gathering. It does not render, open files, or mutate map viewport state. +## Team Location Source + +`TeamMapOverlaySource` owns Team member position projection for map consumers. + +It may depend on: + +- `ITeamUiSnapshotStore` +- Team posring storage API +- `IMapOverlayTeamSource` + +It exposes fixed-capacity map facts through: + +- `latestTeamPoints(...)` +- `latestTeamLocations(...)` +- `loadMemberLocation(...)` + +GPS pages, map runtimes, dashboards, and renderers must not call +`team_ui_posring_load_latest(...)` directly. They consume `TeamMapOverlaySource` +or a built `MapOverlaySnapshot`. + ## Runtime Wiring Map page runtime or composition root owns: diff --git a/docs/specification/MAP_WORKSPACE_MODEL_SPEC.md b/docs/specification/MAP_WORKSPACE_MODEL_SPEC.md index c0117f79..b31c5da7 100644 --- a/docs/specification/MAP_WORKSPACE_MODEL_SPEC.md +++ b/docs/specification/MAP_WORKSPACE_MODEL_SPEC.md @@ -161,7 +161,9 @@ It must not expose: - marker widget objects - rich Team location/command payloads -Rich Team map markers require a later bounded map overlay presentation phase. +Team member position rows are projected through `TeamMapOverlaySource` / +`MapOverlaySnapshotSource`. `TeamOverlaySummary` remains only the workspace +summary; rich map marker facts must stay in the overlay source path. ## Legacy Adapters diff --git a/docs/specification/TEAM_ACTION_RUNTIME_SPEC.md b/docs/specification/TEAM_ACTION_RUNTIME_SPEC.md index 8cd1d7b1..e95458fb 100644 --- a/docs/specification/TEAM_ACTION_RUNTIME_SPEC.md +++ b/docs/specification/TEAM_ACTION_RUNTIME_SPEC.md @@ -14,10 +14,11 @@ Team action payload encoding belongs to Team action/runtime adapters. ```text renderer/controller input - -> TeamActionRequest - -> ITeamActionSink - -> LegacyTeamActionBridge - -> Team payload encoder / Team runtime command port + -> ChatTeamWorkflow + -> TeamActionRequest + -> ITeamActionSink + -> TeamActionRuntimeSink + -> Team payload encoder / Team runtime command port ``` ## Types @@ -32,7 +33,8 @@ Phase 7.2 defines: - `TeamActionRequest` - `ITeamActionSink` - `ITeamLocationSource` -- `LegacyTeamActionBridge` +- `ChatTeamWorkflow` +- `TeamActionRuntimeSink` ## Ownership @@ -41,8 +43,8 @@ contain renderer widgets, LVGL objects, raw packets, or app service references. `ITeamActionSink` handles Team actions. It must not build chat snapshots. -`LegacyTeamActionBridge` translates Team action requests into the current Team -runtime command port and Team UI store compatibility helpers. +`TeamActionRuntimeSink` translates Team action requests into the current Team +runtime command port and narrowed Team snapshot/chat-log store seams. `ITeamLocationSource` provides current location facts to Team action adapters when a renderer submits `use_current_location`. It is a runtime source, not a UI @@ -51,15 +53,20 @@ widget dependency. `TeamChatPresentationSource` remains a read projection and must not send Team actions. -`ChatUiController` may collect input and submit `TeamActionRequest`, but it must -not encode Team payloads or directly send Team runtime packets. +`ChatTeamWorkflow` owns Team chat UI action semantics for the chat page: Team +text send, selected Team snapshot selection, mark-read, location marker action +request construction, and Team action failure messages. + +`ChatUiController` may collect input and open/close picker UI, but it must not +construct `TeamActionRequest`, access `ITeamActionSink`, encode Team payloads, +or directly send Team runtime packets. ## Text Team text continues to flow through `TeamChatActionSink` and `team_chat_model_.sendMessage(...)`. -`LegacyTeamActionBridge` may support text for command sink completeness, but the +`TeamActionRuntimeSink` may support text for command sink completeness, but the existing Team chat compose path remains owned by `TeamChatActionSink`. ## Location Marker @@ -73,10 +80,11 @@ TeamLocationMarkerRequest The request contains location facts and marker identity. Encoding `TeamChatLocation` and appending the outgoing structured Team log belong to the -Team action bridge. +Team action runtime sink. When the UI wants to send the current location, it submits -`use_current_location` and marker identity. The bridge resolves the current +the selected marker identity through `ChatTeamWorkflow`. The workflow builds a +`use_current_location` request. `TeamActionRuntimeSink` resolves the current location through `ITeamLocationSource`; the renderer/controller must not read GPS runtime directly for this action. @@ -89,8 +97,8 @@ TeamActionKind::Command TeamCommandRequest ``` -Phase 7.2 defines the command request and bridge boundary. If no concrete -command UI/runtime path is available, the bridge may return `Unsupported` +Phase 7.2 defines the command request and runtime sink seam. If no concrete +command UI/runtime path is available, the sink may return `Unsupported` rather than encoding command payload in a renderer. ## Non-Goals diff --git a/docs/specification/TEAM_RICH_PAYLOAD_PRESENTATION_SPEC.md b/docs/specification/TEAM_RICH_PAYLOAD_PRESENTATION_SPEC.md index bb3f67cd..3949e4af 100644 --- a/docs/specification/TEAM_RICH_PAYLOAD_PRESENTATION_SPEC.md +++ b/docs/specification/TEAM_RICH_PAYLOAD_PRESENTATION_SPEC.md @@ -76,9 +76,18 @@ The projector may read legacy payload strings after decode, but it must not allo `TeamChatPresentationSource` consumes `TeamRichPayloadProjector`. -For Phase 7.8, it projects `TeamRichPayloadDisplay::summary` into existing `MessageRow::text` and conversation subtitle fields. +It maps `TeamRichPayloadDisplay` into `MessageRow::team_rich_payload` and sets +`MessageRow::has_team_rich_payload`. -This keeps compatibility with the current chat renderer while making the display decode owner explicit. +`MessageRow::text` and conversation subtitles may still receive +`TeamRichPayloadDisplay::summary` as fallback text. The fallback is not the +owner boundary. Structured Team row fields are the render contract for Team +location and command rows. + +`ui_presentation` must not depend on `ui_shared` Team projector types. The row +model carries a presentation-native `TeamMessageRichPayload` shape so Chat +renderers can consume structured Team display data without depending on Team +runtime or Team Page state. ## Controller Rule @@ -103,11 +112,10 @@ Phase 7.8 does not: - change Team protocol packet format - change Team action send path -- replace `LegacyTeamActionBridge` +- keep Team rich display separate from `TeamActionRuntimeSink` - change `TeamUiStore` schema - add Map overlays -- implement full Team rich cards -- redesign `MessageRow` +- require a full Team rich card visual redesign - rewrite `ChatWorkspaceModel` - replace the legacy `TeamUiStore` recent-log API diff --git a/modules/core_chat/src/usecase/chat_service.cpp b/modules/core_chat/src/usecase/chat_service.cpp index d1af3782..1ea8fd91 100644 --- a/modules/core_chat/src/usecase/chat_service.cpp +++ b/modules/core_chat/src/usecase/chat_service.cpp @@ -224,6 +224,11 @@ void ChatService::processIncoming() } } + if (incoming_data_observers_.empty()) + { + return; + } + MeshIncomingData incoming_data; while (adapter_.pollIncomingData(&incoming_data)) { diff --git a/modules/core_sys/include/platform/ui/team_ui_chat_log_store.h b/modules/core_sys/include/platform/ui/team_ui_chat_log_store.h new file mode 100644 index 00000000..51b3013f --- /dev/null +++ b/modules/core_sys/include/platform/ui/team_ui_chat_log_store.h @@ -0,0 +1,54 @@ +/** + * @file team_ui_chat_log_store.h + * @brief Narrow Team chat log persistence contract. + */ + +#pragma once + +#include "team/domain/team_types.h" +#include "team/protocol/team_chat.h" + +#include +#include +#include +#include + +namespace team +{ +namespace ui +{ + +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; +}; + +class ITeamUiChatLogStore +{ + public: + virtual ~ITeamUiChatLogStore() = default; + + virtual bool appendText(const TeamId& team_id, + uint32_t peer_id, + bool incoming, + uint32_t ts, + const std::string& text) = 0; + virtual bool appendStructured(const TeamId& team_id, + uint32_t peer_id, + bool incoming, + uint32_t ts, + team::proto::TeamChatType type, + const std::vector& payload) = 0; + virtual bool loadRecent(const TeamId& team_id, + std::size_t max_count, + std::vector& out) = 0; +}; + +ITeamUiChatLogStore& team_ui_chat_log_store(); + +} // namespace ui +} // namespace team diff --git a/modules/core_sys/include/platform/ui/team_ui_snapshot_store.h b/modules/core_sys/include/platform/ui/team_ui_snapshot_store.h new file mode 100644 index 00000000..e56f67e7 --- /dev/null +++ b/modules/core_sys/include/platform/ui/team_ui_snapshot_store.h @@ -0,0 +1,70 @@ +/** + * @file team_ui_snapshot_store.h + * @brief Narrow Team UI snapshot persistence contract. + */ + +#pragma once + +#include "platform/ui/team_ui_types.h" +#include "team/domain/team_types.h" +#include "team/protocol/team_mgmt.h" + +#include +#include +#include +#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; + uint32_t team_chat_unread = 0; + + TeamId team_id{}; + bool has_team_id = false; + + std::string team_name; + uint32_t security_round = 0; + uint32_t last_update_s = 0; + std::array team_psk{}; + bool has_team_psk = false; + + std::vector members; +}; + +class ITeamUiSnapshotStore +{ + public: + virtual ~ITeamUiSnapshotStore() = default; + virtual bool load(TeamUiSnapshot& out) = 0; + virtual void save(const TeamUiSnapshot& in) = 0; + virtual void clear() = 0; +}; + +// Simple in-memory fallback store used when no platform persistence store is installed. +class TeamUiSnapshotMemoryStore : public ITeamUiSnapshotStore +{ + public: + bool load(TeamUiSnapshot& out) override; + void save(const TeamUiSnapshot& in) override; + void clear() override; + + private: + static bool has_snapshot_; + static TeamUiSnapshot snapshot_; +}; + +ITeamUiSnapshotStore& team_ui_snapshot_store(); +void team_ui_set_snapshot_store(ITeamUiSnapshotStore* store); + +} // namespace ui +} // namespace team diff --git a/modules/core_sys/include/platform/ui/team_ui_store_runtime.h b/modules/core_sys/include/platform/ui/team_ui_store_runtime.h index 45a855be..e0759403 100644 --- a/modules/core_sys/include/platform/ui/team_ui_store_runtime.h +++ b/modules/core_sys/include/platform/ui/team_ui_store_runtime.h @@ -5,7 +5,8 @@ #pragma once -#include "platform/ui/team_ui_types.h" +#include "platform/ui/team_ui_chat_log_store.h" +#include "platform/ui/team_ui_snapshot_store.h" #include "team/domain/team_types.h" #include "team/protocol/team_chat.h" #include "team/protocol/team_mgmt.h" @@ -22,28 +23,6 @@ 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; - uint32_t team_chat_unread = 0; - - TeamId team_id{}; - bool has_team_id = false; - - std::string team_name; - uint32_t security_round = 0; - uint32_t last_update_s = 0; - std::array team_psk{}; - bool has_team_psk = false; - - std::vector members; -}; - enum class TeamKeyEventType : uint8_t { TeamCreated = 1, @@ -53,15 +32,6 @@ enum class TeamKeyEventType : uint8_t 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; @@ -72,27 +42,8 @@ struct TeamPosSample 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 fallback store used when no platform persistence store is installed. -class TeamUiMemoryStore : 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_; -}; +using ITeamUiStore = ITeamUiSnapshotStore; +using TeamUiMemoryStore = TeamUiSnapshotMemoryStore; ITeamUiStore& team_ui_get_store(); void team_ui_set_store(ITeamUiStore* store); diff --git a/modules/core_team/include/team/domain/team_events.h b/modules/core_team/include/team/domain/team_events.h index 1cfcbadf..d743df66 100644 --- a/modules/core_team/include/team/domain/team_events.h +++ b/modules/core_team/include/team/domain/team_events.h @@ -38,6 +38,12 @@ struct TeamKeyDistEvent team::proto::TeamKeyDist msg; }; +struct TeamKeyRequestEvent +{ + TeamEventContext ctx; + team::proto::TeamKeyRequest msg; +}; + struct TeamStatusEvent { TeamEventContext ctx; diff --git a/modules/core_team/include/team/ports/i_team_event_sink.h b/modules/core_team/include/team/ports/i_team_event_sink.h index e23ea2fc..1e4ffd32 100644 --- a/modules/core_team/include/team/ports/i_team_event_sink.h +++ b/modules/core_team/include/team/ports/i_team_event_sink.h @@ -13,6 +13,7 @@ class ITeamEventSink virtual void onTeamKick(const TeamKickEvent& event) = 0; virtual void onTeamTransferLeader(const TeamTransferLeaderEvent& event) = 0; virtual void onTeamKeyDist(const TeamKeyDistEvent& event) = 0; + virtual void onTeamKeyRequest(const TeamKeyRequestEvent& event) = 0; virtual void onTeamStatus(const TeamStatusEvent& event) = 0; virtual void onTeamPosition(const TeamPositionEvent& event) = 0; virtual void onTeamWaypoint(const TeamWaypointEvent& event) = 0; diff --git a/modules/core_team/include/team/protocol/team_mgmt.h b/modules/core_team/include/team/protocol/team_mgmt.h index 9cff8d04..367a7546 100644 --- a/modules/core_team/include/team/protocol/team_mgmt.h +++ b/modules/core_team/include/team/protocol/team_mgmt.h @@ -22,7 +22,8 @@ enum class TeamMgmtType : uint8_t Disband = 8, Kick = 10, TransferLeader = 11, - KeyDist = 12 + KeyDist = 12, + KeyRequest = 13 }; struct TeamParams @@ -51,6 +52,13 @@ struct TeamKeyDist uint8_t channel_psk_len = 0; }; +struct TeamKeyRequest +{ + std::array team_id{}; + uint32_t current_key_id = 0; + uint32_t requester_id = 0; +}; + struct TeamStatus { std::array member_list_hash{}; @@ -78,6 +86,9 @@ bool decodeTeamTransferLeader(const uint8_t* data, size_t len, TeamTransferLeade bool encodeTeamKeyDist(const TeamKeyDist& input, std::vector& out); bool decodeTeamKeyDist(const uint8_t* data, size_t len, TeamKeyDist* out); +bool encodeTeamKeyRequest(const TeamKeyRequest& input, std::vector& out); +bool decodeTeamKeyRequest(const uint8_t* data, size_t len, TeamKeyRequest* out); + bool encodeTeamStatus(const TeamStatus& input, std::vector& out); bool decodeTeamStatus(const uint8_t* data, size_t len, TeamStatus* out); diff --git a/modules/core_team/include/team/usecase/team_controller.h b/modules/core_team/include/team/usecase/team_controller.h index 5fdf3fb6..4fd407fd 100644 --- a/modules/core_team/include/team/usecase/team_controller.h +++ b/modules/core_team/include/team/usecase/team_controller.h @@ -37,6 +37,9 @@ class TeamController bool onKeyDistPlain(const team::proto::TeamKeyDist& msg, chat::ChannelId channel, chat::NodeId dest, bool want_ack = false, bool want_response = false); + bool onKeyRequest(const team::proto::TeamKeyRequest& msg, + chat::ChannelId channel, chat::NodeId dest = 0, + bool want_ack = false, bool want_response = false); bool onStatus(const team::proto::TeamStatus& status, chat::ChannelId channel, chat::NodeId dest = 0, bool want_ack = false, bool want_response = false); diff --git a/modules/core_team/include/team/usecase/team_service.h b/modules/core_team/include/team/usecase/team_service.h index 6cd7dd9e..f8da4d2c 100644 --- a/modules/core_team/include/team/usecase/team_service.h +++ b/modules/core_team/include/team/usecase/team_service.h @@ -69,6 +69,9 @@ class TeamService bool sendKeyDistPlain(const team::proto::TeamKeyDist& msg, chat::ChannelId channel, chat::NodeId dest, bool want_ack = false, bool want_response = false); + bool sendKeyRequest(const team::proto::TeamKeyRequest& msg, + chat::ChannelId channel, chat::NodeId dest = 0, + bool want_ack = false, bool want_response = false); bool sendStatus(const team::proto::TeamStatus& msg, chat::ChannelId channel, chat::NodeId dest = 0, bool want_ack = false, bool want_response = false); diff --git a/modules/core_team/src/protocol/team_mgmt.cpp b/modules/core_team/src/protocol/team_mgmt.cpp index 2b3a9d60..6e73fde2 100644 --- a/modules/core_team/src/protocol/team_mgmt.cpp +++ b/modules/core_team/src/protocol/team_mgmt.cpp @@ -241,6 +241,29 @@ bool decodeTeamKeyDist(const uint8_t* data, size_t len, TeamKeyDist* out) return true; } +bool encodeTeamKeyRequest(const TeamKeyRequest& input, std::vector& out) +{ + out.clear(); + ByteWriter writer(out); + writer.putBytes(input.team_id.data(), input.team_id.size()); + writer.putU32(input.current_key_id); + writer.putU32(input.requester_id); + return true; +} + +bool decodeTeamKeyRequest(const uint8_t* data, size_t len, TeamKeyRequest* 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->current_key_id)) return false; + if (!reader.getU32(&out->requester_id)) return false; + return true; +} + bool encodeTeamStatus(const TeamStatus& input, std::vector& out) { out.clear(); diff --git a/modules/core_team/src/usecase/team_controller.cpp b/modules/core_team/src/usecase/team_controller.cpp index b55980f6..d964dfa0 100644 --- a/modules/core_team/src/usecase/team_controller.cpp +++ b/modules/core_team/src/usecase/team_controller.cpp @@ -53,6 +53,13 @@ bool TeamController::onKeyDistPlain(const team::proto::TeamKeyDist& msg, return service_.sendKeyDistPlain(msg, channel, dest, want_ack, want_response); } +bool TeamController::onKeyRequest(const team::proto::TeamKeyRequest& msg, + chat::ChannelId channel, chat::NodeId dest, + bool want_ack, bool want_response) +{ + return service_.sendKeyRequest(msg, channel, dest, want_ack, want_response); +} + bool TeamController::onStatus(const team::proto::TeamStatus& status, chat::ChannelId channel, chat::NodeId dest, bool want_ack, bool want_response) diff --git a/modules/core_team/src/usecase/team_service.cpp b/modules/core_team/src/usecase/team_service.cpp index 9f243ccc..06b9e0f4 100644 --- a/modules/core_team/src/usecase/team_service.cpp +++ b/modules/core_team/src/usecase/team_service.cpp @@ -67,6 +67,8 @@ const char* mgmtTypeName(team::proto::TeamMgmtType type) return "TransferLeader"; case team::proto::TeamMgmtType::KeyDist: return "KeyDist"; + case team::proto::TeamMgmtType::KeyRequest: + return "KeyRequest"; default: return "Unknown"; } @@ -173,6 +175,15 @@ void logTeamKeyDist(const team::proto::TeamKeyDist& msg, const char* dir) psk_hex.c_str()); } +void logTeamKeyRequest(const team::proto::TeamKeyRequest& msg, const char* dir) +{ + TEAM_LOG("[TEAM] %s KeyRequest team_id=%s current_key_id=%lu requester=%08lX\n", + dir, + hexFromArray(msg.team_id).c_str(), + static_cast(msg.current_key_id), + static_cast(msg.requester_id)); +} + 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 members=%u leader=%08lX\n", @@ -454,6 +465,30 @@ void TeamService::processIncoming() } break; } + case team::proto::TeamMgmtType::KeyRequest: + { + if (!decoded_encrypted) + { + break; + } + team::proto::TeamKeyRequest msg; + if (!team::proto::decodeTeamKeyRequest(payload.data(), payload.size(), &msg)) + { + emitError(data, TeamProtocolError::DecodeFail, + decoded_encrypted ? &envelope : nullptr); + break; + } + auto ctx = makeContext(data, &envelope, runtime_); + if (msg.requester_id == 0) + { + msg.requester_id = ctx.from; + } + logTeamKeyRequest(msg, "RX"); + TeamKeyRequestEvent event{ctx, msg}; + sink_.onTeamKeyRequest(event); + rememberTeamMember(event.ctx.from); + break; + } case team::proto::TeamMgmtType::Status: { team::proto::TeamStatus msg; @@ -685,6 +720,29 @@ bool TeamService::sendKeyDistPlain(const team::proto::TeamKeyDist& msg, want_ack, want_response); } +bool TeamService::sendKeyRequest(const team::proto::TeamKeyRequest& msg, + chat::ChannelId channel, chat::NodeId dest, + bool want_ack, bool want_response) +{ + std::vector payload; + if (!team::proto::encodeTeamKeyRequest(msg, payload)) + { + last_send_error_ = SendError::EncodeFail; + return false; + } + logTeamKeyRequest(msg, "TX"); + std::string payload_hex = toHex(payload.data(), payload.size(), payload.size()); + TEAM_LOG("[TEAM] TX TEAM_MGMT KeyRequest payload_len=%u payload_hex=%s\n", + static_cast(payload.size()), + payload_hex.c_str()); + return sendMgmtEncrypted(team::proto::TeamMgmtType::KeyRequest, + payload, + channel, + dest, + want_ack, + want_response); +} + bool TeamService::sendStatus(const team::proto::TeamStatus& msg, chat::ChannelId channel, chat::NodeId dest, bool want_ack, bool want_response) diff --git a/modules/core_team/tests/test_team_app_data_poll_order.cpp b/modules/core_team/tests/test_team_app_data_poll_order.cpp new file mode 100644 index 00000000..4a3a0243 --- /dev/null +++ b/modules/core_team/tests/test_team_app_data_poll_order.cpp @@ -0,0 +1,318 @@ +#include "chat/domain/chat_model.h" +#include "chat/ports/i_chat_store.h" +#include "chat/ports/i_mesh_adapter.h" +#include "chat/usecase/chat_service.h" +#include "team/ports/i_team_crypto.h" +#include "team/ports/i_team_event_sink.h" +#include "team/ports/i_team_runtime.h" +#include "team/protocol/team_chat.h" +#include "team/protocol/team_portnum.h" +#include "team/protocol/team_wire.h" +#include "team/usecase/team_service.h" + +#include +#include +#include + +namespace +{ + +class FakeMeshAdapter final : public chat::IMeshAdapter +{ + public: + chat::MeshCapabilities getCapabilities() const override + { + chat::MeshCapabilities caps{}; + caps.supports_broadcast_appdata = true; + caps.provides_appdata_sender = true; + return caps; + } + + bool sendText(chat::ChannelId, + const std::string&, + chat::MessageId* out_msg_id, + chat::NodeId = 0) override + { + if (out_msg_id) + { + *out_msg_id = 1; + } + return true; + } + + bool pollIncomingText(chat::MeshIncomingText*) override { return false; } + + bool sendAppData(chat::ChannelId, + uint32_t, + const uint8_t*, + size_t, + chat::NodeId = 0, + bool = false, + chat::MessageId = 0, + bool = false) override + { + return true; + } + + bool pollIncomingData(chat::MeshIncomingData* out) override + { + if (incoming.empty()) + { + return false; + } + if (out) + { + *out = incoming.front(); + } + incoming.pop_front(); + return true; + } + + void applyConfig(const chat::MeshConfig&) override {} + bool isReady() const override { return true; } + bool pollIncomingRawPacket(uint8_t*, size_t& out_len, size_t) override + { + out_len = 0; + return false; + } + chat::NodeId getNodeId() const override { return 0x0C16AAEC; } + + std::deque incoming; +}; + +class FakeChatStore final : public chat::IChatStore +{ + public: + void append(const chat::ChatMessage&) override {} + std::vector loadRecent(const chat::ConversationId&, size_t) override + { + return {}; + } + std::vector loadConversationPage(size_t, + size_t, + size_t* total) override + { + if (total) + { + *total = 0; + } + return {}; + } + void setUnread(const chat::ConversationId&, int) override {} + int getUnread(const chat::ConversationId&) const override { return 0; } + void clearConversation(const chat::ConversationId&) override {} + void clearAll() override {} + bool updateMessageStatus(chat::MessageId, chat::MessageStatus) override + { + return false; + } + bool getMessage(chat::MessageId, chat::ChatMessage*) const override + { + return false; + } +}; + +class FakeCrypto final : public team::ITeamCrypto +{ + public: + bool deriveKey(const uint8_t* key, + size_t key_len, + const char*, + uint8_t* out, + size_t out_len) override + { + if (!key || key_len == 0 || !out) + { + return false; + } + for (size_t i = 0; i < out_len; ++i) + { + out[i] = static_cast(key[i % key_len] ^ 0x5A); + } + return true; + } + + bool aeadEncrypt(const uint8_t*, + size_t, + const uint8_t*, + size_t, + const uint8_t*, + size_t, + const uint8_t* plain, + size_t plain_len, + std::vector& out_cipher) override + { + out_cipher.assign(plain, plain + plain_len); + return true; + } + + bool aeadDecrypt(const uint8_t*, + size_t, + const uint8_t*, + size_t, + const uint8_t*, + size_t, + const uint8_t* cipher, + size_t cipher_len, + std::vector& out_plain) override + { + out_plain.assign(cipher, cipher + cipher_len); + return true; + } +}; + +class FakeRuntime final : public team::ITeamRuntime +{ + public: + uint32_t nowMillis() override { return 1234; } + uint32_t nowUnixSeconds() override { return 5678; } + void fillRandomBytes(uint8_t* out, size_t len) override + { + for (size_t i = 0; i < len; ++i) + { + out[i] = static_cast(0xA0 + i); + } + } +}; + +class FakeTeamSink final : public team::ITeamEventSink +{ + public: + void onTeamKick(const team::TeamKickEvent&) override {} + void onTeamTransferLeader(const team::TeamTransferLeaderEvent&) override {} + void onTeamKeyDist(const team::TeamKeyDistEvent&) override {} + void onTeamKeyRequest(const team::TeamKeyRequestEvent&) override {} + void onTeamStatus(const team::TeamStatusEvent&) override {} + void onTeamPosition(const team::TeamPositionEvent&) override {} + void onTeamWaypoint(const team::TeamWaypointEvent&) override {} + void onTeamTrack(const team::TeamTrackEvent&) override {} + void onTeamChat(const team::TeamChatEvent& event) override + { + chat_count += 1; + last_from = event.ctx.from; + last_payload = event.msg.payload; + } + void onTeamError(const team::TeamErrorEvent&) override + { + error_count += 1; + } + + int chat_count = 0; + int error_count = 0; + chat::NodeId last_from = 0; + std::vector last_payload; +}; + +team::TeamId testTeamId() +{ + team::TeamId id{}; + id[0] = 0x42; + id[1] = 0x24; + return id; +} + +team::TeamKeys testKeys() +{ + team::TeamKeys keys{}; + keys.team_id = testTeamId(); + keys.key_id = 7; + keys.valid = true; + for (size_t i = 0; i < team::kTeamKeySize; ++i) + { + keys.chat_key[i] = static_cast(0x30 + i); + keys.mgmt_key[i] = static_cast(0x40 + i); + keys.pos_key[i] = static_cast(0x50 + i); + keys.wp_key[i] = static_cast(0x60 + i); + } + return keys; +} + +std::vector makeTeamChatWire() +{ + team::proto::TeamChatMessage msg{}; + msg.header.version = team::proto::kTeamChatVersion; + msg.header.type = team::proto::TeamChatType::Text; + msg.header.from = 0x4ECF80D8; + msg.payload = {'p', 'i', 'n', 'g'}; + + std::vector plain; + assert(team::proto::encodeTeamChatMessage(msg, plain)); + + team::proto::TeamEncrypted envelope{}; + envelope.team_id = testTeamId(); + envelope.key_id = 7; + envelope.sender_id = 0x4ECF80D8; + envelope.ciphertext = plain; + + std::vector wire; + assert(team::proto::encodeTeamEncrypted(envelope, wire)); + return wire; +} + +chat::MeshIncomingData makeIncomingTeamChat() +{ + chat::MeshIncomingData data{}; + data.portnum = team::proto::TEAM_CHAT_APP; + data.from = 0x4ECF80D8; + data.to = 0xFFFFFFFF; + data.packet_id = 0x09EDDEC3; + data.channel = static_cast(2); + data.channel_hash = 8; + data.want_response = false; + data.payload = makeTeamChatWire(); + return data; +} + +void testChatServiceDoesNotDrainAppDataWithoutObservers() +{ + FakeMeshAdapter mesh; + mesh.incoming.push_back(makeIncomingTeamChat()); + + chat::ChatModel model; + FakeChatStore store; + chat::ChatService chat_service( + model, + mesh, + store, + chat::MeshProtocol::Meshtastic); + + chat_service.processIncoming(); + assert(mesh.incoming.size() == 1); +} + +void testTeamServiceReceivesTeamChatAfterChatServiceTick() +{ + FakeMeshAdapter mesh; + mesh.incoming.push_back(makeIncomingTeamChat()); + + chat::ChatModel model; + FakeChatStore store; + chat::ChatService chat_service( + model, + mesh, + store, + chat::MeshProtocol::Meshtastic); + + FakeCrypto crypto; + FakeRuntime runtime; + FakeTeamSink sink; + team::TeamService team_service(crypto, mesh, sink, runtime); + team_service.setKeys(testKeys()); + + chat_service.processIncoming(); + team_service.processIncoming(); + + assert(sink.error_count == 0); + assert(sink.chat_count == 1); + assert(sink.last_from == 0x4ECF80D8); + assert(std::string(sink.last_payload.begin(), sink.last_payload.end()) == "ping"); +} + +} // namespace + +int main() +{ + testChatServiceDoesNotDrainAppDataWithoutObservers(); + testTeamServiceReceivesTeamChatAfterChatServiceTick(); + return 0; +} diff --git a/modules/core_team/tests/test_team_mgmt_key_request.cpp b/modules/core_team/tests/test_team_mgmt_key_request.cpp new file mode 100644 index 00000000..0c746978 --- /dev/null +++ b/modules/core_team/tests/test_team_mgmt_key_request.cpp @@ -0,0 +1,75 @@ +#include "team/protocol/team_mgmt.h" + +#include +#include + +namespace +{ + +team::proto::TeamKeyRequest makeRequest() +{ + team::proto::TeamKeyRequest request{}; + request.team_id[0] = 0x42; + request.team_id[1] = 0x24; + request.current_key_id = 7; + request.requester_id = 0x22222222; + return request; +} + +void testKeyRequestRoundTrips() +{ + const auto request = makeRequest(); + std::vector payload; + assert(team::proto::encodeTeamKeyRequest(request, payload)); + + team::proto::TeamKeyRequest decoded{}; + assert(team::proto::decodeTeamKeyRequest(payload.data(), + payload.size(), + &decoded)); + assert(decoded.team_id == request.team_id); + assert(decoded.current_key_id == 7); + assert(decoded.requester_id == 0x22222222); +} + +void testMgmtEnvelopeCarriesKeyRequestType() +{ + std::vector payload; + assert(team::proto::encodeTeamKeyRequest(makeRequest(), payload)); + + std::vector wire; + assert(team::proto::encodeTeamMgmtMessage( + team::proto::TeamMgmtType::KeyRequest, + payload, + wire)); + + uint8_t version = 0; + team::proto::TeamMgmtType type = team::proto::TeamMgmtType::Status; + std::vector decoded_payload; + assert(team::proto::decodeTeamMgmtMessage(wire.data(), + wire.size(), + &version, + &type, + decoded_payload)); + assert(version == team::proto::kTeamMgmtVersion); + assert(type == team::proto::TeamMgmtType::KeyRequest); + assert(decoded_payload == payload); +} + +void testDecodeRejectsShortPayloads() +{ + uint8_t payload[4] = {0}; + team::proto::TeamKeyRequest decoded{}; + assert(!team::proto::decodeTeamKeyRequest(payload, + sizeof(payload), + &decoded)); +} + +} // namespace + +int main() +{ + testKeyRequestRoundTrips(); + testMgmtEnvelopeCarriesKeyRequestType(); + testDecodeRejectsShortPayloads(); + return 0; +} diff --git a/modules/ui_presentation/include/ui_presentation/chat/chat_workspace_snapshot.h b/modules/ui_presentation/include/ui_presentation/chat/chat_workspace_snapshot.h index c9de0b02..f38059c5 100644 --- a/modules/ui_presentation/include/ui_presentation/chat/chat_workspace_snapshot.h +++ b/modules/ui_presentation/include/ui_presentation/chat/chat_workspace_snapshot.h @@ -28,6 +28,56 @@ struct ConversationRow MessageDeliveryState last_delivery = MessageDeliveryState::Unknown; }; +enum class TeamMessageRichPayloadKind : uint8_t +{ + None, + Text, + Location, + Command, + Unsupported, +}; + +enum class TeamMessageCommandKind : uint8_t +{ + Unknown, + MoveTo, + RallyPoint, + Hold, + Help, + CheckIn, + Custom, +}; + +struct TeamMessageLocationPayload +{ + double lat = 0.0; + double lon = 0.0; + bool has_altitude = false; + float altitude_m = 0.0f; + uint8_t marker_icon = 0; +}; + +struct TeamMessageCommandPayload +{ + TeamMessageCommandKind kind = TeamMessageCommandKind::Unknown; + double lat = 0.0; + double lon = 0.0; + float radius_m = 0.0f; + uint8_t priority = 0; +}; + +struct TeamMessageRichPayload +{ + TeamMessageRichPayloadKind kind = TeamMessageRichPayloadKind::None; + + ui::FixedText<32> title; + ui::FixedText<96> summary; + ui::FixedText<32> badge; + + TeamMessageLocationPayload location; + TeamMessageCommandPayload command; +}; + struct MessageRow { MessageRef ref; @@ -41,6 +91,9 @@ struct MessageRow ui::FixedText<160> text; ui::FixedText<24> time_label; ui::FixedText<32> sender_label; + + bool has_team_rich_payload = false; + TeamMessageRichPayload team_rich_payload; }; struct ChatWorkspaceSnapshot @@ -93,6 +146,8 @@ inline void resetMessageRow(MessageRow& row) row.text.clear(); row.time_label.clear(); row.sender_label.clear(); + row.has_team_rich_payload = false; + row.team_rich_payload = TeamMessageRichPayload{}; } inline void resetChatWorkspaceSnapshot(ChatWorkspaceSnapshot& out) diff --git a/modules/ui_presentation/tests/test_chat_workspace_model.cpp b/modules/ui_presentation/tests/test_chat_workspace_model.cpp index 4c3a1f40..8469b565 100644 --- a/modules/ui_presentation/tests/test_chat_workspace_model.cpp +++ b/modules/ui_presentation/tests/test_chat_workspace_model.cpp @@ -41,6 +41,10 @@ int main() reset_probe.messages[0].ref.origin = ui::chat::MessageOrigin::RemoteStored; reset_probe.messages[0].delivery = ui::chat::MessageDeliveryState::Delivered; ui::copyText(reset_probe.messages[0].text, "stale"); + reset_probe.messages[0].has_team_rich_payload = true; + reset_probe.messages[0].team_rich_payload.kind = + ui::chat::TeamMessageRichPayloadKind::Location; + ui::copyText(reset_probe.messages[0].team_rich_payload.summary, "stale rich"); ui::chat::resetChatWorkspaceSnapshot(reset_probe); assert(!reset_probe.header.valid); assert(reset_probe.conversation_count == 0); @@ -52,6 +56,10 @@ int main() assert(reset_probe.messages[0].delivery == ui::chat::MessageDeliveryState::Unknown); assert(reset_probe.messages[0].text.empty()); + assert(!reset_probe.messages[0].has_team_rich_payload); + assert(reset_probe.messages[0].team_rich_payload.kind == + ui::chat::TeamMessageRichPayloadKind::None); + assert(reset_probe.messages[0].team_rich_payload.summary.empty()); ui::tests::FakeChatPresentationSource source; source.snapshot_value.header.valid = true; diff --git a/modules/ui_shared/include/ui/presentation_sources/legacy_map_presentation_source.h b/modules/ui_shared/include/ui/presentation_sources/legacy_map_presentation_source.h index 153d36a7..695b93ea 100644 --- a/modules/ui_shared/include/ui/presentation_sources/legacy_map_presentation_source.h +++ b/modules/ui_shared/include/ui/presentation_sources/legacy_map_presentation_source.h @@ -1,6 +1,6 @@ #pragma once -#include "platform/ui/team_ui_store_runtime.h" +#include "platform/ui/team_ui_snapshot_store.h" #include "ui/presentation_sources/legacy_map_presentation_state.h" #include "ui_presentation/gps/gps_status_source.h" #include "ui_presentation/map/map_presentation_source.h" @@ -28,7 +28,7 @@ class LegacyMapPresentationSource final : public ui::map::IMapPresentationSource public: LegacyMapPresentationSource(ui::gps::IGpsStatusSource& gps_source, const LegacyMapPresentationState& state, - team::ui::ITeamUiStore* team_store = nullptr); + team::ui::ITeamUiSnapshotStore* team_store = nullptr); bool buildMapWorkspaceSnapshot( const ui::map::MapWorkspaceRequest& request, @@ -37,7 +37,7 @@ class LegacyMapPresentationSource final : public ui::map::IMapPresentationSource private: ui::gps::IGpsStatusSource& gps_source_; const LegacyMapPresentationState& state_; - team::ui::ITeamUiStore* team_store_ = nullptr; + team::ui::ITeamUiSnapshotStore* team_store_ = nullptr; }; } // namespace ui::presentation_sources diff --git a/modules/ui_shared/include/ui/presentation_sources/team_chat_action_sink.h b/modules/ui_shared/include/ui/presentation_sources/team_chat_action_sink.h index ea33a8a6..d9bf6c94 100644 --- a/modules/ui_shared/include/ui/presentation_sources/team_chat_action_sink.h +++ b/modules/ui_shared/include/ui/presentation_sources/team_chat_action_sink.h @@ -11,7 +11,8 @@ namespace team { namespace ui { -class ITeamUiStore; +class ITeamUiChatLogStore; +class ITeamUiSnapshotStore; } } // namespace team @@ -41,7 +42,8 @@ class ITeamChatCommandPort class TeamChatActionSink final : public ui::chat::IChatActionSink { public: - TeamChatActionSink(::team::ui::ITeamUiStore& team_store, + TeamChatActionSink(::team::ui::ITeamUiSnapshotStore& snapshot_store, + ::team::ui::ITeamUiChatLogStore& chat_log_store, ITeamChatCommandPort* command_port, uint8_t team_channel_raw = 2); @@ -50,7 +52,8 @@ class TeamChatActionSink final : public ui::chat::IChatActionSink ui::UiActionResult markRead(ui::chat::ConversationId id) override; private: - ::team::ui::ITeamUiStore& team_store_; + ::team::ui::ITeamUiSnapshotStore& snapshot_store_; + ::team::ui::ITeamUiChatLogStore& chat_log_store_; ITeamChatCommandPort* command_port_ = nullptr; uint8_t team_channel_raw_ = 2; uint32_t next_message_id_ = 1; diff --git a/modules/ui_shared/include/ui/presentation_sources/team_chat_presentation_source.h b/modules/ui_shared/include/ui/presentation_sources/team_chat_presentation_source.h index 905cc68b..52fa3c5c 100644 --- a/modules/ui_shared/include/ui/presentation_sources/team_chat_presentation_source.h +++ b/modules/ui_shared/include/ui/presentation_sources/team_chat_presentation_source.h @@ -6,7 +6,8 @@ namespace team { namespace ui { -class ITeamUiStore; +class ITeamUiChatLogStore; +class ITeamUiSnapshotStore; } } // namespace team @@ -31,14 +32,16 @@ namespace ui::presentation_sources class TeamChatPresentationSource final : public ui::chat::IChatPresentationSource { public: - explicit TeamChatPresentationSource(::team::ui::ITeamUiStore& team_store); + TeamChatPresentationSource(::team::ui::ITeamUiSnapshotStore& snapshot_store, + ::team::ui::ITeamUiChatLogStore& chat_log_store); bool buildChatWorkspaceSnapshot( const ui::chat::ChatWorkspaceRequest& request, ui::chat::ChatWorkspaceSnapshot& out) const override; private: - ::team::ui::ITeamUiStore& team_store_; + ::team::ui::ITeamUiSnapshotStore& snapshot_store_; + ::team::ui::ITeamUiChatLogStore& chat_log_store_; }; } // namespace ui::presentation_sources diff --git a/modules/ui_shared/include/ui/presentation_sources/team_map_overlay_source.h b/modules/ui_shared/include/ui/presentation_sources/team_map_overlay_source.h new file mode 100644 index 00000000..3400c5fa --- /dev/null +++ b/modules/ui_shared/include/ui/presentation_sources/team_map_overlay_source.h @@ -0,0 +1,53 @@ +#pragma once + +#include "platform/ui/team_ui_store_runtime.h" +#include "ui_map_runtime/map_overlay_projection_adapter.h" + +#include +#include + +namespace ui::presentation_sources +{ + +struct TeamMapLocation +{ + uint32_t member_id = 0; + int32_t lat_e7 = 0; + int32_t lon_e7 = 0; + int16_t alt_m = 0; + uint32_t ts = 0; + uint32_t color = 0; + bool valid = false; +}; + +class TeamMapOverlaySource final : public ::ui::map_overlay::IMapOverlayTeamSource +{ + public: + explicit TeamMapOverlaySource( + ::team::ui::ITeamUiSnapshotStore& snapshot_store); + + std::size_t latestTeamPoints(TeamPoint* out, + std::size_t capacity) const override; + + std::size_t latestTeamLocations(TeamMapLocation* out, + std::size_t capacity) const; + + bool loadMemberLocation(uint32_t member_id, + TeamMapLocation& out) const; + + private: + ::team::ui::ITeamUiSnapshotStore& snapshot_store_; + + bool loadSnapshot(::team::ui::TeamUiSnapshot& out) const; + static TeamMapLocation locationFromSample( + const ::team::ui::TeamUiSnapshot& snapshot, + const ::team::ui::TeamPosSample& sample); + static uint32_t colorForMember(const ::team::ui::TeamUiSnapshot& snapshot, + uint32_t member_id); + static const char* labelForMember( + const ::team::ui::TeamUiSnapshot& snapshot, + uint32_t member_id, + std::size_t index); +}; + +} // namespace ui::presentation_sources diff --git a/modules/ui_shared/include/ui/screens/chat/chat_team_workflow.h b/modules/ui_shared/include/ui/screens/chat/chat_team_workflow.h new file mode 100644 index 00000000..42367060 --- /dev/null +++ b/modules/ui_shared/include/ui/screens/chat/chat_team_workflow.h @@ -0,0 +1,32 @@ +#pragma once + +#include "ui/team_actions/team_action_sink.h" +#include "ui_presentation/chat/chat_workspace_model.h" +#include "ui_presentation/common/ui_action_result.h" + +#include + +namespace chat::ui +{ + +class ChatTeamWorkflow +{ + public: + ChatTeamWorkflow(::ui::chat::ChatWorkspaceModel& team_chat_model, + ::ui::team_actions::ITeamActionSink* team_action_sink); + + bool buildSnapshot(::ui::chat::ChatWorkspaceSnapshot& out); + bool buildSelectedSnapshot(::ui::chat::ChatWorkspaceSnapshot& out); + ::ui::UiActionResult markRead(const ::ui::chat::ConversationId& conversation); + ::ui::UiActionResult sendText(const char* text); + ::ui::UiActionResult sendCurrentLocationMarker(uint8_t icon_id); + + const char* textSendFailureMessage(::ui::UiActionResult result) const; + const char* locationSendFailureMessage(::ui::UiActionResult result) const; + + private: + ::ui::chat::ChatWorkspaceModel& team_chat_model_; + ::ui::team_actions::ITeamActionSink* team_action_sink_ = nullptr; +}; + +} // namespace chat::ui diff --git a/modules/ui_shared/include/ui/screens/chat/chat_ui_controller.h b/modules/ui_shared/include/ui/screens/chat/chat_ui_controller.h index 206a3fb0..75f9a460 100644 --- a/modules/ui_shared/include/ui/screens/chat/chat_ui_controller.h +++ b/modules/ui_shared/include/ui/screens/chat/chat_ui_controller.h @@ -13,7 +13,6 @@ #include "ui/screens/chat/chat_compose_components.h" #include "ui/screens/chat/chat_conversation_components.h" #include "ui/screens/chat/chat_message_list_components.h" -#include "ui/team_actions/team_action_sink.h" #include "ui/widgets/ime/ime_widget.h" #include "ui_chat_runtime/chat_ui_refresh_sink.h" #include "ui_lvgl_ux_packs/common/key_verification_modal_renderer.h" @@ -35,6 +34,8 @@ namespace chat namespace ui { +class ChatTeamWorkflow; + // Phase 5.6 note: // // UiController is still a legacy application controller. It has started @@ -55,8 +56,7 @@ class UiController : public IChatUiRefreshSink UiController(lv_obj_t* parent, chat::ChatService& service, ::ui::chat::ChatWorkspaceModel& chat_model, - ::ui::chat::ChatWorkspaceModel& team_chat_model, - ::ui::team_actions::ITeamActionSink* team_action_sink = nullptr, + ChatTeamWorkflow& team_workflow, ::ui::key_verification::KeyVerificationModel* key_verification_model = nullptr, chat::ChannelId initial_channel = chat::ChannelId::PRIMARY, @@ -90,8 +90,7 @@ class UiController : public IChatUiRefreshSink // presentation should migrate toward ChatWorkspaceModel. chat::ChatService& service_; ::ui::chat::ChatWorkspaceModel& chat_model_; - ::ui::chat::ChatWorkspaceModel& team_chat_model_; - ::ui::team_actions::ITeamActionSink* team_action_sink_ = nullptr; + ChatTeamWorkflow& team_workflow_; ::ui::key_verification::KeyVerificationModel* key_verification_model_ = nullptr; State state_; @@ -130,15 +129,11 @@ class UiController : public IChatUiRefreshSink void updateConversationMetaForMessage(const chat::ChatMessage& msg, bool increment_unread); bool updateConversationViewForIncoming(const chat::ChatMessage& msg); void reloadConversationView(); - // Phase 5.6-f: Team text projection now flows through team_chat_model_. - // Team location and command rendering remain a bounded legacy path until - // their richer presentation shape is defined. + // Team text, read projection, and location action sends flow through + // ChatTeamWorkflow. void refreshTeamConversation(); void startTeamConversationTimer(); void stopTeamConversationTimer(); - // Phase 7.2: Team location/command sends flow through Team action sinks. - // Rich Team payload rendering remains bounded legacy. - bool sendTeamLocationWithIcon(uint8_t icon_id); void openTeamPositionPicker(); void closeTeamPositionPicker(bool restore_group); void updateTeamPositionPickerHint(uint8_t icon_id); diff --git a/modules/ui_shared/include/ui/screens/contacts/contacts_team_snapshot_source.h b/modules/ui_shared/include/ui/screens/contacts/contacts_team_snapshot_source.h new file mode 100644 index 00000000..a1aa64a8 --- /dev/null +++ b/modules/ui_shared/include/ui/screens/contacts/contacts_team_snapshot_source.h @@ -0,0 +1,35 @@ +#pragma once + +#include "platform/ui/team_ui_snapshot_store.h" + +#include + +namespace contacts::ui +{ + +struct ContactsTeamSnapshot +{ + bool available = false; + bool in_team = false; + bool has_team_id = false; + ::team::TeamId team_id{}; + std::string team_name; +}; + +class ContactsTeamSnapshotSource +{ + public: + explicit ContactsTeamSnapshotSource( + ::team::ui::ITeamUiSnapshotStore& snapshot_store); + + bool load(ContactsTeamSnapshot& out); + bool isAvailable(); + + private: + ::team::ui::ITeamUiSnapshotStore& snapshot_store_; +}; + +std::string contactsTeamDisplayName(const ContactsTeamSnapshot& snapshot, + const char* fallback); + +} // namespace contacts::ui diff --git a/modules/ui_shared/include/ui/screens/gps/gps_page_map.h b/modules/ui_shared/include/ui/screens/gps/gps_page_map.h index 0de199b4..dcac1b91 100644 --- a/modules/ui_shared/include/ui/screens/gps/gps_page_map.h +++ b/modules/ui_shared/include/ui/screens/gps/gps_page_map.h @@ -16,7 +16,7 @@ void update_gps_marker_position(); void create_gps_marker(); void hide_gps_marker(); -void refresh_team_markers_from_posring(); +void refresh_team_markers_from_team_source(); void update_team_marker_positions(); void clear_team_markers(); void refresh_team_signal_markers_from_chatlog(); diff --git a/modules/ui_shared/include/ui/screens/team/team_page_activity_sink.h b/modules/ui_shared/include/ui/screens/team/team_page_activity_sink.h new file mode 100644 index 00000000..32406fbb --- /dev/null +++ b/modules/ui_shared/include/ui/screens/team/team_page_activity_sink.h @@ -0,0 +1,161 @@ +#pragma once + +#include "team/domain/team_events.h" +#include "team/protocol/team_chat.h" +#include "team/protocol/team_track.h" + +#include +#include +#include + +namespace team +{ +namespace ui +{ + +class ITeamPagePositionLog +{ + public: + virtual ~ITeamPagePositionLog() = default; + + virtual bool appendPosition(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) = 0; + virtual bool appendMemberTrack( + const TeamId& team_id, + uint32_t member_id, + const team::proto::TeamTrackMessage& track) = 0; + virtual bool memberTrackPath(const TeamId& team_id, + uint32_t member_id, + std::string& out_path) = 0; +}; + +class ITeamPageChatLog +{ + public: + virtual ~ITeamPageChatLog() = default; + + virtual bool appendStructuredChat( + const TeamId& team_id, + uint32_t peer_id, + bool incoming, + uint32_t ts, + team::proto::TeamChatType type, + const std::vector& payload) = 0; +}; + +class ITeamPageGpsTrackLoader +{ + public: + virtual ~ITeamPageGpsTrackLoader() = default; + + virtual uint32_t selectedMemberId() const = 0; + virtual bool loadTrackFile(const char* path, bool show_toast) = 0; +}; + +class ITeamPageUnreadPublisher +{ + public: + virtual ~ITeamPageUnreadPublisher() = default; + + virtual void publishTeamUnread(uint32_t unread_count) = 0; +}; + +struct TeamPageActivityState +{ + TeamId team_id{}; + bool has_team_id = false; + uint32_t team_chat_unread = 0; +}; + +struct TeamPageActivityContext +{ + uint32_t now_s = 0; + uint32_t self_node_id = 0; + bool team_chat_visible = false; +}; + +class TeamPageActivitySink +{ + public: + TeamPageActivitySink(ITeamPagePositionLog& position_log, + ITeamPageChatLog& chat_log, + ITeamPageGpsTrackLoader& gps_track_loader, + ITeamPageUnreadPublisher& unread_publisher, + TeamPageActivityContext context); + + void consumePosition(TeamPageActivityState& state, + const team::TeamPositionEvent& event) const; + void consumeWaypoint(TeamPageActivityState& state, + const team::TeamWaypointEvent& event) const; + void consumeTrack(TeamPageActivityState& state, + const team::TeamTrackEvent& event) const; + void consumeChat(TeamPageActivityState& state, + const team::TeamChatEvent& event) const; + + private: + uint32_t fallbackTimestamp(uint32_t preferred_ts, + uint32_t event_ts) const; + bool appendPositionSample(const TeamPageActivityState& state, + uint32_t member_id, + int32_t lat_e7, + int32_t lon_e7, + int16_t alt_m, + uint16_t speed_dmps, + uint32_t ts) const; + void publishUnreadIfChanged(uint32_t previous, + uint32_t current) const; + + ITeamPagePositionLog& position_log_; + ITeamPageChatLog& chat_log_; + ITeamPageGpsTrackLoader& gps_track_loader_; + ITeamPageUnreadPublisher& unread_publisher_; + TeamPageActivityContext context_; +}; + +class TeamPageActivityStoreAdapter final : public ITeamPagePositionLog, + public ITeamPageChatLog +{ + public: + bool appendPosition(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) override; + bool appendMemberTrack( + const TeamId& team_id, + uint32_t member_id, + const team::proto::TeamTrackMessage& track) override; + bool memberTrackPath(const TeamId& team_id, + uint32_t member_id, + std::string& out_path) override; + bool appendStructuredChat( + const TeamId& team_id, + uint32_t peer_id, + bool incoming, + uint32_t ts, + team::proto::TeamChatType type, + const std::vector& payload) override; +}; + +class TeamPageGpsTrackLoaderAdapter final : public ITeamPageGpsTrackLoader +{ + public: + uint32_t selectedMemberId() const override; + bool loadTrackFile(const char* path, bool show_toast) override; +}; + +class TeamPageUnreadPublisherAdapter final : public ITeamPageUnreadPublisher +{ + public: + void publishTeamUnread(uint32_t unread_count) override; +}; + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/include/ui/screens/team/team_page_command_reducer.h b/modules/ui_shared/include/ui/screens/team/team_page_command_reducer.h new file mode 100644 index 00000000..65021635 --- /dev/null +++ b/modules/ui_shared/include/ui/screens/team/team_page_command_reducer.h @@ -0,0 +1,122 @@ +#pragma once + +#include "platform/ui/team_ui_types.h" +#include "team/domain/team_types.h" +#include "team/protocol/team_mgmt.h" + +#include +#include +#include +#include + +namespace team +{ +namespace ui +{ + +struct TeamPageCommandState +{ + 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; + TeamPairingRole pairing_role = TeamPairingRole::None; + TeamPairingState pairing_state = TeamPairingState::Idle; + uint32_t pairing_peer_id = 0; + std::string pairing_team_name; + + TeamId team_id{}; + bool has_team_id = false; + std::string team_name; + uint32_t security_round = 0; + uint32_t last_update_s = 0; + std::array team_psk{}; + bool has_team_psk = false; + bool waiting_new_keys = false; + + int selected_member_index = -1; + std::vector members; +}; + +struct TeamPageCommandContext +{ + uint32_t now_s = 0; + uint32_t self_node_id = 0; +}; + +struct TeamPageGeneratedTeamSecrets +{ + TeamId team_id{}; + std::array team_psk{}; +}; + +struct TeamPageKickRotation +{ + bool rotate_keys = false; + uint32_t key_id = 0; + std::array team_psk{}; +}; + +struct TeamPageCommandEffects +{ + bool accepted = false; + bool changed = false; + bool clear_keys = false; + bool stop_pairing = false; + bool reset_controller_ui = false; + bool clear_keydist_pending = false; + bool clear_status_pending = false; + bool team_created_key_event = false; + bool keys_changed = false; + bool status_should_send = false; + bool member_kicked = false; + uint32_t member_kicked_id = 0; + bool leader_transferred_key_event = false; + uint32_t leader_target = 0; +}; + +class TeamPageCommandReducer +{ + public: + explicit TeamPageCommandReducer(TeamPageCommandContext context); + + TeamPageCommandEffects reduceCreate( + TeamPageCommandState& state, + const TeamPageGeneratedTeamSecrets& generated) const; + TeamPageCommandEffects reduceLeave(TeamPageCommandState& state) const; + TeamPageCommandEffects reduceKickedOut(TeamPageCommandState& state) const; + TeamPageCommandEffects reduceClearKickedOut( + TeamPageCommandState& state) const; + TeamPageCommandEffects reducePairingStarted( + TeamPageCommandState& state, + TeamPairingRole role, + TeamPairingState pairing_state) const; + TeamPageCommandEffects reduceJoinCanceled( + TeamPageCommandState& state) const; + TeamPageCommandEffects reduceKickConfirmed( + TeamPageCommandState& state, + uint32_t target_node_id, + const TeamPageKickRotation& rotation) const; + TeamPageCommandEffects reduceTransferLeader( + TeamPageCommandState& state, + uint32_t target_node_id) const; + + static std::string formatTeamNameFromId(const TeamId& id); + + private: + void resetTeamMembership(TeamPageCommandState& state, + bool kicked_out) const; + void resetPairing(TeamPageCommandState& state) const; + void ensureSelfMember(TeamPageCommandState& state, + bool leader) const; + int findMemberIndex(const TeamPageCommandState& state, + uint32_t node_id) const; + void assignMemberColor(TeamMemberUi& member) const; + + TeamPageCommandContext context_; +}; + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/include/ui/screens/team/team_page_create_team_action.h b/modules/ui_shared/include/ui/screens/team/team_page_create_team_action.h new file mode 100644 index 00000000..36a59ebb --- /dev/null +++ b/modules/ui_shared/include/ui/screens/team/team_page_create_team_action.h @@ -0,0 +1,71 @@ +#pragma once + +#include "team/usecase/team_service.h" +#include "ui/screens/team/team_page_command_reducer.h" +#include "ui/screens/team/team_page_key_event_log.h" +#include "ui/screens/team/team_page_runtime_port.h" + +#include +#include + +namespace team +{ +namespace ui +{ + +class ITeamPageCreateTeamRandom +{ + public: + virtual ~ITeamPageCreateTeamRandom() = default; + virtual uint8_t nextByte() = 0; +}; + +enum class TeamPageCreateTeamFailureAction : uint8_t +{ + Keys, + Pairing +}; + +enum class TeamPageCreateTeamFailureKind : uint8_t +{ + SendFailed, + PairingNotReady, + PairingInitFailed +}; + +struct TeamPageCreateTeamFailure +{ + TeamPageCreateTeamFailureAction action = + TeamPageCreateTeamFailureAction::Pairing; + TeamPageCreateTeamFailureKind kind = + TeamPageCreateTeamFailureKind::SendFailed; + bool needs_keys = false; + team::TeamService::SendError error = team::TeamService::SendError::None; +}; + +struct TeamPageCreateTeamEffects +{ + TeamPageCommandEffects command; + std::vector failures; + bool accepted = false; + bool appended_key_event = false; + bool applied_keys = false; + bool saved_keys = false; + bool started_pairing = false; +}; + +class TeamPageCreateTeamAction +{ + public: + TeamPageCreateTeamEffects createTeam( + TeamPageCommandState& state, + TeamPageKeyEventState& key_event_state, + const TeamPageCommandReducer& reducer, + const TeamPageRuntimePort& runtime, + const TeamPageKeyEventLog& key_log, + ITeamPageCreateTeamRandom& random, + uint32_t self_node_id) const; +}; + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/include/ui/screens/team/team_page_deferred_dispatch.h b/modules/ui_shared/include/ui/screens/team/team_page_deferred_dispatch.h new file mode 100644 index 00000000..8da87b35 --- /dev/null +++ b/modules/ui_shared/include/ui/screens/team/team_page_deferred_dispatch.h @@ -0,0 +1,160 @@ +#pragma once + +#include "chat/domain/chat_types.h" +#include "team/domain/team_types.h" +#include "team/protocol/team_mgmt.h" +#include "team/usecase/team_service.h" + +#include +#include +#include +#include + +namespace team +{ +namespace ui +{ + +class TeamPageRuntimePort; + +class ITeamPageDeferredDispatchPort +{ + public: + virtual ~ITeamPageDeferredDispatchPort() = default; + + virtual bool hasController() const = 0; + virtual bool sendKeyDistPlain(const team::proto::TeamKeyDist& key_dist, + chat::ChannelId channel, + chat::NodeId dest) = 0; + virtual bool sendStatus(const team::proto::TeamStatus& status, + chat::ChannelId channel, + chat::NodeId dest) = 0; + virtual bool sendStatusPlain(const team::proto::TeamStatus& status, + chat::ChannelId channel, + chat::NodeId dest) = 0; + virtual team::TeamService::SendError lastSendError() const = 0; +}; + +struct TeamPageDeferredDispatchConfig +{ + uint8_t keydist_max_retries = 3; + uint32_t keydist_retry_interval_s = 5; + uint32_t status_rebroadcast_interval_s = 2; +}; + +struct TeamPageDeferredDispatchState +{ + bool in_team = false; + bool has_team_id = false; + bool self_is_leader = false; + TeamId team_id{}; + uint32_t security_round = 0; + std::array team_psk{}; + bool has_team_psk = false; +}; + +enum class TeamPageDeferredDispatchAction : uint8_t +{ + KeyDist, + Status +}; + +enum class TeamPageDeferredDispatchFailureKind : uint8_t +{ + SendFailed, + SendFailedDetail +}; + +struct TeamPageDeferredDispatchFailure +{ + TeamPageDeferredDispatchAction action = + TeamPageDeferredDispatchAction::Status; + TeamPageDeferredDispatchFailureKind kind = + TeamPageDeferredDispatchFailureKind::SendFailed; + bool needs_keys = false; + team::TeamService::SendError error = team::TeamService::SendError::None; +}; + +struct TeamPageDeferredDispatchEffects +{ + std::vector failures; + bool sent_keydist = false; + bool sent_status = false; +}; + +class TeamPageDeferredDispatchQueue +{ + public: + explicit TeamPageDeferredDispatchQueue( + TeamPageDeferredDispatchConfig config = {}); + + void clearAll(); + void clearKeyDist(); + void clearStatusBroadcasts(); + + void enqueueKeyDist(uint32_t node_id, + uint32_t key_id, + uint32_t now_s); + void confirmKeyDist(uint32_t node_id, uint32_t key_id); + void scheduleStatusBroadcast(uint8_t repeats, + uint32_t now_s, + uint32_t delay_s); + + TeamPageDeferredDispatchEffects processKeyDistRetries( + const TeamPageDeferredDispatchState& state, + ITeamPageDeferredDispatchPort& port, + uint32_t now_s); + TeamPageDeferredDispatchEffects processStatusBroadcasts( + const TeamPageDeferredDispatchState& state, + const team::proto::TeamStatus& status, + ITeamPageDeferredDispatchPort& port, + uint32_t now_s); + + size_t keyDistPendingCount() const; + size_t statusBroadcastPendingCount() const; + + private: + struct KeyDistPending + { + uint32_t node_id = 0; + uint32_t key_id = 0; + uint8_t attempts = 0; + uint32_t next_retry_s = 0; + }; + + struct StatusBroadcastPending + { + uint32_t next_send_s = 0; + uint8_t remaining = 0; + }; + + TeamPageDeferredDispatchConfig config_; + std::vector keydist_pending_; + std::vector status_pending_; +}; + +class TeamPageDeferredDispatchRuntimeAdapter final + : public ITeamPageDeferredDispatchPort +{ + public: + explicit TeamPageDeferredDispatchRuntimeAdapter( + const TeamPageRuntimePort& runtime); + + bool hasController() const override; + bool sendKeyDistPlain(const team::proto::TeamKeyDist& key_dist, + chat::ChannelId channel, + chat::NodeId dest) override; + bool sendStatus(const team::proto::TeamStatus& status, + chat::ChannelId channel, + chat::NodeId dest) override; + bool sendStatusPlain(const team::proto::TeamStatus& status, + chat::ChannelId channel, + chat::NodeId dest) override; + team::TeamService::SendError lastSendError() const override; + + private: + const TeamPageRuntimePort& runtime_; +}; + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/include/ui/screens/team/team_page_event_effect_sink.h b/modules/ui_shared/include/ui/screens/team/team_page_event_effect_sink.h new file mode 100644 index 00000000..30b7e4ee --- /dev/null +++ b/modules/ui_shared/include/ui/screens/team/team_page_event_effect_sink.h @@ -0,0 +1,63 @@ +#pragma once + +#include "ui/screens/team/team_page_event_reducer.h" +#include "ui/screens/team/team_page_key_event_log.h" +#include "ui/screens/team/team_page_runtime_port.h" + +#include + +namespace team +{ +namespace ui +{ + +class ITeamPageEventDeferred +{ + public: + virtual ~ITeamPageEventDeferred() = default; + virtual void confirmKeyDist(uint32_t node_id, uint32_t key_id) = 0; + virtual void scheduleStatusBroadcast(uint8_t repeats, + uint32_t delay_s) = 0; +}; + +class ITeamPageEventNotifier +{ + public: + virtual ~ITeamPageEventNotifier() = default; + virtual void showMessage(const char* message) = 0; + virtual void notifySendFailed(const char* action, bool needs_keys) = 0; +}; + +struct TeamPageEventEffectResult +{ + bool confirmed_keydist = false; + bool appended_epoch_rotated = false; + bool appended_member_accepted = false; + bool saved_keys = false; + bool applied_keys = false; + bool sent_status = false; + bool sent_status_plain = false; + bool scheduled_status_broadcast = false; + bool request_status_in_team_page = false; + bool request_status_not_in_team_page = false; + bool request_kicked_out_page = false; + bool clear_nav_stack = false; +}; + +class TeamPageEventEffectSink +{ + public: + TeamPageEventEffectResult applyEffects( + const TeamPageEventState& state, + TeamPageKeyEventState& key_event_state, + const TeamPageEventEffects& effects, + const TeamPageEventReducer& reducer, + const TeamPageRuntimePort& runtime, + const TeamPageKeyEventLog& key_log, + ITeamPageEventDeferred& deferred, + ITeamPageEventNotifier& notifier, + const ITeamPageMemberNameResolver& names) const; +}; + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/include/ui/screens/team/team_page_event_reducer.h b/modules/ui_shared/include/ui/screens/team/team_page_event_reducer.h new file mode 100644 index 00000000..99b4bf2d --- /dev/null +++ b/modules/ui_shared/include/ui/screens/team/team_page_event_reducer.h @@ -0,0 +1,153 @@ +#pragma once + +#include "platform/ui/team_ui_types.h" +#include "team/domain/team_events.h" + +#include +#include +#include +#include + +namespace team +{ +namespace ui +{ + +struct TeamPageEventState +{ + 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; + TeamPairingRole pairing_role = TeamPairingRole::None; + TeamPairingState pairing_state = TeamPairingState::Idle; + uint32_t pairing_peer_id = 0; + std::string pairing_team_name; + TeamId team_id{}; + bool has_team_id = false; + std::string team_name; + uint32_t security_round = 0; + uint32_t last_update_s = 0; + std::array team_psk{}; + bool has_team_psk = false; + bool waiting_new_keys = false; + std::vector members; +}; + +class ITeamPageMemberNameResolver +{ + public: + virtual ~ITeamPageMemberNameResolver() = default; + virtual std::string resolveMemberName(uint32_t node_id) const = 0; +}; + +struct TeamPageEventContext +{ + uint32_t now_s = 0; + uint32_t self_node_id = 0; + const ITeamPageMemberNameResolver* names = nullptr; +}; + +struct TeamPageEventEffects +{ + bool accepted = false; + bool changed = false; + bool show_key_mismatch = false; + bool clear_keys = false; + bool stop_pairing = false; + bool clear_keydist_pending = false; + bool clear_status_pending = false; + bool save_keys = false; + bool apply_keys = false; + bool request_status_in_team_page = false; + bool request_status_not_in_team_page = false; + bool request_kicked_out_page = false; + bool clear_nav_stack = false; + bool epoch_rotated = false; + uint32_t epoch_key_id = 0; + bool keydist_confirmed = false; + uint32_t keydist_member_id = 0; + uint32_t keydist_key_id = 0; + bool member_kicked_key_event = false; + uint32_t member_kicked_id = 0; + bool member_accepted_key_event = false; + uint32_t member_accepted_id = 0; + bool status_should_send = false; + bool show_pairing_peer = false; + uint32_t pairing_peer_id = 0; + bool show_pairing_success = false; + bool show_pairing_failed = false; +}; + +struct TeamPagePairingUpdate +{ + TeamPairingRole role = TeamPairingRole::None; + TeamPairingState state = TeamPairingState::Idle; + TeamId team_id{}; + bool has_team_id = false; + uint32_t key_id = 0; + uint32_t peer_id = 0; + std::string team_name; + bool has_team_name = false; +}; + +class TeamPageEventReducer +{ + public: + explicit TeamPageEventReducer(TeamPageEventContext context); + + int findMemberIndex(const TeamPageEventState& state, uint32_t node_id) const; + void touchMember(TeamPageEventState& state, + uint32_t node_id, + uint32_t last_seen_s) const; + void fillStatusMembers(const TeamPageEventState& state, + team::proto::TeamStatus& status) const; + void applyStatusRoster(TeamPageEventState& state, + const team::proto::TeamStatus& status) const; + + TeamPageEventEffects reduceError(TeamPageEventState& state, + const team::TeamErrorEvent& event) const; + TeamPageEventEffects reduceStatus(TeamPageEventState& state, + const team::TeamStatusEvent& event) const; + TeamPageEventEffects reduceActivity(TeamPageEventState& state, + const team::TeamEventContext& event, + uint32_t member_id) const; + TeamPageEventEffects reduceKick(TeamPageEventState& state, + const team::TeamKickEvent& event) const; + TeamPageEventEffects reduceTransferLeader( + TeamPageEventState& state, + const team::TeamTransferLeaderEvent& event) const; + TeamPageEventEffects reduceKeyDist( + TeamPageEventState& state, + const team::TeamKeyDistEvent& event) const; + TeamPageEventEffects reducePairing( + TeamPageEventState& state, + const TeamPagePairingUpdate& update) const; + TeamPageEventEffects reducePairingStatus( + TeamPageEventState& state, + const TeamPagePairingUpdate& update) const; + + static std::string formatTeamNameFromId(const TeamId& id); + + private: + bool acceptsTeam(const TeamPageEventState& state, + const TeamEventContext& event) const; + void ensureTeamIdentity(TeamPageEventState& state, + const TeamEventContext& event) const; + void ensureSelfMember(TeamPageEventState& state, + bool leader) const; + void resetTeamMembership(TeamPageEventState& state, + bool kicked_out) const; + void applyPairingIdentity(TeamPageEventState& state, + const TeamPagePairingUpdate& update) const; + bool isPairingActive(TeamPairingState state) const; + std::string resolveMemberName(uint32_t node_id) const; + void assignMemberColor(TeamMemberUi& member) const; + + TeamPageEventContext context_; +}; + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/include/ui/screens/team/team_page_flow_controller.h b/modules/ui_shared/include/ui/screens/team/team_page_flow_controller.h new file mode 100644 index 00000000..b165e23f --- /dev/null +++ b/modules/ui_shared/include/ui/screens/team/team_page_flow_controller.h @@ -0,0 +1,48 @@ +#pragma once + +#include "team/domain/team_types.h" +#include "ui/screens/team/team_page_types.h" + +#include +#include + +namespace team +{ +namespace ui +{ + +struct TeamPageFlowState +{ + TeamPage page = TeamPage::StatusNotInTeam; + std::vector nav_stack; + bool in_team = false; + bool pending_join = false; + bool kicked_out = false; + uint32_t pending_join_started_s = 0; + TeamPairingState pairing_state = TeamPairingState::Idle; +}; + +struct TeamPageFlowResult +{ + bool changed = false; + bool request_exit = false; +}; + +class TeamPageFlowController +{ + public: + TeamPageFlowResult navigateTo(TeamPageFlowState& state, + TeamPage page, + bool push) const; + TeamPageFlowResult navigateBack(TeamPageFlowState& state) const; + TeamPageFlowResult resetTo(TeamPageFlowState& state, + TeamPage page) const; + TeamPageFlowResult selectInitialPage(TeamPageFlowState& state) const; + TeamPageFlowResult syncRuntime(TeamPageFlowState& state, + uint32_t now_s) const; + + static bool isPairingActive(TeamPairingState state); +}; + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/include/ui/screens/team/team_page_input.h b/modules/ui_shared/include/ui/screens/team/team_page_input.h index afab754f..6fa3a8a0 100644 --- a/modules/ui_shared/include/ui/screens/team/team_page_input.h +++ b/modules/ui_shared/include/ui/screens/team/team_page_input.h @@ -5,6 +5,27 @@ #pragma once -void init_team_input(); -void refresh_team_input(); +#include "lvgl.h" +#include "ui/widgets/top_bar.h" + +#include + +namespace team +{ +namespace ui +{ + +struct TeamPageInputContext +{ + lv_obj_t* root = nullptr; + ::ui::widgets::TopBar* top_bar = nullptr; + std::vector* focusables = nullptr; + lv_obj_t** default_focus = nullptr; +}; + +void init_team_input(const TeamPageInputContext& context); +void refresh_team_input(const TeamPageInputContext& context); void cleanup_team_input(); + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/include/ui/screens/team/team_page_key_event_log.h b/modules/ui_shared/include/ui/screens/team/team_page_key_event_log.h new file mode 100644 index 00000000..676c996c --- /dev/null +++ b/modules/ui_shared/include/ui/screens/team/team_page_key_event_log.h @@ -0,0 +1,63 @@ +#pragma once + +#include "platform/ui/team_ui_store_runtime.h" + +#include + +namespace team +{ +namespace ui +{ + +struct TeamPageKeyEventState +{ + TeamId team_id{}; + bool has_team_id = false; + uint32_t last_event_seq = 0; + uint32_t security_round = 0; +}; + +class ITeamPageKeyEventWriter +{ + public: + virtual ~ITeamPageKeyEventWriter() = default; + virtual bool appendKeyEvent(const TeamId& team_id, + TeamKeyEventType type, + uint32_t event_seq, + uint32_t timestamp_s, + const uint8_t* payload, + size_t payload_size) = 0; +}; + +class TeamPageKeyEventLog +{ + public: + TeamPageKeyEventLog(ITeamPageKeyEventWriter& writer, + uint32_t timestamp_s); + + bool appendTeamCreated(TeamPageKeyEventState& state, + uint32_t leader_id) const; + bool appendMemberAccepted(TeamPageKeyEventState& state, + uint32_t member_id, + uint8_t role) const; + bool appendMemberKicked(TeamPageKeyEventState& state, + uint32_t member_id) const; + bool appendLeaderTransferred(TeamPageKeyEventState& state, + uint32_t leader_id) const; + bool appendEpochRotated(TeamPageKeyEventState& state, + uint32_t key_id) const; + + static uint64_t teamIdToU64(const TeamId& id); + + private: + bool append(TeamPageKeyEventState& state, + TeamKeyEventType type, + const uint8_t* payload, + size_t payload_size) const; + + ITeamPageKeyEventWriter& writer_; + uint32_t timestamp_s_ = 0; +}; + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/include/ui/screens/team/team_page_key_request_action.h b/modules/ui_shared/include/ui/screens/team/team_page_key_request_action.h new file mode 100644 index 00000000..bfd2b81c --- /dev/null +++ b/modules/ui_shared/include/ui/screens/team/team_page_key_request_action.h @@ -0,0 +1,45 @@ +#pragma once + +#include "team/usecase/team_service.h" +#include "ui/screens/team/team_page_command_reducer.h" +#include "ui/screens/team/team_page_runtime_port.h" + +#include +#include + +namespace team +{ +namespace ui +{ + +enum class TeamPageKeyRequestFailureKind : uint8_t +{ + Ignored, + SendFailedDetail +}; + +struct TeamPageKeyRequestFailure +{ + TeamPageKeyRequestFailureKind kind = + TeamPageKeyRequestFailureKind::Ignored; + team::TeamService::SendError error = team::TeamService::SendError::None; +}; + +struct TeamPageKeyRequestEffects +{ + std::vector failures; + bool accepted = false; + bool sent_keydist = false; +}; + +class TeamPageKeyRequestAction +{ + public: + TeamPageKeyRequestEffects handleRequest( + const TeamPageCommandState& state, + const team::TeamKeyRequestEvent& event, + const TeamPageRuntimePort& runtime) const; +}; + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/include/ui/screens/team/team_page_kick_confirm_action.h b/modules/ui_shared/include/ui/screens/team/team_page_kick_confirm_action.h new file mode 100644 index 00000000..5137b60f --- /dev/null +++ b/modules/ui_shared/include/ui/screens/team/team_page_kick_confirm_action.h @@ -0,0 +1,78 @@ +#pragma once + +#include "team/usecase/team_service.h" +#include "ui/screens/team/team_page_command_reducer.h" +#include "ui/screens/team/team_page_runtime_port.h" + +#include +#include + +namespace team +{ +namespace ui +{ + +class ITeamPageKickConfirmRandom +{ + public: + virtual ~ITeamPageKickConfirmRandom() = default; + virtual uint8_t nextByte() = 0; +}; + +class ITeamPageKickConfirmDeferred +{ + public: + virtual ~ITeamPageKickConfirmDeferred() = default; + virtual void enqueueKeyDist(uint32_t node_id, uint32_t key_id) = 0; +}; + +enum class TeamPageKickConfirmFailureAction : uint8_t +{ + Kick, + KeyDist, + Keys, + Status +}; + +enum class TeamPageKickConfirmFailureKind : uint8_t +{ + SendFailed, + SendFailedDetail +}; + +struct TeamPageKickConfirmFailure +{ + TeamPageKickConfirmFailureAction action = + TeamPageKickConfirmFailureAction::Status; + TeamPageKickConfirmFailureKind kind = + TeamPageKickConfirmFailureKind::SendFailed; + bool needs_keys = false; + team::TeamService::SendError error = team::TeamService::SendError::None; +}; + +struct TeamPageKickConfirmEffects +{ + TeamPageCommandEffects command; + std::vector failures; + bool accepted = false; + bool sent_kick = false; + bool sent_keydist = false; + bool enqueued_keydist = false; + bool applied_keys = false; + bool sent_status = false; +}; + +class TeamPageKickConfirmAction +{ + public: + TeamPageKickConfirmEffects confirmKick( + TeamPageCommandState& state, + const TeamPageCommandReducer& reducer, + const TeamPageRuntimePort& runtime, + ITeamPageKickConfirmRandom& random, + ITeamPageKickConfirmDeferred& deferred, + uint32_t self_node_id) const; +}; + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/include/ui/screens/team/team_page_lvgl_renderer.h b/modules/ui_shared/include/ui/screens/team/team_page_lvgl_renderer.h new file mode 100644 index 00000000..9efa313e --- /dev/null +++ b/modules/ui_shared/include/ui/screens/team/team_page_lvgl_renderer.h @@ -0,0 +1,138 @@ +#pragma once + +#include "lvgl.h" +#include "ui/screens/team/team_page_read_model.h" +#include "ui/screens/team/team_page_types.h" +#include "ui/widgets/top_bar.h" + +#include +#include +#include +#include + +namespace team +{ +namespace ui +{ + +class ITeamPageLvglNameResolver +{ + public: + virtual ~ITeamPageLvglNameResolver() = default; + virtual std::string resolveNodeName(uint32_t node_id) const = 0; +}; + +struct TeamPageLvglRendererHandlers +{ + lv_event_cb_t create_team = nullptr; + lv_event_cb_t join_team = nullptr; + lv_event_cb_t view_team = nullptr; + lv_event_cb_t invite = nullptr; + lv_event_cb_t request_keys = nullptr; + lv_event_cb_t leave = nullptr; + lv_event_cb_t manage = nullptr; + lv_event_cb_t member_clicked = nullptr; + lv_event_cb_t kick = nullptr; + lv_event_cb_t transfer_leader = nullptr; + lv_event_cb_t kick_cancel = nullptr; + lv_event_cb_t kick_confirm = nullptr; + lv_event_cb_t join_cancel = nullptr; + lv_event_cb_t join_retry = nullptr; + lv_event_cb_t kicked_join = nullptr; + lv_event_cb_t kicked_ok = nullptr; +}; + +struct TeamPageLvglRendererContext +{ + lv_obj_t* body = nullptr; + lv_obj_t* actions = nullptr; + ::ui::widgets::TopBar* top_bar = nullptr; + + lv_obj_t** action_btns = nullptr; + std::size_t action_btn_count = 0; + lv_obj_t** action_labels = nullptr; + std::size_t action_label_count = 0; + lv_obj_t** detail_label = nullptr; + + std::vector* list_items = nullptr; + std::vector* focusables = nullptr; + lv_obj_t** default_focus = nullptr; +}; + +struct TeamPageLvglRendererInput +{ + TeamPage page = TeamPage::StatusNotInTeam; + TeamPageReadModelInput read_model; + uint32_t pairing_peer_id = 0; +}; + +class TeamPageLvglRenderer +{ + public: + explicit TeamPageLvglRenderer(uint32_t now_s); + + void render(TeamPageLvglRendererContext& context, + const TeamPageLvglRendererInput& input, + const TeamPageLvglRendererHandlers& handlers, + const ITeamPageLvglNameResolver& names) const; + + private: + void clearContent(TeamPageLvglRendererContext& context) const; + void registerFocus(TeamPageLvglRendererContext& context, + lv_obj_t* obj, + bool is_default = false) const; + void updateTopBarTitle(TeamPageLvglRendererContext& context, + const char* title) const; + + lv_obj_t* addLabel(lv_obj_t* parent, + const char* text, + bool section = false, + bool meta = false) const; + lv_obj_t* createFittedButton(lv_obj_t* parent, + const char* text, + lv_event_cb_t cb) const; + lv_obj_t* createActionButton(TeamPageLvglRendererContext& context, + const char* text, + lv_event_cb_t cb) const; + lv_obj_t* createListItem(TeamPageLvglRendererContext& context, + const char* left, + const char* right) const; + + void renderMemberChipRow(TeamPageLvglRendererContext& context, + const std::vector& rows) const; + void renderMemberChipsOrEmpty( + TeamPageLvglRendererContext& context, + const TeamPageReadModelInput& input) const; + + void renderStatusNotInTeam(TeamPageLvglRendererContext& context, + const TeamPageLvglRendererHandlers& handlers) const; + void renderStatusInTeam(TeamPageLvglRendererContext& context, + const TeamPageReadModelInput& input, + const TeamPageLvglRendererHandlers& handlers) const; + void renderTeamHome(TeamPageLvglRendererContext& context, + const TeamPageReadModelInput& input, + const TeamPageLvglRendererHandlers& handlers) const; + void renderJoinPending(TeamPageLvglRendererContext& context, + const TeamPageLvglRendererInput& input, + const TeamPageLvglRendererHandlers& handlers, + const ITeamPageLvglNameResolver& names) const; + void renderMembers(TeamPageLvglRendererContext& context, + const TeamPageReadModelInput& input, + const TeamPageLvglRendererHandlers& handlers) const; + void renderMemberDetail(TeamPageLvglRendererContext& context, + const TeamPageReadModelInput& input, + const TeamPageLvglRendererHandlers& handlers) const; + void renderKickConfirm(TeamPageLvglRendererContext& context, + const TeamPageReadModelInput& input, + const TeamPageLvglRendererHandlers& handlers) const; + void renderKickedOut(TeamPageLvglRendererContext& context, + const TeamPageLvglRendererHandlers& handlers) const; + + std::string formatLastSeen(TeamRelativeTimeView view) const; + std::string formatLastUpdate(TeamRelativeTimeView view) const; + + uint32_t now_s_ = 0; +}; + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/include/ui/screens/team/team_page_pairing_command_action.h b/modules/ui_shared/include/ui/screens/team/team_page_pairing_command_action.h new file mode 100644 index 00000000..23e35b05 --- /dev/null +++ b/modules/ui_shared/include/ui/screens/team/team_page_pairing_command_action.h @@ -0,0 +1,55 @@ +#pragma once + +#include "ui/screens/team/team_page_command_reducer.h" +#include "ui/screens/team/team_page_runtime_port.h" + +#include +#include + +namespace team +{ +namespace ui +{ + +enum class TeamPagePairingCommandRole : uint8_t +{ + Leader, + Member +}; + +enum class TeamPagePairingCommandFailureKind : uint8_t +{ + LeaderRequired, + PairingNotReady, + PairingNotAvailable, + PairingInitFailed +}; + +struct TeamPagePairingCommandFailure +{ + TeamPagePairingCommandRole role = TeamPagePairingCommandRole::Member; + TeamPagePairingCommandFailureKind kind = + TeamPagePairingCommandFailureKind::PairingInitFailed; +}; + +struct TeamPagePairingCommandEffects +{ + TeamPageCommandEffects command; + std::vector failures; + bool accepted = false; + bool started_pairing = false; +}; + +class TeamPagePairingCommandAction +{ + public: + TeamPagePairingCommandEffects startPairing( + TeamPageCommandState& state, + const TeamPageCommandReducer& reducer, + const TeamPageRuntimePort& runtime, + TeamPagePairingCommandRole role, + uint32_t self_node_id) const; +}; + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/include/ui/screens/team/team_page_read_model.h b/modules/ui_shared/include/ui/screens/team/team_page_read_model.h new file mode 100644 index 00000000..2c20e23a --- /dev/null +++ b/modules/ui_shared/include/ui/screens/team/team_page_read_model.h @@ -0,0 +1,123 @@ +#pragma once + +#include "platform/ui/team_ui_types.h" +#include "team/domain/team_types.h" + +#include +#include +#include +#include + +namespace team +{ +namespace ui +{ + +struct TeamUiSnapshot; + +enum class TeamRelativeTimeKind +{ + Unknown, + Online, + SecondsAgo, + MinutesAgo, + HoursAgo +}; + +struct TeamRelativeTimeView +{ + TeamRelativeTimeKind kind = TeamRelativeTimeKind::Unknown; + uint32_t value = 0; +}; + +struct TeamPageReadModelInput +{ + bool in_team = false; + bool pending_join = false; + bool kicked_out = false; + bool self_is_leader = false; + bool waiting_new_keys = false; + + TeamId team_id{}; + bool has_team_id = false; + std::string team_name; + uint32_t security_round = 0; + uint32_t last_update_s = 0; + bool has_team_psk = false; + + TeamPairingRole pairing_role = TeamPairingRole::None; + TeamPairingState pairing_state = TeamPairingState::Idle; + std::string pairing_team_name; + + std::vector members; + int selected_member_index = -1; +}; + +struct TeamMemberRowView +{ + std::size_t source_index = 0; + uint32_t node_id = 0; + std::string name; + bool online = false; + bool leader = false; + uint8_t color_index = kTeamColorUnassigned; +}; + +struct TeamPageSummaryView +{ + bool in_team = false; + bool pending_join = false; + bool kicked_out = false; + bool self_is_leader = false; + bool waiting_new_keys = false; + + std::string team_name; + std::size_t member_count = 0; + std::size_t online_count = 0; + uint32_t security_round = 0; + bool has_security_round = false; + TeamRelativeTimeView last_update; +}; + +struct TeamMemberDetailView +{ + bool valid = false; + TeamMemberRowView member; + TeamRelativeTimeView last_seen; + bool management_actions_enabled = false; +}; + +struct TeamJoinPendingView +{ + const char* title = "Pairing"; + bool show_leader_members = false; + std::string target_team_name; + const char* state_line = "Waiting for handshake..."; +}; + +class TeamPageReadModel +{ + public: + explicit TeamPageReadModel(uint32_t now_s); + + static TeamPageReadModelInput inputFromSnapshot(const TeamUiSnapshot& snapshot); + static std::string formatTeamNameFromId(const TeamId& id); + + TeamPageSummaryView buildSummary(const TeamPageReadModelInput& input) const; + TeamJoinPendingView buildJoinPending(const TeamPageReadModelInput& input) const; + std::vector buildMemberRows( + const std::vector& members) const; + TeamMemberDetailView buildSelectedMember( + const TeamPageReadModelInput& input) const; + TeamRelativeTimeView buildLastSeen(uint32_t last_seen_s) const; + TeamRelativeTimeView buildLastUpdate(uint32_t last_update_s) const; + + private: + TeamMemberRowView buildMemberRow(const TeamMemberUi& member, + std::size_t source_index) const; + + uint32_t now_s_ = 0; +}; + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/include/ui/screens/team/team_page_request_keys_action.h b/modules/ui_shared/include/ui/screens/team/team_page_request_keys_action.h new file mode 100644 index 00000000..d1f8ff6e --- /dev/null +++ b/modules/ui_shared/include/ui/screens/team/team_page_request_keys_action.h @@ -0,0 +1,30 @@ +#pragma once + +#include "ui/screens/team/team_page_command_reducer.h" +#include "ui/screens/team/team_page_runtime_port.h" + +namespace team +{ +namespace ui +{ + +struct TeamPageRequestKeysEffects +{ + bool accepted = false; + bool ignored = false; + bool sent_request = false; + bool send_failed = false; + team::TeamService::SendError error = team::TeamService::SendError::None; +}; + +class TeamPageRequestKeysAction +{ + public: + TeamPageRequestKeysEffects requestKeys( + const TeamPageCommandState& state, + const TeamPageRuntimePort& runtime, + uint32_t self_node_id) const; +}; + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/include/ui/screens/team/team_page_runtime_port.h b/modules/ui_shared/include/ui/screens/team/team_page_runtime_port.h new file mode 100644 index 00000000..494980fa --- /dev/null +++ b/modules/ui_shared/include/ui/screens/team/team_page_runtime_port.h @@ -0,0 +1,214 @@ +#pragma once + +#include "chat/domain/chat_types.h" +#include "team/domain/team_types.h" +#include "team/protocol/team_mgmt.h" +#include "team/usecase/team_pairing_service.h" +#include "team/usecase/team_service.h" + +#include +#include +#include + +namespace team +{ + +class TeamController; +class TeamPairingService; + +namespace ui +{ + +class ITeamPageControllerPort +{ + public: + virtual ~ITeamPageControllerPort() = default; + + virtual void clearKeys() = 0; + virtual void resetUiState() = 0; + virtual bool setKeysFromPsk(const TeamId& team_id, + uint32_t key_id, + const uint8_t* psk, + size_t psk_len) = 0; + virtual bool sendKick(const team::proto::TeamKick& kick, + chat::ChannelId channel, + chat::NodeId dest) = 0; + virtual bool sendTransferLeader( + const team::proto::TeamTransferLeader& transfer, + chat::ChannelId channel, + chat::NodeId dest) = 0; + virtual bool sendKeyDist(const team::proto::TeamKeyDist& key_dist, + chat::ChannelId channel, + chat::NodeId dest) = 0; + virtual bool sendKeyDistPlain( + const team::proto::TeamKeyDist& key_dist, + chat::ChannelId channel, + chat::NodeId dest) = 0; + virtual bool sendKeyRequest( + const team::proto::TeamKeyRequest& request, + chat::ChannelId channel, + chat::NodeId dest) = 0; + virtual bool sendStatus(const team::proto::TeamStatus& status, + chat::ChannelId channel, + chat::NodeId dest) = 0; + virtual bool sendStatusPlain(const team::proto::TeamStatus& status, + chat::ChannelId channel, + chat::NodeId dest) = 0; + virtual team::TeamService::SendError lastSendError() const = 0; +}; + +class ITeamPagePairingPort +{ + public: + virtual ~ITeamPagePairingPort() = default; + + virtual bool startLeader(const TeamId& team_id, + uint32_t key_id, + const uint8_t* psk, + size_t psk_len, + uint32_t leader_id, + const char* team_name) = 0; + virtual bool startMember(uint32_t self_id) = 0; + virtual void stop() = 0; + virtual team::TeamPairingStatus status() const = 0; +}; + +class ITeamPageKeyStorePort +{ + public: + virtual ~ITeamPageKeyStorePort() = default; + + virtual bool saveKeysNow( + const TeamId& team_id, + uint32_t key_id, + const std::array& psk) = 0; +}; + +class TeamPageRuntimePort +{ + public: + TeamPageRuntimePort(ITeamPageControllerPort* controller, + ITeamPagePairingPort* pairing, + ITeamPageKeyStorePort* key_store); + + bool hasController() const; + bool hasPairing() const; + + void clearKeys() const; + void resetControllerUi() const; + void stopPairing() const; + team::TeamPairingStatus pairingStatus() const; + + bool startLeader(const TeamId& team_id, + uint32_t key_id, + const uint8_t* psk, + size_t psk_len, + uint32_t leader_id, + const char* team_name) const; + bool startMember(uint32_t self_id) const; + bool setKeysFromPsk(const TeamId& team_id, + uint32_t key_id, + const uint8_t* psk, + size_t psk_len) const; + bool saveKeysNow( + const TeamId& team_id, + uint32_t key_id, + const std::array& psk) const; + + bool sendKick(const team::proto::TeamKick& kick, + chat::ChannelId channel, + chat::NodeId dest = 0) const; + bool sendTransferLeader(const team::proto::TeamTransferLeader& transfer, + chat::ChannelId channel, + chat::NodeId dest = 0) const; + bool sendKeyDist(const team::proto::TeamKeyDist& key_dist, + chat::ChannelId channel, + chat::NodeId dest) const; + bool sendKeyDistPlain(const team::proto::TeamKeyDist& key_dist, + chat::ChannelId channel, + chat::NodeId dest) const; + bool sendKeyRequest(const team::proto::TeamKeyRequest& request, + chat::ChannelId channel, + chat::NodeId dest) const; + bool sendStatus(const team::proto::TeamStatus& status, + chat::ChannelId channel, + chat::NodeId dest = 0) const; + bool sendStatusPlain(const team::proto::TeamStatus& status, + chat::ChannelId channel, + chat::NodeId dest = 0) const; + team::TeamService::SendError lastSendError() const; + + private: + ITeamPageControllerPort* controller_ = nullptr; + ITeamPagePairingPort* pairing_ = nullptr; + ITeamPageKeyStorePort* key_store_ = nullptr; +}; + +class TeamPageControllerPortAdapter final : public ITeamPageControllerPort +{ + public: + explicit TeamPageControllerPortAdapter(team::TeamController* controller); + + void clearKeys() override; + void resetUiState() override; + bool setKeysFromPsk(const TeamId& team_id, + uint32_t key_id, + const uint8_t* psk, + size_t psk_len) override; + bool sendKick(const team::proto::TeamKick& kick, + chat::ChannelId channel, + chat::NodeId dest) override; + bool sendTransferLeader(const team::proto::TeamTransferLeader& transfer, + chat::ChannelId channel, + chat::NodeId dest) override; + bool sendKeyDist(const team::proto::TeamKeyDist& key_dist, + chat::ChannelId channel, + chat::NodeId dest) override; + bool sendKeyDistPlain(const team::proto::TeamKeyDist& key_dist, + chat::ChannelId channel, + chat::NodeId dest) override; + bool sendKeyRequest(const team::proto::TeamKeyRequest& request, + chat::ChannelId channel, + chat::NodeId dest) override; + bool sendStatus(const team::proto::TeamStatus& status, + chat::ChannelId channel, + chat::NodeId dest) override; + bool sendStatusPlain(const team::proto::TeamStatus& status, + chat::ChannelId channel, + chat::NodeId dest) override; + team::TeamService::SendError lastSendError() const override; + + private: + team::TeamController* controller_ = nullptr; +}; + +class TeamPagePairingPortAdapter final : public ITeamPagePairingPort +{ + public: + explicit TeamPagePairingPortAdapter(team::TeamPairingService* pairing); + + bool startLeader(const TeamId& team_id, + uint32_t key_id, + const uint8_t* psk, + size_t psk_len, + uint32_t leader_id, + const char* team_name) override; + bool startMember(uint32_t self_id) override; + void stop() override; + team::TeamPairingStatus status() const override; + + private: + team::TeamPairingService* pairing_ = nullptr; +}; + +class TeamPageKeyStorePortAdapter final : public ITeamPageKeyStorePort +{ + public: + bool saveKeysNow( + const TeamId& team_id, + uint32_t key_id, + const std::array& psk) override; +}; + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/include/ui/screens/team/team_page_state_store.h b/modules/ui_shared/include/ui/screens/team/team_page_state_store.h new file mode 100644 index 00000000..6eb10ac0 --- /dev/null +++ b/modules/ui_shared/include/ui/screens/team/team_page_state_store.h @@ -0,0 +1,74 @@ +#pragma once + +#include "platform/ui/team_ui_snapshot_store.h" + +#include +#include +#include +#include + +namespace team +{ +namespace ui +{ + +struct TeamPageColorContext +{ + constexpr TeamPageColorContext() = default; + explicit constexpr TeamPageColorContext(uint32_t self_node_id_in) + : self_node_id(self_node_id_in) + { + } + + uint32_t self_node_id = 0; +}; + +struct TeamPagePersistentState +{ + 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; + uint32_t team_chat_unread = 0; + + TeamId team_id{}; + bool has_team_id = false; + + std::string team_name; + uint32_t security_round = 0; + uint32_t last_update_s = 0; + std::array team_psk{}; + bool has_team_psk = false; + + std::vector members; +}; + +class TeamPageStateStore +{ + public: + bool loadOnce(ITeamUiSnapshotStore& store, + TeamPagePersistentState& state, + TeamPageColorContext color_context); + bool refresh(ITeamUiSnapshotStore& store, + TeamPagePersistentState& state, + TeamPageColorContext color_context); + void save(ITeamUiSnapshotStore& store, + const TeamPagePersistentState& state) const; + void resetLoaded(); + + static TeamUiSnapshot snapshotFromState(const TeamPagePersistentState& state); + static void applySnapshot(TeamPagePersistentState& state, + const TeamUiSnapshot& snapshot, + TeamPageColorContext color_context); + + private: + bool loaded_ = false; +}; + +void assignTeamPageMemberColors(std::vector& members, + TeamPageColorContext color_context); + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/include/ui/screens/team/team_page_transfer_leader_action.h b/modules/ui_shared/include/ui/screens/team/team_page_transfer_leader_action.h new file mode 100644 index 00000000..a9c8a332 --- /dev/null +++ b/modules/ui_shared/include/ui/screens/team/team_page_transfer_leader_action.h @@ -0,0 +1,57 @@ +#pragma once + +#include "team/usecase/team_service.h" +#include "ui/screens/team/team_page_command_reducer.h" +#include "ui/screens/team/team_page_key_event_log.h" +#include "ui/screens/team/team_page_runtime_port.h" + +#include +#include + +namespace team +{ +namespace ui +{ + +enum class TeamPageTransferLeaderFailureAction : uint8_t +{ + Transfer +}; + +enum class TeamPageTransferLeaderFailureKind : uint8_t +{ + SendFailed +}; + +struct TeamPageTransferLeaderFailure +{ + TeamPageTransferLeaderFailureAction action = + TeamPageTransferLeaderFailureAction::Transfer; + TeamPageTransferLeaderFailureKind kind = + TeamPageTransferLeaderFailureKind::SendFailed; + bool needs_keys = false; + team::TeamService::SendError error = team::TeamService::SendError::None; +}; + +struct TeamPageTransferLeaderEffects +{ + TeamPageCommandEffects command; + std::vector failures; + bool accepted = false; + bool sent_transfer = false; + bool appended_key_event = false; +}; + +class TeamPageTransferLeaderAction +{ + public: + TeamPageTransferLeaderEffects transferLeader( + TeamPageCommandState& state, + TeamPageKeyEventState& key_event_state, + const TeamPageCommandReducer& reducer, + const TeamPageRuntimePort& runtime, + const TeamPageKeyEventLog& key_log) const; +}; + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/include/ui/screens/team/team_page_types.h b/modules/ui_shared/include/ui/screens/team/team_page_types.h new file mode 100644 index 00000000..bea8797b --- /dev/null +++ b/modules/ui_shared/include/ui/screens/team/team_page_types.h @@ -0,0 +1,21 @@ +#pragma once + +namespace team +{ +namespace ui +{ + +enum class TeamPage +{ + StatusNotInTeam, + StatusInTeam, + TeamHome, + JoinPending, + Members, + MemberDetail, + KickConfirm, + KickedOut +}; + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/include/ui/screens/team/team_state.h b/modules/ui_shared/include/ui/screens/team/team_state.h deleted file mode 100644 index 2ea4270a..00000000 --- a/modules/ui_shared/include/ui/screens/team/team_state.h +++ /dev/null @@ -1,89 +0,0 @@ -/** - * @file team_state.h - * @brief Team page UI state - */ - -#pragma once - -#include "lvgl.h" -#include "platform/ui/team_ui_types.h" -#include "team/domain/team_types.h" -#include "team/protocol/team_mgmt.h" -#include "ui/widgets/top_bar.h" -#include -#include -#include - -namespace team -{ -namespace ui -{ - -enum class TeamPage -{ - StatusNotInTeam, - StatusInTeam, - TeamHome, - JoinPending, - Members, - MemberDetail, - KickConfirm, - KickedOut -}; - -struct TeamPageState -{ - lv_obj_t* root = 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* action_btns[3] = {nullptr, nullptr, nullptr}; - lv_obj_t* action_labels[3] = {nullptr, nullptr, nullptr}; - lv_obj_t* detail_label = 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* leave_confirm_modal = nullptr; - - TeamPage page = TeamPage::StatusNotInTeam; - int selected_member_index = -1; - - bool in_team = false; - bool pending_join = false; - bool kicked_out = false; - bool self_is_leader = false; - uint32_t last_event_seq = 0; - TeamPairingRole pairing_role = TeamPairingRole::None; - TeamPairingState pairing_state = TeamPairingState::Idle; - uint32_t pairing_peer_id = 0; - std::string pairing_team_name; - - TeamId team_id{}; - bool has_team_id = false; - - std::string team_name; - uint32_t security_round = 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; - uint32_t team_chat_unread = 0; - - std::vector members; -}; - -extern TeamPageState g_team_state; - -} // namespace ui -} // namespace team diff --git a/modules/ui_shared/include/ui/team_actions/legacy_team_action_bridge.h b/modules/ui_shared/include/ui/team_actions/team_action_runtime_sink.h similarity index 61% rename from modules/ui_shared/include/ui/team_actions/legacy_team_action_bridge.h rename to modules/ui_shared/include/ui/team_actions/team_action_runtime_sink.h index eb99ea85..71b48d56 100644 --- a/modules/ui_shared/include/ui/team_actions/legacy_team_action_bridge.h +++ b/modules/ui_shared/include/ui/team_actions/team_action_runtime_sink.h @@ -1,6 +1,7 @@ #pragma once -#include "platform/ui/team_ui_store_runtime.h" +#include "platform/ui/team_ui_chat_log_store.h" +#include "platform/ui/team_ui_snapshot_store.h" #include "ui/presentation_sources/team_chat_action_sink.h" #include "ui/team_actions/team_action_sink.h" @@ -9,13 +10,13 @@ namespace ui::team_actions { -// LegacyTeamActionBridge is a Phase 7.2 anti-corruption bridge. +// TeamActionRuntimeSink is the runtime adapter behind the Team action seam. // // Pattern: -// Command Sink / Anti-Corruption Adapter / Strangler Fig. +// Command Sink / Runtime Adapter. // -// It translates presentation-level Team actions into existing Team runtime -// helpers. +// It translates presentation-level Team actions into Team runtime sends and +// local Team chat-log persistence. // // It must not: // - build ChatWorkspaceSnapshot @@ -23,11 +24,12 @@ namespace ui::team_actions // - map Team to DirectPeer/Channel // - use LegacyChatActionSink // - expose raw packet encoding to ChatUiController -class LegacyTeamActionBridge final : public ITeamActionSink +class TeamActionRuntimeSink final : public ITeamActionSink { public: - LegacyTeamActionBridge( - ::team::ui::ITeamUiStore& team_store, + TeamActionRuntimeSink( + ::team::ui::ITeamUiSnapshotStore& snapshot_store, + ::team::ui::ITeamUiChatLogStore& chat_log_store, ::ui::presentation_sources::ITeamChatCommandPort* command_port, ITeamLocationSource* location_source = nullptr, uint8_t team_channel_raw = 2); @@ -37,10 +39,12 @@ class LegacyTeamActionBridge final : public ITeamActionSink private: ui::UiActionResult sendText(const TeamActionRequest& request); + ui::UiActionResult sendLocationShare(const TeamActionRequest& request); ui::UiActionResult sendLocationMarker(const TeamActionRequest& request); ui::UiActionResult sendCommand(const TeamActionRequest& request); - ::team::ui::ITeamUiStore& team_store_; + ::team::ui::ITeamUiSnapshotStore& snapshot_store_; + ::team::ui::ITeamUiChatLogStore& chat_log_store_; ::ui::presentation_sources::ITeamChatCommandPort* command_port_ = nullptr; ITeamLocationSource* location_source_ = nullptr; uint8_t team_channel_raw_ = 2; diff --git a/modules/ui_shared/include/ui/team_actions/team_action_types.h b/modules/ui_shared/include/ui/team_actions/team_action_types.h index f4b900af..3b36b827 100644 --- a/modules/ui_shared/include/ui/team_actions/team_action_types.h +++ b/modules/ui_shared/include/ui/team_actions/team_action_types.h @@ -8,6 +8,7 @@ namespace ui::team_actions enum class TeamActionKind : uint8_t { Text, + LocationShare, LocationMarker, Command, }; @@ -34,6 +35,17 @@ struct TeamLocationMarkerRequest const char* label = nullptr; }; +struct TeamLocationShareRequest +{ + bool use_current_location = false; + double lat = 0.0; + double lon = 0.0; + bool has_altitude = false; + double altitude_m = 0.0; + uint32_t timestamp = 0; + const char* label = nullptr; +}; + struct TeamLocationSnapshot { bool valid = false; @@ -59,6 +71,7 @@ struct TeamActionRequest { TeamActionKind kind = TeamActionKind::Text; const char* text = nullptr; + TeamLocationShareRequest location_share; TeamLocationMarkerRequest location; TeamCommandRequest command; }; diff --git a/modules/ui_shared/include/ui/team_actions/team_runtime_adapters.h b/modules/ui_shared/include/ui/team_actions/team_runtime_adapters.h new file mode 100644 index 00000000..f5576403 --- /dev/null +++ b/modules/ui_shared/include/ui/team_actions/team_runtime_adapters.h @@ -0,0 +1,40 @@ +#pragma once + +#include "ui/presentation_sources/team_chat_action_sink.h" +#include "ui/team_actions/team_action_sink.h" + +#include +#include + +namespace team +{ +class TeamController; +} // namespace team + +namespace ui::team_actions +{ + +class TeamControllerChatCommandPort final + : public ::ui::presentation_sources::ITeamChatCommandPort +{ + public: + explicit TeamControllerChatCommandPort(::team::TeamController& controller); + + bool setKeysFromPsk(const ::team::TeamId& team_id, + uint32_t key_id, + const uint8_t* psk, + size_t psk_len) override; + bool sendTeamChat(const ::team::proto::TeamChatMessage& message, + uint8_t team_channel_raw) override; + + private: + ::team::TeamController& controller_; +}; + +class GpsTeamLocationSource final : public ITeamLocationSource +{ + public: + bool currentTeamLocation(TeamLocationSnapshot& out) override; +}; + +} // namespace ui::team_actions diff --git a/modules/ui_shared/include/ui/team_persistence/team_ui_snapshot_codec.h b/modules/ui_shared/include/ui/team_persistence/team_ui_snapshot_codec.h new file mode 100644 index 00000000..2e7500ba --- /dev/null +++ b/modules/ui_shared/include/ui/team_persistence/team_ui_snapshot_codec.h @@ -0,0 +1,24 @@ +#pragma once + +#include "platform/ui/team_ui_snapshot_store.h" + +#include +#include +#include + +namespace ui::team_persistence +{ + +constexpr uint8_t kTeamUiSnapshotVersionV1 = 1; +constexpr uint8_t kTeamUiSnapshotVersionV2 = 2; +constexpr uint8_t kTeamUiSnapshotCurrentVersion = kTeamUiSnapshotVersionV2; + +bool encodeTeamUiSnapshot(const ::team::ui::TeamUiSnapshot& snapshot, + uint32_t updated_s, + std::vector& out); + +bool decodeTeamUiSnapshot(const uint8_t* data, + std::size_t len, + ::team::ui::TeamUiSnapshot& out); + +} // namespace ui::team_persistence diff --git a/modules/ui_shared/include/ui/team_presence/team_presence_model.h b/modules/ui_shared/include/ui/team_presence/team_presence_model.h new file mode 100644 index 00000000..d7bbf1c9 --- /dev/null +++ b/modules/ui_shared/include/ui/team_presence/team_presence_model.h @@ -0,0 +1,41 @@ +#pragma once + +#include "platform/ui/team_ui_types.h" + +#include +#include +#include + +namespace ui::team_presence +{ + +constexpr uint32_t kDefaultOnlineWindowSeconds = 120; +constexpr std::size_t kInvalidTeamMemberIndex = static_cast(-1); + +struct TeamMemberTouchResult +{ + std::size_t index = kInvalidTeamMemberIndex; + bool created = false; + bool valid = false; +}; + +bool isTeamMemberOnline(uint32_t now_s, + uint32_t last_seen_s, + uint32_t online_window_s = kDefaultOnlineWindowSeconds); + +int refreshTeamMemberPresence(std::vector<::team::ui::TeamMemberUi>& members, + uint32_t now_s, + uint32_t online_window_s = kDefaultOnlineWindowSeconds); + +std::size_t findTeamMemberIndex( + const std::vector<::team::ui::TeamMemberUi>& members, + uint32_t node_id); + +TeamMemberTouchResult touchTeamMember( + std::vector<::team::ui::TeamMemberUi>& members, + uint32_t node_id, + uint32_t seen_s); + +uint32_t normalizeSeenSeconds(uint32_t observed_s, uint32_t fallback_now_s); + +} // namespace ui::team_presence diff --git a/modules/ui_shared/include/ui/team_presentation/team_rich_payload_projector.h b/modules/ui_shared/include/ui/team_presentation/team_rich_payload_projector.h index c4bd298f..fc76f450 100644 --- a/modules/ui_shared/include/ui/team_presentation/team_rich_payload_projector.h +++ b/modules/ui_shared/include/ui/team_presentation/team_rich_payload_projector.h @@ -1,6 +1,6 @@ #pragma once -#include "platform/ui/team_ui_store_runtime.h" +#include "platform/ui/team_ui_chat_log_store.h" #include "ui/team_presentation/team_rich_payload_display.h" namespace ui::team_presentation diff --git a/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp b/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp index c4a42481..6c7bbe7c 100644 --- a/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp +++ b/modules/ui_shared/src/ui/menu/dashboard/dashboard_compass_widget.cpp @@ -170,7 +170,7 @@ void refresh_compass_widget() if (self_valid) { team::ui::TeamUiSnapshot team_snap; - if (team::ui::team_ui_get_store().load(team_snap)) + if (team::ui::team_ui_snapshot_store().load(team_snap)) { auto& contacts = app::messagingFacade().getContactService(); const uint32_t self_id = app::messagingFacade().getSelfNodeId(); diff --git a/modules/ui_shared/src/ui/presentation_sources/legacy_map_presentation_source.cpp b/modules/ui_shared/src/ui/presentation_sources/legacy_map_presentation_source.cpp index 6bda5ddc..86659d02 100644 --- a/modules/ui_shared/src/ui/presentation_sources/legacy_map_presentation_source.cpp +++ b/modules/ui_shared/src/ui/presentation_sources/legacy_map_presentation_source.cpp @@ -1,11 +1,23 @@ #include "ui/presentation_sources/legacy_map_presentation_source.h" +#include "sys/clock.h" +#include "ui/team_presence/team_presence_model.h" #include "ui_presentation/common/fixed_text.h" namespace ui::presentation_sources { namespace { +constexpr uint32_t kMinValidEpoch = 1577836800U; // 2020-01-01 + +uint32_t presenceNowForMember(uint32_t last_seen_s) +{ + if (last_seen_s >= kMinValidEpoch) + { + return sys::epoch_seconds_now(); + } + return sys::uptime_seconds_now(); +} void copyStatusLine(const ui::gps::GpsStatusSnapshot& gps, ui::map::MapWorkspaceSnapshot& out) @@ -31,7 +43,7 @@ void copyStatusLine(const ui::gps::GpsStatusSnapshot& gps, ui::copyText(out.status_line, gps.fix_valid ? "GPS fix" : "No GPS fix"); } -void projectTeamSummary(team::ui::ITeamUiStore* store, +void projectTeamSummary(team::ui::ITeamUiSnapshotStore* store, ui::map::TeamOverlaySummary& out) { out = ui::map::TeamOverlaySummary{}; @@ -50,7 +62,8 @@ void projectTeamSummary(team::ui::ITeamUiStore* store, for (const auto& member : snapshot.members) { ++out.visible_members; - if (!member.online) + if (!::ui::team_presence::isTeamMemberOnline(presenceNowForMember(member.last_seen_s), + member.last_seen_s)) { ++out.stale_members; } @@ -62,7 +75,7 @@ void projectTeamSummary(team::ui::ITeamUiStore* store, LegacyMapPresentationSource::LegacyMapPresentationSource( ui::gps::IGpsStatusSource& gps_source, const LegacyMapPresentationState& state, - team::ui::ITeamUiStore* team_store) + team::ui::ITeamUiSnapshotStore* team_store) : gps_source_(gps_source), state_(state), team_store_(team_store) diff --git a/modules/ui_shared/src/ui/presentation_sources/legacy_mesh_status_source.cpp b/modules/ui_shared/src/ui/presentation_sources/legacy_mesh_status_source.cpp index bb3bebcd..516cb55c 100644 --- a/modules/ui_shared/src/ui/presentation_sources/legacy_mesh_status_source.cpp +++ b/modules/ui_shared/src/ui/presentation_sources/legacy_mesh_status_source.cpp @@ -4,7 +4,7 @@ #include "app/app_facade_access.h" #include "chat/infra/mesh_protocol_utils.h" #include "chat/usecase/chat_service.h" -#include "platform/ui/team_ui_store_runtime.h" +#include "platform/ui/team_ui_snapshot_store.h" #include "sys/clock.h" #include @@ -29,7 +29,7 @@ bool LegacyMeshStatusSource::buildMeshStatusSnapshot(ui::mesh::MeshStatusSnapsho out.radio_ready = true; team::ui::TeamUiSnapshot team_snapshot; - const bool has_team = team::ui::team_ui_get_store().load(team_snapshot) && team_snapshot.in_team; + const bool has_team = team::ui::team_ui_snapshot_store().load(team_snapshot) && team_snapshot.in_team; const uint32_t team_unread = has_team ? team_snapshot.team_chat_unread : 0U; const uint32_t chat_unread = static_cast(app::messagingFacade().getChatService().getTotalUnread()); diff --git a/modules/ui_shared/src/ui/presentation_sources/team_chat_action_sink.cpp b/modules/ui_shared/src/ui/presentation_sources/team_chat_action_sink.cpp index af777edf..656e7606 100644 --- a/modules/ui_shared/src/ui/presentation_sources/team_chat_action_sink.cpp +++ b/modules/ui_shared/src/ui/presentation_sources/team_chat_action_sink.cpp @@ -1,6 +1,7 @@ #include "ui/presentation_sources/team_chat_action_sink.h" -#include "platform/ui/team_ui_store_runtime.h" +#include "platform/ui/team_ui_chat_log_store.h" +#include "platform/ui/team_ui_snapshot_store.h" #include "sys/clock.h" #include "team/protocol/team_chat.h" @@ -31,10 +32,12 @@ uint32_t currentTimestamp() } // namespace -TeamChatActionSink::TeamChatActionSink(::team::ui::ITeamUiStore& team_store, +TeamChatActionSink::TeamChatActionSink(::team::ui::ITeamUiSnapshotStore& snapshot_store, + ::team::ui::ITeamUiChatLogStore& chat_log_store, ITeamChatCommandPort* command_port, uint8_t team_channel_raw) - : team_store_(team_store), + : snapshot_store_(snapshot_store), + chat_log_store_(chat_log_store), command_port_(command_port), team_channel_raw_(team_channel_raw) { @@ -69,7 +72,7 @@ ui::UiActionResult TeamChatActionSink::sendMessage( } ::team::ui::TeamUiSnapshot snap; - if (!team_store_.load(snap) || !snap.has_team_id) + if (!snapshot_store_.load(snap) || !snap.has_team_id) { return ui::UiActionResult::fail(ui::UiActionFailure::NotReady); } @@ -105,12 +108,12 @@ ui::UiActionResult TeamChatActionSink::sendMessage( return ui::UiActionResult::fail(ui::UiActionFailure::Rejected); } - (void)team::ui::team_ui_chatlog_append_structured(snap.team_id, - 0, - false, - ts, - team::proto::TeamChatType::Text, - team_message.payload); + (void)chat_log_store_.appendStructured(snap.team_id, + 0, + false, + ts, + team::proto::TeamChatType::Text, + team_message.payload); return ui::UiActionResult::success(); } @@ -126,13 +129,13 @@ ui::UiActionResult TeamChatActionSink::markRead(ui::chat::ConversationId id) } ::team::ui::TeamUiSnapshot snap; - if (!team_store_.load(snap) || !snap.has_team_id) + if (!snapshot_store_.load(snap) || !snap.has_team_id) { return ui::UiActionResult::fail(ui::UiActionFailure::NotReady); } snap.team_chat_unread = 0; - team_store_.save(snap); + snapshot_store_.save(snap); return ui::UiActionResult::success(); } diff --git a/modules/ui_shared/src/ui/presentation_sources/team_chat_presentation_source.cpp b/modules/ui_shared/src/ui/presentation_sources/team_chat_presentation_source.cpp index 4198b6b7..8bba2d27 100644 --- a/modules/ui_shared/src/ui/presentation_sources/team_chat_presentation_source.cpp +++ b/modules/ui_shared/src/ui/presentation_sources/team_chat_presentation_source.cpp @@ -1,6 +1,7 @@ #include "ui/presentation_sources/team_chat_presentation_source.h" -#include "platform/ui/team_ui_store_runtime.h" +#include "platform/ui/team_ui_chat_log_store.h" +#include "platform/ui/team_ui_snapshot_store.h" #include "ui/team_presentation/team_rich_payload_projector.h" #include "ui_presentation/common/fixed_text.h" @@ -96,11 +97,76 @@ uint64_t messageLocalId(const ::team::ui::TeamChatLogEntry& entry, return id == 0 ? static_cast(index + 1U) : id; } +ui::chat::TeamMessageRichPayloadKind toMessageKind( + ::ui::team_presentation::TeamRichPayloadKind kind) +{ + switch (kind) + { + case ::ui::team_presentation::TeamRichPayloadKind::None: + return ui::chat::TeamMessageRichPayloadKind::None; + case ::ui::team_presentation::TeamRichPayloadKind::Text: + return ui::chat::TeamMessageRichPayloadKind::Text; + case ::ui::team_presentation::TeamRichPayloadKind::Location: + return ui::chat::TeamMessageRichPayloadKind::Location; + case ::ui::team_presentation::TeamRichPayloadKind::Command: + return ui::chat::TeamMessageRichPayloadKind::Command; + case ::ui::team_presentation::TeamRichPayloadKind::Unsupported: + return ui::chat::TeamMessageRichPayloadKind::Unsupported; + } + return ui::chat::TeamMessageRichPayloadKind::Unsupported; +} + +ui::chat::TeamMessageCommandKind toMessageCommandKind( + ::ui::team_presentation::TeamCommandDisplayKind kind) +{ + switch (kind) + { + case ::ui::team_presentation::TeamCommandDisplayKind::Unknown: + return ui::chat::TeamMessageCommandKind::Unknown; + case ::ui::team_presentation::TeamCommandDisplayKind::MoveTo: + return ui::chat::TeamMessageCommandKind::MoveTo; + case ::ui::team_presentation::TeamCommandDisplayKind::RallyPoint: + return ui::chat::TeamMessageCommandKind::RallyPoint; + case ::ui::team_presentation::TeamCommandDisplayKind::Hold: + return ui::chat::TeamMessageCommandKind::Hold; + case ::ui::team_presentation::TeamCommandDisplayKind::Help: + return ui::chat::TeamMessageCommandKind::Help; + case ::ui::team_presentation::TeamCommandDisplayKind::CheckIn: + return ui::chat::TeamMessageCommandKind::CheckIn; + case ::ui::team_presentation::TeamCommandDisplayKind::Custom: + return ui::chat::TeamMessageCommandKind::Custom; + } + return ui::chat::TeamMessageCommandKind::Unknown; +} + +void copyRichPayload( + ui::chat::TeamMessageRichPayload& out, + const ::ui::team_presentation::TeamRichPayloadDisplay& display) +{ + out = ui::chat::TeamMessageRichPayload{}; + out.kind = toMessageKind(display.kind); + ui::copyText(out.title, display.title.c_str()); + ui::copyText(out.summary, display.summary.c_str()); + ui::copyText(out.badge, display.badge.c_str()); + out.location.lat = display.location.lat; + out.location.lon = display.location.lon; + out.location.has_altitude = display.location.has_altitude; + out.location.altitude_m = display.location.altitude_m; + out.location.marker_icon = display.location.marker_icon; + out.command.kind = toMessageCommandKind(display.command.kind); + out.command.lat = display.command.lat; + out.command.lon = display.command.lon; + out.command.radius_m = display.command.radius_m; + out.command.priority = display.command.priority; +} + } // namespace TeamChatPresentationSource::TeamChatPresentationSource( - ::team::ui::ITeamUiStore& team_store) - : team_store_(team_store) + ::team::ui::ITeamUiSnapshotStore& snapshot_store, + ::team::ui::ITeamUiChatLogStore& chat_log_store) + : snapshot_store_(snapshot_store), + chat_log_store_(chat_log_store) { } @@ -118,7 +184,7 @@ bool TeamChatPresentationSource::buildChatWorkspaceSnapshot( ui::copyText(out.composer_placeholder, "Team message"); ::team::ui::TeamUiSnapshot snap; - if (!team_store_.load(snap) || !snap.has_team_id) + if (!snapshot_store_.load(snap) || !snap.has_team_id) { return true; } @@ -138,9 +204,9 @@ bool TeamChatPresentationSource::buildChatWorkspaceSnapshot( ui::copyText(conversation.subtitle, "No messages"); std::vector<::team::ui::TeamChatLogEntry> entries; - if (team::ui::team_ui_chatlog_load_recent(snap.team_id, - kMaxMessageRows, - entries) && + if (chat_log_store_.loadRecent(snap.team_id, + kMaxMessageRows, + entries) && !entries.empty()) { ::ui::team_presentation::TeamRichPayloadProjector projector; @@ -180,6 +246,8 @@ bool TeamChatPresentationSource::buildChatWorkspaceSnapshot( : ui::chat::MessageOrigin::LocalStored; row.ref.local_id = messageLocalId(entry, i); ui::copyText(row.text, display.summary.c_str()); + row.has_team_rich_payload = true; + copyRichPayload(row.team_rich_payload, display); copyTimeLabel(row.time_label, entry.ts); copySenderLabel(row.sender_label, snap, entry); } diff --git a/modules/ui_shared/src/ui/presentation_sources/team_map_overlay_source.cpp b/modules/ui_shared/src/ui/presentation_sources/team_map_overlay_source.cpp new file mode 100644 index 00000000..939e2a6c --- /dev/null +++ b/modules/ui_shared/src/ui/presentation_sources/team_map_overlay_source.cpp @@ -0,0 +1,199 @@ +#include "ui/presentation_sources/team_map_overlay_source.h" + +#include +#include + +namespace ui::presentation_sources +{ +namespace +{ + +bool sampleHasCoordinate(const ::team::ui::TeamPosSample& sample) +{ + return sample.lat_e7 != 0 || sample.lon_e7 != 0; +} + +} // namespace + +TeamMapOverlaySource::TeamMapOverlaySource( + ::team::ui::ITeamUiSnapshotStore& snapshot_store) + : snapshot_store_(snapshot_store) +{ +} + +bool TeamMapOverlaySource::loadSnapshot( + ::team::ui::TeamUiSnapshot& out) const +{ + return snapshot_store_.load(out) && out.in_team && out.has_team_id; +} + +std::size_t TeamMapOverlaySource::latestTeamPoints( + TeamPoint* out, + std::size_t capacity) const +{ + if (out == nullptr || capacity == 0) + { + return 0; + } + + ::team::ui::TeamUiSnapshot snapshot; + if (!loadSnapshot(snapshot)) + { + return 0; + } + + std::vector<::team::ui::TeamPosSample> samples; + if (!::team::ui::team_ui_posring_load_latest(snapshot.team_id, samples)) + { + return 0; + } + + std::size_t written = 0; + for (const auto& sample : samples) + { + if (written >= capacity) + { + break; + } + + out[written].node_id = sample.member_id; + out[written].label = labelForMember(snapshot, sample.member_id, written); + out[written].lat = static_cast(sample.lat_e7) / 10000000.0; + out[written].lon = static_cast(sample.lon_e7) / 10000000.0; + out[written].valid = sampleHasCoordinate(sample); + ++written; + } + return samples.size(); +} + +std::size_t TeamMapOverlaySource::latestTeamLocations( + TeamMapLocation* out, + std::size_t capacity) const +{ + if (out == nullptr || capacity == 0) + { + return 0; + } + + ::team::ui::TeamUiSnapshot snapshot; + if (!loadSnapshot(snapshot)) + { + return 0; + } + + std::vector<::team::ui::TeamPosSample> samples; + if (!::team::ui::team_ui_posring_load_latest(snapshot.team_id, samples)) + { + return 0; + } + + std::size_t count = 0; + for (const auto& sample : samples) + { + if (count >= capacity) + { + break; + } + out[count] = locationFromSample(snapshot, sample); + ++count; + } + return count; +} + +bool TeamMapOverlaySource::loadMemberLocation( + uint32_t member_id, + TeamMapLocation& out) const +{ + out = TeamMapLocation{}; + if (member_id == 0) + { + return false; + } + + ::team::ui::TeamUiSnapshot snapshot; + if (!loadSnapshot(snapshot)) + { + return false; + } + + std::vector<::team::ui::TeamPosSample> samples; + if (!::team::ui::team_ui_posring_load_latest(snapshot.team_id, samples)) + { + return false; + } + + for (const auto& sample : samples) + { + if (sample.member_id != member_id) + { + continue; + } + + out = locationFromSample(snapshot, sample); + return out.valid; + } + + return false; +} + +TeamMapLocation TeamMapOverlaySource::locationFromSample( + const ::team::ui::TeamUiSnapshot& snapshot, + const ::team::ui::TeamPosSample& sample) +{ + TeamMapLocation out; + out.member_id = sample.member_id; + out.lat_e7 = sample.lat_e7; + out.lon_e7 = sample.lon_e7; + out.alt_m = sample.alt_m; + out.ts = sample.ts; + out.color = colorForMember(snapshot, sample.member_id); + out.valid = sampleHasCoordinate(sample); + return out; +} + +uint32_t TeamMapOverlaySource::colorForMember( + const ::team::ui::TeamUiSnapshot& snapshot, + uint32_t member_id) +{ + for (const auto& member : snapshot.members) + { + if (member.node_id == member_id) + { + if (member.color_index < ::team::ui::kTeamMaxMembers) + { + return ::team::ui::team_color_from_index(member.color_index); + } + break; + } + } + + return ::team::ui::team_color_from_index( + ::team::ui::team_color_index_from_node_id(member_id)); +} + +const char* TeamMapOverlaySource::labelForMember( + const ::team::ui::TeamUiSnapshot& snapshot, + uint32_t member_id, + std::size_t index) +{ + static char labels[::ui::map::MapOverlaySnapshot::kMaxItems][32]{}; + if (index >= ::ui::map::MapOverlaySnapshot::kMaxItems) + { + return nullptr; + } + + for (const auto& member : snapshot.members) + { + if (member.node_id == member_id && !member.name.empty()) + { + std::snprintf(labels[index], + sizeof(labels[index]), + "%s", + member.name.c_str()); + return labels[index]; + } + } + return nullptr; +} + +} // namespace ui::presentation_sources diff --git a/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp b/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp index 9492abd2..f11ba2df 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp @@ -93,6 +93,46 @@ uint32_t timestamp_from_presentation_label(const ::ui::FixedText<24>& label) } return static_cast(value); } + +bool is_structured_team_payload( + const ::ui::chat::TeamMessageRichPayload& payload) +{ + return payload.kind == ::ui::chat::TeamMessageRichPayloadKind::Location || + payload.kind == ::ui::chat::TeamMessageRichPayloadKind::Command; +} + +std::string rich_payload_kind_label( + ::ui::chat::TeamMessageRichPayloadKind kind) +{ + switch (kind) + { + case ::ui::chat::TeamMessageRichPayloadKind::Location: + return "Location"; + case ::ui::chat::TeamMessageRichPayloadKind::Command: + return "Command"; + case ::ui::chat::TeamMessageRichPayloadKind::Unsupported: + return "Unsupported"; + case ::ui::chat::TeamMessageRichPayloadKind::Text: + return "Text"; + case ::ui::chat::TeamMessageRichPayloadKind::None: + break; + } + return "Team"; +} + +std::string format_team_rich_payload_text( + const ::ui::chat::TeamMessageRichPayload& payload) +{ + if (!payload.summary.empty()) + { + return payload.summary.c_str(); + } + if (!payload.title.empty()) + { + return payload.title.c_str(); + } + return rich_payload_kind_label(payload.kind); +} } // namespace static bool is_valid_epoch_ts(uint32_t ts) @@ -475,7 +515,10 @@ void ChatConversationScreen::createMessageItem(const ::ui::chat::MessageRow& row chat::ui::conversation::styles::apply_message_row(item.container); const bool is_self = row.outgoing; - std::string display_text = row.text.c_str(); + std::string display_text = + row.has_team_rich_payload + ? format_team_rich_payload_text(row.team_rich_payload) + : std::string(row.text.c_str()); std::string inferred_sender; if (!is_self && conv_.protocol == chat::MeshProtocol::MeshCore && @@ -564,6 +607,36 @@ void ChatConversationScreen::createMessageItem(const ::ui::chat::MessageRow& row item.text_label = chat::ui::layout::create_bubble_text(bubble); chat::ui::conversation::styles::apply_bubble_text(item.text_label); + if (row.has_team_rich_payload && + is_structured_team_payload(row.team_rich_payload)) + { + lv_obj_t* badge_label = chat::ui::layout::create_bubble_text(bubble); + chat::ui::conversation::styles::apply_bubble_time(badge_label); + const std::string badge = + row.team_rich_payload.badge.empty() + ? rich_payload_kind_label(row.team_rich_payload.kind) + : std::string(row.team_rich_payload.badge.c_str()); + lv_label_set_text(badge_label, badge.c_str()); + ::ui::fonts::apply_localized_font( + badge_label, + lv_label_get_text(badge_label), + ::ui::fonts::ui_chrome_font()); + const lv_coord_t max_badge_w = + std::max(max_bubble_w - 2 * kBubblePadX, 24); + lv_obj_set_width(badge_label, max_badge_w); + + if (!row.team_rich_payload.title.empty()) + { + lv_obj_t* title_label = chat::ui::layout::create_bubble_text(bubble); + chat::ui::conversation::styles::apply_bubble_text(title_label); + lv_label_set_text(title_label, + row.team_rich_payload.title.c_str()); + ::ui::fonts::apply_chat_content_font( + title_label, + row.team_rich_payload.title.c_str()); + lv_obj_set_width(title_label, max_badge_w); + } + } lv_label_set_text(item.text_label, display_text.c_str()); ::ui::fonts::apply_chat_content_font(item.text_label, display_text.c_str()); diff --git a/modules/ui_shared/src/ui/screens/chat/chat_page_runtime.cpp b/modules/ui_shared/src/ui/screens/chat/chat_page_runtime.cpp index d20db9b9..f62f9715 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_page_runtime.cpp @@ -4,16 +4,16 @@ #include "app/app_facade_access.h" #include "chat/delivery/chat_delivery_action_service.h" #include "chat/delivery/chat_delivery_event_port.h" -#include "platform/ui/gps_runtime.h" #include "platform/ui/team_ui_store_runtime.h" -#include "team/usecase/team_controller.h" #include "ui/app_runtime.h" #include "ui/presentation_sources/chat_presentation_source.h" #include "ui/presentation_sources/legacy_chat_action_sink.h" #include "ui/presentation_sources/team_chat_action_sink.h" #include "ui/presentation_sources/team_chat_presentation_source.h" +#include "ui/screens/chat/chat_team_workflow.h" #include "ui/screens/chat/chat_ui_controller.h" -#include "ui/team_actions/legacy_team_action_bridge.h" +#include "ui/team_actions/team_action_runtime_sink.h" +#include "ui/team_actions/team_runtime_adapters.h" #include "ui/ui_common.h" #include "ui_chat_runtime/chat_delivery_action_port_adapter.h" #include "ui_chat_runtime/chat_delivery_event_projection_adapter.h" @@ -49,7 +49,8 @@ std::unique_ptr<::ui::presentation_sources::ITeamChatCommandPort> s_team_chat_co std::unique_ptr<::ui::presentation_sources::TeamChatActionSink> s_team_chat_sink = nullptr; std::unique_ptr<::ui::chat::ChatWorkspaceModel> s_team_chat_model = nullptr; std::unique_ptr<::ui::team_actions::ITeamLocationSource> s_team_location_source = nullptr; -std::unique_ptr<::ui::team_actions::LegacyTeamActionBridge> s_team_action_sink = nullptr; +std::unique_ptr<::ui::team_actions::TeamActionRuntimeSink> s_team_action_sink = nullptr; +std::unique_ptr s_team_workflow = nullptr; std::unique_ptr s_ui_controller = nullptr; std::unique_ptr s_event_pump = nullptr; std::unique_ptr s_runtime_facade = nullptr; @@ -89,59 +90,6 @@ class ChatPageRuntimeFacade final : public chat::ui::IChatUiRuntime chat::ui::UiController& controller_; }; -class TeamControllerChatCommandPort final - : public ::ui::presentation_sources::ITeamChatCommandPort -{ - public: - explicit TeamControllerChatCommandPort(::team::TeamController& controller) - : controller_(controller) - { - } - - bool setKeysFromPsk(const ::team::TeamId& team_id, - uint32_t key_id, - const uint8_t* psk, - size_t psk_len) override - { - return controller_.setKeysFromPsk(team_id, key_id, psk, psk_len); - } - - bool sendTeamChat(const ::team::proto::TeamChatMessage& message, - uint8_t team_channel_raw) override - { - return controller_.onChat( - message, - static_cast<::chat::ChannelId>(team_channel_raw)); - } - - private: - ::team::TeamController& controller_; -}; - -class GpsTeamLocationSource final - : public ::ui::team_actions::ITeamLocationSource -{ - public: - bool currentTeamLocation(::ui::team_actions::TeamLocationSnapshot& out) override - { - const auto gps_state = platform::ui::gps::get_data(); - if (!gps_state.valid) - { - out = {}; - return false; - } - - out.valid = true; - out.lat = gps_state.lat; - out.lon = gps_state.lng; - out.has_altitude = gps_state.has_alt; - out.altitude_m = gps_state.alt_m; - out.accuracy_m = 0.0f; - out.timestamp = 0; - return true; - } -}; - void request_shell_exit(void*) { if (s_host) @@ -184,6 +132,7 @@ void enter(const shell::Host* host, lv_obj_t* parent) s_runtime_facade.reset(); s_event_pump.reset(); s_ui_controller.reset(); + s_team_workflow.reset(); s_team_action_sink.reset(); s_team_location_source.reset(); s_team_chat_model.reset(); @@ -284,36 +233,42 @@ void enter(const shell::Host* host, lv_obj_t* parent) s_team_chat_source = std::unique_ptr<::ui::presentation_sources::TeamChatPresentationSource>( new ::ui::presentation_sources::TeamChatPresentationSource( - team::ui::team_ui_get_store())); + team::ui::team_ui_snapshot_store(), + team::ui::team_ui_chat_log_store())); if (auto* team_controller = app::teamFacade().getTeamController()) { s_team_chat_command_port = std::unique_ptr<::ui::presentation_sources::ITeamChatCommandPort>( - new TeamControllerChatCommandPort(*team_controller)); + new ::ui::team_actions::TeamControllerChatCommandPort( + *team_controller)); } s_team_chat_sink = std::unique_ptr<::ui::presentation_sources::TeamChatActionSink>( new ::ui::presentation_sources::TeamChatActionSink( - team::ui::team_ui_get_store(), + team::ui::team_ui_snapshot_store(), + team::ui::team_ui_chat_log_store(), s_team_chat_command_port.get())); s_team_chat_model = std::unique_ptr<::ui::chat::ChatWorkspaceModel>( new ::ui::chat::ChatWorkspaceModel(*s_team_chat_source, *s_team_chat_sink)); s_team_location_source = std::unique_ptr<::ui::team_actions::ITeamLocationSource>( - new GpsTeamLocationSource()); + new ::ui::team_actions::GpsTeamLocationSource()); s_team_action_sink = - std::unique_ptr<::ui::team_actions::LegacyTeamActionBridge>( - new ::ui::team_actions::LegacyTeamActionBridge( - team::ui::team_ui_get_store(), + std::unique_ptr<::ui::team_actions::TeamActionRuntimeSink>( + new ::ui::team_actions::TeamActionRuntimeSink( + team::ui::team_ui_snapshot_store(), + team::ui::team_ui_chat_log_store(), s_team_chat_command_port.get(), s_team_location_source.get())); + s_team_workflow = std::unique_ptr( + new chat::ui::ChatTeamWorkflow(*s_team_chat_model, + s_team_action_sink.get())); s_ui_controller = std::unique_ptr( new chat::ui::UiController(s_chat_container, chat_service, *s_chat_model, - *s_team_chat_model, - s_team_action_sink.get(), + *s_team_workflow, s_key_verification_model.get(), default_channel, request_shell_exit, @@ -348,6 +303,7 @@ void exit(lv_obj_t* parent) s_runtime_facade.reset(); s_event_pump.reset(); s_ui_controller.reset(); + s_team_workflow.reset(); s_team_action_sink.reset(); s_team_location_source.reset(); s_team_chat_model.reset(); diff --git a/modules/ui_shared/src/ui/screens/chat/chat_team_workflow.cpp b/modules/ui_shared/src/ui/screens/chat/chat_team_workflow.cpp new file mode 100644 index 00000000..d727efd8 --- /dev/null +++ b/modules/ui_shared/src/ui/screens/chat/chat_team_workflow.cpp @@ -0,0 +1,102 @@ +#include "ui/screens/chat/chat_team_workflow.h" + +#include "team/protocol/team_location_marker.h" + +namespace chat::ui +{ + +ChatTeamWorkflow::ChatTeamWorkflow( + ::ui::chat::ChatWorkspaceModel& team_chat_model, + ::ui::team_actions::ITeamActionSink* team_action_sink) + : team_chat_model_(team_chat_model), + team_action_sink_(team_action_sink) +{ +} + +bool ChatTeamWorkflow::buildSnapshot(::ui::chat::ChatWorkspaceSnapshot& out) +{ + return team_chat_model_.buildSnapshot(out); +} + +bool ChatTeamWorkflow::buildSelectedSnapshot( + ::ui::chat::ChatWorkspaceSnapshot& out) +{ + if (!team_chat_model_.buildSnapshot(out) || out.conversation_count == 0) + { + return false; + } + + (void)team_chat_model_.selectConversation(out.conversations[0].id); + (void)team_chat_model_.buildSnapshot(out); + return out.header.valid && out.conversation_count > 0; +} + +::ui::UiActionResult ChatTeamWorkflow::markRead( + const ::ui::chat::ConversationId& conversation) +{ + return team_chat_model_.markRead(conversation); +} + +::ui::UiActionResult ChatTeamWorkflow::sendText(const char* text) +{ + return team_chat_model_.sendMessage(text); +} + +::ui::UiActionResult ChatTeamWorkflow::sendCurrentLocationMarker( + uint8_t icon_id) +{ + if (!team::proto::team_location_marker_icon_is_valid(icon_id)) + { + return ::ui::UiActionResult::fail(::ui::UiActionFailure::InvalidInput); + } + if (team_action_sink_ == nullptr) + { + return ::ui::UiActionResult::fail(::ui::UiActionFailure::NotReady); + } + + ::ui::team_actions::TeamActionRequest request; + request.kind = ::ui::team_actions::TeamActionKind::LocationMarker; + request.location.use_current_location = true; + request.location.marker_icon = icon_id; + request.location.label = team::proto::team_location_marker_icon_name(icon_id); + + return team_action_sink_->sendTeamAction(request); +} + +const char* ChatTeamWorkflow::textSendFailureMessage( + ::ui::UiActionResult result) const +{ + if (result.failure == ::ui::UiActionFailure::NotReady) + { + return "Team keys not ready"; + } + if (result.failure == ::ui::UiActionFailure::Unsupported) + { + return "Team chat unsupported"; + } + if (result.failure == ::ui::UiActionFailure::InvalidInput) + { + return "Message unavailable"; + } + return "Team chat send failed"; +} + +const char* ChatTeamWorkflow::locationSendFailureMessage( + ::ui::UiActionResult result) const +{ + if (result.failure == ::ui::UiActionFailure::NotReady) + { + return "Team location not ready"; + } + if (result.failure == ::ui::UiActionFailure::Unsupported) + { + return "Team location unsupported"; + } + if (result.failure == ::ui::UiActionFailure::InvalidInput) + { + return "Invalid marker"; + } + return "Team location send failed"; +} + +} // namespace chat::ui diff --git a/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp b/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp index a1f7f9ba..b8ee351d 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp @@ -12,12 +12,11 @@ #include "chat_presentation_adapters/chat_conversation_mapper.h" #include "platform/ui/screen_runtime.h" #include "sys/event_bus.h" -#include "team/protocol/team_location_marker.h" #include "ui/app_runtime.h" #include "ui/assets/fonts/font_utils.h" #include "ui/localization.h" +#include "ui/screens/chat/chat_team_workflow.h" #include "ui/screens/chat/chat_protocol_support.h" -#include "ui/team_actions/team_action_sink.h" #include "ui/ui_common.h" #include "ui/widgets/ime/ime_widget.h" #include "ui/widgets/system_notification.h" @@ -174,55 +173,6 @@ void applySnapshotMessagesToConversation( conversation.scrollToBottom(); } -bool buildSelectedTeamSnapshot(::ui::chat::ChatWorkspaceModel& model, - ::ui::chat::ChatWorkspaceSnapshot& out) -{ - if (!model.buildSnapshot(out) || out.conversation_count == 0) - { - return false; - } - - (void)model.selectConversation(out.conversations[0].id); - (void)model.buildSnapshot(out); - return out.header.valid && out.conversation_count > 0; -} - -void showTeamSendFailure(::ui::UiActionResult result) -{ - const char* message = "Team chat send failed"; - if (result.failure == ::ui::UiActionFailure::NotReady) - { - message = "Team keys not ready"; - } - else if (result.failure == ::ui::UiActionFailure::Unsupported) - { - message = "Team chat unsupported"; - } - else if (result.failure == ::ui::UiActionFailure::InvalidInput) - { - message = "Message unavailable"; - } - ::ui::SystemNotification::show(message, 2000); -} - -void showTeamLocationSendFailure(::ui::UiActionResult result) -{ - const char* message = "Team location send failed"; - if (result.failure == ::ui::UiActionFailure::NotReady) - { - message = "Team location not ready"; - } - else if (result.failure == ::ui::UiActionFailure::Unsupported) - { - message = "Team location unsupported"; - } - else if (result.failure == ::ui::UiActionFailure::InvalidInput) - { - message = "Invalid marker"; - } - ::ui::SystemNotification::show(message, 2000); -} - const char* key_verification_action_failure_message(::ui::UiActionResult result) { if (result.failure == ::ui::UiActionFailure::NotReady) @@ -300,16 +250,14 @@ void handle_conversation_back(void* user_data) UiController::UiController(lv_obj_t* parent, chat::ChatService& service, ::ui::chat::ChatWorkspaceModel& chat_model, - ::ui::chat::ChatWorkspaceModel& team_chat_model, - ::ui::team_actions::ITeamActionSink* team_action_sink, + ChatTeamWorkflow& team_workflow, ::ui::key_verification::KeyVerificationModel* key_verification_model, chat::ChannelId initial_channel, ExitRequestCallback exit_request, void* exit_request_user_data) : parent_(parent), service_(service), chat_model_(chat_model), - team_chat_model_(team_chat_model), - team_action_sink_(team_action_sink), + team_workflow_(team_workflow), key_verification_model_(key_verification_model), state_(State::ChannelList), current_channel_(initial_channel), @@ -583,7 +531,7 @@ void UiController::switchToConversation(chat::ConversationId conv) if (team_conv_active_) { const bool loaded = - buildSelectedTeamSnapshot(team_chat_model_, team_chat_snapshot_buffer_); + team_workflow_.buildSelectedSnapshot(team_chat_snapshot_buffer_); std::string title = loaded && team_chat_snapshot_buffer_.conversation_count > 0 ? team_chat_snapshot_buffer_.conversations[0].title.c_str() : "Team"; @@ -599,7 +547,7 @@ void UiController::switchToConversation(chat::ConversationId conv) startTeamConversationTimer(); if (loaded && unread != 0) { - (void)team_chat_model_.markRead( + (void)team_workflow_.markRead( team_chat_snapshot_buffer_.conversations[0].id); sys::EventBus::publish( new sys::ChatUnreadChangedEvent(kTeamChatChannelRaw, 0), 0); @@ -730,7 +678,7 @@ void UiController::switchToCompose(chat::ConversationId conv) std::string title = resolveConversationDisplayName(conv); if (team_conv_active_) { - if (buildSelectedTeamSnapshot(team_chat_model_, team_chat_snapshot_buffer_) && + if (team_workflow_.buildSelectedSnapshot(team_chat_snapshot_buffer_) && team_chat_snapshot_buffer_.conversation_count > 0) { title = team_chat_snapshot_buffer_.conversations[0].title.c_str(); @@ -785,10 +733,12 @@ void UiController::handleSendMessage(const std::string& text) if (team_conv_active_) { const ::ui::UiActionResult result = - team_chat_model_.sendMessage(text.c_str()); + team_workflow_.sendText(text.c_str()); if (!result.ok) { - showTeamSendFailure(result); + ::ui::SystemNotification::show( + team_workflow_.textSendFailureMessage(result), + 2000); } handleComposeSendDone(result.ok, false); return; @@ -1027,7 +977,7 @@ bool UiController::loadChatSnapshot() bool UiController::loadTeamChatSnapshot() { - return team_chat_model_.buildSnapshot(team_chat_snapshot_buffer_); + return team_workflow_.buildSnapshot(team_chat_snapshot_buffer_); } void UiController::refreshTeamConversation() @@ -1288,38 +1238,16 @@ void UiController::onTeamPositionCancel() closeTeamPositionPicker(true); } -bool UiController::sendTeamLocationWithIcon(uint8_t icon_id) -{ - if (!team::proto::team_location_marker_icon_is_valid(icon_id)) - { - ::ui::SystemNotification::show("Invalid marker", 1500); - return false; - } - - if (team_action_sink_ == nullptr) - { - ::ui::SystemNotification::show("Team chat send failed", 2000); - return false; - } - - ::ui::team_actions::TeamActionRequest request; - request.kind = ::ui::team_actions::TeamActionKind::LocationMarker; - request.location.use_current_location = true; - request.location.marker_icon = icon_id; - request.location.label = team::proto::team_location_marker_icon_name(icon_id); - - const auto result = team_action_sink_->sendTeamAction(request); - if (!result.ok) - { - showTeamLocationSendFailure(result); - } - return result.ok; -} - void UiController::onTeamPositionIconSelected(uint8_t icon_id) { closeTeamPositionPicker(true); - sendTeamLocationWithIcon(icon_id); + const auto result = team_workflow_.sendCurrentLocationMarker(icon_id); + if (!result.ok) + { + ::ui::SystemNotification::show( + team_workflow_.locationSendFailureMessage(result), + 2000); + } switchToConversation(current_conv_); } @@ -1381,10 +1309,12 @@ void UiController::handleComposeAction(ChatComposeScreen::ActionIntent intent) return; } const ::ui::UiActionResult result = - team_chat_model_.sendMessage(text.c_str()); + team_workflow_.sendText(text.c_str()); if (!result.ok) { - showTeamSendFailure(result); + ::ui::SystemNotification::show( + team_workflow_.textSendFailureMessage(result), + 2000); } switchToConversation(current_conv_); return; diff --git a/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp b/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp index 1b30d593..61f02436 100644 --- a/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp +++ b/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp @@ -39,6 +39,31 @@ bool message_ref_matches_id(const ::ui::chat::MessageRef& ref, } return ref.local_id == static_cast(msg_id); } + +std::string format_team_rich_payload_text( + const ::ui::chat::TeamMessageRichPayload& payload) +{ + std::string out; + if (!payload.badge.empty() && + payload.kind != ::ui::chat::TeamMessageRichPayloadKind::Text) + { + out += payload.badge.c_str(); + out += ": "; + } + if (!payload.summary.empty()) + { + out += payload.summary.c_str(); + } + else if (!payload.title.empty()) + { + out += payload.title.c_str(); + } + else + { + out += "Team"; + } + return out; +} } // namespace namespace chat::ui @@ -157,15 +182,19 @@ void ChatConversationScreen::createMessageItem(const ::ui::chat::MessageRow& row LV_FLEX_ALIGN_START); std::string display_text; + const std::string body_text = + row.has_team_rich_payload + ? format_team_rich_payload_text(row.team_rich_payload) + : std::string(row.text.c_str()); if (!row.outgoing && !row.sender_label.empty()) { display_text = row.sender_label.c_str(); display_text += ": "; - display_text += row.text.c_str(); + display_text += body_text; } else { - display_text = row.text.c_str(); + display_text = body_text; } item.text_label = lv_label_create(item.container); diff --git a/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp b/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp index 37d0b149..514be62e 100644 --- a/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp @@ -14,14 +14,13 @@ #include "platform/ui/gps_runtime.h" #include "platform/ui/team_ui_store_runtime.h" #include "sys/clock.h" -#include "team/protocol/team_chat.h" #include "team/protocol/team_position.h" #include "team/usecase/team_controller.h" #include "ui/app_runtime.h" #include "ui/components/info_card.h" -#include "ui/formatters.h" #include "ui/localization.h" #include "ui/page/page_profile.h" +#include "ui/presentation_sources/team_chat_presentation_source.h" #include "ui/screens/chat/chat_compose_components.h" #include "ui/screens/chat/chat_conversation_components.h" #include "ui/screens/chat/chat_page_shell.h" @@ -30,19 +29,20 @@ #include "ui/screens/contacts/contacts_page_input.h" #include "ui/screens/contacts/contacts_page_layout.h" #include "ui/screens/contacts/contacts_page_styles.h" +#include "ui/screens/contacts/contacts_team_snapshot_source.h" #include "ui/screens/contacts/contacts_state.h" #include "ui/screens/node_info/node_info_page_components.h" -#include "ui/screens/team/team_state.h" +#include "ui/team_actions/team_action_runtime_sink.h" +#include "ui/team_actions/team_runtime_adapters.h" #include "ui/ui_common.h" #include "ui/widgets/ime/ime_widget.h" #include "ui/widgets/system_notification.h" -#include "ui_presentation/chat/chat_workspace_snapshot.h" #include #include -#include #include -#include +#include +#include #include #define CONTACTS_DEBUG 0 @@ -91,9 +91,12 @@ 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; +static std::unique_ptr<::ui::presentation_sources::ITeamChatCommandPort> s_team_chat_command_port; +static std::unique_ptr<::ui::team_actions::ITeamLocationSource> s_team_location_source; +static std::unique_ptr<::ui::team_actions::TeamActionRuntimeSink> s_team_action_sink; +static std::unique_ptr<::ui::presentation_sources::TeamChatPresentationSource> s_team_chat_source; +static team::TeamController* s_team_action_controller = nullptr; +static std::unique_ptr s_team_snapshot_source; static bool show_second_column_back() { @@ -151,7 +154,6 @@ 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 uint32_t current_timestamp_seconds(); static void send_team_position(); static void refresh_filter_checked_state(); @@ -524,195 +526,117 @@ static bool get_discovery_action_spec(int index, DiscoveryActionSpec* out) return true; } -static const char* team_command_name(team::proto::TeamCommandType type) +static contacts::ui::ContactsTeamSnapshotSource& contacts_team_snapshot_source() { - switch (type) + if (!s_team_snapshot_source) { - case team::proto::TeamCommandType::RallyTo: - return "RallyTo"; - case team::proto::TeamCommandType::MoveTo: - return "MoveTo"; - case team::proto::TeamCommandType::Hold: - return "Hold"; - default: - return "Command"; + s_team_snapshot_source = + std::unique_ptr( + new contacts::ui::ContactsTeamSnapshotSource( + team::ui::team_ui_snapshot_store())); } + return *s_team_snapshot_source; } -static std::string truncate_text(const std::string& text, size_t max_len) +static bool load_contacts_team_snapshot( + contacts::ui::ContactsTeamSnapshot& snapshot) { - 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]; - char coord_buf[64]; - uint8_t coord_fmt = app::appFacade().getConfig().gps_coord_format; - ui_format_coords(lat, lon, coord_fmt, coord_buf, sizeof(coord_buf)); - if (!loc.label.empty()) - { - snprintf(buf, - sizeof(buf), - "%s", - ::ui::i18n::format("Location: %s %s", loc.label.c_str(), coord_buf).c_str()); - } - else - { - snprintf(buf, sizeof(buf), "%s", ::ui::i18n::format("Location: %s", coord_buf).c_str()); - } - return std::string(buf); - } - return ::ui::i18n::tr("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]; - char coord_buf[64]; - uint8_t coord_fmt = app::appFacade().getConfig().gps_coord_format; - ui_format_coords(lat, lon, coord_fmt, coord_buf, sizeof(coord_buf)); - if (cmd.lat_e7 != 0 || cmd.lon_e7 != 0) - { - if (!cmd.note.empty()) - { - snprintf(buf, - sizeof(buf), - "%s", - ::ui::i18n::format("Command: %s %s %s", name, coord_buf, cmd.note.c_str()).c_str()); - } - else - { - snprintf(buf, - sizeof(buf), - "%s", - ::ui::i18n::format("Command: %s %s", name, coord_buf).c_str()); - } - } - else if (!cmd.note.empty()) - { - snprintf(buf, - sizeof(buf), - "%s", - ::ui::i18n::format("Command: %s %s", name, cmd.note.c_str()).c_str()); - } - else - { - snprintf(buf, sizeof(buf), "%s", ::ui::i18n::format("Command: %s", name).c_str()); - } - return std::string(buf); - } - return ::ui::i18n::tr("Command"); - } - return ::ui::i18n::tr("Message"); -} - -static void copy_team_chat_time_label(::ui::FixedText<24>& out, uint32_t timestamp) -{ - if (timestamp == 0) - { - out.clear(); - return; - } - - char buffer[24]{}; - snprintf(buffer, sizeof(buffer), "%lu", static_cast(timestamp)); - ::ui::copyText(out, buffer); -} - -static void copy_team_chat_sender_label(::ui::FixedText<32>& out, - const team::ui::TeamChatLogEntry& entry) -{ - if (!entry.incoming) - { - ::ui::copyText(out, "Me"); - return; - } - - for (const auto& member : team::ui::g_team_state.members) - { - if (member.node_id == entry.peer_id && !member.name.empty()) - { - ::ui::copyText(out, member.name.c_str()); - return; - } - } - - char buffer[16]{}; - snprintf(buffer, - sizeof(buffer), - "%04lX", - static_cast(entry.peer_id & 0xFFFFU)); - ::ui::copyText(out, buffer); -} - -static uint64_t team_chat_message_local_id(const team::ui::TeamChatLogEntry& entry, - size_t index) -{ - uint64_t id = 1469598103934665603ULL; - id ^= entry.ts; - id *= 1099511628211ULL; - id ^= entry.peer_id; - id *= 1099511628211ULL; - id ^= static_cast(index + 1U); - id *= 1099511628211ULL; - return id == 0 ? static_cast(index + 1U) : id; -} - -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; + // Team chat should be reachable once we know a team_id (e.g. after receiving TEAM_CHAT). + return contacts_team_snapshot_source().load(snapshot) && snapshot.available; } 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; + return contacts_team_snapshot_source().isAvailable(); +} + +static std::string contacts_team_title() +{ + contacts::ui::ContactsTeamSnapshot snapshot; + (void)load_contacts_team_snapshot(snapshot); + return contacts::ui::contactsTeamDisplayName(snapshot, "Team"); +} + +static void reset_contacts_team_action_seam() +{ + s_team_action_sink.reset(); + s_team_location_source.reset(); + s_team_chat_command_port.reset(); + s_team_action_controller = nullptr; + s_team_snapshot_source.reset(); +} + +static ::ui::team_actions::ITeamActionSink* contacts_team_action_sink() +{ + team::TeamController* controller = app::teamFacade().getTeamController(); + if (controller == nullptr) + { + reset_contacts_team_action_seam(); + return nullptr; + } + if (controller != s_team_action_controller) + { + reset_contacts_team_action_seam(); + s_team_action_controller = controller; + } + + if (!s_team_chat_command_port) + { + s_team_chat_command_port = + std::unique_ptr<::ui::presentation_sources::ITeamChatCommandPort>( + new ::ui::team_actions::TeamControllerChatCommandPort( + *controller)); + } + if (!s_team_location_source) + { + s_team_location_source = + std::unique_ptr<::ui::team_actions::ITeamLocationSource>( + new ::ui::team_actions::GpsTeamLocationSource()); + } + if (!s_team_action_sink) + { + s_team_action_sink = + std::unique_ptr<::ui::team_actions::TeamActionRuntimeSink>( + new ::ui::team_actions::TeamActionRuntimeSink( + team::ui::team_ui_snapshot_store(), + team::ui::team_ui_chat_log_store(), + s_team_chat_command_port.get(), + s_team_location_source.get())); + } + return s_team_action_sink.get(); +} + +static ::ui::presentation_sources::TeamChatPresentationSource& contacts_team_chat_source() +{ + if (!s_team_chat_source) + { + s_team_chat_source = + std::unique_ptr<::ui::presentation_sources::TeamChatPresentationSource>( + new ::ui::presentation_sources::TeamChatPresentationSource( + team::ui::team_ui_snapshot_store(), + team::ui::team_ui_chat_log_store())); + } + return *s_team_chat_source; +} + +static const char* team_action_failure_message( + ::ui::UiActionResult result, + const char* default_message, + bool location_action = false) +{ + if (result.failure == ::ui::UiActionFailure::NotReady) + { + return location_action ? "Team location not ready" : "Team keys not ready"; + } + if (result.failure == ::ui::UiActionFailure::Unsupported) + { + return "Team chat unsupported"; + } + if (result.failure == ::ui::UiActionFailure::InvalidInput) + { + return "Message unavailable"; + } + return default_message; } static uint32_t current_timestamp_seconds() @@ -1421,9 +1345,7 @@ static void open_chat_compose() } channel = chat::ChannelId::PRIMARY; peer_id = 0; - title = team::ui::g_team_state.team_name.empty() - ? "Team" - : team::ui::g_team_state.team_name; + title = contacts_team_title(); } else { @@ -1586,90 +1508,21 @@ static void on_compose_action(chat::ui::ChatComposeScreen::ActionIntent intent, { if (s_compose_is_team) { - app::IAppFacade& app_ctx = app::appFacade(); - team::TeamController* controller = app_ctx.getTeamController(); - if (!controller || !is_team_available()) + auto* action_sink = contacts_team_action_sink(); + if (!action_sink || !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 = current_timestamp_seconds(); + ::ui::team_actions::TeamActionRequest request; if (intent == chat::ui::ChatComposeScreen::ActionIntent::Position) { std::string label = g_contacts_state.compose_screen->getText(); - ::gps::GpsState gps_state = platform::ui::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); - } + request.kind = ::ui::team_actions::TeamActionKind::LocationShare; + request.location_share.use_current_location = true; + request.location_share.label = label.empty() ? nullptr : label.c_str(); } else { @@ -1679,30 +1532,28 @@ static void on_compose_action(chat::ui::ChatComposeScreen::ActionIntent intent, 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()); + request.kind = ::ui::team_actions::TeamActionKind::Text; + request.text = text.c_str(); + } - bool ok = controller->onChat(msg, chat::ChannelId::PRIMARY); - if (ok) + const auto result = action_sink->sendTeamAction(request); + if (!result.ok) + { + const char* default_message = + (intent == chat::ui::ChatComposeScreen::ActionIntent::Position) + ? "Team location send failed" + : "Team chat send failed"; + const bool location_action = + intent == chat::ui::ChatComposeScreen::ActionIntent::Position; + ::ui::SystemNotification::show( + team_action_failure_message(result, + default_message, + location_action), + 2000); + if (location_action && + result.failure == ::ui::UiActionFailure::NotReady) { - 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); + return; } } close_chat_compose(); @@ -1732,8 +1583,6 @@ static void on_compose_action(chat::ui::ChatComposeScreen::ActionIntent intent, { if (g_contacts_state.chat_service) { - s_last_sent_text = text; - s_last_sent_ts = sys::epoch_seconds_now(); const chat::ConversationId conv(s_compose_channel, s_compose_peer_id, s_compose_protocol); chat::ui::send_flow::begin_local_text_send(g_contacts_state.compose_screen, g_contacts_state.chat_service, @@ -1755,24 +1604,8 @@ static void on_compose_back(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 = sys::epoch_seconds_now(); - } - team::ui::team_ui_chatlog_append(team::ui::g_team_state.team_id, - 0, - false, - ts, - s_last_sent_text); - } + (void)ok; close_chat_compose(); - s_last_sent_text.clear(); - s_last_sent_ts = 0; if (g_contacts_state.conversation_screen) { refresh_team_conversation(); @@ -1787,28 +1620,19 @@ static void refresh_team_conversation() } 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)) + ::ui::chat::ChatWorkspaceRequest request; + ::ui::chat::ChatWorkspaceSnapshot snapshot; + if (contacts_team_chat_source().buildChatWorkspaceSnapshot(request, snapshot) && + snapshot.conversation_count > 0) { - for (size_t index = 0; index < entries.size(); ++index) + request.selected = snapshot.conversations[0].id; + if (contacts_team_chat_source().buildChatWorkspaceSnapshot(request, snapshot)) { - const auto& entry = entries[index]; - ::ui::chat::MessageRow row; - row.conversation.kind = ::ui::chat::ConversationKind::Team; - row.conversation.protocol = ::ui::chat::ChatProtocolKind::TrailMate; - row.conversation.primary = 1; - row.outgoing = !entry.incoming; - row.delivery = entry.incoming ? ::ui::chat::MessageDeliveryState::Received - : ::ui::chat::MessageDeliveryState::Sent; - row.failure = ::ui::chat::MessageFailureKind::None; - row.sender_node_id = entry.incoming ? entry.peer_id : 0; - row.ref.origin = entry.incoming ? ::ui::chat::MessageOrigin::RemoteStored - : ::ui::chat::MessageOrigin::LocalStored; - row.ref.local_id = team_chat_message_local_id(entry, index); - ::ui::copyText(row.text, format_team_chat_entry(entry).c_str()); - copy_team_chat_time_label(row.time_label, entry.ts); - copy_team_chat_sender_label(row.sender_label, entry); - g_contacts_state.conversation_screen->addMessage(row); + for (size_t index = 0; index < snapshot.message_count; ++index) + { + g_contacts_state.conversation_screen->addMessage( + snapshot.messages[index]); + } } } g_contacts_state.conversation_screen->scrollToBottom(); @@ -1864,10 +1688,8 @@ static void on_team_conversation_back(void* /*user_data*/) 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); + const std::string title = contacts_team_title(); + g_contacts_state.conversation_screen->setHeaderText(title.c_str(), nullptr); g_contacts_state.conversation_screen->updateBatteryFromBoard(); refresh_team_conversation(); @@ -1930,7 +1752,8 @@ static void close_team_conversation() static void send_team_position() { - if (!is_team_available()) + contacts::ui::ContactsTeamSnapshot team_snapshot; + if (!load_contacts_team_snapshot(team_snapshot)) { return; } @@ -2013,7 +1836,7 @@ static void send_team_position() 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, + team::ui::team_ui_posring_append(team_snapshot.team_id, 0, lat_e7, lon_e7, @@ -2625,15 +2448,17 @@ void refresh_ui() } else if (g_contacts_state.current_mode == ContactsMode::Team) { + contacts::ui::ContactsTeamSnapshot team_snapshot; + (void)load_contacts_team_snapshot(team_snapshot); 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() - ? ::ui::i18n::tr("Team") - : team::ui::g_team_state.team_name; + team_node.display_name = + contacts::ui::contactsTeamDisplayName(team_snapshot, + ::ui::i18n::tr("Team")); team_list.push_back(team_node); current_list = &team_list; } diff --git a/modules/ui_shared/src/ui/screens/contacts/contacts_team_snapshot_source.cpp b/modules/ui_shared/src/ui/screens/contacts/contacts_team_snapshot_source.cpp new file mode 100644 index 00000000..f9012fea --- /dev/null +++ b/modules/ui_shared/src/ui/screens/contacts/contacts_team_snapshot_source.cpp @@ -0,0 +1,46 @@ +#include "ui/screens/contacts/contacts_team_snapshot_source.h" + +namespace contacts::ui +{ + +ContactsTeamSnapshotSource::ContactsTeamSnapshotSource( + ::team::ui::ITeamUiSnapshotStore& snapshot_store) + : snapshot_store_(snapshot_store) +{ +} + +bool ContactsTeamSnapshotSource::load(ContactsTeamSnapshot& out) +{ + out = ContactsTeamSnapshot{}; + + ::team::ui::TeamUiSnapshot snapshot; + if (!snapshot_store_.load(snapshot)) + { + return false; + } + + out.in_team = snapshot.in_team; + out.has_team_id = snapshot.has_team_id; + out.team_id = snapshot.team_id; + out.team_name = snapshot.team_name; + out.available = snapshot.has_team_id; + return out.available; +} + +bool ContactsTeamSnapshotSource::isAvailable() +{ + ContactsTeamSnapshot snapshot; + return load(snapshot) && snapshot.available; +} + +std::string contactsTeamDisplayName(const ContactsTeamSnapshot& snapshot, + const char* fallback) +{ + if (!snapshot.team_name.empty()) + { + return snapshot.team_name; + } + return fallback != nullptr ? std::string(fallback) : std::string(); +} + +} // namespace contacts::ui diff --git a/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp b/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp index fe17d143..1df9dd1a 100644 --- a/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp @@ -271,7 +271,7 @@ class EspGpsRuntimeRefreshModel final : public ::ui::screens::gps::IGpsStatusRef } refresh_member_panel(false); - refresh_team_markers_from_posring(); + refresh_team_markers_from_team_source(); refresh_team_signal_markers_from_chatlog(); if (g_gps_state.zoom_modal.is_open()) @@ -652,13 +652,13 @@ using Host = gps::ui::shell::Host; #include "app/app_config.h" #include "app/app_facade_access.h" #include "platform/ui/device_runtime.h" -#include "platform/ui/team_ui_store_runtime.h" #include "sys/clock.h" #include "ui/app_runtime.h" #include "ui/localization.h" #include "ui/presentation_sources/legacy_gps_status_source.h" #include "ui/presentation_sources/legacy_map_action_sink.h" #include "ui/presentation_sources/legacy_map_presentation_source.h" +#include "ui/presentation_sources/team_map_overlay_source.h" #include "ui/screens/gps/gps_constants.h" #include "ui/ui_common.h" #include "ui/widgets/map/map_viewport.h" @@ -670,7 +670,6 @@ using Host = gps::ui::shell::Host; #include #include -#include #if !defined(LV_FONT_MONTSERRAT_12) || !LV_FONT_MONTSERRAT_12 #define lv_font_montserrat_12 lv_font_montserrat_14 @@ -710,7 +709,7 @@ void request_exit() static ::ui::presentation_sources::LegacyMapPresentationSource source( ::ui::presentation_sources::legacy_gps_status_source(), ::ui::presentation_sources::legacy_map_presentation_state(), - &::team::ui::team_ui_get_store()); + &::team::ui::team_ui_snapshot_store()); static ::ui::presentation_sources::LegacyMapActionSink sink( ::ui::presentation_sources::legacy_gps_status_source(), ::ui::presentation_sources::legacy_map_presentation_state()); @@ -736,75 +735,11 @@ class LinuxMapOverlayGpsSource final : public ::ui::map_overlay::IMapOverlayGpsS } }; -class LinuxMapOverlayTeamSource final : public ::ui::map_overlay::IMapOverlayTeamSource -{ - public: - std::size_t latestTeamPoints(TeamPoint* out, std::size_t capacity) const override - { - if (out == nullptr || capacity == 0) - { - return 0; - } - - ::team::ui::TeamUiSnapshot team_snapshot; - if (!::team::ui::team_ui_get_store().load(team_snapshot) || - !team_snapshot.in_team || - !team_snapshot.has_team_id) - { - return 0; - } - - std::vector<::team::ui::TeamPosSample> samples; - if (!::team::ui::team_ui_posring_load_latest(team_snapshot.team_id, samples)) - { - return 0; - } - - std::size_t count = 0; - for (const auto& sample : samples) - { - if (count >= capacity) - { - break; - } - - out[count].node_id = sample.member_id; - out[count].label = labelForMember(team_snapshot, sample.member_id, count); - out[count].lat = static_cast(sample.lat_e7) / 10000000.0; - out[count].lon = static_cast(sample.lon_e7) / 10000000.0; - out[count].valid = sample.lat_e7 != 0 || sample.lon_e7 != 0; - ++count; - } - return count; - } - - private: - static const char* labelForMember(const ::team::ui::TeamUiSnapshot& snapshot, - uint32_t node_id, - std::size_t index) - { - static char labels[::ui::map::MapOverlaySnapshot::kMaxItems][32]{}; - if (index >= ::ui::map::MapOverlaySnapshot::kMaxItems) - { - return nullptr; - } - - for (const auto& member : snapshot.members) - { - if (member.node_id == node_id && !member.name.empty()) - { - std::snprintf(labels[index], sizeof(labels[index]), "%s", member.name.c_str()); - return labels[index]; - } - } - return nullptr; - } -}; - ::ui::map::IMapOverlayPresentationSource& map_overlay_source() { static LinuxMapOverlayGpsSource gps; - static LinuxMapOverlayTeamSource team; + static ::ui::presentation_sources::TeamMapOverlaySource team( + ::team::ui::team_ui_snapshot_store()); static ::ui::map_overlay::MapOverlaySnapshotSource source(&gps, &team); return source; } diff --git a/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp b/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp index 94627088..785ba5ba 100644 --- a/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp @@ -885,7 +885,7 @@ static void perform_factory_reset() prefs_clear_ns(ns); } - team::ui::team_ui_get_store().clear(); + team::ui::team_ui_snapshot_store().clear(); ::ui::SystemNotification::show(::ui::i18n::tr("Resetting..."), 1500); platform_delay_ms(300); diff --git a/modules/ui_shared/src/ui/screens/team/team_page_activity_adapters.cpp b/modules/ui_shared/src/ui/screens/team/team_page_activity_adapters.cpp new file mode 100644 index 00000000..ca4f4cec --- /dev/null +++ b/modules/ui_shared/src/ui/screens/team/team_page_activity_adapters.cpp @@ -0,0 +1,81 @@ +#include "ui/screens/team/team_page_activity_sink.h" + +#include "platform/ui/team_ui_store_runtime.h" +#include "sys/event_bus.h" +#include "ui/screens/gps/gps_state.h" +#include "ui/screens/gps/gps_tracker_overlay.h" + +namespace team +{ +namespace ui +{ + +bool TeamPageActivityStoreAdapter::appendPosition(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) +{ + return team_ui_posring_append(team_id, + member_id, + lat_e7, + lon_e7, + alt_m, + speed_dmps, + ts); +} + +bool TeamPageActivityStoreAdapter::appendMemberTrack( + const TeamId& team_id, + uint32_t member_id, + const team::proto::TeamTrackMessage& track) +{ + return team_ui_append_member_track(team_id, member_id, track); +} + +bool TeamPageActivityStoreAdapter::memberTrackPath(const TeamId& team_id, + uint32_t member_id, + std::string& out_path) +{ + return team_ui_get_member_track_path(team_id, member_id, out_path); +} + +bool TeamPageActivityStoreAdapter::appendStructuredChat( + const TeamId& team_id, + uint32_t peer_id, + bool incoming, + uint32_t ts, + team::proto::TeamChatType type, + const std::vector& payload) +{ + return team_ui_chatlog_append_structured(team_id, + peer_id, + incoming, + ts, + type, + payload); +} + +uint32_t TeamPageGpsTrackLoaderAdapter::selectedMemberId() const +{ + return g_gps_state.selected_member_id; +} + +bool TeamPageGpsTrackLoaderAdapter::loadTrackFile(const char* path, + bool show_toast) +{ + return gps_tracker_load_file(path, show_toast); +} + +void TeamPageUnreadPublisherAdapter::publishTeamUnread(uint32_t unread_count) +{ + sys::EventBus::publish(new sys::ChatUnreadChangedEvent( + 2, + static_cast(unread_count)), + 0); +} + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/src/ui/screens/team/team_page_activity_sink.cpp b/modules/ui_shared/src/ui/screens/team/team_page_activity_sink.cpp new file mode 100644 index 00000000..bc48bb8e --- /dev/null +++ b/modules/ui_shared/src/ui/screens/team/team_page_activity_sink.cpp @@ -0,0 +1,237 @@ +#include "ui/screens/team/team_page_activity_sink.h" + +#include "team/protocol/team_position.h" +#include "team/protocol/team_waypoint.h" + +#include +#include + +namespace team +{ +namespace ui +{ + +TeamPageActivitySink::TeamPageActivitySink( + ITeamPagePositionLog& position_log, + ITeamPageChatLog& chat_log, + ITeamPageGpsTrackLoader& gps_track_loader, + ITeamPageUnreadPublisher& unread_publisher, + TeamPageActivityContext context) + : position_log_(position_log), + chat_log_(chat_log), + gps_track_loader_(gps_track_loader), + unread_publisher_(unread_publisher), + context_(context) +{ +} + +void TeamPageActivitySink::consumePosition( + TeamPageActivityState& state, + const team::TeamPositionEvent& event) const +{ + if (!state.has_team_id || event.payload.empty()) + { + return; + } + + team::proto::TeamPositionMessage position; + if (!team::proto::decodeTeamPositionMessage(event.payload.data(), + event.payload.size(), + &position)) + { + return; + } + + const int16_t alt_m = + team::proto::teamPositionHasAltitude(position) ? position.alt_m : 0; + const uint16_t speed_dmps = + team::proto::teamPositionHasSpeed(position) ? position.speed_dmps : 0; + appendPositionSample(state, + event.ctx.from, + position.lat_e7, + position.lon_e7, + alt_m, + speed_dmps, + fallbackTimestamp(position.ts, event.ctx.timestamp)); +} + +void TeamPageActivitySink::consumeWaypoint( + TeamPageActivityState& state, + const team::TeamWaypointEvent& event) const +{ + if (!state.has_team_id || + !team::proto::teamWaypointHasLocation(event.msg)) + { + return; + } + + appendPositionSample(state, + event.ctx.from, + event.msg.lat_e7, + event.msg.lon_e7, + 0, + 0, + fallbackTimestamp(0, event.ctx.timestamp)); +} + +void TeamPageActivitySink::consumeTrack( + TeamPageActivityState& state, + const team::TeamTrackEvent& event) const +{ + if (!state.has_team_id || event.payload.empty()) + { + return; + } + + team::proto::TeamTrackMessage track; + if (!team::proto::decodeTeamTrackMessage(event.payload.data(), + event.payload.size(), + &track) || + track.version != team::proto::kTeamTrackVersion) + { + return; + } + + const uint32_t base_ts = + fallbackTimestamp(track.start_ts, event.ctx.timestamp); + for (size_t i = 0; i < track.points.size(); ++i) + { + if ((track.valid_mask & (1u << static_cast(i))) == 0) + { + continue; + } + + const auto& point = track.points[i]; + const uint32_t ts = + base_ts + static_cast(track.interval_s) * + static_cast(i); + appendPositionSample(state, + event.ctx.from, + point.lat_e7, + point.lon_e7, + 0, + 0, + ts); + } + + position_log_.appendMemberTrack(state.team_id, event.ctx.from, track); + if (gps_track_loader_.selectedMemberId() != event.ctx.from) + { + return; + } + + std::string track_path; + if (position_log_.memberTrackPath(state.team_id, + event.ctx.from, + track_path)) + { + gps_track_loader_.loadTrackFile(track_path.c_str(), false); + } +} + +void TeamPageActivitySink::consumeChat( + TeamPageActivityState& state, + const team::TeamChatEvent& event) const +{ + if (!state.has_team_id) + { + return; + } + + const uint32_t from_id = + event.msg.header.from != 0 ? event.msg.header.from : event.ctx.from; + const uint32_t ts = + fallbackTimestamp(event.msg.header.ts, event.ctx.timestamp); + + chat_log_.appendStructuredChat(state.team_id, + from_id, + true, + ts, + event.msg.header.type, + event.msg.payload); + + const uint32_t previous_unread = state.team_chat_unread; + const bool incoming = + from_id != 0 && from_id != context_.self_node_id; + if (incoming && !context_.team_chat_visible) + { + if (state.team_chat_unread < std::numeric_limits::max()) + { + state.team_chat_unread += 1; + } + } + else if (context_.team_chat_visible) + { + state.team_chat_unread = 0; + } + publishUnreadIfChanged(previous_unread, state.team_chat_unread); + + if (event.msg.header.type != team::proto::TeamChatType::Location) + { + return; + } + + team::proto::TeamChatLocation location; + if (!team::proto::decodeTeamChatLocation(event.msg.payload.data(), + event.msg.payload.size(), + &location)) + { + return; + } + + appendPositionSample(state, + from_id, + location.lat_e7, + location.lon_e7, + location.alt_m, + 0, + fallbackTimestamp(location.ts, ts)); +} + +uint32_t TeamPageActivitySink::fallbackTimestamp(uint32_t preferred_ts, + uint32_t event_ts) const +{ + if (preferred_ts != 0) + { + return preferred_ts; + } + if (event_ts != 0) + { + return event_ts; + } + return context_.now_s; +} + +bool TeamPageActivitySink::appendPositionSample( + const TeamPageActivityState& state, + uint32_t member_id, + int32_t lat_e7, + int32_t lon_e7, + int16_t alt_m, + uint16_t speed_dmps, + uint32_t ts) const +{ + if (!state.has_team_id) + { + return false; + } + return position_log_.appendPosition(state.team_id, + member_id, + lat_e7, + lon_e7, + alt_m, + speed_dmps, + ts); +} + +void TeamPageActivitySink::publishUnreadIfChanged(uint32_t previous, + uint32_t current) const +{ + if (current != previous) + { + unread_publisher_.publishTeamUnread(current); + } +} + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/src/ui/screens/team/team_page_command_reducer.cpp b/modules/ui_shared/src/ui/screens/team/team_page_command_reducer.cpp new file mode 100644 index 00000000..ec0e709b --- /dev/null +++ b/modules/ui_shared/src/ui/screens/team/team_page_command_reducer.cpp @@ -0,0 +1,268 @@ +#include "ui/screens/team/team_page_command_reducer.h" + +#include +#include + +namespace team +{ +namespace ui +{ + +TeamPageCommandReducer::TeamPageCommandReducer( + TeamPageCommandContext context) + : context_(context) +{ +} + +TeamPageCommandEffects TeamPageCommandReducer::reduceCreate( + TeamPageCommandState& state, + const TeamPageGeneratedTeamSecrets& generated) const +{ + TeamPageCommandEffects effects; + state.in_team = true; + state.pending_join = false; + state.pending_join_started_s = 0; + state.kicked_out = false; + state.self_is_leader = true; + state.members.clear(); + + if (!state.has_team_id) + { + state.team_id = generated.team_id; + state.has_team_id = true; + state.team_name = formatTeamNameFromId(state.team_id); + } + if (state.security_round == 0) + { + state.security_round = 1; + } + if (!state.has_team_psk) + { + state.team_psk = generated.team_psk; + state.has_team_psk = true; + } + + ensureSelfMember(state, true); + state.last_update_s = context_.now_s; + + effects.accepted = true; + effects.changed = true; + effects.team_created_key_event = state.has_team_id; + effects.keys_changed = state.has_team_id && state.has_team_psk; + return effects; +} + +TeamPageCommandEffects TeamPageCommandReducer::reduceLeave( + TeamPageCommandState& state) const +{ + TeamPageCommandEffects effects; + resetTeamMembership(state, false); + effects.accepted = true; + effects.changed = true; + effects.clear_keys = true; + effects.stop_pairing = true; + effects.clear_keydist_pending = true; + effects.clear_status_pending = true; + return effects; +} + +TeamPageCommandEffects TeamPageCommandReducer::reduceKickedOut( + TeamPageCommandState& state) const +{ + TeamPageCommandEffects effects; + resetTeamMembership(state, true); + effects.accepted = true; + effects.changed = true; + effects.clear_keys = true; + effects.stop_pairing = true; + effects.clear_keydist_pending = true; + effects.clear_status_pending = true; + return effects; +} + +TeamPageCommandEffects TeamPageCommandReducer::reduceClearKickedOut( + TeamPageCommandState& state) const +{ + TeamPageCommandEffects effects; + state.kicked_out = false; + effects.accepted = true; + effects.changed = true; + return effects; +} + +TeamPageCommandEffects TeamPageCommandReducer::reducePairingStarted( + TeamPageCommandState& state, + TeamPairingRole role, + TeamPairingState pairing_state) const +{ + TeamPageCommandEffects effects; + state.pending_join = true; + state.pending_join_started_s = context_.now_s; + state.pairing_role = role; + state.pairing_state = pairing_state; + effects.accepted = true; + effects.changed = true; + return effects; +} + +TeamPageCommandEffects TeamPageCommandReducer::reduceJoinCanceled( + TeamPageCommandState& state) const +{ + TeamPageCommandEffects effects; + state.pending_join = false; + state.pending_join_started_s = 0; + resetPairing(state); + effects.accepted = true; + effects.changed = true; + effects.reset_controller_ui = true; + effects.stop_pairing = true; + return effects; +} + +TeamPageCommandEffects TeamPageCommandReducer::reduceKickConfirmed( + TeamPageCommandState& state, + uint32_t target_node_id, + const TeamPageKickRotation& rotation) const +{ + TeamPageCommandEffects effects; + const int index = findMemberIndex(state, target_node_id); + if (index < 0) + { + return effects; + } + + if (rotation.rotate_keys) + { + state.security_round = rotation.key_id; + state.team_psk = rotation.team_psk; + state.has_team_psk = true; + state.waiting_new_keys = false; + effects.keys_changed = true; + } + + state.members.erase(state.members.begin() + index); + state.selected_member_index = -1; + effects.accepted = true; + effects.changed = true; + effects.status_should_send = true; + effects.member_kicked = true; + effects.member_kicked_id = target_node_id; + return effects; +} + +TeamPageCommandEffects TeamPageCommandReducer::reduceTransferLeader( + TeamPageCommandState& state, + uint32_t target_node_id) const +{ + TeamPageCommandEffects effects; + const int index = findMemberIndex(state, target_node_id); + if (index < 0) + { + return effects; + } + + for (auto& member : state.members) + { + member.leader = false; + } + state.members[static_cast(index)].leader = true; + state.self_is_leader = false; + + effects.accepted = true; + effects.changed = true; + effects.leader_transferred_key_event = true; + effects.leader_target = target_node_id; + return effects; +} + +std::string TeamPageCommandReducer::formatTeamNameFromId(const TeamId& id) +{ + char buf[16]; + std::snprintf(buf, sizeof(buf), "TEAM-%02X%02X", id[0], id[1]); + return std::string(buf); +} + +void TeamPageCommandReducer::resetTeamMembership( + TeamPageCommandState& state, + bool kicked_out) const +{ + state.in_team = false; + state.pending_join = false; + state.pending_join_started_s = 0; + state.kicked_out = kicked_out; + state.self_is_leader = false; + state.last_event_seq = 0; + state.members.clear(); + state.selected_member_index = -1; + state.has_team_id = false; + state.team_id = TeamId{}; + state.team_name.clear(); + state.security_round = 0; + state.last_update_s = 0; + state.team_psk = {}; + state.has_team_psk = false; + state.waiting_new_keys = false; + resetPairing(state); +} + +void TeamPageCommandReducer::resetPairing( + TeamPageCommandState& state) const +{ + state.pairing_role = TeamPairingRole::None; + state.pairing_state = TeamPairingState::Idle; + state.pairing_peer_id = 0; + state.pairing_team_name.clear(); +} + +void TeamPageCommandReducer::ensureSelfMember(TeamPageCommandState& state, + bool leader) const +{ + const int index = findMemberIndex(state, 0); + if (index >= 0) + { + auto& self = state.members[static_cast(index)]; + self.name = "You"; + self.leader = leader; + if (self.last_seen_s == 0) + { + self.last_seen_s = context_.now_s; + } + assignMemberColor(self); + return; + } + + TeamMemberUi self; + self.node_id = 0; + self.name = "You"; + self.leader = leader; + self.last_seen_s = context_.now_s; + assignMemberColor(self); + state.members.push_back(self); +} + +int TeamPageCommandReducer::findMemberIndex( + const TeamPageCommandState& state, + uint32_t node_id) const +{ + for (size_t i = 0; i < state.members.size(); ++i) + { + if (state.members[i].node_id == node_id) + { + return static_cast(i); + } + } + return -1; +} + +void TeamPageCommandReducer::assignMemberColor(TeamMemberUi& member) const +{ + uint32_t node_id = member.node_id; + if (node_id == 0) + { + node_id = context_.self_node_id; + } + member.color_index = team_color_index_from_node_id(node_id); +} + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/src/ui/screens/team/team_page_components.cpp b/modules/ui_shared/src/ui/screens/team/team_page_components.cpp index 9ea6634f..3d25d040 100644 --- a/modules/ui_shared/src/ui/screens/team/team_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/team/team_page_components.cpp @@ -10,9 +10,6 @@ #include "sys/clock.h" #include "sys/event_bus.h" #include "team/protocol/team_mgmt.h" -#include "team/protocol/team_position.h" -#include "team/protocol/team_track.h" -#include "team/protocol/team_waypoint.h" #include "team/usecase/team_controller.h" #include "team/usecase/team_pairing_service.h" #include "team/usecase/team_service.h" @@ -20,20 +17,35 @@ #include "ui/chat_ui_runtime.h" #include "ui/localization.h" #include "ui/page/page_profile.h" -#include "ui/screens/gps/gps_state.h" -#include "ui/screens/gps/gps_tracker_overlay.h" +#include "ui/screens/team/team_page_activity_sink.h" #include "ui/screens/team/team_page_input.h" +#include "ui/screens/team/team_page_command_reducer.h" +#include "ui/screens/team/team_page_create_team_action.h" +#include "ui/screens/team/team_page_deferred_dispatch.h" +#include "ui/screens/team/team_page_event_effect_sink.h" +#include "ui/screens/team/team_page_event_reducer.h" +#include "ui/screens/team/team_page_flow_controller.h" +#include "ui/screens/team/team_page_key_event_log.h" +#include "ui/screens/team/team_page_key_request_action.h" +#include "ui/screens/team/team_page_kick_confirm_action.h" #include "ui/screens/team/team_page_layout.h" +#include "ui/screens/team/team_page_lvgl_renderer.h" +#include "ui/screens/team/team_page_pairing_command_action.h" +#include "ui/screens/team/team_page_read_model.h" +#include "ui/screens/team/team_page_request_keys_action.h" +#include "ui/screens/team/team_page_runtime_port.h" +#include "ui/screens/team/team_page_state_store.h" #include "ui/screens/team/team_page_styles.h" -#include "ui/screens/team/team_state.h" +#include "ui/screens/team/team_page_transfer_leader_action.h" #include "ui/ui_common.h" +#include "ui/widgets/top_bar.h" #include "ui/widgets/system_notification.h" -#include #include #include #include #include +#include namespace team { @@ -41,41 +53,125 @@ namespace ui { namespace { -constexpr int kActionBtnHeight = 28; -constexpr int kActionBtnMinWidth = 56; constexpr int kActionBtnPadH = 10; -constexpr int kListItemHeight = 28; -constexpr uint8_t kKeyDistMaxRetries = 3; -constexpr uint32_t kKeyDistRetryIntervalSec = 5; constexpr uint8_t kKeyRoleMember = 1; -bool s_state_loaded = false; -uint32_t s_rng_state = 0; + +struct TeamPageLvglContext +{ + lv_obj_t* root = 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* action_btns[3] = {nullptr, nullptr, nullptr}; + lv_obj_t* action_labels[3] = {nullptr, nullptr, nullptr}; + lv_obj_t* detail_label = nullptr; + + std::vector list_items; + std::vector focusables; + 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* leave_confirm_modal = nullptr; +}; + +struct TeamPageState +{ + TeamPage page = TeamPage::StatusNotInTeam; + std::vector nav_stack; + int selected_member_index = -1; + + bool in_team = false; + bool pending_join = false; + bool kicked_out = false; + bool self_is_leader = false; + uint32_t last_event_seq = 0; + TeamPairingRole pairing_role = TeamPairingRole::None; + TeamPairingState pairing_state = TeamPairingState::Idle; + uint32_t pairing_peer_id = 0; + std::string pairing_team_name; + + TeamId team_id{}; + bool has_team_id = false; + + std::string team_name; + uint32_t security_round = 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; + uint32_t team_chat_unread = 0; + + std::vector members; +}; + +struct TeamPageControllerContext +{ + TeamPageState state; + TeamPageLvglContext lvgl; + TeamPageStateStore state_store; + TeamPageDeferredDispatchQueue deferred_dispatch; + uint32_t rng_state = 0; +}; + +TeamPageControllerContext s_page_context; + +TeamPageControllerContext& team_page_context() +{ + return s_page_context; +} + +TeamPageState& team_page_state() +{ + return team_page_context().state; +} + +TeamPageLvglContext& team_page_lvgl_context() +{ + return team_page_context().lvgl; +} + +void reset_team_page_state() +{ + team_page_context().state = TeamPageState{}; +} + +void reset_team_page_lvgl_context() +{ + team_page_context().lvgl = TeamPageLvglContext{}; +} + +TeamPageInputContext input_context_from_lvgl() +{ + TeamPageInputContext context; + context.root = team_page_lvgl_context().root; + context.top_bar = &team_page_lvgl_context().top_bar_widget; + context.focusables = &team_page_lvgl_context().focusables; + context.default_focus = &team_page_lvgl_context().default_focus; + return context; +} uint8_t next_random_byte() { - if (s_rng_state == 0) + uint32_t& rng_state = team_page_context().rng_state; + if (rng_state == 0) { - s_rng_state = sys::millis_now() ^ 0xA5A55A5Au; - if (s_rng_state == 0) + rng_state = sys::millis_now() ^ 0xA5A55A5Au; + if (rng_state == 0) { - s_rng_state = 0x13579BDFu; + rng_state = 0x13579BDFu; } } - s_rng_state ^= (s_rng_state << 13); - s_rng_state ^= (s_rng_state >> 17); - s_rng_state ^= (s_rng_state << 5); - return static_cast(s_rng_state & 0xFFu); -} - -int action_button_height() -{ - const auto& profile = ::ui::page_profile::current(); - return profile.large_touch_hitbox ? profile.list_item_height : kActionBtnHeight; -} - -int list_item_height() -{ - return ::ui::page_profile::current().list_item_height; + rng_state ^= (rng_state << 13); + rng_state ^= (rng_state >> 17); + rng_state ^= (rng_state << 5); + return static_cast(rng_state & 0xFFu); } uint32_t now_secs(); @@ -84,54 +180,71 @@ bool is_team_chat_visible(); std::string resolve_node_name(uint32_t node_id); bool is_pairing_active(); void sync_pairing_from_service(); -void update_team_name_from_id(const TeamId& id); void fill_status_members(team::proto::TeamStatus& status); -void apply_member_list_from_status(const team::proto::TeamStatus& status); +void add_keydist_pending(uint32_t node_id, uint32_t key_id); +void mark_keydist_confirmed(uint32_t node_id, uint32_t key_id); +void schedule_status_broadcast(uint8_t repeats, uint32_t delay_s); -uint64_t team_id_to_u64(const TeamId& id) +class TeamPageRuntimeKeyEventWriter final : public ITeamPageKeyEventWriter { - uint64_t value = 0; - for (size_t i = 0; i < id.size(); ++i) + public: + bool appendKeyEvent(const TeamId& team_id, + TeamKeyEventType type, + uint32_t event_seq, + uint32_t timestamp_s, + const uint8_t* payload, + size_t payload_size) override { - value |= (static_cast(id[i]) << (8 * i)); + return team_ui_append_key_event(team_id, + type, + event_seq, + timestamp_s, + payload, + payload_size); } - return value; +}; + +TeamPageKeyEventState key_event_state_from_page() +{ + TeamPageKeyEventState state; + state.team_id = team_page_state().team_id; + state.has_team_id = team_page_state().has_team_id; + state.last_event_seq = team_page_state().last_event_seq; + state.security_round = team_page_state().security_round; + return state; } -void write_u32_le(std::vector& out, uint32_t v) +void apply_key_event_state_to_page(const TeamPageKeyEventState& state) { - 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)); + team_page_state().last_event_seq = state.last_event_seq; } -void write_u64_le(std::vector& out, uint64_t v) +TeamPageKeyEventLog current_key_event_log() { - for (int i = 0; i < 8; ++i) - { - out.push_back(static_cast((v >> (8 * i)) & 0xFF)); - } + static TeamPageRuntimeKeyEventWriter writer; + return TeamPageKeyEventLog(writer, now_secs()); } -bool append_key_event(TeamKeyEventType type, const std::vector& payload) +template +bool append_key_event_to_page(Append append) { - 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; + auto state = key_event_state_from_page(); + const auto log = current_key_event_log(); + const bool ok = append(log, state); + apply_key_event_state_to_page(state); + return ok; +} + +TeamPageRuntimePort current_runtime_port() +{ + static TeamPageKeyStorePortAdapter key_store; + static TeamPageControllerPortAdapter controller(nullptr); + static TeamPagePairingPortAdapter pairing(nullptr); + controller = + TeamPageControllerPortAdapter(app::teamFacade().getTeamController()); + pairing = + TeamPagePairingPortAdapter(app::teamFacade().getTeamPairing()); + return TeamPageRuntimePort(&controller, &pairing, &key_store); } void notify_send_failed(const char* action, bool needs_keys) @@ -174,165 +287,594 @@ void notify_send_failed_detail(const char* action, team::TeamService::SendError ::ui::SystemNotification::show(notice.c_str(), 2000); } -struct KeyDistPending +const char* kick_confirm_failure_action_text( + TeamPageKickConfirmFailureAction action) { - uint32_t node_id = 0; - uint32_t key_id = 0; - uint8_t attempts = 0; - uint32_t next_retry_s = 0; + switch (action) + { + case TeamPageKickConfirmFailureAction::Kick: + return "Kick"; + case TeamPageKickConfirmFailureAction::KeyDist: + return "KeyDist"; + case TeamPageKickConfirmFailureAction::Keys: + return "Keys"; + case TeamPageKickConfirmFailureAction::Status: + return "Status"; + default: + return "Send"; + } +} + +void apply_kick_confirm_failures( + const TeamPageKickConfirmEffects& effects) +{ + for (const auto& failure : effects.failures) + { + const char* action = + kick_confirm_failure_action_text(failure.action); + if (failure.kind == + TeamPageKickConfirmFailureKind::SendFailedDetail) + { + notify_send_failed_detail(action, failure.error); + } + else + { + notify_send_failed(action, failure.needs_keys); + } + } +} + +void apply_transfer_leader_failures( + const TeamPageTransferLeaderEffects& effects) +{ + for (const auto& failure : effects.failures) + { + (void)failure; + notify_send_failed("Transfer", true); + } +} + +void apply_create_team_failures(const TeamPageCreateTeamEffects& effects) +{ + for (const auto& failure : effects.failures) + { + if (failure.action == TeamPageCreateTeamFailureAction::Keys) + { + notify_send_failed("Keys", failure.needs_keys); + continue; + } + if (failure.kind == TeamPageCreateTeamFailureKind::PairingNotReady) + { + ::ui::SystemNotification::show("Pairing not ready", 2000); + } + else if (failure.kind == + TeamPageCreateTeamFailureKind::PairingInitFailed) + { + ::ui::SystemNotification::show("Pairing init failed", 2000); + } + } +} + +void apply_pairing_command_failures( + const TeamPagePairingCommandEffects& effects) +{ + for (const auto& failure : effects.failures) + { + switch (failure.kind) + { + case TeamPagePairingCommandFailureKind::LeaderRequired: + ::ui::SystemNotification::show("Only leader can pair", 2000); + break; + case TeamPagePairingCommandFailureKind::PairingNotReady: + ::ui::SystemNotification::show("Pairing not ready", 2000); + break; + case TeamPagePairingCommandFailureKind::PairingNotAvailable: + ::ui::SystemNotification::show("Pairing not available", 2000); + break; + case TeamPagePairingCommandFailureKind::PairingInitFailed: + default: + ::ui::SystemNotification::show("Pairing init failed", 2000); + break; + } + } +} + +class TeamPageMemberNameResolver final : public ITeamPageMemberNameResolver + , public ITeamPageLvglNameResolver +{ + public: + std::string resolveMemberName(uint32_t node_id) const override + { + return resolve_node_name(node_id); + } + + std::string resolveNodeName(uint32_t node_id) const override + { + return resolve_node_name(node_id); + } }; -std::vector s_keydist_pending; - -struct StatusBroadcastPending +TeamPageEventState event_state_from_page() { - uint32_t next_send_s = 0; - uint8_t remaining = 0; + TeamPageEventState state; + state.in_team = team_page_state().in_team; + state.pending_join = team_page_state().pending_join; + state.pending_join_started_s = team_page_state().pending_join_started_s; + state.kicked_out = team_page_state().kicked_out; + state.self_is_leader = team_page_state().self_is_leader; + state.last_event_seq = team_page_state().last_event_seq; + state.pairing_role = team_page_state().pairing_role; + state.pairing_state = team_page_state().pairing_state; + state.pairing_peer_id = team_page_state().pairing_peer_id; + state.pairing_team_name = team_page_state().pairing_team_name; + state.team_id = team_page_state().team_id; + state.has_team_id = team_page_state().has_team_id; + state.team_name = team_page_state().team_name; + state.security_round = team_page_state().security_round; + state.last_update_s = team_page_state().last_update_s; + state.team_psk = team_page_state().team_psk; + state.has_team_psk = team_page_state().has_team_psk; + state.waiting_new_keys = team_page_state().waiting_new_keys; + state.members = team_page_state().members; + return state; +} + +void apply_event_state_to_page(const TeamPageEventState& state) +{ + team_page_state().in_team = state.in_team; + team_page_state().pending_join = state.pending_join; + team_page_state().pending_join_started_s = state.pending_join_started_s; + team_page_state().kicked_out = state.kicked_out; + team_page_state().self_is_leader = state.self_is_leader; + team_page_state().last_event_seq = state.last_event_seq; + team_page_state().pairing_role = state.pairing_role; + team_page_state().pairing_state = state.pairing_state; + team_page_state().pairing_peer_id = state.pairing_peer_id; + team_page_state().pairing_team_name = state.pairing_team_name; + team_page_state().team_id = state.team_id; + team_page_state().has_team_id = state.has_team_id; + team_page_state().team_name = state.team_name; + team_page_state().security_round = state.security_round; + team_page_state().last_update_s = state.last_update_s; + team_page_state().team_psk = state.team_psk; + team_page_state().has_team_psk = state.has_team_psk; + team_page_state().waiting_new_keys = state.waiting_new_keys; + team_page_state().members = state.members; +} + +TeamPageFlowState flow_state_from_page() +{ + TeamPageFlowState state; + state.page = team_page_state().page; + state.nav_stack = team_page_state().nav_stack; + state.in_team = team_page_state().in_team; + state.pending_join = team_page_state().pending_join; + state.kicked_out = team_page_state().kicked_out; + state.pending_join_started_s = team_page_state().pending_join_started_s; + state.pairing_state = team_page_state().pairing_state; + return state; +} + +void apply_flow_state_to_page(const TeamPageFlowState& state) +{ + team_page_state().page = state.page; + team_page_state().nav_stack = state.nav_stack; + team_page_state().pending_join_started_s = state.pending_join_started_s; +} + +TeamPageFlowController current_flow_controller() +{ + return TeamPageFlowController(); +} + +TeamPageEventReducer current_event_reducer() +{ + static TeamPageMemberNameResolver names; + TeamPageEventContext context; + context.now_s = now_secs(); + context.self_node_id = app::messagingFacade().getSelfNodeId(); + context.names = &names; + return TeamPageEventReducer(context); +} + +template +TeamPageEventEffects reduce_event_state(Reduce reduce) +{ + auto state = event_state_from_page(); + auto reducer = current_event_reducer(); + const auto effects = reduce(reducer, state); + apply_event_state_to_page(state); + return effects; +} + +void apply_keydist_effect(const TeamPageEventEffects& effects) +{ + if (effects.keydist_confirmed) + { + mark_keydist_confirmed(effects.keydist_member_id, + effects.keydist_key_id); + } +} + +void apply_event_navigation_requests( + const TeamPageEventEffectResult& result) +{ + if (result.request_kicked_out_page) + { + team_page_state().page = TeamPage::KickedOut; + } + if (result.request_status_in_team_page) + { + team_page_state().page = TeamPage::StatusInTeam; + } + if (result.request_status_not_in_team_page) + { + team_page_state().page = TeamPage::StatusNotInTeam; + } + if (result.clear_nav_stack) + { + team_page_state().nav_stack.clear(); + } +} + +class TeamPageEventDeferredAdapter final : public ITeamPageEventDeferred +{ + public: + void confirmKeyDist(uint32_t node_id, uint32_t key_id) override + { + mark_keydist_confirmed(node_id, key_id); + } + + void scheduleStatusBroadcast(uint8_t repeats, + uint32_t delay_s) override + { + schedule_status_broadcast(repeats, delay_s); + } }; -std::vector s_status_pending; +class TeamPageEventNotifierAdapter final : public ITeamPageEventNotifier +{ + public: + void showMessage(const char* message) override + { + ::ui::SystemNotification::show(message, 2000); + } + + void notifySendFailed(const char* action, bool needs_keys) override + { + notify_send_failed(action, needs_keys); + } +}; + +TeamPageEventEffectResult apply_event_effects( + const TeamPageEventEffects& effects) +{ + static TeamPageMemberNameResolver names; + static TeamPageEventDeferredAdapter deferred; + static TeamPageEventNotifierAdapter notifier; + auto key_state = key_event_state_from_page(); + const auto result = TeamPageEventEffectSink().applyEffects( + event_state_from_page(), + key_state, + effects, + current_event_reducer(), + current_runtime_port(), + current_key_event_log(), + deferred, + notifier, + names); + apply_key_event_state_to_page(key_state); + apply_event_navigation_requests(result); + return result; +} + +TeamPageActivityState activity_state_from_page() +{ + TeamPageActivityState state; + state.team_id = team_page_state().team_id; + state.has_team_id = team_page_state().has_team_id; + state.team_chat_unread = team_page_state().team_chat_unread; + return state; +} + +void apply_activity_state_to_page(const TeamPageActivityState& state) +{ + team_page_state().team_chat_unread = state.team_chat_unread; +} + +TeamPageActivitySink current_activity_sink() +{ + static TeamPageActivityStoreAdapter store; + static TeamPageGpsTrackLoaderAdapter gps_track_loader; + static TeamPageUnreadPublisherAdapter unread_publisher; + TeamPageActivityContext context; + context.now_s = now_secs(); + context.self_node_id = app::messagingFacade().getSelfNodeId(); + context.team_chat_visible = is_team_chat_visible(); + return TeamPageActivitySink(store, + store, + gps_track_loader, + unread_publisher, + context); +} + +template +void consume_activity(Consume consume) +{ + auto state = activity_state_from_page(); + const auto sink = current_activity_sink(); + consume(sink, state); + apply_activity_state_to_page(state); +} + +void apply_event_cleanup_effects(const TeamPageEventEffects& effects) +{ + if (effects.clear_keydist_pending) + { + team_page_context().deferred_dispatch.clearKeyDist(); + } + if (effects.clear_status_pending) + { + team_page_context().deferred_dispatch.clearStatusBroadcasts(); + } +} + +void apply_event_runtime_effects(const TeamPageEventEffects& effects) +{ + const auto runtime = current_runtime_port(); + if (effects.clear_keys) + { + runtime.clearKeys(); + } + if (effects.stop_pairing) + { + runtime.stopPairing(); + } + apply_event_cleanup_effects(effects); +} + +TeamPagePairingUpdate pairing_update_from_event( + const team::TeamPairingEvent& event) +{ + TeamPagePairingUpdate update; + update.role = event.role; + update.state = event.state; + update.team_id = event.team_id; + update.has_team_id = event.has_team_id; + update.key_id = event.key_id; + update.peer_id = event.peer_id; + update.has_team_name = event.has_team_name; + if (event.has_team_name) + { + update.team_name = event.team_name; + } + return update; +} + +TeamPagePairingUpdate pairing_update_from_status( + const team::TeamPairingStatus& status) +{ + TeamPagePairingUpdate update; + update.role = status.role; + update.state = status.state; + update.team_id = status.team_id; + update.has_team_id = status.has_team_id; + update.key_id = status.key_id; + update.peer_id = status.peer_id; + update.has_team_name = status.has_team_name; + if (status.has_team_name) + { + update.team_name = status.team_name; + } + return update; +} + +TeamPageCommandState command_state_from_page() +{ + TeamPageCommandState state; + state.in_team = team_page_state().in_team; + state.pending_join = team_page_state().pending_join; + state.pending_join_started_s = team_page_state().pending_join_started_s; + state.kicked_out = team_page_state().kicked_out; + state.self_is_leader = team_page_state().self_is_leader; + state.last_event_seq = team_page_state().last_event_seq; + state.pairing_role = team_page_state().pairing_role; + state.pairing_state = team_page_state().pairing_state; + state.pairing_peer_id = team_page_state().pairing_peer_id; + state.pairing_team_name = team_page_state().pairing_team_name; + state.team_id = team_page_state().team_id; + state.has_team_id = team_page_state().has_team_id; + state.team_name = team_page_state().team_name; + state.security_round = team_page_state().security_round; + state.last_update_s = team_page_state().last_update_s; + state.team_psk = team_page_state().team_psk; + state.has_team_psk = team_page_state().has_team_psk; + state.waiting_new_keys = team_page_state().waiting_new_keys; + state.selected_member_index = team_page_state().selected_member_index; + state.members = team_page_state().members; + return state; +} + +void apply_command_state_to_page(const TeamPageCommandState& state) +{ + team_page_state().in_team = state.in_team; + team_page_state().pending_join = state.pending_join; + team_page_state().pending_join_started_s = state.pending_join_started_s; + team_page_state().kicked_out = state.kicked_out; + team_page_state().self_is_leader = state.self_is_leader; + team_page_state().last_event_seq = state.last_event_seq; + team_page_state().pairing_role = state.pairing_role; + team_page_state().pairing_state = state.pairing_state; + team_page_state().pairing_peer_id = state.pairing_peer_id; + team_page_state().pairing_team_name = state.pairing_team_name; + team_page_state().team_id = state.team_id; + team_page_state().has_team_id = state.has_team_id; + team_page_state().team_name = state.team_name; + team_page_state().security_round = state.security_round; + team_page_state().last_update_s = state.last_update_s; + team_page_state().team_psk = state.team_psk; + team_page_state().has_team_psk = state.has_team_psk; + team_page_state().waiting_new_keys = state.waiting_new_keys; + team_page_state().selected_member_index = state.selected_member_index; + team_page_state().members = state.members; +} + +TeamPageCommandReducer current_command_reducer() +{ + TeamPageCommandContext context; + context.now_s = now_secs(); + context.self_node_id = app::messagingFacade().getSelfNodeId(); + return TeamPageCommandReducer(context); +} + +template +TeamPageCommandEffects reduce_command_state(Reduce reduce) +{ + auto state = command_state_from_page(); + auto reducer = current_command_reducer(); + const auto effects = reduce(reducer, state); + apply_command_state_to_page(state); + return effects; +} + +void apply_command_cleanup_effects(const TeamPageCommandEffects& effects) +{ + if (effects.clear_keydist_pending) + { + team_page_context().deferred_dispatch.clearKeyDist(); + } + if (effects.clear_status_pending) + { + team_page_context().deferred_dispatch.clearStatusBroadcasts(); + } +} + +void apply_command_runtime_effects(const TeamPageCommandEffects& effects) +{ + const auto runtime = current_runtime_port(); + if (effects.clear_keys) + { + runtime.clearKeys(); + } + if (effects.reset_controller_ui) + { + runtime.resetControllerUi(); + } + + if (effects.stop_pairing) + { + runtime.stopPairing(); + } + + apply_command_cleanup_effects(effects); +} + +class TeamPageRandomByteAdapter final + : public ITeamPageKickConfirmRandom + , public ITeamPageCreateTeamRandom +{ + public: + uint8_t nextByte() override + { + return next_random_byte(); + } +}; + +class TeamPageKickConfirmDeferredAdapter final + : public ITeamPageKickConfirmDeferred +{ + public: + void enqueueKeyDist(uint32_t node_id, uint32_t key_id) override + { + add_keydist_pending(node_id, key_id); + } +}; + +TeamPageDeferredDispatchState deferred_dispatch_state_from_page() +{ + TeamPageDeferredDispatchState state; + state.in_team = team_page_state().in_team; + state.has_team_id = team_page_state().has_team_id; + state.self_is_leader = team_page_state().self_is_leader; + state.team_id = team_page_state().team_id; + state.security_round = team_page_state().security_round; + state.team_psk = team_page_state().team_psk; + state.has_team_psk = team_page_state().has_team_psk; + return state; +} + +void apply_deferred_dispatch_failures( + const TeamPageDeferredDispatchEffects& effects) +{ + for (const auto& failure : effects.failures) + { + const char* action = + failure.action == TeamPageDeferredDispatchAction::KeyDist + ? "KeyDist" + : "Status"; + if (failure.kind == + TeamPageDeferredDispatchFailureKind::SendFailedDetail) + { + notify_send_failed_detail(action, failure.error); + } + else + { + notify_send_failed(action, failure.needs_keys); + } + } +} 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); + team_page_context().deferred_dispatch.enqueueKeyDist( + node_id, + key_id, + now_secs()); } 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()); + team_page_context().deferred_dispatch.confirmKeyDist(node_id, key_id); } void schedule_status_broadcast(uint8_t repeats, uint32_t delay_s) { - if (repeats == 0) - { - return; - } - StatusBroadcastPending item; - item.next_send_s = now_secs() + delay_s; - item.remaining = repeats; - s_status_pending.push_back(item); + team_page_context().deferred_dispatch.scheduleStatusBroadcast( + repeats, + now_secs(), + delay_s); } void process_keydist_retries() { - if (s_keydist_pending.empty()) - { - return; - } - if (!g_team_state.has_team_psk || !g_team_state.has_team_id) - { - return; - } - team::TeamController* controller = app::teamFacade().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; - } + const auto runtime = current_runtime_port(); + TeamPageDeferredDispatchRuntimeAdapter dispatch_port(runtime); + const auto effects = team_page_context().deferred_dispatch.processKeyDistRetries( + deferred_dispatch_state_from_page(), + dispatch_port, + now_secs()); + apply_deferred_dispatch_failures(effects); } void process_status_broadcasts() { - if (s_status_pending.empty()) + const auto runtime = current_runtime_port(); + TeamPageDeferredDispatchRuntimeAdapter dispatch_port(runtime); + team::proto::TeamStatus status{}; + status.key_id = team_page_state().security_round; + fill_status_members(status); + const auto effects = team_page_context().deferred_dispatch.processStatusBroadcasts( + deferred_dispatch_state_from_page(), + status, + dispatch_port, + now_secs()); + if (effects.sent_status) { - return; - } - if (!g_team_state.in_team || !g_team_state.has_team_id || !g_team_state.self_is_leader) - { - s_status_pending.clear(); - return; - } - team::TeamController* controller = app::teamFacade().getTeamController(); - if (!controller) - { - return; - } - uint32_t now = now_secs(); - for (auto it = s_status_pending.begin(); it != s_status_pending.end();) - { - if (now < it->next_send_s) - { - ++it; - continue; - } - team::proto::TeamStatus status{}; - status.key_id = g_team_state.security_round; - fill_status_members(status); std::printf("[Team] status rebroadcast members=%u leader=%08lX\n", static_cast(status.members.size()), static_cast(status.leader_id)); - 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 (it->remaining > 0) - { - it->remaining -= 1; - } - if (it->remaining == 0) - { - it = s_status_pending.erase(it); - } - else - { - it->next_send_s = now + 2; - ++it; - } } + apply_deferred_dispatch_failures(effects); } void update_top_bar_title(const char* title) @@ -341,113 +883,49 @@ void update_top_bar_title(const char* title) { return; } - ::ui::widgets::top_bar_set_title(g_team_state.top_bar_widget, title); + ::ui::widgets::top_bar_set_title(team_page_lvgl_context().top_bar_widget, title); } void reset_team_ui_state() { - team::TeamController* controller = app::teamFacade().getTeamController(); - if (controller) - { - controller->resetUiState(); - } + current_runtime_port().resetControllerUi(); } void enter_kicked_out_state() { - team::TeamController* controller = app::teamFacade().getTeamController(); - if (controller) - { - controller->clearKeys(); - } - team::TeamPairingService* pairing = app::teamFacade().getTeamPairing(); - if (pairing) - { - pairing->stop(); - } - 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; - s_keydist_pending.clear(); - s_status_pending.clear(); - g_team_state.page = TeamPage::KickedOut; - g_team_state.nav_stack.clear(); -} - -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; - clear_focusables(); + const auto effects = reduce_command_state( + [](TeamPageCommandReducer& reducer, TeamPageCommandState& state) + { + return reducer.reduceKickedOut(state); + }); + apply_command_runtime_effects(effects); + team_page_state().page = TeamPage::KickedOut; + team_page_state().nav_stack.clear(); } void modal_prepare_group() { - if (!g_team_state.modal_group) + if (!team_page_lvgl_context().modal_group) { - g_team_state.modal_group = lv_group_create(); + team_page_lvgl_context().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); + lv_group_remove_all_objs(team_page_lvgl_context().modal_group); + team_page_lvgl_context().prev_group = lv_group_get_default(); + set_default_group(team_page_lvgl_context().modal_group); } void modal_restore_group() { - lv_group_t* restore = g_team_state.prev_group; + lv_group_t* restore = team_page_lvgl_context().prev_group; if (!restore) { - restore = g_team_state.group; + restore = team_page_lvgl_context().group; } if (restore) { set_default_group(restore); } - g_team_state.prev_group = nullptr; + team_page_lvgl_context().prev_group = nullptr; } lv_obj_t* create_modal_root(int width, int height) @@ -483,10 +961,10 @@ lv_obj_t* create_modal_root(int width, int height) void close_leave_confirm_modal() { - if (g_team_state.leave_confirm_modal) + if (team_page_lvgl_context().leave_confirm_modal) { - lv_obj_del(g_team_state.leave_confirm_modal); - g_team_state.leave_confirm_modal = nullptr; + lv_obj_del(team_page_lvgl_context().leave_confirm_modal); + team_page_lvgl_context().leave_confirm_modal = nullptr; } modal_restore_group(); } @@ -496,49 +974,10 @@ uint32_t now_secs() return sys::millis_now() / 1000U; } -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); - ::ui::i18n::set_label_text_raw(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_fitted_button(lv_obj_t* parent, const char* text, lv_event_cb_t cb) +lv_obj_t* create_modal_button(lv_obj_t* parent, const char* text, lv_event_cb_t cb) { lv_obj_t* btn = lv_btn_create(parent); - lv_obj_set_height(btn, action_button_height()); + lv_obj_set_height(btn, ::ui::page_profile::current().list_item_height); style::apply_button_secondary(btn); lv_obj_set_style_pad_hor(btn, kActionBtnPadH, LV_PART_MAIN); lv_obj_t* label = lv_label_create(btn); @@ -558,102 +997,31 @@ lv_obj_t* create_fitted_button(lv_obj_t* parent, const char* text, lv_event_cb_t return btn; } -lv_obj_t* create_action_button(const char* text, lv_event_cb_t cb) +TeamPageReadModelInput current_read_model_input() { - lv_obj_t* btn = create_fitted_button(g_team_state.actions, text, cb); - return btn; + TeamPageReadModelInput input; + input.in_team = team_page_state().in_team; + input.pending_join = team_page_state().pending_join; + input.kicked_out = team_page_state().kicked_out; + input.self_is_leader = team_page_state().self_is_leader; + input.waiting_new_keys = team_page_state().waiting_new_keys; + input.team_id = team_page_state().team_id; + input.has_team_id = team_page_state().has_team_id; + input.team_name = team_page_state().team_name; + input.security_round = team_page_state().security_round; + input.last_update_s = team_page_state().last_update_s; + input.has_team_psk = team_page_state().has_team_psk; + input.pairing_role = team_page_state().pairing_role; + input.pairing_state = team_page_state().pairing_state; + input.pairing_team_name = team_page_state().pairing_team_name; + input.members = team_page_state().members; + input.selected_member_index = team_page_state().selected_member_index; + return input; } -lv_obj_t* create_list_item(const char* left, const char* right) +TeamPageReadModel current_read_model() { - lv_obj_t* btn = lv_btn_create(g_team_state.body); - lv_obj_set_size(btn, LV_PCT(100), list_item_height()); - 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); - ::ui::i18n::set_content_label_text_raw(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); - ::ui::i18n::set_label_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_last_seen(uint32_t last_seen_s) -{ - if (last_seen_s == 0) - { - return ::ui::i18n::tr("Last seen --"); - } - uint32_t now = now_secs(); - if (now <= last_seen_s) - { - return ::ui::i18n::tr("Online"); - } - uint32_t age = now - last_seen_s; - if (age <= 120) - { - return ::ui::i18n::tr("Online"); - } - if (age < 3600) - { - return ::ui::i18n::format("Last seen %um ago", static_cast(age / 60)); - } - return ::ui::i18n::format("Last seen %uh ago", static_cast(age / 3600)); -} - -std::string format_last_update(uint32_t last_update_s) -{ - if (last_update_s == 0) - { - return ::ui::i18n::tr("Last update --"); - } - uint32_t now = now_secs(); - if (now <= last_update_s) - { - return ::ui::i18n::tr("Last update 0s ago"); - } - uint32_t age = now - last_update_s; - return ::ui::i18n::format("Last update %us ago", static_cast(age)); -} - -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] = next_random_byte(); - } - return id; + return TeamPageReadModel(now_secs()); } std::string resolve_node_name(uint32_t node_id) @@ -668,102 +1036,54 @@ std::string resolve_node_name(uint32_t node_id) return std::string(fallback); } -int find_member_index(uint32_t node_id) +TeamPageColorContext current_color_context() { - 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; + return TeamPageColorContext(app::messagingFacade().getSelfNodeId()); } -void assign_member_color(TeamMemberUi& member) +TeamPagePersistentState persistent_state_from_page() { - uint32_t node_id = member.node_id; - if (node_id == 0) - { - node_id = app::messagingFacade().getSelfNodeId(); - } - member.color_index = team_color_index_from_node_id(node_id); + TeamPagePersistentState state; + state.in_team = team_page_state().in_team; + state.pending_join = team_page_state().pending_join; + state.pending_join_started_s = team_page_state().pending_join_started_s; + state.kicked_out = team_page_state().kicked_out; + state.self_is_leader = team_page_state().self_is_leader; + state.last_event_seq = team_page_state().last_event_seq; + state.team_chat_unread = team_page_state().team_chat_unread; + state.team_id = team_page_state().team_id; + state.has_team_id = team_page_state().has_team_id; + state.team_name = team_page_state().team_name; + state.security_round = team_page_state().security_round; + state.last_update_s = team_page_state().last_update_s; + state.team_psk = team_page_state().team_psk; + state.has_team_psk = team_page_state().has_team_psk; + state.members = team_page_state().members; + return state; } -void ensure_member_colors() +void apply_persistent_state_to_page(const TeamPagePersistentState& state) { - for (auto& m : g_team_state.members) - { - uint32_t node_id = m.node_id; - if (node_id == 0) - { - node_id = app::messagingFacade().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); -} - -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_chat_unread = g_team_state.team_chat_unread; - snap.team_id = g_team_state.team_id; - snap.has_team_id = g_team_state.has_team_id; - snap.team_name = g_team_state.team_name; - snap.security_round = g_team_state.security_round; - 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; - 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_chat_unread = snap.team_chat_unread; - g_team_state.team_id = snap.team_id; - g_team_state.has_team_id = snap.has_team_id; - g_team_state.team_name = snap.team_name; - g_team_state.security_round = snap.security_round; - 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; - ensure_member_colors(); + team_page_state().in_team = state.in_team; + team_page_state().pending_join = state.pending_join; + team_page_state().pending_join_started_s = state.pending_join_started_s; + team_page_state().kicked_out = state.kicked_out; + team_page_state().self_is_leader = state.self_is_leader; + team_page_state().last_event_seq = state.last_event_seq; + team_page_state().team_chat_unread = state.team_chat_unread; + team_page_state().team_id = state.team_id; + team_page_state().has_team_id = state.has_team_id; + team_page_state().team_name = state.team_name; + team_page_state().security_round = state.security_round; + team_page_state().last_update_s = state.last_update_s; + team_page_state().team_psk = state.team_psk; + team_page_state().has_team_psk = state.has_team_psk; + team_page_state().members = state.members; } bool is_team_ui_active() { - return g_team_state.root && lv_obj_is_valid(g_team_state.root); + return team_page_lvgl_context().root && lv_obj_is_valid(team_page_lvgl_context().root); } bool is_team_chat_visible() @@ -784,571 +1104,258 @@ bool is_team_chat_visible() bool is_pairing_active() { - return g_team_state.pairing_state != TeamPairingState::Idle && - g_team_state.pairing_state != TeamPairingState::Completed && - g_team_state.pairing_state != TeamPairingState::Failed; + return TeamPageFlowController::isPairingActive( + team_page_state().pairing_state); } void sync_pairing_from_service() { - team::TeamPairingService* pairing = app::teamFacade().getTeamPairing(); - if (!pairing) + const auto runtime = current_runtime_port(); + if (!runtime.hasPairing()) { return; } - TeamPairingStatus status = pairing->getStatus(); - g_team_state.pairing_role = status.role; - g_team_state.pairing_state = status.state; - g_team_state.pairing_team_name.clear(); - if (status.has_team_name) - { - g_team_state.pairing_team_name = status.team_name; - } - if (status.has_team_id) - { - g_team_state.team_id = status.team_id; - g_team_state.has_team_id = true; - update_team_name_from_id(status.team_id); - } - if (is_pairing_active()) - { - g_team_state.pending_join = true; - if (g_team_state.pending_join_started_s == 0) + const auto update = pairing_update_from_status(runtime.pairingStatus()); + reduce_event_state( + [&](TeamPageEventReducer& reducer, TeamPageEventState& state) { - g_team_state.pending_join_started_s = now_secs(); - } - } - else - { - g_team_state.pending_join = false; - g_team_state.pending_join_started_s = 0; - } + return reducer.reducePairingStatus(state, update); + }); } void load_state_from_store() { - if (s_state_loaded) + auto state = persistent_state_from_page(); + if (team_page_context().state_store.loadOnce(team_ui_snapshot_store(), + state, + current_color_context())) { - return; + apply_persistent_state_to_page(state); } - TeamUiSnapshot snap; - if (team_ui_get_store().load(snap)) - { - apply_snapshot(snap); - } - s_state_loaded = true; } void refresh_state_from_store() { - TeamUiSnapshot snap; - if (team_ui_get_store().load(snap)) + auto state = persistent_state_from_page(); + if (team_page_context().state_store.refresh(team_ui_snapshot_store(), + state, + current_color_context())) { - apply_snapshot(snap); + apply_persistent_state_to_page(state); } } 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); + team_page_context().state_store.save(team_ui_snapshot_store(), + persistent_state_from_page()); } void fill_status_members(team::proto::TeamStatus& status) { - uint32_t self_id = app::messagingFacade().getSelfNodeId(); - status.members.clear(); - status.leader_id = 0; - for (const auto& m : g_team_state.members) - { - uint32_t id = (m.node_id == 0) ? self_id : m.node_id; - if (id == 0) - { - continue; - } - if (std::find(status.members.begin(), status.members.end(), id) == status.members.end()) - { - status.members.push_back(id); - } - if (m.leader) - { - status.leader_id = id; - } - } - if (status.leader_id == 0 && g_team_state.self_is_leader && self_id != 0) - { - status.leader_id = self_id; - } - status.has_members = !status.members.empty(); -} - -void apply_member_list_from_status(const team::proto::TeamStatus& status) -{ - if (!status.has_members) - { - return; - } - uint32_t self_id = app::messagingFacade().getSelfNodeId(); - - auto find_existing = [&](uint32_t node_id) -> const TeamMemberUi* - { - for (const auto& m : g_team_state.members) - { - if (m.node_id == node_id) - { - return &m; - } - } - return nullptr; - }; - - std::vector updated; - updated.reserve(status.members.size() + 1); - for (uint32_t id : status.members) - { - if (id == 0) - { - continue; - } - uint32_t node_id = (id == self_id) ? 0 : id; - TeamMemberUi entry; - if (const TeamMemberUi* existing = find_existing(node_id)) - { - entry = *existing; - } - entry.node_id = node_id; - entry.leader = (id == status.leader_id); - if (entry.name.empty()) - { - entry.name = (node_id == 0) ? "You" : resolve_node_name(id); - } - if (entry.last_seen_s == 0) - { - entry.last_seen_s = now_secs(); - } - assign_member_color(entry); - updated.push_back(entry); - } - - bool has_self = std::any_of(updated.begin(), updated.end(), - [](const TeamMemberUi& m) - { - return m.node_id == 0; - }); - if (!has_self && self_id != 0) - { - TeamMemberUi self; - self.node_id = 0; - self.name = "You"; - self.leader = (status.leader_id == self_id); - self.last_seen_s = now_secs(); - assign_member_color(self); - updated.push_back(self); - } - - g_team_state.members = std::move(updated); - g_team_state.self_is_leader = (status.leader_id != 0 && status.leader_id == self_id); + current_event_reducer().fillStatusMembers(event_state_from_page(), status); } 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) + const auto effects = reduce_event_state( + [&](TeamPageEventReducer& reducer, TeamPageEventState& state) { - ::ui::SystemNotification::show("Team keys mismatch", 2000); - } - g_team_state.waiting_new_keys = true; + return reducer.reduceError(state, ev); + }); + if (effects.show_key_mismatch) + { + ::ui::SystemNotification::show("Team keys mismatch", 2000); } } void handle_team_status(const team::TeamStatusEvent& ev) { - if (g_team_state.has_team_id && ev.ctx.team_id != g_team_state.team_id) + const auto effects = reduce_event_state( + [&](TeamPageEventReducer& reducer, TeamPageEventState& state) + { + return reducer.reduceStatus(state, ev); + }); + if (!effects.accepted) { 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; - } - apply_member_list_from_status(ev.msg); - 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; + apply_event_effects(effects); } void handle_team_position(const team::TeamPositionEvent& ev) { - if (g_team_state.has_team_id && ev.ctx.team_id != g_team_state.team_id) + const auto effects = reduce_event_state( + [&](TeamPageEventReducer& reducer, TeamPageEventState& state) + { + return reducer.reduceActivity(state, ev.ctx, ev.ctx.from); + }); + if (!effects.accepted) { 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()) - { - team::proto::TeamPositionMessage pos{}; - if (team::proto::decodeTeamPositionMessage(ev.payload.data(), ev.payload.size(), &pos)) + apply_keydist_effect(effects); + consume_activity( + [&](const TeamPageActivitySink& sink, + TeamPageActivityState& state) { - const int16_t alt_m = team::proto::teamPositionHasAltitude(pos) ? pos.alt_m : 0; - const uint16_t speed_dmps = team::proto::teamPositionHasSpeed(pos) ? pos.speed_dmps : 0; - uint32_t ts = pos.ts != 0 ? pos.ts : ev.ctx.timestamp; - if (ts == 0) - { - ts = now_secs(); - } - team_ui_posring_append(g_team_state.team_id, - ev.ctx.from, - pos.lat_e7, - pos.lon_e7, - alt_m, - speed_dmps, - ts); - } - } - g_team_state.last_update_s = ev.ctx.timestamp; + sink.consumePosition(state, ev); + }); } void handle_team_waypoint(const team::TeamWaypointEvent& ev) { - if (g_team_state.has_team_id && ev.ctx.team_id != g_team_state.team_id) + const auto effects = reduce_event_state( + [&](TeamPageEventReducer& reducer, TeamPageEventState& state) + { + return reducer.reduceActivity(state, ev.ctx, ev.ctx.from); + }); + if (!effects.accepted) { 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 (team::proto::teamWaypointHasLocation(ev.msg)) - { - uint32_t ts = ev.ctx.timestamp; - if (ts == 0) + apply_keydist_effect(effects); + consume_activity( + [&](const TeamPageActivitySink& sink, + TeamPageActivityState& state) { - ts = now_secs(); - } - team_ui_posring_append(g_team_state.team_id, - ev.ctx.from, - ev.msg.lat_e7, - ev.msg.lon_e7, - 0, - 0, - ts); - } - g_team_state.last_update_s = ev.ctx.timestamp; + sink.consumeWaypoint(state, ev); + }); } void handle_team_track(const team::TeamTrackEvent& ev) { - if (g_team_state.has_team_id && ev.ctx.team_id != g_team_state.team_id) + const auto effects = reduce_event_state( + [&](TeamPageEventReducer& reducer, TeamPageEventState& state) + { + return reducer.reduceActivity(state, ev.ctx, ev.ctx.from); + }); + if (!effects.accepted) { 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()) - { - team::proto::TeamTrackMessage track{}; - if (team::proto::decodeTeamTrackMessage(ev.payload.data(), ev.payload.size(), &track)) + apply_keydist_effect(effects); + consume_activity( + [&](const TeamPageActivitySink& sink, + TeamPageActivityState& state) { - if (track.version == team::proto::kTeamTrackVersion) - { - uint32_t base_ts = track.start_ts != 0 ? track.start_ts : ev.ctx.timestamp; - if (base_ts == 0) - { - base_ts = now_secs(); - } - for (size_t i = 0; i < track.points.size(); ++i) - { - if ((track.valid_mask & (1u << static_cast(i))) == 0) - { - continue; - } - const auto& pt = track.points[i]; - uint32_t ts = base_ts + static_cast(track.interval_s) * static_cast(i); - team_ui_posring_append(g_team_state.team_id, - ev.ctx.from, - pt.lat_e7, - pt.lon_e7, - 0, - 0, - ts); - } - team_ui_append_member_track(g_team_state.team_id, ev.ctx.from, track); - if (g_gps_state.selected_member_id == ev.ctx.from) - { - std::string track_path; - if (team_ui_get_member_track_path(g_team_state.team_id, ev.ctx.from, track_path)) - { - gps_tracker_load_file(track_path.c_str(), false); - } - } - } - } - } - g_team_state.last_update_s = ev.ctx.timestamp; + sink.consumeTrack(state, ev); + }); } void handle_team_chat(const team::TeamChatEvent& ev) { - if (g_team_state.has_team_id && ev.ctx.team_id != g_team_state.team_id) + uint32_t from_id = ev.msg.header.from != 0 ? ev.msg.header.from : ev.ctx.from; + const auto effects = reduce_event_state( + [&](TeamPageEventReducer& reducer, TeamPageEventState& state) + { + return reducer.reduceActivity(state, ev.ctx, from_id); + }); + if (!effects.accepted) { 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); - uint32_t prev_unread = g_team_state.team_chat_unread; - bool incoming = (from_id != 0 && from_id != app::messagingFacade().getSelfNodeId()); - if (incoming && !is_team_chat_visible()) - { - if (g_team_state.team_chat_unread < UINT32_MAX) + apply_keydist_effect(effects); + consume_activity( + [&](const TeamPageActivitySink& sink, + TeamPageActivityState& state) { - g_team_state.team_chat_unread += 1; - } - } - else if (is_team_chat_visible()) - { - g_team_state.team_chat_unread = 0; - } - if (g_team_state.team_chat_unread != prev_unread) - { - sys::EventBus::publish(new sys::ChatUnreadChangedEvent(2, - static_cast(g_team_state.team_chat_unread)), - 0); - } - 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; + sink.consumeChat(state, ev); + }); } void handle_team_kick(const team::TeamKickEvent& ev) { - uint32_t target = ev.msg.target; - int idx = find_member_index(target); - if (idx >= 0) + const auto effects = reduce_event_state( + [&](TeamPageEventReducer& reducer, TeamPageEventState& state) + { + return reducer.reduceKick(state, ev); + }); + if (!effects.accepted) { - g_team_state.members.erase(g_team_state.members.begin() + idx); + return; } + if (effects.member_kicked_key_event) { - std::vector payload; - write_u32_le(payload, target); - append_key_event(TeamKeyEventType::MemberKicked, payload); + append_key_event_to_page( + [&](const TeamPageKeyEventLog& log, + TeamPageKeyEventState& state) + { + return log.appendMemberKicked(state, + effects.member_kicked_id); + }); } - g_team_state.security_round += 1; - if (g_team_state.security_round != 0) + if (effects.epoch_rotated) { - std::vector payload; - write_u32_le(payload, g_team_state.security_round); - append_key_event(TeamKeyEventType::EpochRotated, payload); + append_key_event_to_page( + [&](const TeamPageKeyEventLog& log, + TeamPageKeyEventState& state) + { + return log.appendEpochRotated(state, effects.epoch_key_id); + }); } - - if (target == 0) + apply_event_runtime_effects(effects); + if (effects.request_kicked_out_page) { - enter_kicked_out_state(); + team_page_state().page = TeamPage::KickedOut; + } + if (effects.clear_nav_stack) + { + team_page_state().nav_stack.clear(); } } 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); + append_key_event_to_page( + [&](const TeamPageKeyEventLog& log, + TeamPageKeyEventState& state) + { + return log.appendLeaderTransferred(state, target); + }); + reduce_event_state( + [&](TeamPageEventReducer& reducer, TeamPageEventState& state) + { + return reducer.reduceTransferLeader(state, ev); + }); } 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) + const auto effects = reduce_event_state( + [&](TeamPageEventReducer& reducer, TeamPageEventState& state) + { + return reducer.reduceKeyDist(state, ev); + }); + if (!effects.accepted) { - g_team_state.security_round = ev.msg.key_id; + return; } - if (ev.msg.channel_psk_len > 0) + apply_event_effects(effects); +} + +void handle_team_key_request(const team::TeamKeyRequestEvent& ev) +{ + const auto effects = TeamPageKeyRequestAction().handleRequest( + command_state_from_page(), + ev, + current_runtime_port()); + if (effects.sent_keydist) { - 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; - team::TeamController* controller = app::teamFacade().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()); + add_keydist_pending(ev.msg.requester_id != 0 ? ev.msg.requester_id + : ev.ctx.from, + team_page_state().security_round); + ::ui::SystemNotification::show("Sent team keys", 2000); + return; } - bool was_pairing = g_team_state.pending_join || is_pairing_active() || - g_team_state.pairing_role == TeamPairingRole::Member; - if (!g_team_state.in_team || was_pairing) + for (const auto& failure : effects.failures) { - g_team_state.in_team = true; - g_team_state.kicked_out = false; - g_team_state.pending_join = false; - g_team_state.pending_join_started_s = 0; - if (g_team_state.pairing_role == TeamPairingRole::Member) + if (failure.kind == + TeamPageKeyRequestFailureKind::SendFailedDetail) { - g_team_state.self_is_leader = false; - } - if (g_team_state.members.empty()) - { - TeamMemberUi self; - self.node_id = 0; - self.name = "You"; - self.leader = g_team_state.self_is_leader; - self.last_seen_s = now_secs(); - assign_member_color(self); - g_team_state.members.push_back(self); - } - g_team_state.page = TeamPage::StatusInTeam; - g_team_state.nav_stack.clear(); - } - - if (!g_team_state.self_is_leader && ev.ctx.from != 0) - { - uint32_t leader_id = ev.ctx.from; - int idx = find_member_index(leader_id); - if (idx < 0) - { - TeamMemberUi leader; - leader.node_id = leader_id; - leader.name = resolve_node_name(leader_id); - leader.leader = true; - leader.last_seen_s = now_secs(); - assign_member_color(leader); - g_team_state.members.push_back(leader); - } - else - { - g_team_state.members[idx].leader = true; + notify_send_failed_detail("KeyDist", failure.error); } } } @@ -1359,136 +1366,26 @@ void handle_team_pairing(const team::TeamPairingEvent& ev) static_cast(ev.role), static_cast(ev.state), static_cast(ev.peer_id), - g_team_state.in_team ? 1 : 0, - g_team_state.self_is_leader ? 1 : 0, - static_cast(g_team_state.members.size())); - g_team_state.pairing_role = ev.role; - g_team_state.pairing_state = ev.state; - g_team_state.pairing_peer_id = ev.peer_id; - g_team_state.pairing_team_name.clear(); - if (ev.has_team_name) + team_page_state().in_team ? 1 : 0, + team_page_state().self_is_leader ? 1 : 0, + static_cast(team_page_state().members.size())); + const auto update = pairing_update_from_event(ev); + const auto effects = reduce_event_state( + [&](TeamPageEventReducer& reducer, TeamPageEventState& state) + { + return reducer.reducePairing(state, update); + }); + if (!effects.accepted) { - g_team_state.pairing_team_name = ev.team_name; - } - if (ev.has_team_id) - { - g_team_state.team_id = ev.team_id; - g_team_state.has_team_id = true; - update_team_name_from_id(ev.team_id); + return; } - if (ev.role == TeamPairingRole::Leader) + const auto result = apply_event_effects(effects); + if (result.appended_member_accepted) { - g_team_state.in_team = true; - g_team_state.kicked_out = false; - g_team_state.self_is_leader = true; - int self_idx = find_member_index(0); - if (self_idx < 0) - { - 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); - } - else - { - g_team_state.members[self_idx].leader = true; - } - } - - if (ev.role == TeamPairingRole::Leader && ev.peer_id != 0 && - ev.state == TeamPairingState::LeaderBeacon) - { - bool is_new_member = (find_member_index(ev.peer_id) < 0); - touch_member(ev.peer_id, now_secs()); - g_team_state.last_update_s = now_secs(); - std::vector payload; - write_u32_le(payload, ev.peer_id); - payload.push_back(kKeyRoleMember); - append_key_event(TeamKeyEventType::MemberAccepted, payload); std::printf("[TeamUI] leader accept member=%08lX members=%u\n", - static_cast(ev.peer_id), - static_cast(g_team_state.members.size())); - if (is_new_member) - { - team::TeamController* controller = app::teamFacade().getTeamController(); - if (controller && g_team_state.has_team_id) - { - team::proto::TeamStatus status{}; - status.key_id = g_team_state.security_round; - fill_status_members(status); - 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); - } - schedule_status_broadcast(1, 2); - } - } - std::string name = resolve_node_name(ev.peer_id); - std::string msg = "Paired: " + name; - ::ui::SystemNotification::show(msg.c_str(), 2000); - } - - if (ev.state == TeamPairingState::Completed) - { - g_team_state.pending_join = false; - g_team_state.pending_join_started_s = 0; - if (!g_team_state.in_team) - { - g_team_state.in_team = true; - g_team_state.kicked_out = false; - g_team_state.self_is_leader = (ev.role == TeamPairingRole::Leader); - if (g_team_state.members.empty()) - { - TeamMemberUi self; - self.node_id = 0; - self.name = "You"; - self.leader = g_team_state.self_is_leader; - self.last_seen_s = now_secs(); - assign_member_color(self); - g_team_state.members.push_back(self); - } - } - g_team_state.page = TeamPage::StatusInTeam; - g_team_state.nav_stack.clear(); - ::ui::SystemNotification::show("Paired successfully", 2000); - return; - } - if (ev.state == TeamPairingState::Failed) - { - g_team_state.pending_join = false; - g_team_state.pending_join_started_s = 0; - if (!g_team_state.in_team) - { - g_team_state.page = TeamPage::StatusNotInTeam; - } - else - { - g_team_state.page = TeamPage::StatusInTeam; - } - g_team_state.nav_stack.clear(); - ::ui::SystemNotification::show("Pairing failed", 2000); - return; - } - if (is_pairing_active()) - { - g_team_state.pending_join = true; - if (g_team_state.pending_join_started_s == 0) - { - g_team_state.pending_join_started_s = now_secs(); - } - } - else - { - g_team_state.pending_join = false; - g_team_state.pending_join_started_s = 0; + static_cast(effects.member_accepted_id), + static_cast(team_page_state().members.size())); } } @@ -1505,43 +1402,33 @@ void top_bar_back(void*) 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; + auto state = flow_state_from_page(); + current_flow_controller().navigateTo(state, page, push); + apply_flow_state_to_page(state); + handle_page_transition(team_page_state().page); render_page(); } void nav_back() { - if (g_team_state.page == TeamPage::JoinPending && g_team_state.in_team) + auto state = flow_state_from_page(); + const auto result = current_flow_controller().navigateBack(state); + if (result.request_exit) { - nav_reset(TeamPage::StatusInTeam); + ui_request_exit_to_menu(); return; } - if (!g_team_state.nav_stack.empty()) - { - TeamPage next_page = g_team_state.nav_stack.back(); - g_team_state.nav_stack.pop_back(); - if (next_page == TeamPage::StatusNotInTeam && g_team_state.in_team) - { - next_page = TeamPage::StatusInTeam; - } - handle_page_transition(next_page); - g_team_state.page = next_page; - render_page(); - return; - } - ui_request_exit_to_menu(); + apply_flow_state_to_page(state); + handle_page_transition(team_page_state().page); + render_page(); } void nav_reset(TeamPage page) { - g_team_state.nav_stack.clear(); - handle_page_transition(page); - g_team_state.page = page; + auto state = flow_state_from_page(); + current_flow_controller().resetTo(state, page); + apply_flow_state_to_page(state); + handle_page_transition(team_page_state().page); render_page(); } @@ -1550,118 +1437,47 @@ void handle_page_transition(TeamPage next_page) (void)next_page; } -bool start_pairing_leader() +bool start_pairing_command(TeamPagePairingCommandRole role) { - team::TeamPairingService* pairing = app::teamFacade().getTeamPairing(); - if (!pairing || !g_team_state.has_team_id || !g_team_state.has_team_psk) - { - ::ui::SystemNotification::show("Pairing not ready", 2000); - return false; - } - std::string team_name = current_team_name(); - bool ok = pairing->startLeader(g_team_state.team_id, - g_team_state.security_round, - g_team_state.team_psk.data(), - g_team_state.team_psk.size(), - app::messagingFacade().getSelfNodeId(), - team_name.c_str()); - if (!ok) - { - ::ui::SystemNotification::show("Pairing init failed", 2000); - return false; - } - g_team_state.pending_join = true; - g_team_state.pending_join_started_s = now_secs(); - g_team_state.pairing_role = TeamPairingRole::Leader; - g_team_state.pairing_state = TeamPairingState::LeaderBeacon; + auto state = command_state_from_page(); + const auto effects = TeamPagePairingCommandAction().startPairing( + state, + current_command_reducer(), + current_runtime_port(), + role, + app::messagingFacade().getSelfNodeId()); + apply_command_state_to_page(state); + apply_pairing_command_failures(effects); save_state_to_store(); - nav_to(TeamPage::JoinPending); - return true; -} - -bool start_pairing_member() -{ - team::TeamPairingService* pairing = app::teamFacade().getTeamPairing(); - if (!pairing) + if (effects.started_pairing) { - ::ui::SystemNotification::show("Pairing not available", 2000); - return false; + nav_to(TeamPage::JoinPending); } - bool ok = pairing->startMember(app::messagingFacade().getSelfNodeId()); - if (!ok) - { - ::ui::SystemNotification::show("Pairing init failed", 2000); - return false; - } - g_team_state.pending_join = true; - g_team_state.pending_join_started_s = now_secs(); - g_team_state.pairing_role = TeamPairingRole::Member; - g_team_state.pairing_state = TeamPairingState::MemberScanning; - save_state_to_store(); - nav_to(TeamPage::JoinPending); - return true; + return effects.started_pairing; } void handle_create(lv_event_t*) { - team::TeamController* controller = app::teamFacade().getTeamController(); - - 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) - { - 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.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] = next_random_byte(); - } - g_team_state.has_team_psk = true; - } - - 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(); + auto command_state = command_state_from_page(); + auto key_state = key_event_state_from_page(); + TeamPageRandomByteAdapter random; + const auto effects = TeamPageCreateTeamAction().createTeam( + command_state, + key_state, + current_command_reducer(), + current_runtime_port(), + current_key_event_log(), + random, + app::messagingFacade().getSelfNodeId()); + apply_command_state_to_page(command_state); + apply_key_event_state_to_page(key_state); + apply_create_team_failures(effects); save_state_to_store(); - if (!start_pairing_leader()) + if (effects.started_pairing) + { + nav_to(TeamPage::JoinPending); + } + else { nav_reset(TeamPage::StatusInTeam); } @@ -1669,7 +1485,7 @@ void handle_create(lv_event_t*) void handle_join(lv_event_t*) { - start_pairing_member(); + start_pairing_command(TeamPagePairingCommandRole::Member); } void handle_view_team(lv_event_t*) @@ -1679,53 +1495,30 @@ void handle_view_team(lv_event_t*) void handle_invite(lv_event_t*) { - if (!g_team_state.self_is_leader) - { - ::ui::SystemNotification::show("Only leader can pair", 2000); - return; - } - start_pairing_leader(); + start_pairing_command(TeamPagePairingCommandRole::Leader); } void perform_leave() { - team::TeamController* controller = app::teamFacade().getTeamController(); - if (controller) - { - controller->clearKeys(); - } - team::TeamPairingService* pairing = app::teamFacade().getTeamPairing(); - if (pairing) - { - pairing->stop(); - } - 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; - s_keydist_pending.clear(); - s_status_pending.clear(); + const auto effects = reduce_command_state( + [](TeamPageCommandReducer& reducer, TeamPageCommandState& state) + { + return reducer.reduceLeave(state); + }); + apply_command_runtime_effects(effects); save_state_to_store(); nav_reset(TeamPage::StatusNotInTeam); } void handle_leave(lv_event_t*) { - if (g_team_state.leave_confirm_modal) + if (team_page_lvgl_context().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); + team_page_lvgl_context().leave_confirm_modal = create_modal_root(260, 140); + lv_obj_t* win = lv_obj_get_child(team_page_lvgl_context().leave_confirm_modal, 0); lv_obj_t* title_label = lv_label_create(win); ::ui::i18n::set_label_text(title_label, "Leave team?"); @@ -1746,13 +1539,13 @@ void handle_leave(lv_event_t*) 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 = create_fitted_button(btn_row, "Cancel", nullptr); + lv_obj_t* cancel_btn = create_modal_button(btn_row, "Cancel", nullptr); lv_obj_add_event_cb( cancel_btn, [](lv_event_t*) { close_leave_confirm_modal(); }, LV_EVENT_CLICKED, nullptr); - lv_obj_t* leave_btn = create_fitted_button(btn_row, "Leave", nullptr); + lv_obj_t* leave_btn = create_modal_button(btn_row, "Leave", nullptr); lv_obj_add_event_cb( leave_btn, [](lv_event_t*) { @@ -1760,14 +1553,14 @@ void handle_leave(lv_event_t*) 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_add_obj(team_page_lvgl_context().modal_group, cancel_btn); + lv_group_add_obj(team_page_lvgl_context().modal_group, leave_btn); lv_group_focus_obj(cancel_btn); } void handle_manage(lv_event_t*) { - if (!g_team_state.self_is_leader) + if (!team_page_state().self_is_leader) { ::ui::SystemNotification::show("Only leader can manage", 2000); return; @@ -1779,7 +1572,7 @@ 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; + team_page_state().selected_member_index = index; nav_to(TeamPage::MemberDetail); } @@ -1790,82 +1583,20 @@ void handle_kick(lv_event_t*) 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()) - { - team::TeamController* controller = app::teamFacade().getTeamController(); - uint32_t kick_target = g_team_state.members[idx].node_id; - if (controller) - { - team::proto::TeamKick kick{}; - kick.target = kick_target; - 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] = next_random_byte(); - } - - 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); - } - } - - g_team_state.members.erase(g_team_state.members.begin() + idx); - g_team_state.selected_member_index = -1; - - if (controller) - { - team::proto::TeamStatus status{}; - status.key_id = g_team_state.security_round; - fill_status_members(status); - 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); - } - } - } + auto state = command_state_from_page(); + auto reducer = current_command_reducer(); + const auto runtime = current_runtime_port(); + TeamPageRandomByteAdapter random; + TeamPageKickConfirmDeferredAdapter deferred; + const auto effects = TeamPageKickConfirmAction().confirmKick( + state, + reducer, + runtime, + random, + deferred, + app::messagingFacade().getSelfNodeId()); + apply_command_state_to_page(state); + apply_kick_confirm_failures(effects); save_state_to_store(); nav_reset(TeamPage::StatusInTeam); } @@ -1877,62 +1608,48 @@ void handle_kick_cancel(lv_event_t*) 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()) - { - team::TeamController* controller = app::teamFacade().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); - } - } + auto command_state = command_state_from_page(); + auto key_state = key_event_state_from_page(); + const auto effects = TeamPageTransferLeaderAction().transferLeader( + command_state, + key_state, + current_command_reducer(), + current_runtime_port(), + current_key_event_log()); + apply_command_state_to_page(command_state); + apply_key_event_state_to_page(key_state); + apply_transfer_leader_failures(effects); save_state_to_store(); nav_reset(TeamPage::TeamHome); } 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) + const auto effects = + TeamPageRequestKeysAction().requestKeys( + command_state_from_page(), + current_runtime_port(), + app::messagingFacade().getSelfNodeId()); + if (effects.sent_request) { - return; + ::ui::SystemNotification::show("Requested team keys", 2000); + } + else if (effects.send_failed) + { + notify_send_failed_detail("Request Keys", effects.error); } - ::ui::SystemNotification::show("Key refresh not implemented", 2000); } void handle_join_cancel(lv_event_t*) { - g_team_state.pending_join = false; - g_team_state.pending_join_started_s = 0; - { - team::TeamController* controller = app::teamFacade().getTeamController(); - if (controller) + const auto effects = reduce_command_state( + [](TeamPageCommandReducer& reducer, TeamPageCommandState& state) { - controller->resetUiState(); - } - team::TeamPairingService* pairing = app::teamFacade().getTeamPairing(); - if (pairing) - { - pairing->stop(); - } - } + return reducer.reduceJoinCanceled(state); + }); + apply_command_runtime_effects(effects); save_state_to_store(); - if (g_team_state.in_team) + if (team_page_state().in_team) { nav_reset(TeamPage::StatusInTeam); } @@ -1944,535 +1661,137 @@ void handle_join_cancel(lv_event_t*) void handle_join_retry(lv_event_t*) { - if (g_team_state.self_is_leader) + if (team_page_state().self_is_leader) { - start_pairing_leader(); + start_pairing_command(TeamPagePairingCommandRole::Leader); } else { - start_pairing_member(); + start_pairing_command(TeamPagePairingCommandRole::Member); } } void handle_kicked_join(lv_event_t*) { - g_team_state.kicked_out = false; + reduce_command_state( + [](TeamPageCommandReducer& reducer, TeamPageCommandState& state) + { + return reducer.reduceClearKickedOut(state); + }); save_state_to_store(); nav_reset(TeamPage::StatusNotInTeam); } void handle_kicked_ok(lv_event_t*) { - g_team_state.kicked_out = false; + reduce_command_state( + [](TeamPageCommandReducer& reducer, TeamPageCommandState& state) + { + return reducer.reduceClearKickedOut(state); + }); save_state_to_store(); nav_reset(TeamPage::StatusNotInTeam); } -void render_status_not_in_team() +void render_page() { - update_top_bar_title(::ui::i18n::tr("Team")); - - add_label(g_team_state.body, ::ui::i18n::tr("You are not in a team"), true, false); - add_label(g_team_state.body, ::ui::i18n::tr("- No shared map\n- No team awareness"), false, true); - add_label(g_team_state.body, ::ui::i18n::tr("Keep devices within 5m"), false, false); - - g_team_state.action_btns[0] = create_action_button("Create Team", handle_create); - g_team_state.action_btns[1] = create_action_button("Join Team", 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(::ui::i18n::tr("Team Status")); - - std::string team_name = current_team_name(); - add_label(g_team_state.body, ::ui::i18n::format("Team: %s", team_name.c_str()).c_str(), true, false); - add_label(g_team_state.body, - ::ui::i18n::format("Role: %s", - ::ui::i18n::tr(g_team_state.self_is_leader ? "Leader" : "Member")) - .c_str(), - false, - true); - add_label(g_team_state.body, - ::ui::i18n::format("Members: %u", static_cast(g_team_state.members.size())).c_str(), - false, - true); - add_label(g_team_state.body, - ::ui::i18n::format("Online: %u", static_cast(online_count())).c_str(), - false, - true); - if (g_team_state.security_round == 0) - { - add_label(g_team_state.body, ::ui::i18n::tr("KeyId: --"), false, true); - } - else - { - add_label(g_team_state.body, - ::ui::i18n::format("KeyId: %u", static_cast(g_team_state.security_round)).c_str(), - false, - true); - } - if (g_team_state.security_round == 0) - { - add_label(g_team_state.body, ::ui::i18n::tr("Security: OK (Round --)"), false, true); - } - else - { - add_label( - g_team_state.body, - ::ui::i18n::format("Security: OK (Round %u)", static_cast(g_team_state.security_round)).c_str(), - false, - true); - } - if (g_team_state.waiting_new_keys) - { - add_label(g_team_state.body, ::ui::i18n::tr("Waiting for new keys..."), false, true); - } - - add_label(g_team_state.body, ::ui::i18n::tr("Team Health"), true, false); - std::string last_update = format_last_update(g_team_state.last_update_s); - const std::string leader_health = std::string("- ") + ::ui::i18n::tr("Leader online") + - "\n- " + last_update + - "\n- " + ::ui::i18n::tr("1 member stale"); -#if 0 - std::string health = std::string("鈼?Leader online\n") + - "鈼?" + last_update + "\n" + - "鈼?1 member stale"; -#endif - add_label(g_team_state.body, leader_health.c_str(), false, true); - - g_team_state.action_btns[0] = create_action_button("View Team", handle_view_team); - if (g_team_state.self_is_leader) - { - g_team_state.action_btns[1] = create_action_button("Pair Member", handle_invite); - } - else - { - g_team_state.action_btns[1] = create_action_button("Request Keys", handle_request_keydist); - } - g_team_state.action_btns[2] = create_action_button("Leave", handle_leave); - 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() -{ - // Replaced immediately below with a localized title, but keep this call out of the active path. - - const std::string title = ::ui::i18n::format( - "Team / %s", - ::ui::i18n::tr(g_team_state.self_is_leader ? "Leader" : "Member")); - update_top_bar_title(title.c_str()); - - std::string team_name = current_team_name(); - add_label(g_team_state.body, ::ui::i18n::format("Team: %s", team_name.c_str()).c_str(), true, false); - add_label(g_team_state.body, - ::ui::i18n::format("Members: %u Online: %u", - static_cast(g_team_state.members.size()), - static_cast(online_count())) - .c_str(), - false, - true); - if (g_team_state.security_round == 0) - { - add_label(g_team_state.body, ::ui::i18n::tr("Security Round: --"), false, true); - } - else - { - add_label(g_team_state.body, - ::ui::i18n::format("Security Round: %u", - static_cast(g_team_state.security_round)) - .c_str(), - false, - true); - } - - add_label(g_team_state.body, ::ui::i18n::tr("Members"), true, false); - if (g_team_state.members.empty()) - { - add_label(g_team_state.body, ::ui::i18n::tr("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); - ::ui::i18n::set_content_label_text_raw(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); - } - } - } - - g_team_state.action_btns[0] = create_action_button("Pair Member", handle_invite); - g_team_state.action_btns[1] = create_action_button("Manage", handle_manage); - g_team_state.action_btns[2] = create_action_button("Leave", 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_join_pending() -{ - lv_obj_set_style_translate_y(g_team_state.body, -5, 0); - lv_obj_set_style_pad_top(g_team_state.body, 2, 0); - lv_obj_set_style_pad_bottom(g_team_state.body, 2, 0); - lv_obj_set_style_pad_row(g_team_state.body, 2, 0); - - const char* title = "Pairing"; - if (g_team_state.pairing_role == TeamPairingRole::Leader) - { - title = "Pairing (Leader)"; - } - else if (g_team_state.pairing_role == TeamPairingRole::Member) - { - title = "Pairing (Member)"; - } - update_top_bar_title(::ui::i18n::tr(title)); - - add_label(g_team_state.body, ::ui::i18n::tr("Pairing in progress"), true, false); - add_label(g_team_state.body, ::ui::i18n::tr("Keep devices within 5m"), false, true); - if (g_team_state.pairing_role == TeamPairingRole::Leader) - { - add_label(g_team_state.body, ::ui::i18n::tr("Members"), true, false); - if (g_team_state.members.empty()) - { - add_label(g_team_state.body, ::ui::i18n::tr("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); - ::ui::i18n::set_content_label_text_raw(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); - } - } - } - if (g_team_state.pairing_peer_id != 0) - { - std::string name = resolve_node_name(g_team_state.pairing_peer_id); - std::string paired = ::ui::i18n::format("Last paired: %s", name.c_str()); - add_label(g_team_state.body, paired.c_str(), false, true); - } - } - if (!g_team_state.pairing_team_name.empty()) - { - std::string line = ::ui::i18n::format("Target: %s", g_team_state.pairing_team_name.c_str()); - add_label(g_team_state.body, line.c_str(), false, true); - } - - const char* state_line = "Waiting for handshake..."; - switch (g_team_state.pairing_state) - { - case TeamPairingState::LeaderBeacon: - state_line = "Waiting for member..."; - break; - case TeamPairingState::MemberScanning: - state_line = "Scanning for team..."; - break; - case TeamPairingState::JoinSent: - state_line = "Join request sent..."; - break; - case TeamPairingState::WaitingKey: - state_line = "Waiting for keys..."; - break; - case TeamPairingState::Completed: - state_line = "Paired successfully"; - break; - case TeamPairingState::Failed: - state_line = "Pairing failed"; - break; - default: - break; - } - add_label(g_team_state.body, ::ui::i18n::tr(state_line), false, true); - - g_team_state.action_btns[0] = create_action_button("Cancel", handle_join_cancel); - g_team_state.action_btns[1] = create_action_button("Retry", 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(::ui::i18n::tr("Members")); - - if (g_team_state.members.empty()) - { - add_label(g_team_state.body, ::ui::i18n::tr("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 += " (" + std::string(::ui::i18n::tr("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()) + const auto read_model_input = current_read_model_input(); + if (team_page_state().page == TeamPage::MemberDetail && + !current_read_model().buildSelectedMember(read_model_input).valid) { nav_to(TeamPage::Members, false); return; } - const auto& member = g_team_state.members[g_team_state.selected_member_index]; - std::string title = ::ui::i18n::format("Member: %s", member.name.c_str()); - update_top_bar_title(title.c_str()); + TeamPageLvglRendererContext context; + context.body = team_page_lvgl_context().body; + context.actions = team_page_lvgl_context().actions; + context.top_bar = &team_page_lvgl_context().top_bar_widget; + context.action_btns = team_page_lvgl_context().action_btns; + context.action_btn_count = 3; + context.action_labels = team_page_lvgl_context().action_labels; + context.action_label_count = 3; + context.detail_label = &team_page_lvgl_context().detail_label; + context.list_items = &team_page_lvgl_context().list_items; + context.focusables = &team_page_lvgl_context().focusables; + context.default_focus = &team_page_lvgl_context().default_focus; - std::string status = member.online ? "Online" : format_last_seen(member.last_seen_s); - char line[64]; - snprintf(line, sizeof(line), "%s", ::ui::i18n::format("Status: %s", ::ui::i18n::tr(status.c_str())).c_str()); - add_label(g_team_state.body, line, true, false); - snprintf(line, - sizeof(line), - "%s", - ::ui::i18n::format("Role: %s", ::ui::i18n::tr(member.leader ? "Leader" : "Member")).c_str()); - add_label(g_team_state.body, line, false, true); - add_label(g_team_state.body, ::ui::i18n::format("Device: %s", "Pager").c_str(), false, true); - add_label(g_team_state.body, ::ui::i18n::tr("Capability:"), true, false); - const std::string capabilities = std::string("- ") + ::ui::i18n::tr("Position") + - "\n- " + ::ui::i18n::tr("Waypoint"); - add_label(g_team_state.body, capabilities.c_str(), false, true); + TeamPageLvglRendererHandlers handlers; + handlers.create_team = handle_create; + handlers.join_team = handle_join; + handlers.view_team = handle_view_team; + handlers.invite = handle_invite; + handlers.request_keys = handle_request_keydist; + handlers.leave = handle_leave; + handlers.manage = handle_manage; + handlers.member_clicked = handle_member_clicked; + handlers.kick = handle_kick; + handlers.transfer_leader = handle_transfer_leader; + handlers.kick_cancel = handle_kick_cancel; + handlers.kick_confirm = handle_kick_confirm; + handlers.join_cancel = handle_join_cancel; + handlers.join_retry = handle_join_retry; + handlers.kicked_join = handle_kicked_join; + handlers.kicked_ok = handle_kicked_ok; - g_team_state.action_btns[0] = create_action_button("Kick", handle_kick); - g_team_state.action_btns[1] = create_action_button("Transfer Leader", 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]); -} + TeamPageLvglRendererInput input; + input.page = team_page_state().page; + input.read_model = read_model_input; + input.pairing_peer_id = team_page_state().pairing_peer_id; -void render_kick_confirm() -{ - update_top_bar_title(::ui::i18n::tr("Kick Member")); + static TeamPageMemberNameResolver names; + TeamPageLvglRenderer(now_secs()).render(context, input, handlers, names); - std::string name = ::ui::i18n::tr("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 = ::ui::i18n::format("Remove %s from team?", name.c_str()); - add_label(g_team_state.body, line.c_str(), true, false); - add_label(g_team_state.body, - ::ui::i18n::tr("This will update the security round.\nThe member will no longer receive\nteam messages or waypoints."), - false, true); - - g_team_state.action_btns[0] = create_action_button("Cancel", handle_kick_cancel); - g_team_state.action_btns[1] = create_action_button("Confirm Kick", 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(::ui::i18n::tr("Team")); - - add_label(g_team_state.body, ::ui::i18n::tr("You are no longer in this team"), true, false); - add_label(g_team_state.body, ::ui::i18n::tr("Access to team data revoked"), false, true); - - g_team_state.action_btns[0] = create_action_button("Pair Again", handle_kicked_join); - g_team_state.action_btns[1] = create_action_button("OK", 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(); - if (g_team_state.body) - { - lv_obj_set_style_translate_y(g_team_state.body, 0, 0); - lv_obj_set_style_pad_top(g_team_state.body, 6, 0); - lv_obj_set_style_pad_bottom(g_team_state.body, 6, 0); - lv_obj_set_style_pad_left(g_team_state.body, 6, 0); - lv_obj_set_style_pad_right(g_team_state.body, 6, 0); - lv_obj_set_style_pad_row(g_team_state.body, 6, 0); - } - - 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::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(); + ui_update_top_bar_battery(team_page_lvgl_context().top_bar_widget); + refresh_team_input(input_context_from_lvgl()); } void sync_from_controller() { refresh_state_from_store(); sync_pairing_from_service(); - bool pairing_active = is_pairing_active(); - if (pairing_active && g_team_state.pending_join_started_s == 0) - { - g_team_state.pending_join_started_s = now_secs(); - } - if (pairing_active && !g_team_state.in_team && g_team_state.page != TeamPage::JoinPending) - { - g_team_state.page = TeamPage::JoinPending; - g_team_state.nav_stack.clear(); - } - if (!pairing_active && g_team_state.in_team && - (g_team_state.page == TeamPage::StatusNotInTeam || - g_team_state.page == TeamPage::JoinPending)) - { - g_team_state.page = TeamPage::StatusInTeam; - g_team_state.nav_stack.clear(); - } - if (!pairing_active && !g_team_state.in_team && g_team_state.page == TeamPage::JoinPending) - { - g_team_state.page = TeamPage::StatusNotInTeam; - g_team_state.nav_stack.clear(); - } + auto state = flow_state_from_page(); + current_flow_controller().syncRuntime(state, now_secs()); + apply_flow_state_to_page(state); } } // namespace void team_page_create(lv_obj_t* parent) { - if (g_team_state.root) + if (team_page_lvgl_context().root) { - lv_obj_del(g_team_state.root); - g_team_state.root = nullptr; + lv_obj_del(team_page_lvgl_context().root); + team_page_lvgl_context().root = nullptr; } style::init_once(); load_state_from_store(); sync_pairing_from_service(); - if (g_team_state.kicked_out) - { - g_team_state.page = TeamPage::KickedOut; - } - else if (is_pairing_active() || 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; - } + auto flow_state = flow_state_from_page(); + current_flow_controller().selectInitialPage(flow_state); + apply_flow_state_to_page(flow_state); - 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.body = layout::create_body(g_team_state.content); - g_team_state.actions = layout::create_actions(g_team_state.content); + team_page_lvgl_context().root = layout::create_root(parent); + team_page_lvgl_context().header = layout::create_header(team_page_lvgl_context().root); + team_page_lvgl_context().content = layout::create_content(team_page_lvgl_context().root); + team_page_lvgl_context().body = layout::create_body(team_page_lvgl_context().content); + team_page_lvgl_context().actions = layout::create_actions(team_page_lvgl_context().content); - style::apply_root(g_team_state.root); - style::apply_header(g_team_state.header); - style::apply_content(g_team_state.content); - style::apply_body(g_team_state.body); - style::apply_actions(g_team_state.actions); + style::apply_root(team_page_lvgl_context().root); + style::apply_header(team_page_lvgl_context().header); + style::apply_content(team_page_lvgl_context().content); + style::apply_body(team_page_lvgl_context().body); + style::apply_actions(team_page_lvgl_context().actions); ::ui::widgets::TopBarConfig cfg; cfg.height = ::ui::page_profile::current().top_bar_height; - ::ui::widgets::top_bar_init(g_team_state.top_bar_widget, g_team_state.header, cfg); + ::ui::widgets::top_bar_init(team_page_lvgl_context().top_bar_widget, team_page_lvgl_context().header, cfg); update_top_bar_title(::ui::i18n::tr("Team")); - ::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); + ::ui::widgets::top_bar_set_back_callback(team_page_lvgl_context().top_bar_widget, top_bar_back, nullptr); + ui_update_top_bar_battery(team_page_lvgl_context().top_bar_widget); - init_team_input(); + init_team_input(input_context_from_lvgl()); team_page_refresh(); } @@ -2481,20 +1800,21 @@ void team_page_destroy() cleanup_team_input(); close_leave_confirm_modal(); - if (g_team_state.modal_group) + if (team_page_lvgl_context().modal_group) { - lv_group_del(g_team_state.modal_group); - g_team_state.modal_group = nullptr; + lv_group_del(team_page_lvgl_context().modal_group); + team_page_lvgl_context().modal_group = nullptr; } - s_keydist_pending.clear(); + team_page_context().deferred_dispatch.clearAll(); - if (g_team_state.root) + if (team_page_lvgl_context().root) { - lv_obj_del(g_team_state.root); - g_team_state.root = nullptr; + lv_obj_del(team_page_lvgl_context().root); + team_page_lvgl_context().root = nullptr; } - g_team_state = TeamPageState{}; - s_state_loaded = false; + reset_team_page_state(); + reset_team_page_lvgl_context(); + team_page_context().state_store.resetLoaded(); } void team_page_refresh() @@ -2527,6 +1847,10 @@ bool team_page_handle_event(sys::Event* event) handle_team_key_dist(static_cast(event)->data); changed = true; break; + case sys::EventType::TeamKeyRequest: + handle_team_key_request(static_cast(event)->data); + changed = true; + break; case sys::EventType::TeamPairing: handle_team_pairing(static_cast(event)->data); changed = true; diff --git a/modules/ui_shared/src/ui/screens/team/team_page_create_team_action.cpp b/modules/ui_shared/src/ui/screens/team/team_page_create_team_action.cpp new file mode 100644 index 00000000..256ffeba --- /dev/null +++ b/modules/ui_shared/src/ui/screens/team/team_page_create_team_action.cpp @@ -0,0 +1,136 @@ +#include "ui/screens/team/team_page_create_team_action.h" + +namespace team +{ +namespace ui +{ +namespace +{ + +TeamPageCreateTeamFailure makeFailure( + TeamPageCreateTeamFailureAction action, + TeamPageCreateTeamFailureKind kind, + bool needs_keys) +{ + TeamPageCreateTeamFailure failure; + failure.action = action; + failure.kind = kind; + failure.needs_keys = needs_keys; + return failure; +} + +TeamId generateTeamId(ITeamPageCreateTeamRandom& random) +{ + TeamId id{}; + for (size_t i = 0; i < id.size(); ++i) + { + id[i] = random.nextByte(); + } + return id; +} + +std::array generateTeamPsk( + ITeamPageCreateTeamRandom& random) +{ + std::array psk{}; + for (size_t i = 0; i < psk.size(); ++i) + { + psk[i] = random.nextByte(); + } + return psk; +} + +} // namespace + +TeamPageCreateTeamEffects TeamPageCreateTeamAction::createTeam( + TeamPageCommandState& state, + TeamPageKeyEventState& key_event_state, + const TeamPageCommandReducer& reducer, + const TeamPageRuntimePort& runtime, + const TeamPageKeyEventLog& key_log, + ITeamPageCreateTeamRandom& random, + uint32_t self_node_id) const +{ + TeamPageCreateTeamEffects effects; + + TeamPageGeneratedTeamSecrets generated; + if (!state.has_team_id) + { + generated.team_id = generateTeamId(random); + } + if (!state.has_team_psk) + { + generated.team_psk = generateTeamPsk(random); + } + + effects.command = reducer.reduceCreate(state, generated); + effects.accepted = effects.command.accepted; + if (!effects.command.accepted) + { + return effects; + } + + if (effects.command.team_created_key_event) + { + key_event_state.team_id = state.team_id; + key_event_state.has_team_id = state.has_team_id; + key_event_state.security_round = state.security_round; + effects.appended_key_event = + key_log.appendTeamCreated(key_event_state, 0); + } + + if (effects.command.keys_changed) + { + effects.applied_keys = true; + if (!runtime.setKeysFromPsk(state.team_id, + state.security_round, + state.team_psk.data(), + state.team_psk.size())) + { + effects.failures.push_back(makeFailure( + TeamPageCreateTeamFailureAction::Keys, + TeamPageCreateTeamFailureKind::SendFailed, + true)); + } + + effects.saved_keys = runtime.saveKeysNow(state.team_id, + state.security_round, + state.team_psk); + } + + if (!runtime.hasPairing() || !state.has_team_id || !state.has_team_psk) + { + effects.failures.push_back(makeFailure( + TeamPageCreateTeamFailureAction::Pairing, + TeamPageCreateTeamFailureKind::PairingNotReady, + false)); + return effects; + } + + const std::string team_name = state.team_name.empty() + ? TeamPageCommandReducer::formatTeamNameFromId(state.team_id) + : state.team_name; + if (!runtime.startLeader(state.team_id, + state.security_round, + state.team_psk.data(), + state.team_psk.size(), + self_node_id, + team_name.c_str())) + { + effects.failures.push_back(makeFailure( + TeamPageCreateTeamFailureAction::Pairing, + TeamPageCreateTeamFailureKind::PairingInitFailed, + false)); + return effects; + } + + effects.started_pairing = true; + effects.command = reducer.reducePairingStarted( + state, + TeamPairingRole::Leader, + TeamPairingState::LeaderBeacon); + return effects; +} + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/src/ui/screens/team/team_page_deferred_dispatch.cpp b/modules/ui_shared/src/ui/screens/team/team_page_deferred_dispatch.cpp new file mode 100644 index 00000000..5ad4c797 --- /dev/null +++ b/modules/ui_shared/src/ui/screens/team/team_page_deferred_dispatch.cpp @@ -0,0 +1,237 @@ +#include "ui/screens/team/team_page_deferred_dispatch.h" + +#include + +namespace team +{ +namespace ui +{ +namespace +{ + +TeamPageDeferredDispatchFailure makeFailure( + TeamPageDeferredDispatchAction action, + TeamPageDeferredDispatchFailureKind kind, + bool needs_keys, + team::TeamService::SendError error = team::TeamService::SendError::None) +{ + TeamPageDeferredDispatchFailure failure; + failure.action = action; + failure.kind = kind; + failure.needs_keys = needs_keys; + failure.error = error; + return failure; +} + +} // namespace + +TeamPageDeferredDispatchQueue::TeamPageDeferredDispatchQueue( + TeamPageDeferredDispatchConfig config) + : config_(config) +{ +} + +void TeamPageDeferredDispatchQueue::clearAll() +{ + clearKeyDist(); + clearStatusBroadcasts(); +} + +void TeamPageDeferredDispatchQueue::clearKeyDist() +{ + keydist_pending_.clear(); +} + +void TeamPageDeferredDispatchQueue::clearStatusBroadcasts() +{ + status_pending_.clear(); +} + +void TeamPageDeferredDispatchQueue::enqueueKeyDist(uint32_t node_id, + uint32_t key_id, + uint32_t now_s) +{ + for (const auto& item : 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.next_retry_s = now_s + config_.keydist_retry_interval_s; + keydist_pending_.push_back(item); +} + +void TeamPageDeferredDispatchQueue::confirmKeyDist(uint32_t node_id, + uint32_t key_id) +{ + keydist_pending_.erase( + std::remove_if(keydist_pending_.begin(), + keydist_pending_.end(), + [&](const KeyDistPending& item) + { + return item.node_id == node_id && + item.key_id == key_id; + }), + keydist_pending_.end()); +} + +void TeamPageDeferredDispatchQueue::scheduleStatusBroadcast(uint8_t repeats, + uint32_t now_s, + uint32_t delay_s) +{ + if (repeats == 0) + { + return; + } + + StatusBroadcastPending item; + item.next_send_s = now_s + delay_s; + item.remaining = repeats; + status_pending_.push_back(item); +} + +TeamPageDeferredDispatchEffects +TeamPageDeferredDispatchQueue::processKeyDistRetries( + const TeamPageDeferredDispatchState& state, + ITeamPageDeferredDispatchPort& port, + uint32_t now_s) +{ + TeamPageDeferredDispatchEffects effects; + if (keydist_pending_.empty() || !state.has_team_psk || + !state.has_team_id || !port.hasController()) + { + return effects; + } + + for (auto it = keydist_pending_.begin(); it != keydist_pending_.end();) + { + if (now_s < it->next_retry_s) + { + ++it; + continue; + } + + if (it->attempts >= config_.keydist_max_retries) + { + effects.failures.push_back(makeFailure( + TeamPageDeferredDispatchAction::KeyDist, + TeamPageDeferredDispatchFailureKind::SendFailed, + false)); + it = keydist_pending_.erase(it); + continue; + } + + team::proto::TeamKeyDist key_dist; + key_dist.team_id = state.team_id; + key_dist.key_id = it->key_id; + key_dist.channel_psk_len = + static_cast(state.team_psk.size()); + key_dist.channel_psk = state.team_psk; + + const bool ok = + port.sendKeyDistPlain(key_dist, + chat::ChannelId::PRIMARY, + it->node_id); + effects.sent_keydist = true; + if (!ok) + { + effects.failures.push_back(makeFailure( + TeamPageDeferredDispatchAction::KeyDist, + TeamPageDeferredDispatchFailureKind::SendFailedDetail, + false, + port.lastSendError())); + } + + it->attempts += 1; + it->next_retry_s = now_s + config_.keydist_retry_interval_s; + ++it; + } + + return effects; +} + +TeamPageDeferredDispatchEffects +TeamPageDeferredDispatchQueue::processStatusBroadcasts( + const TeamPageDeferredDispatchState& state, + const team::proto::TeamStatus& status, + ITeamPageDeferredDispatchPort& port, + uint32_t now_s) +{ + TeamPageDeferredDispatchEffects effects; + if (status_pending_.empty()) + { + return effects; + } + + if (!state.in_team || !state.has_team_id || !state.self_is_leader) + { + clearStatusBroadcasts(); + return effects; + } + + if (!port.hasController()) + { + return effects; + } + + for (auto it = status_pending_.begin(); it != status_pending_.end();) + { + if (now_s < it->next_send_s) + { + ++it; + continue; + } + + if (!port.sendStatus(status, chat::ChannelId::PRIMARY, 0)) + { + effects.failures.push_back(makeFailure( + TeamPageDeferredDispatchAction::Status, + TeamPageDeferredDispatchFailureKind::SendFailed, + true)); + } + effects.sent_status = true; + + if (!port.sendStatusPlain(status, chat::ChannelId::PRIMARY, 0)) + { + effects.failures.push_back(makeFailure( + TeamPageDeferredDispatchAction::Status, + TeamPageDeferredDispatchFailureKind::SendFailed, + false)); + } + effects.sent_status = true; + + if (it->remaining > 0) + { + it->remaining -= 1; + } + if (it->remaining == 0) + { + it = status_pending_.erase(it); + } + else + { + it->next_send_s = now_s + config_.status_rebroadcast_interval_s; + ++it; + } + } + + return effects; +} + +size_t TeamPageDeferredDispatchQueue::keyDistPendingCount() const +{ + return keydist_pending_.size(); +} + +size_t TeamPageDeferredDispatchQueue::statusBroadcastPendingCount() const +{ + return status_pending_.size(); +} + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/src/ui/screens/team/team_page_deferred_dispatch_adapters.cpp b/modules/ui_shared/src/ui/screens/team/team_page_deferred_dispatch_adapters.cpp new file mode 100644 index 00000000..015eaf37 --- /dev/null +++ b/modules/ui_shared/src/ui/screens/team/team_page_deferred_dispatch_adapters.cpp @@ -0,0 +1,52 @@ +#include "ui/screens/team/team_page_deferred_dispatch.h" + +#include "ui/screens/team/team_page_runtime_port.h" + +namespace team +{ +namespace ui +{ + +TeamPageDeferredDispatchRuntimeAdapter::TeamPageDeferredDispatchRuntimeAdapter( + const TeamPageRuntimePort& runtime) + : runtime_(runtime) +{ +} + +bool TeamPageDeferredDispatchRuntimeAdapter::hasController() const +{ + return runtime_.hasController(); +} + +bool TeamPageDeferredDispatchRuntimeAdapter::sendKeyDistPlain( + const team::proto::TeamKeyDist& key_dist, + chat::ChannelId channel, + chat::NodeId dest) +{ + return runtime_.sendKeyDistPlain(key_dist, channel, dest); +} + +bool TeamPageDeferredDispatchRuntimeAdapter::sendStatus( + const team::proto::TeamStatus& status, + chat::ChannelId channel, + chat::NodeId dest) +{ + return runtime_.sendStatus(status, channel, dest); +} + +bool TeamPageDeferredDispatchRuntimeAdapter::sendStatusPlain( + const team::proto::TeamStatus& status, + chat::ChannelId channel, + chat::NodeId dest) +{ + return runtime_.sendStatusPlain(status, channel, dest); +} + +team::TeamService::SendError +TeamPageDeferredDispatchRuntimeAdapter::lastSendError() const +{ + return runtime_.lastSendError(); +} + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/src/ui/screens/team/team_page_event_effect_sink.cpp b/modules/ui_shared/src/ui/screens/team/team_page_event_effect_sink.cpp new file mode 100644 index 00000000..74fff08d --- /dev/null +++ b/modules/ui_shared/src/ui/screens/team/team_page_event_effect_sink.cpp @@ -0,0 +1,118 @@ +#include "ui/screens/team/team_page_event_effect_sink.h" + +#include "chat/domain/chat_types.h" + +namespace team +{ +namespace ui +{ +namespace +{ + +constexpr uint8_t kKeyRoleMember = 1; + +} // namespace + +TeamPageEventEffectResult TeamPageEventEffectSink::applyEffects( + const TeamPageEventState& state, + TeamPageKeyEventState& key_event_state, + const TeamPageEventEffects& effects, + const TeamPageEventReducer& reducer, + const TeamPageRuntimePort& runtime, + const TeamPageKeyEventLog& key_log, + ITeamPageEventDeferred& deferred, + ITeamPageEventNotifier& notifier, + const ITeamPageMemberNameResolver& names) const +{ + TeamPageEventEffectResult result; + result.request_status_in_team_page = + effects.request_status_in_team_page; + result.request_status_not_in_team_page = + effects.request_status_not_in_team_page; + result.request_kicked_out_page = effects.request_kicked_out_page; + result.clear_nav_stack = effects.clear_nav_stack; + + if (effects.keydist_confirmed) + { + deferred.confirmKeyDist(effects.keydist_member_id, + effects.keydist_key_id); + result.confirmed_keydist = true; + } + + if (effects.epoch_rotated) + { + result.appended_epoch_rotated = + key_log.appendEpochRotated(key_event_state, + effects.epoch_key_id); + } + + if (effects.member_accepted_key_event) + { + result.appended_member_accepted = + key_log.appendMemberAccepted(key_event_state, + effects.member_accepted_id, + kKeyRoleMember); + } + + if (effects.save_keys) + { + result.saved_keys = runtime.saveKeysNow(state.team_id, + state.security_round, + state.team_psk); + } + + if (effects.apply_keys && state.has_team_psk && state.has_team_id) + { + result.applied_keys = + runtime.setKeysFromPsk(state.team_id, + state.security_round, + state.team_psk.data(), + state.team_psk.size()); + } + + if (effects.status_should_send && runtime.hasController() && + state.has_team_id) + { + team::proto::TeamStatus status{}; + status.key_id = state.security_round; + reducer.fillStatusMembers(state, status); + + result.sent_status = + runtime.sendStatus(status, chat::ChannelId::PRIMARY, 0); + if (!result.sent_status) + { + notifier.notifySendFailed("Status", true); + } + + result.sent_status_plain = + runtime.sendStatusPlain(status, chat::ChannelId::PRIMARY, 0); + if (!result.sent_status_plain) + { + notifier.notifySendFailed("Status", false); + } + + deferred.scheduleStatusBroadcast(1, 2); + result.scheduled_status_broadcast = true; + } + + if (effects.show_pairing_peer) + { + const std::string name = + names.resolveMemberName(effects.pairing_peer_id); + const std::string message = "Paired: " + name; + notifier.showMessage(message.c_str()); + } + if (effects.show_pairing_success) + { + notifier.showMessage("Paired successfully"); + } + if (effects.show_pairing_failed) + { + notifier.showMessage("Pairing failed"); + } + + return result; +} + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/src/ui/screens/team/team_page_event_reducer.cpp b/modules/ui_shared/src/ui/screens/team/team_page_event_reducer.cpp new file mode 100644 index 00000000..01f487f9 --- /dev/null +++ b/modules/ui_shared/src/ui/screens/team/team_page_event_reducer.cpp @@ -0,0 +1,637 @@ +#include "ui/screens/team/team_page_event_reducer.h" + +#include "ui/team_presence/team_presence_model.h" + +#include +#include + +namespace team +{ +namespace ui +{ + +TeamPageEventReducer::TeamPageEventReducer(TeamPageEventContext context) + : context_(context) +{ +} + +int TeamPageEventReducer::findMemberIndex(const TeamPageEventState& state, + uint32_t node_id) const +{ + const size_t index = + ::ui::team_presence::findTeamMemberIndex(state.members, node_id); + if (index == ::ui::team_presence::kInvalidTeamMemberIndex) + { + return -1; + } + return static_cast(index); +} + +void TeamPageEventReducer::touchMember(TeamPageEventState& state, + uint32_t node_id, + uint32_t last_seen_s) const +{ + const uint32_t seen_s = + ::ui::team_presence::normalizeSeenSeconds(last_seen_s, + context_.now_s); + const auto result = + ::ui::team_presence::touchTeamMember(state.members, + node_id, + seen_s); + if (!result.valid) + { + return; + } + + auto& member = state.members[result.index]; + member.name = resolveMemberName(node_id); + assignMemberColor(member); +} + +void TeamPageEventReducer::fillStatusMembers( + const TeamPageEventState& state, + team::proto::TeamStatus& status) const +{ + status.members.clear(); + status.leader_id = 0; + for (const auto& member : state.members) + { + const uint32_t id = + (member.node_id == 0) ? context_.self_node_id : member.node_id; + if (id == 0) + { + continue; + } + if (std::find(status.members.begin(), status.members.end(), id) == + status.members.end()) + { + status.members.push_back(id); + } + if (member.leader) + { + status.leader_id = id; + } + } + if (status.leader_id == 0 && state.self_is_leader && + context_.self_node_id != 0) + { + status.leader_id = context_.self_node_id; + } + status.has_members = !status.members.empty(); +} + +void TeamPageEventReducer::applyStatusRoster( + TeamPageEventState& state, + const team::proto::TeamStatus& status) const +{ + if (!status.has_members) + { + return; + } + + auto find_existing = [&](uint32_t node_id) -> const TeamMemberUi* + { + for (const auto& member : state.members) + { + if (member.node_id == node_id) + { + return &member; + } + } + return nullptr; + }; + + std::vector updated; + updated.reserve(status.members.size() + 1); + for (uint32_t id : status.members) + { + if (id == 0) + { + continue; + } + + const uint32_t node_id = (id == context_.self_node_id) ? 0 : id; + TeamMemberUi entry; + if (const TeamMemberUi* existing = find_existing(node_id)) + { + entry = *existing; + } + entry.node_id = node_id; + entry.leader = (id == status.leader_id); + if (entry.name.empty()) + { + entry.name = (node_id == 0) ? "You" : resolveMemberName(id); + } + if (node_id == 0 && entry.last_seen_s == 0) + { + entry.last_seen_s = context_.now_s; + } + assignMemberColor(entry); + updated.push_back(entry); + } + + const bool has_self = + std::any_of(updated.begin(), + updated.end(), + [](const TeamMemberUi& member) + { + return member.node_id == 0; + }); + if (!has_self && context_.self_node_id != 0) + { + TeamMemberUi self; + self.node_id = 0; + self.name = "You"; + self.leader = (status.leader_id == context_.self_node_id); + self.last_seen_s = context_.now_s; + assignMemberColor(self); + updated.push_back(self); + } + + state.members = std::move(updated); + state.self_is_leader = + (status.leader_id != 0 && status.leader_id == context_.self_node_id); +} + +TeamPageEventEffects TeamPageEventReducer::reduceError( + TeamPageEventState& state, + const team::TeamErrorEvent& event) const +{ + TeamPageEventEffects effects; + if (!state.in_team || state.self_is_leader || !acceptsTeam(state, event.ctx)) + { + return effects; + } + + effects.accepted = true; + if (event.error == team::TeamProtocolError::DecryptFail || + event.error == team::TeamProtocolError::KeyMismatch) + { + effects.show_key_mismatch = !state.waiting_new_keys; + state.waiting_new_keys = true; + effects.changed = true; + } + return effects; +} + +TeamPageEventEffects TeamPageEventReducer::reduceStatus( + TeamPageEventState& state, + const team::TeamStatusEvent& event) const +{ + TeamPageEventEffects effects; + if (!acceptsTeam(state, event.ctx)) + { + return effects; + } + + effects.accepted = true; + const uint32_t prev_round = state.security_round; + ensureTeamIdentity(state, event.ctx); + if (event.ctx.key_id != 0) + { + state.security_round = event.ctx.key_id; + } + + applyStatusRoster(state, event.msg); + if (event.ctx.from != 0) + { + touchMember(state, event.ctx.from, event.ctx.timestamp); + } + + if (event.msg.key_id != 0) + { + if (event.msg.key_id > state.security_round) + { + state.waiting_new_keys = true; + } + else if (event.msg.key_id == state.security_round) + { + state.waiting_new_keys = false; + } + if (event.ctx.from != 0) + { + effects.keydist_confirmed = true; + effects.keydist_member_id = event.ctx.from; + effects.keydist_key_id = event.msg.key_id; + } + } + + if (event.msg.key_id != 0 && event.msg.key_id > prev_round) + { + effects.epoch_rotated = true; + effects.epoch_key_id = event.msg.key_id; + } + state.last_update_s = event.ctx.timestamp; + effects.changed = true; + return effects; +} + +TeamPageEventEffects TeamPageEventReducer::reduceActivity( + TeamPageEventState& state, + const team::TeamEventContext& event, + uint32_t member_id) const +{ + TeamPageEventEffects effects; + if (!acceptsTeam(state, event)) + { + return effects; + } + + effects.accepted = true; + ensureTeamIdentity(state, event); + touchMember(state, member_id, event.timestamp); + if (event.key_id != 0 && member_id != 0) + { + effects.keydist_confirmed = true; + effects.keydist_member_id = member_id; + effects.keydist_key_id = event.key_id; + } + state.last_update_s = event.timestamp; + effects.changed = true; + return effects; +} + +TeamPageEventEffects TeamPageEventReducer::reduceKick( + TeamPageEventState& state, + const team::TeamKickEvent& event) const +{ + TeamPageEventEffects effects; + if (!acceptsTeam(state, event.ctx)) + { + return effects; + } + + effects.accepted = true; + const uint32_t target = event.msg.target; + const int index = findMemberIndex(state, target); + if (index >= 0) + { + state.members.erase(state.members.begin() + index); + } + + effects.member_kicked_key_event = true; + effects.member_kicked_id = target; + state.security_round += 1; + if (state.security_round != 0) + { + effects.epoch_rotated = true; + effects.epoch_key_id = state.security_round; + } + + if (target == 0) + { + resetTeamMembership(state, true); + effects.clear_keys = true; + effects.stop_pairing = true; + effects.clear_keydist_pending = true; + effects.clear_status_pending = true; + effects.request_kicked_out_page = true; + effects.clear_nav_stack = true; + } + + effects.changed = true; + return effects; +} + +TeamPageEventEffects TeamPageEventReducer::reduceTransferLeader( + TeamPageEventState& state, + const team::TeamTransferLeaderEvent& event) const +{ + TeamPageEventEffects effects; + const uint32_t target = event.msg.target; + for (auto& member : state.members) + { + member.leader = false; + } + + const int index = findMemberIndex(state, target); + if (index < 0) + { + TeamMemberUi info; + info.node_id = target; + info.name = resolveMemberName(target); + info.leader = true; + info.last_seen_s = context_.now_s; + assignMemberColor(info); + state.members.push_back(info); + } + else + { + state.members[static_cast(index)].leader = true; + } + state.self_is_leader = (target == 0); + effects.accepted = true; + effects.changed = true; + return effects; +} + +TeamPageEventEffects TeamPageEventReducer::reduceKeyDist( + TeamPageEventState& state, + const team::TeamKeyDistEvent& event) const +{ + TeamPageEventEffects effects; + if (!acceptsTeam(state, event.ctx)) + { + return effects; + } + + effects.accepted = true; + const bool was_pairing = + state.pending_join || isPairingActive(state.pairing_state) || + state.pairing_role == TeamPairingRole::Member; + + state.team_id = event.msg.team_id; + state.has_team_id = true; + state.team_name = formatTeamNameFromId(event.msg.team_id); + if (event.msg.key_id != 0) + { + state.security_round = event.msg.key_id; + } + if (event.msg.channel_psk_len > 0) + { + state.team_psk = event.msg.channel_psk; + state.has_team_psk = true; + effects.save_keys = true; + effects.apply_keys = true; + } + state.waiting_new_keys = false; + state.last_update_s = event.ctx.timestamp; + + if (!state.in_team || was_pairing) + { + state.in_team = true; + state.kicked_out = false; + state.pending_join = false; + state.pending_join_started_s = 0; + if (state.pairing_role == TeamPairingRole::Member) + { + state.self_is_leader = false; + } + ensureSelfMember(state, state.self_is_leader); + effects.request_status_in_team_page = true; + effects.clear_nav_stack = true; + } + + if (!state.self_is_leader && event.ctx.from != 0) + { + const uint32_t leader_id = event.ctx.from; + const int index = findMemberIndex(state, leader_id); + if (index < 0) + { + TeamMemberUi leader; + leader.node_id = leader_id; + leader.name = resolveMemberName(leader_id); + leader.leader = true; + leader.last_seen_s = context_.now_s; + assignMemberColor(leader); + state.members.push_back(leader); + } + else + { + state.members[static_cast(index)].leader = true; + } + } + + effects.changed = true; + return effects; +} + +TeamPageEventEffects TeamPageEventReducer::reducePairing( + TeamPageEventState& state, + const TeamPagePairingUpdate& update) const +{ + TeamPageEventEffects effects; + applyPairingIdentity(state, update); + + if (update.role == TeamPairingRole::Leader) + { + state.in_team = true; + state.kicked_out = false; + state.self_is_leader = true; + ensureSelfMember(state, true); + } + + if (update.role == TeamPairingRole::Leader && update.peer_id != 0 && + update.state == TeamPairingState::LeaderBeacon) + { + effects.status_should_send = (findMemberIndex(state, update.peer_id) < 0); + touchMember(state, update.peer_id, context_.now_s); + state.last_update_s = context_.now_s; + effects.member_accepted_key_event = true; + effects.member_accepted_id = update.peer_id; + effects.show_pairing_peer = true; + effects.pairing_peer_id = update.peer_id; + } + + if (update.state == TeamPairingState::Completed) + { + state.pending_join = false; + state.pending_join_started_s = 0; + if (!state.in_team) + { + state.in_team = true; + state.kicked_out = false; + state.self_is_leader = (update.role == TeamPairingRole::Leader); + ensureSelfMember(state, state.self_is_leader); + } + effects.request_status_in_team_page = true; + effects.clear_nav_stack = true; + effects.show_pairing_success = true; + } + else if (update.state == TeamPairingState::Failed) + { + state.pending_join = false; + state.pending_join_started_s = 0; + if (state.in_team) + { + effects.request_status_in_team_page = true; + } + else + { + effects.request_status_not_in_team_page = true; + } + effects.clear_nav_stack = true; + effects.show_pairing_failed = true; + } + else if (isPairingActive(update.state)) + { + state.pending_join = true; + if (state.pending_join_started_s == 0) + { + state.pending_join_started_s = context_.now_s; + } + } + else + { + state.pending_join = false; + state.pending_join_started_s = 0; + } + + effects.accepted = true; + effects.changed = true; + return effects; +} + +TeamPageEventEffects TeamPageEventReducer::reducePairingStatus( + TeamPageEventState& state, + const TeamPagePairingUpdate& update) const +{ + TeamPageEventEffects effects; + applyPairingIdentity(state, update); + if (isPairingActive(update.state)) + { + state.pending_join = true; + if (state.pending_join_started_s == 0) + { + state.pending_join_started_s = context_.now_s; + } + } + else + { + state.pending_join = false; + state.pending_join_started_s = 0; + } + effects.accepted = true; + effects.changed = true; + return effects; +} + +std::string TeamPageEventReducer::formatTeamNameFromId(const TeamId& id) +{ + char buf[16]; + std::snprintf(buf, sizeof(buf), "TEAM-%02X%02X", id[0], id[1]); + return std::string(buf); +} + +bool TeamPageEventReducer::acceptsTeam(const TeamPageEventState& state, + const TeamEventContext& event) const +{ + return !state.has_team_id || event.team_id == state.team_id; +} + +void TeamPageEventReducer::ensureTeamIdentity( + TeamPageEventState& state, + const TeamEventContext& event) const +{ + if (state.has_team_id) + { + return; + } + state.team_id = event.team_id; + state.has_team_id = true; + state.team_name = formatTeamNameFromId(event.team_id); +} + +void TeamPageEventReducer::ensureSelfMember(TeamPageEventState& state, + bool leader) const +{ + const int index = findMemberIndex(state, 0); + if (index >= 0) + { + auto& self = state.members[static_cast(index)]; + self.name = "You"; + self.leader = leader; + if (self.last_seen_s == 0) + { + self.last_seen_s = context_.now_s; + } + assignMemberColor(self); + return; + } + + TeamMemberUi self; + self.node_id = 0; + self.name = "You"; + self.leader = leader; + self.last_seen_s = context_.now_s; + assignMemberColor(self); + state.members.push_back(self); +} + +void TeamPageEventReducer::resetTeamMembership( + TeamPageEventState& state, + bool kicked_out) const +{ + state.in_team = false; + state.pending_join = false; + state.pending_join_started_s = 0; + state.kicked_out = kicked_out; + state.self_is_leader = false; + state.last_event_seq = 0; + state.pairing_role = TeamPairingRole::None; + state.pairing_state = TeamPairingState::Idle; + state.pairing_peer_id = 0; + state.pairing_team_name.clear(); + state.has_team_id = false; + state.team_id = TeamId{}; + state.team_name.clear(); + state.security_round = 0; + state.last_update_s = 0; + state.team_psk = {}; + state.has_team_psk = false; + state.waiting_new_keys = false; + state.members.clear(); +} + +void TeamPageEventReducer::applyPairingIdentity( + TeamPageEventState& state, + const TeamPagePairingUpdate& update) const +{ + state.pairing_role = update.role; + state.pairing_state = update.state; + state.pairing_peer_id = update.peer_id; + state.pairing_team_name.clear(); + if (update.has_team_name) + { + state.pairing_team_name = update.team_name; + } + if (update.has_team_id) + { + state.team_id = update.team_id; + state.has_team_id = true; + state.team_name = formatTeamNameFromId(update.team_id); + } +} + +bool TeamPageEventReducer::isPairingActive(TeamPairingState state) const +{ + return state != TeamPairingState::Idle && + state != TeamPairingState::Completed && + state != TeamPairingState::Failed; +} + +std::string TeamPageEventReducer::resolveMemberName(uint32_t node_id) const +{ + if (node_id == 0) + { + return "You"; + } + if (context_.names) + { + std::string name = context_.names->resolveMemberName(node_id); + if (!name.empty()) + { + return name; + } + } + + char fallback[16]; + std::snprintf(fallback, + sizeof(fallback), + "%08lX", + static_cast(node_id)); + return std::string(fallback); +} + +void TeamPageEventReducer::assignMemberColor(TeamMemberUi& member) const +{ + uint32_t node_id = member.node_id; + if (node_id == 0) + { + node_id = context_.self_node_id; + } + member.color_index = team_color_index_from_node_id(node_id); +} + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/src/ui/screens/team/team_page_flow_controller.cpp b/modules/ui_shared/src/ui/screens/team/team_page_flow_controller.cpp new file mode 100644 index 00000000..8bfd09f2 --- /dev/null +++ b/modules/ui_shared/src/ui/screens/team/team_page_flow_controller.cpp @@ -0,0 +1,129 @@ +#include "ui/screens/team/team_page_flow_controller.h" + +namespace team +{ +namespace ui +{ + +TeamPageFlowResult TeamPageFlowController::navigateTo( + TeamPageFlowState& state, + TeamPage page, + bool push) const +{ + TeamPageFlowResult result; + if (push && state.page != page) + { + state.nav_stack.push_back(state.page); + } + result.changed = state.page != page; + state.page = page; + return result; +} + +TeamPageFlowResult TeamPageFlowController::navigateBack( + TeamPageFlowState& state) const +{ + if (state.page == TeamPage::JoinPending && state.in_team) + { + return resetTo(state, TeamPage::StatusInTeam); + } + + if (!state.nav_stack.empty()) + { + TeamPage next_page = state.nav_stack.back(); + state.nav_stack.pop_back(); + if (next_page == TeamPage::StatusNotInTeam && state.in_team) + { + next_page = TeamPage::StatusInTeam; + } + const bool changed = state.page != next_page; + state.page = next_page; + TeamPageFlowResult result; + result.changed = changed || true; + return result; + } + + TeamPageFlowResult result; + result.request_exit = true; + return result; +} + +TeamPageFlowResult TeamPageFlowController::resetTo( + TeamPageFlowState& state, + TeamPage page) const +{ + TeamPageFlowResult result; + result.changed = state.page != page || !state.nav_stack.empty(); + state.nav_stack.clear(); + state.page = page; + return result; +} + +TeamPageFlowResult TeamPageFlowController::selectInitialPage( + TeamPageFlowState& state) const +{ + TeamPage next_page = TeamPage::StatusNotInTeam; + if (state.kicked_out) + { + next_page = TeamPage::KickedOut; + } + else if (isPairingActive(state.pairing_state) || state.pending_join) + { + next_page = TeamPage::JoinPending; + } + else if (state.in_team) + { + next_page = TeamPage::StatusInTeam; + } + + TeamPageFlowResult result; + result.changed = state.page != next_page; + state.page = next_page; + return result; +} + +TeamPageFlowResult TeamPageFlowController::syncRuntime( + TeamPageFlowState& state, + uint32_t now_s) const +{ + TeamPageFlowResult result; + const bool pairing_active = isPairingActive(state.pairing_state); + if (pairing_active && state.pending_join_started_s == 0) + { + state.pending_join_started_s = now_s; + result.changed = true; + } + if (pairing_active && !state.in_team && + state.page != TeamPage::JoinPending) + { + state.page = TeamPage::JoinPending; + state.nav_stack.clear(); + result.changed = true; + } + if (!pairing_active && state.in_team && + (state.page == TeamPage::StatusNotInTeam || + state.page == TeamPage::JoinPending)) + { + state.page = TeamPage::StatusInTeam; + state.nav_stack.clear(); + result.changed = true; + } + if (!pairing_active && !state.in_team && + state.page == TeamPage::JoinPending) + { + state.page = TeamPage::StatusNotInTeam; + state.nav_stack.clear(); + result.changed = true; + } + return result; +} + +bool TeamPageFlowController::isPairingActive(TeamPairingState state) +{ + return state != TeamPairingState::Idle && + state != TeamPairingState::Completed && + state != TeamPairingState::Failed; +} + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/src/ui/screens/team/team_page_input.cpp b/modules/ui_shared/src/ui/screens/team/team_page_input.cpp index 697db9b2..e1d75721 100644 --- a/modules/ui_shared/src/ui/screens/team/team_page_input.cpp +++ b/modules/ui_shared/src/ui/screens/team/team_page_input.cpp @@ -5,7 +5,6 @@ #include "ui/screens/team/team_page_input.h" #include "ui/app_runtime.h" -#include "ui/screens/team/team_state.h" #include "ui/ui_common.h" namespace team @@ -15,6 +14,12 @@ namespace ui namespace { lv_group_t* s_group = nullptr; +TeamPageInputContext s_context{}; + +lv_obj_t* back_button() +{ + return s_context.top_bar ? s_context.top_bar->back_btn : nullptr; +} void root_key_event_cb(lv_event_t* e) { @@ -23,9 +28,9 @@ void root_key_event_cb(lv_event_t* e) { return; } - if (g_team_state.top_bar_widget.back_btn) + if (back_button()) { - lv_obj_send_event(g_team_state.top_bar_widget.back_btn, LV_EVENT_CLICKED, nullptr); + lv_obj_send_event(back_button(), LV_EVENT_CLICKED, nullptr); } } @@ -49,8 +54,9 @@ void add_if(lv_obj_t* obj) } } // namespace -void init_team_input() +void init_team_input(const TeamPageInputContext& context) { + s_context = context; if (!::app_g) { return; @@ -58,35 +64,39 @@ void init_team_input() s_group = ::app_g; set_default_group(s_group); - refresh_team_input(); + refresh_team_input(s_context); - if (g_team_state.root) + if (s_context.root) { - lv_obj_add_event_cb(g_team_state.root, root_key_event_cb, LV_EVENT_KEY, nullptr); + lv_obj_add_event_cb(s_context.root, root_key_event_cb, LV_EVENT_KEY, nullptr); } } -void refresh_team_input() +void refresh_team_input(const TeamPageInputContext& context) { + s_context = context; if (!s_group) { return; } group_clear_all(s_group); - if (g_team_state.top_bar_widget.back_btn) + if (back_button()) { - lv_group_add_obj(s_group, g_team_state.top_bar_widget.back_btn); + lv_group_add_obj(s_group, back_button()); } - for (lv_obj_t* obj : g_team_state.focusables) + if (s_context.focusables) { - add_if(obj); + for (lv_obj_t* obj : *s_context.focusables) + { + add_if(obj); + } } - if (g_team_state.default_focus) + if (s_context.default_focus && *s_context.default_focus) { - lv_group_focus_obj(g_team_state.default_focus); + lv_group_focus_obj(*s_context.default_focus); } } @@ -98,22 +108,8 @@ void cleanup_team_input() } group_clear_all(s_group); s_group = nullptr; + s_context = TeamPageInputContext{}; } } // namespace ui } // namespace team - -void init_team_input() -{ - team::ui::init_team_input(); -} - -void refresh_team_input() -{ - team::ui::refresh_team_input(); -} - -void cleanup_team_input() -{ - team::ui::cleanup_team_input(); -} diff --git a/modules/ui_shared/src/ui/screens/team/team_page_key_event_log.cpp b/modules/ui_shared/src/ui/screens/team/team_page_key_event_log.cpp new file mode 100644 index 00000000..55822210 --- /dev/null +++ b/modules/ui_shared/src/ui/screens/team/team_page_key_event_log.cpp @@ -0,0 +1,136 @@ +#include "ui/screens/team/team_page_key_event_log.h" + +#include + +namespace team +{ +namespace ui +{ +namespace +{ + +void writeU32Le(std::vector& out, uint32_t value) +{ + out.push_back(static_cast(value & 0xFFu)); + out.push_back(static_cast((value >> 8) & 0xFFu)); + out.push_back(static_cast((value >> 16) & 0xFFu)); + out.push_back(static_cast((value >> 24) & 0xFFu)); +} + +void writeU64Le(std::vector& out, uint64_t value) +{ + for (int i = 0; i < 8; ++i) + { + out.push_back(static_cast((value >> (8 * i)) & 0xFFu)); + } +} + +} // namespace + +TeamPageKeyEventLog::TeamPageKeyEventLog(ITeamPageKeyEventWriter& writer, + uint32_t timestamp_s) + : writer_(writer), + timestamp_s_(timestamp_s) +{ +} + +bool TeamPageKeyEventLog::appendTeamCreated(TeamPageKeyEventState& state, + uint32_t leader_id) const +{ + std::vector payload; + writeU64Le(payload, teamIdToU64(state.team_id)); + writeU32Le(payload, leader_id); + writeU32Le(payload, state.security_round); + return append(state, + TeamKeyEventType::TeamCreated, + payload.data(), + payload.size()); +} + +bool TeamPageKeyEventLog::appendMemberAccepted( + TeamPageKeyEventState& state, + uint32_t member_id, + uint8_t role) const +{ + std::vector payload; + writeU32Le(payload, member_id); + payload.push_back(role); + return append(state, + TeamKeyEventType::MemberAccepted, + payload.data(), + payload.size()); +} + +bool TeamPageKeyEventLog::appendMemberKicked( + TeamPageKeyEventState& state, + uint32_t member_id) const +{ + std::vector payload; + writeU32Le(payload, member_id); + return append(state, + TeamKeyEventType::MemberKicked, + payload.data(), + payload.size()); +} + +bool TeamPageKeyEventLog::appendLeaderTransferred( + TeamPageKeyEventState& state, + uint32_t leader_id) const +{ + std::vector payload; + writeU32Le(payload, leader_id); + return append(state, + TeamKeyEventType::LeaderTransferred, + payload.data(), + payload.size()); +} + +bool TeamPageKeyEventLog::appendEpochRotated( + TeamPageKeyEventState& state, + uint32_t key_id) const +{ + std::vector payload; + writeU32Le(payload, key_id); + return append(state, + TeamKeyEventType::EpochRotated, + payload.data(), + payload.size()); +} + +uint64_t TeamPageKeyEventLog::teamIdToU64(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; +} + +bool TeamPageKeyEventLog::append(TeamPageKeyEventState& state, + TeamKeyEventType type, + const uint8_t* payload, + size_t payload_size) const +{ + if (!state.has_team_id) + { + return false; + } + + const uint32_t next_seq = state.last_event_seq + 1; + if (!writer_.appendKeyEvent(state.team_id, + type, + next_seq, + timestamp_s_, + payload, + payload_size)) + { + return false; + } + + state.last_event_seq = next_seq; + return true; +} + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/src/ui/screens/team/team_page_key_request_action.cpp b/modules/ui_shared/src/ui/screens/team/team_page_key_request_action.cpp new file mode 100644 index 00000000..afb88fc8 --- /dev/null +++ b/modules/ui_shared/src/ui/screens/team/team_page_key_request_action.cpp @@ -0,0 +1,69 @@ +#include "ui/screens/team/team_page_key_request_action.h" + +#include "chat/domain/chat_types.h" + +namespace team +{ +namespace ui +{ +namespace +{ + +TeamPageKeyRequestFailure makeFailure( + TeamPageKeyRequestFailureKind kind, + team::TeamService::SendError error = team::TeamService::SendError::None) +{ + TeamPageKeyRequestFailure failure; + failure.kind = kind; + failure.error = error; + return failure; +} + +} // namespace + +TeamPageKeyRequestEffects TeamPageKeyRequestAction::handleRequest( + const TeamPageCommandState& state, + const team::TeamKeyRequestEvent& event, + const TeamPageRuntimePort& runtime) const +{ + TeamPageKeyRequestEffects effects; + const uint32_t requester = + event.msg.requester_id != 0 ? event.msg.requester_id : event.ctx.from; + + if (!state.in_team || !state.self_is_leader || !state.has_team_id || + !state.has_team_psk || requester == 0 || + event.msg.team_id != state.team_id) + { + effects.failures.push_back( + makeFailure(TeamPageKeyRequestFailureKind::Ignored)); + return effects; + } + + effects.accepted = true; + if (!runtime.hasController()) + { + effects.failures.push_back( + makeFailure(TeamPageKeyRequestFailureKind::SendFailedDetail)); + return effects; + } + + team::proto::TeamKeyDist key_dist{}; + key_dist.team_id = state.team_id; + key_dist.key_id = state.security_round; + key_dist.channel_psk_len = + static_cast(state.team_psk.size()); + key_dist.channel_psk = state.team_psk; + + effects.sent_keydist = + runtime.sendKeyDist(key_dist, chat::ChannelId::PRIMARY, requester); + if (!effects.sent_keydist) + { + effects.failures.push_back(makeFailure( + TeamPageKeyRequestFailureKind::SendFailedDetail, + runtime.lastSendError())); + } + return effects; +} + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/src/ui/screens/team/team_page_kick_confirm_action.cpp b/modules/ui_shared/src/ui/screens/team/team_page_kick_confirm_action.cpp new file mode 100644 index 00000000..0194fdce --- /dev/null +++ b/modules/ui_shared/src/ui/screens/team/team_page_kick_confirm_action.cpp @@ -0,0 +1,197 @@ +#include "ui/screens/team/team_page_kick_confirm_action.h" + +#include + +namespace team +{ +namespace ui +{ +namespace +{ + +TeamPageKickConfirmFailure makeFailure( + TeamPageKickConfirmFailureAction action, + bool needs_keys) +{ + TeamPageKickConfirmFailure failure; + failure.action = action; + failure.kind = TeamPageKickConfirmFailureKind::SendFailed; + failure.needs_keys = needs_keys; + return failure; +} + +TeamPageKickConfirmFailure makeFailureDetail( + TeamPageKickConfirmFailureAction action, + team::TeamService::SendError error) +{ + TeamPageKickConfirmFailure failure; + failure.action = action; + failure.kind = TeamPageKickConfirmFailureKind::SendFailedDetail; + failure.error = error; + return failure; +} + +bool containsMember(const team::proto::TeamStatus& status, uint32_t id) +{ + return std::find(status.members.begin(), status.members.end(), id) != + status.members.end(); +} + +void fillStatusMembers(const TeamPageCommandState& state, + team::proto::TeamStatus& status, + uint32_t self_node_id) +{ + status.members.clear(); + status.leader_id = 0; + + for (const auto& member : state.members) + { + const uint32_t id = + (member.node_id == 0) ? self_node_id : member.node_id; + if (id == 0) + { + continue; + } + if (!containsMember(status, id)) + { + status.members.push_back(id); + } + if (member.leader) + { + status.leader_id = id; + } + } + + if (status.leader_id == 0 && state.self_is_leader && + self_node_id != 0) + { + status.leader_id = self_node_id; + } + status.has_members = !status.members.empty(); +} + +uint32_t nextKickKeyId(uint32_t security_round) +{ + uint32_t old_key_id = security_round; + if (old_key_id == 0) + { + old_key_id = 1; + } + return old_key_id + 1; +} + +} // namespace + +TeamPageKickConfirmEffects TeamPageKickConfirmAction::confirmKick( + TeamPageCommandState& state, + const TeamPageCommandReducer& reducer, + const TeamPageRuntimePort& runtime, + ITeamPageKickConfirmRandom& random, + ITeamPageKickConfirmDeferred& deferred, + uint32_t self_node_id) const +{ + TeamPageKickConfirmEffects effects; + const int idx = state.selected_member_index; + if (idx < 0 || idx >= static_cast(state.members.size())) + { + return effects; + } + + const uint32_t kick_target = + state.members[static_cast(idx)].node_id; + TeamPageKickRotation rotation; + + if (runtime.hasController()) + { + team::proto::TeamKick kick{}; + kick.target = kick_target; + effects.sent_kick = true; + if (!runtime.sendKick(kick, chat::ChannelId::PRIMARY, 0)) + { + effects.failures.push_back( + makeFailure(TeamPageKickConfirmFailureAction::Kick, true)); + } + + const uint32_t new_key_id = nextKickKeyId(state.security_round); + std::array new_psk{}; + for (size_t i = 0; i < new_psk.size(); ++i) + { + new_psk[i] = random.nextByte(); + } + + team::proto::TeamKeyDist key_dist{}; + key_dist.team_id = state.team_id; + key_dist.key_id = new_key_id; + key_dist.channel_psk_len = static_cast(new_psk.size()); + key_dist.channel_psk = new_psk; + + for (const auto& member : state.members) + { + if (member.node_id == 0 || member.node_id == kick_target) + { + continue; + } + effects.sent_keydist = true; + if (!runtime.sendKeyDist( + key_dist, + chat::ChannelId::PRIMARY, + member.node_id)) + { + effects.failures.push_back(makeFailureDetail( + TeamPageKickConfirmFailureAction::KeyDist, + runtime.lastSendError())); + } + deferred.enqueueKeyDist(member.node_id, new_key_id); + effects.enqueued_keydist = true; + } + + rotation.rotate_keys = true; + rotation.key_id = new_key_id; + rotation.team_psk = new_psk; + } + + effects.command = + reducer.reduceKickConfirmed(state, kick_target, rotation); + effects.accepted = effects.command.accepted; + if (!effects.command.accepted) + { + return effects; + } + + if (effects.command.keys_changed) + { + effects.applied_keys = true; + if (!runtime.setKeysFromPsk(state.team_id, + state.security_round, + state.team_psk.data(), + state.team_psk.size())) + { + effects.failures.push_back( + makeFailure(TeamPageKickConfirmFailureAction::Keys, true)); + } + } + + if (runtime.hasController() && effects.command.status_should_send) + { + team::proto::TeamStatus status{}; + status.key_id = state.security_round; + fillStatusMembers(state, status, self_node_id); + + effects.sent_status = true; + if (!runtime.sendStatus(status, chat::ChannelId::PRIMARY, 0)) + { + effects.failures.push_back( + makeFailure(TeamPageKickConfirmFailureAction::Status, true)); + } + if (!runtime.sendStatusPlain(status, chat::ChannelId::PRIMARY, 0)) + { + effects.failures.push_back( + makeFailure(TeamPageKickConfirmFailureAction::Status, false)); + } + } + + return effects; +} + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/src/ui/screens/team/team_page_lvgl_renderer.cpp b/modules/ui_shared/src/ui/screens/team/team_page_lvgl_renderer.cpp new file mode 100644 index 00000000..a891c706 --- /dev/null +++ b/modules/ui_shared/src/ui/screens/team/team_page_lvgl_renderer.cpp @@ -0,0 +1,696 @@ +#include "ui/screens/team/team_page_lvgl_renderer.h" + +#include "ui/localization.h" +#include "ui/page/page_profile.h" +#include "ui/screens/team/team_page_styles.h" + +#include + +namespace team +{ +namespace ui +{ +namespace +{ +constexpr int kActionBtnHeight = 28; +constexpr int kActionBtnPadH = 10; + +int actionButtonHeight() +{ + const auto& profile = ::ui::page_profile::current(); + return profile.large_touch_hitbox ? profile.list_item_height : kActionBtnHeight; +} + +int listItemHeight() +{ + return ::ui::page_profile::current().list_item_height; +} + +} // namespace + +TeamPageLvglRenderer::TeamPageLvglRenderer(uint32_t now_s) + : now_s_(now_s) +{ +} + +void TeamPageLvglRenderer::render( + TeamPageLvglRendererContext& context, + const TeamPageLvglRendererInput& input, + const TeamPageLvglRendererHandlers& handlers, + const ITeamPageLvglNameResolver& names) const +{ + clearContent(context); + if (context.body) + { + lv_obj_set_style_translate_y(context.body, 0, 0); + lv_obj_set_style_pad_top(context.body, 6, 0); + lv_obj_set_style_pad_bottom(context.body, 6, 0); + lv_obj_set_style_pad_left(context.body, 6, 0); + lv_obj_set_style_pad_right(context.body, 6, 0); + lv_obj_set_style_pad_row(context.body, 6, 0); + } + + switch (input.page) + { + case TeamPage::StatusNotInTeam: + renderStatusNotInTeam(context, handlers); + break; + case TeamPage::StatusInTeam: + renderStatusInTeam(context, input.read_model, handlers); + break; + case TeamPage::TeamHome: + renderTeamHome(context, input.read_model, handlers); + break; + case TeamPage::JoinPending: + renderJoinPending(context, input, handlers, names); + break; + case TeamPage::Members: + renderMembers(context, input.read_model, handlers); + break; + case TeamPage::MemberDetail: + renderMemberDetail(context, input.read_model, handlers); + break; + case TeamPage::KickConfirm: + renderKickConfirm(context, input.read_model, handlers); + break; + case TeamPage::KickedOut: + renderKickedOut(context, handlers); + break; + default: + renderStatusNotInTeam(context, handlers); + break; + } +} + +void TeamPageLvglRenderer::clearContent( + TeamPageLvglRendererContext& context) const +{ + if (context.body) + { + lv_obj_clean(context.body); + } + if (context.actions) + { + lv_obj_clean(context.actions); + } + if (context.list_items) + { + context.list_items->clear(); + } + for (std::size_t i = 0; context.action_btns && i < context.action_btn_count; ++i) + { + context.action_btns[i] = nullptr; + } + for (std::size_t i = 0; context.action_labels && i < context.action_label_count; ++i) + { + context.action_labels[i] = nullptr; + } + if (context.detail_label) + { + *context.detail_label = nullptr; + } + if (context.focusables) + { + context.focusables->clear(); + } + if (context.default_focus) + { + *context.default_focus = nullptr; + } +} + +void TeamPageLvglRenderer::registerFocus( + TeamPageLvglRendererContext& context, + lv_obj_t* obj, + bool is_default) const +{ + if (!obj || !context.focusables) + { + return; + } + context.focusables->push_back(obj); + if (context.default_focus && + (is_default || *context.default_focus == nullptr)) + { + *context.default_focus = obj; + } +} + +void TeamPageLvglRenderer::updateTopBarTitle( + TeamPageLvglRendererContext& context, + const char* title) const +{ + if (!context.top_bar || !title) + { + return; + } + ::ui::widgets::top_bar_set_title(*context.top_bar, title); +} + +lv_obj_t* TeamPageLvglRenderer::addLabel(lv_obj_t* parent, + const char* text, + bool section, + bool meta) const +{ + lv_obj_t* label = lv_label_create(parent); + ::ui::i18n::set_label_text_raw(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* TeamPageLvglRenderer::createFittedButton( + lv_obj_t* parent, + const char* text, + lv_event_cb_t cb) const +{ + lv_obj_t* btn = lv_btn_create(parent); + lv_obj_set_height(btn, actionButtonHeight()); + style::apply_button_secondary(btn); + lv_obj_set_style_pad_hor(btn, kActionBtnPadH, LV_PART_MAIN); + lv_obj_t* label = lv_label_create(btn); + ::ui::i18n::set_label_text(label, text); + lv_obj_update_layout(label); + lv_coord_t width = lv_obj_get_width(label) + (kActionBtnPadH * 2); + if (width < ::ui::page_profile::resolve_compact_button_min_width()) + { + width = ::ui::page_profile::resolve_compact_button_min_width(); + } + lv_obj_set_width(btn, width); + lv_obj_center(label); + if (cb) + { + lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, nullptr); + } + return btn; +} + +lv_obj_t* TeamPageLvglRenderer::createActionButton( + TeamPageLvglRendererContext& context, + const char* text, + lv_event_cb_t cb) const +{ + return createFittedButton(context.actions, text, cb); +} + +lv_obj_t* TeamPageLvglRenderer::createListItem( + TeamPageLvglRendererContext& context, + const char* left, + const char* right) const +{ + lv_obj_t* btn = lv_btn_create(context.body); + lv_obj_set_size(btn, LV_PCT(100), listItemHeight()); + 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); + ::ui::i18n::set_content_label_text_raw(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); + ::ui::i18n::set_label_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); + + if (context.list_items) + { + context.list_items->push_back(btn); + } + return btn; +} + +void TeamPageLvglRenderer::renderMemberChipRow( + TeamPageLvglRendererContext& context, + const std::vector& rows) const +{ + lv_obj_t* row = lv_obj_create(context.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& member : rows) + { + lv_obj_t* label = lv_label_create(row); + ::ui::i18n::set_content_label_text_raw(label, member.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(member.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 (member.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); + } + } +} + +void TeamPageLvglRenderer::renderMemberChipsOrEmpty( + TeamPageLvglRendererContext& context, + const TeamPageReadModelInput& input) const +{ + const auto rows = TeamPageReadModel(now_s_).buildMemberRows(input.members); + if (rows.empty()) + { + addLabel(context.body, ::ui::i18n::tr("No members yet"), false, true); + return; + } + renderMemberChipRow(context, rows); +} + +void TeamPageLvglRenderer::renderStatusNotInTeam( + TeamPageLvglRendererContext& context, + const TeamPageLvglRendererHandlers& handlers) const +{ + updateTopBarTitle(context, ::ui::i18n::tr("Team")); + + addLabel(context.body, ::ui::i18n::tr("You are not in a team"), true, false); + addLabel(context.body, ::ui::i18n::tr("- No shared map\n- No team awareness"), false, true); + addLabel(context.body, ::ui::i18n::tr("Keep devices within 5m"), false, false); + + if (context.action_btns && context.action_btn_count > 0) + { + context.action_btns[0] = + createActionButton(context, "Create Team", handlers.create_team); + registerFocus(context, context.action_btns[0], true); + } + if (context.action_btns && context.action_btn_count > 1) + { + context.action_btns[1] = + createActionButton(context, "Join Team", handlers.join_team); + registerFocus(context, context.action_btns[1]); + } +} + +void TeamPageLvglRenderer::renderStatusInTeam( + TeamPageLvglRendererContext& context, + const TeamPageReadModelInput& input, + const TeamPageLvglRendererHandlers& handlers) const +{ + updateTopBarTitle(context, ::ui::i18n::tr("Team Status")); + + const auto summary = TeamPageReadModel(now_s_).buildSummary(input); + addLabel(context.body, + ::ui::i18n::format("Team: %s", summary.team_name.c_str()).c_str(), + true, + false); + addLabel(context.body, + ::ui::i18n::format("Role: %s", + ::ui::i18n::tr(summary.self_is_leader ? "Leader" : "Member")) + .c_str(), + false, + true); + addLabel(context.body, + ::ui::i18n::format("Members: %u", + static_cast(summary.member_count)) + .c_str(), + false, + true); + addLabel(context.body, + ::ui::i18n::format("Online: %u", + static_cast(summary.online_count)) + .c_str(), + false, + true); + if (!summary.has_security_round) + { + addLabel(context.body, ::ui::i18n::tr("KeyId: --"), false, true); + addLabel(context.body, ::ui::i18n::tr("Security: OK (Round --)"), false, true); + } + else + { + addLabel(context.body, + ::ui::i18n::format("KeyId: %u", + static_cast(summary.security_round)) + .c_str(), + false, + true); + addLabel(context.body, + ::ui::i18n::format("Security: OK (Round %u)", + static_cast(summary.security_round)) + .c_str(), + false, + true); + } + if (summary.waiting_new_keys) + { + addLabel(context.body, ::ui::i18n::tr("Waiting for new keys..."), false, true); + } + + addLabel(context.body, ::ui::i18n::tr("Team Health"), true, false); + std::string last_update = formatLastUpdate(summary.last_update); + const std::string leader_health = + std::string("- ") + ::ui::i18n::tr("Leader online") + + "\n- " + last_update + + "\n- " + ::ui::i18n::tr("1 member stale"); + addLabel(context.body, leader_health.c_str(), false, true); + + if (context.action_btns && context.action_btn_count > 0) + { + context.action_btns[0] = + createActionButton(context, "View Team", handlers.view_team); + registerFocus(context, context.action_btns[0], true); + } + if (context.action_btns && context.action_btn_count > 1) + { + context.action_btns[1] = + input.self_is_leader + ? createActionButton(context, "Pair Member", handlers.invite) + : createActionButton(context, "Request Keys", handlers.request_keys); + registerFocus(context, context.action_btns[1]); + } + if (context.action_btns && context.action_btn_count > 2) + { + context.action_btns[2] = + createActionButton(context, "Leave", handlers.leave); + registerFocus(context, context.action_btns[2]); + } +} + +void TeamPageLvglRenderer::renderTeamHome( + TeamPageLvglRendererContext& context, + const TeamPageReadModelInput& input, + const TeamPageLvglRendererHandlers& handlers) const +{ + const auto summary = TeamPageReadModel(now_s_).buildSummary(input); + const std::string title = ::ui::i18n::format( + "Team / %s", + ::ui::i18n::tr(summary.self_is_leader ? "Leader" : "Member")); + updateTopBarTitle(context, title.c_str()); + + addLabel(context.body, + ::ui::i18n::format("Team: %s", summary.team_name.c_str()).c_str(), + true, + false); + addLabel(context.body, + ::ui::i18n::format("Members: %u Online: %u", + static_cast(summary.member_count), + static_cast(summary.online_count)) + .c_str(), + false, + true); + if (!summary.has_security_round) + { + addLabel(context.body, ::ui::i18n::tr("Security Round: --"), false, true); + } + else + { + addLabel(context.body, + ::ui::i18n::format("Security Round: %u", + static_cast(summary.security_round)) + .c_str(), + false, + true); + } + + addLabel(context.body, ::ui::i18n::tr("Members"), true, false); + renderMemberChipsOrEmpty(context, input); + + if (context.action_btns && context.action_btn_count > 0) + { + context.action_btns[0] = + createActionButton(context, "Pair Member", handlers.invite); + registerFocus(context, context.action_btns[0], + context.default_focus && *context.default_focus == nullptr); + } + if (context.action_btns && context.action_btn_count > 1) + { + context.action_btns[1] = + createActionButton(context, "Manage", handlers.manage); + registerFocus(context, context.action_btns[1]); + } + if (context.action_btns && context.action_btn_count > 2) + { + context.action_btns[2] = + createActionButton(context, "Leave", handlers.leave); + registerFocus(context, context.action_btns[2]); + } +} + +void TeamPageLvglRenderer::renderJoinPending( + TeamPageLvglRendererContext& context, + const TeamPageLvglRendererInput& input, + const TeamPageLvglRendererHandlers& handlers, + const ITeamPageLvglNameResolver& names) const +{ + lv_obj_set_style_translate_y(context.body, -5, 0); + lv_obj_set_style_pad_top(context.body, 2, 0); + lv_obj_set_style_pad_bottom(context.body, 2, 0); + lv_obj_set_style_pad_row(context.body, 2, 0); + + const auto pending = + TeamPageReadModel(now_s_).buildJoinPending(input.read_model); + updateTopBarTitle(context, ::ui::i18n::tr(pending.title)); + + addLabel(context.body, ::ui::i18n::tr("Pairing in progress"), true, false); + addLabel(context.body, ::ui::i18n::tr("Keep devices within 5m"), false, true); + if (pending.show_leader_members) + { + addLabel(context.body, ::ui::i18n::tr("Members"), true, false); + renderMemberChipsOrEmpty(context, input.read_model); + if (input.pairing_peer_id != 0) + { + const std::string name = names.resolveNodeName(input.pairing_peer_id); + const std::string paired = + ::ui::i18n::format("Last paired: %s", name.c_str()); + addLabel(context.body, paired.c_str(), false, true); + } + } + if (!pending.target_team_name.empty()) + { + const std::string line = + ::ui::i18n::format("Target: %s", pending.target_team_name.c_str()); + addLabel(context.body, line.c_str(), false, true); + } + + addLabel(context.body, ::ui::i18n::tr(pending.state_line), false, true); + + if (context.action_btns && context.action_btn_count > 0) + { + context.action_btns[0] = + createActionButton(context, "Cancel", handlers.join_cancel); + registerFocus(context, context.action_btns[0], true); + } + if (context.action_btns && context.action_btn_count > 1) + { + context.action_btns[1] = + createActionButton(context, "Retry", handlers.join_retry); + registerFocus(context, context.action_btns[1]); + } +} + +void TeamPageLvglRenderer::renderMembers( + TeamPageLvglRendererContext& context, + const TeamPageReadModelInput& input, + const TeamPageLvglRendererHandlers& handlers) const +{ + updateTopBarTitle(context, ::ui::i18n::tr("Members")); + const auto rows = TeamPageReadModel(now_s_).buildMemberRows(input.members); + + if (rows.empty()) + { + addLabel(context.body, ::ui::i18n::tr("No members yet"), false, true); + } + for (const auto& member : rows) + { + const char* dot = member.online ? "\xE2\x97\x8F " : "\xE2\x97\x8B "; + std::string left = std::string(dot) + member.name; + if (member.leader) + { + left += " (" + std::string(::ui::i18n::tr("Leader")) + ")"; + } + lv_obj_t* item = createListItem(context, left.c_str(), "Select"); + lv_obj_set_user_data(item, (void*)(intptr_t)member.source_index); + lv_obj_add_event_cb(item, handlers.member_clicked, LV_EVENT_CLICKED, nullptr); + registerFocus(context, + item, + context.default_focus && *context.default_focus == nullptr); + } +} + +void TeamPageLvglRenderer::renderMemberDetail( + TeamPageLvglRendererContext& context, + const TeamPageReadModelInput& input, + const TeamPageLvglRendererHandlers& handlers) const +{ + const auto detail = TeamPageReadModel(now_s_).buildSelectedMember(input); + if (!detail.valid) + { + return; + } + + const std::string title = + ::ui::i18n::format("Member: %s", detail.member.name.c_str()); + updateTopBarTitle(context, title.c_str()); + + const std::string status = + detail.member.online ? std::string(::ui::i18n::tr("Online")) + : formatLastSeen(detail.last_seen); + char line[64]; + std::snprintf(line, + sizeof(line), + "%s", + ::ui::i18n::format("Status: %s", + ::ui::i18n::tr(status.c_str())) + .c_str()); + addLabel(context.body, line, true, false); + std::snprintf(line, + sizeof(line), + "%s", + ::ui::i18n::format( + "Role: %s", + ::ui::i18n::tr(detail.member.leader ? "Leader" : "Member")) + .c_str()); + addLabel(context.body, line, false, true); + addLabel(context.body, + ::ui::i18n::format("Device: %s", "Pager").c_str(), + false, + true); + addLabel(context.body, ::ui::i18n::tr("Capability:"), true, false); + const std::string capabilities = + std::string("- ") + ::ui::i18n::tr("Position") + + "\n- " + ::ui::i18n::tr("Waypoint"); + addLabel(context.body, capabilities.c_str(), false, true); + + if (context.action_btns && context.action_btn_count > 0) + { + context.action_btns[0] = + createActionButton(context, "Kick", handlers.kick); + if (!detail.management_actions_enabled) + { + lv_obj_add_state(context.action_btns[0], LV_STATE_DISABLED); + } + registerFocus(context, context.action_btns[0], true); + } + if (context.action_btns && context.action_btn_count > 1) + { + context.action_btns[1] = + createActionButton(context, "Transfer Leader", handlers.transfer_leader); + if (!detail.management_actions_enabled) + { + lv_obj_add_state(context.action_btns[1], LV_STATE_DISABLED); + } + registerFocus(context, context.action_btns[1]); + } +} + +void TeamPageLvglRenderer::renderKickConfirm( + TeamPageLvglRendererContext& context, + const TeamPageReadModelInput& input, + const TeamPageLvglRendererHandlers& handlers) const +{ + updateTopBarTitle(context, ::ui::i18n::tr("Kick Member")); + + std::string name = ::ui::i18n::tr("member"); + if (input.selected_member_index >= 0 && + input.selected_member_index < static_cast(input.members.size())) + { + name = input.members[static_cast(input.selected_member_index)].name; + } + + const std::string line = + ::ui::i18n::format("Remove %s from team?", name.c_str()); + addLabel(context.body, line.c_str(), true, false); + addLabel(context.body, + ::ui::i18n::tr("This will update the security round.\nThe member will no longer receive\nteam messages or waypoints."), + false, + true); + + if (context.action_btns && context.action_btn_count > 0) + { + context.action_btns[0] = + createActionButton(context, "Cancel", handlers.kick_cancel); + registerFocus(context, context.action_btns[0], true); + } + if (context.action_btns && context.action_btn_count > 1) + { + context.action_btns[1] = + createActionButton(context, "Confirm Kick", handlers.kick_confirm); + registerFocus(context, context.action_btns[1]); + } +} + +void TeamPageLvglRenderer::renderKickedOut( + TeamPageLvglRendererContext& context, + const TeamPageLvglRendererHandlers& handlers) const +{ + updateTopBarTitle(context, ::ui::i18n::tr("Team")); + + addLabel(context.body, ::ui::i18n::tr("You are no longer in this team"), true, false); + addLabel(context.body, ::ui::i18n::tr("Access to team data revoked"), false, true); + + if (context.action_btns && context.action_btn_count > 0) + { + context.action_btns[0] = + createActionButton(context, "Pair Again", handlers.kicked_join); + registerFocus(context, context.action_btns[0], true); + } + if (context.action_btns && context.action_btn_count > 1) + { + context.action_btns[1] = + createActionButton(context, "OK", handlers.kicked_ok); + registerFocus(context, context.action_btns[1]); + } +} + +std::string TeamPageLvglRenderer::formatLastSeen( + TeamRelativeTimeView view) const +{ + switch (view.kind) + { + case TeamRelativeTimeKind::Online: + return ::ui::i18n::tr("Online"); + case TeamRelativeTimeKind::MinutesAgo: + return ::ui::i18n::format("Last seen %um ago", + static_cast(view.value)); + case TeamRelativeTimeKind::HoursAgo: + return ::ui::i18n::format("Last seen %uh ago", + static_cast(view.value)); + case TeamRelativeTimeKind::Unknown: + default: + break; + } + return ::ui::i18n::tr("Last seen --"); +} + +std::string TeamPageLvglRenderer::formatLastUpdate( + TeamRelativeTimeView view) const +{ + if (view.kind != TeamRelativeTimeKind::SecondsAgo) + { + return ::ui::i18n::tr("Last update --"); + } + return ::ui::i18n::format("Last update %us ago", + static_cast(view.value)); +} + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/src/ui/screens/team/team_page_pairing_command_action.cpp b/modules/ui_shared/src/ui/screens/team/team_page_pairing_command_action.cpp new file mode 100644 index 00000000..190fc951 --- /dev/null +++ b/modules/ui_shared/src/ui/screens/team/team_page_pairing_command_action.cpp @@ -0,0 +1,105 @@ +#include "ui/screens/team/team_page_pairing_command_action.h" + +namespace team +{ +namespace ui +{ +namespace +{ + +TeamPagePairingCommandFailure makeFailure( + TeamPagePairingCommandRole role, + TeamPagePairingCommandFailureKind kind) +{ + TeamPagePairingCommandFailure failure; + failure.role = role; + failure.kind = kind; + return failure; +} + +std::string teamNameForLeader(const TeamPageCommandState& state) +{ + if (!state.team_name.empty()) + { + return state.team_name; + } + return TeamPageCommandReducer::formatTeamNameFromId(state.team_id); +} + +} // namespace + +TeamPagePairingCommandEffects TeamPagePairingCommandAction::startPairing( + TeamPageCommandState& state, + const TeamPageCommandReducer& reducer, + const TeamPageRuntimePort& runtime, + TeamPagePairingCommandRole role, + uint32_t self_node_id) const +{ + TeamPagePairingCommandEffects effects; + if (role == TeamPagePairingCommandRole::Leader) + { + if (!state.self_is_leader) + { + effects.failures.push_back(makeFailure( + role, + TeamPagePairingCommandFailureKind::LeaderRequired)); + return effects; + } + if (!runtime.hasPairing() || !state.has_team_id || + !state.has_team_psk) + { + effects.failures.push_back(makeFailure( + role, + TeamPagePairingCommandFailureKind::PairingNotReady)); + return effects; + } + + const std::string team_name = teamNameForLeader(state); + if (!runtime.startLeader(state.team_id, + state.security_round, + state.team_psk.data(), + state.team_psk.size(), + self_node_id, + team_name.c_str())) + { + effects.failures.push_back(makeFailure( + role, + TeamPagePairingCommandFailureKind::PairingInitFailed)); + return effects; + } + + effects.started_pairing = true; + effects.command = reducer.reducePairingStarted( + state, + TeamPairingRole::Leader, + TeamPairingState::LeaderBeacon); + effects.accepted = effects.command.accepted; + return effects; + } + + if (!runtime.hasPairing()) + { + effects.failures.push_back(makeFailure( + role, + TeamPagePairingCommandFailureKind::PairingNotAvailable)); + return effects; + } + if (!runtime.startMember(self_node_id)) + { + effects.failures.push_back(makeFailure( + role, + TeamPagePairingCommandFailureKind::PairingInitFailed)); + return effects; + } + + effects.started_pairing = true; + effects.command = reducer.reducePairingStarted( + state, + TeamPairingRole::Member, + TeamPairingState::MemberScanning); + effects.accepted = effects.command.accepted; + return effects; +} + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/src/ui/screens/team/team_page_read_model.cpp b/modules/ui_shared/src/ui/screens/team/team_page_read_model.cpp new file mode 100644 index 00000000..36b01f1a --- /dev/null +++ b/modules/ui_shared/src/ui/screens/team/team_page_read_model.cpp @@ -0,0 +1,215 @@ +#include "ui/screens/team/team_page_read_model.h" + +#include "platform/ui/team_ui_snapshot_store.h" +#include "ui/team_presence/team_presence_model.h" + +#include + +namespace team +{ +namespace ui +{ + +TeamPageReadModel::TeamPageReadModel(uint32_t now_s) + : now_s_(now_s) +{ +} + +TeamPageReadModelInput TeamPageReadModel::inputFromSnapshot( + const TeamUiSnapshot& snapshot) +{ + TeamPageReadModelInput input; + input.in_team = snapshot.in_team; + input.pending_join = snapshot.pending_join; + input.kicked_out = snapshot.kicked_out; + input.self_is_leader = snapshot.self_is_leader; + input.team_id = snapshot.team_id; + input.has_team_id = snapshot.has_team_id; + input.team_name = snapshot.team_name; + input.security_round = snapshot.security_round; + input.last_update_s = snapshot.last_update_s; + input.has_team_psk = snapshot.has_team_psk; + input.members = snapshot.members; + return input; +} + +std::string TeamPageReadModel::formatTeamNameFromId(const TeamId& id) +{ + char buf[16]; + std::snprintf(buf, sizeof(buf), "TEAM-%02X%02X", id[0], id[1]); + return std::string(buf); +} + +TeamPageSummaryView TeamPageReadModel::buildSummary( + const TeamPageReadModelInput& input) const +{ + TeamPageSummaryView summary; + summary.in_team = input.in_team; + summary.pending_join = input.pending_join; + summary.kicked_out = input.kicked_out; + summary.self_is_leader = input.self_is_leader; + summary.waiting_new_keys = input.waiting_new_keys; + summary.member_count = input.members.size(); + summary.security_round = input.security_round; + summary.has_security_round = input.security_round != 0; + summary.last_update = buildLastUpdate(input.last_update_s); + + if (!input.team_name.empty()) + { + summary.team_name = input.team_name; + } + else if (input.has_team_id) + { + summary.team_name = formatTeamNameFromId(input.team_id); + } + else + { + summary.team_name = "Unknown"; + } + + for (const auto& member : input.members) + { + if (::ui::team_presence::isTeamMemberOnline(now_s_, + member.last_seen_s)) + { + ++summary.online_count; + } + } + + return summary; +} + +TeamJoinPendingView TeamPageReadModel::buildJoinPending( + const TeamPageReadModelInput& input) const +{ + TeamJoinPendingView view; + view.target_team_name = input.pairing_team_name; + + if (input.pairing_role == TeamPairingRole::Leader) + { + view.title = "Pairing (Leader)"; + view.show_leader_members = true; + } + else if (input.pairing_role == TeamPairingRole::Member) + { + view.title = "Pairing (Member)"; + } + + switch (input.pairing_state) + { + case TeamPairingState::LeaderBeacon: + view.state_line = "Waiting for member..."; + break; + case TeamPairingState::MemberScanning: + view.state_line = "Scanning for team..."; + break; + case TeamPairingState::JoinSent: + view.state_line = "Join request sent..."; + break; + case TeamPairingState::WaitingKey: + view.state_line = "Waiting for keys..."; + break; + case TeamPairingState::Completed: + view.state_line = "Paired successfully"; + break; + case TeamPairingState::Failed: + view.state_line = "Pairing failed"; + break; + default: + break; + } + return view; +} + +std::vector TeamPageReadModel::buildMemberRows( + const std::vector& members) const +{ + std::vector rows; + rows.reserve(members.size()); + for (std::size_t i = 0; i < members.size(); ++i) + { + rows.push_back(buildMemberRow(members[i], i)); + } + return rows; +} + +TeamMemberDetailView TeamPageReadModel::buildSelectedMember( + const TeamPageReadModelInput& input) const +{ + TeamMemberDetailView detail; + if (input.selected_member_index < 0 || + static_cast(input.selected_member_index) >= input.members.size()) + { + return detail; + } + + const auto index = static_cast(input.selected_member_index); + detail.valid = true; + detail.member = buildMemberRow(input.members[index], index); + detail.last_seen = buildLastSeen(input.members[index].last_seen_s); + detail.management_actions_enabled = + input.has_team_psk && + input.has_team_id && + input.security_round > 0 && + !input.waiting_new_keys; + return detail; +} + +TeamRelativeTimeView TeamPageReadModel::buildLastSeen(uint32_t last_seen_s) const +{ + TeamRelativeTimeView view; + if (last_seen_s == 0) + { + return view; + } + if (::ui::team_presence::isTeamMemberOnline(now_s_, last_seen_s)) + { + view.kind = TeamRelativeTimeKind::Online; + return view; + } + + const uint32_t age = now_s_ - last_seen_s; + if (age < 3600) + { + view.kind = TeamRelativeTimeKind::MinutesAgo; + view.value = age / 60; + return view; + } + + view.kind = TeamRelativeTimeKind::HoursAgo; + view.value = age / 3600; + return view; +} + +TeamRelativeTimeView TeamPageReadModel::buildLastUpdate(uint32_t last_update_s) const +{ + TeamRelativeTimeView view; + if (last_update_s == 0) + { + return view; + } + view.kind = TeamRelativeTimeKind::SecondsAgo; + if (now_s_ > last_update_s) + { + view.value = now_s_ - last_update_s; + } + return view; +} + +TeamMemberRowView TeamPageReadModel::buildMemberRow( + const TeamMemberUi& member, + std::size_t source_index) const +{ + TeamMemberRowView row; + row.source_index = source_index; + row.node_id = member.node_id; + row.name = member.name; + row.online = ::ui::team_presence::isTeamMemberOnline(now_s_, + member.last_seen_s); + row.leader = member.leader; + row.color_index = member.color_index; + return row; +} + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/src/ui/screens/team/team_page_request_keys_action.cpp b/modules/ui_shared/src/ui/screens/team/team_page_request_keys_action.cpp new file mode 100644 index 00000000..a698701e --- /dev/null +++ b/modules/ui_shared/src/ui/screens/team/team_page_request_keys_action.cpp @@ -0,0 +1,46 @@ +#include "ui/screens/team/team_page_request_keys_action.h" + +#include "chat/domain/chat_types.h" + +namespace team +{ +namespace ui +{ + +TeamPageRequestKeysEffects TeamPageRequestKeysAction::requestKeys( + const TeamPageCommandState& state, + const TeamPageRuntimePort& runtime, + uint32_t self_node_id) const +{ + TeamPageRequestKeysEffects effects; + if (!state.in_team || state.self_is_leader || !state.has_team_id || + self_node_id == 0) + { + effects.ignored = true; + return effects; + } + + effects.accepted = true; + if (!runtime.hasController()) + { + effects.send_failed = true; + return effects; + } + + team::proto::TeamKeyRequest request{}; + request.team_id = state.team_id; + request.current_key_id = state.security_round; + request.requester_id = self_node_id; + + effects.sent_request = + runtime.sendKeyRequest(request, chat::ChannelId::PRIMARY, 0); + if (!effects.sent_request) + { + effects.send_failed = true; + effects.error = runtime.lastSendError(); + } + return effects; +} + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/src/ui/screens/team/team_page_runtime_adapters.cpp b/modules/ui_shared/src/ui/screens/team/team_page_runtime_adapters.cpp new file mode 100644 index 00000000..6159f722 --- /dev/null +++ b/modules/ui_shared/src/ui/screens/team/team_page_runtime_adapters.cpp @@ -0,0 +1,161 @@ +#include "ui/screens/team/team_page_runtime_port.h" + +#include "platform/ui/team_ui_store_runtime.h" +#include "team/usecase/team_controller.h" + +namespace team +{ +namespace ui +{ + +TeamPageControllerPortAdapter::TeamPageControllerPortAdapter( + team::TeamController* controller) + : controller_(controller) +{ +} + +void TeamPageControllerPortAdapter::clearKeys() +{ + if (controller_) + { + controller_->clearKeys(); + } +} + +void TeamPageControllerPortAdapter::resetUiState() +{ + if (controller_) + { + controller_->resetUiState(); + } +} + +bool TeamPageControllerPortAdapter::setKeysFromPsk(const TeamId& team_id, + uint32_t key_id, + const uint8_t* psk, + size_t psk_len) +{ + return controller_ && + controller_->setKeysFromPsk(team_id, key_id, psk, psk_len); +} + +bool TeamPageControllerPortAdapter::sendKick( + const team::proto::TeamKick& kick, + chat::ChannelId channel, + chat::NodeId dest) +{ + return controller_ && controller_->onKick(kick, channel, dest); +} + +bool TeamPageControllerPortAdapter::sendTransferLeader( + const team::proto::TeamTransferLeader& transfer, + chat::ChannelId channel, + chat::NodeId dest) +{ + return controller_ && + controller_->onTransferLeader(transfer, channel, dest); +} + +bool TeamPageControllerPortAdapter::sendKeyDist( + const team::proto::TeamKeyDist& key_dist, + chat::ChannelId channel, + chat::NodeId dest) +{ + return controller_ && controller_->onKeyDist(key_dist, channel, dest); +} + +bool TeamPageControllerPortAdapter::sendKeyDistPlain( + const team::proto::TeamKeyDist& key_dist, + chat::ChannelId channel, + chat::NodeId dest) +{ + return controller_ && + controller_->onKeyDistPlain(key_dist, channel, dest); +} + +bool TeamPageControllerPortAdapter::sendKeyRequest( + const team::proto::TeamKeyRequest& request, + chat::ChannelId channel, + chat::NodeId dest) +{ + return controller_ && controller_->onKeyRequest(request, channel, dest); +} + +bool TeamPageControllerPortAdapter::sendStatus( + const team::proto::TeamStatus& status, + chat::ChannelId channel, + chat::NodeId dest) +{ + return controller_ && controller_->onStatus(status, channel, dest); +} + +bool TeamPageControllerPortAdapter::sendStatusPlain( + const team::proto::TeamStatus& status, + chat::ChannelId channel, + chat::NodeId dest) +{ + return controller_ && controller_->onStatusPlain(status, channel, dest); +} + +team::TeamService::SendError TeamPageControllerPortAdapter::lastSendError() const +{ + if (!controller_) + { + return team::TeamService::SendError::None; + } + return controller_->getLastSendError(); +} + +TeamPagePairingPortAdapter::TeamPagePairingPortAdapter( + team::TeamPairingService* pairing) + : pairing_(pairing) +{ +} + +bool TeamPagePairingPortAdapter::startLeader(const TeamId& team_id, + uint32_t key_id, + const uint8_t* psk, + size_t psk_len, + uint32_t leader_id, + const char* team_name) +{ + return pairing_ && pairing_->startLeader(team_id, + key_id, + psk, + psk_len, + leader_id, + team_name); +} + +bool TeamPagePairingPortAdapter::startMember(uint32_t self_id) +{ + return pairing_ && pairing_->startMember(self_id); +} + +void TeamPagePairingPortAdapter::stop() +{ + if (pairing_) + { + pairing_->stop(); + } +} + +team::TeamPairingStatus TeamPagePairingPortAdapter::status() const +{ + if (!pairing_) + { + return {}; + } + return pairing_->getStatus(); +} + +bool TeamPageKeyStorePortAdapter::saveKeysNow( + const TeamId& team_id, + uint32_t key_id, + const std::array& psk) +{ + return team_ui_save_keys_now(team_id, key_id, psk); +} + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/src/ui/screens/team/team_page_runtime_port.cpp b/modules/ui_shared/src/ui/screens/team/team_page_runtime_port.cpp new file mode 100644 index 00000000..0a66a28d --- /dev/null +++ b/modules/ui_shared/src/ui/screens/team/team_page_runtime_port.cpp @@ -0,0 +1,165 @@ +#include "ui/screens/team/team_page_runtime_port.h" + +namespace team +{ +namespace ui +{ + +TeamPageRuntimePort::TeamPageRuntimePort( + ITeamPageControllerPort* controller, + ITeamPagePairingPort* pairing, + ITeamPageKeyStorePort* key_store) + : controller_(controller), + pairing_(pairing), + key_store_(key_store) +{ +} + +bool TeamPageRuntimePort::hasController() const +{ + return controller_ != nullptr; +} + +bool TeamPageRuntimePort::hasPairing() const +{ + return pairing_ != nullptr; +} + +void TeamPageRuntimePort::clearKeys() const +{ + if (controller_) + { + controller_->clearKeys(); + } +} + +void TeamPageRuntimePort::resetControllerUi() const +{ + if (controller_) + { + controller_->resetUiState(); + } +} + +void TeamPageRuntimePort::stopPairing() const +{ + if (pairing_) + { + pairing_->stop(); + } +} + +team::TeamPairingStatus TeamPageRuntimePort::pairingStatus() const +{ + if (!pairing_) + { + return {}; + } + return pairing_->status(); +} + +bool TeamPageRuntimePort::startLeader(const TeamId& team_id, + uint32_t key_id, + const uint8_t* psk, + size_t psk_len, + uint32_t leader_id, + const char* team_name) const +{ + return pairing_ && pairing_->startLeader(team_id, + key_id, + psk, + psk_len, + leader_id, + team_name); +} + +bool TeamPageRuntimePort::startMember(uint32_t self_id) const +{ + return pairing_ && pairing_->startMember(self_id); +} + +bool TeamPageRuntimePort::setKeysFromPsk(const TeamId& team_id, + uint32_t key_id, + const uint8_t* psk, + size_t psk_len) const +{ + return controller_ && + controller_->setKeysFromPsk(team_id, key_id, psk, psk_len); +} + +bool TeamPageRuntimePort::saveKeysNow( + const TeamId& team_id, + uint32_t key_id, + const std::array& psk) const +{ + return key_store_ && key_store_->saveKeysNow(team_id, key_id, psk); +} + +bool TeamPageRuntimePort::sendKick(const team::proto::TeamKick& kick, + chat::ChannelId channel, + chat::NodeId dest) const +{ + return controller_ && controller_->sendKick(kick, channel, dest); +} + +bool TeamPageRuntimePort::sendTransferLeader( + const team::proto::TeamTransferLeader& transfer, + chat::ChannelId channel, + chat::NodeId dest) const +{ + return controller_ && + controller_->sendTransferLeader(transfer, channel, dest); +} + +bool TeamPageRuntimePort::sendKeyDist( + const team::proto::TeamKeyDist& key_dist, + chat::ChannelId channel, + chat::NodeId dest) const +{ + return controller_ && controller_->sendKeyDist(key_dist, channel, dest); +} + +bool TeamPageRuntimePort::sendKeyDistPlain( + const team::proto::TeamKeyDist& key_dist, + chat::ChannelId channel, + chat::NodeId dest) const +{ + return controller_ && + controller_->sendKeyDistPlain(key_dist, channel, dest); +} + +bool TeamPageRuntimePort::sendKeyRequest( + const team::proto::TeamKeyRequest& request, + chat::ChannelId channel, + chat::NodeId dest) const +{ + return controller_ && + controller_->sendKeyRequest(request, channel, dest); +} + +bool TeamPageRuntimePort::sendStatus(const team::proto::TeamStatus& status, + chat::ChannelId channel, + chat::NodeId dest) const +{ + return controller_ && controller_->sendStatus(status, channel, dest); +} + +bool TeamPageRuntimePort::sendStatusPlain( + const team::proto::TeamStatus& status, + chat::ChannelId channel, + chat::NodeId dest) const +{ + return controller_ && controller_->sendStatusPlain(status, channel, dest); +} + +team::TeamService::SendError TeamPageRuntimePort::lastSendError() const +{ + if (!controller_) + { + return team::TeamService::SendError::None; + } + return controller_->lastSendError(); +} + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/src/ui/screens/team/team_page_state_store.cpp b/modules/ui_shared/src/ui/screens/team/team_page_state_store.cpp new file mode 100644 index 00000000..b614aa1f --- /dev/null +++ b/modules/ui_shared/src/ui/screens/team/team_page_state_store.cpp @@ -0,0 +1,105 @@ +#include "ui/screens/team/team_page_state_store.h" + +namespace team +{ +namespace ui +{ + +void assignTeamPageMemberColors(std::vector& members, + TeamPageColorContext color_context) +{ + for (auto& member : members) + { + uint32_t node_id = member.node_id; + if (node_id == 0) + { + node_id = color_context.self_node_id; + } + member.color_index = team_color_index_from_node_id(node_id); + } +} + +bool TeamPageStateStore::loadOnce(ITeamUiSnapshotStore& store, + TeamPagePersistentState& state, + TeamPageColorContext color_context) +{ + if (loaded_) + { + return false; + } + + loaded_ = true; + return refresh(store, state, color_context); +} + +bool TeamPageStateStore::refresh(ITeamUiSnapshotStore& store, + TeamPagePersistentState& state, + TeamPageColorContext color_context) +{ + TeamUiSnapshot snapshot; + if (!store.load(snapshot)) + { + return false; + } + + applySnapshot(state, snapshot, color_context); + return true; +} + +void TeamPageStateStore::save(ITeamUiSnapshotStore& store, + const TeamPagePersistentState& state) const +{ + store.save(snapshotFromState(state)); +} + +void TeamPageStateStore::resetLoaded() +{ + loaded_ = false; +} + +TeamUiSnapshot TeamPageStateStore::snapshotFromState( + const TeamPagePersistentState& state) +{ + TeamUiSnapshot snapshot; + snapshot.in_team = state.in_team; + snapshot.pending_join = state.pending_join; + snapshot.pending_join_started_s = state.pending_join_started_s; + snapshot.kicked_out = state.kicked_out; + snapshot.self_is_leader = state.self_is_leader; + snapshot.last_event_seq = state.last_event_seq; + snapshot.team_chat_unread = state.team_chat_unread; + snapshot.team_id = state.team_id; + snapshot.has_team_id = state.has_team_id; + snapshot.team_name = state.team_name; + snapshot.security_round = state.security_round; + snapshot.last_update_s = state.last_update_s; + snapshot.team_psk = state.team_psk; + snapshot.has_team_psk = state.has_team_psk; + snapshot.members = state.members; + return snapshot; +} + +void TeamPageStateStore::applySnapshot(TeamPagePersistentState& state, + const TeamUiSnapshot& snapshot, + TeamPageColorContext color_context) +{ + state.in_team = snapshot.in_team; + state.pending_join = snapshot.pending_join; + state.pending_join_started_s = snapshot.pending_join_started_s; + state.kicked_out = snapshot.kicked_out; + state.self_is_leader = snapshot.self_is_leader; + state.last_event_seq = snapshot.last_event_seq; + state.team_chat_unread = snapshot.team_chat_unread; + state.team_id = snapshot.team_id; + state.has_team_id = snapshot.has_team_id; + state.team_name = snapshot.team_name; + state.security_round = snapshot.security_round; + state.last_update_s = snapshot.last_update_s; + state.team_psk = snapshot.team_psk; + state.has_team_psk = snapshot.has_team_psk; + state.members = snapshot.members; + assignTeamPageMemberColors(state.members, color_context); +} + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/src/ui/screens/team/team_page_transfer_leader_action.cpp b/modules/ui_shared/src/ui/screens/team/team_page_transfer_leader_action.cpp new file mode 100644 index 00000000..5491070d --- /dev/null +++ b/modules/ui_shared/src/ui/screens/team/team_page_transfer_leader_action.cpp @@ -0,0 +1,73 @@ +#include "ui/screens/team/team_page_transfer_leader_action.h" + +namespace team +{ +namespace ui +{ +namespace +{ + +TeamPageTransferLeaderFailure makeFailure( + TeamPageTransferLeaderFailureAction action, + bool needs_keys) +{ + TeamPageTransferLeaderFailure failure; + failure.action = action; + failure.kind = TeamPageTransferLeaderFailureKind::SendFailed; + failure.needs_keys = needs_keys; + return failure; +} + +} // namespace + +TeamPageTransferLeaderEffects +TeamPageTransferLeaderAction::transferLeader( + TeamPageCommandState& state, + TeamPageKeyEventState& key_event_state, + const TeamPageCommandReducer& reducer, + const TeamPageRuntimePort& runtime, + const TeamPageKeyEventLog& key_log) const +{ + TeamPageTransferLeaderEffects effects; + const int idx = state.selected_member_index; + if (idx < 0 || idx >= static_cast(state.members.size())) + { + return effects; + } + + const uint32_t target = state.members[static_cast(idx)].node_id; + if (runtime.hasController()) + { + team::proto::TeamTransferLeader transfer{}; + transfer.target = target; + effects.sent_transfer = true; + if (!runtime.sendTransferLeader( + transfer, + chat::ChannelId::PRIMARY, + 0)) + { + effects.failures.push_back(makeFailure( + TeamPageTransferLeaderFailureAction::Transfer, + true)); + } + } + + effects.command = reducer.reduceTransferLeader(state, target); + effects.accepted = effects.command.accepted; + if (!effects.command.accepted) + { + return effects; + } + + if (effects.command.leader_transferred_key_event) + { + effects.appended_key_event = + key_log.appendLeaderTransferred(key_event_state, + effects.command.leader_target); + } + + return effects; +} + +} // namespace ui +} // namespace team diff --git a/modules/ui_shared/src/ui/screens/team/team_state.cpp b/modules/ui_shared/src/ui/screens/team/team_state.cpp deleted file mode 100644 index 837ff2b0..00000000 --- a/modules/ui_shared/src/ui/screens/team/team_state.cpp +++ /dev/null @@ -1,16 +0,0 @@ -/** - * @file team_state.cpp - * @brief Team UI state storage - */ - -#include "ui/screens/team/team_state.h" - -namespace team -{ -namespace ui -{ - -TeamPageState g_team_state{}; - -} // namespace ui -} // namespace team diff --git a/modules/ui_shared/src/ui/team_actions/legacy_team_action_bridge.cpp b/modules/ui_shared/src/ui/team_actions/team_action_runtime_sink.cpp similarity index 70% rename from modules/ui_shared/src/ui/team_actions/legacy_team_action_bridge.cpp rename to modules/ui_shared/src/ui/team_actions/team_action_runtime_sink.cpp index 2b18821d..8427ff51 100644 --- a/modules/ui_shared/src/ui/team_actions/legacy_team_action_bridge.cpp +++ b/modules/ui_shared/src/ui/team_actions/team_action_runtime_sink.cpp @@ -1,4 +1,4 @@ -#include "ui/team_actions/legacy_team_action_bridge.h" +#include "ui/team_actions/team_action_runtime_sink.h" #include "sys/clock.h" #include "team/protocol/team_chat.h" @@ -83,25 +83,29 @@ team::proto::TeamCommandType toProtoCommand(TeamCommandKind kind) } // namespace -LegacyTeamActionBridge::LegacyTeamActionBridge( - ::team::ui::ITeamUiStore& team_store, +TeamActionRuntimeSink::TeamActionRuntimeSink( + ::team::ui::ITeamUiSnapshotStore& snapshot_store, + ::team::ui::ITeamUiChatLogStore& chat_log_store, ::ui::presentation_sources::ITeamChatCommandPort* command_port, ITeamLocationSource* location_source, uint8_t team_channel_raw) - : team_store_(team_store), + : snapshot_store_(snapshot_store), + chat_log_store_(chat_log_store), command_port_(command_port), location_source_(location_source), team_channel_raw_(team_channel_raw) { } -ui::UiActionResult LegacyTeamActionBridge::sendTeamAction( +ui::UiActionResult TeamActionRuntimeSink::sendTeamAction( const TeamActionRequest& request) { switch (request.kind) { case TeamActionKind::Text: return sendText(request); + case TeamActionKind::LocationShare: + return sendLocationShare(request); case TeamActionKind::LocationMarker: return sendLocationMarker(request); case TeamActionKind::Command: @@ -110,7 +114,7 @@ ui::UiActionResult LegacyTeamActionBridge::sendTeamAction( return ui::UiActionResult::fail(ui::UiActionFailure::Unsupported); } -ui::UiActionResult LegacyTeamActionBridge::sendText( +ui::UiActionResult TeamActionRuntimeSink::sendText( const TeamActionRequest& request) { if (request.text == nullptr || request.text[0] == '\0') @@ -119,7 +123,7 @@ ui::UiActionResult LegacyTeamActionBridge::sendText( } ::team::ui::TeamUiSnapshot snap; - if (!team_store_.load(snap) || !snap.has_team_id || !snap.has_team_psk || + if (!snapshot_store_.load(snap) || !snap.has_team_id || !snap.has_team_psk || command_port_ == nullptr) { return ui::UiActionResult::fail(ui::UiActionFailure::NotReady); @@ -150,7 +154,7 @@ ui::UiActionResult LegacyTeamActionBridge::sendText( return ui::UiActionResult::fail(ui::UiActionFailure::Rejected); } - (void)::team::ui::team_ui_chatlog_append_structured( + (void)chat_log_store_.appendStructured( snap.team_id, 0, false, @@ -160,7 +164,100 @@ ui::UiActionResult LegacyTeamActionBridge::sendText( return ui::UiActionResult::success(); } -ui::UiActionResult LegacyTeamActionBridge::sendLocationMarker( +ui::UiActionResult TeamActionRuntimeSink::sendLocationShare( + const TeamActionRequest& request) +{ + TeamLocationShareRequest location = request.location_share; + if (location.use_current_location) + { + if (location_source_ == nullptr) + { + return ui::UiActionResult::fail(ui::UiActionFailure::NotReady); + } + + TeamLocationSnapshot snapshot; + if (!location_source_->currentTeamLocation(snapshot) || !snapshot.valid) + { + return ui::UiActionResult::fail(ui::UiActionFailure::NotReady); + } + + location.lat = snapshot.lat; + location.lon = snapshot.lon; + location.has_altitude = snapshot.has_altitude; + location.altitude_m = snapshot.altitude_m; + if (location.timestamp == 0) + { + location.timestamp = snapshot.timestamp; + } + } + + if (!validCoordinate(location.lat, location.lon)) + { + return ui::UiActionResult::fail(ui::UiActionFailure::InvalidInput); + } + + ::team::ui::TeamUiSnapshot snap; + if (!snapshot_store_.load(snap) || !snap.has_team_id || !snap.has_team_psk || + command_port_ == nullptr) + { + return ui::UiActionResult::fail(ui::UiActionFailure::NotReady); + } + if (!command_port_->setKeysFromPsk(snap.team_id, + snap.security_round, + snap.team_psk.data(), + snap.team_psk.size())) + { + return ui::UiActionResult::fail(ui::UiActionFailure::NotReady); + } + + uint32_t ts = location.timestamp; + if (ts == 0) + { + ts = currentTimestamp(); + } + + team::proto::TeamChatLocation loc; + loc.lat_e7 = coordinateToE7(location.lat); + loc.lon_e7 = coordinateToE7(location.lon); + loc.alt_m = location.has_altitude ? clampAltitude(location.altitude_m) : 0; + loc.ts = ts; + if (location.label != nullptr) + { + loc.label = location.label; + } + + std::vector payload; + if (!team::proto::encodeTeamChatLocation(loc, payload)) + { + return ui::UiActionResult::fail(ui::UiActionFailure::Rejected); + } + + team::proto::TeamChatMessage message; + message.header.type = team::proto::TeamChatType::Location; + message.header.ts = ts; + message.header.msg_id = next_message_id_++; + if (next_message_id_ == 0) + { + next_message_id_ = kTeamComposeMsgIdStart; + } + message.payload = payload; + + if (!command_port_->sendTeamChat(message, team_channel_raw_)) + { + return ui::UiActionResult::fail(ui::UiActionFailure::Rejected); + } + + (void)chat_log_store_.appendStructured( + snap.team_id, + 0, + false, + ts, + team::proto::TeamChatType::Location, + payload); + return ui::UiActionResult::success(); +} + +ui::UiActionResult TeamActionRuntimeSink::sendLocationMarker( const TeamActionRequest& request) { TeamLocationMarkerRequest location = request.location; @@ -202,7 +299,7 @@ ui::UiActionResult LegacyTeamActionBridge::sendLocationMarker( } ::team::ui::TeamUiSnapshot snap; - if (!team_store_.load(snap) || !snap.has_team_id || !snap.has_team_psk || + if (!snapshot_store_.load(snap) || !snap.has_team_id || !snap.has_team_psk || command_port_ == nullptr) { return ui::UiActionResult::fail(ui::UiActionFailure::NotReady); @@ -254,7 +351,7 @@ ui::UiActionResult LegacyTeamActionBridge::sendLocationMarker( return ui::UiActionResult::fail(ui::UiActionFailure::Rejected); } - (void)::team::ui::team_ui_chatlog_append_structured( + (void)chat_log_store_.appendStructured( snap.team_id, 0, false, @@ -264,7 +361,7 @@ ui::UiActionResult LegacyTeamActionBridge::sendLocationMarker( return ui::UiActionResult::success(); } -ui::UiActionResult LegacyTeamActionBridge::sendCommand( +ui::UiActionResult TeamActionRuntimeSink::sendCommand( const TeamActionRequest& request) { const auto& command = request.command; @@ -282,7 +379,7 @@ ui::UiActionResult LegacyTeamActionBridge::sendCommand( } ::team::ui::TeamUiSnapshot snap; - if (!team_store_.load(snap) || !snap.has_team_id || !snap.has_team_psk || + if (!snapshot_store_.load(snap) || !snap.has_team_id || !snap.has_team_psk || command_port_ == nullptr) { return ui::UiActionResult::fail(ui::UiActionFailure::NotReady); @@ -329,7 +426,7 @@ ui::UiActionResult LegacyTeamActionBridge::sendCommand( return ui::UiActionResult::fail(ui::UiActionFailure::Rejected); } - (void)::team::ui::team_ui_chatlog_append_structured( + (void)chat_log_store_.appendStructured( snap.team_id, 0, false, diff --git a/modules/ui_shared/src/ui/team_actions/team_runtime_adapters.cpp b/modules/ui_shared/src/ui/team_actions/team_runtime_adapters.cpp new file mode 100644 index 00000000..a00bc1b3 --- /dev/null +++ b/modules/ui_shared/src/ui/team_actions/team_runtime_adapters.cpp @@ -0,0 +1,52 @@ +#include "ui/team_actions/team_runtime_adapters.h" + +#include "platform/ui/gps_runtime.h" +#include "team/usecase/team_controller.h" + +namespace ui::team_actions +{ + +TeamControllerChatCommandPort::TeamControllerChatCommandPort( + ::team::TeamController& controller) + : controller_(controller) +{ +} + +bool TeamControllerChatCommandPort::setKeysFromPsk( + const ::team::TeamId& team_id, + uint32_t key_id, + const uint8_t* psk, + size_t psk_len) +{ + return controller_.setKeysFromPsk(team_id, key_id, psk, psk_len); +} + +bool TeamControllerChatCommandPort::sendTeamChat( + const ::team::proto::TeamChatMessage& message, + uint8_t team_channel_raw) +{ + return controller_.onChat( + message, + static_cast<::chat::ChannelId>(team_channel_raw)); +} + +bool GpsTeamLocationSource::currentTeamLocation(TeamLocationSnapshot& out) +{ + const auto gps_state = platform::ui::gps::get_data(); + if (!gps_state.valid) + { + out = {}; + return false; + } + + out.valid = true; + out.lat = gps_state.lat; + out.lon = gps_state.lng; + out.has_altitude = gps_state.has_alt; + out.altitude_m = gps_state.alt_m; + out.accuracy_m = 0.0f; + out.timestamp = 0; + return true; +} + +} // namespace ui::team_actions diff --git a/modules/ui_shared/src/ui/team_persistence/team_ui_snapshot_codec.cpp b/modules/ui_shared/src/ui/team_persistence/team_ui_snapshot_codec.cpp new file mode 100644 index 00000000..acdbab9b --- /dev/null +++ b/modules/ui_shared/src/ui/team_persistence/team_ui_snapshot_codec.cpp @@ -0,0 +1,303 @@ +#include "ui/team_persistence/team_ui_snapshot_codec.h" + +#include +#include +#include + +namespace ui::team_persistence +{ +namespace +{ + +constexpr uint8_t kRoleNone = 0; +constexpr uint8_t kRoleMember = 1; +constexpr uint8_t kRoleLeader = 2; +constexpr std::size_t kMaxMemberNameBytes = 24; + +uint64_t teamIdToU64(const ::team::TeamId& id) +{ + uint64_t value = 0; + for (std::size_t i = 0; i < id.size(); ++i) + { + value |= static_cast(id[i]) << (8 * i); + } + return value; +} + +::team::TeamId teamIdFromU64(uint64_t value) +{ + ::team::TeamId id{}; + for (std::size_t i = 0; i < id.size(); ++i) + { + id[i] = static_cast((value >> (8 * i)) & 0xFF); + } + return id; +} + +void appendU8(std::vector& out, uint8_t value) +{ + out.push_back(value); +} + +void appendU16(std::vector& out, uint16_t value) +{ + out.push_back(static_cast(value & 0xFF)); + out.push_back(static_cast((value >> 8) & 0xFF)); +} + +void appendU32(std::vector& out, uint32_t value) +{ + for (int i = 0; i < 4; ++i) + { + out.push_back(static_cast((value >> (8 * i)) & 0xFF)); + } +} + +void appendU64(std::vector& out, uint64_t value) +{ + for (int i = 0; i < 8; ++i) + { + out.push_back(static_cast((value >> (8 * i)) & 0xFF)); + } +} + +bool readU8(const uint8_t* data, std::size_t len, std::size_t& off, uint8_t& out) +{ + if (off + 1U > len) + { + return false; + } + out = data[off++]; + return true; +} + +bool readU16(const uint8_t* data, std::size_t len, std::size_t& off, uint16_t& out) +{ + if (off + 2U > len) + { + return false; + } + out = static_cast(data[off]) | + (static_cast(data[off + 1U]) << 8); + off += 2U; + return true; +} + +bool readU32(const uint8_t* data, std::size_t len, std::size_t& off, uint32_t& out) +{ + if (off + 4U > len) + { + return false; + } + out = static_cast(data[off]) | + (static_cast(data[off + 1U]) << 8) | + (static_cast(data[off + 2U]) << 16) | + (static_cast(data[off + 3U]) << 24); + off += 4U; + return true; +} + +bool readU64(const uint8_t* data, std::size_t len, std::size_t& off, uint64_t& out) +{ + if (off + 8U > len) + { + return false; + } + out = 0; + for (int i = 0; i < 8; ++i) + { + out |= static_cast(data[off + static_cast(i)]) + << (8 * i); + } + off += 8U; + return true; +} + +} // namespace + +bool encodeTeamUiSnapshot(const ::team::ui::TeamUiSnapshot& snapshot, + uint32_t updated_s, + std::vector& out) +{ + out.clear(); + out.reserve(64U + snapshot.members.size() * 36U); + + out.push_back('T'); + out.push_back('M'); + out.push_back('S'); + out.push_back('1'); + appendU8(out, kTeamUiSnapshotCurrentVersion); + + const uint8_t flags = snapshot.in_team ? 0x01 : 0x00; + appendU8(out, flags); + appendU16(out, 0); + appendU32(out, updated_s); + appendU64(out, snapshot.has_team_id ? teamIdToU64(snapshot.team_id) : 0); + appendU32(out, snapshot.security_round); + appendU32(out, snapshot.last_event_seq); + appendU32(out, 0); + + uint32_t leader_node_id = 0; + for (const auto& member : snapshot.members) + { + if (member.leader) + { + leader_node_id = member.node_id; + break; + } + } + appendU32(out, leader_node_id); + appendU8(out, snapshot.self_is_leader ? kRoleLeader + : (snapshot.in_team ? kRoleMember + : kRoleNone)); + appendU8(out, 0); + appendU8(out, 0); + appendU8(out, 0); + + const std::size_t capped_count = + std::min(snapshot.members.size(), UINT16_MAX); + appendU16(out, static_cast(capped_count)); + appendU16(out, 0); + + for (std::size_t i = 0; i < capped_count; ++i) + { + const auto& member = snapshot.members[i]; + appendU32(out, member.node_id); + appendU8(out, member.leader ? kRoleLeader : kRoleMember); + + std::string name = member.name; + if (name.size() > kMaxMemberNameBytes) + { + name.resize(kMaxMemberNameBytes); + } + const uint8_t name_flag = name.empty() ? 0x00 : 0x01; + appendU8(out, name_flag); + appendU32(out, member.last_seen_s); + appendU16(out, static_cast(name.size())); + out.insert(out.end(), name.begin(), name.end()); + } + + appendU32(out, snapshot.team_chat_unread); + return true; +} + +bool decodeTeamUiSnapshot(const uint8_t* data, + std::size_t len, + ::team::ui::TeamUiSnapshot& out) +{ + if (data == nullptr || len < 4U || std::memcmp(data, "TMS1", 4) != 0) + { + return false; + } + + std::size_t off = 4U; + 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 (!readU8(data, len, off, version) || + !readU8(data, len, off, flags) || + !readU16(data, len, off, reserved) || + !readU32(data, len, off, updated_ts) || + !readU64(data, len, off, team_id) || + !readU32(data, len, off, epoch) || + !readU32(data, len, off, last_event_seq) || + !readU32(data, len, off, self_node_id) || + !readU32(data, len, off, leader_node_id) || + !readU8(data, len, off, self_role)) + { + return false; + } + if (off + 3U > len) + { + return false; + } + off += 3U; + if (!readU16(data, len, off, member_count) || + !readU16(data, len, off, reserved3)) + { + return false; + } + + if (version != kTeamUiSnapshotVersionV1 && + version != kTeamUiSnapshotVersionV2) + { + return false; + } + + ::team::ui::TeamUiSnapshot decoded; + decoded.in_team = (flags & 0x01) != 0; + decoded.has_team_id = (team_id != 0); + decoded.team_id = teamIdFromU64(team_id); + decoded.security_round = epoch; + decoded.last_event_seq = last_event_seq; + decoded.self_is_leader = (self_role == kRoleLeader); + + for (uint16_t i = 0; i < member_count; ++i) + { + uint32_t node_id = 0; + uint8_t role = 0; + uint8_t member_flags = 0; + uint32_t last_seen_s = 0; + uint16_t name_len = 0; + + if (!readU32(data, len, off, node_id) || + !readU8(data, len, off, role) || + !readU8(data, len, off, member_flags)) + { + return false; + } + if (version >= kTeamUiSnapshotVersionV2 && + !readU32(data, len, off, last_seen_s)) + { + return false; + } + if (!readU16(data, len, off, name_len)) + { + return false; + } + if (off + name_len > len) + { + return false; + } + + std::string name; + if (name_len > 0) + { + name.assign(reinterpret_cast(data + off), name_len); + } + off += name_len; + + ::team::ui::TeamMemberUi member; + member.node_id = node_id; + member.name = name; + member.leader = (role == kRoleLeader); + member.last_seen_s = last_seen_s; + decoded.members.push_back(member); + } + + uint32_t chat_unread = 0; + if (off + sizeof(chat_unread) <= len && readU32(data, len, off, chat_unread)) + { + decoded.team_chat_unread = chat_unread; + } + + (void)reserved; + (void)reserved3; + (void)updated_ts; + (void)self_node_id; + (void)leader_node_id; + out = decoded; + return true; +} + +} // namespace ui::team_persistence diff --git a/modules/ui_shared/src/ui/team_presence/team_presence_model.cpp b/modules/ui_shared/src/ui/team_presence/team_presence_model.cpp new file mode 100644 index 00000000..4c327434 --- /dev/null +++ b/modules/ui_shared/src/ui/team_presence/team_presence_model.cpp @@ -0,0 +1,84 @@ +#include "ui/team_presence/team_presence_model.h" + +namespace ui::team_presence +{ + +bool isTeamMemberOnline(uint32_t now_s, + uint32_t last_seen_s, + uint32_t online_window_s) +{ + if (last_seen_s == 0) + { + return false; + } + if (last_seen_s >= now_s) + { + return true; + } + return (now_s - last_seen_s) <= online_window_s; +} + +int refreshTeamMemberPresence(std::vector<::team::ui::TeamMemberUi>& members, + uint32_t now_s, + uint32_t online_window_s) +{ + int online_count = 0; + for (auto& member : members) + { + member.online = isTeamMemberOnline(now_s, + member.last_seen_s, + online_window_s); + if (member.online) + { + ++online_count; + } + } + return online_count; +} + +std::size_t findTeamMemberIndex( + const std::vector<::team::ui::TeamMemberUi>& members, + uint32_t node_id) +{ + for (std::size_t i = 0; i < members.size(); ++i) + { + if (members[i].node_id == node_id) + { + return i; + } + } + return kInvalidTeamMemberIndex; +} + +TeamMemberTouchResult touchTeamMember( + std::vector<::team::ui::TeamMemberUi>& members, + uint32_t node_id, + uint32_t seen_s) +{ + std::size_t index = findTeamMemberIndex(members, node_id); + if (index == kInvalidTeamMemberIndex) + { + ::team::ui::TeamMemberUi member; + member.node_id = node_id; + member.last_seen_s = seen_s; + members.push_back(member); + TeamMemberTouchResult result; + result.index = members.size() - 1U; + result.created = true; + result.valid = true; + return result; + } + + members[index].last_seen_s = seen_s; + TeamMemberTouchResult result; + result.index = index; + result.valid = true; + return result; +} + +uint32_t normalizeSeenSeconds(uint32_t observed_s, uint32_t fallback_now_s) +{ + return observed_s != 0 ? observed_s : fallback_now_s; +} + +} // namespace ui::team_presence diff --git a/modules/ui_shared/src/ui/ui_status.cpp b/modules/ui_shared/src/ui/ui_status.cpp index 54d9e592..63663a82 100644 --- a/modules/ui_shared/src/ui/ui_status.cpp +++ b/modules/ui_shared/src/ui/ui_status.cpp @@ -13,7 +13,7 @@ #include "platform/ui/wifi_runtime.h" #include "sys/clock.h" #if !defined(GAT562_NO_TEAM) || !GAT562_NO_TEAM -#include "platform/ui/team_ui_store_runtime.h" +#include "platform/ui/team_ui_snapshot_store.h" #endif #include "ui/presentation_sources/legacy_gps_status_source.h" #include "ui_presentation/gps/gps_status_model.h" @@ -103,7 +103,7 @@ void refresh_team_cache(bool force = false) } team::ui::TeamUiSnapshot snap; - if (team::ui::team_ui_get_store().load(snap)) + if (team::ui::team_ui_snapshot_store().load(snap)) { s_team_cache.team_active = snap.in_team; s_team_cache.team_unread = static_cast(snap.team_chat_unread); diff --git a/modules/ui_shared/tests/test_chat_team_workflow.cpp b/modules/ui_shared/tests/test_chat_team_workflow.cpp new file mode 100644 index 00000000..ccc01a4b --- /dev/null +++ b/modules/ui_shared/tests/test_chat_team_workflow.cpp @@ -0,0 +1,142 @@ +#include "ui/screens/chat/chat_team_workflow.h" + +#include +#include + +namespace +{ + +ui::chat::ConversationId teamConversation() +{ + ui::chat::ConversationId id; + id.kind = ui::chat::ConversationKind::Team; + id.protocol = ui::chat::ChatProtocolKind::TrailMate; + id.primary = 1; + return id; +} + +class FakeTeamSource final : public ui::chat::IChatPresentationSource +{ + public: + bool buildChatWorkspaceSnapshot( + const ui::chat::ChatWorkspaceRequest& request, + ui::chat::ChatWorkspaceSnapshot& out) const override + { + ui::chat::resetChatWorkspaceSnapshot(out); + out.header.valid = true; + out.selected_conversation = request.selected; + out.conversation_count = 1; + out.conversations[0].id = teamConversation(); + out.conversations[0].kind = ui::chat::ConversationKind::Team; + ui::copyText(out.conversations[0].title, "Trail team"); + out.conversations[0].selected = + request.selected == out.conversations[0].id; + if (out.conversations[0].selected) + { + out.message_count = 1; + out.messages[0].conversation = out.conversations[0].id; + ui::copyText(out.messages[0].text, "ready"); + } + return true; + } +}; + +class FakeTeamChatSink final : public ui::chat::IChatActionSink +{ + public: + ui::UiActionResult selectConversation(ui::chat::ConversationId id) override + { + selected = id; + return ui::UiActionResult::success(); + } + + ui::UiActionResult sendMessage( + const ui::chat::SendMessageView& message) override + { + ++send_count; + last_sent = message.text; + return send_result; + } + + ui::UiActionResult markRead(ui::chat::ConversationId id) override + { + marked = id; + return ui::UiActionResult::success(); + } + + ui::chat::ConversationId selected{}; + ui::chat::ConversationId marked{}; + int send_count = 0; + const char* last_sent = nullptr; + ui::UiActionResult send_result = ui::UiActionResult::success(); +}; + +class FakeTeamActionSink final : public ui::team_actions::ITeamActionSink +{ + public: + ui::UiActionResult sendTeamAction( + const ui::team_actions::TeamActionRequest& request) override + { + ++send_count; + last_request = request; + return result; + } + + int send_count = 0; + ui::team_actions::TeamActionRequest last_request{}; + ui::UiActionResult result = ui::UiActionResult::success(); +}; + +} // namespace + +int main() +{ + FakeTeamSource source; + FakeTeamChatSink chat_sink; + ui::chat::ChatWorkspaceModel model(source, chat_sink); + FakeTeamActionSink action_sink; + chat::ui::ChatTeamWorkflow workflow(model, &action_sink); + + ui::chat::ChatWorkspaceSnapshot snapshot; + assert(workflow.buildSelectedSnapshot(snapshot)); + assert(snapshot.conversation_count == 1); + assert(snapshot.message_count == 1); + assert(chat_sink.selected == teamConversation()); + + const auto text_result = workflow.sendText("hello team"); + assert(text_result.ok); + assert(chat_sink.send_count == 1); + assert(std::strcmp(chat_sink.last_sent, "hello team") == 0); + + chat_sink.send_result = + ui::UiActionResult::fail(ui::UiActionFailure::NotReady); + const auto failed_text = workflow.sendText("not ready"); + assert(!failed_text.ok); + assert(std::strcmp(workflow.textSendFailureMessage(failed_text), + "Team keys not ready") == 0); + + const auto marker_result = workflow.sendCurrentLocationMarker(1); + assert(marker_result.ok); + assert(action_sink.send_count == 1); + assert(action_sink.last_request.kind == + ui::team_actions::TeamActionKind::LocationMarker); + assert(action_sink.last_request.location.use_current_location); + assert(action_sink.last_request.location.marker_icon == 1); + assert(action_sink.last_request.location.label != nullptr); + + const auto invalid_marker = workflow.sendCurrentLocationMarker(99); + assert(!invalid_marker.ok); + assert(invalid_marker.failure == ui::UiActionFailure::InvalidInput); + assert(std::strcmp(workflow.locationSendFailureMessage(invalid_marker), + "Invalid marker") == 0); + + chat::ui::ChatTeamWorkflow no_action_sink(model, nullptr); + const auto missing_sink = no_action_sink.sendCurrentLocationMarker(1); + assert(!missing_sink.ok); + assert(missing_sink.failure == ui::UiActionFailure::NotReady); + + assert(workflow.markRead(teamConversation()).ok); + assert(chat_sink.marked == teamConversation()); + + return 0; +} diff --git a/modules/ui_shared/tests/test_contacts_team_snapshot_source.cpp b/modules/ui_shared/tests/test_contacts_team_snapshot_source.cpp new file mode 100644 index 00000000..42f5d193 --- /dev/null +++ b/modules/ui_shared/tests/test_contacts_team_snapshot_source.cpp @@ -0,0 +1,104 @@ +#include "ui/screens/contacts/contacts_team_snapshot_source.h" + +#include + +namespace +{ + +class FakeSnapshotStore final : public team::ui::ITeamUiSnapshotStore +{ + public: + bool load(team::ui::TeamUiSnapshot& out) override + { + if (!has_snapshot) + { + return false; + } + out = snapshot; + return true; + } + + void save(const team::ui::TeamUiSnapshot& in) override + { + snapshot = in; + has_snapshot = true; + } + + void clear() override + { + has_snapshot = false; + snapshot = team::ui::TeamUiSnapshot{}; + } + + bool has_snapshot = false; + team::ui::TeamUiSnapshot snapshot; +}; + +team::TeamId testTeamId() +{ + team::TeamId id{}; + id[0] = 0xCA; + id[1] = 0xFE; + return id; +} + +void testMissingSnapshotIsUnavailable() +{ + FakeSnapshotStore store; + contacts::ui::ContactsTeamSnapshotSource source(store); + contacts::ui::ContactsTeamSnapshot snapshot; + + assert(!source.load(snapshot)); + assert(!source.isAvailable()); + assert(!snapshot.available); + assert(contacts::ui::contactsTeamDisplayName(snapshot, "Team") == "Team"); +} + +void testSnapshotProjectsContactsTeamFields() +{ + FakeSnapshotStore store; + store.snapshot.has_team_id = true; + store.snapshot.in_team = true; + store.snapshot.team_id = testTeamId(); + store.snapshot.team_name = "Trail"; + store.has_snapshot = true; + + contacts::ui::ContactsTeamSnapshotSource source(store); + contacts::ui::ContactsTeamSnapshot snapshot; + + assert(source.load(snapshot)); + assert(source.isAvailable()); + assert(snapshot.available); + assert(snapshot.in_team); + assert(snapshot.has_team_id); + assert(snapshot.team_id == testTeamId()); + assert(snapshot.team_name == "Trail"); + assert(contacts::ui::contactsTeamDisplayName(snapshot, "Team") == "Trail"); +} + +void testTeamIdAloneKeepsTeamChatReachable() +{ + FakeSnapshotStore store; + store.snapshot.has_team_id = true; + store.snapshot.in_team = false; + store.snapshot.team_id = testTeamId(); + store.has_snapshot = true; + + contacts::ui::ContactsTeamSnapshotSource source(store); + contacts::ui::ContactsTeamSnapshot snapshot; + + assert(source.load(snapshot)); + assert(snapshot.available); + assert(!snapshot.in_team); + assert(contacts::ui::contactsTeamDisplayName(snapshot, "Team") == "Team"); +} + +} // namespace + +int main() +{ + testMissingSnapshotIsUnavailable(); + testSnapshotProjectsContactsTeamFields(); + testTeamIdAloneKeepsTeamChatReachable(); + return 0; +} diff --git a/modules/ui_shared/tests/test_legacy_map_presentation_adapters.cpp b/modules/ui_shared/tests/test_legacy_map_presentation_adapters.cpp index 854a845b..5e7a320e 100644 --- a/modules/ui_shared/tests/test_legacy_map_presentation_adapters.cpp +++ b/modules/ui_shared/tests/test_legacy_map_presentation_adapters.cpp @@ -1,4 +1,5 @@ -#include "platform/ui/team_ui_store_runtime.h" +#include "platform/ui/team_ui_snapshot_store.h" +#include "sys/clock.h" #include "ui/presentation_sources/legacy_map_action_sink.h" #include "ui/presentation_sources/legacy_map_presentation_source.h" #include "ui_presentation/common/fixed_text.h" @@ -29,7 +30,7 @@ class FakeGpsStatusSource final : public ui::gps::IGpsStatusSource ui::gps::GpsStatusSnapshot snapshot{}; }; -class FakeTeamStore final : public team::ui::ITeamUiStore +class FakeTeamStore final : public team::ui::ITeamUiSnapshotStore { public: bool load(team::ui::TeamUiSnapshot& out) override @@ -82,6 +83,9 @@ void assertViewport(const ui::map::MapViewport& actual, int main() { + sys::set_millis_provider([]() -> uint32_t { return 200000U; }); + sys::set_epoch_seconds_provider([]() -> uint32_t { return 1700000200U; }); + FakeGpsStatusSource gps; gps.snapshot.header.valid = true; gps.snapshot.receiver_enabled = true; @@ -99,11 +103,11 @@ int main() team::ui::TeamMemberUi ada; ada.node_id = 1; ada.name = "Ada"; - ada.online = true; + ada.last_seen_s = 200; team::ui::TeamMemberUi grace; grace.node_id = 2; grace.name = "Grace"; - grace.online = false; + grace.last_seen_s = 10; team_store.snapshot.members.push_back(ada); team_store.snapshot.members.push_back(grace); @@ -143,6 +147,15 @@ int main() assert(snapshot.team.stale_members == 1); assert(std::strcmp(snapshot.status_line.c_str(), "GPS fix") == 0); + team_store.snapshot.members.clear(); + ada.last_seen_s = 1700000200U; + grace.last_seen_s = 1700000000U; + team_store.snapshot.members.push_back(ada); + team_store.snapshot.members.push_back(grace); + assert(source.buildMapWorkspaceSnapshot(request, snapshot)); + assert(snapshot.team.visible_members == 2); + assert(snapshot.team.stale_members == 1); + const auto center = sink.centerOnSelf(); assert(center.ok); diff --git a/modules/ui_shared/tests/test_legacy_team_action_bridge.cpp b/modules/ui_shared/tests/test_team_action_runtime_sink.cpp similarity index 72% rename from modules/ui_shared/tests/test_legacy_team_action_bridge.cpp rename to modules/ui_shared/tests/test_team_action_runtime_sink.cpp index e040918e..8bfeebcc 100644 --- a/modules/ui_shared/tests/test_legacy_team_action_bridge.cpp +++ b/modules/ui_shared/tests/test_team_action_runtime_sink.cpp @@ -1,4 +1,4 @@ -#include "ui/team_actions/legacy_team_action_bridge.h" +#include "ui/team_actions/team_action_runtime_sink.h" #include "team/protocol/team_chat.h" #include "team/protocol/team_location_marker.h" @@ -79,8 +79,8 @@ team::ui::TeamUiSnapshot readySnapshot() int main() { - team::ui::TeamUiMemoryStore store; - team::ui::team_ui_set_store(&store); + team::ui::TeamUiSnapshotMemoryStore store; + team::ui::team_ui_set_snapshot_store(&store); store.clear(); FakeTeamCommandPort port; @@ -93,9 +93,10 @@ int main() location_source.snapshot.accuracy_m = 6.0f; location_source.snapshot.timestamp = 12345; - ui::team_actions::LegacyTeamActionBridge bridge(store, - &port, - &location_source); + ui::team_actions::TeamActionRuntimeSink sink(store, + team::ui::team_ui_chat_log_store(), + &port, + &location_source); ui::team_actions::TeamActionRequest location; location.kind = ui::team_actions::TeamActionKind::LocationMarker; @@ -103,12 +104,12 @@ int main() location.location.lon = 121.4737; location.location.marker_icon = static_cast(team::proto::TeamLocationMarkerIcon::Rally); - assert(bridge.sendTeamAction(location).failure == ui::UiActionFailure::NotReady); + assert(sink.sendTeamAction(location).failure == ui::UiActionFailure::NotReady); store.save(readySnapshot()); location.location.marker_icon = 99; - assert(bridge.sendTeamAction(location).failure == + assert(sink.sendTeamAction(location).failure == ui::UiActionFailure::InvalidInput); location.location.marker_icon = @@ -116,7 +117,7 @@ int main() location.location.has_altitude = true; location.location.altitude_m = 123.4; location.location.accuracy_m = 7.0f; - const auto location_result = bridge.sendTeamAction(location); + const auto location_result = sink.sendTeamAction(location); assert(location_result.ok); assert(port.set_keys_count == 1); assert(port.send_count == 1); @@ -139,7 +140,7 @@ int main() current_location.location.use_current_location = true; current_location.location.marker_icon = static_cast(team::proto::TeamLocationMarkerIcon::GoodFind); - const auto current_location_result = bridge.sendTeamAction(current_location); + const auto current_location_result = sink.sendTeamAction(current_location); assert(current_location_result.ok); assert(location_source.read_count == 1); assert(port.last_message.header.type == team::proto::TeamChatType::Location); @@ -154,42 +155,65 @@ int main() assert(decoded_location.ts == 12345); location_source.snapshot.valid = false; - assert(bridge.sendTeamAction(current_location).failure == + assert(sink.sendTeamAction(current_location).failure == ui::UiActionFailure::NotReady); location_source.snapshot.valid = true; - ui::team_actions::LegacyTeamActionBridge bridge_without_location_source( + ui::team_actions::TeamActionRequest share_location; + share_location.kind = ui::team_actions::TeamActionKind::LocationShare; + share_location.location_share.use_current_location = true; + share_location.location_share.label = "camp"; + const auto share_location_result = sink.sendTeamAction(share_location); + assert(share_location_result.ok); + assert(location_source.read_count == 3); + assert(port.last_message.header.type == team::proto::TeamChatType::Location); + assert(team::proto::decodeTeamChatLocation( + port.last_message.payload.data(), + port.last_message.payload.size(), + &decoded_location)); + assert(decoded_location.lat_e7 == 302672000); + assert(decoded_location.lon_e7 == -977431000); + assert(decoded_location.source == 0); + assert(decoded_location.label == "camp"); + + ui::team_actions::TeamActionRuntimeSink sink_without_location_source( store, + team::ui::team_ui_chat_log_store(), &port); - assert(bridge_without_location_source.sendTeamAction(current_location).failure == + assert(sink_without_location_source.sendTeamAction(current_location).failure == + ui::UiActionFailure::NotReady); + assert(sink_without_location_source.sendTeamAction(share_location).failure == ui::UiActionFailure::NotReady); std::vector log; - assert(team::ui::team_ui_chatlog_load_recent(readySnapshot().team_id, 4, log)); + assert(team::ui::team_ui_chat_log_store().loadRecent( + readySnapshot().team_id, + 4, + log)); assert(!log.empty()); assert(log.back().type == team::proto::TeamChatType::Location); ui::team_actions::TeamActionRequest text; text.kind = ui::team_actions::TeamActionKind::Text; text.text = "team hello"; - const auto text_result = bridge.sendTeamAction(text); + const auto text_result = sink.sendTeamAction(text); assert(text_result.ok); assert(port.last_message.header.type == team::proto::TeamChatType::Text); ui::team_actions::TeamActionRequest command; command.kind = ui::team_actions::TeamActionKind::Command; - assert(bridge.sendTeamAction(command).failure == + assert(sink.sendTeamAction(command).failure == ui::UiActionFailure::InvalidInput); command.command.kind = ui::team_actions::TeamCommandKind::Custom; - assert(bridge.sendTeamAction(command).failure == + assert(sink.sendTeamAction(command).failure == ui::UiActionFailure::Unsupported); command.command.kind = ui::team_actions::TeamCommandKind::MoveTo; command.command.lat = 31.2304; command.command.lon = 121.4737; command.command.payload = "move"; - const auto command_result = bridge.sendTeamAction(command); + const auto command_result = sink.sendTeamAction(command); assert(command_result.ok); assert(port.last_message.header.type == team::proto::TeamChatType::Command); @@ -202,6 +226,6 @@ int main() assert(decoded_command.note == "move"); store.clear(); - team::ui::team_ui_set_store(nullptr); + team::ui::team_ui_set_snapshot_store(nullptr); return 0; } diff --git a/modules/ui_shared/tests/test_team_action_types.cpp b/modules/ui_shared/tests/test_team_action_types.cpp index bc45ffa4..2ea3e2ae 100644 --- a/modules/ui_shared/tests/test_team_action_types.cpp +++ b/modules/ui_shared/tests/test_team_action_types.cpp @@ -25,6 +25,12 @@ int main() assert(action.location.use_current_location); assert(location_snapshot.valid); + action.kind = ui::team_actions::TeamActionKind::LocationShare; + action.location_share.use_current_location = true; + action.location_share.label = "camp"; + assert(action.location_share.use_current_location); + assert(action.location_share.label != nullptr); + action.kind = ui::team_actions::TeamActionKind::Command; action.command.kind = ui::team_actions::TeamCommandKind::RallyPoint; action.command.payload = "rally"; diff --git a/modules/ui_shared/tests/test_team_chat_action_sink.cpp b/modules/ui_shared/tests/test_team_chat_action_sink.cpp index fda5b97d..f7e17880 100644 --- a/modules/ui_shared/tests/test_team_chat_action_sink.cpp +++ b/modules/ui_shared/tests/test_team_chat_action_sink.cpp @@ -1,4 +1,5 @@ -#include "platform/ui/team_ui_store_runtime.h" +#include "platform/ui/team_ui_chat_log_store.h" +#include "platform/ui/team_ui_snapshot_store.h" #include "ui/presentation_sources/team_chat_action_sink.h" #include @@ -112,12 +113,16 @@ ui::chat::SendMessageView sendView(ui::chat::ConversationId id, int main() { - team::ui::ITeamUiStore& store = team::ui::team_ui_get_store(); + team::ui::ITeamUiSnapshotStore& store = team::ui::team_ui_snapshot_store(); store.clear(); store.save(makeSnapshot()); FakeTeamChatCommandPort fake_port; - ui::presentation_sources::TeamChatActionSink sink(store, &fake_port, 7); + ui::presentation_sources::TeamChatActionSink sink( + store, + team::ui::team_ui_chat_log_store(), + &fake_port, + 7); assert(sink.selectConversation(teamConversation()).ok); const auto direct_select = sink.selectConversation(directConversation()); @@ -136,7 +141,10 @@ int main() assert(!empty_send.ok); assert(empty_send.failure == ui::UiActionFailure::InvalidInput); - ui::presentation_sources::TeamChatActionSink no_port_sink(store, nullptr); + ui::presentation_sources::TeamChatActionSink no_port_sink( + store, + team::ui::team_ui_chat_log_store(), + nullptr); const auto no_port_send = no_port_sink.sendMessage(sendView(teamConversation(), "no controller")); assert(!no_port_send.ok); @@ -163,7 +171,10 @@ int main() fake_port.last_message.payload.end()) == "team hello"); std::vector entries; - assert(team::ui::team_ui_chatlog_load_recent(testTeamId(), 4, entries)); + assert(team::ui::team_ui_chat_log_store().loadRecent( + testTeamId(), + 4, + entries)); assert(entries.size() == 1); assert(!entries[0].incoming); assert(entries[0].type == team::proto::TeamChatType::Text); diff --git a/modules/ui_shared/tests/test_team_chat_presentation_source.cpp b/modules/ui_shared/tests/test_team_chat_presentation_source.cpp index dcf9645d..79f59401 100644 --- a/modules/ui_shared/tests/test_team_chat_presentation_source.cpp +++ b/modules/ui_shared/tests/test_team_chat_presentation_source.cpp @@ -1,4 +1,5 @@ -#include "platform/ui/team_ui_store_runtime.h" +#include "platform/ui/team_ui_chat_log_store.h" +#include "platform/ui/team_ui_snapshot_store.h" #include "team/protocol/team_chat.h" #include "team/protocol/team_location_marker.h" #include "ui/presentation_sources/team_chat_presentation_source.h" @@ -58,13 +59,13 @@ bool contains(const ui::FixedText<160>& text, const char* needle) int main() { - team::ui::ITeamUiStore& store = team::ui::team_ui_get_store(); + team::ui::ITeamUiSnapshotStore& store = team::ui::team_ui_snapshot_store(); store.clear(); const team::ui::TeamUiSnapshot snap = makeSnapshot(); store.save(snap); - assert(team::ui::team_ui_chatlog_append( + assert(team::ui::team_ui_chat_log_store().appendText( snap.team_id, 0x12345678, true, 100, "hello team")); team::proto::TeamChatLocation loc; @@ -73,7 +74,7 @@ int main() loc.source = static_cast(team::proto::TeamLocationMarkerIcon::Rally); std::vector location_payload; assert(team::proto::encodeTeamChatLocation(loc, location_payload)); - assert(team::ui::team_ui_chatlog_append_structured( + assert(team::ui::team_ui_chat_log_store().appendStructured( snap.team_id, 0, false, @@ -86,7 +87,7 @@ int main() command.note = "wait"; std::vector command_payload; assert(team::proto::encodeTeamChatCommand(command, command_payload)); - assert(team::ui::team_ui_chatlog_append_structured( + assert(team::ui::team_ui_chat_log_store().appendStructured( snap.team_id, 0x12345678, true, @@ -94,7 +95,9 @@ int main() team::proto::TeamChatType::Command, command_payload)); - ui::presentation_sources::TeamChatPresentationSource source(store); + ui::presentation_sources::TeamChatPresentationSource source( + store, + team::ui::team_ui_chat_log_store()); ui::chat::ChatWorkspaceSnapshot overview = buildSnapshot(source); assert(overview.conversation_count == 1); @@ -123,6 +126,11 @@ int main() assert(selected.messages[0].sender_node_id == 0x12345678); assert(std::strcmp(selected.messages[0].sender_label.c_str(), "Ada") == 0); assert(std::strcmp(selected.messages[0].text.c_str(), "hello team") == 0); + assert(selected.messages[0].has_team_rich_payload); + assert(selected.messages[0].team_rich_payload.kind == + ui::chat::TeamMessageRichPayloadKind::Text); + assert(std::strcmp(selected.messages[0].team_rich_payload.summary.c_str(), + "hello team") == 0); assert(selected.messages[1].outgoing); assert(selected.messages[1].sender_node_id == 0); @@ -132,10 +140,22 @@ int main() ui::chat::MessageOrigin::LocalStored); assert(contains(selected.messages[1].text, "Rally")); assert(contains(selected.messages[1].text, "31.23457")); + assert(selected.messages[1].has_team_rich_payload); + assert(selected.messages[1].team_rich_payload.kind == + ui::chat::TeamMessageRichPayloadKind::Location); + assert(std::strcmp(selected.messages[1].team_rich_payload.badge.c_str(), + "Marker") == 0); + assert(selected.messages[1].team_rich_payload.location.marker_icon == + static_cast(team::proto::TeamLocationMarkerIcon::Rally)); assert(!selected.messages[2].outgoing); assert(contains(selected.messages[2].text, "Command: Hold")); assert(contains(selected.messages[2].text, "wait")); + assert(selected.messages[2].has_team_rich_payload); + assert(selected.messages[2].team_rich_payload.kind == + ui::chat::TeamMessageRichPayloadKind::Command); + assert(selected.messages[2].team_rich_payload.command.kind == + ui::chat::TeamMessageCommandKind::Hold); store.clear(); return 0; diff --git a/modules/ui_shared/tests/test_team_map_overlay_source.cpp b/modules/ui_shared/tests/test_team_map_overlay_source.cpp new file mode 100644 index 00000000..e76f10a8 --- /dev/null +++ b/modules/ui_shared/tests/test_team_map_overlay_source.cpp @@ -0,0 +1,96 @@ +#include "ui/presentation_sources/team_map_overlay_source.h" + +#include +#include + +namespace +{ + +team::TeamId testTeamId() +{ + team::TeamId id{}; + id[0] = 0x54; + id[1] = 0x4D; + return id; +} + +team::ui::TeamUiSnapshot makeSnapshot() +{ + team::ui::TeamUiSnapshot snapshot; + snapshot.in_team = true; + snapshot.has_team_id = true; + snapshot.team_id = testTeamId(); + + team::ui::TeamMemberUi member; + member.node_id = 0xCAFE; + member.name = "Ada"; + member.color_index = 2; + snapshot.members.push_back(member); + return snapshot; +} + +void resetStores() +{ + team::ui::team_ui_snapshot_store().clear(); +} + +} // namespace + +int main() +{ + resetStores(); + + ui::presentation_sources::TeamMapOverlaySource source( + team::ui::team_ui_snapshot_store()); + + ui::map_overlay::IMapOverlayTeamSource::TeamPoint points[4]{}; + assert(source.latestTeamPoints(points, 4) == 0); + + const team::ui::TeamUiSnapshot snapshot = makeSnapshot(); + team::ui::team_ui_snapshot_store().save(snapshot); + assert(team::ui::team_ui_posring_append(snapshot.team_id, + 0xCAFE, + 312345678, + 1219876543, + 42, + 0, + 100)); + assert(team::ui::team_ui_posring_append(snapshot.team_id, + 0xBEEF, + 0, + 0, + 0, + 0, + 101)); + + const std::size_t count = source.latestTeamPoints(points, 4); + assert(count == 2); + assert(points[0].node_id == 0xCAFE); + assert(std::strcmp(points[0].label, "Ada") == 0); + assert(points[0].valid); + assert(points[1].node_id == 0xBEEF); + assert(!points[1].valid); + + ui::presentation_sources::TeamMapLocation locations[4]{}; + const std::size_t location_count = source.latestTeamLocations(locations, 4); + assert(location_count == 2); + assert(locations[0].member_id == 0xCAFE); + assert(locations[0].valid); + assert(locations[1].member_id == 0xBEEF); + assert(!locations[1].valid); + + ui::presentation_sources::TeamMapLocation location; + assert(source.loadMemberLocation(0xCAFE, location)); + assert(location.member_id == 0xCAFE); + assert(location.lat_e7 == 312345678); + assert(location.lon_e7 == 1219876543); + assert(location.alt_m == 42); + assert(location.ts == 100); + assert(location.color == team::ui::team_color_from_index(2)); + + assert(!source.loadMemberLocation(0xBEEF, location)); + assert(!source.loadMemberLocation(0x1234, location)); + + resetStores(); + return 0; +} diff --git a/modules/ui_shared/tests/test_team_page_activity_sink.cpp b/modules/ui_shared/tests/test_team_page_activity_sink.cpp new file mode 100644 index 00000000..2688a107 --- /dev/null +++ b/modules/ui_shared/tests/test_team_page_activity_sink.cpp @@ -0,0 +1,396 @@ +#include "ui/screens/team/team_page_activity_sink.h" + +#include "team/protocol/team_position.h" + +#include + +namespace +{ + +struct PositionSample +{ + team::TeamId team_id{}; + 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; +}; + +struct ChatSample +{ + team::TeamId team_id{}; + uint32_t peer_id = 0; + bool incoming = false; + uint32_t ts = 0; + team::proto::TeamChatType type = team::proto::TeamChatType::Text; + std::vector payload; +}; + +class FakePositionLog final : public team::ui::ITeamPagePositionLog +{ + public: + bool appendPosition(const team::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) override + { + positions.push_back({team_id, + member_id, + lat_e7, + lon_e7, + alt_m, + speed_dmps, + ts}); + return true; + } + + bool appendMemberTrack( + const team::TeamId& team_id, + uint32_t member_id, + const team::proto::TeamTrackMessage& track) override + { + track_team_id = team_id; + track_member_id = member_id; + last_track = track; + append_track_count += 1; + return true; + } + + bool memberTrackPath(const team::TeamId&, + uint32_t, + std::string& out_path) override + { + path_requested_count += 1; + if (!path_ok) + { + return false; + } + out_path = track_path; + return true; + } + + std::vector positions; + int append_track_count = 0; + int path_requested_count = 0; + bool path_ok = true; + std::string track_path = "/tmp/member.trk"; + team::TeamId track_team_id{}; + uint32_t track_member_id = 0; + team::proto::TeamTrackMessage last_track; +}; + +class FakeChatLog final : public team::ui::ITeamPageChatLog +{ + public: + bool appendStructuredChat(const team::TeamId& team_id, + uint32_t peer_id, + bool incoming, + uint32_t ts, + team::proto::TeamChatType type, + const std::vector& payload) override + { + chats.push_back({team_id, peer_id, incoming, ts, type, payload}); + return true; + } + + std::vector chats; +}; + +class FakeGpsLoader final : public team::ui::ITeamPageGpsTrackLoader +{ + public: + uint32_t selectedMemberId() const override { return selected_member_id; } + + bool loadTrackFile(const char* path, bool show_toast) override + { + load_count += 1; + loaded_path = path ? path : ""; + last_show_toast = show_toast; + return load_ok; + } + + uint32_t selected_member_id = 0xFFFFFFFFu; + bool load_ok = true; + int load_count = 0; + std::string loaded_path; + bool last_show_toast = true; +}; + +class FakeUnreadPublisher final : public team::ui::ITeamPageUnreadPublisher +{ + public: + void publishTeamUnread(uint32_t unread_count) override + { + published.push_back(unread_count); + } + + std::vector published; +}; + +team::TeamId testTeamId() +{ + team::TeamId id{}; + id[0] = 0x44; + id[1] = 0x55; + return id; +} + +team::TeamEventContext eventContext(uint32_t from, uint32_t timestamp) +{ + team::TeamEventContext context; + context.team_id = testTeamId(); + context.key_id = 7; + context.from = from; + context.timestamp = timestamp; + return context; +} + +team::ui::TeamPageActivityState activityState(uint32_t unread = 0) +{ + team::ui::TeamPageActivityState state; + state.team_id = testTeamId(); + state.has_team_id = true; + state.team_chat_unread = unread; + return state; +} + +team::ui::TeamPageActivitySink makeSink(FakePositionLog& positions, + FakeChatLog& chats, + FakeGpsLoader& gps, + FakeUnreadPublisher& unread, + bool chat_visible = false) +{ + team::ui::TeamPageActivityContext context; + context.now_s = 1000; + context.self_node_id = 0x11111111; + context.team_chat_visible = chat_visible; + return team::ui::TeamPageActivitySink(positions, + chats, + gps, + unread, + context); +} + +std::vector encodePosition(int32_t lat_e7, + int32_t lon_e7, + uint32_t ts) +{ + team::proto::TeamPositionMessage position; + position.flags = team::proto::kTeamPosHasAltitude | + team::proto::kTeamPosHasSpeed; + position.lat_e7 = lat_e7; + position.lon_e7 = lon_e7; + position.alt_m = 321; + position.speed_dmps = 42; + position.ts = ts; + std::vector payload; + assert(team::proto::encodeTeamPositionMessage(position, payload)); + return payload; +} + +std::vector encodeTrack() +{ + team::proto::TeamTrackMessage track; + track.start_ts = 0; + track.interval_s = 10; + track.valid_mask = 0b101u; + track.points.push_back({100000001, 200000001}); + track.points.push_back({100000002, 200000002}); + track.points.push_back({100000003, 200000003}); + std::vector payload; + assert(team::proto::encodeTeamTrackMessage(track, payload)); + return payload; +} + +std::vector encodeLocationPayload(uint32_t ts) +{ + team::proto::TeamChatLocation location; + location.lat_e7 = 333000000; + location.lon_e7 = 444000000; + location.alt_m = 66; + location.ts = ts; + location.label = "ridge"; + std::vector payload; + assert(team::proto::encodeTeamChatLocation(location, payload)); + return payload; +} + +void testPositionPayloadAppendsSample() +{ + FakePositionLog positions; + FakeChatLog chats; + FakeGpsLoader gps; + FakeUnreadPublisher unread; + auto sink = makeSink(positions, chats, gps, unread); + auto state = activityState(); + + team::TeamPositionEvent event; + event.ctx = eventContext(0x22222222, 0); + event.payload = encodePosition(123456789, 987654321, 0); + + sink.consumePosition(state, event); + + assert(positions.positions.size() == 1); + assert(positions.positions[0].team_id == testTeamId()); + assert(positions.positions[0].member_id == 0x22222222); + assert(positions.positions[0].lat_e7 == 123456789); + assert(positions.positions[0].lon_e7 == 987654321); + assert(positions.positions[0].alt_m == 321); + assert(positions.positions[0].speed_dmps == 42); + assert(positions.positions[0].ts == 1000); +} + +void testWaypointWithLocationAppendsSample() +{ + FakePositionLog positions; + FakeChatLog chats; + FakeGpsLoader gps; + FakeUnreadPublisher unread; + auto sink = makeSink(positions, chats, gps, unread); + auto state = activityState(); + + team::TeamWaypointEvent event; + event.ctx = eventContext(0x22222222, 777); + event.msg.flags = team::proto::kTeamWaypointHasLocation; + event.msg.lat_e7 = 111; + event.msg.lon_e7 = 222; + + sink.consumeWaypoint(state, event); + + assert(positions.positions.size() == 1); + assert(positions.positions[0].lat_e7 == 111); + assert(positions.positions[0].lon_e7 == 222); + assert(positions.positions[0].ts == 777); +} + +void testTrackAppendsValidPointsAndReloadsSelectedMember() +{ + FakePositionLog positions; + FakeChatLog chats; + FakeGpsLoader gps; + FakeUnreadPublisher unread; + gps.selected_member_id = 0x22222222; + auto sink = makeSink(positions, chats, gps, unread); + auto state = activityState(); + + team::TeamTrackEvent event; + event.ctx = eventContext(0x22222222, 500); + event.payload = encodeTrack(); + + sink.consumeTrack(state, event); + + assert(positions.positions.size() == 2); + assert(positions.positions[0].lat_e7 == 100000001); + assert(positions.positions[0].ts == 500); + assert(positions.positions[1].lat_e7 == 100000003); + assert(positions.positions[1].ts == 520); + assert(positions.append_track_count == 1); + assert(positions.track_member_id == 0x22222222); + assert(positions.path_requested_count == 1); + assert(gps.load_count == 1); + assert(gps.loaded_path == "/tmp/member.trk"); + assert(!gps.last_show_toast); + + gps.selected_member_id = 0x33333333; + event.ctx.from = 0x44444444; + sink.consumeTrack(state, event); + assert(gps.load_count == 1); +} + +void testChatAppendsLogAndUpdatesUnread() +{ + FakePositionLog positions; + FakeChatLog chats; + FakeGpsLoader gps; + FakeUnreadPublisher unread; + auto sink = makeSink(positions, chats, gps, unread, false); + auto state = activityState(4); + + team::TeamChatEvent event; + event.ctx = eventContext(0x22222222, 900); + event.msg.header.from = 0x22222222; + event.msg.header.type = team::proto::TeamChatType::Text; + event.msg.payload = {1, 2, 3}; + + sink.consumeChat(state, event); + + assert(chats.chats.size() == 1); + assert(chats.chats[0].peer_id == 0x22222222); + assert(chats.chats[0].incoming); + assert(chats.chats[0].ts == 900); + assert(state.team_chat_unread == 5); + assert(unread.published.size() == 1); + assert(unread.published[0] == 5); + + event.msg.header.from = 0x11111111; + sink.consumeChat(state, event); + assert(state.team_chat_unread == 5); + assert(unread.published.size() == 1); +} + +void testVisibleChatResetsUnread() +{ + FakePositionLog positions; + FakeChatLog chats; + FakeGpsLoader gps; + FakeUnreadPublisher unread; + auto sink = makeSink(positions, chats, gps, unread, true); + auto state = activityState(3); + + team::TeamChatEvent event; + event.ctx = eventContext(0x22222222, 901); + event.msg.header.from = 0x22222222; + event.msg.header.type = team::proto::TeamChatType::Text; + + sink.consumeChat(state, event); + + assert(state.team_chat_unread == 0); + assert(unread.published.size() == 1); + assert(unread.published[0] == 0); +} + +void testLocationChatAppendsPositionSample() +{ + FakePositionLog positions; + FakeChatLog chats; + FakeGpsLoader gps; + FakeUnreadPublisher unread; + auto sink = makeSink(positions, chats, gps, unread); + auto state = activityState(); + + team::TeamChatEvent event; + event.ctx = eventContext(0x22222222, 600); + event.msg.header.from = 0; + event.msg.header.type = team::proto::TeamChatType::Location; + event.msg.header.ts = 601; + event.msg.payload = encodeLocationPayload(0); + + sink.consumeChat(state, event); + + assert(chats.chats.size() == 1); + assert(chats.chats[0].peer_id == 0x22222222); + assert(positions.positions.size() == 1); + assert(positions.positions[0].member_id == 0x22222222); + assert(positions.positions[0].lat_e7 == 333000000); + assert(positions.positions[0].lon_e7 == 444000000); + assert(positions.positions[0].alt_m == 66); + assert(positions.positions[0].ts == 601); +} + +} // namespace + +int main() +{ + testPositionPayloadAppendsSample(); + testWaypointWithLocationAppendsSample(); + testTrackAppendsValidPointsAndReloadsSelectedMember(); + testChatAppendsLogAndUpdatesUnread(); + testVisibleChatResetsUnread(); + testLocationChatAppendsPositionSample(); + return 0; +} diff --git a/modules/ui_shared/tests/test_team_page_command_reducer.cpp b/modules/ui_shared/tests/test_team_page_command_reducer.cpp new file mode 100644 index 00000000..7a526571 --- /dev/null +++ b/modules/ui_shared/tests/test_team_page_command_reducer.cpp @@ -0,0 +1,247 @@ +#include "ui/screens/team/team_page_command_reducer.h" + +#include + +namespace +{ + +team::TeamId testTeamId() +{ + team::TeamId id{}; + id[0] = 0xCA; + id[1] = 0xFE; + return id; +} + +std::array testPsk(uint8_t seed) +{ + std::array psk{}; + for (size_t i = 0; i < psk.size(); ++i) + { + psk[i] = static_cast(seed + i); + } + return psk; +} + +team::ui::TeamPageCommandReducer makeReducer() +{ + team::ui::TeamPageCommandContext context; + context.now_s = 1234; + context.self_node_id = 0x11111111; + return team::ui::TeamPageCommandReducer(context); +} + +team::ui::TeamMemberUi makeMember(uint32_t node_id, + const char* name, + bool leader = false) +{ + team::ui::TeamMemberUi member; + member.node_id = node_id; + member.name = name; + member.leader = leader; + member.last_seen_s = 900; + return member; +} + +team::ui::TeamPageCommandState makeInTeamState() +{ + team::ui::TeamPageCommandState state; + state.in_team = true; + state.self_is_leader = true; + state.has_team_id = true; + state.team_id = testTeamId(); + state.team_name = "Trail"; + state.security_round = 7; + state.has_team_psk = true; + state.team_psk = testPsk(0x20); + state.waiting_new_keys = true; + state.last_event_seq = 8; + state.members.push_back(makeMember(0, "You", true)); + state.members.push_back(makeMember(0x22222222, "Ada")); + state.members.push_back(makeMember(0x33333333, "Ben")); + return state; +} + +void testCreateInitializesLocalLeaderState() +{ + auto reducer = makeReducer(); + team::ui::TeamPageCommandState state; + team::ui::TeamPageGeneratedTeamSecrets generated; + generated.team_id = testTeamId(); + generated.team_psk = testPsk(0x10); + + const auto effects = reducer.reduceCreate(state, generated); + + assert(effects.accepted); + assert(effects.changed); + assert(effects.team_created_key_event); + assert(effects.keys_changed); + assert(state.in_team); + assert(!state.pending_join); + assert(!state.kicked_out); + assert(state.self_is_leader); + assert(state.has_team_id); + assert(state.team_name == "TEAM-CAFE"); + assert(state.security_round == 1); + assert(state.has_team_psk); + assert(state.team_psk[0] == 0x10); + assert(state.last_update_s == 1234); + assert(state.members.size() == 1); + assert(state.members[0].node_id == 0); + assert(state.members[0].name == "You"); + assert(state.members[0].leader); + assert(state.members[0].color_index == + team::ui::team_color_index_from_node_id(0x11111111)); +} + +void testLeaveClearsLocalMembershipAndRequestsSideEffects() +{ + auto reducer = makeReducer(); + auto state = makeInTeamState(); + state.pending_join = true; + state.pairing_role = team::TeamPairingRole::Leader; + state.pairing_state = team::TeamPairingState::LeaderBeacon; + + const auto effects = reducer.reduceLeave(state); + + assert(effects.accepted); + assert(effects.clear_keys); + assert(effects.stop_pairing); + assert(effects.clear_keydist_pending); + assert(effects.clear_status_pending); + assert(!state.in_team); + assert(!state.pending_join); + assert(!state.kicked_out); + assert(!state.has_team_id); + assert(!state.has_team_psk); + assert(state.security_round == 0); + assert(state.last_event_seq == 0); + assert(state.members.empty()); + assert(state.pairing_role == team::TeamPairingRole::None); + assert(state.pairing_state == team::TeamPairingState::Idle); +} + +void testKickedOutKeepsKickedFlag() +{ + auto reducer = makeReducer(); + auto state = makeInTeamState(); + + const auto effects = reducer.reduceKickedOut(state); + + assert(effects.accepted); + assert(effects.clear_keys); + assert(effects.stop_pairing); + assert(state.kicked_out); + assert(!state.in_team); + assert(state.members.empty()); +} + +void testPairingStartedRecordsPendingJoin() +{ + auto reducer = makeReducer(); + team::ui::TeamPageCommandState state; + + const auto effects = reducer.reducePairingStarted( + state, + team::TeamPairingRole::Member, + team::TeamPairingState::MemberScanning); + + assert(effects.accepted); + assert(state.pending_join); + assert(state.pending_join_started_s == 1234); + assert(state.pairing_role == team::TeamPairingRole::Member); + assert(state.pairing_state == team::TeamPairingState::MemberScanning); +} + +void testJoinCancelStopsTransientPairingOnly() +{ + auto reducer = makeReducer(); + auto state = makeInTeamState(); + state.pending_join = true; + state.pending_join_started_s = 22; + state.pairing_role = team::TeamPairingRole::Member; + state.pairing_state = team::TeamPairingState::WaitingKey; + + const auto effects = reducer.reduceJoinCanceled(state); + + assert(effects.accepted); + assert(effects.reset_controller_ui); + assert(effects.stop_pairing); + assert(state.in_team); + assert(!state.pending_join); + assert(state.pending_join_started_s == 0); + assert(state.pairing_role == team::TeamPairingRole::None); + assert(state.pairing_state == team::TeamPairingState::Idle); + assert(state.has_team_id); +} + +void testKickConfirmedRotatesKeysAndRemovesMember() +{ + auto reducer = makeReducer(); + auto state = makeInTeamState(); + state.selected_member_index = 1; + team::ui::TeamPageKickRotation rotation; + rotation.rotate_keys = true; + rotation.key_id = 9; + rotation.team_psk = testPsk(0x40); + + const auto effects = + reducer.reduceKickConfirmed(state, 0x22222222, rotation); + + assert(effects.accepted); + assert(effects.keys_changed); + assert(effects.status_should_send); + assert(effects.member_kicked); + assert(effects.member_kicked_id == 0x22222222); + assert(state.security_round == 9); + assert(state.team_psk[0] == 0x40); + assert(!state.waiting_new_keys); + assert(state.selected_member_index == -1); + assert(state.members.size() == 2); + assert(state.members[1].node_id == 0x33333333); +} + +void testKickMissingMemberIsIgnored() +{ + auto reducer = makeReducer(); + auto state = makeInTeamState(); + + const auto effects = + reducer.reduceKickConfirmed(state, 0x99999999, {}); + + assert(!effects.accepted); + assert(state.members.size() == 3); + assert(state.security_round == 7); +} + +void testTransferLeaderUpdatesLocalLeader() +{ + auto reducer = makeReducer(); + auto state = makeInTeamState(); + + const auto effects = + reducer.reduceTransferLeader(state, 0x33333333); + + assert(effects.accepted); + assert(effects.leader_transferred_key_event); + assert(effects.leader_target == 0x33333333); + assert(!state.self_is_leader); + assert(!state.members[0].leader); + assert(!state.members[1].leader); + assert(state.members[2].leader); +} + +} // namespace + +int main() +{ + testCreateInitializesLocalLeaderState(); + testLeaveClearsLocalMembershipAndRequestsSideEffects(); + testKickedOutKeepsKickedFlag(); + testPairingStartedRecordsPendingJoin(); + testJoinCancelStopsTransientPairingOnly(); + testKickConfirmedRotatesKeysAndRemovesMember(); + testKickMissingMemberIsIgnored(); + testTransferLeaderUpdatesLocalLeader(); + return 0; +} diff --git a/modules/ui_shared/tests/test_team_page_create_team_action.cpp b/modules/ui_shared/tests/test_team_page_create_team_action.cpp new file mode 100644 index 00000000..91fad807 --- /dev/null +++ b/modules/ui_shared/tests/test_team_page_create_team_action.cpp @@ -0,0 +1,400 @@ +#include "ui/screens/team/team_page_create_team_action.h" + +#include +#include +#include + +namespace +{ + +team::TeamId testTeamId() +{ + team::TeamId id{}; + id[0] = 0xCA; + id[1] = 0xFE; + return id; +} + +std::array testPsk(uint8_t seed) +{ + std::array psk{}; + for (size_t i = 0; i < psk.size(); ++i) + { + psk[i] = static_cast(seed + i); + } + return psk; +} + +team::ui::TeamPageCommandReducer makeReducer() +{ + team::ui::TeamPageCommandContext context; + context.now_s = 1234; + context.self_node_id = 0x11111111; + return team::ui::TeamPageCommandReducer(context); +} + +team::ui::TeamPageKeyEventState makeKeyEventState() +{ + team::ui::TeamPageKeyEventState state; + state.last_event_seq = 10; + return state; +} + +class FakeRandom final : public team::ui::ITeamPageCreateTeamRandom +{ + public: + uint8_t nextByte() override + { + const uint8_t out = next; + next = static_cast(next + 1); + return out; + } + + uint8_t next = 0x20; +}; + +class FakeKeyEventWriter final : public team::ui::ITeamPageKeyEventWriter +{ + public: + bool appendKeyEvent(const team::TeamId& team_id, + team::ui::TeamKeyEventType type, + uint32_t event_seq, + uint32_t, + const uint8_t* payload, + size_t payload_size) override + { + append_count += 1; + last_team_id = team_id; + last_type = type; + last_event_seq = event_seq; + last_payload.assign(payload, payload + payload_size); + return append_ok; + } + + bool append_ok = true; + int append_count = 0; + team::TeamId last_team_id{}; + team::ui::TeamKeyEventType last_type = + team::ui::TeamKeyEventType::TeamCreated; + uint32_t last_event_seq = 0; + std::vector last_payload; +}; + +class FakeController final : public team::ui::ITeamPageControllerPort +{ + public: + void clearKeys() override {} + void resetUiState() override {} + + bool setKeysFromPsk(const team::TeamId& team_id, + uint32_t key_id, + const uint8_t* psk, + size_t psk_len) override + { + set_keys_count += 1; + last_set_keys_team_id = team_id; + last_set_keys_key_id = key_id; + last_set_keys_len = psk_len; + last_set_keys_psk0 = psk ? psk[0] : 0; + return set_keys_ok; + } + + bool sendKick(const team::proto::TeamKick&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendTransferLeader(const team::proto::TeamTransferLeader&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendKeyDist(const team::proto::TeamKeyDist&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendKeyDistPlain(const team::proto::TeamKeyDist&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendKeyRequest(const team::proto::TeamKeyRequest&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendStatus(const team::proto::TeamStatus&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendStatusPlain(const team::proto::TeamStatus&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + team::TeamService::SendError lastSendError() const override + { + return error; + } + + bool set_keys_ok = true; + team::TeamService::SendError error = + team::TeamService::SendError::None; + int set_keys_count = 0; + team::TeamId last_set_keys_team_id{}; + uint32_t last_set_keys_key_id = 0; + size_t last_set_keys_len = 0; + uint8_t last_set_keys_psk0 = 0; +}; + +class FakePairing final : public team::ui::ITeamPagePairingPort +{ + public: + bool startLeader(const team::TeamId& team_id, + uint32_t key_id, + const uint8_t* psk, + size_t psk_len, + uint32_t leader_id, + const char* team_name) override + { + start_leader_count += 1; + last_team_id = team_id; + last_key_id = key_id; + last_psk_len = psk_len; + last_psk0 = psk ? psk[0] : 0; + last_leader_id = leader_id; + last_team_name = team_name ? team_name : ""; + return start_ok; + } + + bool startMember(uint32_t) override { return true; } + void stop() override {} + team::TeamPairingStatus status() const override { return {}; } + + bool start_ok = true; + int start_leader_count = 0; + team::TeamId last_team_id{}; + uint32_t last_key_id = 0; + size_t last_psk_len = 0; + uint8_t last_psk0 = 0; + uint32_t last_leader_id = 0; + std::string last_team_name; +}; + +class FakeKeyStore final : public team::ui::ITeamPageKeyStorePort +{ + public: + bool saveKeysNow( + const team::TeamId& team_id, + uint32_t key_id, + const std::array& psk) override + { + save_count += 1; + last_team_id = team_id; + last_key_id = key_id; + last_psk0 = psk[0]; + return save_ok; + } + + bool save_ok = true; + int save_count = 0; + team::TeamId last_team_id{}; + uint32_t last_key_id = 0; + uint8_t last_psk0 = 0; +}; + +void testCreateGeneratesSecretsAppliesKeysAndStartsLeaderPairing() +{ + team::ui::TeamPageCommandState state; + auto key_event_state = makeKeyEventState(); + FakeController controller; + FakePairing pairing; + FakeKeyStore key_store; + team::ui::TeamPageRuntimePort runtime(&controller, &pairing, &key_store); + FakeRandom random; + FakeKeyEventWriter writer; + team::ui::TeamPageKeyEventLog log(writer, 777); + + const auto effects = team::ui::TeamPageCreateTeamAction().createTeam( + state, + key_event_state, + makeReducer(), + runtime, + log, + random, + 0x11111111); + + assert(effects.accepted); + assert(effects.appended_key_event); + assert(effects.applied_keys); + assert(effects.saved_keys); + assert(effects.started_pairing); + assert(effects.failures.empty()); + assert(state.in_team); + assert(state.pending_join); + assert(state.self_is_leader); + assert(state.has_team_id); + assert(state.team_id[0] == 0x20); + assert(state.team_id[1] == 0x21); + assert(state.security_round == 1); + assert(state.has_team_psk); + assert(state.team_psk[0] == 0x28); + assert(state.team_name == "TEAM-2021"); + assert(state.pairing_role == team::TeamPairingRole::Leader); + assert(state.pairing_state == team::TeamPairingState::LeaderBeacon); + assert(state.members.size() == 1); + assert(state.members[0].node_id == 0); + assert(state.members[0].leader); + + assert(controller.set_keys_count == 1); + assert(controller.last_set_keys_team_id == state.team_id); + assert(controller.last_set_keys_key_id == 1); + assert(controller.last_set_keys_psk0 == 0x28); + assert(controller.last_set_keys_len == team::proto::kTeamChannelPskSize); + assert(key_store.save_count == 1); + assert(key_store.last_team_id == state.team_id); + assert(key_store.last_key_id == 1); + assert(key_store.last_psk0 == 0x28); + assert(pairing.start_leader_count == 1); + assert(pairing.last_team_id == state.team_id); + assert(pairing.last_key_id == 1); + assert(pairing.last_psk0 == 0x28); + assert(pairing.last_psk_len == team::proto::kTeamChannelPskSize); + assert(pairing.last_leader_id == 0x11111111); + assert(pairing.last_team_name == "TEAM-2021"); + assert(writer.append_count == 1); + assert(writer.last_type == team::ui::TeamKeyEventType::TeamCreated); + assert(writer.last_event_seq == 11); + assert(key_event_state.last_event_seq == 11); +} + +void testCreateReusesExistingIdentityAndPsk() +{ + team::ui::TeamPageCommandState state; + state.has_team_id = true; + state.team_id = testTeamId(); + state.team_name = "Existing"; + state.security_round = 4; + state.has_team_psk = true; + state.team_psk = testPsk(0xA0); + auto key_event_state = makeKeyEventState(); + FakeController controller; + FakePairing pairing; + FakeKeyStore key_store; + team::ui::TeamPageRuntimePort runtime(&controller, &pairing, &key_store); + FakeRandom random; + FakeKeyEventWriter writer; + team::ui::TeamPageKeyEventLog log(writer, 777); + + const auto effects = team::ui::TeamPageCreateTeamAction().createTeam( + state, + key_event_state, + makeReducer(), + runtime, + log, + random, + 0x11111111); + + assert(effects.accepted); + assert(state.team_id == testTeamId()); + assert(state.team_name == "Existing"); + assert(state.security_round == 4); + assert(state.team_psk[0] == 0xA0); + assert(pairing.last_team_name == "Existing"); + assert(random.next == 0x20); +} + +void testMissingPairingReturnsPairingNotReadyAfterLocalCreate() +{ + team::ui::TeamPageCommandState state; + auto key_event_state = makeKeyEventState(); + FakeController controller; + FakeKeyStore key_store; + team::ui::TeamPageRuntimePort runtime(&controller, nullptr, &key_store); + FakeRandom random; + FakeKeyEventWriter writer; + team::ui::TeamPageKeyEventLog log(writer, 777); + + const auto effects = team::ui::TeamPageCreateTeamAction().createTeam( + state, + key_event_state, + makeReducer(), + runtime, + log, + random, + 0x11111111); + + assert(effects.accepted); + assert(!effects.started_pairing); + assert(effects.failures.size() == 1); + assert(effects.failures[0].action == + team::ui::TeamPageCreateTeamFailureAction::Pairing); + assert(effects.failures[0].kind == + team::ui::TeamPageCreateTeamFailureKind::PairingNotReady); + assert(state.in_team); + assert(!state.pending_join); +} + +void testSetKeysAndPairingInitFailuresAreReported() +{ + team::ui::TeamPageCommandState state; + auto key_event_state = makeKeyEventState(); + FakeController controller; + controller.set_keys_ok = false; + FakePairing pairing; + pairing.start_ok = false; + FakeKeyStore key_store; + team::ui::TeamPageRuntimePort runtime(&controller, &pairing, &key_store); + FakeRandom random; + FakeKeyEventWriter writer; + team::ui::TeamPageKeyEventLog log(writer, 777); + + const auto effects = team::ui::TeamPageCreateTeamAction().createTeam( + state, + key_event_state, + makeReducer(), + runtime, + log, + random, + 0x11111111); + + assert(effects.accepted); + assert(!effects.started_pairing); + assert(effects.failures.size() == 2); + assert(effects.failures[0].action == + team::ui::TeamPageCreateTeamFailureAction::Keys); + assert(effects.failures[0].kind == + team::ui::TeamPageCreateTeamFailureKind::SendFailed); + assert(effects.failures[0].needs_keys); + assert(effects.failures[1].action == + team::ui::TeamPageCreateTeamFailureAction::Pairing); + assert(effects.failures[1].kind == + team::ui::TeamPageCreateTeamFailureKind::PairingInitFailed); +} + +} // namespace + +int main() +{ + testCreateGeneratesSecretsAppliesKeysAndStartsLeaderPairing(); + testCreateReusesExistingIdentityAndPsk(); + testMissingPairingReturnsPairingNotReadyAfterLocalCreate(); + testSetKeysAndPairingInitFailuresAreReported(); + return 0; +} diff --git a/modules/ui_shared/tests/test_team_page_deferred_dispatch.cpp b/modules/ui_shared/tests/test_team_page_deferred_dispatch.cpp new file mode 100644 index 00000000..cd6b8531 --- /dev/null +++ b/modules/ui_shared/tests/test_team_page_deferred_dispatch.cpp @@ -0,0 +1,243 @@ +#include "ui/screens/team/team_page_deferred_dispatch.h" + +#include + +namespace +{ + +class FakeDispatchPort final + : public team::ui::ITeamPageDeferredDispatchPort +{ + public: + bool hasController() const override { return has_controller; } + + bool sendKeyDistPlain(const team::proto::TeamKeyDist& key_dist, + chat::ChannelId, + chat::NodeId dest) override + { + keydist_send_count += 1; + last_keydist = key_dist; + last_keydist_dest = dest; + return keydist_ok; + } + + bool sendStatus(const team::proto::TeamStatus& status, + chat::ChannelId, + chat::NodeId dest) override + { + status_send_count += 1; + last_status = status; + last_status_dest = dest; + return status_ok; + } + + bool sendStatusPlain(const team::proto::TeamStatus& status, + chat::ChannelId, + chat::NodeId dest) override + { + status_plain_send_count += 1; + last_status = status; + last_status_plain_dest = dest; + return status_plain_ok; + } + + team::TeamService::SendError lastSendError() const override + { + return error; + } + + bool has_controller = true; + bool keydist_ok = true; + bool status_ok = true; + bool status_plain_ok = true; + team::TeamService::SendError error = + team::TeamService::SendError::None; + int keydist_send_count = 0; + int status_send_count = 0; + int status_plain_send_count = 0; + chat::NodeId last_keydist_dest = 0; + chat::NodeId last_status_dest = 1; + chat::NodeId last_status_plain_dest = 1; + team::proto::TeamKeyDist last_keydist; + team::proto::TeamStatus last_status; +}; + +team::TeamId testTeamId() +{ + team::TeamId id{}; + id[0] = 0xCA; + id[1] = 0xFE; + return id; +} + +team::ui::TeamPageDeferredDispatchState readyState() +{ + team::ui::TeamPageDeferredDispatchState state; + state.in_team = true; + state.has_team_id = true; + state.self_is_leader = true; + state.team_id = testTeamId(); + state.security_round = 12; + state.has_team_psk = true; + state.team_psk[0] = 0x42; + return state; +} + +team::ui::TeamPageDeferredDispatchQueue makeQueue() +{ + team::ui::TeamPageDeferredDispatchConfig config; + config.keydist_max_retries = 2; + config.keydist_retry_interval_s = 5; + config.status_rebroadcast_interval_s = 2; + return team::ui::TeamPageDeferredDispatchQueue(config); +} + +void testKeyDistRetriesRespectDelayAndDeduplicate() +{ + auto queue = makeQueue(); + FakeDispatchPort port; + auto state = readyState(); + + queue.enqueueKeyDist(0x22222222, 13, 100); + queue.enqueueKeyDist(0x22222222, 13, 100); + assert(queue.keyDistPendingCount() == 1); + + auto effects = queue.processKeyDistRetries(state, port, 104); + assert(!effects.sent_keydist); + assert(port.keydist_send_count == 0); + + effects = queue.processKeyDistRetries(state, port, 105); + assert(effects.sent_keydist); + assert(port.keydist_send_count == 1); + assert(port.last_keydist_dest == 0x22222222); + assert(port.last_keydist.team_id == testTeamId()); + assert(port.last_keydist.key_id == 13); + assert(port.last_keydist.channel_psk_len == + team::proto::kTeamChannelPskSize); + assert(port.last_keydist.channel_psk[0] == 0x42); + + queue.confirmKeyDist(0x22222222, 13); + assert(queue.keyDistPendingCount() == 0); +} + +void testKeyDistFailureEffects() +{ + auto queue = makeQueue(); + FakeDispatchPort port; + auto state = readyState(); + port.keydist_ok = false; + port.error = team::TeamService::SendError::MeshSendFail; + + queue.enqueueKeyDist(0x33333333, 21, 0); + auto effects = queue.processKeyDistRetries(state, port, 5); + + assert(effects.sent_keydist); + assert(effects.failures.size() == 1); + assert(effects.failures[0].action == + team::ui::TeamPageDeferredDispatchAction::KeyDist); + assert(effects.failures[0].kind == + team::ui::TeamPageDeferredDispatchFailureKind::SendFailedDetail); + assert(effects.failures[0].error == + team::TeamService::SendError::MeshSendFail); + + port.keydist_ok = true; + effects = queue.processKeyDistRetries(state, port, 10); + assert(effects.failures.empty()); + effects = queue.processKeyDistRetries(state, port, 15); + assert(effects.failures.size() == 1); + assert(effects.failures[0].kind == + team::ui::TeamPageDeferredDispatchFailureKind::SendFailed); + assert(queue.keyDistPendingCount() == 0); +} + +void testKeyDistMissingRuntimeDoesNothing() +{ + auto queue = makeQueue(); + FakeDispatchPort port; + auto state = readyState(); + port.has_controller = false; + + queue.enqueueKeyDist(0x33333333, 21, 0); + auto effects = queue.processKeyDistRetries(state, port, 5); + + assert(!effects.sent_keydist); + assert(port.keydist_send_count == 0); + assert(queue.keyDistPendingCount() == 1); + + state.has_team_psk = false; + port.has_controller = true; + effects = queue.processKeyDistRetries(state, port, 10); + assert(!effects.sent_keydist); + assert(port.keydist_send_count == 0); +} + +void testStatusBroadcastSendsAndReschedules() +{ + auto queue = makeQueue(); + FakeDispatchPort port; + auto state = readyState(); + team::proto::TeamStatus status; + status.key_id = 12; + status.members = {0x11111111, 0x22222222}; + status.has_members = true; + status.leader_id = 0x11111111; + + queue.scheduleStatusBroadcast(2, 100, 3); + assert(queue.statusBroadcastPendingCount() == 1); + + auto effects = queue.processStatusBroadcasts(state, status, port, 102); + assert(!effects.sent_status); + assert(port.status_send_count == 0); + + effects = queue.processStatusBroadcasts(state, status, port, 103); + assert(effects.sent_status); + assert(port.status_send_count == 1); + assert(port.status_plain_send_count == 1); + assert(port.last_status.key_id == 12); + assert(queue.statusBroadcastPendingCount() == 1); + + effects = queue.processStatusBroadcasts(state, status, port, 105); + assert(effects.sent_status); + assert(port.status_send_count == 2); + assert(port.status_plain_send_count == 2); + assert(queue.statusBroadcastPendingCount() == 0); +} + +void testStatusBroadcastFailureAndInvalidLeaderState() +{ + auto queue = makeQueue(); + FakeDispatchPort port; + auto state = readyState(); + team::proto::TeamStatus status; + status.key_id = 3; + port.status_ok = false; + port.status_plain_ok = false; + + queue.scheduleStatusBroadcast(1, 0, 0); + auto effects = queue.processStatusBroadcasts(state, status, port, 0); + + assert(effects.sent_status); + assert(effects.failures.size() == 2); + assert(effects.failures[0].action == + team::ui::TeamPageDeferredDispatchAction::Status); + assert(effects.failures[0].needs_keys); + assert(!effects.failures[1].needs_keys); + + queue.scheduleStatusBroadcast(1, 10, 0); + state.self_is_leader = false; + effects = queue.processStatusBroadcasts(state, status, port, 10); + assert(!effects.sent_status); + assert(queue.statusBroadcastPendingCount() == 0); +} + +} // namespace + +int main() +{ + testKeyDistRetriesRespectDelayAndDeduplicate(); + testKeyDistFailureEffects(); + testKeyDistMissingRuntimeDoesNothing(); + testStatusBroadcastSendsAndReschedules(); + testStatusBroadcastFailureAndInvalidLeaderState(); + return 0; +} diff --git a/modules/ui_shared/tests/test_team_page_event_effect_sink.cpp b/modules/ui_shared/tests/test_team_page_event_effect_sink.cpp new file mode 100644 index 00000000..346b1bac --- /dev/null +++ b/modules/ui_shared/tests/test_team_page_event_effect_sink.cpp @@ -0,0 +1,434 @@ +#include "ui/screens/team/team_page_event_effect_sink.h" + +#include +#include +#include +#include + +namespace +{ + +team::TeamId testTeamId() +{ + team::TeamId id{}; + id[0] = 0xAB; + id[1] = 0xCD; + return id; +} + +class FakeNames final : public team::ui::ITeamPageMemberNameResolver +{ + public: + std::string resolveMemberName(uint32_t node_id) const override + { + if (node_id == 0x22222222) + { + return "Ada"; + } + return "Unknown"; + } +}; + +team::ui::TeamPageEventReducer makeReducer() +{ + static FakeNames names; + team::ui::TeamPageEventContext context; + context.now_s = 1000; + context.self_node_id = 0x11111111; + context.names = &names; + return team::ui::TeamPageEventReducer(context); +} + +team::ui::TeamPageEventState makeState() +{ + team::ui::TeamPageEventState state; + state.in_team = true; + state.self_is_leader = true; + state.has_team_id = true; + state.team_id = testTeamId(); + state.security_round = 7; + state.has_team_psk = true; + state.team_psk[0] = 0xA5; + state.members.push_back({0, "You", false, true, 1000, 0}); + state.members.push_back({0x22222222, "Ada", false, false, 1000, 0}); + return state; +} + +team::ui::TeamPageKeyEventState makeKeyEventState() +{ + team::ui::TeamPageKeyEventState state; + state.has_team_id = true; + state.team_id = testTeamId(); + state.security_round = 7; + state.last_event_seq = 3; + return state; +} + +class FakeDeferred final : public team::ui::ITeamPageEventDeferred +{ + public: + void confirmKeyDist(uint32_t node_id, uint32_t key_id) override + { + confirmed.push_back(std::make_pair(node_id, key_id)); + } + + void scheduleStatusBroadcast(uint8_t repeats, + uint32_t delay_s) override + { + scheduled.push_back(std::make_pair(repeats, delay_s)); + } + + std::vector> confirmed; + std::vector> scheduled; +}; + +class FakeNotifier final : public team::ui::ITeamPageEventNotifier +{ + public: + void showMessage(const char* message) override + { + messages.push_back(message ? message : ""); + } + + void notifySendFailed(const char* action, bool needs_keys) override + { + failures.push_back(std::make_pair(std::string(action ? action : ""), + needs_keys)); + } + + std::vector messages; + std::vector> failures; +}; + +class FakeKeyEventWriter final : public team::ui::ITeamPageKeyEventWriter +{ + public: + bool appendKeyEvent(const team::TeamId&, + team::ui::TeamKeyEventType type, + uint32_t event_seq, + uint32_t, + const uint8_t*, + size_t) override + { + append_count += 1; + last_type = type; + last_event_seq = event_seq; + return append_ok; + } + + bool append_ok = true; + int append_count = 0; + team::ui::TeamKeyEventType last_type = + team::ui::TeamKeyEventType::TeamCreated; + uint32_t last_event_seq = 0; +}; + +class FakeController final : public team::ui::ITeamPageControllerPort +{ + public: + void clearKeys() override {} + void resetUiState() override {} + + bool setKeysFromPsk(const team::TeamId& team_id, + uint32_t key_id, + const uint8_t* psk, + size_t psk_len) override + { + set_keys_count += 1; + last_set_keys_team_id = team_id; + last_set_keys_key_id = key_id; + last_set_keys_psk0 = psk ? psk[0] : 0; + last_set_keys_len = psk_len; + return set_keys_ok; + } + + bool sendKick(const team::proto::TeamKick&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendTransferLeader(const team::proto::TeamTransferLeader&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendKeyDist(const team::proto::TeamKeyDist&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendKeyDistPlain(const team::proto::TeamKeyDist&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendKeyRequest(const team::proto::TeamKeyRequest&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendStatus(const team::proto::TeamStatus& status, + chat::ChannelId, + chat::NodeId) override + { + status_count += 1; + last_status = status; + return status_ok; + } + + bool sendStatusPlain(const team::proto::TeamStatus& status, + chat::ChannelId, + chat::NodeId) override + { + status_plain_count += 1; + last_status_plain = status; + return status_plain_ok; + } + + team::TeamService::SendError lastSendError() const override + { + return team::TeamService::SendError::None; + } + + bool set_keys_ok = true; + bool status_ok = true; + bool status_plain_ok = true; + int set_keys_count = 0; + int status_count = 0; + int status_plain_count = 0; + team::TeamId last_set_keys_team_id{}; + uint32_t last_set_keys_key_id = 0; + uint8_t last_set_keys_psk0 = 0; + size_t last_set_keys_len = 0; + team::proto::TeamStatus last_status; + team::proto::TeamStatus last_status_plain; +}; + +class FakeKeyStore final : public team::ui::ITeamPageKeyStorePort +{ + public: + bool saveKeysNow( + const team::TeamId& team_id, + uint32_t key_id, + const std::array& psk) override + { + save_count += 1; + last_team_id = team_id; + last_key_id = key_id; + last_psk0 = psk[0]; + return save_ok; + } + + bool save_ok = true; + int save_count = 0; + team::TeamId last_team_id{}; + uint32_t last_key_id = 0; + uint8_t last_psk0 = 0; +}; + +bool hasMember(const team::proto::TeamStatus& status, uint32_t node_id) +{ + for (const uint32_t member : status.members) + { + if (member == node_id) + { + return true; + } + } + return false; +} + +void testStatusEffectsConfirmKeyDistAndAppendEpoch() +{ + const auto state = makeState(); + auto key_state = makeKeyEventState(); + team::ui::TeamPageEventEffects effects; + effects.keydist_confirmed = true; + effects.keydist_member_id = 0x22222222; + effects.keydist_key_id = 7; + effects.epoch_rotated = true; + effects.epoch_key_id = 8; + FakeDeferred deferred; + FakeNotifier notifier; + FakeKeyEventWriter writer; + team::ui::TeamPageKeyEventLog log(writer, 900); + team::ui::TeamPageRuntimePort runtime(nullptr, nullptr, nullptr); + FakeNames names; + + const auto result = team::ui::TeamPageEventEffectSink().applyEffects( + state, + key_state, + effects, + makeReducer(), + runtime, + log, + deferred, + notifier, + names); + + assert(result.confirmed_keydist); + assert(result.appended_epoch_rotated); + assert(deferred.confirmed.size() == 1); + assert(deferred.confirmed[0].first == 0x22222222); + assert(deferred.confirmed[0].second == 7); + assert(writer.append_count == 1); + assert(writer.last_type == team::ui::TeamKeyEventType::EpochRotated); + assert(writer.last_event_seq == 4); + assert(key_state.last_event_seq == 4); +} + +void testKeyDistEffectsSaveApplyKeysAndForwardNavigationRequests() +{ + const auto state = makeState(); + auto key_state = makeKeyEventState(); + team::ui::TeamPageEventEffects effects; + effects.save_keys = true; + effects.apply_keys = true; + effects.request_status_in_team_page = true; + effects.clear_nav_stack = true; + FakeDeferred deferred; + FakeNotifier notifier; + FakeKeyEventWriter writer; + team::ui::TeamPageKeyEventLog log(writer, 900); + FakeController controller; + FakeKeyStore key_store; + team::ui::TeamPageRuntimePort runtime(&controller, nullptr, &key_store); + FakeNames names; + + const auto result = team::ui::TeamPageEventEffectSink().applyEffects( + state, + key_state, + effects, + makeReducer(), + runtime, + log, + deferred, + notifier, + names); + + assert(result.saved_keys); + assert(result.applied_keys); + assert(result.request_status_in_team_page); + assert(result.clear_nav_stack); + assert(key_store.save_count == 1); + assert(key_store.last_team_id == testTeamId()); + assert(key_store.last_key_id == 7); + assert(key_store.last_psk0 == 0xA5); + assert(controller.set_keys_count == 1); + assert(controller.last_set_keys_team_id == testTeamId()); + assert(controller.last_set_keys_key_id == 7); + assert(controller.last_set_keys_psk0 == 0xA5); + assert(controller.last_set_keys_len == team::proto::kTeamChannelPskSize); +} + +void testPairingEffectsAppendAcceptedMemberSendStatusAndNotify() +{ + const auto state = makeState(); + auto key_state = makeKeyEventState(); + team::ui::TeamPageEventEffects effects; + effects.member_accepted_key_event = true; + effects.member_accepted_id = 0x22222222; + effects.status_should_send = true; + effects.show_pairing_peer = true; + effects.pairing_peer_id = 0x22222222; + effects.show_pairing_success = true; + effects.request_status_in_team_page = true; + effects.clear_nav_stack = true; + FakeDeferred deferred; + FakeNotifier notifier; + FakeKeyEventWriter writer; + team::ui::TeamPageKeyEventLog log(writer, 900); + FakeController controller; + team::ui::TeamPageRuntimePort runtime(&controller, nullptr, nullptr); + FakeNames names; + + const auto result = team::ui::TeamPageEventEffectSink().applyEffects( + state, + key_state, + effects, + makeReducer(), + runtime, + log, + deferred, + notifier, + names); + + assert(result.appended_member_accepted); + assert(result.sent_status); + assert(result.sent_status_plain); + assert(result.scheduled_status_broadcast); + assert(result.request_status_in_team_page); + assert(result.clear_nav_stack); + assert(writer.append_count == 1); + assert(writer.last_type == team::ui::TeamKeyEventType::MemberAccepted); + assert(controller.status_count == 1); + assert(controller.status_plain_count == 1); + assert(controller.last_status.key_id == 7); + assert(controller.last_status.leader_id == 0x11111111); + assert(hasMember(controller.last_status, 0x11111111)); + assert(hasMember(controller.last_status, 0x22222222)); + assert(deferred.scheduled.size() == 1); + assert(deferred.scheduled[0].first == 1); + assert(deferred.scheduled[0].second == 2); + assert(notifier.messages.size() == 2); + assert(notifier.messages[0] == "Paired: Ada"); + assert(notifier.messages[1] == "Paired successfully"); +} + +void testStatusSendFailuresAreTranslatedToNotifier() +{ + const auto state = makeState(); + auto key_state = makeKeyEventState(); + team::ui::TeamPageEventEffects effects; + effects.status_should_send = true; + FakeDeferred deferred; + FakeNotifier notifier; + FakeKeyEventWriter writer; + team::ui::TeamPageKeyEventLog log(writer, 900); + FakeController controller; + controller.status_ok = false; + controller.status_plain_ok = false; + team::ui::TeamPageRuntimePort runtime(&controller, nullptr, nullptr); + FakeNames names; + + const auto result = team::ui::TeamPageEventEffectSink().applyEffects( + state, + key_state, + effects, + makeReducer(), + runtime, + log, + deferred, + notifier, + names); + + assert(!result.sent_status); + assert(!result.sent_status_plain); + assert(result.scheduled_status_broadcast); + assert(notifier.failures.size() == 2); + assert(notifier.failures[0].first == "Status"); + assert(notifier.failures[0].second); + assert(notifier.failures[1].first == "Status"); + assert(!notifier.failures[1].second); +} + +} // namespace + +int main() +{ + testStatusEffectsConfirmKeyDistAndAppendEpoch(); + testKeyDistEffectsSaveApplyKeysAndForwardNavigationRequests(); + testPairingEffectsAppendAcceptedMemberSendStatusAndNotify(); + testStatusSendFailuresAreTranslatedToNotifier(); + return 0; +} diff --git a/modules/ui_shared/tests/test_team_page_event_reducer.cpp b/modules/ui_shared/tests/test_team_page_event_reducer.cpp new file mode 100644 index 00000000..8e0d46ed --- /dev/null +++ b/modules/ui_shared/tests/test_team_page_event_reducer.cpp @@ -0,0 +1,399 @@ +#include "ui/screens/team/team_page_event_reducer.h" + +#include + +namespace +{ + +class FakeNames final : public team::ui::ITeamPageMemberNameResolver +{ + public: + std::string resolveMemberName(uint32_t node_id) const override + { + if (node_id == 0x22222222) + { + return "Ada"; + } + if (node_id == 0x33333333) + { + return "Ben"; + } + return ""; + } +}; + +team::TeamId testTeamId() +{ + team::TeamId id{}; + id[0] = 0xAB; + id[1] = 0xCD; + return id; +} + +team::ui::TeamPageEventReducer makeReducer() +{ + static FakeNames names; + team::ui::TeamPageEventContext context; + context.now_s = 1000; + context.self_node_id = 0x11111111; + context.names = &names; + return team::ui::TeamPageEventReducer(context); +} + +team::TeamEventContext makeEventContext() +{ + team::TeamEventContext context; + context.team_id = testTeamId(); + context.key_id = 7; + context.from = 0x22222222; + context.timestamp = 990; + return context; +} + +void testStatusRostersPreservePresenceAndLeader() +{ + auto reducer = makeReducer(); + team::ui::TeamPageEventState state; + state.security_round = 6; + + team::TeamStatusEvent event; + event.ctx = makeEventContext(); + event.msg.key_id = 7; + event.msg.leader_id = 0x11111111; + event.msg.members = {0x11111111, 0x22222222}; + event.msg.has_members = true; + + const auto effects = reducer.reduceStatus(state, event); + + assert(effects.accepted); + assert(effects.changed); + assert(effects.epoch_rotated); + assert(effects.epoch_key_id == 7); + assert(effects.keydist_confirmed); + assert(effects.keydist_member_id == 0x22222222); + assert(state.has_team_id); + assert(state.team_name == "TEAM-ABCD"); + assert(state.security_round == 7); + assert(!state.waiting_new_keys); + assert(state.self_is_leader); + assert(state.members.size() == 2); + assert(state.members[0].node_id == 0); + assert(state.members[0].name == "You"); + assert(state.members[0].leader); + assert(state.members[1].node_id == 0x22222222); + assert(state.members[1].name == "Ada"); + assert(state.members[1].last_seen_s == 990); +} + +void testStatusFromOtherTeamIsIgnored() +{ + auto reducer = makeReducer(); + team::ui::TeamPageEventState state; + state.team_id = testTeamId(); + state.has_team_id = true; + + team::TeamStatusEvent event; + event.ctx = makeEventContext(); + event.ctx.team_id[0] = 0x99; + event.msg.members = {0x22222222}; + event.msg.has_members = true; + + const auto effects = reducer.reduceStatus(state, event); + + assert(!effects.accepted); + assert(state.members.empty()); +} + +void testActivityTouchesSenderAndConfirmsKeys() +{ + auto reducer = makeReducer(); + team::ui::TeamPageEventState state; + state.team_id = testTeamId(); + state.has_team_id = true; + + const auto effects = + reducer.reduceActivity(state, makeEventContext(), 0x22222222); + + assert(effects.accepted); + assert(effects.keydist_confirmed); + assert(effects.keydist_key_id == 7); + assert(state.members.size() == 1); + assert(state.members[0].name == "Ada"); + assert(state.last_update_s == 990); +} + +void testActivityCanTouchSelfPlaceholder() +{ + auto reducer = makeReducer(); + team::ui::TeamPageEventState state; + state.team_id = testTeamId(); + state.has_team_id = true; + + auto context = makeEventContext(); + context.from = 0; + context.key_id = 0; + const auto effects = reducer.reduceActivity(state, context, 0); + + assert(effects.accepted); + assert(!effects.keydist_confirmed); + assert(state.members.size() == 1); + assert(state.members[0].node_id == 0); + assert(state.members[0].name == "You"); + assert(state.members[0].last_seen_s == 990); +} + +void testErrorSetsWaitingNewKeysOnlyForMembers() +{ + auto reducer = makeReducer(); + team::ui::TeamPageEventState state; + state.in_team = true; + state.self_is_leader = false; + state.team_id = testTeamId(); + state.has_team_id = true; + + team::TeamErrorEvent event; + event.ctx = makeEventContext(); + event.error = team::TeamProtocolError::KeyMismatch; + + const auto first = reducer.reduceError(state, event); + const auto second = reducer.reduceError(state, event); + + assert(first.accepted); + assert(first.show_key_mismatch); + assert(state.waiting_new_keys); + assert(second.accepted); + assert(!second.show_key_mismatch); + + state.self_is_leader = true; + state.waiting_new_keys = false; + const auto leader = reducer.reduceError(state, event); + assert(!leader.accepted); + assert(!state.waiting_new_keys); +} + +void testTransferLeaderUpdatesRoster() +{ + auto reducer = makeReducer(); + team::ui::TeamPageEventState state; + team::ui::TeamMemberUi self; + self.node_id = 0; + self.name = "You"; + self.leader = true; + state.members.push_back(self); + state.self_is_leader = true; + + team::TeamTransferLeaderEvent event; + event.msg.target = 0x33333333; + + const auto effects = reducer.reduceTransferLeader(state, event); + + assert(effects.accepted); + assert(!state.self_is_leader); + assert(state.members.size() == 2); + assert(!state.members[0].leader); + assert(state.members[1].node_id == 0x33333333); + assert(state.members[1].name == "Ben"); + assert(state.members[1].leader); +} + +void testFillStatusMembersUsesSelfNodeId() +{ + auto reducer = makeReducer(); + team::ui::TeamPageEventState state; + state.self_is_leader = true; + team::ui::TeamMemberUi self; + self.node_id = 0; + self.leader = true; + state.members.push_back(self); + team::ui::TeamMemberUi other; + other.node_id = 0x22222222; + state.members.push_back(other); + + team::proto::TeamStatus status; + reducer.fillStatusMembers(state, status); + + assert(status.has_members); + assert(status.members.size() == 2); + assert(status.members[0] == 0x11111111); + assert(status.members[1] == 0x22222222); + assert(status.leader_id == 0x11111111); +} + +void testKickRemovesMemberAndRotatesEpoch() +{ + auto reducer = makeReducer(); + team::ui::TeamPageEventState state; + state.team_id = testTeamId(); + state.has_team_id = true; + state.security_round = 7; + state.members.push_back({0, "You", false, true, 100, 0}); + state.members.push_back({0x22222222, "Ada", false, false, 100, 0}); + + team::TeamKickEvent event; + event.ctx = makeEventContext(); + event.msg.target = 0x22222222; + + const auto effects = reducer.reduceKick(state, event); + + assert(effects.accepted); + assert(effects.member_kicked_key_event); + assert(effects.member_kicked_id == 0x22222222); + assert(effects.epoch_rotated); + assert(effects.epoch_key_id == 8); + assert(state.security_round == 8); + assert(state.members.size() == 1); + assert(state.members[0].node_id == 0); +} + +void testKickSelfResetsLocalTeamAfterEffects() +{ + auto reducer = makeReducer(); + team::ui::TeamPageEventState state; + state.in_team = true; + state.team_id = testTeamId(); + state.has_team_id = true; + state.has_team_psk = true; + state.security_round = 2; + state.members.push_back({0, "You", false, true, 100, 0}); + + team::TeamKickEvent event; + event.ctx = makeEventContext(); + event.msg.target = 0; + + const auto effects = reducer.reduceKick(state, event); + + assert(effects.accepted); + assert(effects.member_kicked_key_event); + assert(effects.member_kicked_id == 0); + assert(effects.epoch_rotated); + assert(effects.epoch_key_id == 3); + assert(effects.clear_keys); + assert(effects.stop_pairing); + assert(effects.request_kicked_out_page); + assert(!state.in_team); + assert(state.kicked_out); + assert(!state.has_team_id); + assert(!state.has_team_psk); + assert(state.security_round == 0); + assert(state.members.empty()); +} + +void testKeyDistStoresKeysAndTransitionsFromPairing() +{ + auto reducer = makeReducer(); + team::ui::TeamPageEventState state; + state.pending_join = true; + state.pairing_role = team::TeamPairingRole::Member; + + team::TeamKeyDistEvent event; + event.ctx = makeEventContext(); + event.msg.team_id = testTeamId(); + event.msg.key_id = 9; + event.msg.channel_psk_len = team::proto::kTeamChannelPskSize; + event.msg.channel_psk[0] = 0xA5; + + const auto effects = reducer.reduceKeyDist(state, event); + + assert(effects.accepted); + assert(effects.save_keys); + assert(effects.apply_keys); + assert(effects.request_status_in_team_page); + assert(effects.clear_nav_stack); + assert(state.in_team); + assert(!state.pending_join); + assert(!state.kicked_out); + assert(!state.self_is_leader); + assert(state.has_team_id); + assert(state.team_name == "TEAM-ABCD"); + assert(state.security_round == 9); + assert(state.has_team_psk); + assert(state.team_psk[0] == 0xA5); + assert(state.members.size() == 2); + assert(state.members[0].node_id == 0); + assert(state.members[1].node_id == 0x22222222); + assert(state.members[1].leader); +} + +void testPairingLeaderBeaconAcceptsNewMember() +{ + auto reducer = makeReducer(); + team::ui::TeamPageEventState state; + state.has_team_id = true; + state.team_id = testTeamId(); + state.security_round = 4; + + team::ui::TeamPagePairingUpdate update; + update.role = team::TeamPairingRole::Leader; + update.state = team::TeamPairingState::LeaderBeacon; + update.peer_id = 0x22222222; + + const auto effects = reducer.reducePairing(state, update); + + assert(effects.accepted); + assert(effects.status_should_send); + assert(effects.member_accepted_key_event); + assert(effects.member_accepted_id == 0x22222222); + assert(effects.show_pairing_peer); + assert(state.in_team); + assert(state.self_is_leader); + assert(state.members.size() == 2); + assert(state.members[0].node_id == 0); + assert(state.members[0].leader); + assert(state.members[1].node_id == 0x22222222); + assert(state.last_update_s == 1000); +} + +void testPairingCompletedAndFailedDrivePageEffects() +{ + auto reducer = makeReducer(); + team::ui::TeamPageEventState state; + state.pending_join = true; + state.pending_join_started_s = 11; + + team::ui::TeamPagePairingUpdate completed; + completed.role = team::TeamPairingRole::Member; + completed.state = team::TeamPairingState::Completed; + + const auto done = reducer.reducePairing(state, completed); + + assert(done.accepted); + assert(done.request_status_in_team_page); + assert(done.clear_nav_stack); + assert(done.show_pairing_success); + assert(state.in_team); + assert(!state.pending_join); + assert(!state.self_is_leader); + assert(state.members.size() == 1); + + team::ui::TeamPageEventState failed_state; + failed_state.pending_join = true; + team::ui::TeamPagePairingUpdate failed; + failed.state = team::TeamPairingState::Failed; + + const auto fail = reducer.reducePairing(failed_state, failed); + + assert(fail.accepted); + assert(fail.request_status_not_in_team_page); + assert(fail.clear_nav_stack); + assert(fail.show_pairing_failed); + assert(!failed_state.pending_join); +} + +} // namespace + +int main() +{ + testStatusRostersPreservePresenceAndLeader(); + testStatusFromOtherTeamIsIgnored(); + testActivityTouchesSenderAndConfirmsKeys(); + testActivityCanTouchSelfPlaceholder(); + testErrorSetsWaitingNewKeysOnlyForMembers(); + testTransferLeaderUpdatesRoster(); + testFillStatusMembersUsesSelfNodeId(); + testKickRemovesMemberAndRotatesEpoch(); + testKickSelfResetsLocalTeamAfterEffects(); + testKeyDistStoresKeysAndTransitionsFromPairing(); + testPairingLeaderBeaconAcceptsNewMember(); + testPairingCompletedAndFailedDrivePageEffects(); + return 0; +} diff --git a/modules/ui_shared/tests/test_team_page_flow_controller.cpp b/modules/ui_shared/tests/test_team_page_flow_controller.cpp new file mode 100644 index 00000000..7e876f46 --- /dev/null +++ b/modules/ui_shared/tests/test_team_page_flow_controller.cpp @@ -0,0 +1,127 @@ +#include "ui/screens/team/team_page_flow_controller.h" + +#include + +namespace +{ + +void testNavigateToPushesPreviousPage() +{ + team::ui::TeamPageFlowState state; + state.page = team::ui::TeamPage::StatusInTeam; + + const auto result = team::ui::TeamPageFlowController().navigateTo( + state, + team::ui::TeamPage::TeamHome, + true); + + assert(result.changed); + assert(state.page == team::ui::TeamPage::TeamHome); + assert(state.nav_stack.size() == 1); + assert(state.nav_stack[0] == team::ui::TeamPage::StatusInTeam); +} + +void testNavigateBackNormalizesNotInTeamWhenAlreadyInTeam() +{ + team::ui::TeamPageFlowState state; + state.page = team::ui::TeamPage::TeamHome; + state.in_team = true; + state.nav_stack.push_back(team::ui::TeamPage::StatusNotInTeam); + + const auto result = + team::ui::TeamPageFlowController().navigateBack(state); + + assert(result.changed); + assert(!result.request_exit); + assert(state.page == team::ui::TeamPage::StatusInTeam); + assert(state.nav_stack.empty()); +} + +void testNavigateBackRequestsExitWhenStackEmpty() +{ + team::ui::TeamPageFlowState state; + state.page = team::ui::TeamPage::StatusNotInTeam; + + const auto result = + team::ui::TeamPageFlowController().navigateBack(state); + + assert(!result.changed); + assert(result.request_exit); +} + +void testJoinPendingBackResetsToInTeamStatus() +{ + team::ui::TeamPageFlowState state; + state.page = team::ui::TeamPage::JoinPending; + state.in_team = true; + state.nav_stack.push_back(team::ui::TeamPage::TeamHome); + + const auto result = + team::ui::TeamPageFlowController().navigateBack(state); + + assert(result.changed); + assert(state.page == team::ui::TeamPage::StatusInTeam); + assert(state.nav_stack.empty()); +} + +void testInitialPageSelection() +{ + team::ui::TeamPageFlowState state; + state.kicked_out = true; + team::ui::TeamPageFlowController controller; + controller.selectInitialPage(state); + assert(state.page == team::ui::TeamPage::KickedOut); + + state = {}; + state.pending_join = true; + controller.selectInitialPage(state); + assert(state.page == team::ui::TeamPage::JoinPending); + + state = {}; + state.in_team = true; + controller.selectInitialPage(state); + assert(state.page == team::ui::TeamPage::StatusInTeam); + + state = {}; + controller.selectInitialPage(state); + assert(state.page == team::ui::TeamPage::StatusNotInTeam); +} + +void testSyncRuntimeKeepsPageConsistentWithPairingAndMembership() +{ + team::ui::TeamPageFlowController controller; + team::ui::TeamPageFlowState state; + state.pairing_state = team::TeamPairingState::MemberScanning; + state.page = team::ui::TeamPage::StatusNotInTeam; + + auto result = controller.syncRuntime(state, 50); + + assert(result.changed); + assert(state.pending_join_started_s == 50); + assert(state.page == team::ui::TeamPage::JoinPending); + + state.in_team = true; + state.pairing_state = team::TeamPairingState::Idle; + result = controller.syncRuntime(state, 60); + assert(result.changed); + assert(state.page == team::ui::TeamPage::StatusInTeam); + + state.in_team = false; + state.page = team::ui::TeamPage::JoinPending; + result = controller.syncRuntime(state, 70); + assert(result.changed); + assert(state.page == team::ui::TeamPage::StatusNotInTeam); +} + +} // namespace + +int main() +{ + testNavigateToPushesPreviousPage(); + testNavigateBackNormalizesNotInTeamWhenAlreadyInTeam(); + testNavigateBackRequestsExitWhenStackEmpty(); + testJoinPendingBackResetsToInTeamStatus(); + testInitialPageSelection(); + testSyncRuntimeKeepsPageConsistentWithPairingAndMembership(); + return 0; +} diff --git a/modules/ui_shared/tests/test_team_page_key_event_log.cpp b/modules/ui_shared/tests/test_team_page_key_event_log.cpp new file mode 100644 index 00000000..0e03e368 --- /dev/null +++ b/modules/ui_shared/tests/test_team_page_key_event_log.cpp @@ -0,0 +1,151 @@ +#include "ui/screens/team/team_page_key_event_log.h" + +#include +#include + +namespace +{ + +struct CapturedEvent +{ + team::TeamId team_id{}; + team::ui::TeamKeyEventType type = team::ui::TeamKeyEventType::TeamCreated; + uint32_t seq = 0; + uint32_t timestamp_s = 0; + std::vector payload; +}; + +class FakeWriter final : public team::ui::ITeamPageKeyEventWriter +{ + public: + bool appendKeyEvent(const team::TeamId& team_id, + team::ui::TeamKeyEventType type, + uint32_t event_seq, + uint32_t timestamp_s, + const uint8_t* payload, + size_t payload_size) override + { + if (!accept) + { + return false; + } + CapturedEvent event; + event.team_id = team_id; + event.type = type; + event.seq = event_seq; + event.timestamp_s = timestamp_s; + event.payload.assign(payload, payload + payload_size); + events.push_back(event); + return true; + } + + bool accept = true; + std::vector events; +}; + +team::TeamId testTeamId() +{ + team::TeamId id{}; + id[0] = 0x12; + id[1] = 0x34; + id[2] = 0x56; + id[3] = 0x78; + return id; +} + +uint32_t readU32(const std::vector& data, size_t offset) +{ + return static_cast(data[offset]) | + (static_cast(data[offset + 1]) << 8) | + (static_cast(data[offset + 2]) << 16) | + (static_cast(data[offset + 3]) << 24); +} + +uint64_t readU64(const std::vector& data, size_t offset) +{ + uint64_t value = 0; + for (size_t i = 0; i < 8; ++i) + { + value |= (static_cast(data[offset + i]) << (8 * i)); + } + return value; +} + +team::ui::TeamPageKeyEventState makeState() +{ + team::ui::TeamPageKeyEventState state; + state.team_id = testTeamId(); + state.has_team_id = true; + state.last_event_seq = 4; + state.security_round = 9; + return state; +} + +void testTeamCreatedPayloadAndSeq() +{ + FakeWriter writer; + team::ui::TeamPageKeyEventLog log(writer, 77); + auto state = makeState(); + + assert(log.appendTeamCreated(state, 0)); + assert(state.last_event_seq == 5); + assert(writer.events.size() == 1); + assert(writer.events[0].type == team::ui::TeamKeyEventType::TeamCreated); + assert(writer.events[0].seq == 5); + assert(writer.events[0].timestamp_s == 77); + assert(readU64(writer.events[0].payload, 0) == + team::ui::TeamPageKeyEventLog::teamIdToU64(testTeamId())); + assert(readU32(writer.events[0].payload, 8) == 0); + assert(readU32(writer.events[0].payload, 12) == 9); +} + +void testMemberEventsPayloads() +{ + FakeWriter writer; + team::ui::TeamPageKeyEventLog log(writer, 88); + auto state = makeState(); + + assert(log.appendMemberAccepted(state, 0x22222222, 1)); + assert(log.appendMemberKicked(state, 0x33333333)); + assert(log.appendLeaderTransferred(state, 0x44444444)); + assert(log.appendEpochRotated(state, 12)); + + assert(state.last_event_seq == 8); + assert(writer.events.size() == 4); + assert(writer.events[0].type == team::ui::TeamKeyEventType::MemberAccepted); + assert(readU32(writer.events[0].payload, 0) == 0x22222222); + assert(writer.events[0].payload[4] == 1); + assert(writer.events[1].type == team::ui::TeamKeyEventType::MemberKicked); + assert(readU32(writer.events[1].payload, 0) == 0x33333333); + assert(writer.events[2].type == team::ui::TeamKeyEventType::LeaderTransferred); + assert(readU32(writer.events[2].payload, 0) == 0x44444444); + assert(writer.events[3].type == team::ui::TeamKeyEventType::EpochRotated); + assert(readU32(writer.events[3].payload, 0) == 12); +} + +void testAppendFailureDoesNotAdvanceSeq() +{ + FakeWriter writer; + writer.accept = false; + team::ui::TeamPageKeyEventLog log(writer, 99); + auto state = makeState(); + + assert(!log.appendMemberKicked(state, 0x33333333)); + assert(state.last_event_seq == 4); + assert(writer.events.empty()); + + state.has_team_id = false; + writer.accept = true; + assert(!log.appendEpochRotated(state, 12)); + assert(state.last_event_seq == 4); +} + +} // namespace + +int main() +{ + testTeamCreatedPayloadAndSeq(); + testMemberEventsPayloads(); + testAppendFailureDoesNotAdvanceSeq(); + return 0; +} diff --git a/modules/ui_shared/tests/test_team_page_key_request_action.cpp b/modules/ui_shared/tests/test_team_page_key_request_action.cpp new file mode 100644 index 00000000..53220037 --- /dev/null +++ b/modules/ui_shared/tests/test_team_page_key_request_action.cpp @@ -0,0 +1,227 @@ +#include "ui/screens/team/team_page_key_request_action.h" + +#include + +namespace +{ + +team::TeamId testTeamId() +{ + team::TeamId id{}; + id[0] = 0x42; + id[1] = 0x24; + return id; +} + +team::ui::TeamPageCommandState makeLeaderState() +{ + team::ui::TeamPageCommandState state; + state.in_team = true; + state.self_is_leader = true; + state.has_team_id = true; + state.team_id = testTeamId(); + state.security_round = 9; + state.has_team_psk = true; + state.team_psk[0] = 0xA5; + return state; +} + +team::TeamKeyRequestEvent makeRequest() +{ + team::TeamKeyRequestEvent event; + event.ctx.team_id = testTeamId(); + event.ctx.key_id = 7; + event.ctx.from = 0x22222222; + event.msg.team_id = testTeamId(); + event.msg.current_key_id = 7; + event.msg.requester_id = 0x22222222; + return event; +} + +class FakeController final : public team::ui::ITeamPageControllerPort +{ + public: + void clearKeys() override {} + void resetUiState() override {} + + bool setKeysFromPsk(const team::TeamId&, + uint32_t, + const uint8_t*, + size_t) override + { + return true; + } + + bool sendKick(const team::proto::TeamKick&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendTransferLeader(const team::proto::TeamTransferLeader&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendKeyDist(const team::proto::TeamKeyDist& key_dist, + chat::ChannelId, + chat::NodeId dest) override + { + sent_keydist_count += 1; + last_keydist = key_dist; + last_dest = dest; + return send_ok; + } + + bool sendKeyDistPlain(const team::proto::TeamKeyDist&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendKeyRequest(const team::proto::TeamKeyRequest&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendStatus(const team::proto::TeamStatus&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendStatusPlain(const team::proto::TeamStatus&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + team::TeamService::SendError lastSendError() const override + { + return error; + } + + bool send_ok = true; + team::TeamService::SendError error = + team::TeamService::SendError::None; + int sent_keydist_count = 0; + team::proto::TeamKeyDist last_keydist{}; + chat::NodeId last_dest = 0; +}; + +void testLeaderRespondsWithKeyDist() +{ + auto state = makeLeaderState(); + auto request = makeRequest(); + FakeController controller; + team::ui::TeamPageRuntimePort runtime(&controller, nullptr, nullptr); + + const auto effects = team::ui::TeamPageKeyRequestAction().handleRequest( + state, + request, + runtime); + + assert(effects.accepted); + assert(effects.sent_keydist); + assert(effects.failures.empty()); + assert(controller.sent_keydist_count == 1); + assert(controller.last_dest == 0x22222222); + assert(controller.last_keydist.team_id == testTeamId()); + assert(controller.last_keydist.key_id == 9); + assert(controller.last_keydist.channel_psk_len == + team::proto::kTeamChannelPskSize); + assert(controller.last_keydist.channel_psk[0] == 0xA5); +} + +void testRequestCanFallbackToEventSender() +{ + auto state = makeLeaderState(); + auto request = makeRequest(); + request.msg.requester_id = 0; + FakeController controller; + team::ui::TeamPageRuntimePort runtime(&controller, nullptr, nullptr); + + const auto effects = team::ui::TeamPageKeyRequestAction().handleRequest( + state, + request, + runtime); + + assert(effects.accepted); + assert(effects.sent_keydist); + assert(controller.last_dest == 0x22222222); +} + +void testInvalidRequestsAreIgnored() +{ + team::ui::TeamPageRuntimePort runtime(nullptr, nullptr, nullptr); + + auto state = makeLeaderState(); + state.self_is_leader = false; + auto effects = team::ui::TeamPageKeyRequestAction().handleRequest( + state, + makeRequest(), + runtime); + assert(!effects.accepted); + assert(!effects.failures.empty()); + + state = makeLeaderState(); + state.has_team_psk = false; + effects = team::ui::TeamPageKeyRequestAction().handleRequest( + state, + makeRequest(), + runtime); + assert(!effects.accepted); + assert(!effects.failures.empty()); + + state = makeLeaderState(); + auto request = makeRequest(); + request.msg.team_id[0] = 0x99; + effects = team::ui::TeamPageKeyRequestAction().handleRequest( + state, + request, + runtime); + assert(!effects.accepted); + assert(!effects.failures.empty()); +} + +void testSendFailureIsReported() +{ + auto state = makeLeaderState(); + auto request = makeRequest(); + FakeController controller; + controller.send_ok = false; + controller.error = team::TeamService::SendError::MeshSendFail; + team::ui::TeamPageRuntimePort runtime(&controller, nullptr, nullptr); + + const auto effects = team::ui::TeamPageKeyRequestAction().handleRequest( + state, + request, + runtime); + + assert(effects.accepted); + assert(!effects.sent_keydist); + assert(effects.failures.size() == 1); + assert(effects.failures[0].kind == + team::ui::TeamPageKeyRequestFailureKind::SendFailedDetail); + assert(effects.failures[0].error == + team::TeamService::SendError::MeshSendFail); +} + +} // namespace + +int main() +{ + testLeaderRespondsWithKeyDist(); + testRequestCanFallbackToEventSender(); + testInvalidRequestsAreIgnored(); + testSendFailureIsReported(); + return 0; +} diff --git a/modules/ui_shared/tests/test_team_page_kick_confirm_action.cpp b/modules/ui_shared/tests/test_team_page_kick_confirm_action.cpp new file mode 100644 index 00000000..37a69095 --- /dev/null +++ b/modules/ui_shared/tests/test_team_page_kick_confirm_action.cpp @@ -0,0 +1,405 @@ +#include "ui/screens/team/team_page_kick_confirm_action.h" + +#include +#include +#include + +namespace +{ + +team::TeamId testTeamId() +{ + team::TeamId id{}; + id[0] = 0xCA; + id[1] = 0xFE; + return id; +} + +team::ui::TeamMemberUi makeMember(uint32_t node_id, + const char* name, + bool leader = false) +{ + team::ui::TeamMemberUi member; + member.node_id = node_id; + member.name = name; + member.leader = leader; + member.last_seen_s = 900; + return member; +} + +team::ui::TeamPageCommandState makeKickState(uint32_t security_round = 7) +{ + team::ui::TeamPageCommandState state; + state.in_team = true; + state.self_is_leader = true; + state.has_team_id = true; + state.team_id = testTeamId(); + state.team_name = "Trail"; + state.security_round = security_round; + state.has_team_psk = true; + state.waiting_new_keys = true; + state.selected_member_index = 1; + state.members.push_back(makeMember(0, "You", true)); + state.members.push_back(makeMember(0x22222222, "Ada")); + state.members.push_back(makeMember(0x33333333, "Ben")); + return state; +} + +team::ui::TeamPageCommandReducer makeReducer() +{ + team::ui::TeamPageCommandContext context; + context.now_s = 1234; + context.self_node_id = 0x11111111; + return team::ui::TeamPageCommandReducer(context); +} + +class FakeRandom final : public team::ui::ITeamPageKickConfirmRandom +{ + public: + uint8_t nextByte() override + { + const uint8_t out = next; + next = static_cast(next + 1); + return out; + } + + uint8_t next = 0x80; +}; + +class FakeDeferred final : public team::ui::ITeamPageKickConfirmDeferred +{ + public: + void enqueueKeyDist(uint32_t node_id, uint32_t key_id) override + { + enqueued.push_back(std::make_pair(node_id, key_id)); + } + + std::vector> enqueued; +}; + +class FakeController final : public team::ui::ITeamPageControllerPort +{ + public: + void clearKeys() override {} + void resetUiState() override {} + + bool setKeysFromPsk(const team::TeamId& team_id, + uint32_t key_id, + const uint8_t* psk, + size_t psk_len) override + { + set_keys_count += 1; + last_set_keys_team_id = team_id; + last_set_keys_key_id = key_id; + last_set_keys_len = psk_len; + last_set_keys_psk0 = psk ? psk[0] : 0; + return set_keys_ok; + } + + bool sendKick(const team::proto::TeamKick& kick, + chat::ChannelId, + chat::NodeId dest) override + { + kick_count += 1; + last_kick_target = kick.target; + last_dest = dest; + return kick_ok; + } + + bool sendTransferLeader(const team::proto::TeamTransferLeader&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendKeyDist(const team::proto::TeamKeyDist& key_dist, + chat::ChannelId, + chat::NodeId dest) override + { + keydist_count += 1; + last_keydist = key_dist; + last_keydist_dest = dest; + return keydist_ok; + } + + bool sendKeyDistPlain(const team::proto::TeamKeyDist&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendKeyRequest(const team::proto::TeamKeyRequest&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendStatus(const team::proto::TeamStatus& status, + chat::ChannelId, + chat::NodeId dest) override + { + status_count += 1; + last_status = status; + last_status_dest = dest; + return status_ok; + } + + bool sendStatusPlain(const team::proto::TeamStatus& status, + chat::ChannelId, + chat::NodeId dest) override + { + status_plain_count += 1; + last_status_plain = status; + last_status_plain_dest = dest; + return status_plain_ok; + } + + team::TeamService::SendError lastSendError() const override + { + return error; + } + + bool kick_ok = true; + bool keydist_ok = true; + bool set_keys_ok = true; + bool status_ok = true; + bool status_plain_ok = true; + team::TeamService::SendError error = + team::TeamService::SendError::None; + int kick_count = 0; + int keydist_count = 0; + int set_keys_count = 0; + int status_count = 0; + int status_plain_count = 0; + uint32_t last_kick_target = 0; + chat::NodeId last_dest = 0; + chat::NodeId last_keydist_dest = 0; + chat::NodeId last_status_dest = 1; + chat::NodeId last_status_plain_dest = 1; + team::TeamId last_set_keys_team_id{}; + uint32_t last_set_keys_key_id = 0; + size_t last_set_keys_len = 0; + uint8_t last_set_keys_psk0 = 0; + team::proto::TeamKeyDist last_keydist; + team::proto::TeamStatus last_status; + team::proto::TeamStatus last_status_plain; +}; + +bool hasStatusMember(const team::proto::TeamStatus& status, uint32_t node_id) +{ + for (const auto member : status.members) + { + if (member == node_id) + { + return true; + } + } + return false; +} + +void testInvalidSelectionIsIgnored() +{ + auto state = makeKickState(); + state.selected_member_index = 9; + auto reducer = makeReducer(); + FakeController controller; + team::ui::TeamPageRuntimePort runtime(&controller, nullptr, nullptr); + FakeRandom random; + FakeDeferred deferred; + + const auto effects = team::ui::TeamPageKickConfirmAction().confirmKick( + state, + reducer, + runtime, + random, + deferred, + 0x11111111); + + assert(!effects.accepted); + assert(controller.kick_count == 0); + assert(controller.keydist_count == 0); + assert(deferred.enqueued.empty()); + assert(state.members.size() == 3); + assert(state.selected_member_index == 9); +} + +void testKickRotatesKeysSendsKeyDistAndStatusWithoutTarget() +{ + auto state = makeKickState(); + auto reducer = makeReducer(); + FakeController controller; + team::ui::TeamPageRuntimePort runtime(&controller, nullptr, nullptr); + FakeRandom random; + FakeDeferred deferred; + + const auto effects = team::ui::TeamPageKickConfirmAction().confirmKick( + state, + reducer, + runtime, + random, + deferred, + 0x11111111); + + assert(effects.accepted); + assert(effects.command.member_kicked); + assert(effects.command.member_kicked_id == 0x22222222); + assert(effects.sent_kick); + assert(effects.sent_keydist); + assert(effects.enqueued_keydist); + assert(effects.applied_keys); + assert(effects.sent_status); + assert(effects.failures.empty()); + + assert(controller.kick_count == 1); + assert(controller.last_kick_target == 0x22222222); + assert(controller.keydist_count == 1); + assert(controller.last_keydist_dest == 0x33333333); + assert(controller.last_keydist.team_id == testTeamId()); + assert(controller.last_keydist.key_id == 8); + assert(controller.last_keydist.channel_psk_len == + team::proto::kTeamChannelPskSize); + assert(controller.last_keydist.channel_psk[0] == 0x80); + assert(controller.last_keydist.channel_psk[1] == 0x81); + + assert(deferred.enqueued.size() == 1); + assert(deferred.enqueued[0].first == 0x33333333); + assert(deferred.enqueued[0].second == 8); + + assert(state.security_round == 8); + assert(state.has_team_psk); + assert(state.team_psk[0] == 0x80); + assert(!state.waiting_new_keys); + assert(state.selected_member_index == -1); + assert(state.members.size() == 2); + assert(state.members[0].node_id == 0); + assert(state.members[1].node_id == 0x33333333); + + assert(controller.set_keys_count == 1); + assert(controller.last_set_keys_team_id == testTeamId()); + assert(controller.last_set_keys_key_id == 8); + assert(controller.last_set_keys_len == team::proto::kTeamChannelPskSize); + assert(controller.last_set_keys_psk0 == 0x80); + + assert(controller.status_count == 1); + assert(controller.status_plain_count == 1); + assert(controller.last_status.key_id == 8); + assert(controller.last_status.has_members); + assert(controller.last_status.leader_id == 0x11111111); + assert(hasStatusMember(controller.last_status, 0x11111111)); + assert(hasStatusMember(controller.last_status, 0x33333333)); + assert(!hasStatusMember(controller.last_status, 0x22222222)); +} + +void testZeroSecurityRoundRotatesFromFallbackRound() +{ + auto state = makeKickState(0); + auto reducer = makeReducer(); + FakeController controller; + team::ui::TeamPageRuntimePort runtime(&controller, nullptr, nullptr); + FakeRandom random; + FakeDeferred deferred; + + const auto effects = team::ui::TeamPageKickConfirmAction().confirmKick( + state, + reducer, + runtime, + random, + deferred, + 0x11111111); + + assert(effects.accepted); + assert(state.security_round == 2); + assert(controller.last_keydist.key_id == 2); + assert(deferred.enqueued[0].second == 2); +} + +void testFailuresAreReportedAsEffects() +{ + auto state = makeKickState(); + auto reducer = makeReducer(); + FakeController controller; + controller.kick_ok = false; + controller.keydist_ok = false; + controller.set_keys_ok = false; + controller.status_ok = false; + controller.status_plain_ok = false; + controller.error = team::TeamService::SendError::MeshSendFail; + team::ui::TeamPageRuntimePort runtime(&controller, nullptr, nullptr); + FakeRandom random; + FakeDeferred deferred; + + const auto effects = team::ui::TeamPageKickConfirmAction().confirmKick( + state, + reducer, + runtime, + random, + deferred, + 0x11111111); + + assert(effects.accepted); + assert(effects.failures.size() == 5); + assert(effects.failures[0].action == + team::ui::TeamPageKickConfirmFailureAction::Kick); + assert(effects.failures[0].kind == + team::ui::TeamPageKickConfirmFailureKind::SendFailed); + assert(effects.failures[0].needs_keys); + assert(effects.failures[1].action == + team::ui::TeamPageKickConfirmFailureAction::KeyDist); + assert(effects.failures[1].kind == + team::ui::TeamPageKickConfirmFailureKind::SendFailedDetail); + assert(effects.failures[1].error == + team::TeamService::SendError::MeshSendFail); + assert(effects.failures[2].action == + team::ui::TeamPageKickConfirmFailureAction::Keys); + assert(effects.failures[2].needs_keys); + assert(effects.failures[3].action == + team::ui::TeamPageKickConfirmFailureAction::Status); + assert(effects.failures[3].needs_keys); + assert(effects.failures[4].action == + team::ui::TeamPageKickConfirmFailureAction::Status); + assert(!effects.failures[4].needs_keys); + assert(deferred.enqueued.size() == 1); +} + +void testMissingControllerStillReducesLocalKick() +{ + auto state = makeKickState(); + auto reducer = makeReducer(); + team::ui::TeamPageRuntimePort runtime(nullptr, nullptr, nullptr); + FakeRandom random; + FakeDeferred deferred; + + const auto effects = team::ui::TeamPageKickConfirmAction().confirmKick( + state, + reducer, + runtime, + random, + deferred, + 0x11111111); + + assert(effects.accepted); + assert(!effects.sent_kick); + assert(!effects.sent_keydist); + assert(!effects.enqueued_keydist); + assert(!effects.applied_keys); + assert(!effects.sent_status); + assert(effects.failures.empty()); + assert(state.security_round == 7); + assert(state.members.size() == 2); + assert(state.members[1].node_id == 0x33333333); +} + +} // namespace + +int main() +{ + testInvalidSelectionIsIgnored(); + testKickRotatesKeysSendsKeyDistAndStatusWithoutTarget(); + testZeroSecurityRoundRotatesFromFallbackRound(); + testFailuresAreReportedAsEffects(); + testMissingControllerStillReducesLocalKick(); + return 0; +} diff --git a/modules/ui_shared/tests/test_team_page_pairing_command_action.cpp b/modules/ui_shared/tests/test_team_page_pairing_command_action.cpp new file mode 100644 index 00000000..2f6ed705 --- /dev/null +++ b/modules/ui_shared/tests/test_team_page_pairing_command_action.cpp @@ -0,0 +1,251 @@ +#include "ui/screens/team/team_page_pairing_command_action.h" + +#include +#include + +namespace +{ + +team::TeamId testTeamId() +{ + team::TeamId id{}; + id[0] = 0xCA; + id[1] = 0xFE; + return id; +} + +std::array testPsk() +{ + std::array psk{}; + for (size_t i = 0; i < psk.size(); ++i) + { + psk[i] = static_cast(0x40 + i); + } + return psk; +} + +team::ui::TeamPageCommandState makeLeaderState() +{ + team::ui::TeamPageCommandState state; + state.in_team = true; + state.self_is_leader = true; + state.has_team_id = true; + state.team_id = testTeamId(); + state.team_name = "Trail"; + state.security_round = 9; + state.has_team_psk = true; + state.team_psk = testPsk(); + return state; +} + +team::ui::TeamPageCommandReducer makeReducer() +{ + team::ui::TeamPageCommandContext context; + context.now_s = 1234; + context.self_node_id = 0x11111111; + return team::ui::TeamPageCommandReducer(context); +} + +class FakePairing final : public team::ui::ITeamPagePairingPort +{ + public: + bool startLeader(const team::TeamId& team_id, + uint32_t key_id, + const uint8_t* psk, + size_t psk_len, + uint32_t leader_id, + const char* team_name) override + { + start_leader_count += 1; + last_team_id = team_id; + last_key_id = key_id; + last_psk0 = psk ? psk[0] : 0; + last_psk_len = psk_len; + last_leader_id = leader_id; + last_team_name = team_name ? team_name : ""; + return start_leader_ok; + } + + bool startMember(uint32_t self_id) override + { + start_member_count += 1; + last_member_self_id = self_id; + return start_member_ok; + } + + void stop() override {} + team::TeamPairingStatus status() const override { return {}; } + + bool start_leader_ok = true; + bool start_member_ok = true; + int start_leader_count = 0; + int start_member_count = 0; + team::TeamId last_team_id{}; + uint32_t last_key_id = 0; + uint8_t last_psk0 = 0; + size_t last_psk_len = 0; + uint32_t last_leader_id = 0; + uint32_t last_member_self_id = 0; + std::string last_team_name; +}; + +void testLeaderStartsPairingAndReducesPendingState() +{ + auto state = makeLeaderState(); + FakePairing pairing; + team::ui::TeamPageRuntimePort runtime(nullptr, &pairing, nullptr); + + const auto effects = + team::ui::TeamPagePairingCommandAction().startPairing( + state, + makeReducer(), + runtime, + team::ui::TeamPagePairingCommandRole::Leader, + 0x11111111); + + assert(effects.accepted); + assert(effects.started_pairing); + assert(effects.failures.empty()); + assert(pairing.start_leader_count == 1); + assert(pairing.last_team_id == testTeamId()); + assert(pairing.last_key_id == 9); + assert(pairing.last_psk0 == 0x40); + assert(pairing.last_psk_len == team::proto::kTeamChannelPskSize); + assert(pairing.last_leader_id == 0x11111111); + assert(pairing.last_team_name == "Trail"); + assert(state.pending_join); + assert(state.pending_join_started_s == 1234); + assert(state.pairing_role == team::TeamPairingRole::Leader); + assert(state.pairing_state == team::TeamPairingState::LeaderBeacon); +} + +void testLeaderUsesFallbackName() +{ + auto state = makeLeaderState(); + state.team_name.clear(); + FakePairing pairing; + team::ui::TeamPageRuntimePort runtime(nullptr, &pairing, nullptr); + + const auto effects = + team::ui::TeamPagePairingCommandAction().startPairing( + state, + makeReducer(), + runtime, + team::ui::TeamPagePairingCommandRole::Leader, + 0x11111111); + + assert(effects.accepted); + assert(pairing.last_team_name == "TEAM-CAFE"); +} + +void testLeaderValidationFailuresDoNotCallRuntime() +{ + auto state = makeLeaderState(); + state.self_is_leader = false; + FakePairing pairing; + team::ui::TeamPageRuntimePort runtime(nullptr, &pairing, nullptr); + + const auto effects = + team::ui::TeamPagePairingCommandAction().startPairing( + state, + makeReducer(), + runtime, + team::ui::TeamPagePairingCommandRole::Leader, + 0x11111111); + + assert(!effects.accepted); + assert(!effects.started_pairing); + assert(effects.failures.size() == 1); + assert(effects.failures[0].kind == + team::ui::TeamPagePairingCommandFailureKind::LeaderRequired); + assert(pairing.start_leader_count == 0); + + state = makeLeaderState(); + state.has_team_psk = false; + const auto missing_keys = + team::ui::TeamPagePairingCommandAction().startPairing( + state, + makeReducer(), + runtime, + team::ui::TeamPagePairingCommandRole::Leader, + 0x11111111); + + assert(!missing_keys.accepted); + assert(missing_keys.failures.size() == 1); + assert(missing_keys.failures[0].kind == + team::ui::TeamPagePairingCommandFailureKind::PairingNotReady); + assert(pairing.start_leader_count == 0); +} + +void testMemberStartsPairingAndReducesPendingState() +{ + team::ui::TeamPageCommandState state; + FakePairing pairing; + team::ui::TeamPageRuntimePort runtime(nullptr, &pairing, nullptr); + + const auto effects = + team::ui::TeamPagePairingCommandAction().startPairing( + state, + makeReducer(), + runtime, + team::ui::TeamPagePairingCommandRole::Member, + 0x11111111); + + assert(effects.accepted); + assert(effects.started_pairing); + assert(effects.failures.empty()); + assert(pairing.start_member_count == 1); + assert(pairing.last_member_self_id == 0x11111111); + assert(state.pending_join); + assert(state.pending_join_started_s == 1234); + assert(state.pairing_role == team::TeamPairingRole::Member); + assert(state.pairing_state == team::TeamPairingState::MemberScanning); +} + +void testMemberMissingPairingAndInitFailureAreReported() +{ + team::ui::TeamPageCommandState state; + team::ui::TeamPageRuntimePort missing_runtime(nullptr, nullptr, nullptr); + + const auto missing = + team::ui::TeamPagePairingCommandAction().startPairing( + state, + makeReducer(), + missing_runtime, + team::ui::TeamPagePairingCommandRole::Member, + 0x11111111); + + assert(!missing.accepted); + assert(missing.failures.size() == 1); + assert(missing.failures[0].kind == + team::ui::TeamPagePairingCommandFailureKind::PairingNotAvailable); + + FakePairing pairing; + pairing.start_member_ok = false; + team::ui::TeamPageRuntimePort runtime(nullptr, &pairing, nullptr); + const auto failed = + team::ui::TeamPagePairingCommandAction().startPairing( + state, + makeReducer(), + runtime, + team::ui::TeamPagePairingCommandRole::Member, + 0x11111111); + + assert(!failed.accepted); + assert(failed.failures.size() == 1); + assert(failed.failures[0].kind == + team::ui::TeamPagePairingCommandFailureKind::PairingInitFailed); + assert(pairing.start_member_count == 1); +} + +} // namespace + +int main() +{ + testLeaderStartsPairingAndReducesPendingState(); + testLeaderUsesFallbackName(); + testLeaderValidationFailuresDoNotCallRuntime(); + testMemberStartsPairingAndReducesPendingState(); + testMemberMissingPairingAndInitFailureAreReported(); + return 0; +} diff --git a/modules/ui_shared/tests/test_team_page_read_model.cpp b/modules/ui_shared/tests/test_team_page_read_model.cpp new file mode 100644 index 00000000..e01ca46b --- /dev/null +++ b/modules/ui_shared/tests/test_team_page_read_model.cpp @@ -0,0 +1,163 @@ +#include "ui/screens/team/team_page_read_model.h" + +#include "platform/ui/team_ui_snapshot_store.h" + +#include + +namespace +{ + +team::TeamId testTeamId() +{ + team::TeamId id{}; + id[0] = 0xAB; + id[1] = 0xCD; + return id; +} + +team::ui::TeamPageReadModelInput makeInput() +{ + team::ui::TeamPageReadModelInput input; + input.in_team = true; + input.self_is_leader = true; + input.has_team_id = true; + input.team_id = testTeamId(); + input.security_round = 7; + input.last_update_s = 990; + input.has_team_psk = true; + + team::ui::TeamMemberUi leader; + leader.node_id = 0x11111111; + leader.name = "Ada"; + leader.leader = true; + leader.last_seen_s = 980; + leader.color_index = 1; + input.members.push_back(leader); + + team::ui::TeamMemberUi stale; + stale.node_id = 0x22222222; + stale.name = "Ben"; + stale.last_seen_s = 600; + stale.color_index = 2; + input.members.push_back(stale); + + team::ui::TeamMemberUi unknown; + unknown.node_id = 0x33333333; + unknown.name = "Cy"; + unknown.last_seen_s = 0; + unknown.color_index = 3; + input.members.push_back(unknown); + + input.selected_member_index = 1; + return input; +} + +void testSummaryProjection() +{ + const team::ui::TeamPageReadModel model(1000); + const auto input = makeInput(); + const auto summary = model.buildSummary(input); + + assert(summary.in_team); + assert(summary.self_is_leader); + assert(summary.team_name == "TEAM-ABCD"); + assert(summary.member_count == 3); + assert(summary.online_count == 1); + assert(summary.has_security_round); + assert(summary.security_round == 7); + assert(summary.last_update.kind == team::ui::TeamRelativeTimeKind::SecondsAgo); + assert(summary.last_update.value == 10); +} + +void testRowsDoNotMutatePresence() +{ + const team::ui::TeamPageReadModel model(1000); + auto input = makeInput(); + input.members[0].online = false; + + const auto rows = model.buildMemberRows(input.members); + + assert(rows.size() == 3); + assert(rows[0].source_index == 0); + assert(rows[0].name == "Ada"); + assert(rows[0].online); + assert(rows[0].leader); + assert(rows[1].source_index == 1); + assert(!rows[1].online); + assert(input.members[0].online == false); +} + +void testMemberDetailProjection() +{ + const team::ui::TeamPageReadModel model(1000); + auto input = makeInput(); + + const auto detail = model.buildSelectedMember(input); + + assert(detail.valid); + assert(detail.member.name == "Ben"); + assert(!detail.member.online); + assert(detail.last_seen.kind == team::ui::TeamRelativeTimeKind::MinutesAgo); + assert(detail.last_seen.value == 6); + assert(detail.management_actions_enabled); + + input.waiting_new_keys = true; + assert(!model.buildSelectedMember(input).management_actions_enabled); + + input.selected_member_index = 42; + assert(!model.buildSelectedMember(input).valid); +} + +void testJoinPendingProjection() +{ + const team::ui::TeamPageReadModel model(1000); + auto input = makeInput(); + input.pairing_role = team::TeamPairingRole::Leader; + input.pairing_state = team::TeamPairingState::WaitingKey; + input.pairing_team_name = "Trail"; + + const auto pending = model.buildJoinPending(input); + + assert(std::string(pending.title) == "Pairing (Leader)"); + assert(pending.show_leader_members); + assert(pending.target_team_name == "Trail"); + assert(std::string(pending.state_line) == "Waiting for keys..."); + + input.pairing_role = team::TeamPairingRole::Member; + input.pairing_state = team::TeamPairingState::Failed; + const auto member_pending = model.buildJoinPending(input); + assert(std::string(member_pending.title) == "Pairing (Member)"); + assert(!member_pending.show_leader_members); + assert(std::string(member_pending.state_line) == "Pairing failed"); +} + +void testSnapshotInputProjection() +{ + team::ui::TeamUiSnapshot snapshot; + snapshot.in_team = true; + snapshot.has_team_id = true; + snapshot.team_id = testTeamId(); + snapshot.team_name = "Trail"; + snapshot.has_team_psk = true; + + const auto input = team::ui::TeamPageReadModel::inputFromSnapshot(snapshot); + + assert(input.in_team); + assert(input.has_team_id); + assert(input.team_id == snapshot.team_id); + assert(input.team_name == "Trail"); + assert(input.has_team_psk); + assert(!input.waiting_new_keys); +} + +} // namespace + +int main() +{ + testSummaryProjection(); + testRowsDoNotMutatePresence(); + testMemberDetailProjection(); + testJoinPendingProjection(); + testSnapshotInputProjection(); + return 0; +} diff --git a/modules/ui_shared/tests/test_team_page_request_keys_action.cpp b/modules/ui_shared/tests/test_team_page_request_keys_action.cpp new file mode 100644 index 00000000..225ee57d --- /dev/null +++ b/modules/ui_shared/tests/test_team_page_request_keys_action.cpp @@ -0,0 +1,219 @@ +#include "ui/screens/team/team_page_request_keys_action.h" + +#include + +namespace +{ + +team::TeamId testTeamId() +{ + team::TeamId id{}; + id[0] = 0x42; + id[1] = 0x24; + return id; +} + +team::ui::TeamPageCommandState makeEligibleMemberState() +{ + team::ui::TeamPageCommandState state; + state.in_team = true; + state.self_is_leader = false; + state.has_team_id = true; + state.team_id = testTeamId(); + state.security_round = 7; + return state; +} + +class FakeController final : public team::ui::ITeamPageControllerPort +{ + public: + void clearKeys() override {} + void resetUiState() override {} + + bool setKeysFromPsk(const team::TeamId&, + uint32_t, + const uint8_t*, + size_t) override + { + return true; + } + + bool sendKick(const team::proto::TeamKick&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendTransferLeader(const team::proto::TeamTransferLeader&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendKeyDist(const team::proto::TeamKeyDist&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendKeyDistPlain(const team::proto::TeamKeyDist&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendKeyRequest(const team::proto::TeamKeyRequest& request, + chat::ChannelId, + chat::NodeId dest) override + { + sent_key_request_count += 1; + last_request = request; + last_dest = dest; + return send_ok; + } + + bool sendStatus(const team::proto::TeamStatus&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendStatusPlain(const team::proto::TeamStatus&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + team::TeamService::SendError lastSendError() const override + { + return error; + } + + bool send_ok = true; + team::TeamService::SendError error = + team::TeamService::SendError::None; + int sent_key_request_count = 0; + team::proto::TeamKeyRequest last_request{}; + chat::NodeId last_dest = 0; +}; + +void testEligibleMemberSendsKeyRequest() +{ + auto state = makeEligibleMemberState(); + FakeController controller; + team::ui::TeamPageRuntimePort runtime(&controller, nullptr, nullptr); + + const auto effects = + team::ui::TeamPageRequestKeysAction().requestKeys( + state, + runtime, + 0x22222222); + + assert(effects.accepted); + assert(!effects.ignored); + assert(effects.sent_request); + assert(!effects.send_failed); + assert(controller.sent_key_request_count == 1); + assert(controller.last_request.team_id == testTeamId()); + assert(controller.last_request.current_key_id == 7); + assert(controller.last_request.requester_id == 0x22222222); + assert(controller.last_dest == 0); +} + +void testSendFailureIsReported() +{ + auto state = makeEligibleMemberState(); + FakeController controller; + controller.send_ok = false; + controller.error = team::TeamService::SendError::MeshSendFail; + team::ui::TeamPageRuntimePort runtime(&controller, nullptr, nullptr); + + const auto effects = + team::ui::TeamPageRequestKeysAction().requestKeys( + state, + runtime, + 0x22222222); + + assert(effects.accepted); + assert(!effects.sent_request); + assert(effects.send_failed); + assert(effects.error == team::TeamService::SendError::MeshSendFail); +} + +void testMissingRuntimeIsReportedAsSendFailure() +{ + auto state = makeEligibleMemberState(); + team::ui::TeamPageRuntimePort runtime(nullptr, nullptr, nullptr); + + const auto effects = + team::ui::TeamPageRequestKeysAction().requestKeys( + state, + runtime, + 0x22222222); + + assert(effects.accepted); + assert(!effects.sent_request); + assert(effects.send_failed); +} + +void testInvalidStatesAreIgnored() +{ + team::ui::TeamPageRuntimePort runtime(nullptr, nullptr, nullptr); + + auto state = makeEligibleMemberState(); + state.in_team = false; + auto effects = + team::ui::TeamPageRequestKeysAction().requestKeys( + state, + runtime, + 0x22222222); + assert(!effects.accepted); + assert(effects.ignored); + assert(!effects.sent_request); + + state = makeEligibleMemberState(); + state.self_is_leader = true; + effects = team::ui::TeamPageRequestKeysAction().requestKeys( + state, + runtime, + 0x22222222); + assert(!effects.accepted); + assert(effects.ignored); + assert(!effects.sent_request); + + state = makeEligibleMemberState(); + state.has_team_id = false; + effects = team::ui::TeamPageRequestKeysAction().requestKeys( + state, + runtime, + 0x22222222); + assert(!effects.accepted); + assert(effects.ignored); + assert(!effects.sent_request); + + state = makeEligibleMemberState(); + effects = team::ui::TeamPageRequestKeysAction().requestKeys( + state, + runtime, + 0); + assert(!effects.accepted); + assert(effects.ignored); + assert(!effects.sent_request); +} + +} // namespace + +int main() +{ + testEligibleMemberSendsKeyRequest(); + testSendFailureIsReported(); + testMissingRuntimeIsReportedAsSendFailure(); + testInvalidStatesAreIgnored(); + return 0; +} diff --git a/modules/ui_shared/tests/test_team_page_runtime_port.cpp b/modules/ui_shared/tests/test_team_page_runtime_port.cpp new file mode 100644 index 00000000..acb7f646 --- /dev/null +++ b/modules/ui_shared/tests/test_team_page_runtime_port.cpp @@ -0,0 +1,282 @@ +#include "ui/screens/team/team_page_runtime_port.h" + +#include + +namespace +{ + +team::TeamId testTeamId() +{ + team::TeamId id{}; + id[0] = 0x42; + return id; +} + +class FakeController final : public team::ui::ITeamPageControllerPort +{ + public: + void clearKeys() override { clear_keys_count += 1; } + void resetUiState() override { reset_ui_count += 1; } + + bool setKeysFromPsk(const team::TeamId& team_id, + uint32_t key_id, + const uint8_t*, + size_t psk_len) override + { + last_team_id = team_id; + last_key_id = key_id; + last_psk_len = psk_len; + return set_keys_ok; + } + + bool sendKick(const team::proto::TeamKick& kick, + chat::ChannelId, + chat::NodeId dest) override + { + last_kick_target = kick.target; + last_dest = dest; + return send_ok; + } + + bool sendTransferLeader(const team::proto::TeamTransferLeader& transfer, + chat::ChannelId, + chat::NodeId dest) override + { + last_transfer_target = transfer.target; + last_dest = dest; + return send_ok; + } + + bool sendKeyDist(const team::proto::TeamKeyDist& key_dist, + chat::ChannelId, + chat::NodeId dest) override + { + last_key_id = key_dist.key_id; + last_dest = dest; + return send_ok; + } + + bool sendKeyDistPlain(const team::proto::TeamKeyDist& key_dist, + chat::ChannelId, + chat::NodeId dest) override + { + last_key_id = key_dist.key_id; + last_dest = dest; + return send_ok; + } + + bool sendKeyRequest(const team::proto::TeamKeyRequest& request, + chat::ChannelId, + chat::NodeId dest) override + { + last_key_request_current_key_id = request.current_key_id; + last_key_request_requester_id = request.requester_id; + last_dest = dest; + return send_ok; + } + + bool sendStatus(const team::proto::TeamStatus& status, + chat::ChannelId, + chat::NodeId dest) override + { + last_status_key_id = status.key_id; + last_dest = dest; + return send_ok; + } + + bool sendStatusPlain(const team::proto::TeamStatus& status, + chat::ChannelId, + chat::NodeId dest) override + { + last_status_key_id = status.key_id; + last_dest = dest; + return send_plain_ok; + } + + team::TeamService::SendError lastSendError() const override + { + return error; + } + + int clear_keys_count = 0; + int reset_ui_count = 0; + bool set_keys_ok = true; + bool send_ok = true; + bool send_plain_ok = true; + team::TeamService::SendError error = + team::TeamService::SendError::None; + team::TeamId last_team_id{}; + uint32_t last_key_id = 0; + uint32_t last_psk_len = 0; + uint32_t last_kick_target = 0; + uint32_t last_transfer_target = 0; + uint32_t last_status_key_id = 0; + uint32_t last_key_request_current_key_id = 0; + uint32_t last_key_request_requester_id = 0; + chat::NodeId last_dest = 0; +}; + +class FakePairing final : public team::ui::ITeamPagePairingPort +{ + public: + bool startLeader(const team::TeamId& team_id, + uint32_t key_id, + const uint8_t*, + size_t psk_len, + uint32_t leader_id, + const char* team_name) override + { + last_team_id = team_id; + last_key_id = key_id; + last_psk_len = psk_len; + last_leader_id = leader_id; + last_team_name = team_name ? team_name : ""; + return start_ok; + } + + bool startMember(uint32_t self_id) override + { + last_self_id = self_id; + return start_ok; + } + + void stop() override { stop_count += 1; } + + team::TeamPairingStatus status() const override + { + return pairing_status; + } + + bool start_ok = true; + int stop_count = 0; + team::TeamPairingStatus pairing_status; + team::TeamId last_team_id{}; + uint32_t last_key_id = 0; + size_t last_psk_len = 0; + uint32_t last_leader_id = 0; + uint32_t last_self_id = 0; + std::string last_team_name; +}; + +class FakeKeyStore final : public team::ui::ITeamPageKeyStorePort +{ + public: + bool saveKeysNow( + const team::TeamId& team_id, + uint32_t key_id, + const std::array& psk) override + { + last_team_id = team_id; + last_key_id = key_id; + last_psk0 = psk[0]; + return save_ok; + } + + bool save_ok = true; + team::TeamId last_team_id{}; + uint32_t last_key_id = 0; + uint8_t last_psk0 = 0; +}; + +void testPortDelegatesControllerAndPairing() +{ + FakeController controller; + FakePairing pairing; + FakeKeyStore key_store; + team::ui::TeamPageRuntimePort port(&controller, &pairing, &key_store); + + const auto id = testTeamId(); + std::array psk{}; + psk[0] = 0xA5; + + assert(port.hasController()); + assert(port.hasPairing()); + port.clearKeys(); + port.resetControllerUi(); + port.stopPairing(); + assert(controller.clear_keys_count == 1); + assert(controller.reset_ui_count == 1); + assert(pairing.stop_count == 1); + + assert(port.setKeysFromPsk(id, 7, psk.data(), psk.size())); + assert(controller.last_key_id == 7); + assert(controller.last_psk_len == psk.size()); + assert(port.saveKeysNow(id, 8, psk)); + assert(key_store.last_key_id == 8); + assert(key_store.last_psk0 == 0xA5); + + assert(port.startLeader(id, 9, psk.data(), psk.size(), 0x11111111, "Trail")); + assert(pairing.last_leader_id == 0x11111111); + assert(pairing.last_team_name == "Trail"); + assert(port.startMember(0x22222222)); + assert(pairing.last_self_id == 0x22222222); +} + +void testPortDelegatesSendsAndErrors() +{ + FakeController controller; + team::ui::TeamPageRuntimePort port(&controller, nullptr, nullptr); + + team::proto::TeamKick kick; + kick.target = 0x11111111; + assert(port.sendKick(kick, chat::ChannelId::PRIMARY, 0x22222222)); + assert(controller.last_kick_target == 0x11111111); + assert(controller.last_dest == 0x22222222); + + team::proto::TeamTransferLeader transfer; + transfer.target = 0x33333333; + assert(port.sendTransferLeader(transfer, chat::ChannelId::PRIMARY)); + assert(controller.last_transfer_target == 0x33333333); + + team::proto::TeamKeyDist key_dist; + key_dist.key_id = 12; + assert(port.sendKeyDist(key_dist, chat::ChannelId::PRIMARY, 0x44444444)); + assert(controller.last_key_id == 12); + assert(port.sendKeyDistPlain(key_dist, chat::ChannelId::PRIMARY, 0x44444444)); + + team::proto::TeamKeyRequest key_request; + key_request.current_key_id = 11; + key_request.requester_id = 0x55555555; + assert(port.sendKeyRequest(key_request, chat::ChannelId::PRIMARY, 0)); + assert(controller.last_key_request_current_key_id == 11); + assert(controller.last_key_request_requester_id == 0x55555555); + + team::proto::TeamStatus status; + status.key_id = 13; + assert(port.sendStatus(status, chat::ChannelId::PRIMARY)); + assert(port.sendStatusPlain(status, chat::ChannelId::PRIMARY)); + assert(controller.last_status_key_id == 13); + + controller.error = team::TeamService::SendError::MeshSendFail; + assert(port.lastSendError() == team::TeamService::SendError::MeshSendFail); +} + +void testMissingPortsFailSafely() +{ + team::ui::TeamPageRuntimePort port(nullptr, nullptr, nullptr); + team::TeamId id{}; + std::array psk{}; + + assert(!port.hasController()); + assert(!port.hasPairing()); + port.clearKeys(); + port.resetControllerUi(); + port.stopPairing(); + assert(!port.setKeysFromPsk(id, 1, psk.data(), psk.size())); + assert(!port.saveKeysNow(id, 1, psk)); + assert(!port.startMember(1)); + team::proto::TeamKeyRequest key_request; + assert(!port.sendKeyRequest(key_request, chat::ChannelId::PRIMARY, 0)); + assert(port.pairingStatus().state == team::TeamPairingState::Idle); + assert(port.lastSendError() == team::TeamService::SendError::None); +} + +} // namespace + +int main() +{ + testPortDelegatesControllerAndPairing(); + testPortDelegatesSendsAndErrors(); + testMissingPortsFailSafely(); + return 0; +} diff --git a/modules/ui_shared/tests/test_team_page_state_store.cpp b/modules/ui_shared/tests/test_team_page_state_store.cpp new file mode 100644 index 00000000..d2c83c26 --- /dev/null +++ b/modules/ui_shared/tests/test_team_page_state_store.cpp @@ -0,0 +1,183 @@ +#include "ui/screens/team/team_page_state_store.h" + +#include + +namespace +{ + +team::TeamId testTeamId() +{ + team::TeamId id{}; + id[0] = 0x12; + id[1] = 0x34; + return id; +} + +team::ui::TeamMemberUi makeMember(uint32_t node_id, const char* name) +{ + team::ui::TeamMemberUi member; + member.node_id = node_id; + member.name = name; + member.last_seen_s = 77; + return member; +} + +team::ui::TeamPagePersistentState makeState() +{ + team::ui::TeamPagePersistentState state; + state.in_team = true; + state.pending_join = true; + state.pending_join_started_s = 11; + state.kicked_out = true; + state.self_is_leader = true; + state.last_event_seq = 22; + state.team_chat_unread = 33; + state.team_id = testTeamId(); + state.has_team_id = true; + state.team_name = "Trail"; + state.security_round = 44; + state.last_update_s = 55; + state.team_psk[0] = 0xAA; + state.has_team_psk = true; + state.members.push_back(makeMember(0, "Self")); + state.members.push_back(makeMember(0x87654321, "Ada")); + return state; +} + +class FakeSnapshotStore final : public team::ui::ITeamUiSnapshotStore +{ + public: + bool load(team::ui::TeamUiSnapshot& out) override + { + if (!has_snapshot) + { + return false; + } + out = snapshot; + ++load_count; + return true; + } + + void save(const team::ui::TeamUiSnapshot& in) override + { + snapshot = in; + has_snapshot = true; + ++save_count; + } + + void clear() override + { + has_snapshot = false; + } + + bool has_snapshot = false; + int load_count = 0; + int save_count = 0; + team::ui::TeamUiSnapshot snapshot; +}; + +void testSnapshotFromStatePreservesPersistentFields() +{ + const auto state = makeState(); + + const auto snapshot = + team::ui::TeamPageStateStore::snapshotFromState(state); + + assert(snapshot.in_team); + assert(snapshot.pending_join); + assert(snapshot.pending_join_started_s == 11); + assert(snapshot.kicked_out); + assert(snapshot.self_is_leader); + assert(snapshot.last_event_seq == 22); + assert(snapshot.team_chat_unread == 33); + assert(snapshot.team_id == testTeamId()); + assert(snapshot.has_team_id); + assert(snapshot.team_name == "Trail"); + assert(snapshot.security_round == 44); + assert(snapshot.last_update_s == 55); + assert(snapshot.team_psk[0] == 0xAA); + assert(snapshot.has_team_psk); + assert(snapshot.members.size() == 2); +} + +void testApplySnapshotAssignsMemberColors() +{ + team::ui::TeamUiSnapshot snapshot = + team::ui::TeamPageStateStore::snapshotFromState(makeState()); + snapshot.members[0].color_index = team::ui::kTeamColorUnassigned; + snapshot.members[1].color_index = team::ui::kTeamColorUnassigned; + + team::ui::TeamPagePersistentState state; + team::ui::TeamPageStateStore::applySnapshot( + state, + snapshot, + team::ui::TeamPageColorContext(0x12345678)); + + assert(state.team_name == "Trail"); + assert(state.members.size() == 2); + assert(state.members[0].node_id == 0); + assert(state.members[0].color_index == + team::ui::team_color_index_from_node_id(0x12345678)); + assert(state.members[1].color_index == + team::ui::team_color_index_from_node_id(0x87654321)); +} + +void testLoadOnceAndRefresh() +{ + FakeSnapshotStore fake; + fake.save(team::ui::TeamPageStateStore::snapshotFromState(makeState())); + + team::ui::TeamPagePersistentState state; + team::ui::TeamPageStateStore store; + + assert(store.loadOnce(fake, + state, + team::ui::TeamPageColorContext(0x12345678))); + assert(state.team_name == "Trail"); + assert(fake.load_count == 1); + + fake.snapshot.team_name = "Changed"; + assert(!store.loadOnce(fake, + state, + team::ui::TeamPageColorContext(0x12345678))); + assert(state.team_name == "Trail"); + assert(fake.load_count == 1); + + assert(store.refresh(fake, + state, + team::ui::TeamPageColorContext(0x12345678))); + assert(state.team_name == "Changed"); + assert(fake.load_count == 2); + + store.resetLoaded(); + fake.snapshot.team_name = "Reloaded"; + assert(store.loadOnce(fake, + state, + team::ui::TeamPageColorContext(0x12345678))); + assert(state.team_name == "Reloaded"); +} + +void testSaveWritesSnapshot() +{ + FakeSnapshotStore fake; + const auto state = makeState(); + + team::ui::TeamPageStateStore store; + store.save(fake, state); + + assert(fake.save_count == 1); + assert(fake.has_snapshot); + assert(fake.snapshot.team_name == "Trail"); + assert(fake.snapshot.members.size() == 2); +} + +} // namespace + +int main() +{ + testSnapshotFromStatePreservesPersistentFields(); + testApplySnapshotAssignsMemberColors(); + testLoadOnceAndRefresh(); + testSaveWritesSnapshot(); + return 0; +} diff --git a/modules/ui_shared/tests/test_team_page_transfer_leader_action.cpp b/modules/ui_shared/tests/test_team_page_transfer_leader_action.cpp new file mode 100644 index 00000000..05231c33 --- /dev/null +++ b/modules/ui_shared/tests/test_team_page_transfer_leader_action.cpp @@ -0,0 +1,297 @@ +#include "ui/screens/team/team_page_transfer_leader_action.h" + +#include +#include + +namespace +{ + +team::TeamId testTeamId() +{ + team::TeamId id{}; + id[0] = 0xCA; + id[1] = 0xFE; + return id; +} + +team::ui::TeamMemberUi makeMember(uint32_t node_id, + const char* name, + bool leader = false) +{ + team::ui::TeamMemberUi member; + member.node_id = node_id; + member.name = name; + member.leader = leader; + member.last_seen_s = 900; + return member; +} + +team::ui::TeamPageCommandState makeState() +{ + team::ui::TeamPageCommandState state; + state.in_team = true; + state.self_is_leader = true; + state.has_team_id = true; + state.team_id = testTeamId(); + state.team_name = "Trail"; + state.security_round = 7; + state.has_team_psk = true; + state.selected_member_index = 2; + state.members.push_back(makeMember(0, "You", true)); + state.members.push_back(makeMember(0x22222222, "Ada")); + state.members.push_back(makeMember(0x33333333, "Ben")); + return state; +} + +team::ui::TeamPageCommandReducer makeReducer() +{ + team::ui::TeamPageCommandContext context; + context.now_s = 1234; + context.self_node_id = 0x11111111; + return team::ui::TeamPageCommandReducer(context); +} + +team::ui::TeamPageKeyEventState makeKeyEventState() +{ + team::ui::TeamPageKeyEventState state; + state.has_team_id = true; + state.team_id = testTeamId(); + state.security_round = 7; + state.last_event_seq = 4; + return state; +} + +class FakeKeyEventWriter final : public team::ui::ITeamPageKeyEventWriter +{ + public: + bool appendKeyEvent(const team::TeamId& team_id, + team::ui::TeamKeyEventType type, + uint32_t event_seq, + uint32_t timestamp_s, + const uint8_t* payload, + size_t payload_size) override + { + append_count += 1; + last_team_id = team_id; + last_type = type; + last_event_seq = event_seq; + last_timestamp_s = timestamp_s; + last_payload.assign(payload, payload + payload_size); + return append_ok; + } + + bool append_ok = true; + int append_count = 0; + team::TeamId last_team_id{}; + team::ui::TeamKeyEventType last_type = + team::ui::TeamKeyEventType::TeamCreated; + uint32_t last_event_seq = 0; + uint32_t last_timestamp_s = 0; + std::vector last_payload; +}; + +class FakeController final : public team::ui::ITeamPageControllerPort +{ + public: + void clearKeys() override {} + void resetUiState() override {} + + bool setKeysFromPsk(const team::TeamId&, + uint32_t, + const uint8_t*, + size_t) override + { + return true; + } + + bool sendKick(const team::proto::TeamKick&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendTransferLeader( + const team::proto::TeamTransferLeader& transfer, + chat::ChannelId, + chat::NodeId dest) override + { + transfer_count += 1; + last_transfer_target = transfer.target; + last_dest = dest; + return transfer_ok; + } + + bool sendKeyDist(const team::proto::TeamKeyDist&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendKeyDistPlain(const team::proto::TeamKeyDist&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendKeyRequest(const team::proto::TeamKeyRequest&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendStatus(const team::proto::TeamStatus&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + bool sendStatusPlain(const team::proto::TeamStatus&, + chat::ChannelId, + chat::NodeId) override + { + return true; + } + + team::TeamService::SendError lastSendError() const override + { + return error; + } + + bool transfer_ok = true; + team::TeamService::SendError error = + team::TeamService::SendError::None; + int transfer_count = 0; + uint32_t last_transfer_target = 0; + chat::NodeId last_dest = 1; +}; + +void testInvalidSelectionIsIgnored() +{ + auto state = makeState(); + state.selected_member_index = -1; + auto key_event_state = makeKeyEventState(); + FakeController controller; + team::ui::TeamPageRuntimePort runtime(&controller, nullptr, nullptr); + FakeKeyEventWriter writer; + team::ui::TeamPageKeyEventLog log(writer, 777); + + const auto effects = + team::ui::TeamPageTransferLeaderAction().transferLeader( + state, + key_event_state, + makeReducer(), + runtime, + log); + + assert(!effects.accepted); + assert(controller.transfer_count == 0); + assert(writer.append_count == 0); + assert(state.self_is_leader); + assert(state.members[0].leader); +} + +void testTransferSendsRuntimeUpdatesStateAndWritesKeyEvent() +{ + auto state = makeState(); + auto key_event_state = makeKeyEventState(); + FakeController controller; + team::ui::TeamPageRuntimePort runtime(&controller, nullptr, nullptr); + FakeKeyEventWriter writer; + team::ui::TeamPageKeyEventLog log(writer, 777); + + const auto effects = + team::ui::TeamPageTransferLeaderAction().transferLeader( + state, + key_event_state, + makeReducer(), + runtime, + log); + + assert(effects.accepted); + assert(effects.sent_transfer); + assert(effects.appended_key_event); + assert(effects.failures.empty()); + assert(controller.transfer_count == 1); + assert(controller.last_transfer_target == 0x33333333); + assert(controller.last_dest == 0); + assert(!state.self_is_leader); + assert(!state.members[0].leader); + assert(!state.members[1].leader); + assert(state.members[2].leader); + assert(writer.append_count == 1); + assert(writer.last_team_id == testTeamId()); + assert(writer.last_type == + team::ui::TeamKeyEventType::LeaderTransferred); + assert(writer.last_event_seq == 5); + assert(writer.last_timestamp_s == 777); + assert(key_event_state.last_event_seq == 5); +} + +void testTransferFailureIsReturnedAsEffect() +{ + auto state = makeState(); + auto key_event_state = makeKeyEventState(); + FakeController controller; + controller.transfer_ok = false; + controller.error = team::TeamService::SendError::MeshSendFail; + team::ui::TeamPageRuntimePort runtime(&controller, nullptr, nullptr); + FakeKeyEventWriter writer; + team::ui::TeamPageKeyEventLog log(writer, 777); + + const auto effects = + team::ui::TeamPageTransferLeaderAction().transferLeader( + state, + key_event_state, + makeReducer(), + runtime, + log); + + assert(effects.accepted); + assert(effects.failures.size() == 1); + assert(effects.failures[0].action == + team::ui::TeamPageTransferLeaderFailureAction::Transfer); + assert(effects.failures[0].kind == + team::ui::TeamPageTransferLeaderFailureKind::SendFailed); + assert(effects.failures[0].needs_keys); + assert(writer.append_count == 1); +} + +void testMissingControllerStillReducesAndWritesKeyEvent() +{ + auto state = makeState(); + auto key_event_state = makeKeyEventState(); + team::ui::TeamPageRuntimePort runtime(nullptr, nullptr, nullptr); + FakeKeyEventWriter writer; + team::ui::TeamPageKeyEventLog log(writer, 777); + + const auto effects = + team::ui::TeamPageTransferLeaderAction().transferLeader( + state, + key_event_state, + makeReducer(), + runtime, + log); + + assert(effects.accepted); + assert(!effects.sent_transfer); + assert(effects.appended_key_event); + assert(!state.self_is_leader); + assert(state.members[2].leader); + assert(writer.append_count == 1); +} + +} // namespace + +int main() +{ + testInvalidSelectionIsIgnored(); + testTransferSendsRuntimeUpdatesStateAndWritesKeyEvent(); + testTransferFailureIsReturnedAsEffect(); + testMissingControllerStillReducesAndWritesKeyEvent(); + return 0; +} diff --git a/modules/ui_shared/tests/test_team_presence_and_snapshot_codec.cpp b/modules/ui_shared/tests/test_team_presence_and_snapshot_codec.cpp new file mode 100644 index 00000000..4e626477 --- /dev/null +++ b/modules/ui_shared/tests/test_team_presence_and_snapshot_codec.cpp @@ -0,0 +1,209 @@ +#include "ui/team_persistence/team_ui_snapshot_codec.h" +#include "ui/team_presence/team_presence_model.h" + +#include +#include +#include + +namespace +{ + +team::TeamId testTeamId() +{ + team::TeamId id{}; + for (std::size_t i = 0; i < id.size(); ++i) + { + id[i] = static_cast(0xA0 + i); + } + return id; +} + +void appendU8(std::vector& out, uint8_t value) +{ + out.push_back(value); +} + +void appendU16(std::vector& out, uint16_t value) +{ + out.push_back(static_cast(value & 0xFF)); + out.push_back(static_cast((value >> 8) & 0xFF)); +} + +void appendU32(std::vector& out, uint32_t value) +{ + for (int i = 0; i < 4; ++i) + { + out.push_back(static_cast((value >> (8 * i)) & 0xFF)); + } +} + +void appendU64(std::vector& out, uint64_t value) +{ + for (int i = 0; i < 8; ++i) + { + out.push_back(static_cast((value >> (8 * i)) & 0xFF)); + } +} + +uint64_t teamIdToU64(const team::TeamId& id) +{ + uint64_t value = 0; + for (std::size_t i = 0; i < id.size(); ++i) + { + value |= static_cast(id[i]) << (8 * i); + } + return value; +} + +std::vector buildV1SnapshotBytes() +{ + std::vector out; + out.insert(out.end(), {'T', 'M', 'S', '1'}); + appendU8(out, ui::team_persistence::kTeamUiSnapshotVersionV1); + appendU8(out, 0x01); + appendU16(out, 0); + appendU32(out, 777); + appendU64(out, teamIdToU64(testTeamId())); + appendU32(out, 42); + appendU32(out, 7); + appendU32(out, 0); + appendU32(out, 0x11111111); + appendU8(out, 2); + appendU8(out, 0); + appendU8(out, 0); + appendU8(out, 0); + appendU16(out, 1); + appendU16(out, 0); + appendU32(out, 0x11111111); + appendU8(out, 2); + appendU8(out, 1); + appendU16(out, 3); + out.insert(out.end(), {'A', 'd', 'a'}); + appendU32(out, 5); + return out; +} + +team::ui::TeamUiSnapshot makeV2Snapshot() +{ + team::ui::TeamUiSnapshot snapshot; + snapshot.in_team = true; + snapshot.has_team_id = true; + snapshot.team_id = testTeamId(); + snapshot.self_is_leader = true; + snapshot.security_round = 9; + snapshot.last_event_seq = 33; + snapshot.team_chat_unread = 4; + + team::ui::TeamMemberUi self; + self.node_id = 0; + self.name = "You"; + self.leader = true; + self.last_seen_s = 1000; + snapshot.members.push_back(self); + + team::ui::TeamMemberUi remote; + remote.node_id = 0x12345678; + remote.name = "Remote teammate with an overlong name"; + remote.last_seen_s = 997; + snapshot.members.push_back(remote); + return snapshot; +} + +void testPresenceModel() +{ + std::vector members; + team::ui::TeamMemberUi recent; + recent.node_id = 1; + recent.last_seen_s = 980; + members.push_back(recent); + + team::ui::TeamMemberUi stale; + stale.node_id = 2; + stale.last_seen_s = 850; + members.push_back(stale); + + team::ui::TeamMemberUi unknown; + unknown.node_id = 3; + unknown.last_seen_s = 0; + members.push_back(unknown); + + assert(ui::team_presence::refreshTeamMemberPresence(members, 1000) == 1); + assert(members[0].online); + assert(!members[1].online); + assert(!members[2].online); + assert(ui::team_presence::isTeamMemberOnline(1000, 1005)); + assert(!ui::team_presence::isTeamMemberOnline(1000, 0)); + + const uint32_t seen = + ui::team_presence::normalizeSeenSeconds(0, 1001); + const auto touch = + ui::team_presence::touchTeamMember(members, 2, seen); + assert(touch.valid); + assert(!touch.created); + assert(touch.index == 1); + assert(members[1].last_seen_s == 1001); + assert(ui::team_presence::refreshTeamMemberPresence(members, 1001) == 2); + + const auto created = + ui::team_presence::touchTeamMember(members, 4, 1002); + assert(created.valid); + assert(created.created); + assert(members[created.index].node_id == 4); +} + +void testSnapshotV2RoundTrip() +{ + const auto input = makeV2Snapshot(); + std::vector bytes; + assert(ui::team_persistence::encodeTeamUiSnapshot(input, 1234, bytes)); + + team::ui::TeamUiSnapshot decoded; + assert(ui::team_persistence::decodeTeamUiSnapshot( + bytes.data(), bytes.size(), decoded)); + + assert(decoded.in_team); + assert(decoded.has_team_id); + assert(decoded.team_id == input.team_id); + assert(decoded.self_is_leader); + assert(decoded.security_round == 9); + assert(decoded.last_event_seq == 33); + assert(decoded.team_chat_unread == 4); + assert(decoded.members.size() == 2); + assert(decoded.members[0].node_id == 0); + assert(decoded.members[0].leader); + assert(decoded.members[0].last_seen_s == 1000); + assert(decoded.members[1].node_id == 0x12345678); + assert(decoded.members[1].last_seen_s == 997); + assert(decoded.members[1].name.size() == 24); +} + +void testSnapshotV1Compatibility() +{ + const std::vector bytes = buildV1SnapshotBytes(); + team::ui::TeamUiSnapshot decoded; + assert(ui::team_persistence::decodeTeamUiSnapshot( + bytes.data(), bytes.size(), decoded)); + + assert(decoded.in_team); + assert(decoded.has_team_id); + assert(decoded.team_id == testTeamId()); + assert(decoded.self_is_leader); + assert(decoded.security_round == 42); + assert(decoded.last_event_seq == 7); + assert(decoded.team_chat_unread == 5); + assert(decoded.members.size() == 1); + assert(decoded.members[0].node_id == 0x11111111); + assert(decoded.members[0].leader); + assert(decoded.members[0].name == "Ada"); + assert(decoded.members[0].last_seen_s == 0); +} + +} // namespace + +int main() +{ + testPresenceModel(); + testSnapshotV2RoundTrip(); + testSnapshotV1Compatibility(); + return 0; +} diff --git a/modules/ui_shared/tests/test_team_rich_payload_projector.cpp b/modules/ui_shared/tests/test_team_rich_payload_projector.cpp index c3c43795..af335ced 100644 --- a/modules/ui_shared/tests/test_team_rich_payload_projector.cpp +++ b/modules/ui_shared/tests/test_team_rich_payload_projector.cpp @@ -1,4 +1,4 @@ -#include "platform/ui/team_ui_store_runtime.h" +#include "platform/ui/team_ui_chat_log_store.h" #include "team/protocol/team_chat.h" #include "team/protocol/team_location_marker.h" #include "ui/team_presentation/team_rich_payload_projector.h" diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/hostlink_bridge_radio.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/hostlink_bridge_radio.h index a09723f7..6f1cf975 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/hostlink_bridge_radio.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/hostlink_bridge_radio.h @@ -14,6 +14,7 @@ struct AppRxStats }; void on_event(const sys::Event& event); +void on_team_state_changed(); void on_link_ready(); AppRxStats get_app_rx_stats(); diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/team/event/team_event_bus_sink.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/team/event/team_event_bus_sink.h index 3c9cbd61..c3819422 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/team/event/team_event_bus_sink.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/team/event/team_event_bus_sink.h @@ -11,6 +11,7 @@ class TeamEventBusSink : public team::ITeamEventSink void onTeamKick(const TeamKickEvent& event) override; void onTeamTransferLeader(const TeamTransferLeaderEvent& event) override; void onTeamKeyDist(const TeamKeyDistEvent& event) override; + void onTeamKeyRequest(const TeamKeyRequestEvent& event) override; void onTeamStatus(const TeamStatusEvent& event) override; void onTeamPosition(const TeamPositionEvent& event) override; void onTeamWaypoint(const TeamWaypointEvent& event) override; diff --git a/platform/esp/arduino_common/include/sys/event_bus.h b/platform/esp/arduino_common/include/sys/event_bus.h index 2f3bdea1..42f59c82 100644 --- a/platform/esp/arduino_common/include/sys/event_bus.h +++ b/platform/esp/arduino_common/include/sys/event_bus.h @@ -38,6 +38,7 @@ enum class EventType TeamKick, // Team kick received TeamTransferLeader, // Team transfer leader received TeamKeyDist, // Team key distribution received + TeamKeyRequest, // Team key refresh requested TeamStatus, // Team status received TeamPosition, // Team position received TeamWaypoint, // Team waypoint received @@ -362,6 +363,17 @@ struct TeamKeyDistEvent : public Event : Event(EventType::TeamKeyDist), data(evt) {} }; +/** + * @brief Team key request event + */ +struct TeamKeyRequestEvent : public Event +{ + team::TeamKeyRequestEvent data; + + explicit TeamKeyRequestEvent(const team::TeamKeyRequestEvent& evt) + : Event(EventType::TeamKeyRequest), data(evt) {} +}; + /** * @brief Team status event */ diff --git a/platform/esp/arduino_common/src/app_context_platform_bindings.cpp b/platform/esp/arduino_common/src/app_context_platform_bindings.cpp index 74ab6a14..92808915 100644 --- a/platform/esp/arduino_common/src/app_context_platform_bindings.cpp +++ b/platform/esp/arduino_common/src/app_context_platform_bindings.cpp @@ -275,7 +275,7 @@ void finalize_startup(app::IAppFacade& app_facade) if (team_controller) { team::ui::TeamUiSnapshot snap; - if (team::ui::team_ui_get_store().load(snap) && + if (team::ui::team_ui_snapshot_store().load(snap) && snap.has_team_id && snap.has_team_psk && snap.security_round > 0) { if (team_controller->setKeysFromPsk(snap.team_id, diff --git a/platform/esp/arduino_common/src/app_event_runtime_support.cpp b/platform/esp/arduino_common/src/app_event_runtime_support.cpp index 86881fab..82c60b04 100644 --- a/platform/esp/arduino_common/src/app_event_runtime_support.cpp +++ b/platform/esp/arduino_common/src/app_event_runtime_support.cpp @@ -29,6 +29,21 @@ bool messageAlertsEnabled() return platform::ui::settings_store::get_int(kSettingsNs, kMessageAlertsKey, 1) != 0; } +bool isTeamRuntimeEvent(sys::EventType type) +{ + return type == sys::EventType::TeamKick || + type == sys::EventType::TeamTransferLeader || + type == sys::EventType::TeamKeyDist || + type == sys::EventType::TeamKeyRequest || + type == sys::EventType::TeamStatus || + type == sys::EventType::TeamPosition || + type == sys::EventType::TeamWaypoint || + type == sys::EventType::TeamTrack || + type == sys::EventType::TeamChat || + type == sys::EventType::TeamPairing || + type == sys::EventType::TeamError; +} + void triggerMessageFeedback(app::IAppFacade& app_context) { BoardBase* board = app_context.getBoard(); @@ -216,19 +231,14 @@ bool handleUiEvent(app::IAppFacade& app_context, sys::Event* event) break; } - if (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::TeamTrack || - event->type == sys::EventType::TeamChat || - event->type == sys::EventType::TeamPairing || - event->type == sys::EventType::TeamError || - event->type == sys::EventType::SystemTick) + const bool team_runtime_event = isTeamRuntimeEvent(event->type); + if (team_runtime_event || event->type == sys::EventType::SystemTick) { team::ui::shell::handle_event(nullptr, event); + if (team_runtime_event) + { + hostlink::bridge::on_team_state_changed(); + } delete event; return true; } diff --git a/platform/esp/arduino_common/src/hostlink/hostlink_bridge_radio.cpp b/platform/esp/arduino_common/src/hostlink/hostlink_bridge_radio.cpp index 2e80a97b..c4836d6d 100644 --- a/platform/esp/arduino_common/src/hostlink/hostlink_bridge_radio.cpp +++ b/platform/esp/arduino_common/src/hostlink/hostlink_bridge_radio.cpp @@ -8,13 +8,15 @@ #include "hostlink/hostlink_types.h" #include "platform/esp/arduino_common/hostlink/hostlink_service.h" #include "platform/ui/team_ui_store_runtime.h" +#include "sys/clock.h" #include "team/protocol/team_chat.h" #include "team/protocol/team_mgmt.h" #include "team/protocol/team_portnum.h" #include "team/protocol/team_waypoint.h" +#include "ui/team_presence/team_presence_model.h" -#include #include +#include #include namespace hostlink::bridge @@ -25,89 +27,19 @@ namespace constexpr uint8_t kAppFlagTeamMeta = 1 << 0; constexpr uint8_t kAppFlagWantResponse = 1 << 1; constexpr uint8_t kAppFlagWasEncrypted = 1 << 2; +constexpr uint32_t kMinValidEpoch = 1577836800U; // 2020-01-01 uint32_t s_team_state_hash = 0; bool s_team_state_has_hash = false; AppRxStats s_app_rx_stats{}; -bool s_runtime_team_state_inited = false; -team::ui::TeamUiSnapshot s_runtime_team_state{}; -bool team_id_has_value(const team::TeamId& id) +uint32_t presenceNowForMember(uint32_t last_seen_s) { - for (uint8_t b : id) + if (last_seen_s >= kMinValidEpoch) { - if (b != 0) - { - return true; - } - } - return false; -} - -void ensure_runtime_team_state_loaded() -{ - if (s_runtime_team_state_inited) - { - return; - } - s_runtime_team_state_inited = true; -} - -int find_runtime_member_index(uint32_t node_id) -{ - for (size_t i = 0; i < s_runtime_team_state.members.size(); ++i) - { - if (s_runtime_team_state.members[i].node_id == node_id) - { - return static_cast(i); - } - } - return -1; -} - -void touch_runtime_member(uint32_t node_id, bool leader, uint32_t last_seen_s) -{ - if (node_id == 0) - { - return; - } - - int idx = find_runtime_member_index(node_id); - if (idx < 0) - { - team::ui::TeamMemberUi member{}; - member.node_id = node_id; - member.online = true; - member.leader = leader; - member.last_seen_s = last_seen_s; - s_runtime_team_state.members.push_back(member); - return; - } - - auto& member = s_runtime_team_state.members[static_cast(idx)]; - member.online = true; - member.leader = leader; - member.last_seen_s = last_seen_s; -} - -void update_runtime_team_context(const team::TeamEventContext& ctx, uint32_t timestamp_s) -{ - ensure_runtime_team_state_loaded(); - - if (team_id_has_value(ctx.team_id)) - { - s_runtime_team_state.team_id = ctx.team_id; - s_runtime_team_state.has_team_id = true; - s_runtime_team_state.in_team = true; - } - if (ctx.key_id != 0) - { - s_runtime_team_state.security_round = ctx.key_id; - } - if (timestamp_s != 0) - { - s_runtime_team_state.last_update_s = timestamp_s; + return sys::epoch_seconds_now(); } + return sys::uptime_seconds_now(); } void update_app_rx_stats(const chat::RxMeta* rx_meta) @@ -134,11 +66,8 @@ void update_app_rx_stats(const chat::RxMeta* rx_meta) } } -hostlink::TeamStateSnapshot make_team_state_snapshot() +hostlink::TeamStateSnapshot make_team_state_snapshot(const team::ui::TeamUiSnapshot& snap) { - ensure_runtime_team_state_loaded(); - const team::ui::TeamUiSnapshot& snap = s_runtime_team_state; - hostlink::TeamStateSnapshot snapshot; snapshot.in_team = snap.in_team; snapshot.pending_join = snap.pending_join; @@ -159,7 +88,9 @@ hostlink::TeamStateSnapshot make_team_state_snapshot() hostlink::TeamStateMemberSnapshot item; item.node_id = member.node_id; item.leader = member.leader; - item.online = member.online; + item.online = ::ui::team_presence::isTeamMemberOnline( + presenceNowForMember(member.last_seen_s), + member.last_seen_s); item.last_seen_s = member.last_seen_s; item.name = member.name; snapshot.members.push_back(std::move(item)); @@ -169,8 +100,11 @@ hostlink::TeamStateSnapshot make_team_state_snapshot() } void maybe_send_team_state(bool force) { + team::ui::TeamUiSnapshot snap{}; + (void)team::ui::team_ui_snapshot_store().load(snap); + std::vector payload; - const hostlink::TeamStateSnapshot snapshot = make_team_state_snapshot(); + const hostlink::TeamStateSnapshot snapshot = make_team_state_snapshot(snap); if (!hostlink::build_team_state_payload(snapshot, payload)) { return; @@ -330,25 +264,6 @@ void on_event(const sys::Event& event) case sys::EventType::TeamKick: { const auto& team_evt = static_cast(event); - uint32_t ts_s = event.timestamp / 1000; - update_runtime_team_context(team_evt.data.ctx, ts_s); - if (team_evt.data.msg.target != 0) - { - int idx = find_runtime_member_index(team_evt.data.msg.target); - if (idx >= 0) - { - s_runtime_team_state.members.erase(s_runtime_team_state.members.begin() + idx); - } - uint32_t self_id = app::messagingFacade().getSelfNodeId(); - if (self_id != 0 && team_evt.data.msg.target == self_id) - { - s_runtime_team_state.in_team = false; - s_runtime_team_state.pending_join = false; - s_runtime_team_state.kicked_out = true; - s_runtime_team_state.members.clear(); - } - } - std::vector wire; if (encode_team_mgmt_wire( team::proto::TeamMgmtType::Kick, team_evt.data.msg, wire)) @@ -376,19 +291,6 @@ void on_event(const sys::Event& event) case sys::EventType::TeamTransferLeader: { const auto& team_evt = static_cast(event); - uint32_t ts_s = event.timestamp / 1000; - update_runtime_team_context(team_evt.data.ctx, ts_s); - for (auto& member : s_runtime_team_state.members) - { - member.leader = false; - } - if (team_evt.data.msg.target != 0) - { - touch_runtime_member(team_evt.data.msg.target, true, ts_s); - } - uint32_t self_id = app::messagingFacade().getSelfNodeId(); - s_runtime_team_state.self_is_leader = (self_id != 0 && team_evt.data.msg.target == self_id); - std::vector wire; if (encode_team_mgmt_wire( team::proto::TeamMgmtType::TransferLeader, team_evt.data.msg, wire)) @@ -416,12 +318,6 @@ void on_event(const sys::Event& event) case sys::EventType::TeamKeyDist: { const auto& team_evt = static_cast(event); - update_runtime_team_context(team_evt.data.ctx, event.timestamp / 1000); - if (team_evt.data.msg.key_id != 0) - { - s_runtime_team_state.security_round = team_evt.data.msg.key_id; - } - std::vector wire; if (encode_team_mgmt_wire( team::proto::TeamMgmtType::KeyDist, team_evt.data.msg, wire)) @@ -446,39 +342,36 @@ void on_event(const sys::Event& event) } break; } + case sys::EventType::TeamKeyRequest: + { + const auto& team_evt = static_cast(event); + std::vector wire; + if (encode_team_mgmt_wire( + team::proto::TeamMgmtType::KeyRequest, team_evt.data.msg, wire)) + { + uint8_t flags = kAppFlagTeamMeta; + if (team_evt.data.ctx.key_id != 0) + { + flags |= kAppFlagWasEncrypted; + } + send_app_data(team::proto::TEAM_MGMT_APP, + team_evt.data.ctx.from, + 0, + 0, + flags, + team_evt.data.ctx.team_id.data(), + team_evt.data.ctx.key_id, + event.timestamp / 1000, + 0, + &team_evt.data.ctx.rx_meta, + wire.data(), + wire.size()); + } + break; + } case sys::EventType::TeamStatus: { const auto& team_evt = static_cast(event); - uint32_t ts_s = event.timestamp / 1000; - update_runtime_team_context(team_evt.data.ctx, ts_s); - if (team_evt.data.msg.key_id != 0) - { - s_runtime_team_state.security_round = team_evt.data.msg.key_id; - } - if (team_evt.data.msg.has_members) - { - s_runtime_team_state.members.clear(); - for (uint32_t member_id : team_evt.data.msg.members) - { - if (member_id == 0) - { - continue; - } - bool leader = (team_evt.data.msg.leader_id != 0 && member_id == team_evt.data.msg.leader_id); - touch_runtime_member(member_id, leader, ts_s); - } - } - if (team_evt.data.ctx.from != 0) - { - bool from_is_leader = (team_evt.data.msg.leader_id != 0 && team_evt.data.ctx.from == team_evt.data.msg.leader_id); - touch_runtime_member(team_evt.data.ctx.from, from_is_leader, ts_s); - } - uint32_t self_id = app::messagingFacade().getSelfNodeId(); - if (self_id != 0 && team_evt.data.msg.leader_id != 0) - { - s_runtime_team_state.self_is_leader = (team_evt.data.msg.leader_id == self_id); - } - std::vector wire; if (encode_team_mgmt_wire( team::proto::TeamMgmtType::Status, team_evt.data.msg, wire)) @@ -506,10 +399,6 @@ void on_event(const sys::Event& event) case sys::EventType::TeamPosition: { const auto& team_evt = static_cast(event); - uint32_t ts_s = event.timestamp / 1000; - update_runtime_team_context(team_evt.data.ctx, ts_s); - touch_runtime_member(team_evt.data.ctx.from, false, ts_s); - uint8_t flags = kAppFlagTeamMeta | kAppFlagWasEncrypted; send_app_data(team::proto::TEAM_POSITION_APP, team_evt.data.ctx.from, @@ -528,10 +417,6 @@ void on_event(const sys::Event& event) case sys::EventType::TeamWaypoint: { const auto& team_evt = static_cast(event); - uint32_t ts_s = event.timestamp / 1000; - update_runtime_team_context(team_evt.data.ctx, ts_s); - touch_runtime_member(team_evt.data.ctx.from, false, ts_s); - std::vector payload; if (!team::proto::encodeTeamWaypointMessage(team_evt.data.msg, payload)) { @@ -555,10 +440,6 @@ void on_event(const sys::Event& event) case sys::EventType::TeamTrack: { const auto& team_evt = static_cast(event); - uint32_t ts_s = event.timestamp / 1000; - update_runtime_team_context(team_evt.data.ctx, ts_s); - touch_runtime_member(team_evt.data.ctx.from, false, ts_s); - uint8_t flags = kAppFlagTeamMeta | kAppFlagWasEncrypted; send_app_data(team::proto::TEAM_TRACK_APP, team_evt.data.ctx.from, @@ -577,10 +458,6 @@ void on_event(const sys::Event& event) case sys::EventType::TeamChat: { const auto& team_evt = static_cast(event); - uint32_t ts_s = event.timestamp / 1000; - update_runtime_team_context(team_evt.data.ctx, ts_s); - touch_runtime_member(team_evt.data.ctx.from, false, ts_s); - std::vector wire; if (team::proto::encodeTeamChatMessage(team_evt.data.msg, wire)) { @@ -600,52 +477,23 @@ void on_event(const sys::Event& event) } break; } - case sys::EventType::TeamPairing: - { - const auto& pair_evt = static_cast(event); - ensure_runtime_team_state_loaded(); - if (pair_evt.data.has_team_id) - { - s_runtime_team_state.team_id = pair_evt.data.team_id; - s_runtime_team_state.has_team_id = true; - s_runtime_team_state.in_team = true; - } - if (pair_evt.data.key_id != 0) - { - s_runtime_team_state.security_round = pair_evt.data.key_id; - } - if (pair_evt.data.has_team_name && pair_evt.data.team_name[0] != '\0') - { - s_runtime_team_state.team_name = pair_evt.data.team_name; - } - if (pair_evt.data.state == team::TeamPairingState::Completed) - { - s_runtime_team_state.in_team = true; - s_runtime_team_state.pending_join = false; - s_runtime_team_state.kicked_out = false; - } - break; - } default: break; } +} - switch (event.type) +void on_team_state_changed() +{ + if (!hostlink::is_active()) { - case sys::EventType::TeamKick: - case sys::EventType::TeamTransferLeader: - case sys::EventType::TeamKeyDist: - case sys::EventType::TeamStatus: - case sys::EventType::TeamPosition: - case sys::EventType::TeamWaypoint: - case sys::EventType::TeamTrack: - case sys::EventType::TeamChat: - case sys::EventType::TeamPairing: - maybe_send_team_state(false); - break; - default: - break; + return; } + hostlink::Status st = hostlink::get_status(); + if (st.state != hostlink::LinkState::Ready) + { + return; + } + maybe_send_team_state(false); } void on_link_ready() diff --git a/platform/esp/arduino_common/src/hostlink/hostlink_service.cpp b/platform/esp/arduino_common/src/hostlink/hostlink_service.cpp index 9b5a0fd6..3038ec12 100644 --- a/platform/esp/arduino_common/src/hostlink/hostlink_service.cpp +++ b/platform/esp/arduino_common/src/hostlink/hostlink_service.cpp @@ -245,6 +245,16 @@ ErrorCode send_team_mgmt_wire(team::TeamController* controller, : controller->onKeyDist(msg, channel, to, false, want_response); break; } + case team::proto::TeamMgmtType::KeyRequest: + { + team::proto::TeamKeyRequest msg; + if (!team::proto::decodeTeamKeyRequest(mgmt_payload.data(), mgmt_payload.size(), &msg)) + { + return ErrorCode::InvalidParam; + } + ok = controller->onKeyRequest(msg, channel, to, false, want_response); + break; + } case team::proto::TeamMgmtType::Status: { team::proto::TeamStatus msg; diff --git a/platform/esp/arduino_common/src/team/event/team_event_bus_sink.cpp b/platform/esp/arduino_common/src/team/event/team_event_bus_sink.cpp index 485c10b5..056b21a3 100644 --- a/platform/esp/arduino_common/src/team/event/team_event_bus_sink.cpp +++ b/platform/esp/arduino_common/src/team/event/team_event_bus_sink.cpp @@ -21,6 +21,11 @@ void TeamEventBusSink::onTeamKeyDist(const TeamKeyDistEvent& event) sys::EventBus::publish(new sys::TeamKeyDistEvent(event), 0); } +void TeamEventBusSink::onTeamKeyRequest(const TeamKeyRequestEvent& event) +{ + sys::EventBus::publish(new sys::TeamKeyRequestEvent(event), 0); +} + void TeamEventBusSink::onTeamStatus(const TeamStatusEvent& event) { sys::EventBus::publish(new sys::TeamStatusEvent(event), 0); diff --git a/platform/esp/arduino_common/src/ui/screens/gps/gps_page_map.cpp b/platform/esp/arduino_common/src/ui/screens/gps/gps_page_map.cpp index 8a4493e4..d406dacd 100644 --- a/platform/esp/arduino_common/src/ui/screens/gps/gps_page_map.cpp +++ b/platform/esp/arduino_common/src/ui/screens/gps/gps_page_map.cpp @@ -10,13 +10,13 @@ #include "sys/clock.h" #include "team/protocol/team_location_marker.h" #include "ui/localization.h" +#include "ui/presentation_sources/team_map_overlay_source.h" #include "ui/screens/gps/gps_constants.h" #include "ui/screens/gps/gps_page_components.h" #include "ui/screens/gps/gps_page_lifetime.h" #include "ui/screens/gps/gps_page_styles.h" #include "ui/screens/gps/gps_state.h" #include "ui/screens/gps/gps_tracker_overlay.h" -#include "ui/screens/team/team_state.h" #include "ui/ui_common.h" #include "ui/widgets/map/map_tiles.h" #include "ui/widgets/map/map_viewport.h" @@ -170,16 +170,8 @@ void ensure_member_colors(std::vector& members) 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) + if (team::ui::team_ui_snapshot_store().load(snap) && snap.in_team && snap.has_team_id) { out_id = snap.team_id; out_members = snap.members; @@ -189,6 +181,13 @@ bool load_team_data(team::TeamId& out_id, std::vector& o return false; } +ui::presentation_sources::TeamMapOverlaySource& team_map_overlay_source() +{ + static ui::presentation_sources::TeamMapOverlaySource source( + team::ui::team_ui_snapshot_store()); + return source; +} + bool member_exists(const std::vector& members, uint32_t member_id) { return std::find_if(members.begin(), members.end(), @@ -913,7 +912,7 @@ static void member_btn_event_cb(lv_event_t* e) return; } select_member(member_id); - refresh_team_markers_from_posring(); + refresh_team_markers_from_team_source(); team::TeamId team_id{}; std::vector members; @@ -1343,7 +1342,7 @@ void refresh_team_signal_markers_from_chatlog() update_team_signal_marker_positions(); } -void refresh_team_markers_from_posring() +void refresh_team_markers_from_team_source() { if (!is_alive() || g_gps_state.map == NULL) { @@ -1375,18 +1374,10 @@ void refresh_team_markers_from_posring() } 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()) + ui::presentation_sources::TeamMapLocation location; + if (!team_map_overlay_source().loadMemberLocation( + g_gps_state.selected_member_id, + location)) { clear_team_markers(); return; @@ -1410,15 +1401,17 @@ void refresh_team_markers_from_posring() ++it; } - uint32_t color = resolve_member_color(members, g_gps_state.selected_member_id); + uint32_t color = location.color != 0 + ? location.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.member_id = location.member_id; + marker.lat_e7 = location.lat_e7; + marker.lon_e7 = location.lon_e7; + marker.ts = location.ts; marker.color = color; marker.obj = create_team_marker_obj(color); marker.label = create_team_marker_label(label_text, color); @@ -1427,9 +1420,9 @@ void refresh_team_markers_from_posring() 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; + marker.lat_e7 = location.lat_e7; + marker.lon_e7 = location.lon_e7; + marker.ts = location.ts; if (!marker.obj) { marker.obj = create_team_marker_obj(color); diff --git a/platform/esp/arduino_common/src/ui/screens/team/team_ui_store.cpp b/platform/esp/arduino_common/src/ui/screens/team/team_ui_store.cpp index ffc2d95d..efd87ed2 100644 --- a/platform/esp/arduino_common/src/ui/screens/team/team_ui_store.cpp +++ b/platform/esp/arduino_common/src/ui/screens/team/team_ui_store.cpp @@ -4,6 +4,7 @@ */ #include "platform/ui/team_ui_store_runtime.h" +#include "ui/team_persistence/team_ui_snapshot_codec.h" #include "sys/clock.h" #include @@ -20,8 +21,8 @@ namespace team namespace ui { -bool TeamUiMemoryStore::has_snapshot_ = false; -TeamUiSnapshot TeamUiMemoryStore::snapshot_{}; +bool TeamUiSnapshotMemoryStore::has_snapshot_ = false; +TeamUiSnapshot TeamUiSnapshotMemoryStore::snapshot_{}; namespace { @@ -43,7 +44,6 @@ constexpr const char* kGpxHeader = "\n" "\n"; -constexpr uint16_t kSnapshotVersion = 1; constexpr uint16_t kEventVersion = 1; constexpr uint8_t kKeysVersion = 1; constexpr uint8_t kPosringVersion = 1; @@ -69,6 +69,24 @@ uint32_t now_secs() return sys::uptime_seconds_now(); } +uint32_t member_presence_fingerprint(const TeamUiSnapshot& in) +{ + uint32_t h = 2166136261u; + auto mix = [&](uint32_t value) + { + h ^= value; + h *= 16777619u; + }; + mix(static_cast(in.members.size())); + for (const auto& member : in.members) + { + mix(member.node_id); + mix(member.leader ? 1U : 0U); + mix(member.last_seen_s); + } + return h; +} + uint64_t team_id_to_u64(const TeamId& id) { uint64_t value = 0; @@ -508,111 +526,9 @@ bool load_snapshot_from_path(const std::string& snapshot_path, TeamUiSnapshot& o 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); - } - - uint32_t chat_unread = 0; - if (off + sizeof(chat_unread) <= buf.size()) - { - if (read_u32(buf, off, chat_unread)) - { - out.team_chat_unread = chat_unread; - } - } - - (void)updated_ts; - (void)self_node_id; - (void)leader_node_id; - return true; + return ::ui::team_persistence::decodeTeamUiSnapshot(buf.data(), + buf.size(), + out); } bool save_snapshot_to_path(const std::string& dir_path, const TeamUiSnapshot& in) @@ -633,65 +549,19 @@ bool save_snapshot_to_path(const std::string& dir_path, const TeamUiSnapshot& in std::string tmp_path = dir_path + "/" + kSnapshotTmpName; std::string out_path = dir_path + "/" + kSnapshotName; + std::vector encoded; + if (!::ui::team_persistence::encodeTeamUiSnapshot(in, now_secs(), encoded)) + { + return false; + } + 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); - } - } - - write_u32(f, in.team_chat_unread); + f.write(encoded.data(), encoded.size()); f.flush(); f.close(); @@ -1015,7 +885,7 @@ bool write_posring_header(File& f, uint32_t write_offset) return true; } -class TeamUiStorePersisted : public ITeamUiStore +class TeamUiSnapshotStorePersisted : public ITeamUiSnapshotStore { public: bool load(TeamUiSnapshot& out) override @@ -1083,7 +953,8 @@ class TeamUiStorePersisted : public ITeamUiStore (in.self_is_leader != last_snapshot_self_is_leader_) || (in.security_round != last_snapshot_epoch_) || (in.has_team_psk != last_snapshot_has_psk_) || - (in.team_chat_unread != last_snapshot_unread_); + (in.team_chat_unread != last_snapshot_unread_) || + (member_presence_fingerprint(in) != last_member_presence_fingerprint_); bool seq_trigger = (in.last_event_seq >= last_snapshot_seq_ + 10); bool time_trigger = (now - last_snapshot_ts_ >= 60); @@ -1108,6 +979,7 @@ class TeamUiStorePersisted : public ITeamUiStore last_snapshot_epoch_ = in.security_round; last_snapshot_has_psk_ = in.has_team_psk; last_snapshot_unread_ = in.team_chat_unread; + last_member_presence_fingerprint_ = member_presence_fingerprint(in); } } @@ -1124,12 +996,13 @@ class TeamUiStorePersisted : public ITeamUiStore uint32_t last_snapshot_epoch_ = 0; bool last_snapshot_has_psk_ = false; uint32_t last_snapshot_unread_ = 0; + uint32_t last_member_presence_fingerprint_ = 0; }; -TeamUiStorePersisted s_persisted_store; +TeamUiSnapshotStorePersisted s_persisted_snapshot_store; } // namespace -bool TeamUiMemoryStore::load(TeamUiSnapshot& out) +bool TeamUiSnapshotMemoryStore::load(TeamUiSnapshot& out) { if (!has_snapshot_) { @@ -1139,38 +1012,86 @@ bool TeamUiMemoryStore::load(TeamUiSnapshot& out) return true; } -void TeamUiMemoryStore::save(const TeamUiSnapshot& in) +void TeamUiSnapshotMemoryStore::save(const TeamUiSnapshot& in) { snapshot_ = in; has_snapshot_ = true; } -void TeamUiMemoryStore::clear() +void TeamUiSnapshotMemoryStore::clear() { has_snapshot_ = false; } namespace { -TeamUiMemoryStore s_memory_store; -ITeamUiStore* s_store = &s_persisted_store; +TeamUiSnapshotMemoryStore s_snapshot_memory_store; +ITeamUiSnapshotStore* s_snapshot_store = &s_persisted_snapshot_store; + +class TeamUiSdChatLogStore final : public ITeamUiChatLogStore +{ + public: + bool appendText(const TeamId& team_id, + uint32_t peer_id, + bool incoming, + uint32_t ts, + const std::string& text) override + { + std::vector payload; + payload.assign(text.begin(), text.end()); + return appendStructured(team_id, + peer_id, + incoming, + ts, + team::proto::TeamChatType::Text, + payload); + } + + bool appendStructured(const TeamId& team_id, + uint32_t peer_id, + bool incoming, + uint32_t ts, + team::proto::TeamChatType type, + const std::vector& payload) override; + + bool loadRecent(const TeamId& team_id, + std::size_t max_count, + std::vector& out) override; +}; + +TeamUiSdChatLogStore s_chat_log_store; } // namespace +ITeamUiSnapshotStore& team_ui_snapshot_store() +{ + return *s_snapshot_store; +} + +void team_ui_set_snapshot_store(ITeamUiSnapshotStore* store) +{ + if (store) + { + s_snapshot_store = store; + } + else + { + s_snapshot_store = &s_snapshot_memory_store; + } +} + ITeamUiStore& team_ui_get_store() { - return *s_store; + return team_ui_snapshot_store(); } void team_ui_set_store(ITeamUiStore* store) { - if (store) - { - s_store = store; - } - else - { - s_store = &s_memory_store; - } + team_ui_set_snapshot_store(store); +} + +ITeamUiChatLogStore& team_ui_chat_log_store() +{ + return s_chat_log_store; } bool team_ui_append_key_event(const TeamId& team_id, @@ -1351,22 +1272,19 @@ bool team_ui_chatlog_append(const TeamId& team_id, 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); + return team_ui_chat_log_store().appendText(team_id, + peer_id, + incoming, + ts, + 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 TeamUiSdChatLogStore::appendStructured(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)) @@ -1421,9 +1339,24 @@ bool team_ui_chatlog_append_structured(const TeamId& team_id, return true; } -bool team_ui_chatlog_load_recent(const TeamId& team_id, - size_t max_count, - std::vector& out) +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) +{ + return team_ui_chat_log_store().appendStructured(team_id, + peer_id, + incoming, + ts, + type, + payload); +} + +bool TeamUiSdChatLogStore::loadRecent(const TeamId& team_id, + std::size_t max_count, + std::vector& out) { out.clear(); if (SD.cardType() == CARD_NONE) @@ -1548,6 +1481,13 @@ bool team_ui_chatlog_load_recent(const TeamId& team_id, return !out.empty(); } +bool team_ui_chatlog_load_recent(const TeamId& team_id, + size_t max_count, + std::vector& out) +{ + return team_ui_chat_log_store().loadRecent(team_id, max_count, out); +} + bool team_ui_save_keys_now(const TeamId& team_id, uint32_t key_id, const std::array& psk) diff --git a/platform/linux/common/include/sys/event_bus.h b/platform/linux/common/include/sys/event_bus.h index 5c983a63..0f28f29a 100644 --- a/platform/linux/common/include/sys/event_bus.h +++ b/platform/linux/common/include/sys/event_bus.h @@ -31,6 +31,7 @@ enum class EventType TeamKick, TeamTransferLeader, TeamKeyDist, + TeamKeyRequest, TeamStatus, TeamPosition, TeamWaypoint, @@ -312,6 +313,12 @@ struct TeamKeyDistEvent : public Event explicit TeamKeyDistEvent(const team::TeamKeyDistEvent& evt) : Event(EventType::TeamKeyDist), data(evt) {} }; +struct TeamKeyRequestEvent : public Event +{ + team::TeamKeyRequestEvent data; + explicit TeamKeyRequestEvent(const team::TeamKeyRequestEvent& evt) : Event(EventType::TeamKeyRequest), data(evt) {} +}; + struct TeamStatusEvent : public Event { team::TeamStatusEvent data; diff --git a/platform/linux/common/src/app/linux_app_facade.cpp b/platform/linux/common/src/app/linux_app_facade.cpp index f151e59b..ae3bcb6d 100644 --- a/platform/linux/common/src/app/linux_app_facade.cpp +++ b/platform/linux/common/src/app/linux_app_facade.cpp @@ -35,6 +35,7 @@ bool isTeamUiEvent(const ::sys::Event& event) return event.type == ::sys::EventType::TeamKick || event.type == ::sys::EventType::TeamTransferLeader || event.type == ::sys::EventType::TeamKeyDist || + event.type == ::sys::EventType::TeamKeyRequest || event.type == ::sys::EventType::TeamStatus || event.type == ::sys::EventType::TeamPosition || event.type == ::sys::EventType::TeamWaypoint || diff --git a/platform/linux/common/src/app/linux_app_services.cpp b/platform/linux/common/src/app/linux_app_services.cpp index b7de3e5c..1f2e6e2e 100644 --- a/platform/linux/common/src/app/linux_app_services.cpp +++ b/platform/linux/common/src/app/linux_app_services.cpp @@ -703,6 +703,11 @@ class LinuxTeamEventBusSink final : public ::team::ITeamEventSink ::sys::EventBus::publish(new ::sys::TeamKeyDistEvent(event), 0); } + void onTeamKeyRequest(const ::team::TeamKeyRequestEvent& event) override + { + ::sys::EventBus::publish(new ::sys::TeamKeyRequestEvent(event), 0); + } + void onTeamStatus(const ::team::TeamStatusEvent& event) override { ::sys::EventBus::publish(new ::sys::TeamStatusEvent(event), 0); @@ -1179,7 +1184,7 @@ void applyPairingEventFallback(::chat::contacts::ContactService& contact_service const ::team::TeamPairingEvent& event) { ::team::ui::TeamUiSnapshot snapshot{}; - (void)::team::ui::team_ui_get_store().load(snapshot); + (void)::team::ui::team_ui_snapshot_store().load(snapshot); if (event.has_team_id) { @@ -1270,7 +1275,7 @@ void applyPairingEventFallback(::chat::contacts::ContactService& contact_service snapshot.pending_join_started_s = 0; } - ::team::ui::team_ui_get_store().save(snapshot); + ::team::ui::team_ui_snapshot_store().save(snapshot); } void applyKeyDistEventFallback(::chat::contacts::ContactService& contact_service, @@ -1279,7 +1284,7 @@ void applyKeyDistEventFallback(::chat::contacts::ContactService& contact_service const ::team::TeamKeyDistEvent& event) { ::team::ui::TeamUiSnapshot snapshot{}; - (void)::team::ui::team_ui_get_store().load(snapshot); + (void)::team::ui::team_ui_snapshot_store().load(snapshot); snapshot.team_id = event.msg.team_id; snapshot.has_team_id = true; @@ -1347,7 +1352,7 @@ void applyKeyDistEventFallback(::chat::contacts::ContactService& contact_service snapshot.team_psk); } - ::team::ui::team_ui_get_store().save(snapshot); + ::team::ui::team_ui_snapshot_store().save(snapshot); } bool isTeamUiEvent(const ::sys::Event& event) @@ -1355,6 +1360,7 @@ bool isTeamUiEvent(const ::sys::Event& event) return event.type == ::sys::EventType::TeamKick || event.type == ::sys::EventType::TeamTransferLeader || event.type == ::sys::EventType::TeamKeyDist || + event.type == ::sys::EventType::TeamKeyRequest || event.type == ::sys::EventType::TeamStatus || event.type == ::sys::EventType::TeamPosition || event.type == ::sys::EventType::TeamWaypoint || @@ -1931,7 +1937,7 @@ void LinuxAppServices::clearMessageDb() ensureServicesReady(); impl().chat_model.clearAll(); impl().chat_store.clearAll(); - ::team::ui::team_ui_get_store().clear(); + ::team::ui::team_ui_snapshot_store().clear(); } bool LinuxAppServices::isBleEnabled() const @@ -1959,7 +1965,7 @@ void LinuxAppServices::updateCoreServices() } ::team::ui::TeamUiSnapshot snap; - const bool has_team = ::team::ui::team_ui_get_store().load(snap) && snap.in_team; + const bool has_team = ::team::ui::team_ui_snapshot_store().load(snap) && snap.in_team; impl_->team_track_sampler.update(&impl_->team_controller, has_team); } diff --git a/platform/linux/common/src/platform/ui/team_ui_store_runtime.cpp b/platform/linux/common/src/platform/ui/team_ui_store_runtime.cpp index 6e7ee688..fd89ca83 100644 --- a/platform/linux/common/src/platform/ui/team_ui_store_runtime.cpp +++ b/platform/linux/common/src/platform/ui/team_ui_store_runtime.cpp @@ -151,15 +151,78 @@ TeamUiRuntimeMemory& runtime_memory() constexpr size_t kMaxChatEntriesPerTeam = 128; constexpr size_t kMaxPosSamplesPerTeam = 96; -TeamUiMemoryStore s_memory_store{}; -ITeamUiStore* s_store = &s_memory_store; +TeamUiSnapshotMemoryStore s_snapshot_memory_store{}; +ITeamUiSnapshotStore* s_snapshot_store = &s_snapshot_memory_store; + +class TeamUiRuntimeChatLogStore final : public ITeamUiChatLogStore +{ + public: + bool appendText(const TeamId& team_id, + uint32_t peer_id, + bool incoming, + uint32_t ts, + const std::string& text) override + { + return appendStructured(team_id, + peer_id, + incoming, + ts, + team::proto::TeamChatType::Text, + std::vector(text.begin(), text.end())); + } + + bool appendStructured(const TeamId& team_id, + uint32_t peer_id, + bool incoming, + uint32_t ts, + team::proto::TeamChatType type, + const std::vector& payload) override + { + auto& chat_log = runtime_memory().chat_logs[team_id_to_u64(team_id)]; + TeamChatLogEntry entry; + entry.incoming = incoming; + entry.ts = ts; + entry.peer_id = peer_id; + entry.type = type; + entry.payload = payload; + chat_log.push_back(std::move(entry)); + while (chat_log.size() > kMaxChatEntriesPerTeam) + { + chat_log.pop_front(); + } + return true; + } + + bool loadRecent(const TeamId& team_id, + std::size_t max_count, + std::vector& out) override + { + out.clear(); + const auto it = runtime_memory().chat_logs.find(team_id_to_u64(team_id)); + if (it == runtime_memory().chat_logs.end()) + { + return false; + } + + const auto& log = it->second; + const size_t available = log.size(); + const size_t count = std::min(max_count, available); + out.reserve(count); + auto start = log.end(); + std::advance(start, -static_cast(count)); + out.assign(start, log.end()); + return !out.empty(); + } +}; + +TeamUiRuntimeChatLogStore s_chat_log_store{}; } // namespace -bool TeamUiMemoryStore::has_snapshot_ = false; -TeamUiSnapshot TeamUiMemoryStore::snapshot_{}; +bool TeamUiSnapshotMemoryStore::has_snapshot_ = false; +TeamUiSnapshot TeamUiSnapshotMemoryStore::snapshot_{}; -bool TeamUiMemoryStore::load(TeamUiSnapshot& out) +bool TeamUiSnapshotMemoryStore::load(TeamUiSnapshot& out) { if (!has_snapshot_) { @@ -170,27 +233,42 @@ bool TeamUiMemoryStore::load(TeamUiSnapshot& out) return true; } -void TeamUiMemoryStore::save(const TeamUiSnapshot& in) +void TeamUiSnapshotMemoryStore::save(const TeamUiSnapshot& in) { snapshot_ = in; has_snapshot_ = true; } -void TeamUiMemoryStore::clear() +void TeamUiSnapshotMemoryStore::clear() { snapshot_ = TeamUiSnapshot{}; has_snapshot_ = false; runtime_memory() = TeamUiRuntimeMemory{}; } +ITeamUiSnapshotStore& team_ui_snapshot_store() +{ + return *s_snapshot_store; +} + +void team_ui_set_snapshot_store(ITeamUiSnapshotStore* store) +{ + s_snapshot_store = store ? store : &s_snapshot_memory_store; +} + ITeamUiStore& team_ui_get_store() { - return *s_store; + return team_ui_snapshot_store(); } void team_ui_set_store(ITeamUiStore* store) { - s_store = store ? store : &s_memory_store; + team_ui_set_snapshot_store(store); +} + +ITeamUiChatLogStore& team_ui_chat_log_store() +{ + return s_chat_log_store; } bool team_ui_append_key_event(const TeamId& team_id, @@ -205,7 +283,7 @@ bool team_ui_append_key_event(const TeamId& team_id, (void)len; TeamUiSnapshot snapshot{}; - if (!team_ui_get_store().load(snapshot)) + if (!team_ui_snapshot_store().load(snapshot)) { snapshot = TeamUiSnapshot{}; } @@ -221,7 +299,7 @@ bool team_ui_append_key_event(const TeamId& team_id, snapshot.kicked_out = false; } - team_ui_get_store().save(snapshot); + team_ui_snapshot_store().save(snapshot); return true; } @@ -234,14 +312,14 @@ bool team_ui_posring_append(const TeamId& team_id, uint32_t ts) { auto& positions = runtime_memory().latest_positions[team_id_to_u64(team_id)]; - positions.push_back(TeamPosSample{ - .member_id = member_id, - .lat_e7 = lat_e7, - .lon_e7 = lon_e7, - .alt_m = alt_m, - .speed_dmps = speed_dmps, - .ts = ts, - }); + TeamPosSample sample; + sample.member_id = member_id; + sample.lat_e7 = lat_e7; + sample.lon_e7 = lon_e7; + sample.alt_m = alt_m; + sample.speed_dmps = speed_dmps; + sample.ts = ts; + positions.push_back(sample); while (positions.size() > kMaxPosSamplesPerTeam) { positions.pop_front(); @@ -268,12 +346,11 @@ bool team_ui_chatlog_append(const TeamId& team_id, uint32_t ts, const std::string& text) { - return team_ui_chatlog_append_structured(team_id, - peer_id, - incoming, - ts, - team::proto::TeamChatType::Text, - std::vector(text.begin(), text.end())); + return team_ui_chat_log_store().appendText(team_id, + peer_id, + incoming, + ts, + text); } bool team_ui_chatlog_append_structured(const TeamId& team_id, @@ -283,40 +360,19 @@ bool team_ui_chatlog_append_structured(const TeamId& team_id, team::proto::TeamChatType type, const std::vector& payload) { - auto& chat_log = runtime_memory().chat_logs[team_id_to_u64(team_id)]; - chat_log.push_back(TeamChatLogEntry{ - .incoming = incoming, - .ts = ts, - .peer_id = peer_id, - .type = type, - .payload = payload, - }); - while (chat_log.size() > kMaxChatEntriesPerTeam) - { - chat_log.pop_front(); - } - return true; + return team_ui_chat_log_store().appendStructured(team_id, + peer_id, + incoming, + ts, + type, + payload); } bool team_ui_chatlog_load_recent(const TeamId& team_id, size_t max_count, std::vector& out) { - out.clear(); - const auto it = runtime_memory().chat_logs.find(team_id_to_u64(team_id)); - if (it == runtime_memory().chat_logs.end()) - { - return false; - } - - const auto& log = it->second; - const size_t available = log.size(); - const size_t count = std::min(max_count, available); - out.reserve(count); - auto start = log.end(); - std::advance(start, -static_cast(count)); - out.assign(start, log.end()); - return !out.empty(); + return team_ui_chat_log_store().loadRecent(team_id, max_count, out); } bool team_ui_save_keys_now(const TeamId& team_id, @@ -324,7 +380,7 @@ bool team_ui_save_keys_now(const TeamId& team_id, const std::array& psk) { TeamUiSnapshot snapshot{}; - if (!team_ui_get_store().load(snapshot)) + if (!team_ui_snapshot_store().load(snapshot)) { snapshot = TeamUiSnapshot{}; } @@ -333,7 +389,7 @@ bool team_ui_save_keys_now(const TeamId& team_id, snapshot.security_round = key_id; snapshot.team_psk = psk; snapshot.has_team_psk = true; - team_ui_get_store().save(snapshot); + team_ui_snapshot_store().save(snapshot); return true; } diff --git a/platform/linux/uconsole/src/uconsole_dashboard_model.cpp b/platform/linux/uconsole/src/uconsole_dashboard_model.cpp index cffc97e5..7004d6a5 100644 --- a/platform/linux/uconsole/src/uconsole_dashboard_model.cpp +++ b/platform/linux/uconsole/src/uconsole_dashboard_model.cpp @@ -23,6 +23,8 @@ #include "platform/ui/team_ui_store_runtime.h" #include "sys/clock.h" #include "team/protocol/team_chat.h" +#include "ui/presentation_sources/team_map_overlay_source.h" +#include "ui/team_presence/team_presence_model.h" #include "uconsole/uconsole_hardware_probe.h" namespace trailmate::uconsole @@ -555,7 +557,7 @@ void appendNodeTimeline(std::vector& out, void buildTeamOverview(UConsoleDashboardSnapshot& out) { ::team::ui::TeamUiSnapshot team_snapshot{}; - if (!::team::ui::team_ui_get_store().load(team_snapshot)) + if (!::team::ui::team_ui_snapshot_store().load(team_snapshot)) { out.team_summary = "No team state stored locally."; return; @@ -622,7 +624,11 @@ void buildTeamOverview(UConsoleDashboardSnapshot& out) continue; } std::string detail = formatLastSeen(member.last_seen_s); - detail += member.online ? " / online" : " / offline"; + detail += ::ui::team_presence::isTeamMemberOnline( + sys::epoch_seconds_now(), + member.last_seen_s) + ? " / online" + : " / offline"; if (member.leader) { detail += " / leader"; @@ -663,19 +669,20 @@ void buildTeamOverview(UConsoleDashboardSnapshot& out) } } - std::vector<::team::ui::TeamPosSample> positions{}; - if (::team::ui::team_ui_posring_load_latest(team_snapshot.team_id, - positions)) + ::ui::presentation_sources::TeamMapOverlaySource team_locations( + ::team::ui::team_ui_snapshot_store()); + ::ui::presentation_sources::TeamMapLocation positions[4]{}; + const std::size_t position_count = + team_locations.latestTeamLocations(positions, 4U); + if (position_count > 0) { - std::sort(positions.begin(), - positions.end(), + std::sort(positions, + positions + position_count, [](const auto& left, const auto& right) { return left.ts > right.ts; }); - const std::size_t count = - std::min(positions.size(), 4U); - for (std::size_t index = 0; index < count; ++index) + for (std::size_t index = 0; index < position_count; ++index) { const auto& position = positions[index]; const double lat = diff --git a/platform/linux/uconsole/src/uconsole_map_workspace_model.cpp b/platform/linux/uconsole/src/uconsole_map_workspace_model.cpp index a4333ffc..87597c9e 100644 --- a/platform/linux/uconsole/src/uconsole_map_workspace_model.cpp +++ b/platform/linux/uconsole/src/uconsole_map_workspace_model.cpp @@ -253,7 +253,7 @@ UConsoleMapWorkspaceModel::UConsoleMapWorkspaceModel( : services_(services), legacy_map_source_(legacy_gps_source_, legacy_map_state_, - &::team::ui::team_ui_get_store()), + &::team::ui::team_ui_snapshot_store()), legacy_map_sink_(legacy_gps_source_, legacy_map_state_), presentation_model_(legacy_map_source_, legacy_map_sink_), zoom_(std::clamp( diff --git a/tools/architecture/check_phase7_runtime_ownership_ready.py b/tools/architecture/check_phase7_runtime_ownership_ready.py index 0a0e896c..42638430 100644 --- a/tools/architecture/check_phase7_runtime_ownership_ready.py +++ b/tools/architecture/check_phase7_runtime_ownership_ready.py @@ -197,14 +197,19 @@ def check_required_files() -> int: "modules/ui_shared/tests/test_legacy_chat_delivery_event_bridge.cpp", "modules/ui_shared/include/ui/team_actions/team_action_types.h", "modules/ui_shared/include/ui/team_actions/team_action_sink.h", - "modules/ui_shared/include/ui/team_actions/legacy_team_action_bridge.h", - "modules/ui_shared/src/ui/team_actions/legacy_team_action_bridge.cpp", + "modules/ui_shared/include/ui/team_actions/team_action_runtime_sink.h", + "modules/ui_shared/src/ui/team_actions/team_action_runtime_sink.cpp", + "modules/ui_shared/include/ui/team_actions/team_runtime_adapters.h", + "modules/ui_shared/src/ui/team_actions/team_runtime_adapters.cpp", "modules/ui_shared/tests/test_team_action_types.cpp", - "modules/ui_shared/tests/test_legacy_team_action_bridge.cpp", + "modules/ui_shared/tests/test_team_action_runtime_sink.cpp", "modules/ui_shared/include/ui/team_presentation/team_rich_payload_display.h", "modules/ui_shared/include/ui/team_presentation/team_rich_payload_projector.h", "modules/ui_shared/src/ui/team_presentation/team_rich_payload_projector.cpp", "modules/ui_shared/tests/test_team_rich_payload_projector.cpp", + "modules/ui_shared/include/ui/presentation_sources/team_map_overlay_source.h", + "modules/ui_shared/src/ui/presentation_sources/team_map_overlay_source.cpp", + "modules/ui_shared/tests/test_team_map_overlay_source.cpp", "modules/ui_presentation/include/ui_presentation/key_verification/key_verification_snapshot.h", "modules/ui_presentation/include/ui_presentation/key_verification/key_verification_source.h", "modules/ui_presentation/include/ui_presentation/key_verification/key_verification_action_sink.h", @@ -249,6 +254,60 @@ def check_required_files() -> int: "modules/ui_shared/include/ui/screens/chat/chat_ui_refresh_sink.h", "modules/ui_shared/include/ui/screens/chat/chat_page_runtime_event_pump.h", "modules/ui_shared/src/ui/screens/chat/chat_page_runtime_event_pump.cpp", + "modules/ui_shared/include/ui/screens/chat/chat_team_workflow.h", + "modules/ui_shared/src/ui/screens/chat/chat_team_workflow.cpp", + "modules/ui_shared/tests/test_chat_team_workflow.cpp", + "modules/ui_shared/include/ui/screens/team/team_page_read_model.h", + "modules/ui_shared/src/ui/screens/team/team_page_read_model.cpp", + "modules/ui_shared/tests/test_team_page_read_model.cpp", + "modules/ui_shared/include/ui/screens/team/team_page_types.h", + "modules/ui_shared/include/ui/screens/team/team_page_flow_controller.h", + "modules/ui_shared/src/ui/screens/team/team_page_flow_controller.cpp", + "modules/ui_shared/tests/test_team_page_flow_controller.cpp", + "modules/ui_shared/include/ui/screens/team/team_page_lvgl_renderer.h", + "modules/ui_shared/src/ui/screens/team/team_page_lvgl_renderer.cpp", + "modules/ui_shared/include/ui/screens/team/team_page_command_reducer.h", + "modules/ui_shared/src/ui/screens/team/team_page_command_reducer.cpp", + "modules/ui_shared/tests/test_team_page_command_reducer.cpp", + "modules/ui_shared/include/ui/screens/team/team_page_create_team_action.h", + "modules/ui_shared/src/ui/screens/team/team_page_create_team_action.cpp", + "modules/ui_shared/tests/test_team_page_create_team_action.cpp", + "modules/ui_shared/include/ui/screens/team/team_page_pairing_command_action.h", + "modules/ui_shared/src/ui/screens/team/team_page_pairing_command_action.cpp", + "modules/ui_shared/tests/test_team_page_pairing_command_action.cpp", + "modules/ui_shared/include/ui/screens/team/team_page_request_keys_action.h", + "modules/ui_shared/src/ui/screens/team/team_page_request_keys_action.cpp", + "modules/ui_shared/tests/test_team_page_request_keys_action.cpp", + "modules/ui_shared/include/ui/screens/team/team_page_event_reducer.h", + "modules/ui_shared/src/ui/screens/team/team_page_event_reducer.cpp", + "modules/ui_shared/tests/test_team_page_event_reducer.cpp", + "modules/ui_shared/include/ui/screens/team/team_page_event_effect_sink.h", + "modules/ui_shared/src/ui/screens/team/team_page_event_effect_sink.cpp", + "modules/ui_shared/tests/test_team_page_event_effect_sink.cpp", + "modules/ui_shared/include/ui/screens/team/team_page_key_event_log.h", + "modules/ui_shared/src/ui/screens/team/team_page_key_event_log.cpp", + "modules/ui_shared/tests/test_team_page_key_event_log.cpp", + "modules/ui_shared/include/ui/screens/team/team_page_runtime_port.h", + "modules/ui_shared/src/ui/screens/team/team_page_runtime_adapters.cpp", + "modules/ui_shared/src/ui/screens/team/team_page_runtime_port.cpp", + "modules/ui_shared/tests/test_team_page_runtime_port.cpp", + "modules/ui_shared/include/ui/screens/team/team_page_activity_sink.h", + "modules/ui_shared/src/ui/screens/team/team_page_activity_adapters.cpp", + "modules/ui_shared/src/ui/screens/team/team_page_activity_sink.cpp", + "modules/ui_shared/tests/test_team_page_activity_sink.cpp", + "modules/ui_shared/include/ui/screens/team/team_page_deferred_dispatch.h", + "modules/ui_shared/src/ui/screens/team/team_page_deferred_dispatch.cpp", + "modules/ui_shared/src/ui/screens/team/team_page_deferred_dispatch_adapters.cpp", + "modules/ui_shared/tests/test_team_page_deferred_dispatch.cpp", + "modules/ui_shared/include/ui/screens/team/team_page_kick_confirm_action.h", + "modules/ui_shared/src/ui/screens/team/team_page_kick_confirm_action.cpp", + "modules/ui_shared/tests/test_team_page_kick_confirm_action.cpp", + "modules/ui_shared/include/ui/screens/team/team_page_transfer_leader_action.h", + "modules/ui_shared/src/ui/screens/team/team_page_transfer_leader_action.cpp", + "modules/ui_shared/tests/test_team_page_transfer_leader_action.cpp", + "modules/ui_shared/include/ui/screens/team/team_page_state_store.h", + "modules/ui_shared/src/ui/screens/team/team_page_state_store.cpp", + "modules/ui_shared/tests/test_team_page_state_store.cpp", "tools/architecture/check_phase7_runtime_ownership_ready.py", ] @@ -364,7 +423,7 @@ def check_docs() -> int: "not owned by", "composition-root ownership", "Team location/command action ownership", - "LegacyTeamActionBridge", + "TeamActionRuntimeSink", "Team rich payload display ownership", "TeamRichPayloadProjector", "Chat retry/cancel/clear failure actions", @@ -400,14 +459,14 @@ def check_docs() -> int: "Renderer/controller code may collect user input", "must not", "encode Team payloads", - "LegacyTeamActionBridge", + "TeamActionRuntimeSink", ], "docs/specification/TEAM_ACTION_RUNTIME_SPEC.md": [ "Team action payload encoding belongs to Team action/runtime adapters", "TeamActionRequest", "ITeamActionSink", "ITeamLocationSource", - "LegacyTeamActionBridge", + "TeamActionRuntimeSink", "Phase 7.2 does not turn Team into DirectPeer or Channel chat", ], "docs/audits/CHAT_RUNTIME_EVENT_PUMP_AUDIT.md": [ @@ -461,7 +520,8 @@ def check_docs() -> int: "TeamRichPayloadProjector", "TeamChatLogEntry -> TeamRichPayloadDisplay", "TeamChatPresentationSource", - "Phase 7.8 does not", + "TeamMessageRichPayload", + "has_team_rich_payload", ], "docs/specification/TEAM_POSITION_PICKER_RENDERER_SPEC.md": [ "Picker rendering belongs to `TeamPositionPickerRenderer`", @@ -481,16 +541,15 @@ def check_docs() -> int: "Status", "LegacyChatDeliveryEventBridge", "LegacyChatDeliveryActionBridge", - "LegacyTeamActionBridge", "LegacyKeyVerificationSource", "LegacyKeyVerificationActionSink", "ChatUiController` key verification modal rendering", - "ChatUiController` Team payload encoding", + "ChatUiController` Team payload/action send special case", "ChatUiController` delivery mutation", "ChatUiController` runtime event pump", "ChatUiController` Team rich payload formatting", "ChatUiController` Team position picker renderer", - "MessageRow` Team rich display limitations", + "Team rich message row structured payload", "Map tile path/cache legacy runtime", "Map tile visible plan in platform renderer", "ESP decoded LVGL tile cache", @@ -646,16 +705,16 @@ def check_legacy_burndown_register() -> int: required_surfaces = [ "LegacyChatDeliveryEventBridge", "LegacyChatDeliveryActionBridge", - "LegacyTeamActionBridge", + "TeamActionRuntimeSink", "LegacyKeyVerificationSource", "LegacyKeyVerificationActionSink", "ChatUiController` key verification modal rendering", - "ChatUiController` Team payload encoding", + "ChatUiController` Team payload/action send special case", "ChatUiController` delivery mutation", "ChatUiController` runtime event pump", "ChatUiController` Team rich payload formatting", "ChatUiController` Team position picker renderer", - "MessageRow` Team rich display limitations", + "Team rich message row structured payload", "Map tile path/cache legacy runtime", "Map tile visible plan in platform renderer", "ESP decoded LVGL tile cache", @@ -920,6 +979,14 @@ def check_presentation_source_enrichment() -> int: def check_composition_roots_own_delivery() -> int: failures = 0 + active_legacy_roots = [ + "legacy/app_implementations/linux_sim/src/linux_sim_composition_root.h", + "legacy/app_implementations/linux_uconsole/src/uconsole_composition_root.h", + "legacy/app_implementations/linux_uconsole/src/uconsole_composition_root.cpp", + ] + if not any(exists(path) for path in active_legacy_roots): + return failures + roots = { "legacy/app_implementations/linux_sim/src/linux_sim_composition_root.h": [ "ChatDeliveryReadModel delivery_read_model_", @@ -950,7 +1017,6 @@ def check_composition_roots_own_delivery() -> int: for path, tokens in roots.items(): if not exists(path): - failures += fail(f"missing composition root file: {path}") continue text = read_text(path) for token in tokens: @@ -1188,9 +1254,11 @@ def check_team_action_type_shape() -> int: text = read_text(types) for token in [ "enum class TeamActionKind", + "LocationShare", "LocationMarker", "Command", "enum class TeamCommandKind", + "TeamLocationShareRequest", "TeamLocationMarkerRequest", "use_current_location", "TeamLocationSnapshot", @@ -1216,22 +1284,23 @@ def check_team_action_type_shape() -> int: return failures -def check_team_action_bridge_boundary() -> int: +def check_team_action_runtime_sink_boundary() -> int: failures = 0 - bridge_files = [ - "modules/ui_shared/include/ui/team_actions/legacy_team_action_bridge.h", - "modules/ui_shared/src/ui/team_actions/legacy_team_action_bridge.cpp", + sink_files = [ + "modules/ui_shared/include/ui/team_actions/team_action_runtime_sink.h", + "modules/ui_shared/src/ui/team_actions/team_action_runtime_sink.cpp", ] - for path in bridge_files: + for path in sink_files: if not exists(path): - failures += fail(f"missing Team action bridge file: {path}") + failures += fail(f"missing Team action runtime sink file: {path}") continue text = strip_cpp_comments(read_text(path)) for token in [ "lvgl.h", "lv_obj_t", "LegacyChatActionSink", + "Legacy" + "TeamActionBridge", "DirectPeer", "ConversationKind::Direct", "ConversationKind::Channel", @@ -1241,19 +1310,49 @@ def check_team_action_bridge_boundary() -> int: if token in text: failures += fail(f"{path} contains forbidden Team action token: {token}") - source = "modules/ui_shared/src/ui/team_actions/legacy_team_action_bridge.cpp" + for path in [ + "modules/ui_shared/include/ui/team_actions/" + "legacy_" + "team_action_bridge.h", + "modules/ui_shared/src/ui/team_actions/" + "legacy_" + "team_action_bridge.cpp", + "modules/ui_shared/tests/test_" + "legacy_" + "team_action_bridge.cpp", + ]: + if exists(path): + failures += fail(f"removed Team action bridge path still exists: {path}") + + source = "modules/ui_shared/src/ui/team_actions/team_action_runtime_sink.cpp" if exists(source): text = read_text(source) for token in [ + "TeamActionRuntimeSink", "ITeamLocationSource", "currentTeamLocation", + "sendLocationShare", "encodeTeamChatLocation", "encodeTeamChatCommand", - "team_ui_chatlog_append_structured", + "ITeamUiSnapshotStore", + "ITeamUiChatLogStore", + "appendStructured", "sendTeamChat", ]: if token not in text: - failures += fail(f"LegacyTeamActionBridge source missing token: {token}") + failures += fail(f"TeamActionRuntimeSink source missing token: {token}") + for token in ["team_ui_chatlog_append_structured", "team_ui_get_store"]: + if token in strip_cpp_comments(text): + failures += fail( + f"TeamActionRuntimeSink source still uses legacy Team store token: {token}" + ) + + runtime_adapter = "modules/ui_shared/src/ui/team_actions/team_runtime_adapters.cpp" + if exists(runtime_adapter): + text = strip_cpp_comments(read_text(runtime_adapter)) + for token in [ + "TeamControllerChatCommandPort", + "GpsTeamLocationSource", + "setKeysFromPsk", + "sendTeamChat", + "platform::ui::gps::get_data", + ]: + if token not in text: + failures += fail(f"Team runtime adapter missing token: {token}") return failures @@ -1272,61 +1371,1732 @@ def check_chat_ui_team_action_migration() -> int: failures = 0 header = "modules/ui_shared/include/ui/screens/chat/chat_ui_controller.h" source = "modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp" + workflow_header = "modules/ui_shared/include/ui/screens/chat/chat_team_workflow.h" + workflow_source = "modules/ui_shared/src/ui/screens/chat/chat_team_workflow.cpp" if exists(header): - text = read_text(header) - for token in ["ITeamActionSink", "team_action_sink_"]: + text = strip_cpp_comments(read_text(header)) + for token in ["ChatTeamWorkflow", "team_workflow_"]: if token not in text: - failures += fail(f"ChatUiController header missing Team action token: {token}") + failures += fail(f"ChatUiController header missing Team workflow token: {token}") + for token in [ + "ITeamActionSink", + "TeamActionRequest", + "team_action_sink_", + "sendTeamLocationWithIcon", + ]: + if token in text: + failures += fail(f"ChatUiController header still owns Team action token: {token}") else: failures += fail("ChatUiController header is missing") if exists(source): - text = read_text(source) + text = strip_cpp_comments(read_text(source)) + for token in [ + "ChatTeamWorkflow", + "team_workflow_.sendText", + "team_workflow_.sendCurrentLocationMarker", + "team_workflow_.buildSelectedSnapshot", + ]: + if token not in text: + failures += fail(f"ChatUiController source missing Team workflow token: {token}") for token in [ "TeamActionRequest", "TeamActionKind::LocationMarker", "sendTeamAction", + "ITeamActionSink", + "team_action_sink_", + "team_location_marker_icon_name", + "team_location_marker_icon_is_valid", "team_chat_model_.sendMessage", ]: - if token not in text: - failures += fail(f"ChatUiController source missing Team action token: {token}") + if token in text: + failures += fail(f"ChatUiController source still owns Team action token: {token}") for token in [ '#include "platform/ui/gps_runtime.h"', '#include "team/usecase/team_controller.h"', + '#include "ui/team_actions/team_action_sink.h"', ]: if token in text: failures += fail(f"ChatUiController source reintroduced runtime include: {token}") - - body = strip_cpp_comments( - _function_body( - text, - "bool UiController::sendTeamLocationWithIcon", - "void UiController::onTeamPositionIconSelected", - ) - ) - if not body: - failures += fail("ChatUiController sendTeamLocationWithIcon body missing") - else: - for token in [ - "encodeTeamChatLocation", - "TeamChatMessage", - "TeamChatLocation", - "team_ui_chatlog_append_structured", - "controller->onChat", - "setKeysFromPsk", - "platform::ui::gps::get_data", - "app::teamFacade", - "team_ui_get_store", - "TeamController", - ]: - if token in body: - failures += fail( - f"ChatUiController location send still owns Team runtime token: {token}" - ) else: failures += fail("ChatUiController source is missing") + for path in [workflow_header, workflow_source]: + if not exists(path): + failures += fail(f"missing Chat Team workflow file: {path}") + continue + text = strip_cpp_comments(read_text(path)) + for token in ["lvgl.h", "lv_obj_t", "ChatService", "TeamController"]: + if token in text: + failures += fail(f"{path} contains forbidden Team workflow token: {token}") + + if exists(workflow_source): + text = strip_cpp_comments(read_text(workflow_source)) + for token in [ + "TeamActionRequest", + "TeamActionKind::LocationMarker", + "sendTeamAction", + "team_location_marker_icon_name", + "team_location_marker_icon_is_valid", + "sendCurrentLocationMarker", + "textSendFailureMessage", + "locationSendFailureMessage", + ]: + if token not in text: + failures += fail(f"ChatTeamWorkflow source missing token: {token}") + + return failures + + +def check_team_page_read_model_boundary() -> int: + failures = 0 + header = "modules/ui_shared/include/ui/screens/team/team_page_read_model.h" + source = "modules/ui_shared/src/ui/screens/team/team_page_read_model.cpp" + test = "modules/ui_shared/tests/test_team_page_read_model.cpp" + controller = "modules/ui_shared/src/ui/screens/team/team_page_components.cpp" + + for path in [header, source, test]: + if not exists(path): + failures += fail(f"missing Team Page read model file: {path}") + + for path in [header, source]: + if not exists(path): + continue + text = strip_cpp_comments(read_text(path)) + for token in [ + "lvgl.h", + "lv_obj_t", + "TeamController", + "TeamPairingService", + "app::teamFacade", + "team_ui_get_store", + "ITeamUiSnapshotStore", + "team_ui_snapshot_store()", + "gps_runtime", + "gps_tracker_overlay", + "sys::Event", + ]: + if token in text: + failures += fail(f"{path} contains forbidden Team Page read model token: {token}") + + if exists(header): + text = read_text(header) + for token in [ + "TeamPageReadModel", + "TeamPageReadModelInput", + "TeamPageSummaryView", + "TeamMemberRowView", + "TeamMemberDetailView", + "TeamRelativeTimeView", + "inputFromSnapshot", + "buildSummary", + "buildMemberRows", + "buildSelectedMember", + ]: + if token not in text: + failures += fail(f"Team Page read model header missing token: {token}") + + if exists(source): + text = read_text(source) + for token in [ + "isTeamMemberOnline", + "formatTeamNameFromId", + "management_actions_enabled", + ]: + if token not in text: + failures += fail(f"Team Page read model source missing token: {token}") + + if exists(controller): + text = strip_cpp_comments(read_text(controller)) + for token in [ + "team_page_read_model.h", + "current_read_model_input", + "buildSelectedMember", + ]: + if token not in text: + failures += fail(f"Team page controller missing read model token: {token}") + for token in [ + "refreshTeamMemberPresence", + "isTeamMemberOnline", + "online_count(", + ]: + if token in text: + failures += fail(f"Team page controller still owns read model token: {token}") + + return failures + + +def check_team_page_flow_controller_boundary() -> int: + failures = 0 + types = "modules/ui_shared/include/ui/screens/team/team_page_types.h" + header = "modules/ui_shared/include/ui/screens/team/team_page_flow_controller.h" + source = "modules/ui_shared/src/ui/screens/team/team_page_flow_controller.cpp" + test = "modules/ui_shared/tests/test_team_page_flow_controller.cpp" + controller = "modules/ui_shared/src/ui/screens/team/team_page_components.cpp" + + for path in [types, header, source, test]: + if not exists(path): + failures += fail(f"missing Team Page flow controller file: {path}") + + for path in [types, header, source]: + if not exists(path): + continue + text = strip_cpp_comments(read_text(path)) + for token in [ + "lvgl.h", + "lv_obj_t", + "team_state.h", + "TeamPageState&", + "TeamPageState*", + "TeamController", + "TeamPairingService", + "app::teamFacade", + "app::messagingFacade", + "team_ui_", + "g_team_state", + "SystemNotification", + "sys::Event", + "EventBus", + "render_page", + "ui_request_exit_to_menu", + ]: + if token in text: + failures += fail(f"{path} contains forbidden Team Page flow controller token: {token}") + + if exists(types): + text = read_text(types) + for token in [ + "enum class TeamPage", + "StatusNotInTeam", + "StatusInTeam", + "JoinPending", + "KickedOut", + ]: + if token not in text: + failures += fail(f"Team Page types header missing token: {token}") + + if exists(header): + text = read_text(header) + for token in [ + "TeamPageFlowState", + "TeamPageFlowResult", + "TeamPageFlowController", + "navigateTo", + "navigateBack", + "resetTo", + "selectInitialPage", + "syncRuntime", + "isPairingActive", + ]: + if token not in text: + failures += fail(f"Team Page flow controller header missing token: {token}") + + if exists(source): + text = read_text(source) + for token in [ + "TeamPageFlowController::navigateTo", + "TeamPageFlowController::navigateBack", + "TeamPageFlowController::resetTo", + "TeamPageFlowController::selectInitialPage", + "TeamPageFlowController::syncRuntime", + "TeamPageFlowController::isPairingActive", + ]: + if token not in text: + failures += fail(f"Team Page flow controller source missing token: {token}") + + if exists(controller): + text = strip_cpp_comments(read_text(controller)) + for token in [ + "team_page_flow_controller.h", + "flow_state_from_page", + "apply_flow_state_to_page", + "current_flow_controller", + "navigateTo", + "navigateBack", + "resetTo", + "selectInitialPage", + "syncRuntime", + ]: + if token not in text: + failures += fail(f"Team page controller missing flow controller token: {token}") + for function_name in ["nav_to", "nav_back", "nav_reset", "sync_from_controller", "team_page_create"]: + match = re.search( + rf"(?:void|bool)\s+{function_name}\s*\([^)]*\)\s*\{{(?P.*?)\n\}}", + text, + re.DOTALL, + ) + if not match: + continue + body = match.group("body") + for token in [ + "TeamPairingState::Idle &&", + "TeamPairingState::Completed", + "TeamPairingState::Failed", + "g_team_state.nav_stack.push_back", + "g_team_state.nav_stack.pop_back", + ]: + if token in body: + failures += fail(f"{function_name} still owns flow controller token: {token}") + + return failures + + +def check_team_page_lvgl_renderer_boundary() -> int: + failures = 0 + header = "modules/ui_shared/include/ui/screens/team/team_page_lvgl_renderer.h" + source = "modules/ui_shared/src/ui/screens/team/team_page_lvgl_renderer.cpp" + controller = "modules/ui_shared/src/ui/screens/team/team_page_components.cpp" + + for path in [header, source]: + if not exists(path): + failures += fail(f"missing Team Page LVGL renderer file: {path}") + + for path in [header, source]: + if not exists(path): + continue + text = strip_cpp_comments(read_text(path)) + for token in [ + "TeamController", + "TeamPairingService", + "app::teamFacade", + "app::messagingFacade", + "team_ui_", + "g_team_state", + "g_gps_state", + "gps_tracker_load_file", + "gps_tracker_overlay", + "SystemNotification", + "sys::Event", + "EventBus", + "TeamPageRuntimePort", + "TeamPageCommandReducer", + "TeamPageEventReducer", + ]: + if token in text: + failures += fail(f"{path} contains forbidden Team Page LVGL renderer token: {token}") + + if exists(header): + text = read_text(header) + for token in [ + "ITeamPageLvglNameResolver", + "TeamPageLvglRendererHandlers", + "TeamPageLvglRendererContext", + "TeamPageLvglRendererInput", + "TeamPageLvglRenderer", + "render", + ]: + if token not in text: + failures += fail(f"Team Page LVGL renderer header missing token: {token}") + + if exists(source): + text = read_text(source) + for token in [ + "TeamPageLvglRenderer::render", + "renderStatusNotInTeam", + "renderStatusInTeam", + "renderTeamHome", + "renderJoinPending", + "renderMembers", + "renderMemberDetail", + "renderKickConfirm", + "renderKickedOut", + "createActionButton", + "createListItem", + "renderMemberChipRow", + ]: + if token not in text: + failures += fail(f"Team Page LVGL renderer source missing token: {token}") + + if exists(controller): + text = strip_cpp_comments(read_text(controller)) + for token in [ + "team_page_lvgl_renderer.h", + "TeamPageLvglRendererContext", + "TeamPageLvglRendererHandlers", + "TeamPageLvglRendererInput", + "TeamPageLvglRenderer(now_secs()).render", + ]: + if token not in text: + failures += fail(f"Team page controller missing LVGL renderer token: {token}") + for token in [ + "void render_status_not_in_team", + "void render_status_in_team", + "void render_team_home", + "void render_join_pending", + "void render_members", + "void render_member_detail", + "void render_kick_confirm", + "void render_kicked_out", + "lv_obj_t* add_label", + "lv_obj_t* create_action_button", + "lv_obj_t* create_list_item", + "render_member_chip_row", + "render_member_chips_or_empty", + "format_last_seen", + "format_last_update", + ]: + if token in text: + failures += fail(f"Team page controller still owns LVGL renderer token: {token}") + + return failures + + +def check_team_page_create_team_action_boundary() -> int: + failures = 0 + header = "modules/ui_shared/include/ui/screens/team/team_page_create_team_action.h" + source = "modules/ui_shared/src/ui/screens/team/team_page_create_team_action.cpp" + test = "modules/ui_shared/tests/test_team_page_create_team_action.cpp" + controller = "modules/ui_shared/src/ui/screens/team/team_page_components.cpp" + + for path in [header, source, test]: + if not exists(path): + failures += fail(f"missing Team Page create-team action file: {path}") + + for path in [header, source]: + if not exists(path): + continue + text = strip_cpp_comments(read_text(path)) + for token in [ + "lvgl.h", + "lv_obj_t", + "team_state.h", + "TeamPageState&", + "TeamPageState*", + "TeamController", + "TeamPairingService", + "app::teamFacade", + "app::messagingFacade", + "team_ui_", + "g_gps_state", + "gps_tracker_load_file", + "gps_tracker_overlay", + "SystemNotification", + "sys::Event", + "EventBus", + "sys::millis_now", + "next_random_byte", + ]: + if token in text: + failures += fail(f"{path} contains forbidden Team Page create-team action token: {token}") + + if exists(header): + text = read_text(header) + for token in [ + "ITeamPageCreateTeamRandom", + "TeamPageCreateTeamFailureAction", + "TeamPageCreateTeamFailureKind", + "TeamPageCreateTeamEffects", + "TeamPageCreateTeamAction", + "createTeam", + "TeamPageRuntimePort", + "TeamPageKeyEventLog", + ]: + if token not in text: + failures += fail(f"Team Page create-team action header missing token: {token}") + + if exists(source): + text = read_text(source) + for token in [ + "TeamPageCreateTeamAction::createTeam", + "generateTeamId", + "generateTeamPsk", + "reducer.reduceCreate", + "key_log.appendTeamCreated", + "runtime.setKeysFromPsk", + "runtime.saveKeysNow", + "runtime.startLeader", + "reducer.reducePairingStarted", + ]: + if token not in text: + failures += fail(f"Team Page create-team action source missing token: {token}") + + if exists(controller): + text = strip_cpp_comments(read_text(controller)) + for token in [ + "team_page_create_team_action.h", + "TeamPageRandomByteAdapter", + "apply_create_team_failures", + "TeamPageCreateTeamAction().createTeam", + ]: + if token not in text: + failures += fail(f"Team page controller missing create-team action token: {token}") + if "handle_create" in text: + match = re.search( + r"void\s+handle_create\s*\([^)]*\)\s*\{(?P.*?)\n\}", + text, + re.DOTALL, + ) + if match: + body = match.group("body") + for token in [ + "TeamPageGeneratedTeamSecrets", + "generate_team_id", + "next_random_byte", + "reduceCreate", + "appendTeamCreated", + "setKeysFromPsk", + "saveKeysNow", + "start_pairing_leader", + ]: + if token in body: + failures += fail(f"handle_create still owns create-team orchestration token: {token}") + + return failures + + +def check_team_page_pairing_command_action_boundary() -> int: + failures = 0 + header = "modules/ui_shared/include/ui/screens/team/team_page_pairing_command_action.h" + source = "modules/ui_shared/src/ui/screens/team/team_page_pairing_command_action.cpp" + test = "modules/ui_shared/tests/test_team_page_pairing_command_action.cpp" + controller = "modules/ui_shared/src/ui/screens/team/team_page_components.cpp" + + for path in [header, source, test]: + if not exists(path): + failures += fail(f"missing Team Page pairing command action file: {path}") + + for path in [header, source]: + if not exists(path): + continue + text = strip_cpp_comments(read_text(path)) + for token in [ + "lvgl.h", + "lv_obj_t", + "team_state.h", + "TeamPageState&", + "TeamPageState*", + "TeamController", + "TeamPairingService", + "app::teamFacade", + "app::messagingFacade", + "team_ui_", + "g_gps_state", + "gps_tracker_load_file", + "gps_tracker_overlay", + "SystemNotification", + "sys::Event", + "EventBus", + "sys::millis_now", + "next_random_byte", + ]: + if token in text: + failures += fail(f"{path} contains forbidden Team Page pairing command action token: {token}") + + if exists(header): + text = read_text(header) + for token in [ + "TeamPagePairingCommandRole", + "TeamPagePairingCommandFailureKind", + "TeamPagePairingCommandEffects", + "TeamPagePairingCommandAction", + "startPairing", + "TeamPageRuntimePort", + ]: + if token not in text: + failures += fail(f"Team Page pairing command action header missing token: {token}") + + if exists(source): + text = read_text(source) + for token in [ + "TeamPagePairingCommandAction::startPairing", + "runtime.startLeader", + "runtime.startMember", + "reducer.reducePairingStarted", + "TeamPairingRole::Leader", + "TeamPairingRole::Member", + "TeamPageCommandReducer::formatTeamNameFromId", + ]: + if token not in text: + failures += fail(f"Team Page pairing command action source missing token: {token}") + + if exists(controller): + text = strip_cpp_comments(read_text(controller)) + for token in [ + "team_page_pairing_command_action.h", + "apply_pairing_command_failures", + "TeamPagePairingCommandAction().startPairing", + "start_pairing_command", + ]: + if token not in text: + failures += fail(f"Team page controller missing pairing command action token: {token}") + for token in [ + "start_pairing_leader", + "start_pairing_member", + "runtime.startLeader", + "runtime.startMember", + "reducePairingStarted", + "current_team_name", + ]: + if token in text: + failures += fail(f"Team page controller still owns pairing command action token: {token}") + + return failures + + +def check_team_page_request_keys_action_boundary() -> int: + failures = 0 + header = "modules/ui_shared/include/ui/screens/team/team_page_request_keys_action.h" + source = "modules/ui_shared/src/ui/screens/team/team_page_request_keys_action.cpp" + test = "modules/ui_shared/tests/test_team_page_request_keys_action.cpp" + controller = "modules/ui_shared/src/ui/screens/team/team_page_components.cpp" + + for path in [header, source, test]: + if not exists(path): + failures += fail(f"missing Team Page request-keys action file: {path}") + + for path in [header, source]: + if not exists(path): + continue + text = strip_cpp_comments(read_text(path)) + for token in [ + "lvgl.h", + "lv_obj_t", + "team_state.h", + "TeamPageState&", + "TeamPageState*", + "TeamController", + "TeamPairingService", + "app::teamFacade", + "app::messagingFacade", + "team_ui_", + "g_team_state", + "g_gps_state", + "gps_tracker_load_file", + "gps_tracker_overlay", + "SystemNotification", + "sys::Event", + "EventBus", + "sys::millis_now", + ]: + if token in text: + failures += fail(f"{path} contains forbidden Team Page request-keys action token: {token}") + + if exists(header): + text = read_text(header) + for token in [ + "TeamPageRequestKeysEffects", + "TeamPageRequestKeysAction", + "requestKeys", + "TeamPageCommandState", + ]: + if token not in text: + failures += fail(f"Team Page request-keys action header missing token: {token}") + + if exists(source): + text = read_text(source) + for token in [ + "TeamPageRequestKeysAction::requestKeys", + "state.in_team", + "state.has_team_id", + "state.self_is_leader", + "runtime.sendKeyRequest", + "TeamKeyRequest", + ]: + if token not in text: + failures += fail(f"Team Page request-keys action source missing token: {token}") + if "effects.unsupported" in text: + failures += fail("Team Page request-keys action still reports unsupported") + + if exists(controller): + text = strip_cpp_comments(read_text(controller)) + for token in [ + "team_page_request_keys_action.h", + "TeamPageRequestKeysAction().requestKeys", + ]: + if token not in text: + failures += fail(f"Team page controller missing request-keys action token: {token}") + match = re.search( + r"void\s+handle_request_keydist\s*\([^)]*\)\s*\{(?P.*?)\n\}", + text, + re.DOTALL, + ) + if match: + body = match.group("body") + for token in [ + "g_team_state.in_team", + "g_team_state.self_is_leader", + "g_team_state.has_team_id", + "return;", + ]: + if token in body: + failures += fail(f"handle_request_keydist still owns request-keys action token: {token}") + + return failures + + +def check_team_page_event_reducer_boundary() -> int: + failures = 0 + header = "modules/ui_shared/include/ui/screens/team/team_page_event_reducer.h" + source = "modules/ui_shared/src/ui/screens/team/team_page_event_reducer.cpp" + test = "modules/ui_shared/tests/test_team_page_event_reducer.cpp" + controller = "modules/ui_shared/src/ui/screens/team/team_page_components.cpp" + + for path in [header, source, test]: + if not exists(path): + failures += fail(f"missing Team Page event reducer file: {path}") + + for path in [header, source]: + if not exists(path): + continue + text = strip_cpp_comments(read_text(path)) + for token in [ + "lvgl.h", + "lv_obj_t", + "team_state.h", + "TeamPageState&", + "TeamPageState*", + "TeamController", + "TeamPairingService", + "app::teamFacade", + "app::messagingFacade", + "team_ui_get_store", + "team_ui_snapshot_store", + "team_ui_chatlog", + "team_ui_posring", + "team_ui_append_key_event", + "gps_runtime", + "gps_tracker_overlay", + "SystemNotification", + "sys::Event", + "EventBus", + ]: + if token in text: + failures += fail(f"{path} contains forbidden Team Page event reducer token: {token}") + + if exists(header): + text = read_text(header) + for token in [ + "TeamPageEventState", + "ITeamPageMemberNameResolver", + "TeamPageEventContext", + "TeamPageEventEffects", + "TeamPageEventReducer", + "findMemberIndex", + "touchMember", + "fillStatusMembers", + "applyStatusRoster", + "reduceError", + "reduceStatus", + "reduceActivity", + "reduceKick", + "reduceTransferLeader", + "reduceKeyDist", + "TeamPagePairingUpdate", + "reducePairing", + "reducePairingStatus", + ]: + if token not in text: + failures += fail(f"Team Page event reducer header missing token: {token}") + + if exists(source): + text = read_text(source) + for token in [ + "findTeamMemberIndex", + "normalizeSeenSeconds", + "touchTeamMember", + "formatTeamNameFromId", + "keydist_confirmed", + "epoch_rotated", + "member_kicked_key_event", + "member_accepted_key_event", + "show_pairing_success", + ]: + if token not in text: + failures += fail(f"Team Page event reducer source missing token: {token}") + + if exists(controller): + text = strip_cpp_comments(read_text(controller)) + for token in [ + "team_page_event_reducer.h", + "TeamPageEventState event_state_from_page", + "apply_event_state_to_page", + "current_event_reducer", + "reduce_event_state", + "reduceStatus", + "reduceActivity", + "reduceError", + "reduceKick", + "reduceTransferLeader", + "reduceKeyDist", + "pairing_update_from_event", + "pairing_update_from_status", + "reducePairing", + "reducePairingStatus", + ]: + if token not in text: + failures += fail(f"Team page controller missing event reducer token: {token}") + for token in [ + "ui/team_presence/team_presence_model.h", + "normalizeSeenSeconds", + "touchTeamMember", + "findTeamMemberIndex", + "apply_member_list_from_status(ev.msg)", + "update_team_name_from_id", + "assign_member_color", + "touch_member(", + "find_member_index", + "g_team_state.security_round += 1", + "g_team_state.team_psk = ev.msg.channel_psk", + "g_team_state.pairing_role = ev.role", + ]: + if token in text: + failures += fail(f"Team page controller still owns event reducer token: {token}") + + return failures + + +def check_team_page_event_effect_sink_boundary() -> int: + failures = 0 + header = "modules/ui_shared/include/ui/screens/team/team_page_event_effect_sink.h" + source = "modules/ui_shared/src/ui/screens/team/team_page_event_effect_sink.cpp" + test = "modules/ui_shared/tests/test_team_page_event_effect_sink.cpp" + controller = "modules/ui_shared/src/ui/screens/team/team_page_components.cpp" + + for path in [header, source, test]: + if not exists(path): + failures += fail(f"missing Team Page event effect sink file: {path}") + + for path in [header, source]: + if not exists(path): + continue + text = strip_cpp_comments(read_text(path)) + for token in [ + "lvgl.h", + "lv_obj_t", + "team_state.h", + "TeamPageState&", + "TeamPageState*", + "TeamController", + "TeamPairingService", + "app::teamFacade", + "app::messagingFacade", + "team_ui_", + "g_team_state", + "g_gps_state", + "gps_tracker_load_file", + "gps_tracker_overlay", + "SystemNotification", + "sys::Event", + "EventBus", + "sys::millis_now", + ]: + if token in text: + failures += fail(f"{path} contains forbidden Team Page event effect sink token: {token}") + + if exists(header): + text = read_text(header) + for token in [ + "ITeamPageEventDeferred", + "ITeamPageEventNotifier", + "TeamPageEventEffectResult", + "TeamPageEventEffectSink", + "applyEffects", + "TeamPageRuntimePort", + "TeamPageKeyEventLog", + ]: + if token not in text: + failures += fail(f"Team Page event effect sink header missing token: {token}") + + if exists(source): + text = read_text(source) + for token in [ + "TeamPageEventEffectSink::applyEffects", + "deferred.confirmKeyDist", + "key_log.appendEpochRotated", + "key_log.appendMemberAccepted", + "runtime.saveKeysNow", + "runtime.setKeysFromPsk", + "runtime.sendStatus", + "runtime.sendStatusPlain", + "deferred.scheduleStatusBroadcast", + "notifier.showMessage", + ]: + if token not in text: + failures += fail(f"Team Page event effect sink source missing token: {token}") + + if exists(controller): + text = strip_cpp_comments(read_text(controller)) + for token in [ + "team_page_event_effect_sink.h", + "TeamPageEventDeferredAdapter", + "TeamPageEventNotifierAdapter", + "apply_event_effects", + "apply_event_navigation_requests", + "TeamPageEventEffectSink().applyEffects", + ]: + if token not in text: + failures += fail(f"Team page controller missing event effect sink token: {token}") + for handler in ["handle_team_status", "handle_team_key_dist", "handle_team_pairing"]: + match = re.search( + rf"void\s+{handler}\s*\([^)]*\)\s*\{{(?P.*?)\n\}}", + text, + re.DOTALL, + ) + if not match: + failures += fail(f"Team page controller missing handler body: {handler}") + continue + body = match.group("body") + for token in [ + "appendEpochRotated", + "appendMemberAccepted", + "saveKeysNow", + "setKeysFromPsk", + "sendStatus(", + "sendStatusPlain(", + "schedule_status_broadcast", + "SystemNotification::show", + "g_team_state.page", + "g_team_state.nav_stack.clear", + ]: + if token in body: + failures += fail(f"{handler} still owns event effect sink token: {token}") + + return failures + + +def check_team_page_command_reducer_boundary() -> int: + failures = 0 + header = "modules/ui_shared/include/ui/screens/team/team_page_command_reducer.h" + source = "modules/ui_shared/src/ui/screens/team/team_page_command_reducer.cpp" + test = "modules/ui_shared/tests/test_team_page_command_reducer.cpp" + controller = "modules/ui_shared/src/ui/screens/team/team_page_components.cpp" + + for path in [header, source, test]: + if not exists(path): + failures += fail(f"missing Team Page command reducer file: {path}") + + for path in [header, source]: + if not exists(path): + continue + text = strip_cpp_comments(read_text(path)) + for token in [ + "lvgl.h", + "lv_obj_t", + "team_state.h", + "TeamPageState&", + "TeamPageState*", + "TeamController", + "TeamPairingService", + "app::teamFacade", + "app::messagingFacade", + "team_ui_get_store", + "team_ui_snapshot_store", + "team_ui_chatlog", + "team_ui_posring", + "team_ui_append_key_event", + "team_ui_save_keys_now", + "gps_runtime", + "gps_tracker_overlay", + "SystemNotification", + "sys::Event", + "EventBus", + "next_random_byte", + "sys::millis_now", + ]: + if token in text: + failures += fail(f"{path} contains forbidden Team Page command reducer token: {token}") + + if exists(header): + text = read_text(header) + for token in [ + "TeamPageCommandState", + "TeamPageCommandContext", + "TeamPageGeneratedTeamSecrets", + "TeamPageKickRotation", + "TeamPageCommandEffects", + "TeamPageCommandReducer", + "reduceCreate", + "reduceLeave", + "reduceKickedOut", + "reduceClearKickedOut", + "reducePairingStarted", + "reduceJoinCanceled", + "reduceKickConfirmed", + "reduceTransferLeader", + ]: + if token not in text: + failures += fail(f"Team Page command reducer header missing token: {token}") + + if exists(source): + text = read_text(source) + for token in [ + "resetTeamMembership", + "resetPairing", + "ensureSelfMember", + "team_color_index_from_node_id", + "formatTeamNameFromId", + "keys_changed", + "status_should_send", + ]: + if token not in text: + failures += fail(f"Team Page command reducer source missing token: {token}") + + if exists(controller): + text = strip_cpp_comments(read_text(controller)) + for token in [ + "team_page_command_reducer.h", + "TeamPageCommandState command_state_from_page", + "apply_command_state_to_page", + "current_command_reducer", + "reduce_command_state", + "apply_command_runtime_effects", + "reduceLeave", + "reduceKickedOut", + "reduceJoinCanceled", + ]: + if token not in text: + failures += fail(f"Team page controller missing command reducer token: {token}") + + return failures + + +def check_team_page_key_event_log_boundary() -> int: + failures = 0 + header = "modules/ui_shared/include/ui/screens/team/team_page_key_event_log.h" + source = "modules/ui_shared/src/ui/screens/team/team_page_key_event_log.cpp" + test = "modules/ui_shared/tests/test_team_page_key_event_log.cpp" + controller = "modules/ui_shared/src/ui/screens/team/team_page_components.cpp" + + for path in [header, source, test]: + if not exists(path): + failures += fail(f"missing Team Page key event log file: {path}") + + for path in [header, source]: + if not exists(path): + continue + text = strip_cpp_comments(read_text(path)) + for token in [ + "lvgl.h", + "lv_obj_t", + "team_state.h", + "TeamPageState&", + "TeamPageState*", + "TeamController", + "TeamPairingService", + "app::teamFacade", + "app::messagingFacade", + "team_ui_append_key_event(", + "team_ui_snapshot_store", + "team_ui_chatlog", + "team_ui_posring", + "gps_runtime", + "gps_tracker_overlay", + "SystemNotification", + "sys::Event", + "EventBus", + ]: + if token in text: + failures += fail(f"{path} contains forbidden Team Page key event log token: {token}") + + if exists(header): + text = read_text(header) + for token in [ + "TeamPageKeyEventState", + "ITeamPageKeyEventWriter", + "TeamPageKeyEventLog", + "appendTeamCreated", + "appendMemberAccepted", + "appendMemberKicked", + "appendLeaderTransferred", + "appendEpochRotated", + "teamIdToU64", + ]: + if token not in text: + failures += fail(f"Team Page key event log header missing token: {token}") + + if exists(source): + text = read_text(source) + for token in [ + "writeU32Le", + "writeU64Le", + "TeamKeyEventType::TeamCreated", + "TeamKeyEventType::MemberAccepted", + "TeamKeyEventType::MemberKicked", + "TeamKeyEventType::LeaderTransferred", + "TeamKeyEventType::EpochRotated", + "state.last_event_seq = next_seq", + ]: + if token not in text: + failures += fail(f"Team Page key event log source missing token: {token}") + + if exists(controller): + text = strip_cpp_comments(read_text(controller)) + for token in [ + "team_page_key_event_log.h", + "TeamPageRuntimeKeyEventWriter", + "key_event_state_from_page", + "apply_key_event_state_to_page", + "current_key_event_log", + "append_key_event_to_page", + "appendMemberKicked", + "appendEpochRotated", + ]: + if token not in text: + failures += fail(f"Team page controller missing key event log token: {token}") + for token in [ + "write_u32_le", + "write_u64_le", + "team_id_to_u64", + "bool append_key_event(", + ]: + if token in text: + failures += fail(f"Team page controller still owns key event log token: {token}") + + return failures + + +def check_team_page_runtime_port_boundary() -> int: + failures = 0 + header = "modules/ui_shared/include/ui/screens/team/team_page_runtime_port.h" + source = "modules/ui_shared/src/ui/screens/team/team_page_runtime_port.cpp" + adapters = "modules/ui_shared/src/ui/screens/team/team_page_runtime_adapters.cpp" + test = "modules/ui_shared/tests/test_team_page_runtime_port.cpp" + controller = "modules/ui_shared/src/ui/screens/team/team_page_components.cpp" + + for path in [header, source, adapters, test]: + if not exists(path): + failures += fail(f"missing Team Page runtime port file: {path}") + + for path in [header, source, adapters]: + if not exists(path): + continue + text = strip_cpp_comments(read_text(path)) + for token in [ + "lvgl.h", + "lv_obj_t", + "team_state.h", + "TeamPageState&", + "TeamPageState*", + "gps_runtime", + "gps_tracker_overlay", + "SystemNotification", + "sys::Event", + "EventBus", + ]: + if token in text: + failures += fail(f"{path} contains forbidden Team Page runtime port token: {token}") + + if exists(header): + text = read_text(header) + for token in [ + "ITeamPageControllerPort", + "ITeamPagePairingPort", + "ITeamPageKeyStorePort", + "TeamPageRuntimePort", + "TeamPageControllerPortAdapter", + "TeamPagePairingPortAdapter", + "TeamPageKeyStorePortAdapter", + "sendStatus", + "sendKeyDist", + "startLeader", + "saveKeysNow", + ]: + if token not in text: + failures += fail(f"Team Page runtime port header missing token: {token}") + + if exists(source): + text = read_text(source) + for token in [ + "TeamPageRuntimePort::TeamPageRuntimePort", + "TeamPageRuntimePort::clearKeys", + "TeamPageRuntimePort::sendStatus", + "TeamPageRuntimePort::startLeader", + "TeamPageRuntimePort::lastSendError", + ]: + if token not in text: + failures += fail(f"Team Page runtime port source missing token: {token}") + for token in [ + "team_ui_save_keys_now", + "controller_->on", + ]: + if token in strip_cpp_comments(text): + failures += fail(f"Team Page runtime port source owns adapter token: {token}") + + if exists(adapters): + text = read_text(adapters) + for token in [ + "team_ui_save_keys_now", + "controller_->onKick", + "controller_->onTransferLeader", + "controller_->onKeyDist", + "controller_->onKeyDistPlain", + "controller_->onStatus", + "controller_->onStatusPlain", + "pairing_->startLeader", + "pairing_->startMember", + "pairing_->getStatus", + ]: + if token not in text: + failures += fail(f"Team Page runtime adapter source missing token: {token}") + + if exists(controller): + text = strip_cpp_comments(read_text(controller)) + for token in [ + "team_page_runtime_port.h", + "current_runtime_port", + "TeamPageRuntimePort", + "TeamPageControllerPortAdapter", + "TeamPagePairingPortAdapter", + "TeamPageKeyStorePortAdapter", + ]: + if token not in text: + failures += fail(f"Team page controller missing runtime port token: {token}") + for token in [ + "TeamController* controller", + "TeamPairingService* pairing", + "controller->", + "pairing->", + "team_ui_save_keys_now", + "getLastSendError", + ]: + if token in text: + failures += fail(f"Team page controller still owns runtime port token: {token}") + if text.count("app::teamFacade()") != 2: + failures += fail("Team page controller should access app::teamFacade only in current_runtime_port") + + return failures + + +def check_team_page_activity_sink_boundary() -> int: + failures = 0 + header = "modules/ui_shared/include/ui/screens/team/team_page_activity_sink.h" + source = "modules/ui_shared/src/ui/screens/team/team_page_activity_sink.cpp" + adapters = "modules/ui_shared/src/ui/screens/team/team_page_activity_adapters.cpp" + test = "modules/ui_shared/tests/test_team_page_activity_sink.cpp" + controller = "modules/ui_shared/src/ui/screens/team/team_page_components.cpp" + + for path in [header, source, adapters, test]: + if not exists(path): + failures += fail(f"missing Team Page activity sink file: {path}") + + for path in [header, source]: + if not exists(path): + continue + text = strip_cpp_comments(read_text(path)) + for token in [ + "lvgl.h", + "lv_obj_t", + "team_state.h", + "TeamPageState&", + "TeamPageState*", + "TeamController", + "TeamPairingService", + "app::teamFacade", + "app::messagingFacade", + "team_ui_", + "g_gps_state", + "gps_tracker_load_file", + "gps_tracker_overlay", + "SystemNotification", + "sys::Event", + "EventBus", + ]: + if token in text: + failures += fail(f"{path} contains forbidden Team Page activity sink token: {token}") + + if exists(header): + text = read_text(header) + for token in [ + "ITeamPagePositionLog", + "ITeamPageChatLog", + "ITeamPageGpsTrackLoader", + "ITeamPageUnreadPublisher", + "TeamPageActivityState", + "TeamPageActivityContext", + "TeamPageActivitySink", + "consumePosition", + "consumeWaypoint", + "consumeTrack", + "consumeChat", + "TeamPageActivityStoreAdapter", + "TeamPageGpsTrackLoaderAdapter", + "TeamPageUnreadPublisherAdapter", + ]: + if token not in text: + failures += fail(f"Team Page activity sink header missing token: {token}") + + if exists(source): + text = read_text(source) + for token in [ + "decodeTeamPositionMessage", + "teamWaypointHasLocation", + "decodeTeamTrackMessage", + "appendMemberTrack", + "memberTrackPath", + "loadTrackFile", + "appendStructuredChat", + "decodeTeamChatLocation", + "publishTeamUnread", + "std::numeric_limits::max()", + ]: + if token not in text: + failures += fail(f"Team Page activity sink source missing token: {token}") + + if exists(adapters): + text = read_text(adapters) + for token in [ + "team_ui_posring_append", + "team_ui_append_member_track", + "team_ui_get_member_track_path", + "team_ui_chatlog_append_structured", + "g_gps_state.selected_member_id", + "gps_tracker_load_file", + "sys::EventBus::publish", + "sys::ChatUnreadChangedEvent", + ]: + if token not in text: + failures += fail(f"Team Page activity adapter source missing token: {token}") + + if exists(controller): + text = strip_cpp_comments(read_text(controller)) + for token in [ + "team_page_activity_sink.h", + "activity_state_from_page", + "apply_activity_state_to_page", + "current_activity_sink", + "consume_activity", + "sink.consumePosition", + "sink.consumeWaypoint", + "sink.consumeTrack", + "sink.consumeChat", + ]: + if token not in text: + failures += fail(f"Team page controller missing activity sink token: {token}") + for token in [ + "team_ui_posring_append", + "team_ui_chatlog_append_structured", + "team_ui_append_member_track", + "team_ui_get_member_track_path", + "gps_tracker_load_file", + "g_gps_state", + "ChatUnreadChangedEvent", + "decodeTeamPositionMessage", + "decodeTeamTrackMessage", + "decodeTeamChatLocation", + "teamWaypointHasLocation", + ]: + if token in text: + failures += fail(f"Team page controller still owns activity sink token: {token}") + + return failures + + +def check_team_page_deferred_dispatch_boundary() -> int: + failures = 0 + header = "modules/ui_shared/include/ui/screens/team/team_page_deferred_dispatch.h" + source = "modules/ui_shared/src/ui/screens/team/team_page_deferred_dispatch.cpp" + adapters = "modules/ui_shared/src/ui/screens/team/team_page_deferred_dispatch_adapters.cpp" + test = "modules/ui_shared/tests/test_team_page_deferred_dispatch.cpp" + controller = "modules/ui_shared/src/ui/screens/team/team_page_components.cpp" + + for path in [header, source, adapters, test]: + if not exists(path): + failures += fail(f"missing Team Page deferred dispatch file: {path}") + + for path in [header, source]: + if not exists(path): + continue + text = strip_cpp_comments(read_text(path)) + for token in [ + "lvgl.h", + "lv_obj_t", + "team_state.h", + "TeamPageState&", + "TeamPageState*", + "TeamController", + "TeamPairingService", + "app::teamFacade", + "app::messagingFacade", + "team_ui_", + "g_gps_state", + "gps_tracker_load_file", + "SystemNotification", + "sys::Event", + "EventBus", + ]: + if token in text: + failures += fail(f"{path} contains forbidden Team Page deferred dispatch token: {token}") + + if exists(header): + text = read_text(header) + for token in [ + "ITeamPageDeferredDispatchPort", + "TeamPageDeferredDispatchConfig", + "TeamPageDeferredDispatchState", + "TeamPageDeferredDispatchEffects", + "TeamPageDeferredDispatchQueue", + "enqueueKeyDist", + "confirmKeyDist", + "scheduleStatusBroadcast", + "processKeyDistRetries", + "processStatusBroadcasts", + "TeamPageDeferredDispatchRuntimeAdapter", + ]: + if token not in text: + failures += fail(f"Team Page deferred dispatch header missing token: {token}") + + if exists(source): + text = read_text(source) + for token in [ + "keydist_pending_", + "status_pending_", + "sendKeyDistPlain", + "sendStatusPlain", + "TeamPageDeferredDispatchFailureKind::SendFailedDetail", + "keydist_max_retries", + "status_rebroadcast_interval_s", + ]: + if token not in text: + failures += fail(f"Team Page deferred dispatch source missing token: {token}") + + if exists(adapters): + text = read_text(adapters) + for token in [ + "TeamPageDeferredDispatchRuntimeAdapter", + "runtime_.sendKeyDistPlain", + "runtime_.sendStatus", + "runtime_.sendStatusPlain", + "runtime_.lastSendError", + ]: + if token not in text: + failures += fail(f"Team Page deferred dispatch adapter source missing token: {token}") + + if exists(controller): + text = strip_cpp_comments(read_text(controller)) + for token in [ + "team_page_deferred_dispatch.h", + "TeamPageDeferredDispatchQueue deferred_dispatch", + "deferred_dispatch_state_from_page", + "apply_deferred_dispatch_failures", + "team_page_context().deferred_dispatch.enqueueKeyDist", + "team_page_context().deferred_dispatch.confirmKeyDist", + "team_page_context().deferred_dispatch.scheduleStatusBroadcast", + "team_page_context().deferred_dispatch.processKeyDistRetries", + "team_page_context().deferred_dispatch.processStatusBroadcasts", + "TeamPageDeferredDispatchRuntimeAdapter", + ]: + if token not in text: + failures += fail(f"Team page controller missing deferred dispatch token: {token}") + for token in [ + "KeyDistPending", + "StatusBroadcastPending", + "s_keydist_pending", + "s_status_pending", + "kKeyDistMaxRetries", + "kKeyDistRetryIntervalSec", + "std::remove_if", + "it->attempts", + "it->next_retry_s", + "it->remaining", + ]: + if token in text: + failures += fail(f"Team page controller still owns deferred dispatch token: {token}") + + return failures + + +def check_team_page_kick_confirm_action_boundary() -> int: + failures = 0 + header = "modules/ui_shared/include/ui/screens/team/team_page_kick_confirm_action.h" + source = "modules/ui_shared/src/ui/screens/team/team_page_kick_confirm_action.cpp" + test = "modules/ui_shared/tests/test_team_page_kick_confirm_action.cpp" + controller = "modules/ui_shared/src/ui/screens/team/team_page_components.cpp" + + for path in [header, source, test]: + if not exists(path): + failures += fail(f"missing Team Page kick-confirm action file: {path}") + + for path in [header, source]: + if not exists(path): + continue + text = strip_cpp_comments(read_text(path)) + for token in [ + "lvgl.h", + "lv_obj_t", + "team_state.h", + "TeamPageState&", + "TeamPageState*", + "TeamController", + "TeamPairingService", + "app::teamFacade", + "app::messagingFacade", + "team_ui_", + "g_gps_state", + "gps_tracker_load_file", + "gps_tracker_overlay", + "SystemNotification", + "sys::Event", + "EventBus", + "sys::millis_now", + "next_random_byte", + ]: + if token in text: + failures += fail(f"{path} contains forbidden Team Page kick-confirm action token: {token}") + + if exists(header): + text = read_text(header) + for token in [ + "ITeamPageKickConfirmRandom", + "ITeamPageKickConfirmDeferred", + "TeamPageKickConfirmFailureAction", + "TeamPageKickConfirmFailureKind", + "TeamPageKickConfirmEffects", + "TeamPageKickConfirmAction", + "confirmKick", + "TeamPageRuntimePort", + ]: + if token not in text: + failures += fail(f"Team Page kick-confirm action header missing token: {token}") + + if exists(source): + text = read_text(source) + for token in [ + "TeamPageKickConfirmAction::confirmKick", + "TeamKick kick", + "TeamKeyDist key_dist", + "runtime.sendKick", + "runtime.sendKeyDist", + "deferred.enqueueKeyDist", + "reducer.reduceKickConfirmed", + "runtime.setKeysFromPsk", + "runtime.sendStatus", + "runtime.sendStatusPlain", + "fillStatusMembers", + ]: + if token not in text: + failures += fail(f"Team Page kick-confirm action source missing token: {token}") + + if exists(controller): + text = strip_cpp_comments(read_text(controller)) + for token in [ + "team_page_kick_confirm_action.h", + "TeamPageRandomByteAdapter", + "TeamPageKickConfirmDeferredAdapter", + "apply_kick_confirm_failures", + "TeamPageKickConfirmAction().confirmKick", + "apply_command_state_to_page(state)", + ]: + if token not in text: + failures += fail(f"Team page controller missing kick-confirm action token: {token}") + for token in [ + "TeamKick kick", + "TeamKeyDist kd", + "TeamKeyDist key_dist", + "sendKeyDist(kd", + "sendKeyDist(key_dist", + "new_psk", + "reducer.reduceKickConfirmed", + "runtime.sendKick", + ]: + if token in text: + failures += fail(f"Team page controller still owns kick-confirm action token: {token}") + if "handle_kick_confirm" in text: + match = re.search( + r"void\s+handle_kick_confirm\s*\([^)]*\)\s*\{(?P.*?)\n\}", + text, + re.DOTALL, + ) + if match: + body = match.group("body") + for token in [ + "for (const auto&", + "sendStatus(", + "sendStatusPlain(", + "setKeysFromPsk(", + "TeamPageKickRotation", + "fill_status_members", + "add_keydist_pending", + ]: + if token in body: + failures += fail(f"handle_kick_confirm still owns kick-confirm orchestration token: {token}") + + return failures + + +def check_team_page_transfer_leader_action_boundary() -> int: + failures = 0 + header = "modules/ui_shared/include/ui/screens/team/team_page_transfer_leader_action.h" + source = "modules/ui_shared/src/ui/screens/team/team_page_transfer_leader_action.cpp" + test = "modules/ui_shared/tests/test_team_page_transfer_leader_action.cpp" + controller = "modules/ui_shared/src/ui/screens/team/team_page_components.cpp" + + for path in [header, source, test]: + if not exists(path): + failures += fail(f"missing Team Page transfer-leader action file: {path}") + + for path in [header, source]: + if not exists(path): + continue + text = strip_cpp_comments(read_text(path)) + for token in [ + "lvgl.h", + "lv_obj_t", + "team_state.h", + "TeamPageState&", + "TeamPageState*", + "TeamController", + "TeamPairingService", + "app::teamFacade", + "app::messagingFacade", + "team_ui_", + "g_gps_state", + "gps_tracker_load_file", + "gps_tracker_overlay", + "SystemNotification", + "sys::Event", + "EventBus", + ]: + if token in text: + failures += fail(f"{path} contains forbidden Team Page transfer-leader action token: {token}") + + if exists(header): + text = read_text(header) + for token in [ + "TeamPageTransferLeaderFailureAction", + "TeamPageTransferLeaderFailureKind", + "TeamPageTransferLeaderEffects", + "TeamPageTransferLeaderAction", + "transferLeader", + "TeamPageRuntimePort", + "TeamPageKeyEventLog", + ]: + if token not in text: + failures += fail(f"Team Page transfer-leader action header missing token: {token}") + + if exists(source): + text = read_text(source) + for token in [ + "TeamPageTransferLeaderAction::transferLeader", + "TeamTransferLeader transfer", + "runtime.sendTransferLeader", + "reducer.reduceTransferLeader", + "key_log.appendLeaderTransferred", + ]: + if token not in text: + failures += fail(f"Team Page transfer-leader action source missing token: {token}") + + if exists(controller): + text = strip_cpp_comments(read_text(controller)) + for token in [ + "team_page_transfer_leader_action.h", + "apply_transfer_leader_failures", + "TeamPageTransferLeaderAction().transferLeader", + "apply_command_state_to_page(command_state)", + "apply_key_event_state_to_page(key_state)", + ]: + if token not in text: + failures += fail(f"Team page controller missing transfer-leader action token: {token}") + if "handle_transfer_leader" in text: + match = re.search( + r"void\s+handle_transfer_leader\s*\([^)]*\)\s*\{(?P.*?)\n\}", + text, + re.DOTALL, + ) + if match: + body = match.group("body") + for token in [ + "TeamTransferLeader transfer", + "sendTransferLeader", + "reduceTransferLeader", + "appendLeaderTransferred", + "runtime.hasController", + ]: + if token in body: + failures += fail(f"handle_transfer_leader still owns transfer-leader orchestration token: {token}") + + return failures + + +def check_team_page_state_store_boundary() -> int: + failures = 0 + header = "modules/ui_shared/include/ui/screens/team/team_page_state_store.h" + source = "modules/ui_shared/src/ui/screens/team/team_page_state_store.cpp" + test = "modules/ui_shared/tests/test_team_page_state_store.cpp" + controller = "modules/ui_shared/src/ui/screens/team/team_page_components.cpp" + + for path in [header, source, test]: + if not exists(path): + failures += fail(f"missing Team Page state store file: {path}") + + for path in [header, source]: + if not exists(path): + continue + text = strip_cpp_comments(read_text(path)) + for token in [ + "lvgl.h", + "lv_obj_t", + "team_state.h", + "TeamPageState&", + "TeamPageState*", + "TeamController", + "TeamPairingService", + "app::teamFacade", + "app::messagingFacade", + "team_ui_get_store", + "team_ui_snapshot_store()", + "gps_runtime", + "gps_tracker_overlay", + "sys::Event", + ]: + if token in text: + failures += fail(f"{path} contains forbidden Team Page state store token: {token}") + + if exists(header): + text = read_text(header) + for token in [ + "TeamPagePersistentState", + "TeamPageColorContext", + "TeamPageStateStore", + "loadOnce", + "refresh", + "resetLoaded", + "snapshotFromState", + "applySnapshot", + "assignTeamPageMemberColors", + "ITeamUiSnapshotStore", + ]: + if token not in text: + failures += fail(f"Team Page state store header missing token: {token}") + + if exists(source): + text = read_text(source) + for token in [ + "team_color_index_from_node_id", + "loaded_ = true", + "store.load(snapshot)", + "store.save(snapshotFromState(state))", + "assignTeamPageMemberColors", + ]: + if token not in text: + failures += fail(f"Team Page state store source missing token: {token}") + + if exists(controller): + text = strip_cpp_comments(read_text(controller)) + for token in [ + "team_page_state_store.h", + "TeamPageStateStore state_store", + "persistent_state_from_page", + "apply_persistent_state_to_page", + "current_color_context", + "team_page_context().state_store.loadOnce", + "team_page_context().state_store.refresh", + "team_page_context().state_store.save", + "team_page_context().state_store.resetLoaded", + ]: + if token not in text: + failures += fail(f"Team page controller missing state store token: {token}") + for token in [ + "s_state_loaded", + "snapshot_from_state", + "apply_snapshot(", + "ensure_member_colors", + "team_ui_snapshot_store().load", + "team_ui_snapshot_store().save", + ]: + if token in text: + failures += fail(f"Team page controller still owns state store token: {token}") + return failures @@ -1417,10 +3187,30 @@ def check_team_rich_payload_presentation_boundary() -> int: failures = 0 controller = "modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp" presentation = "modules/ui_shared/src/ui/presentation_sources/team_chat_presentation_source.cpp" + message_row_header = "modules/ui_presentation/include/ui_presentation/chat/chat_workspace_snapshot.h" + chat_renderer = "modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp" + watch_renderer = "modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp" display_header = "modules/ui_shared/include/ui/team_presentation/team_rich_payload_display.h" projector_header = "modules/ui_shared/include/ui/team_presentation/team_rich_payload_projector.h" projector_source = "modules/ui_shared/src/ui/team_presentation/team_rich_payload_projector.cpp" + if exists(message_row_header): + text = strip_cpp_comments(read_text(message_row_header)) + for token in [ + "enum class TeamMessageRichPayloadKind", + "TeamMessageLocationPayload", + "TeamMessageCommandPayload", + "TeamMessageRichPayload", + "has_team_rich_payload", + "team_rich_payload", + "resetMessageRow", + ]: + if token not in text: + failures += fail(f"MessageRow missing structured Team rich payload token: {token}") + for token in ["lvgl.h", "TeamRichPayloadDisplay", "TeamChatLogEntry", "team/protocol"]: + if token in text: + failures += fail(f"MessageRow owns forbidden Team rich dependency token: {token}") + if exists(display_header): text = read_text(display_header) for token in [ @@ -1480,7 +3270,7 @@ def check_team_rich_payload_presentation_boundary() -> int: "lv_obj_t", "ChatUiController", "ChatWorkspaceModel", - "LegacyTeamActionBridge", + "TeamActionRuntimeSink", "sendTeamAction", "encodeTeamChatLocation", "encodeTeamChatCommand", @@ -1508,6 +3298,10 @@ def check_team_rich_payload_presentation_boundary() -> int: "TeamRichPayloadProjector", "TeamRichPayloadDisplay", "display.summary", + "copyRichPayload", + "has_team_rich_payload", + "team_rich_payload", + "TeamMessageRichPayloadKind", ]: if token not in text: failures += fail( @@ -1526,6 +3320,17 @@ def check_team_rich_payload_presentation_boundary() -> int: f"TeamChatPresentationSource still owns inline rich payload formatter token: {token}" ) + for path in [chat_renderer, watch_renderer]: + if exists(path): + text = strip_cpp_comments(read_text(path)) + for token in [ + "has_team_rich_payload", + "team_rich_payload", + "format_team_rich_payload_text", + ]: + if token not in text: + failures += fail(f"{path} missing structured Team rich render token: {token}") + if exists(controller): text = strip_cpp_comments(read_text(controller)) for token in [ @@ -1597,8 +3402,7 @@ def check_team_position_picker_renderer_boundary() -> int: "team_position_picker_->close(restore_group)", "team_position_picker_->updateHint(icon_id)", "team_position_picker_->isOpen()", - "sendTeamLocationWithIcon", - "team_action_sink_->sendTeamAction", + "team_workflow_.sendCurrentLocationMarker", ]: if token not in text: failures += fail(f"ChatUiController missing Team picker delegation token: {token}") @@ -2057,10 +3861,15 @@ def check_map_overlay_route_tracker_boundary() -> int: projector_source = "modules/ui_shared/src/ui/map_overlay/map_overlay_projector.cpp" legacy_header = "modules/ui_shared/include/ui/presentation_sources/legacy_map_overlay_source.h" legacy_source = "modules/ui_shared/src/ui/presentation_sources/legacy_map_overlay_source.cpp" + team_source_header = "modules/ui_shared/include/ui/presentation_sources/team_map_overlay_source.h" + team_source_source = "modules/ui_shared/src/ui/presentation_sources/team_map_overlay_source.cpp" projector_test = "modules/ui_shared/tests/test_map_overlay_projector.cpp" legacy_test = "modules/ui_shared/tests/test_legacy_map_overlay_source.cpp" + team_source_test = "modules/ui_shared/tests/test_team_map_overlay_source.cpp" viewport_source = "modules/ui_shared/src/ui/widgets/map/map_viewport.cpp" runtime_source = "modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp" + esp_gps_map = "platform/esp/arduino_common/src/ui/screens/gps/gps_page_map.cpp" + uconsole_dashboard = "platform/linux/uconsole/src/uconsole_dashboard_model.cpp" runtime_register = "docs/audits/PHASE7_RUNTIME_OWNERSHIP_REGISTER.md" burndown_register = "docs/audits/LEGACY_BURNDOWN_REGISTER.md" @@ -2177,7 +3986,39 @@ def check_map_overlay_route_tracker_boundary() -> int: if token in text: failures += fail(f"MapOverlaySnapshotSource source owns forbidden render/source token: {token}") - for path in [projector_test, legacy_test]: + if exists(team_source_header): + text = strip_cpp_comments(read_text(team_source_header)) + for token in [ + "TeamMapOverlaySource", + "IMapOverlayTeamSource", + "latestTeamPoints", + "latestTeamLocations", + "loadMemberLocation", + "ITeamUiSnapshotStore", + ]: + if token not in text: + failures += fail(f"TeamMapOverlaySource header missing token: {token}") + for token in ["lvgl.h", "lv_obj_t", "gps_runtime", "ChatWorkspaceModel"]: + if token in text: + failures += fail(f"TeamMapOverlaySource header exposes forbidden token: {token}") + + if exists(team_source_source): + text = strip_cpp_comments(read_text(team_source_source)) + for token in [ + "TeamMapOverlaySource::latestTeamPoints", + "TeamMapOverlaySource::latestTeamLocations", + "TeamMapOverlaySource::loadMemberLocation", + "team_ui_posring_load_latest", + "locationFromSample", + "team_color_from_index", + ]: + if token not in text: + failures += fail(f"TeamMapOverlaySource source missing token: {token}") + for token in ["lvgl.h", "lv_obj_t", "team_state.h", "g_team_state", "gps_runtime"]: + if token in text: + failures += fail(f"TeamMapOverlaySource source owns forbidden token: {token}") + + for path in [projector_test, legacy_test, team_source_test]: if exists(path): text = read_text(path) if "MapOverlay" not in text: @@ -2201,11 +4042,24 @@ def check_map_overlay_route_tracker_boundary() -> int: for token in [ "MapOverlaySnapshotSource", "IMapOverlayGpsSource", - "IMapOverlayTeamSource", + "TeamMapOverlaySource", "buildMapOverlaySnapshot", ]: if token not in text: failures += fail(f"gps_page_runtime.cpp missing overlay runtime wiring token: {token}") + for token in ["team_ui_posring_load_latest", "TeamPosSample"]: + if token in strip_cpp_comments(text): + failures += fail(f"gps_page_runtime.cpp still reads Team posring directly: {token}") + + for path in [esp_gps_map, uconsole_dashboard]: + if exists(path): + text = strip_cpp_comments(read_text(path)) + for token in ["TeamMapOverlaySource"]: + if token not in text: + failures += fail(f"{path} missing TeamMapOverlaySource consumer token") + for token in ["team_ui_posring_load_latest", "TeamPosSample"]: + if token in text: + failures += fail(f"{path} still reads Team posring directly: {token}") if exists(runtime_register): text = read_text(runtime_register) @@ -2214,6 +4068,7 @@ def check_map_overlay_route_tracker_boundary() -> int: "7.12", "MapOverlaySnapshot", "LegacyMapOverlaySource", + "TeamMapOverlaySource", "route/tracker overlay sources have exit conditions", ]: if token not in text: @@ -2223,6 +4078,7 @@ def check_map_overlay_route_tracker_boundary() -> int: text = read_text(burndown_register) for token in [ "Map overlay current/team marker projection", + "TeamMapOverlaySource", "Map route/tracker overlay projection", "MapOverlayProjector", "map renderers consume `MapOverlaySnapshotSource` only", @@ -2332,6 +4188,116 @@ def check_gps_runtime_scheduling_boundary() -> int: return failures +def check_team_consumer_shadow_state_closeout() -> int: + failures = 0 + + hostlink_header = "platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/hostlink_bridge_radio.h" + hostlink_source = "platform/esp/arduino_common/src/hostlink/hostlink_bridge_radio.cpp" + esp_gps_map = "platform/esp/arduino_common/src/ui/screens/gps/gps_page_map.cpp" + map_source = "modules/ui_shared/src/ui/presentation_sources/legacy_map_presentation_source.cpp" + app_events = "platform/esp/arduino_common/src/app_event_runtime_support.cpp" + contacts_header = "modules/ui_shared/include/ui/screens/contacts/contacts_team_snapshot_source.h" + contacts_source = "modules/ui_shared/src/ui/screens/contacts/contacts_team_snapshot_source.cpp" + contacts_page = "modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp" + + if exists(hostlink_header): + text = strip_cpp_comments(read_text(hostlink_header)) + for token in ["void on_event", "void on_team_state_changed", "void on_link_ready"]: + if token not in text: + failures += fail(f"Hostlink bridge header missing Team state closeout token: {token}") + + if exists(hostlink_source): + text = strip_cpp_comments(read_text(hostlink_source)) + for token in [ + "team_ui_snapshot_store().load", + "make_team_state_snapshot(const team::ui::TeamUiSnapshot&", + "team_presence::isTeamMemberOnline", + "on_team_state_changed", + "maybe_send_team_state(false)", + "maybe_send_team_state(true)", + ]: + if token not in text: + failures += fail(f"Hostlink bridge missing Team shadow-state closeout token: {token}") + for token in [ + "s_runtime_team_state", + "touch_runtime_member", + "find_runtime_member_index", + "update_runtime_team_context", + "ensure_runtime_team_state_loaded", + "member.online", + ]: + if token in text: + failures += fail(f"Hostlink bridge still owns Team shadow-state token: {token}") + + if exists(app_events): + text = strip_cpp_comments(read_text(app_events)) + for token in [ + "isTeamRuntimeEvent", + "team::ui::shell::handle_event(nullptr, event)", + "hostlink::bridge::on_team_state_changed()", + ]: + if token not in text: + failures += fail(f"ESP app event runtime missing Team state sync token: {token}") + + if exists(esp_gps_map): + text = strip_cpp_comments(read_text(esp_gps_map)) + for token in [ + "team_ui_snapshot_store().load", + "load_team_data", + "ensure_member_colors", + ]: + if token not in text: + failures += fail(f"ESP GPS map missing Team snapshot overlay token: {token}") + for token in ["team_state.h", "g_team_state"]: + if token in text: + failures += fail(f"ESP GPS map still reads Team Page shadow state token: {token}") + + if exists(map_source): + text = strip_cpp_comments(read_text(map_source)) + for token in [ + "team_presence::isTeamMemberOnline", + "presenceNowForMember", + "ITeamUiSnapshotStore", + ]: + if token not in text: + failures += fail(f"Legacy map presentation source missing Team presence token: {token}") + if "member.online" in text: + failures += fail("Legacy map presentation source still infers liveness from member.online") + + for path in [contacts_header, contacts_source]: + if not exists(path): + failures += fail(f"missing Contacts Team snapshot source file: {path}") + continue + text = strip_cpp_comments(read_text(path)) + for token in [ + "ContactsTeamSnapshot", + "ContactsTeamSnapshotSource", + "ITeamUiSnapshotStore", + "contactsTeamDisplayName", + ]: + if token not in text: + failures += fail(f"{path} missing Contacts Team snapshot token: {token}") + for token in ["team_state.h", "g_team_state", "lvgl.h", "lv_obj_t"]: + if token in text: + failures += fail(f"{path} contains forbidden Contacts Team snapshot token: {token}") + + if exists(contacts_page): + text = strip_cpp_comments(read_text(contacts_page)) + for token in [ + "contacts_team_snapshot_source.h", + "contacts_team_snapshot_source", + "load_contacts_team_snapshot", + "contacts_team_title", + ]: + if token not in text: + failures += fail(f"Contacts page missing Team snapshot closeout token: {token}") + for token in ["team_state.h", "g_team_state"]: + if token in text: + failures += fail(f"Contacts page still reads Team Page state token: {token}") + + return failures + + def check_phase7_final_readiness() -> int: failures = 0 @@ -2570,15 +4536,18 @@ def check_chat_runtime_wires_team_action_sink() -> int: text = read_text(path) for token in [ - "LegacyTeamActionBridge", + "TeamActionRuntimeSink", "GpsTeamLocationSource", + "TeamControllerChatCommandPort", "s_team_action_sink", "s_team_location_source", "s_team_action_sink.get()", - "platform/ui/gps_runtime.h", + "team_runtime_adapters.h", ]: if token not in text: failures += fail(f"chat_page_runtime.cpp missing Team action wiring token: {token}") + if "platform/ui/gps_runtime.h" in text: + failures += fail("chat_page_runtime.cpp reintroduced direct GPS runtime include") return failures @@ -2880,8 +4849,24 @@ def main() -> int: failures += check_delivery_event_bridge_boundary() failures += check_delivery_action_bridge_boundary() failures += check_team_action_type_shape() - failures += check_team_action_bridge_boundary() + failures += check_team_action_runtime_sink_boundary() failures += check_chat_ui_team_action_migration() + failures += check_team_page_read_model_boundary() + failures += check_team_page_flow_controller_boundary() + failures += check_team_page_lvgl_renderer_boundary() + failures += check_team_page_command_reducer_boundary() + failures += check_team_page_create_team_action_boundary() + failures += check_team_page_pairing_command_action_boundary() + failures += check_team_page_request_keys_action_boundary() + failures += check_team_page_event_reducer_boundary() + failures += check_team_page_event_effect_sink_boundary() + failures += check_team_page_key_event_log_boundary() + failures += check_team_page_runtime_port_boundary() + failures += check_team_page_activity_sink_boundary() + failures += check_team_page_deferred_dispatch_boundary() + failures += check_team_page_kick_confirm_action_boundary() + failures += check_team_page_transfer_leader_action_boundary() + failures += check_team_page_state_store_boundary() failures += check_chat_ui_legacy_burndown() failures += check_team_rich_payload_presentation_boundary() failures += check_team_position_picker_renderer_boundary() @@ -2889,6 +4874,7 @@ def main() -> int: failures += check_map_tile_render_queue_cache_boundary() failures += check_map_overlay_route_tracker_boundary() failures += check_gps_runtime_scheduling_boundary() + failures += check_team_consumer_shadow_state_closeout() failures += check_phase7_final_readiness() failures += check_chat_runtime_event_pump_boundary() failures += check_chat_runtime_wires_team_action_sink()