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