From f330c7b3b612b5e40febad716b67c3b6879a0e33 Mon Sep 17 00:00:00 2001 From: liu weikai Date: Mon, 28 Sep 2026 16:47:36 +0800 Subject: [PATCH] Show verified geocache descriptions online and offline --- docs/geocaching-client-runtime.md | 8 ++ .../tests/test_browse_runtime.cpp | 68 +++++++++- .../geocaching/geocaching_source.h | 16 +++ .../geocaching/geocaching_page_shell.cpp | 47 ++++++- .../ui_shared/tests/geocaching/test_page.cpp | 27 ++++ .../arduino_common/geocaching/cache_detail.h | 117 +++++++++++++++++ .../src/geocaching/browse_runtime.cpp | 124 +++++++++++++++++- 7 files changed, 401 insertions(+), 6 deletions(-) create mode 100644 platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/cache_detail.h diff --git a/docs/geocaching-client-runtime.md b/docs/geocaching-client-runtime.md index 721a414b..0b05e0af 100644 --- a/docs/geocaching-client-runtime.md +++ b/docs/geocaching-client-runtime.md @@ -12,6 +12,14 @@ a rejected submission retries after one second. The query client's existing 120-second deadline cancels further sends. Closing abandons the RAM request. Exact accepted duplicates can be acknowledged without disk access. +Opening an item requests its exact signed revision and shows the full description +and hint after author verification. Only the open detail owns these text buffers; +closing it cancels further requests. Online detail reads do not require SD. +Saved items read the complete signed record already retained by the download +transaction, so descriptions and hints remain available offline after restart. +The GPX also includes both fields and the signed record; no second sidecar file +or background detail synchronization is introduced. + Storage starts when the user opens Downloaded or Published, or saves a cache. Downloads and publications retain signature checks, durable transaction state, verified GPX installation, restart recovery and durable reply receipts. Storage diff --git a/modules/core_geocaching/tests/test_browse_runtime.cpp b/modules/core_geocaching/tests/test_browse_runtime.cpp index 325a3cda..eadae860 100644 --- a/modules/core_geocaching/tests/test_browse_runtime.cpp +++ b/modules/core_geocaching/tests/test_browse_runtime.cpp @@ -218,10 +218,34 @@ void appendMaintenanceHistory(unsigned count) copy = 1 - copy; } } +struct DetailText +{ + ui::geocaching::DetailStatus status = ui::geocaching::DetailStatus::Pending; + std::string description, hint, error; +}; +DetailText detailText(const ui::geocaching::Item& item) +{ + test::in_ui = true; + DetailText result; + test::source->readDetail( + item.id, item.revision_hash, [](const ui::geocaching::DetailView& value, void* context) + { + auto& out = *static_cast(context); + out.status = value.status; + out.description = value.description; + out.hint = value.hint; + out.error = value.error; }, + &result); + require(!test::ui_io, "detail UI callback touched SD"); + return result; +} int main(int argc, char** argv) { require(argc == 2, "expected fixture directory"); const std::string folder(argv[1]); + const auto encoded_record = fixture(folder, "record-v1.bin"); + geocaching::RecordView expected_record; + require(geocaching::protocol::decodeGeocacheRecord({encoded_record.data(), encoded_record.size()}, expected_record), "detail record fixture invalid"); std::fprintf(stderr, "Runtime: discovery/download/publication\n"); chat::MeshAdapterRouter router; LoraBoard board; @@ -321,9 +345,35 @@ int main(int argc, char** argv) generation = view.generation; return test::source->item(Section::Discover, 0, generation, item) && item.can_download; }, "discovered row did not become downloadable"); - require(test::source->download(item, generation), "download not queued"); + test::card_ready = false; + test::source->open(item, generation); until([&] { return router.sends == 3; }, + "online detail request missing"); + auto invalid_detail = fixture(folder, "get-response-v1.bin"); + invalid_detail[invalid_detail.size() - 4] ^= 1; + reply(router, invalid_detail, 3); + for (unsigned i = 0; i < 30; ++i) tick(); + require(detailText(item).status == ui::geocaching::DetailStatus::Pending, "invalid detail signature was displayed"); + reply(router, fixture(folder, "get-response-v1.bin"), 3); + until([&] + { return detailText(item).status == ui::geocaching::DetailStatus::Ready; }, + "online detail not displayed"); + require(detailText(item).description == expected_record.description && detailText(item).hint == expected_record.hint, + "online detail omitted full description or hint"); + require(test::files.empty() && !test::blocked_io, "online detail required SD"); + require(router.delivery({{reply_remote.data(), 16}, {router.local.data(), 16}, {}, {last_reply.data(), last_reply.size()}}, router.context), + "verified detail duplicate was not acknowledged"); + require(xSemaphoreTake(semaphore, 0) == pdTRUE, "cannot lock during detail close"); + test::source->closeDetail(); + xSemaphoreGive(semaphore); + tick(); + require(detailText(item).status == ui::geocaching::DetailStatus::Failed, "busy close lost the cancellation"); + require(!test::allocated("geocaching.detail.rx") && !test::allocated("geocaching.detail.verify"), "closed detail retained temporary buffers"); + test::card_ready = true; + require(test::source->download(item, generation), "download not queued"); + until([&] + { return router.sends == 4; }, "download request not dispatched"); reply(router, fixture(folder, "get-response-v1.bin"), 3); until([&] @@ -362,7 +412,7 @@ int main(int argc, char** argv) "draft not ready for publication confirmation"); require(from == 0 && to == 1 && test::source->publishDraft(draft.id, draft_generation, author, to), "publication confirmation rejected"); until([&] - { return router.sends == 4; }, + { return router.sends == 5; }, "publication request not dispatched"); reply(router, publicationReply(router, 1), 1); until([&] @@ -391,7 +441,7 @@ int main(int argc, char** argv) "edited draft not ready for publication"); require(from == 1 && to == 2 && test::source->publishDraft(draft.id, draft_generation, author, to), "second publication confirmation rejected"); until([&] - { return router.sends == 5; }, + { return router.sends == 6; }, "second publication request not dispatched"); reply(router, publicationReply(router, 2), 1); until([&] @@ -420,6 +470,18 @@ int main(int argc, char** argv) return view.count == 1 && test::source->item(Section::Downloaded, 0, view.generation, item) && item.id == saved_id && item.downloaded; }, "restart lost downloaded map row"); require(test::files == disk, "read-only restart changed persisted files"); + router.ready = false; + const auto offline_sends = router.sends; + test::source->open(item, snapshot(Section::Downloaded).generation); + until([&] + { return detailText(item).status == ui::geocaching::DetailStatus::Ready; }, + "saved detail unavailable offline after restart"); + require(detailText(item).description == expected_record.description && detailText(item).hint == expected_record.hint, + "saved detail lost description or hint"); + require(router.sends == offline_sends && test::files == disk, "offline detail used network or rewrote storage"); + test::source->closeDetail(); + tick(); + router.ready = true; test::source->requestWindow(Section::Published, 0, 4); until([&] { diff --git a/modules/ui_presentation/include/ui_presentation/geocaching/geocaching_source.h b/modules/ui_presentation/include/ui_presentation/geocaching/geocaching_source.h index 5b0a0e4b..fb74884f 100644 --- a/modules/ui_presentation/include/ui_presentation/geocaching/geocaching_source.h +++ b/modules/ui_presentation/include/ui_presentation/geocaching/geocaching_source.h @@ -62,6 +62,17 @@ enum class DraftReadStatus : uint8_t Pending, Ready }; +enum class DetailStatus : uint8_t +{ + Pending, + Ready, + Failed +}; +struct DetailView +{ + DetailStatus status = DetailStatus::Pending; + std::string_view description, hint, error; +}; class Source { public: @@ -79,6 +90,11 @@ class Source virtual bool item(Section section, std::size_t index, std::uint64_t generation, Item& out) = 0; virtual void refresh(Section section) = 0; virtual void open(const Item& item, std::uint64_t generation) = 0; + // Borrowed text is consumed synchronously under the source lock. No I/O + // or retained pointers; large descriptions never become stack-local Items. + virtual bool readDetail(const std::array&, const std::array&, + void (*)(const DetailView&, void*), void*) { return false; } + virtual void closeDetail() {} virtual bool loadMore() { return false; } virtual bool download(const Item&, std::uint64_t) { return false; } // UI-thread call: enqueue/poll without I/O. Pending never calls or retains diff --git a/modules/ui_shared/src/ui/screens/geocaching/geocaching_page_shell.cpp b/modules/ui_shared/src/ui/screens/geocaching/geocaching_page_shell.cpp index adf70ae2..ecb06f1a 100644 --- a/modules/ui_shared/src/ui/screens/geocaching/geocaching_page_shell.cpp +++ b/modules/ui_shared/src/ui/screens/geocaching/geocaching_page_shell.cpp @@ -45,6 +45,7 @@ struct PageState size_t offset = 0, rendered_offset = 0, row_count = 0, window = kVisibleRows; size_t detail_index = 0; uint64_t detail_generation = 0; + uint8_t detail_status = 255; std::array detail_id{}, detail_hash{}; bool valid = false, details = false; }; @@ -197,6 +198,36 @@ lv_obj_t* button(lv_obj_t* parent, const char* text, lv_coord_t height) addFocusable(object); return object; } +void refreshDetailText() +{ + if (!page || !page->details || !source) return; + source->readDetail( + page->detail_id, page->detail_hash, + [](const ::ui::geocaching::DetailView& view, void*) + { + auto& p = *page; + const auto status = static_cast(view.status); + if (p.detail_status == status) return; + p.detail_status = status; + auto* description = lv_obj_get_child(p.list, 2); + auto* hint = lv_obj_get_child(p.list, 3); + if (view.status == ::ui::geocaching::DetailStatus::Ready) + { + lv_label_set_text_fmt(description, "Description\n%.*s", int(view.description.size()), view.description.empty() ? "" : view.description.data()); + if (view.description.empty()) lv_label_set_text(description, "No description provided."); + lv_label_set_text_fmt(hint, "Hint\n%.*s", int(view.hint.size()), view.hint.empty() ? "" : view.hint.data()); + if (view.hint.empty()) lv_obj_add_flag(hint, LV_OBJ_FLAG_HIDDEN); + else lv_obj_remove_flag(hint, LV_OBJ_FLAG_HIDDEN); + } + else + { + if (view.status == ::ui::geocaching::DetailStatus::Pending) lv_label_set_text(description, "Loading details..."); + else lv_label_set_text_fmt(description, "%.*s", int(view.error.size()), view.error.empty() ? "" : view.error.data()); + lv_obj_add_flag(hint, LV_OBJ_FLAG_HIDDEN); + } + }, + nullptr); +} void refreshView() { if (!page) return; @@ -238,6 +269,7 @@ void refreshView() if (page->details) { if (!source) return; + refreshDetailText(); ::ui::geocaching::Snapshot current; source->snapshot(page->section, current); if (current.busy) return; @@ -353,6 +385,7 @@ void refreshView() void closeDetails() { if (!page) return; + if (page->details && source) source->closeDetail(); page->details = false; page->valid = false; lv_obj_remove_flag(page->previous, LV_OBJ_FLAG_HIDDEN); @@ -371,6 +404,7 @@ void showDetails(const ::ui::geocaching::Item& item, size_t index) } auto& p = *page; p.details = true; + p.detail_status = 255; p.detail_index = index; p.detail_generation = p.snapshot.generation; p.detail_id = item.id; @@ -387,7 +421,18 @@ void showDetails(const ::ui::geocaching::Item& item, size_t index) lv_obj_set_width(body, LV_PCT(100)); lv_label_set_text(body, item.detail.data()); styles::apply_label_muted(body); - lv_label_set_text(p.range, "Preview"); + auto* description = lv_label_create(p.list); + lv_obj_set_width(description, LV_PCT(100)); + lv_label_set_long_mode(description, LV_LABEL_LONG_WRAP); + lv_label_set_text(description, "Loading details..."); + styles::apply_label_primary(description); + auto* hint = lv_label_create(p.list); + lv_obj_set_width(hint, LV_PCT(100)); + lv_label_set_long_mode(hint, LV_LABEL_LONG_WRAP); + styles::apply_label_primary(hint); + lv_obj_add_flag(hint, LV_OBJ_FLAG_HIDDEN); + refreshDetailText(); + lv_label_set_text(p.range, "Details"); lv_label_set_text(lv_obj_get_child(p.refresh, 0), "Back"); setEnabled(p.refresh, true); setEnabled(p.previous, false); diff --git a/modules/ui_shared/tests/geocaching/test_page.cpp b/modules/ui_shared/tests/geocaching/test_page.cpp index 841b9578..bfa9b7fd 100644 --- a/modules/ui_shared/tests/geocaching/test_page.cpp +++ b/modules/ui_shared/tests/geocaching/test_page.cpp @@ -34,6 +34,22 @@ struct TestSource : ui::geocaching::Source std::string draft_name, draft_description, draft_hint; bool has_draft = false, fail_save = false, snapshot_busy = false; unsigned pending_reads = 0; + bool detail_ready = false; + unsigned detail_closes = 0; + bool readDetail(const std::array&, const std::array&, + void (*sink)(const ui::geocaching::DetailView&, void*), void* context) override + { + ui::geocaching::DetailView view; + if (detail_ready) + { + view.status = ui::geocaching::DetailStatus::Ready; + view.description = "A woodland cache beside the old trail.\nFollow the stream to the stone bridge."; + view.hint = "Look beneath the large flat stone."; + } + sink(view, context); + return true; + } + void closeDetail() override { ++detail_closes; } unsigned cancelled_reads = 0; bool fail_read = false; unsigned publications = 0; @@ -242,7 +258,18 @@ int main(int argc, char** argv) if (lv_obj_get_child_count(list) != visible) return 62; lv_obj_send_event(lv_obj_get_child(list, 0), LV_EVENT_CLICKED, nullptr); if (source.opens != 1 || source.last_open != 0) return 6; + if (std::strcmp(lv_label_get_text(lv_obj_get_child(list, 2)), "Loading details...")) return 64; + source.detail_ready = true; + // Detail completion is independent of list generation changes. + lv_tick_inc(600); + lv_timer_handler(); + if (!std::strstr(lv_label_get_text(lv_obj_get_child(list, 2)), "Follow the stream") || + !std::strstr(lv_label_get_text(lv_obj_get_child(list, 3)), "large flat stone") || + lv_obj_has_flag(lv_obj_get_child(list, 3), LV_OBJ_FLAG_HIDDEN)) return 65; if (!save(std::string(argv[3]) + "-detail.ppm", screen)) return 11; + lv_obj_scroll_to_y(list, LV_COORD_MAX, LV_ANIM_OFF); + if (!save(std::string(argv[3]) + "-detail-hint.ppm", screen)) return 66; + if (lv_obj_get_scroll_y(list) <= 0) return 67; auto* details_footer = lv_obj_get_child(root, 4); const auto detail_reads = source.reads; source.snapshot_busy = true; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/cache_detail.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/cache_detail.h new file mode 100644 index 00000000..2a00064b --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/cache_detail.h @@ -0,0 +1,117 @@ +#pragma once +#include "geocaching/protocol/cmp_writer.h" +#include "geocaching/protocol/get_response.h" +#include "geocaching/protocol/verify_record.h" +#include "platform/esp/arduino_common/chat/infra/mesh_adapter_router.h" +#include "platform/esp/arduino_common/geocaching/sd_indexed_saved_cache.h" +#include "platform/esp/common/memory_budget.h" +#include "ui_presentation/geocaching/geocaching_source.h" +#include +#include + +namespace platform::esp::arduino_common::geocaching +{ +// One open detail, allocated off the ESP stack. Text is copied only after +// author verification against the selected cache ID and revision hash. +struct CacheDetail +{ + enum class State + { + Network, + Saved, + Ready, + Failed + }; + ::geocaching::GeocacheId id; + ::geocaching::RevisionHash hash; + ::geocaching::Destination remote; + ::geocaching::RequestId request; + std::array request_bytes{}; + size_t request_size = 0; + std::array description{}; + std::array hint{}; + std::array receipt{}; + bool has_receipt = false; + uint8_t* response = nullptr; + size_t response_size = 0; + std::unique_ptr saved_read; + uint64_t started = 0, next_send = 0; + State state = State::Network; + const char* error = "Details unavailable. Go back and reopen to retry."; + ~CacheDetail() + { + if (response) heap_caps_free(response); + } + bool matches(const std::array& cache, const std::array& revision) const + { + return id.bytes == cache && hash.bytes == revision; + } + bool matches(const ::geocaching::Destination& source, const ::geocaching::RequestId& incoming) const + { + return request_size && source.bytes == remote.bytes && incoming.bytes == request.bytes; + } + bool verify(::geocaching::ByteView bytes, ::geocaching::protocol::RecordCrypto& crypto) + { + constexpr size_t capacity = ::geocaching::kMaxRecordBytes + 64; + auto* scratch = static_cast(::platform::esp::common::memory::allocatePreferred("geocaching.detail.verify", capacity, false)); + if (!scratch) return false; + ::geocaching::protocol::VerifiedRecordView verified; + const bool valid = ::geocaching::protocol::verifyGeocache(bytes, crypto, scratch, capacity, verified, &id, &hash) == + ::geocaching::protocol::VerificationResult::Valid; + if (valid) + { + description.fill(0); + hint.fill(0); + std::memcpy(description.data(), verified.record.description.data(), verified.record.description.size()); + std::memcpy(hint.data(), verified.record.hint.data(), verified.record.hint.size()); + state = State::Ready; + } + heap_caps_free(scratch); + return valid; + } + bool receive(const ::geocaching::Destination& source, const ::geocaching::RequestId& incoming, + ::geocaching::ByteView bytes, ::geocaching::protocol::RecordCrypto& crypto, uint8_t** owned) + { + if (!matches(source, incoming)) return false; + if (state == State::Ready && has_receipt) + { + std::array digest{}; + return crypto.sha256(bytes, digest.data()) && digest == receipt; + } + if (state != State::Network || response) return false; + response = owned ? *owned : static_cast(::platform::esp::common::memory::allocatePreferred("geocaching.detail.rx", bytes.size, false)); + if (!response) return false; + if (owned) *owned = nullptr; + else std::memcpy(response, bytes.data, bytes.size); + response_size = bytes.size; + return false; + } + void advanceNetwork(chat::MeshAdapterRouter& router, const ::geocaching::Destination& local, + ::geocaching::protocol::RecordCrypto& crypto, uint64_t now) + { + if (state != State::Network) return; + if (response) + { + ::geocaching::protocol::GetResponseView parsed; + if (::geocaching::protocol::decodeGetResponse({response, response_size}, request, 8192, parsed) && !parsed.has_conflict && + verify(parsed.signed_cache, crypto)) + has_receipt = crypto.sha256({response, response_size}, receipt.data()); + heap_caps_free(response); + response = nullptr; + response_size = 0; + if (state == State::Ready) return; + } + if (now - started >= 120000) + { + state = State::Failed; + error = "Directory did not return verified details. Go back and reopen to retry."; + return; + } + if (now < next_send) return; + std::array accepted{}; + const auto sent = router.sendGeocachingData(remote.bytes.data(), {request_bytes.data(), request_size}, false, &accepted, local.bytes.data()); + next_send = now + (sent.ok ? 15000 : 1000); + } +}; +static_assert(sizeof(CacheDetail) < 4096, "Only the open detail owns full text"); +} // namespace platform::esp::arduino_common::geocaching diff --git a/platform/esp/arduino_common/src/geocaching/browse_runtime.cpp b/platform/esp/arduino_common/src/geocaching/browse_runtime.cpp index 6425156d..5b6b2f11 100644 --- a/platform/esp/arduino_common/src/geocaching/browse_runtime.cpp +++ b/platform/esp/arduino_common/src/geocaching/browse_runtime.cpp @@ -7,6 +7,7 @@ #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h" #include "platform/esp/arduino_common/chat/infra/reticulum/reticulum_adapter.h" #include "platform/esp/arduino_common/geocaching/author_issue_port.h" +#include "platform/esp/arduino_common/geocaching/cache_detail.h" #include "platform/esp/arduino_common/geocaching/indexed_dispatch_store.h" #include "platform/esp/arduino_common/geocaching/indexed_download_store.h" #include "platform/esp/arduino_common/geocaching/indexed_publication_store.h" @@ -191,6 +192,7 @@ struct Session std::unique_ptr store; std::unique_ptr dispatch_store; std::unique_ptr port; + std::unique_ptr detail, pending_detail; std::unique_ptr receipts; std::unique_ptr client; std::unique_ptr dispatcher; @@ -261,6 +263,8 @@ struct Session } ~Session() { + workspace_owner.release(detail.get()); + detail.reset(); workspace_owner.release(checkpoint.get()); checkpoint.reset(); if (store && store->commitPending()) store->cancelCommit(); @@ -288,6 +292,7 @@ std::unique_ptr session; std::array boot{}; std::atomic wanted{false}, active{false}, restart{false}; std::atomic cancel_draft_read{false}; +std::atomic cancel_detail{false}; std::atomic next_step{0}; std::atomic replies_seen{0}, replies_busy{0}, replies_queued{0}; uint32_t replies_processed = 0, replies_accepted = 0; @@ -421,7 +426,7 @@ void fail(const char* reason) bool advanceCheckpoint(Session& s) { if (!s.checkpoint) return false; - const bool foreground = s.response || downloadActive() || publicationActive() || draftSaveActive() || + const bool foreground = s.response || (s.detail && s.detail->state == CacheDetail::State::Saved) || downloadActive() || publicationActive() || draftSaveActive() || (s.saved && s.saved->pending() && !s.draft_catalog_wanted) || (s.draft_read && s.draft_read->status == ::ui::geocaching::DraftReadStatus::Pending) || (s.draft_catalog_wanted && !draftCatalogReady(s)); @@ -631,6 +636,12 @@ bool receiveResponse(const chat::lxmf::CustomDeliveryView& message, uint8_t** ow !reader.binary(id, 16) || id.size != 16) return false; gc::RequestId request; std::memcpy(request.bytes.data(), id.data, 16); + if (value == 3 && session->detail && session->detail->matches(source, request)) + { + const bool accepted = session->detail->receive(source, request, {message.data.data, message.data.size}, session->crypto, owned); + next_step.store(0); + return accepted; + } if (value == 0 || value == 2) { const gc::ByteView bytes{message.data.data, message.data.size}; @@ -805,6 +816,45 @@ void startRecovery() ++epoch; } +// Use the same installed-record lookup as the saved list, but retain the full +// verified text for the open detail. No new files or persistent tasks. +bool advanceSavedDetail(Session& s) +{ + if (!s.detail || s.detail->state != CacheDetail::State::Saved) return false; + auto& job = *s.detail; + const auto finish = [&](const char* error) + { + job.saved_read.reset(); + s.workspace_owner.release(&job); + if (error) + { + job.error = error; + job.state = CacheDetail::State::Failed; + } + return true; + }; + if (s.phase == Phase::Failed || s.needsRecovery()) return finish("Saved details need storage recovery."); + if (!storage::sd_card_ready() || storage::sd_external_block_owner_active()) return finish("SD card unavailable. Go back and reopen to retry."); + if (now(nullptr).monotonic_ms - job.started >= 120000) return finish("Saved details timed out. Go back and reopen to retry."); + if (s.phase != Phase::Ready) return false; + if (!job.saved_read) + { + if (downloadActive() || publicationActive() || draftSaveActive()) return false; + if (!s.workspace_owner.acquire(&job)) return false; + job.saved_read.reset(new (std::nothrow) SdIndexedSavedCache(s.volume)); + if (!job.saved_read || !job.saved_read->begin(s.root, {job.id.bytes.data(), job.id.bytes.size()}, true, s.frame, kFrameCapacity)) + return finish("Insufficient memory to read saved details."); + } + const auto result = job.saved_read->step(); + if (result == IndexScanStep::Working) return true; + gc::storage::SavedCacheRecord saved; + gc::ByteView signed_cache; + if (result != IndexScanStep::Item || !job.saved_read->result(saved, signed_cache) || + saved.hash != job.hash.bytes || !job.verify(signed_cache, s.crypto)) + return finish("Saved details could not be verified."); + return finish(nullptr); +} + enum class PublicationRestore : uint8_t { None, @@ -1160,6 +1210,7 @@ class Facade final : public ::ui::geocaching::Source void activate(bool open) override { wanted.store(open); + if (!open) cancel_detail.store(true); next_step.store(0); } void snapshot(::ui::geocaching::Section section, ::ui::geocaching::Snapshot& out) override @@ -1358,7 +1409,62 @@ class Facade final : public ::ui::geocaching::Source if (begun) next_step.store(0); return begun; } - void open(const ::ui::geocaching::Item&, uint64_t) override {} + void open(const ::ui::geocaching::Item& item, uint64_t) override + { + Guard guard; + if (!guard.locked || !session || item.is_draft) return; + auto job = std::unique_ptr(new (std::nothrow) CacheDetail); + if (!job) return; + job->id.bytes = item.id; + job->hash.bytes = item.revision_hash; + job->started = now(nullptr).monotonic_ms; + if (item.downloaded) + { + job->state = CacheDetail::State::Saved; + session->storage_requested = true; + } + else if (!session->port || !session->port->pageSource(job->remote) || + !randomId(nullptr, job->request.bytes.data()) || + !gc::protocol::encodeGetRequest(job->request, job->id, &job->hash, nullptr, 8192, + job->request_bytes.data(), job->request_bytes.size(), job->request_size)) + job->state = CacheDetail::State::Failed; + session->pending_detail = std::move(job); + cancel_detail.store(false); + next_step.store(0); + } + bool readDetail(const std::array& id, const std::array& hash, + void (*sink)(const ::ui::geocaching::DetailView&, void*), void* context) override + { + Guard guard; + if (!guard.locked || !session || !sink) return false; + const auto* job = session->pending_detail ? session->pending_detail.get() : session->detail.get(); + ::ui::geocaching::DetailView view; + if (!job || cancel_detail.load() || !job->matches(id, hash)) + { + view.status = ::ui::geocaching::DetailStatus::Failed; + view.error = "Details unavailable. Go back and reopen to retry."; + sink(view, context); + return true; + } + if (job->state == CacheDetail::State::Ready) + { + view.status = ::ui::geocaching::DetailStatus::Ready; + view.description = job->description.data(); + view.hint = job->hint.data(); + } + else if (job->state == CacheDetail::State::Failed) + { + view.status = ::ui::geocaching::DetailStatus::Failed; + view.error = job->error; + } + sink(view, context); + return true; + } + void closeDetail() override + { + cancel_detail.store(true); + next_step.store(0); + } bool publicationAuthor(const std::array& id, uint64_t generation, std::array& author, uint32_t* from, uint32_t* to) override { Guard guard; @@ -1684,13 +1790,25 @@ void step() ++epoch; } auto& s = *session; + const bool close_detail = cancel_detail.exchange(false); + if (close_detail || s.pending_detail) + { + s.workspace_owner.release(s.detail.get()); + s.detail.reset(); + if (close_detail) s.pending_detail.reset(); + else s.detail = std::move(s.pending_detail); + } advanceBrowse(s); + if (s.detail) s.detail->advanceNetwork(*router, s.local, s.crypto, now(nullptr).monotonic_ms); if (!s.storage_requested || !s.client) { next_step.store(millis() + 100); return; } WorkspaceSlice workspace_slice{s}; + if (s.detail && s.detail->state == CacheDetail::State::Saved && + (s.phase == Phase::Failed || !storage::sd_card_ready() || storage::sd_external_block_owner_active())) + advanceSavedDetail(s); if (s.phase == Phase::Failed) { next_step.store(millis() + 2000); @@ -1766,6 +1884,7 @@ void step() // The active holder must run before new foreground work waiting for it. // Saved reply receipts share the lease with persistent operations. if (advanceCheckpoint(s)) return; + if (s.detail && s.workspace_owner.heldBy(s.detail.get()) && advanceSavedDetail(s)) return; if (s.dispatcher && s.dispatch_store->busy() && s.workspace_owner.heldBy(s.dispatch_store.get())) { const auto sent = s.dispatcher->dispatchOne(now(nullptr)); @@ -1849,6 +1968,7 @@ void step() // Advance the operation that currently owns storage before retrying. } if (s.phase == Phase::Ready && s.download_start && advanceDownloadStart(s)) return; + if (advanceSavedDetail(s)) return; if (s.saved && s.saved->pending() && !s.draft_catalog_wanted && s.phase != Phase::ResumeDownloads && !downloadActive() && !publicationActive() && !draftSaveActive() && !s.store->commitPending() && !s.needsRecovery()) {