From 69167f211fc1bddd2188eef6f67c57e7daa87bee Mon Sep 17 00:00:00 2001 From: liu weikai Date: Wed, 30 Sep 2026 23:28:08 +0800 Subject: [PATCH] Close public cache archival and offline removal flows --- CHANGELOG.md | Bin 122210 -> 122568 bytes docs/geocaching-client-runtime.md | 25 ++ .../tests/test_browse_runtime.cpp | 75 ++++- .../tests/test_index_reference_proofs.cpp | 39 +++ .../tests/test_local_map_overlay.cpp | 28 ++ .../geocaching/geocaching_source.h | 9 + .../geocaching/local_map_overlay.h | 6 +- .../geocaching/geocaching_page_shell.cpp | 97 ++++++ .../ui_shared/tests/geocaching/test_page.cpp | 22 ++ .../arduino_common/geocaching/cache_detail.h | 8 + .../geocaching/sd_indexed_remove_saved.h | 78 +++++ .../src/geocaching/browse_runtime.cpp | 284 ++++++++++++++++-- tools/geocaching/deploy/README.md | 17 ++ tools/geocaching/directory_store.py | 37 ++- tools/geocaching/test_directory_store.py | 33 ++ 15 files changed, 722 insertions(+), 36 deletions(-) create mode 100644 platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_remove_saved.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 51c3796cb73235c1be0cf6c2c5cf83fb4b6e5f2a..c4a4775a4c8ab967c6696b31e846040f122d1b19 100644 GIT binary patch delta 271 zcmaE~i~Yo2_6=)TCh>^LIx^${VLn4Sg94b$V@PEvVkidkN*MAdGyWHrEMO>QNCK+K zWJm^zCNm@gaRyLT@nl0uVZ%(IY6XTAAio4ClL=H;%wP=^$pErJq6$FKBCw83hB6>O zW%7DvN#Pr^#E-EnxX)99mv)kAUhXG7eMJE zhHRi3h+7kZI!eGi1)#1(h)oQ-NUjCBJOe0`$B@pDI=P=yzxf!;_G2uJb8Y|tsiZuq delta 22 ecmX@Hm;KQ$_6=)TCO=S?YSv-fuEWMS=LP_F;t5j# diff --git a/docs/geocaching-client-runtime.md b/docs/geocaching-client-runtime.md index 29d6cb21..cb6a6a8a 100644 --- a/docs/geocaching-client-runtime.md +++ b/docs/geocaching-client-runtime.md @@ -172,3 +172,28 @@ Deletion, rebinding, and recovery retain full reference validation. Read faults can occur during journal readback after redundant preflight reads are removed. Fault coverage requires unchanged committed data and rejection of further writes until recovery; an unconfirmed journal may remain on storage. + +## Public withdrawal and local removal + +On a verified cache detail, `Archive public cache` is available only when the +record author matches the current device identity. Confirming creates a signed +successor with the same cache ID, the selected revision hash as its parent, and +state `2`. An existing authored draft is retained with the archived state. +The UI remains `Awaiting directory confirmation...` until the matching accepted +directory result is committed. An offline delete never substitutes for this +public operation. The same action is available on an owned Downloaded detail. + +Directories hide archived heads from ordinary queries, including continuation +pages created before archival. They reject ordinary reads of a former active +revision with `410 cache_archived`, including retries of previously successful +GET requests. Explicit signed tombstone reads and registered directory peers +retain access for replication. Publication receipts and synchronization history +remain durable; already downloaded copies cannot be remotely erased. + +`Delete local copy` only clears the selected installed head and advances its +installation generation in a journaled transaction. This removes the offline +entry from Downloaded and Map and prevents older installation tasks from +restoring it. Immutable historical objects and GPX files remain on SD; this is +not a promise of immediate physical file reclamation. Local map projections +invalidate on committed saves, removals and installation changes even when the +viewport stays unchanged. Work and payload buffers are allocated in PSRAM. diff --git a/modules/core_geocaching/tests/test_browse_runtime.cpp b/modules/core_geocaching/tests/test_browse_runtime.cpp index b4dbea7f..b77fbf6f 100644 --- a/modules/core_geocaching/tests/test_browse_runtime.cpp +++ b/modules/core_geocaching/tests/test_browse_runtime.cpp @@ -225,6 +225,7 @@ struct DetailText { ui::geocaching::DetailStatus status = ui::geocaching::DetailStatus::Pending; std::string description, hint, error; + bool can_archive = false; }; DetailText detailText(const ui::geocaching::Item& item) { @@ -237,6 +238,7 @@ DetailText detailText(const ui::geocaching::Item& item) out.status = value.status; out.description = value.description; out.hint = value.hint; + out.can_archive = value.can_archive; out.error = value.error; }, &result); require(!test::ui_io, "detail UI callback touched SD"); @@ -577,23 +579,51 @@ int main(int argc, char** argv) // intentionally retains an active revision-2 draft for its map assertions. const auto active_fixture = test::files; const auto active_directories = test::directories; - draft.generation = item.edit_generation; - draft.state = 2; - require(test::source->saveDraft(draft), "archive draft was not queued"); - until([&] - { return test::source->draftSaveStatus(draft.id, draft.generation) == ui::geocaching::DraftSaveStatus::Saved; }, - "archive draft was not saved"); - until([&] - { - const auto view = snapshot(Section::Published); - if (!test::source->item(Section::Published, 0, view.generation, item)) return false; - draft_generation = item.edit_generation; - return item.state == 2 && !item.publication_confirmed && test::source->publicationAuthor(draft.id, draft_generation, author, &from, &to); }, - "archive preview not ready or reused previous confirmation"); - require(from == 2 && to == 3 && test::source->publishDraft(draft.id, draft_generation, author, to), "archive publication was rejected"); + geocaching::protocol::CmpReader sent({router.sent_bytes.data(), router.sent_bytes.size()}); + size_t sent_count = 0; + uint64_t sent_value = 0; + geocaching::ByteView sent_id; + require(sent.array(sent_count, 6) && sent.unsignedInteger(sent_value) && sent.unsignedInteger(sent_value) && + sent.unsignedInteger(sent_value) && sent.binary(sent_id, 16), + "archive source request invalid"); + geocaching::RequestId prior_request; + std::memcpy(prior_request.bytes.data(), sent_id.data, 16); + geocaching::protocol::PublishRequestView published; + geocaching::protocol::VerifiedRecordView public_record; + std::vector detail_scratch(8192), own_detail_response(8192); + platform::esp::common::EspGeocachingCrypto public_crypto; + require(geocaching::protocol::decodePublishRequest({router.sent_bytes.data(), router.sent_bytes.size()}, prior_request, published) && + geocaching::protocol::verifyGeocache(published.signed_cache, public_crypto, detail_scratch.data(), detail_scratch.size(), public_record) == geocaching::protocol::VerificationResult::Valid, + "archive base not verified"); + ui::geocaching::Item public_item; + public_item.id = public_record.id.bytes; + public_item.revision_hash = public_record.hash.bytes; + geocaching::protocol::CmpReader signed_reader(published.signed_cache); + geocaching::ByteView archive_record_bytes, signature; + require(signed_reader.array(sent_count, 2) && signed_reader.binary(archive_record_bytes, 4096) && signed_reader.binary(signature, 64), "archive signed pair invalid"); + geocaching::protocol::CmpWriter detail_writer(own_detail_response.data(), own_detail_response.size()); + require(detail_writer.array(6) && detail_writer.unsignedInteger(1) && detail_writer.unsignedInteger(1) && detail_writer.unsignedInteger(3) && + detail_writer.binary({prior_request.bytes.data(), 16}) && detail_writer.unsignedInteger(200) && detail_writer.array(3) && + detail_writer.array(2) && detail_writer.binary(archive_record_bytes) && detail_writer.binary(signature) && detail_writer.unsignedInteger(1) && detail_writer.unsignedInteger(0), + "archive detail response failed"); + own_detail_response.resize(detail_writer.size()); + test::source->open(public_item, 0); until([&] { return router.sends == 7; }, + "archive detail not requested"); + reply(router, own_detail_response, 3); + until([&] + { return detailText(public_item).status == ui::geocaching::DetailStatus::Ready; }, + "archive detail not verified"); + require(detailText(public_item).can_archive, "owner cannot manage verified public detail"); + auto stale_archive = public_item.revision_hash; + stale_archive[0] ^= 1; + require(!test::source->archiveCache(public_item.id, stale_archive), "archive accepted a stale detail"); + require(test::source->archiveCache(public_item.id, public_item.revision_hash), "public detail archive not queued"); + until([&] + { return router.sends == 8; }, "archive request not dispatched"); + require(test::source->archiveStatus(public_item.id) == ui::geocaching::DraftSaveStatus::Pending, "archive confirmed without directory reply"); reply(router, publicationReply(router, 3), 1); until([&] { @@ -601,6 +631,7 @@ int main(int argc, char** argv) return test::source->item(Section::Published, 0, view.generation, item) && item.state == 2 && item.publication_confirmed && item.publication_revision == 3; }, "archive directory confirmation not reflected in local record"); + require(test::source->archiveStatus(public_item.id) == ui::geocaching::DraftSaveStatus::Saved, "archive detail did not show confirmation"); auto archive_overlay = std::make_unique(); auto archive_map = std::make_unique(); until([&] @@ -611,6 +642,22 @@ int main(int argc, char** argv) return archive_overlay->finished(); }, "archive map did not finish local metadata"); require(archive_map->item_count == 1, "archived local cache remained on Map or hid the downloaded cache"); + test::source->requestWindow(Section::Downloaded, 0, 4); + ui::geocaching::Item offline_copy; + until([&] + { const auto view = snapshot(Section::Downloaded); return view.ready && test::source->item(Section::Downloaded, 0, view.generation, offline_copy); }, + "offline removal row missing"); + require(test::source->removeDownloaded(offline_copy), "offline removal not queued"); + until([&] + { return test::source->downloadedRemovalStatus(offline_copy.id, offline_copy.revision_hash) != ui::geocaching::DraftSaveStatus::Pending; }, + "offline removal stuck"); + require(test::source->downloadedRemovalStatus(offline_copy.id, offline_copy.revision_hash) == ui::geocaching::DraftSaveStatus::Saved, "offline removal failed"); + until([&] + { const auto view = snapshot(Section::Downloaded); return view.ready && !view.count; }, + "deleted offline copy remained in Downloaded"); + until([&] + { archive_overlay->update(*test::source, 31, 121, 15); archive_map->item_count = 0; archive_overlay->append(*archive_map); return archive_overlay->finished() && !archive_map->item_count; }, + "same-viewport map retained deleted offline marker"); test::source->activate(false); until([&] { return !router.service; }, diff --git a/modules/core_geocaching/tests/test_index_reference_proofs.cpp b/modules/core_geocaching/tests/test_index_reference_proofs.cpp index bd5edecc..a262a9f8 100644 --- a/modules/core_geocaching/tests/test_index_reference_proofs.cpp +++ b/modules/core_geocaching/tests/test_index_reference_proofs.cpp @@ -3,6 +3,7 @@ #include "platform/esp/arduino_common/geocaching/indexed_dispatch_store.h" #include "platform/esp/arduino_common/geocaching/sd_index_initialize.h" #include "platform/esp/arduino_common/geocaching/sd_indexed_commit.h" +#include "platform/esp/arduino_common/geocaching/sd_indexed_remove_saved.h" #include "platform/esp/arduino_common/geocaching/sd_indexed_stop_task.h" #include "runtime_environment.h" #include @@ -171,6 +172,44 @@ int main() copy = 1 - copy; } readSend(0, true); + { + // Removal selects the exact current revision and advances the durable + // generation, without deleting the head's rollback protection. + std::array cache{}, hash{}, wrong{}; + cache.fill(0x41); + hash.fill(0x42); + wrong.fill(0x43); + gc::CacheHeadView head; + head.current_hash = {hash.data(), hash.size()}; + head.install_generation = 7; + head.highest_seen_revision = 9; + std::array value{}; + size_t size = 0; + require(gc::encodeCacheHead({cache.data(), cache.size()}, head, value.data(), value.size(), size), "encode removal fixture head"); + gc::MutationView row{2, {cache.data(), cache.size()}, {value.data(), size}, false}; + sd::SdIndexedCommit commit(volume); + require(commit.begin(root, copy, &row, 1, frame.data(), frame.size(), roots[1 - copy]) && + pump(commit, sd::IndexedCommitStep::Working) == sd::IndexedCommitStep::Verified && commit.committed(root), + "commit removal fixture head"); + copy = 1 - copy; + const auto unchanged = test::files; + sd::SdIndexedRemoveSaved stale(volume); + require(stale.begin(root, copy, cache, wrong, frame.data(), frame.size(), roots[1 - copy]) && + pump(stale, sd::IndexedCommitStep::Working) == sd::IndexedCommitStep::Invalid && test::files == unchanged, + "stale detail deleted another revision"); + sd::SdIndexedRemoveSaved remove(volume); + require(remove.begin(root, copy, cache, hash, frame.data(), frame.size(), roots[1 - copy]) && + pump(remove, sd::IndexedCommitStep::Working) == sd::IndexedCommitStep::Verified && remove.committed(root), + "remove offline head"); + copy = 1 - copy; + sd::SdIndexGet read(volume); + require(read.begin(root, 2, {cache.data(), cache.size()}, frame.data(), frame.size()) && + pump(read, sd::IndexGetStep::Working) == sd::IndexGetStep::Ready && + gc::decodeCacheHead({cache.data(), cache.size()}, read.value(), head), + "read removed head"); + require(!head.current_hash.size && head.install_generation == 8 && head.highest_seen_revision == 9, + "removal lost generation or rollback protection"); + } std::printf("Send preparation read bytes: reused=%llu fallback=%llu\n", static_cast(reused), static_cast(small)); std::printf("Reference validator size: %u bytes; eviction and overlay mismatch checks passed\n", static_cast(sizeof(sd::SdIndexReferences))); return 0; diff --git a/modules/core_geocaching/tests/test_local_map_overlay.cpp b/modules/core_geocaching/tests/test_local_map_overlay.cpp index 21a13e66..aa972d75 100644 --- a/modules/core_geocaching/tests/test_local_map_overlay.cpp +++ b/modules/core_geocaching/tests/test_local_map_overlay.cpp @@ -25,6 +25,14 @@ struct LocalSource : Source size_t offset = 0; bool ready = false, busy = false; unsigned requests = 0; + uint64_t revision = 0; + bool revision_available = true; + bool localMapRevision(uint64_t& out) override + { + if (!revision_available) return false; + out = revision; + return true; + } void snapshot(Section part, Snapshot& out) override { assert(part != Section::Discover); @@ -93,6 +101,26 @@ int main() auto result = std::make_unique(); immediate->append(*result); assert(result->item_count == 2); // No extra timer tick for cached drafts. + const auto requests = available.requests; + immediate->update(available, 0, 102, 10); + assert(available.requests == requests); // An unchanged view stays cached. + available.drafts[0].state = 2; + ++available.revision; + available.revision_available = false; + immediate->update(available, 0, 102, 10); + assert(immediate->markerCount() == 2); // Busy source is not deletion. + available.revision_available = true; + immediate->update(available, 0, 102, 10); + assert(immediate->markerCount() == 1); // Archive refreshes the same viewport. + available.drafts[0].state = 0; + available.drafts[0].latitude_e7 = 123000000; + ++available.revision; + immediate->update(available, 0, 102, 10); + assert(immediate->markerCount() == 2); + available.drafts.clear(); + ++available.revision; + immediate->update(available, 0, 102, 10); + assert(immediate->markerCount() == 1); // Local deletion refreshes too. } LocalSource source; source.drafts.push_back(marker(1, true, false)); 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 97455b7c..5b0ab8f2 100644 --- a/modules/ui_presentation/include/ui_presentation/geocaching/geocaching_source.h +++ b/modules/ui_presentation/include/ui_presentation/geocaching/geocaching_source.h @@ -76,6 +76,7 @@ struct DetailView { DetailStatus status = DetailStatus::Pending; std::string_view description, hint, error; + bool can_archive = false; }; class Source { @@ -90,6 +91,9 @@ class Source // UI-thread notification only. A backend may asynchronously prepare this // bounded window; item() returns false until its projection is ready. virtual void requestWindow(Section, std::size_t, std::size_t) {} + // Nonblocking local marker revision; excludes network and loading status. + // False means unavailable this tick, so retain the last projection. + virtual bool localMapRevision(std::uint64_t&) { return false; } // Map needs position metadata only, without publication history auditing. virtual void requestMapWindow(Section section, std::size_t offset, std::size_t count) { @@ -106,6 +110,11 @@ class Source virtual void closeDetail() {} virtual bool loadMore() { return false; } virtual bool download(const Item&, std::uint64_t) { return false; } + // Removes the selected offline copy, never its public publication. + virtual bool removeDownloaded(const Item&) { return false; } + virtual bool archiveCache(const std::array&, const std::array&) { return false; } + virtual DraftSaveStatus archiveStatus(const std::array&) { return DraftSaveStatus::Failed; } + virtual DraftSaveStatus downloadedRemovalStatus(const std::array&, const std::array&) { return DraftSaveStatus::Failed; } // UI-thread call: enqueue/poll without I/O. Pending never calls or retains // the sink/context. Ready calls it synchronously with borrowed text; the // sink consumes it immediately and must not reenter Source. diff --git a/modules/ui_presentation/include/ui_presentation/geocaching/local_map_overlay.h b/modules/ui_presentation/include/ui_presentation/geocaching/local_map_overlay.h index 26005787..953b9e51 100644 --- a/modules/ui_presentation/include/ui_presentation/geocaching/local_map_overlay.h +++ b/modules/ui_presentation/include/ui_presentation/geocaching/local_map_overlay.h @@ -19,15 +19,18 @@ class LocalMapOverlay void update(Source& source, double latitude, double longitude, uint8_t zoom) { const double span = std::ldexp(360.0, -std::min(zoom, 22)); + uint64_t revision = revision_; + const bool revision_ready = source.localMapRevision(revision); // Finish the current bounded scan before reselecting for a new viewport. // GPS movement and zoom updates must not cancel every pending page. - if (!started_ || (finished_ && (zoom != zoom_ || std::abs(latitude - latitude_) > span / 4 || + if (!started_ || (finished_ && ((revision_ready && revision != revision_) || zoom != zoom_ || std::abs(latitude - latitude_) > span / 4 || std::abs(longitudeDelta(longitude, longitude_)) > span / 4))) { started_ = true; latitude_ = latitude; longitude_ = longitude; zoom_ = zoom; + revision_ = revision; section_ = Section::Downloaded; offset_ = count_ = 0; finished_ = truncated_ = false; @@ -125,6 +128,7 @@ class LocalMapOverlay } std::array entries_{}; size_t offset_ = 0, count_ = 0; + uint64_t revision_ = 0; double latitude_ = 0, longitude_ = 0; uint8_t zoom_ = 0; Section section_ = Section::Published; 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 ad0f9bac..842c8406 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 @@ -60,6 +60,9 @@ struct PageState uint8_t detail_status = 255; std::array detail_id{}, detail_hash{}; bool valid = false, details = false; + bool detail_delete_confirmation = false, detail_deleting = false; + lv_obj_t* detail_archive = nullptr; + bool archive_confirmation = false, detail_archiving = false; }; static_assert(sizeof(PageState) <= 640, "Page state must not contain an entire result list"); ::ui::geocaching::Source* source = nullptr; @@ -287,6 +290,7 @@ void refreshDetailText() const auto status = static_cast(view.status); if (p.detail_status == status) return; p.detail_status = status; + if (p.detail_archive && view.can_archive) lv_obj_remove_flag(p.detail_archive, LV_OBJ_FLAG_HIDDEN); auto* description = lv_obj_get_child(lv_obj_get_child(p.list, 1), 1); auto* hint_card = lv_obj_get_child(p.list, 2); auto* hint = lv_obj_get_child(hint_card, 1); @@ -379,6 +383,30 @@ void refreshView() if (page->details) { if (!source) return; + if (page->detail_archiving) + { + const auto result = source->archiveStatus(page->detail_id); + if (result == ::ui::geocaching::DraftSaveStatus::Pending) return; + page->detail_archiving = false; + lv_label_set_text(lv_obj_get_child(page->detail_archive, 0), result == ::ui::geocaching::DraftSaveStatus::Saved ? "Archived; directory confirmed" : "Archive unconfirmed; retry"); + setEnabled(page->detail_archive, result != ::ui::geocaching::DraftSaveStatus::Saved); + return; + } + if (page->detail_deleting) + { + const auto result = source->downloadedRemovalStatus(page->detail_id, page->detail_hash); + if (result == ::ui::geocaching::DraftSaveStatus::Pending) return; + page->detail_deleting = false; + if (result == ::ui::geocaching::DraftSaveStatus::Saved) + { + closeDetails(); + return; + } + auto* action = lv_obj_get_child(page->list, 4); + setEnabled(action, true); + lv_label_set_text(lv_obj_get_child(action, 0), "Delete failed; retry"); + return; + } refreshDetailText(); ::ui::geocaching::Snapshot current; source->snapshot(page->section, current); @@ -504,6 +532,9 @@ void closeDetails() if (!page) return; if (page->details && source) source->closeDetail(); page->details = false; + page->detail_delete_confirmation = page->detail_deleting = false; + page->detail_archive = nullptr; + page->archive_confirmation = page->detail_archiving = false; page->valid = false; lv_obj_remove_flag(lv_obj_get_parent(page->tabs[0]), LV_OBJ_FLAG_HIDDEN); lv_obj_remove_flag(page->status, LV_OBJ_FLAG_HIDDEN); @@ -567,6 +598,8 @@ void showDetails(const ::ui::geocaching::Item& item, size_t index) p.detail_generation = p.snapshot.generation; p.detail_id = item.id; p.detail_hash = item.revision_hash; + p.detail_delete_confirmation = p.detail_deleting = false; + p.archive_confirmation = p.detail_archiving = false; lv_obj_clean(p.list); p.rows.fill(nullptr); p.row_count = 0; @@ -592,6 +625,70 @@ void showDetails(const ::ui::geocaching::Item& item, size_t index) auto* metadata = detailBody(detailCard(p.list, "LOCATION & DETAILS"), item.detail.data()); lv_obj_set_style_text_font(metadata, ::ui::page_profile::resolve_caption_font(), 0); lv_obj_set_style_text_color(metadata, lv_color_hex(styles::kTextMuted), 0); + if (item.downloaded) + { + auto* remove = lv_button_create(p.list); + lv_obj_set_width(remove, LV_PCT(100)); + lv_obj_set_height(remove, LV_SIZE_CONTENT); + auto* label = lv_label_create(remove); + lv_label_set_text(label, "Delete local copy"); + lv_obj_set_width(label, LV_PCT(100)); + lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); + if (p.group) lv_group_add_obj(p.group, remove); + lv_obj_add_event_cb( + remove, [](lv_event_t* event) + { + if (!page || !page->details || !source || page->detail_deleting) return; + auto* button = static_cast(lv_event_get_target(event)); + if (!page->detail_delete_confirmation) + { + page->detail_delete_confirmation = true; + lv_label_set_text(lv_obj_get_child(button, 0), "Confirm local delete"); + return; + } + ::ui::geocaching::Item selected; + selected.id = page->detail_id; + selected.revision_hash = page->detail_hash; + selected.downloaded = true; + if (!source->removeDownloaded(selected)) + { + lv_label_set_text(lv_obj_get_child(button, 0), "Busy; retry local delete"); + return; + } + page->detail_deleting = true; + lv_label_set_text(lv_obj_get_child(button, 0), "Deleting local copy..."); + setEnabled(button, false); + setEnabled(page->next, false); }, + LV_EVENT_CLICKED, nullptr); + } + p.detail_archive = lv_button_create(p.list); + lv_obj_set_width(p.detail_archive, LV_PCT(100)); + lv_obj_set_height(p.detail_archive, LV_SIZE_CONTENT); + auto* archive_label = lv_label_create(p.detail_archive); + lv_label_set_text(archive_label, "Archive public cache"); + lv_obj_set_width(archive_label, LV_PCT(100)); + lv_obj_set_style_text_align(archive_label, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_add_flag(p.detail_archive, LV_OBJ_FLAG_HIDDEN); + if (p.group) lv_group_add_obj(p.group, p.detail_archive); + lv_obj_add_event_cb( + p.detail_archive, [](lv_event_t*) + { + if (!page || !source || page->detail_archiving || page->detail_deleting) return; + if (!page->archive_confirmation) + { + page->archive_confirmation = true; + lv_label_set_text(lv_obj_get_child(page->detail_archive, 0), "Confirm public archive"); + return; + } + if (!source->archiveCache(page->detail_id, page->detail_hash)) + { + lv_label_set_text(lv_obj_get_child(page->detail_archive, 0), "Archive unavailable; retry"); + return; + } + page->detail_archiving = true; + setEnabled(page->detail_archive, false); + lv_label_set_text(lv_obj_get_child(page->detail_archive, 0), "Awaiting directory confirmation..."); }, + LV_EVENT_CLICKED, nullptr); refreshDetailText(); lv_obj_scroll_to_y(p.list, 0, LV_ANIM_OFF); lv_label_set_text(p.range, "Scroll " LV_SYMBOL_DOWN); diff --git a/modules/ui_shared/tests/geocaching/test_page.cpp b/modules/ui_shared/tests/geocaching/test_page.cpp index e486a583..198ec95e 100644 --- a/modules/ui_shared/tests/geocaching/test_page.cpp +++ b/modules/ui_shared/tests/geocaching/test_page.cpp @@ -61,6 +61,14 @@ struct TestSource : ui::geocaching::Source } unsigned pending_reads = 0; bool detail_ready = false; + unsigned archives = 0; + ui::geocaching::DraftSaveStatus archive_result = ui::geocaching::DraftSaveStatus::Pending; + bool archiveCache(const std::array&, const std::array&) override + { + ++archives; + return true; + } + ui::geocaching::DraftSaveStatus archiveStatus(const std::array&) override { return archive_result; } unsigned detail_closes = 0; bool readDetail(const std::array&, const std::array&, void (*sink)(const ui::geocaching::DetailView&, void*), void* context) override @@ -71,6 +79,7 @@ struct TestSource : ui::geocaching::Source 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."; + view.can_archive = true; } sink(view, context); return true; @@ -306,6 +315,19 @@ int main(int argc, char** argv) !std::strstr(lv_label_get_text(lv_obj_get_child(lv_obj_get_child(list, 2), 1)), "large flat stone") || lv_obj_has_flag(lv_obj_get_child(list, 2), LV_OBJ_FLAG_HIDDEN)) return 65; if (!save(std::string(argv[3]) + "-detail.ppm", screen)) return 11; + auto* archive_action = lv_obj_get_child(list, 4); + if (lv_obj_has_flag(archive_action, LV_OBJ_FLAG_HIDDEN)) return 80; + lv_obj_send_event(archive_action, LV_EVENT_CLICKED, nullptr); + if (source.archives || std::strcmp(lv_label_get_text(lv_obj_get_child(archive_action, 0)), "Confirm public archive")) return 81; + lv_obj_send_event(archive_action, LV_EVENT_CLICKED, nullptr); + if (source.archives != 1 || !lv_obj_has_state(archive_action, LV_STATE_DISABLED)) return 82; + lv_tick_inc(600); + lv_timer_handler(); + if (std::strcmp(lv_label_get_text(lv_obj_get_child(archive_action, 0)), "Awaiting directory confirmation...")) return 83; + source.archive_result = ui::geocaching::DraftSaveStatus::Saved; + lv_tick_inc(600); + lv_timer_handler(); + if (std::strcmp(lv_label_get_text(lv_obj_get_child(archive_action, 0)), "Archived; directory confirmed")) return 84; lv_obj_scroll_to_view(lv_obj_get_child(list, 2), 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; 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 index 6db81501..44fb54fe 100644 --- 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 @@ -54,6 +54,14 @@ struct CacheDetail } bool verify(::geocaching::ByteView bytes, ::geocaching::protocol::RecordCrypto& crypto) { + if (state == State::Saved && !response) + { + response = static_cast(::platform::esp::common::memory::allocatePreferred("geocaching.detail.saved", bytes.size, false)); + if (!response) return false; + std::memcpy(response, bytes.data, bytes.size); + response_size = bytes.size; + bytes = {response, response_size}; + } 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; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_remove_saved.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_remove_saved.h new file mode 100644 index 00000000..eb92a3f8 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_remove_saved.h @@ -0,0 +1,78 @@ +#pragma once +#include "geocaching/storage/cache_head.h" +#include "platform/esp/arduino_common/geocaching/sd_indexed_commit.h" + +namespace platform::esp::arduino_common::geocaching +{ +// Removes only the selected offline revision. Retaining and advancing the +// installation generation prevents old download tasks from reinstalling it. +// The owner leases the pinned root/frame and allocates this operation in PSRAM. +class SdIndexedRemoveSaved +{ + public: + explicit SdIndexedRemoveSaved(const ::geocaching::storage::VolumeInstance& volume) : volume_(volume) {} + bool begin(const ::geocaching::storage::IndexRootView& root, unsigned copy, + const std::array& cache, const std::array& hash, + uint8_t* frame, size_t capacity, ::geocaching::storage::IndexRootBytes& candidate) + { + if (result_ != IndexedCommitStep::Idle || copy > 1) return false; + root_ = root; + copy_ = copy; + cache_ = cache; + hash_ = hash; + frame_ = frame; + capacity_ = capacity; + candidate_ = &candidate; + if (!io_.emplace(volume_).begin(root_, 2, {cache_.data(), cache_.size()}, frame, capacity)) return false; + result_ = IndexedCommitStep::Working; + return true; + } + IndexedCommitStep step() + { + using namespace ::geocaching::storage; + if (result_ != IndexedCommitStep::Working) return result_; + if (committing_) return result_ = std::get(io_).step(); + auto& read = std::get(io_); + const auto status = read.step(); + if (status == IndexGetStep::Working) return result_; + if (status != IndexGetStep::Ready) + return result_ = status == IndexGetStep::IoError ? IndexedCommitStep::IoError : status == IndexGetStep::VolumeChanged ? IndexedCommitStep::VolumeChanged + : IndexedCommitStep::Invalid; + CacheHeadView head; + const ::geocaching::ByteView key{cache_.data(), cache_.size()}; + if (!decodeCacheHead(key, read.value(), head) || head.current_hash.size != hash_.size() || + std::memcmp(head.current_hash.data, hash_.data(), hash_.size()) || head.install_generation == UINT64_MAX) + return result_ = IndexedCommitStep::Invalid; + head.current_hash = {}; + head.conflict_state = 0; + ++head.install_generation; + size_t size = 0; + if (!encodeCacheHead(key, head, value_.data(), value_.size(), size)) return result_ = IndexedCommitStep::Invalid; + mutation_ = {2, key, {value_.data(), size}, false}; + if (!io_.emplace(volume_).begin(root_, copy_, &mutation_, 1, frame_, capacity_, *candidate_)) + return result_ = IndexedCommitStep::Invalid; + committing_ = true; + return result_; + } + bool committed(::geocaching::storage::IndexRootView& out) const + { + out = {}; + return result_ == IndexedCommitStep::Verified && committing_ && std::get(io_).committed(out); + } + + private: + ::geocaching::storage::VolumeInstance volume_; + ::geocaching::storage::IndexRootView root_; + std::array cache_{}, hash_{}; + std::array value_{}; + ::geocaching::storage::MutationView mutation_; + uint8_t* frame_ = nullptr; + size_t capacity_ = 0; + unsigned copy_ = 0; + ::geocaching::storage::IndexRootBytes* candidate_ = nullptr; + std::variant io_; + bool committing_ = false; + IndexedCommitStep result_ = IndexedCommitStep::Idle; +}; +static_assert(sizeof(SdIndexedRemoveSaved) <= 1536, "Offline removal owns metadata only, in PSRAM"); +} // 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 e1cc555d..b29d554c 100644 --- a/platform/esp/arduino_common/src/geocaching/browse_runtime.cpp +++ b/platform/esp/arduino_common/src/geocaching/browse_runtime.cpp @@ -18,6 +18,7 @@ #include "platform/esp/arduino_common/geocaching/sd_checkpoint_rotation.h" #include "platform/esp/arduino_common/geocaching/sd_download_port.h" #include "platform/esp/arduino_common/geocaching/sd_index_repair.h" +#include "platform/esp/arduino_common/geocaching/sd_indexed_remove_saved.h" #include "platform/esp/arduino_common/geocaching/sd_indexed_stop_task.h" #include "platform/esp/arduino_common/geocaching/sd_publish_port.h" #include "platform/esp/arduino_common/geocaching/stored_reply_receipt.h" @@ -83,6 +84,7 @@ struct PendingReply std::atomic pending_reply{nullptr}; struct Session { + uint64_t local_map_revision = 0; struct Publication { enum class DraftStage : uint8_t @@ -91,6 +93,8 @@ struct Session Bind, Binding, Encode, + Archive, + ArchiveSaving, Sign }; DraftStage draft_stage = DraftStage::None; @@ -136,6 +140,9 @@ struct Session uint64_t expected = 0; bool started = false, done = false, saved = false, editor_fields = true; bool erase = false; + bool erase_download = false; + std::array cache{}, hash{}; + ::platform::memory::PsramPtr removal; ~DraftSave() { heap_caps_free(bytes); } }; ::platform::memory::PsramPtr draft_save; @@ -775,7 +782,11 @@ bool processResponse(Session& s) auto& attempt = *s.publication->attempt; const auto before = attempt.phase(); if (attempt.accept(s.response_source, {s.response, s.response_size}) && before != gc::PublishAttemptPhase::Confirmed && - attempt.phase() == gc::PublishAttemptPhase::Confirmed) reportPublication("confirmed", attempt); + attempt.phase() == gc::PublishAttemptPhase::Confirmed) + { + ++s.local_map_revision; + reportPublication("confirmed", attempt); + } ++epoch; } else if (s.response_operation == 3 && s.download) @@ -1053,6 +1064,7 @@ PublicationRestore restorePublication(Session& s, const gc::GeocacheId* cache = return PublicationRestore::Pending; } std::memcpy(job->remote.bytes.data(), selected.key.data() + 16, 16); + job->cache = selected.cache; job->port.reset(::platform::memory::createPsram(*s.store, s.crypto, s.local, selected.cache, selected.hash, selected.task, selected.created)); if (job->port) job->attempt.reset(::platform::memory::createPsram(*job->port, s.crypto)); if (!job->attempt) @@ -1133,6 +1145,93 @@ void advanceDraftPublication(Session& s) ++epoch; return; } + if (job.draft_stage == Stage::ArchiveSaving) + { + const auto result = s.store->stepCommit(); + if (result == JournalWriteResult::Busy || result == JournalWriteResult::InProgress) return; + if (result != JournalWriteResult::Verified) + { + stop("Local archive could not be saved"); + return; + } + heap_caps_free(job.draft_bytes); + job.draft_bytes = nullptr; + ++s.local_map_revision; + job.draft_stage = Stage::Archive; + job.draft_generation = UINT64_MAX; + return; + } + if (job.draft_stage == Stage::Archive) + { + if (job.draft_generation != UINT64_MAX) + { + gc::RecordView record; + gc::ByteView value; + if (!gc::protocol::decodeGeocacheRecord({job.unsigned_bytes, job.unsigned_size}, record)) + { + stop("Invalid archive record"); + return; + } + const auto read = s.store->readDraft(record.creation_nonce, value); + if (read == DraftReadResult::Pending || read == DraftReadResult::Busy) return; + if (read == DraftReadResult::Ready) + { + gc::storage::DraftView draft; + if (!gc::storage::decodeDraft(record.creation_nonce, value, draft) || draft.generation == UINT64_MAX || + draft.author.size != 64 || std::memcmp(draft.author.data, job.author.data(), 64)) + { + stop("Local author mismatch"); + return; + } + const auto expected = draft.generation; + draft.state = 2; + ++draft.generation; + job.draft_size = value.size + 32; + job.draft_bytes = static_cast(mem::allocatePreferred("geocaching.archive-draft", job.draft_size, false)); + if (!job.draft_bytes || !gc::storage::encodeDraft(record.creation_nonce, draft, job.draft_bytes, job.draft_size, job.draft_size)) + { + stop("Cannot retain local archive"); + return; + } + s.store->releaseDraftRead(); + const auto saved = s.store->saveDraft(record.creation_nonce, {job.draft_bytes, job.draft_size}, expected); + if (saved == JournalWriteResult::Busy) + { + heap_caps_free(job.draft_bytes); + job.draft_bytes = nullptr; + return; + } + if (saved != JournalWriteResult::InProgress && saved != JournalWriteResult::Verified) + { + stop("Local archive save failed"); + return; + } + job.draft_stage = Stage::ArchiveSaving; + if (saved == JournalWriteResult::Verified) + { + heap_caps_free(job.draft_bytes); + job.draft_bytes = nullptr; + job.draft_generation = UINT64_MAX; + job.draft_stage = Stage::Archive; + ++s.local_map_revision; + } + return; + } + s.store->releaseDraftRead(); + if (read != DraftReadResult::NotFound) + { + stop("Cannot read local archive ownership"); + return; + } + job.draft_generation = UINT64_MAX; + } + job.author_port.reset(::platform::memory::createPsram(*router, *s.store, s.crypto, job.issued)); + if (job.author_port) job.issue.reset(::platform::memory::createPsram(*job.author_port)); + if (!job.issue || !job.issue->begin({job.unsigned_bytes, job.unsigned_size}, job.bytes, job.size)) + stop("Cannot prepare archive signing"); + else job.draft_stage = Stage::Sign; + return; + } gc::ByteView value; gc::storage::DraftView draft; const gc::ByteView key{job.draft_id.data(), job.draft_id.size()}; @@ -1353,6 +1452,13 @@ bool publicationDraftReady(const std::array& id, uint64_t generatio class Facade final : public ::ui::geocaching::Source { public: + bool localMapRevision(uint64_t& out) override + { + Guard guard; + if (!guard.locked || !session) return false; + out = session->local_map_revision; + return true; + } void activate(bool open) override { if (open && !wanted.load()) @@ -1636,6 +1742,10 @@ class Facade final : public ::ui::geocaching::Source view.status = ::ui::geocaching::DetailStatus::Ready; view.description = job->description.data(); view.hint = job->hint.data(); + std::array author{}; + view.can_archive = job->verified_record.record.state != gc::CacheState::Archived && + router->getGeocachingAuthorKey(author.data()) && + !std::memcmp(author.data(), job->verified_record.record.author_public_key.data, author.size()); } else if (job->state == CacheDetail::State::Failed) { @@ -1650,6 +1760,53 @@ class Facade final : public ::ui::geocaching::Source cancel_detail.store(true); next_step.store(0); } + bool archiveCache(const std::array& id, const std::array& hash) override + { + Guard guard; + if (!guard.locked || !session || !session->detail || session->detail->state != CacheDetail::State::Ready || + !session->detail->matches(id, hash) || !session->port || session->needsRecovery() || + publicationActive() || downloadActive() || draftSaveActive()) return false; + const auto& verified = session->detail->verified_record; + auto job = ::platform::memory::PsramPtr(::platform::memory::createPsram()); + if (!job || verified.record.revision == UINT32_MAX || verified.record.state == gc::CacheState::Archived || + !router->getGeocachingAuthorKey(job->author.data()) || + std::memcmp(job->author.data(), verified.record.author_public_key.data, job->author.size()) || + !session->port->pageSource(job->remote)) return false; + auto record = verified.record; + ++record.revision; + record.previous_hash = {hash.data(), hash.size()}; + record.state = gc::CacheState::Archived; + record.updated_at = std::max(record.updated_at, static_cast(std::time(nullptr))); + job->issued = now(nullptr); + job->issued.has_utc = true; + job->issued.utc_seconds = record.updated_at; + job->cache = verified.id; + job->expected_revision = record.revision; + const size_t capacity = record.name.size() + record.description.size() + record.hint.size() + 240; + job->unsigned_bytes = static_cast(mem::allocatePreferred("geocaching.archive", capacity, false)); + job->size = capacity + 70; + job->bytes = static_cast(mem::allocatePreferred("geocaching.sign", 2 * job->size + 26, false)); + if (!job->unsigned_bytes || !job->bytes || !gc::protocol::encodeGeocacheRecord(record, job->unsigned_bytes, capacity, job->unsigned_size)) return false; + job->draft_stage = Session::Publication::DraftStage::Archive; + session->storage_requested = true; + session->publication = std::move(job); + next_step.store(0); + ++epoch; + return true; + } + ::ui::geocaching::DraftSaveStatus archiveStatus(const std::array& id) override + { + using Status = ::ui::geocaching::DraftSaveStatus; + Guard guard; + if (!guard.locked) return Status::Pending; + if (!session || !session->publication || session->publication->cache.bytes != id) return Status::Failed; + const auto& job = *session->publication; + if (job.error) return Status::Failed; + if (job.draft_stage != Session::Publication::DraftStage::None || job.bytes) return Status::Pending; + if (!job.attempt) return Status::Failed; + if (job.attempt->phase() == gc::PublishAttemptPhase::Confirmed) return Status::Saved; + return publicationActive() ? Status::Pending : Status::Failed; + } bool publicationAuthor(const std::array& id, uint64_t generation, std::array& author, uint32_t* from, uint32_t* to) override { Guard guard; @@ -1779,7 +1936,7 @@ class Facade final : public ::ui::geocaching::Source using Status = ::ui::geocaching::DraftSaveStatus; Guard guard; if (!guard.locked) return Status::Pending; - if (!session || !session->draft_save || session->draft_save->id != id || session->draft_save->expected != generation) return Status::Failed; + if (!session || !session->draft_save || session->draft_save->erase_download || session->draft_save->id != id || session->draft_save->expected != generation) return Status::Failed; const auto& job = *session->draft_save; return !job.done ? Status::Pending : job.saved ? Status::Saved : Status::Failed; @@ -1799,6 +1956,33 @@ class Facade final : public ::ui::geocaching::Source ++epoch; return true; } + bool removeDownloaded(const ::ui::geocaching::Item& item) override + { + Guard guard; + if (!guard.locked || item.is_draft || !item.downloaded || !session || session->phase != Phase::Ready || + session->needsRecovery() || downloadActive() || publicationActive() || draftSaveActive() || + (session->store && session->store->commitPending())) return false; + auto job = ::platform::memory::PsramPtr(::platform::memory::createPsram()); + if (!job) return false; + job->erase_download = true; + job->cache = item.id; + job->hash = item.revision_hash; + session->draft_save = std::move(job); + next_step.store(0); + ++epoch; + return true; + } + ::ui::geocaching::DraftSaveStatus downloadedRemovalStatus(const std::array& id, const std::array& hash) override + { + using Status = ::ui::geocaching::DraftSaveStatus; + Guard guard; + if (!guard.locked) return Status::Pending; + if (!session || !session->draft_save || !session->draft_save->erase_download || + session->draft_save->cache != id || session->draft_save->hash != hash) return Status::Failed; + const auto& job = *session->draft_save; + return !job.done ? Status::Pending : job.saved ? Status::Saved + : Status::Failed; + } bool download(const ::ui::geocaching::Item& item, uint64_t generation) override { Guard guard; @@ -1846,6 +2030,47 @@ class Facade final : public ::ui::geocaching::Source } } facade; +bool advanceOfflineRemoval(Session& s) +{ + if (!s.draft_save || !s.draft_save->erase_download || s.draft_save->done) return false; + auto& job = *s.draft_save; + if (!s.workspace_owner.acquire(&job)) return false; + auto finish = [&](bool saved) + { + job.done = true; + job.saved = saved; + job.removal.reset(); + s.workspace_owner.release(&job); + if (saved) + { + if (s.saved) s.saved->reset(); + ++s.local_map_revision; + Serial.printf("[Geocaching][LocalDelete] confirmed cache_prefix=%02x%02x%02x%02x\n", job.cache[0], job.cache[1], job.cache[2], job.cache[3]); + } + ++epoch; + return true; + }; + if (!job.removal) + { + job.removal.reset(::platform::memory::createPsram(s.volume)); + if (!job.removal || !job.removal->begin(s.root, s.root_copy, job.cache, job.hash, s.frame, kFrameCapacity, s.roots[1 - s.root_copy])) + return finish(false); + } + const auto result = job.removal->step(); + if (result == IndexedCommitStep::Working) return true; + if (result == IndexedCommitStep::Verified) + { + gc::storage::IndexRootView next; + if (!job.removal->committed(next)) return finish(false); + s.root = next; + s.root_copy = 1 - s.root_copy; + return finish(true); + } + if (result == IndexedCommitStep::IoError || result == IndexedCommitStep::VolumeChanged || result == IndexedCommitStep::RecoveryRequired) + s.checkpoint_recovery_required = true; + return finish(false); +} + DispatchResult dispatchForeground(Session& s) { std::array key{}; @@ -1883,6 +2108,11 @@ bool closeSession() if (!session->needsRecovery() && session->workspace_owner.holder()) { WorkspaceSlice workspace_slice{*session}; + if (session->draft_save && session->draft_save->erase_download && session->workspace_owner.heldBy(session->draft_save.get())) + { + advanceOfflineRemoval(*session); + return false; + } if (session->dispatch_store && session->workspace_owner.heldBy(session->dispatch_store.get())) { dispatchForeground(*session); @@ -2217,24 +2447,32 @@ void step() if (s.phase == Phase::Ready && draftSaveActive()) { auto& job = *s.draft_save; - const auto result = job.started ? s.store->stepCommit() : job.erase ? s.store->deleteDraft({job.id.data(), job.id.size()}, job.expected) - : job.editor_fields ? s.store->editDraft({job.id.data(), job.id.size()}, job.bytes, job.size, job.capacity, job.expected) - : s.store->saveDraft({job.id.data(), job.id.size()}, {job.bytes, job.size}, job.expected); - if (result != JournalWriteResult::Busy) + if (job.erase_download) { - job.started = true; - if (result != JournalWriteResult::InProgress || s.store->inputConsumed()) + if (advanceOfflineRemoval(s)) return; + } + else + { + const auto result = job.started ? s.store->stepCommit() : job.erase ? s.store->deleteDraft({job.id.data(), job.id.size()}, job.expected) + : job.editor_fields ? s.store->editDraft({job.id.data(), job.id.size()}, job.bytes, job.size, job.capacity, job.expected) + : s.store->saveDraft({job.id.data(), job.id.size()}, {job.bytes, job.size}, job.expected); + if (result != JournalWriteResult::Busy) { - heap_caps_free(job.bytes); - job.bytes = nullptr; + job.started = true; + if (result != JournalWriteResult::InProgress || s.store->inputConsumed()) + { + heap_caps_free(job.bytes); + job.bytes = nullptr; + } + if (result != JournalWriteResult::InProgress) + { + job.done = true; + job.saved = result == JournalWriteResult::Verified; + if (job.saved) ++s.local_map_revision; + ++epoch; + } + return; } - if (result != JournalWriteResult::InProgress) - { - job.done = true; - job.saved = result == JournalWriteResult::Verified; - ++epoch; - } - return; } // Advance the operation that currently owns storage before retrying. } @@ -2493,7 +2731,11 @@ void step() if (phase != gc::PublishAttemptPhase::Waiting) { job.attempt->advance(); - if (job.attempt->phase() == gc::PublishAttemptPhase::Confirmed) reportPublication("confirmed", *job.attempt); + if (job.attempt->phase() == gc::PublishAttemptPhase::Confirmed) + { + ++s.local_map_revision; + reportPublication("confirmed", *job.attempt); + } ++epoch; return; } @@ -2524,7 +2766,11 @@ void step() { // Queuing, waiting and cancelling requests do not change saved // rows. Installation may have committed even if cleanup failed. - if (s.saved && before == gc::DownloadPhase::Installing) s.saved->reset(); + if (before == gc::DownloadPhase::Installing) + { + ++s.local_map_revision; + if (s.saved) s.saved->reset(); + } ++epoch; } return; diff --git a/tools/geocaching/deploy/README.md b/tools/geocaching/deploy/README.md index 8a17d603..8dca0235 100644 --- a/tools/geocaching/deploy/README.md +++ b/tools/geocaching/deploy/README.md @@ -142,3 +142,20 @@ Caddy 负责证书和 WebSocket 转发。桥接进程重启也会断开现有连 回退代码前检查数据库格式兼容性。 协议配置参考: + +### Updating public archive handling + +After pulling `codex/geocaching`, rebuild the NAS application image and recreate +the services from the repository root: + +```sh +git pull --ff-only origin codex/geocaching +docker compose -f tools/geocaching/deploy/compose.yaml up -d --build +docker compose -f tools/geocaching/deploy/compose.yaml logs --tail=50 directory +``` + +Keep the existing volumes and configuration. This update does not require a new +directory identity, discovery address, WSS address or GitHub Pages deployment. +Updating firmware alone does not update the NAS directory. The updated directory +hides archived caches and rejects old active-version downloads with +`410 cache_archived`; it retains signed archival records for peer synchronization. diff --git a/tools/geocaching/directory_store.py b/tools/geocaching/directory_store.py index 99e6b813..05acf78f 100644 --- a/tools/geocaching/directory_store.py +++ b/tools/geocaching/directory_store.py @@ -204,6 +204,13 @@ class DirectoryStore: prior = self.db.execute("SELECT fingerprint,response,created FROM requests WHERE source=? AND version=? AND id=?", (source, version, request_id)).fetchone() if prior and prior["created"] + DEDUP_TTL > now: if prior["fingerprint"] == fingerprint: + if operation == 3 and self._withdrawn(source, body): + return packed([1, 1, operation, request_id, 410, ["cache_archived", None, None]]) + if operation == 2: + cached = unpacked(prior["response"]) + if cached[4] == 200: + cached[5][1] = self._visible_items(cached[5][1]) + return packed(cached) return prior["response"] return packed([1, 1, operation, request_id, 409, ["request_id_reused", None, None]]) if self.db.execute("SELECT COUNT(*) FROM requests").fetchone()[0] >= self.max_results: @@ -252,7 +259,7 @@ class DirectoryStore: south, west, north, east = bbox self.db.execute("""INSERT INTO snapshot_items(snapshot,hash) SELECT ?,o.hash FROM heads h JOIN objects o ON o.hash=h.hash - WHERE h.conflict=0 AND o.latitude BETWEEN ? AND ? + WHERE h.conflict=0 AND o.state!=2 AND o.latitude BETWEEN ? AND ? AND (o.longitude BETWEEN ? AND ? OR (?=1800000000 AND o.longitude=-1800000000)) AND (? & (1 << o.state))!=0 AND (? IS NULL OR o.author=?) ORDER BY o.cache LIMIT ?""", (snapshot, south, north, west, east, east, mask, author, author, self.max_snapshot_items + 1)) @@ -266,6 +273,8 @@ class DirectoryStore: head = self._head(cache_id) if not head: raise ProtocolError(404, "not_found") + if self._withdrawn(source, body): + raise ProtocolError(410, "cache_archived") if wanted is None and head["conflict"]: raise ProtocolError(409, "version_conflict") if wanted is None and known == head["hash"]: @@ -289,6 +298,25 @@ class DirectoryStore: return 200, self._page(source, 4, binding, token, limit, budget, request_id, now) raise ProtocolError(501, "unsupported_operation") + def _withdrawn(self, source, body): + if type(body) is not list or len(body) != 3 or not binary(body[0], 32): + return False + head = self._head(body[0]) + if not head or head["state"] != 2: + return False + # Explicit tombstone reads and known directory peers retain replication. + if body[1] == head["hash"]: + return False + return self.db.execute("SELECT 1 FROM peers WHERE destination=?", (source,)).fetchone() is None + + def _visible_items(self, items): + visible = [] + for item in items: + head = self._head(item[0]) + if head is not None and head["state"] != 2: + visible.append(item) + return visible + @staticmethod def _valid_cursor(token): return token is None or (type(token) is bytes and 1 <= len(token) <= 64) @@ -389,6 +417,8 @@ class DirectoryStore: raise ProtocolError(410, expired) if cursor["body"] is not None: result = msgpack.unpackb(cursor["body"], raw=False) + if operation == 2: + result[1] = self._visible_items(result[1]) next_cursor = result[2] if operation == 2 else result[3] expires = cursor["expires"] if next_cursor is not None: @@ -410,7 +440,10 @@ class DirectoryStore: if not snapshot or snapshot["expires"] <= now: raise ProtocolError(410, expired) rows = self.db.execute("""SELECT i.position,o.* FROM snapshot_items i JOIN objects o ON o.hash=i.hash - WHERE i.snapshot=? AND i.position>? ORDER BY i.position LIMIT ?""", (snapshot_id, position, limit + 1)).fetchall() + JOIN heads h ON h.cache=o.cache + JOIN objects current ON current.hash=h.hash + WHERE i.snapshot=? AND i.position>? AND (?!=2 OR current.state!=2) + ORDER BY i.position LIMIT ?""", (snapshot_id, position, operation, limit + 1)).fetchall() items = [msgpack.unpackb(row["summary"], raw=False) if operation == 2 else [row["cache"], row["revision"], row["hash"], row["state"]] for row in rows] take = min(limit, len(items)) while True: diff --git a/tools/geocaching/test_directory_store.py b/tools/geocaching/test_directory_store.py index ad2d9719..a667256f 100644 --- a/tools/geocaching/test_directory_store.py +++ b/tools/geocaching/test_directory_store.py @@ -83,6 +83,39 @@ class DirectoryTests(unittest.TestCase): self.assertEqual(self.store.sequence(), 0) self.assertEqual(self.store.db.execute("SELECT COUNT(*) FROM objects").fetchone()[0], 0) + def test_archive_withdraws_search_and_old_downloads_after_restart(self): + records = [self.record() for _ in range(4)] + for signed in records: + self.assertEqual(self.publish(signed)[4], 200) + search_id = os.urandom(16) + first = self.query(limit=1, request_id=search_id)[5] + victim_id = first[1][0][0] + victim = next(signed for signed in records if verify_cache(signed).cache_id == victim_id) + item = verify_cache(victim) + get_id = os.urandom(16) + self.assertEqual(self.request(3, [item.cache_id, item.revision_hash, None], request_id=get_id)[4], 200) + archive = self.record(victim, **{"5": 2}) + tombstone = verify_cache(archive) + self.assertEqual(self.publish(archive)[4], 200) + self.store.close() + self.store = self.open_store() + self.assertNotIn(item.cache_id, [row[0] for row in self.query()[5][1]]) + self.assertEqual(self.query(limit=1, request_id=search_id)[5][1], []) + for body in ([item.cache_id, None, None], [item.cache_id, item.revision_hash, None], + [item.cache_id, None, item.revision_hash]): + self.assertEqual(self.request(3, body)[4:6], [410, ["cache_archived", None, None]]) + self.assertEqual(self.request(3, [item.cache_id, item.revision_hash, None], request_id=get_id)[4], 410) + # A signed tombstone remains readable so independent directories can + # learn the withdrawal; it never returns the former active payload. + self.assertEqual(self.request(3, [item.cache_id, tombstone.revision_hash, None])[5][0], archive) + cursor = first[2] + remaining = [] + while cursor: + page = self.query(cursor, limit=1)[5] + remaining.extend(page[1]) + cursor = page[2] + self.assertNotIn(item.cache_id, [row[0] for row in remaining]) + def test_query_snapshot_survives_update_restart_and_budget(self): records = [self.record(**{"8": "X" * 96}) for _ in range(8)] for signed in records: