mirror of
https://github.com/vicliu624/trail-mate.git
synced 2026-10-23 13:33:03 +00:00
Close public cache archival and offline removal flows
This commit is contained in:
1 parent
0b90f47be3
commit
69167f211f
15 files changed
+722
-36
No files matched your search
Binary file not shown.
@@ -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.
|
||||
@@ -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<uint8_t> 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<ui::geocaching::LocalMapOverlay>();
|
||||
auto archive_map = std::make_unique<ui::map::MapOverlaySnapshot>();
|
||||
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; },
|
||||
|
||||
@@ -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 <cstdio>
|
||||
@@ -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<uint8_t, 32> 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<uint8_t, 64> 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<unsigned long long>(reused), static_cast<unsigned long long>(small));
|
||||
std::printf("Reference validator size: %u bytes; eviction and overlay mismatch checks passed\n", static_cast<unsigned>(sizeof(sd::SdIndexReferences)));
|
||||
return 0;
|
||||
|
||||
@@ -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<ui::map::MapOverlaySnapshot>();
|
||||
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));
|
||||
|
||||
@@ -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<uint8_t, 32>&, const std::array<uint8_t, 32>&) { return false; }
|
||||
virtual DraftSaveStatus archiveStatus(const std::array<uint8_t, 32>&) { return DraftSaveStatus::Failed; }
|
||||
virtual DraftSaveStatus downloadedRemovalStatus(const std::array<uint8_t, 32>&, const std::array<uint8_t, 32>&) { 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.
|
||||
|
||||
@@ -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<unsigned>(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<Entry, map::MapOverlaySnapshot::kMaxItems> entries_{};
|
||||
size_t offset_ = 0, count_ = 0;
|
||||
uint64_t revision_ = 0;
|
||||
double latitude_ = 0, longitude_ = 0;
|
||||
uint8_t zoom_ = 0;
|
||||
Section section_ = Section::Published;
|
||||
|
||||
@@ -60,6 +60,9 @@ struct PageState
|
||||
uint8_t detail_status = 255;
|
||||
std::array<uint8_t, 32> 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<uint8_t>(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_obj_t*>(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);
|
||||
|
||||
@@ -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<uint8_t, 32>&, const std::array<uint8_t, 32>&) override
|
||||
{
|
||||
++archives;
|
||||
return true;
|
||||
}
|
||||
ui::geocaching::DraftSaveStatus archiveStatus(const std::array<uint8_t, 32>&) override { return archive_result; }
|
||||
unsigned detail_closes = 0;
|
||||
bool readDetail(const std::array<uint8_t, 32>&, const std::array<uint8_t, 32>&,
|
||||
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;
|
||||
|
||||
+8
@@ -54,6 +54,14 @@ struct CacheDetail
|
||||
}
|
||||
bool verify(::geocaching::ByteView bytes, ::geocaching::protocol::RecordCrypto& crypto)
|
||||
{
|
||||
if (state == State::Saved && !response)
|
||||
{
|
||||
response = static_cast<uint8_t*>(::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<uint8_t*>(::platform::esp::common::memory::allocatePreferred("geocaching.detail.verify", capacity, false));
|
||||
if (!scratch) return false;
|
||||
|
||||
+78
@@ -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<uint8_t, 32>& cache, const std::array<uint8_t, 32>& 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<SdIndexGet>(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<SdIndexedCommit>(io_).step();
|
||||
auto& read = std::get<SdIndexGet>(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<SdIndexedCommit>(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<SdIndexedCommit>(io_).committed(out);
|
||||
}
|
||||
|
||||
private:
|
||||
::geocaching::storage::VolumeInstance volume_;
|
||||
::geocaching::storage::IndexRootView root_;
|
||||
std::array<uint8_t, 32> cache_{}, hash_{};
|
||||
std::array<uint8_t, 64> value_{};
|
||||
::geocaching::storage::MutationView mutation_;
|
||||
uint8_t* frame_ = nullptr;
|
||||
size_t capacity_ = 0;
|
||||
unsigned copy_ = 0;
|
||||
::geocaching::storage::IndexRootBytes* candidate_ = nullptr;
|
||||
std::variant<std::monostate, SdIndexGet, SdIndexedCommit> 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
|
||||
@@ -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<PendingReply*> 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<uint8_t, 32> cache{}, hash{};
|
||||
::platform::memory::PsramPtr<SdIndexedRemoveSaved> removal;
|
||||
~DraftSave() { heap_caps_free(bytes); }
|
||||
};
|
||||
::platform::memory::PsramPtr<DraftSave> 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<SdPublishPort>(*s.store, s.crypto, s.local, selected.cache, selected.hash, selected.task, selected.created));
|
||||
if (job->port) job->attempt.reset(::platform::memory::createPsram<gc::PublishAttempt>(*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<uint8_t*>(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<DeviceAuthorIssuePort>(*router, *s.store, s.crypto, job.issued));
|
||||
if (job.author_port) job.issue.reset(::platform::memory::createPsram<gc::AuthorIssue>(*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<uint8_t, 16>& 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<uint8_t, 64> 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<uint8_t, 32>& id, const std::array<uint8_t, 32>& 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<Session::Publication>(::platform::memory::createPsram<Session::Publication>());
|
||||
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<uint64_t>(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<uint8_t*>(mem::allocatePreferred("geocaching.archive", capacity, false));
|
||||
job->size = capacity + 70;
|
||||
job->bytes = static_cast<uint8_t*>(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<uint8_t, 32>& 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<uint8_t, 16>& id, uint64_t generation, std::array<uint8_t, 64>& 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<Session::DraftSave>(::platform::memory::createPsram<Session::DraftSave>());
|
||||
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<uint8_t, 32>& id, const std::array<uint8_t, 32>& 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<SdIndexedRemoveSaved>(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<uint8_t, 48> 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;
|
||||
|
||||
@@ -142,3 +142,20 @@ Caddy 负责证书和 WebSocket 转发。桥接进程重启也会断开现有连
|
||||
回退代码前检查数据库格式兼容性。
|
||||
|
||||
协议配置参考:<https://reticulum.network/manual/interfaces.html>
|
||||
|
||||
### 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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user