From cf62f438f7dba5d8b65f3c31c8daec1df7353f38 Mon Sep 17 00:00:00 2001 From: liu weikai Date: Mon, 10 Aug 2026 17:17:49 +0800 Subject: [PATCH] fix(chat): keep Pager voice control visible --- ...st_vmp_attachment_persistence_contract.cpp | 8 +++++++ .../ui_shared/include/ui/chat_voice_runtime.h | 14 ++++++++++++ .../ui_shared/src/ui/chat_voice_runtime.cpp | 5 +++++ .../ui/screens/chat/chat_ui_controller.cpp | 22 ++++++++++++++++--- .../tests/test_chat_voice_runtime.cpp | 6 +++++ 5 files changed, 52 insertions(+), 3 deletions(-) diff --git a/modules/core_chat/tests/test_vmp_attachment_persistence_contract.cpp b/modules/core_chat/tests/test_vmp_attachment_persistence_contract.cpp index 199e01aa..34ea4877 100644 --- a/modules/core_chat/tests/test_vmp_attachment_persistence_contract.cpp +++ b/modules/core_chat/tests/test_vmp_attachment_persistence_contract.cpp @@ -63,6 +63,8 @@ int main(int argc, char** argv) root / "modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp"); const std::string chat_compose = readFile( root / "modules/ui_shared/src/ui/screens/chat/chat_compose_components.cpp"); + const std::string chat_voice_runtime = readFile( + root / "modules/ui_shared/src/ui/chat_voice_runtime.cpp"); const std::size_t store_completed = positionOf(session, "bool storeCompletedVoice("); const std::size_t outbound_store = positionOf(session, "bool storeOutboundVoice()"); @@ -109,6 +111,12 @@ int main(int argc, char** argv) std::string::npos); assert(chat_controller.find("::ui::chat_voice::listReceivedMessages(") == std::string::npos); + // Durable attachment recovery is intentionally a send gate, never a + // reason to hide the supported Pager's only voice affordance. + assert(chat_voice_runtime.find("bool isRuntimeBound()") != std::string::npos); + assert(chat_controller.find("setVoiceButton(\"Voice\", voice_runtime_bound)") != + std::string::npos); + assert(chat_controller.find("Hold to talk") == std::string::npos); // Typed attachments have the same conversation boundary as text: a peer // number alone is insufficient because channels and mesh backends can // legitimately reuse it. The logical chat channel is part of VMP control diff --git a/modules/ui_shared/include/ui/chat_voice_runtime.h b/modules/ui_shared/include/ui/chat_voice_runtime.h index 776bd517..eda8b91a 100644 --- a/modules/ui_shared/include/ui/chat_voice_runtime.h +++ b/modules/ui_shared/include/ui/chat_voice_runtime.h @@ -5,6 +5,12 @@ * The shared chat UI depends on this port instead of a radio, MQTT, or chat * transport implementation. Registering an implementation is optional; on * devices without VMP support the compose screen simply has no voice action. + * + * A registered runtime and a runtime that is immediately ready to send are + * intentionally different states. The latter can be delayed while the + * durable attachment inbox is restored after boot. Compose uses the bound + * state to keep its Voice control discoverable, and uses send readiness only + * to report why a press cannot yet begin recording. */ #pragma once @@ -92,6 +98,14 @@ class IVoiceMessageRuntime /** @brief Binds the device-specific VMP service during platform startup. */ void setRuntime(IVoiceMessageRuntime* runtime); +/** + * @brief True when this device has registered its isolated VMP integration. + * + * This is a UI affordance capability, not a readiness check: a bound Pager + * keeps the compact Voice control visible while durable storage is restoring. + */ +bool isRuntimeBound(); + /** @brief True only when this device has initialized an isolated VMP service. */ bool isAvailable(); diff --git a/modules/ui_shared/src/ui/chat_voice_runtime.cpp b/modules/ui_shared/src/ui/chat_voice_runtime.cpp index 84c4c0b0..3a954af3 100644 --- a/modules/ui_shared/src/ui/chat_voice_runtime.cpp +++ b/modules/ui_shared/src/ui/chat_voice_runtime.cpp @@ -19,6 +19,11 @@ void setRuntime(IVoiceMessageRuntime* runtime) s_runtime = runtime; } +bool isRuntimeBound() +{ + return s_runtime != nullptr; +} + bool isAvailable() { return s_runtime && s_runtime->isAvailable(); 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 1bf7c8cd..c04ab64b 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 @@ -1585,7 +1585,20 @@ void UiController::switchToCompose(chat::ConversationId conv) std::string header = "[" + std::string(protocol_short_label(conv.protocol)) + "] " + title; compose_->setHeaderText(header.c_str(), nullptr); #if !defined(ARDUINO_T_WATCH_S3) - compose_->setVoiceButton("Hold to talk", ::ui::chat_voice::canRecordAndSend()); + // Durable VMP attachment recovery happens after the main UI is usable. + // That transient readiness state must never remove the only recording + // affordance from a supported Pager. A press reports the exact current + // state instead; unsupported boards have no bound VMP runtime and still + // do not show this control. + const bool voice_runtime_bound = ::ui::chat_voice::isRuntimeBound(); + const bool voice_send_ready = ::ui::chat_voice::canRecordAndSend(); + compose_->setVoiceButton("Voice", voice_runtime_bound); + CHAT_UI_LOG("[ChatUiTrace][VMP] compose voice bound=%u send_ready=%u protocol=%u channel=%u peer=%08lX\n", + voice_runtime_bound ? 1U : 0U, + voice_send_ready ? 1U : 0U, + static_cast(conv.protocol), + static_cast(conv.channel), + static_cast(conv.peer)); #else compose_->setPositionButton(nullptr, false); #endif @@ -2788,7 +2801,7 @@ void UiController::handleComposeAction(ChatComposeScreen::ActionIntent intent) voice_hold_active_ = true; voice_hold_started_ms_ = lv_tick_get(); voice_hold_last_render_ms_ = voice_hold_started_ms_; - compose_->setVoiceButton("Release 0.0s", true); + compose_->setVoiceButton("Release", true); compose_->setHeaderText(nullptr, "REC 0.0s/5"); CHAT_UI_LOG("[ChatUiTrace][VMP] voice press queued target=%08lX\n", static_cast(current_conv_.peer)); @@ -2801,7 +2814,10 @@ void UiController::handleComposeAction(ChatComposeScreen::ActionIntent intent) return; case ::ui::chat_voice::StartResult::Unsupported: default: - ::ui::feedback::show_notice("Voice unavailable on this device", 2000); + CHAT_UI_LOG("[ChatUiTrace][VMP] voice press rejected result=unsupported bound=%u send_ready=%u\n", + ::ui::chat_voice::isRuntimeBound() ? 1U : 0U, + ::ui::chat_voice::canRecordAndSend() ? 1U : 0U); + ::ui::feedback::show_notice("Voice storage loading; try again", 2000); return; } } diff --git a/modules/ui_shared/tests/test_chat_voice_runtime.cpp b/modules/ui_shared/tests/test_chat_voice_runtime.cpp index dbc7885c..52b03525 100644 --- a/modules/ui_shared/tests/test_chat_voice_runtime.cpp +++ b/modules/ui_shared/tests/test_chat_voice_runtime.cpp @@ -107,6 +107,7 @@ class FakeVoiceRuntime final : public ui::chat_voice::IVoiceMessageRuntime void test_unbound_runtime_is_safe() { ui::chat_voice::setRuntime(nullptr); + assert(!ui::chat_voice::isRuntimeBound()); assert(!ui::chat_voice::isAvailable()); assert(!ui::chat_voice::canRecordAndSend()); assert(ui::chat_voice::requestRecordAndSend({1U, 1U, 0U}) == @@ -118,6 +119,7 @@ void test_runtime_forwards_without_transport_coupling() FakeVoiceRuntime runtime{}; ui::chat_voice::setRuntime(&runtime); + assert(ui::chat_voice::isRuntimeBound()); assert(ui::chat_voice::isAvailable()); assert(ui::chat_voice::canRecordAndSend()); assert(ui::chat_voice::requestRecordAndSend({0x11223344U, 1U, 1U}) == @@ -134,6 +136,9 @@ void test_runtime_forwards_without_transport_coupling() runtime.available = false; runtime.send_available = false; runtime.result = ui::chat_voice::StartResult::PrivateContactUnverified; + // Runtime binding is stable across transient storage/carrier readiness. + // Compose uses this state to keep the compact Voice control visible. + assert(ui::chat_voice::isRuntimeBound()); assert(!ui::chat_voice::isAvailable()); assert(!ui::chat_voice::canRecordAndSend()); assert(ui::chat_voice::requestRecordAndSend({0x55667788U, 4U, 0U}) == @@ -164,6 +169,7 @@ void test_runtime_forwards_without_transport_coupling() assert(runtime.played_id == summaries[0].local_id); ui::chat_voice::setRuntime(nullptr); + assert(!ui::chat_voice::isRuntimeBound()); } } // namespace