Public Access
The Notes App lists the files of /notes by their first line, newest first: n starts a note, Enter opens it, r renames its file, d deletes it after asking, s sorts by name. A new note's file is named after its first line. The editor wraps at spaces, 38 columns by 8 rows; Fn+arrows move through the wrapped text, Ctrl+A and Ctrl+E go to the ends of the line. There is no save key: the note is written five seconds after the last key, on Back, on leaving the App, when the screen turns off and before the device powers off. A save writes a temporary file and puts it in the note's place; a save cut short is put back, or offered, the next time. A note is up to 16 KB, held in one buffer reserved when it's opened: the file is read straight into it and typing never makes it grow. A failed allocation aborts on this device, and with IRC connected the largest free block is about 31 KB: a first version that copied the note once on loading restarted the device when a full note was opened with IRC connected. Editing files of any size is #47. The Storage App's text viewer gets `e`, which edits a text file up to 16 KB with the same editor unless the file is read-only. 439 host tests. Checked on the device: docs/milestones/F1.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
471 lines
18 KiB
C++
471 lines
18 KiB
C++
#include "file_viewer.h"
|
|
|
|
#include <Arduino.h>
|
|
#include <SD.h>
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <cstdio>
|
|
#include <ctime>
|
|
|
|
#include "cleanup_plan.h"
|
|
#include "file_list.h"
|
|
#include "file_names.h"
|
|
#include "file_views.h"
|
|
#include "meshtastic_presets.h"
|
|
#include "packet_view.h"
|
|
#include "platform/ota_device.h"
|
|
#include "ui/fonts.h"
|
|
#include "ui/widgets.h"
|
|
#include "update_parser.h"
|
|
#include "version.h"
|
|
|
|
namespace roro {
|
|
|
|
namespace {
|
|
constexpr uint32_t kSliceMs = 150; // like the Storage App's operations: the Logs get their turn
|
|
constexpr size_t kPiece = 1024;
|
|
constexpr size_t kMaxPackets = 1000; // their places in the file, 4 KB
|
|
constexpr size_t kMaxPacketBytes = 271; // LoRaTap and the longest LoRa packet
|
|
|
|
// Checks an Update File as an install would, writing nothing.
|
|
struct NoSink : UpdateSink {
|
|
bool begin(size_t) override { return true; }
|
|
bool write(const uint8_t*, size_t) override { return true; }
|
|
bool finish() override { return true; }
|
|
void abort() override {}
|
|
};
|
|
|
|
std::string clockTime(uint32_t utcSeconds) {
|
|
time_t t = static_cast<time_t>(utcSeconds);
|
|
struct tm local;
|
|
localtime_r(&t, &local);
|
|
char s[12];
|
|
std::snprintf(s, sizeof s, "%02d:%02d:%02d", local.tm_hour, local.tm_min, local.tm_sec);
|
|
return s;
|
|
}
|
|
} // namespace
|
|
|
|
struct FileViewer::Scan {
|
|
std::atomic<bool> done{false}, stop{false};
|
|
std::atomic<int> percent{0};
|
|
Mode what = Mode::Gpx;
|
|
std::string path;
|
|
// Touched by the storage task until `done`, by the viewer after.
|
|
fs::File file;
|
|
bool opened = false;
|
|
uint32_t at = 0, size = 0;
|
|
std::string error;
|
|
files::GpxSummary gpx;
|
|
files::PcapHeader pcap;
|
|
std::vector<uint32_t> packets; // where each record starts
|
|
bool morePackets = false;
|
|
std::unique_ptr<EcdsaVerifier> verifier;
|
|
NoSink sink;
|
|
std::unique_ptr<UpdateParser> parser;
|
|
std::string version;
|
|
bool genuine = false, older = false;
|
|
|
|
bool slice(); // true when there's nothing more to read
|
|
static void run(StorageService* storage, std::shared_ptr<Scan> self) {
|
|
bool over = self->stop || self->slice();
|
|
if (over) {
|
|
if (self->file) self->file.close();
|
|
self->parser.reset();
|
|
self->verifier.reset();
|
|
self->done = true;
|
|
} else {
|
|
storage->runJob([storage, self]() { run(storage, self); });
|
|
}
|
|
}
|
|
};
|
|
|
|
bool FileViewer::Scan::slice() {
|
|
uint32_t t0 = millis();
|
|
if (!opened) {
|
|
opened = true;
|
|
file = SD.open(path.c_str(), FILE_READ);
|
|
if (!file) {
|
|
error = "The card refused to open it";
|
|
return true;
|
|
}
|
|
size = static_cast<uint32_t>(file.size());
|
|
if (what == Mode::Ota) {
|
|
verifier.reset(new EcdsaVerifier());
|
|
parser.reset(new UpdateParser(*verifier, sink, EspOtaSink().capacity(), versionString()));
|
|
}
|
|
}
|
|
std::unique_ptr<uint8_t[]> piece(new uint8_t[kPiece]);
|
|
while (millis() - t0 < kSliceMs) {
|
|
if (stop) return true;
|
|
if (what == Mode::Pcap) {
|
|
if (at == 0) {
|
|
int n = file.read(piece.get(), files::kPcapHeaderSize);
|
|
pcap = files::parsePcapHeader(piece.get(), n < 0 ? 0 : n);
|
|
if (!pcap.ok) {
|
|
error = "Not a pcap file this viewer reads";
|
|
return true;
|
|
}
|
|
at = files::kPcapHeaderSize;
|
|
}
|
|
files::PcapRecord r;
|
|
if (!file.seek(at)) return true;
|
|
int n = file.read(piece.get(), files::kPcapRecordSize);
|
|
if (n < 0 || !files::parsePcapRecord(piece.get(), n, r) || at + files::kPcapRecordSize + r.length > size) return true;
|
|
if (packets.size() >= kMaxPackets) {
|
|
morePackets = true;
|
|
return true;
|
|
}
|
|
packets.push_back(at);
|
|
at += files::kPcapRecordSize + r.length;
|
|
continue;
|
|
}
|
|
int n = file.read(piece.get(), kPiece);
|
|
if (n <= 0) break;
|
|
at += n;
|
|
percent = size ? static_cast<int>(static_cast<uint64_t>(at) * 100 / size) : 100;
|
|
if (what == Mode::Gpx) {
|
|
gpx.feed(reinterpret_cast<const char*>(piece.get()), n);
|
|
} else {
|
|
parser->feed(piece.get(), n);
|
|
if (parser->state() == UpdateParser::State::Failed) break;
|
|
}
|
|
}
|
|
if (at < size && !(parser && parser->state() == UpdateParser::State::Failed) && millis() - t0 >= kSliceMs) return false;
|
|
if (parser) {
|
|
genuine = parser->end();
|
|
version = parser->version();
|
|
older = parser->isDowngrade();
|
|
error = parser->error();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
size_t FileViewer::readAt(uint32_t offset, uint8_t* into, size_t len) {
|
|
size_t got = 0;
|
|
auto file = file_;
|
|
std::string path = path_;
|
|
storage_.runAndWait([&, file, path]() {
|
|
if (!*file) *file = SD.open(path.c_str(), FILE_READ);
|
|
if (!*file || !file->seek(offset)) return;
|
|
int n = file->read(into, len);
|
|
got = n < 0 ? 0 : n;
|
|
});
|
|
return got;
|
|
}
|
|
|
|
void FileViewer::open(const std::string& path, uint32_t size) {
|
|
close();
|
|
path_ = path;
|
|
size_ = size;
|
|
file_ = std::make_shared<fs::File>();
|
|
std::string name = files::baseName(path);
|
|
files::FileKind kind = files::kindOf(name);
|
|
if (kind == files::FileKind::Unknown) { // what do its first bytes look like?
|
|
uint8_t head[256];
|
|
size_t n = readAt(0, head, sizeof head);
|
|
kind = files::looksLikeText(head, n) ? files::FileKind::Text : files::FileKind::Unknown;
|
|
}
|
|
switch (kind) {
|
|
case files::FileKind::Text: base_ = Mode::Text; break;
|
|
case files::FileKind::Gpx: base_ = Mode::Gpx; break;
|
|
case files::FileKind::Pcap: base_ = Mode::Pcap; break;
|
|
case files::FileKind::Ota: base_ = Mode::Ota; break;
|
|
default: base_ = Mode::Hex; break;
|
|
}
|
|
pager_.reset(new files::TextPager([this](uint32_t offset, uint8_t* into, size_t len) { return readAt(offset, into, len); }, size_,
|
|
kCols, kRows));
|
|
if (files::opensAtEnd(name)) pager_->toEnd();
|
|
hexTop_ = 0;
|
|
show(base_);
|
|
if (base_ == Mode::Gpx || base_ == Mode::Pcap || base_ == Mode::Ota) startScan(base_);
|
|
}
|
|
|
|
void FileViewer::startScan(Mode mode) {
|
|
scan_ = std::make_shared<Scan>();
|
|
scan_->what = mode;
|
|
scan_->path = path_;
|
|
scanShown_ = false;
|
|
shownPercent_ = -1;
|
|
auto scan = scan_;
|
|
StorageService* storage = &storage_;
|
|
storage_.runJob([storage, scan]() { Scan::run(storage, scan); });
|
|
}
|
|
|
|
void FileViewer::close() {
|
|
if (scan_) scan_->stop = true; // its job ends at the next piece and frees itself
|
|
scan_.reset();
|
|
if (file_) {
|
|
auto file = file_;
|
|
storage_.runJob([file]() {
|
|
if (*file) file->close();
|
|
});
|
|
}
|
|
file_.reset();
|
|
pager_.reset();
|
|
confirm_.reset();
|
|
message_.clear();
|
|
std::vector<std::string>().swap(shown_);
|
|
std::vector<std::string>().swap(details_);
|
|
packets_.setCount(0);
|
|
rowsFrom_ = -1;
|
|
}
|
|
|
|
void FileViewer::show(Mode mode) {
|
|
mode_ = mode;
|
|
stale_ = true;
|
|
rowsFrom_ = -1;
|
|
}
|
|
|
|
void FileViewer::say(const std::string& text) {
|
|
message_ = text;
|
|
messageMs_ = millis();
|
|
}
|
|
|
|
std::string FileViewer::title() const { return files::fitName(files::baseName(path_), 24); }
|
|
|
|
void FileViewer::scroll(int lines) {
|
|
if (mode_ == Mode::Text && pager_) {
|
|
if (lines > 0) pager_->down(lines);
|
|
else pager_->up(-lines);
|
|
} else if (mode_ == Mode::Hex) {
|
|
uint32_t rows = (size_ + 7) / 8, last = rows > static_cast<uint32_t>(kRows) ? rows - kRows : 0;
|
|
int64_t to = static_cast<int64_t>(hexTop_) + lines;
|
|
hexTop_ = static_cast<uint32_t>(std::clamp<int64_t>(to, 0, last));
|
|
}
|
|
stale_ = true;
|
|
}
|
|
|
|
void FileViewer::openPacket(int index) {
|
|
if (!scan_ || !scan_->done || index < 0 || index >= static_cast<int>(scan_->packets.size())) return;
|
|
uint8_t buf[files::kPcapRecordSize + kMaxPacketBytes];
|
|
size_t n = readAt(scan_->packets[index], buf, sizeof buf);
|
|
files::PcapRecord r;
|
|
if (!files::parsePcapRecord(buf, n, r)) return;
|
|
const uint8_t* body = buf + files::kPcapRecordSize;
|
|
size_t have = std::min<size_t>(r.length, n - files::kPcapRecordSize);
|
|
details_.clear();
|
|
char line[64];
|
|
lora::RxInfo rx;
|
|
bool tap = scan_->pcap.linkType == files::kLinkLoraTap && files::parseLoraTap(body, have, rx);
|
|
size_t skip = tap ? lora::kLoraTapSize : 0;
|
|
std::snprintf(line, sizeof line, "%s, %u bytes", clockTime(r.seconds).c_str(), static_cast<unsigned>(r.length - skip));
|
|
details_.push_back(line);
|
|
if (tap) {
|
|
std::snprintf(line, sizeof line, "%.0f dBm, SNR %.1f dB, noise %.0f", rx.rssi, rx.snr, rx.noiseFloor);
|
|
details_.push_back(line);
|
|
std::snprintf(line, sizeof line, "%.4f MHz, SF%u, %.0f kHz", rx.frequencyHz / 1e6, rx.spreadingFactor, rx.bandwidthKHz);
|
|
details_.push_back(line);
|
|
if (rx.syncWord == meshtastic::kSyncWord)
|
|
for (auto& l : lora::headerLines(body + skip, have - skip)) details_.push_back(l);
|
|
}
|
|
for (auto& l : lora::hexDump(body + skip, have - skip)) details_.push_back(l);
|
|
if (have < r.length) details_.push_back("(the first bytes only)");
|
|
detailsTop_ = 0;
|
|
mode_ = Mode::Packet;
|
|
}
|
|
|
|
bool FileViewer::onKey(const KeyEvent& e) {
|
|
if (confirm_) {
|
|
confirm_->onKey(e);
|
|
if (confirm_->result() == 1) update_.installFromSd(path_);
|
|
if (confirm_->result() != DialogModel::kPending) confirm_.reset();
|
|
return true;
|
|
}
|
|
if (mode_ == Mode::Packet) {
|
|
int last = std::max(0, static_cast<int>(details_.size()) - (kRows + 1));
|
|
if (e.key == Key::Up) detailsTop_ = std::max(0, detailsTop_ - 1);
|
|
else if (e.key == Key::Down) detailsTop_ = std::min(last, detailsTop_ + 1);
|
|
else if (e.key == Key::Back || e.key == Key::Select) mode_ = Mode::Pcap;
|
|
return true;
|
|
}
|
|
if (e.key == Key::Back) return false;
|
|
if (e.key == Key::Tab) {
|
|
// The file's own view, or what it's made of: text for a Track, bytes for the others.
|
|
if (mode_ != base_) show(base_);
|
|
else show(base_ == Mode::Hex || base_ == Mode::Gpx ? Mode::Text : Mode::Hex);
|
|
return true;
|
|
}
|
|
uint32_t ch = e.key == Key::Char ? e.ch : 0;
|
|
switch (mode_) {
|
|
case Mode::Text:
|
|
case Mode::Hex:
|
|
if (e.key == Key::Up) scroll(-1);
|
|
else if (e.key == Key::Down) scroll(1);
|
|
else if (e.key == Key::Left) scroll(-(kRows - 1));
|
|
else if (e.key == Key::Right) scroll(kRows - 1);
|
|
else if (ch == 't' || ch == 'T') {
|
|
if (pager_) pager_->toStart();
|
|
hexTop_ = 0;
|
|
stale_ = true;
|
|
} else if (ch == 'b' || ch == 'B') {
|
|
if (mode_ == Mode::Text && pager_) pager_->toEnd();
|
|
else scroll(INT32_MAX / 2);
|
|
stale_ = true;
|
|
}
|
|
break;
|
|
case Mode::Pcap:
|
|
if (e.key == Key::Up) packets_.up();
|
|
else if (e.key == Key::Down) packets_.down();
|
|
else if (e.key == Key::Left) packets_.pageUp();
|
|
else if (e.key == Key::Right) packets_.pageDown();
|
|
else if (e.key == Key::Select) openPacket(packets_.selected());
|
|
break;
|
|
case Mode::Ota:
|
|
if (e.key == Key::Select && scan_ && scan_->done && scan_->genuine) confirm_.reset(new DialogModel({"Cancel", "Install"}));
|
|
break;
|
|
default: break;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool FileViewer::update(uint32_t) {
|
|
if (!message_.empty() && millis() - messageMs_ >= 4000) {
|
|
message_.clear();
|
|
return true;
|
|
}
|
|
if (!scan_) return false;
|
|
if (scan_->done && !scanShown_) {
|
|
scanShown_ = true;
|
|
if (scan_->what == Mode::Pcap) packets_.setCount(static_cast<int>(scan_->packets.size()));
|
|
return true;
|
|
}
|
|
int percent = scan_->percent;
|
|
if (!scan_->done && percent != shownPercent_) {
|
|
shownPercent_ = percent;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void FileViewer::refresh() {
|
|
if (mode_ == Mode::Text && stale_ && pager_) {
|
|
shown_ = pager_->lines();
|
|
stale_ = false;
|
|
} else if (mode_ == Mode::Hex && stale_) {
|
|
uint8_t buf[kRows * 8];
|
|
size_t n = readAt(hexTop_ * 8, buf, sizeof buf);
|
|
shown_.clear();
|
|
for (size_t at = 0; at < n; at += 8) shown_.push_back(files::hexRow(hexTop_ * 8 + at, buf + at, std::min<size_t>(8, n - at)));
|
|
stale_ = false;
|
|
} else if (mode_ == Mode::Pcap && scan_ && scan_->done && rowsFrom_ != packets_.firstVisible()) {
|
|
// The rows on screen, read from the card once each time the list moves.
|
|
rowsFrom_ = packets_.firstVisible();
|
|
shown_.clear();
|
|
bool tap = scan_->pcap.linkType == files::kLinkLoraTap;
|
|
for (int i = rowsFrom_; i < rowsFrom_ + kRows && i < static_cast<int>(scan_->packets.size()); i++) {
|
|
uint8_t buf[files::kPcapRecordSize + kMaxPacketBytes];
|
|
size_t n = readAt(scan_->packets[i], buf, sizeof buf);
|
|
files::PcapRecord r;
|
|
lora::RxInfo rx;
|
|
if (!files::parsePcapRecord(buf, n, r)) {
|
|
shown_.push_back("(unreadable)");
|
|
continue;
|
|
}
|
|
const uint8_t* body = buf + files::kPcapRecordSize;
|
|
size_t have = std::min<size_t>(r.length, n - files::kPcapRecordSize);
|
|
if (tap && files::parseLoraTap(body, have, rx)) {
|
|
shown_.push_back(lora::row({body + lora::kLoraTapSize, have - lora::kLoraTapSize, rx.rssi, rx.snr, true,
|
|
rx.syncWord == meshtastic::kSyncWord},
|
|
clockTime(r.seconds)));
|
|
} else {
|
|
shown_.push_back(clockTime(r.seconds) + " " + std::to_string(r.length) + " bytes");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void FileViewer::draw(Canvas& c) {
|
|
const auto& area = theme::kContent;
|
|
refresh();
|
|
c.setTextDatum(top_left);
|
|
theme::Rect body{area.x, area.y + 10, area.w, kRows * theme::kLineHeight};
|
|
std::string right, keys = "Tab: hex";
|
|
bool scanning = scan_ && !scan_->done;
|
|
|
|
switch (mode_) {
|
|
case Mode::Text:
|
|
case Mode::Hex: {
|
|
c.setFont(&fonts::body);
|
|
c.setTextColor(size_ == 0 ? theme::kMuted : theme::kText);
|
|
if (size_ == 0) c.drawString("(empty)", 4, body.y + 1);
|
|
for (size_t i = 0; i < shown_.size(); i++) c.drawString(shown_[i].c_str(), 4, body.y + 1 + static_cast<int>(i) * theme::kLineHeight);
|
|
uint32_t at = mode_ == Mode::Text && pager_ ? pager_->top() : hexTop_ * 8;
|
|
if (size_ > 0) { // where in the file the screen is
|
|
int barH = 12, barY = body.y + static_cast<int>(static_cast<uint64_t>(body.h - barH) * at / size_);
|
|
c.fillRect(area.w - 2, barY, 2, barH, theme::kMuted);
|
|
}
|
|
right = formatBytes(size_);
|
|
keys = std::string("Tab: ") + (mode_ != base_ ? "back" : mode_ == Mode::Text ? "hex" : "text") + " t: top b: end";
|
|
if (mode_ == Mode::Text) keys += " e: edit";
|
|
break;
|
|
}
|
|
case Mode::Gpx: {
|
|
std::vector<std::string> lines;
|
|
if (scanning) lines.push_back("Reading the Track... " + std::to_string(scan_->percent.load()) + "%");
|
|
else if (scan_ && !scan_->error.empty()) lines.push_back(scan_->error);
|
|
else if (scan_) lines = scan_->gpx.lines();
|
|
widgets::textLines(c, lines, 0, body);
|
|
right = formatBytes(size_);
|
|
keys = "Tab: the file as text";
|
|
break;
|
|
}
|
|
case Mode::Pcap: {
|
|
c.setFont(&fonts::body);
|
|
c.setTextColor(theme::kMuted);
|
|
if (scanning) c.drawString("Reading the Capture...", 4, body.y + 1);
|
|
else if (scan_ && !scan_->error.empty()) c.drawString(scan_->error.c_str(), 4, body.y + 1);
|
|
else if (scan_ && scan_->packets.empty()) c.drawString("No packets in this Capture.", 4, body.y + 1);
|
|
else if (scan_) {
|
|
int from = rowsFrom_;
|
|
widgets::list(c, packets_, body, [&](int i) {
|
|
size_t row = static_cast<size_t>(i - from);
|
|
return row < shown_.size() ? shown_[row] : std::string();
|
|
});
|
|
right = std::to_string(scan_->packets.size()) + (scan_->morePackets ? "+ packets" : " packets");
|
|
keys = "Enter: the packet Tab: hex";
|
|
}
|
|
break;
|
|
}
|
|
case Mode::Packet: widgets::textLines(c, details_, detailsTop_, {area.x + 2, area.y + 2, area.w - 2, area.h - 2}); return;
|
|
case Mode::Ota: {
|
|
std::vector<std::string> lines;
|
|
bool ok = scan_ && scan_->done && scan_->genuine;
|
|
if (scanning) {
|
|
lines.push_back("Checking it as an install would:");
|
|
lines.push_back("signature and contents, " + std::to_string(scan_->percent.load()) + "%");
|
|
} else if (scan_) {
|
|
if (!scan_->version.empty()) lines.push_back("Version " + scan_->version);
|
|
lines.push_back(std::string("Running ") + versionString());
|
|
if (ok) {
|
|
lines.push_back("Signed with the project's key, intact.");
|
|
if (scan_->older) lines.push_back("Older than what's running.");
|
|
} else {
|
|
lines.push_back("Not installable:");
|
|
lines.push_back(scan_->error.empty() ? "it ends too early" : scan_->error);
|
|
}
|
|
}
|
|
widgets::textLines(c, lines, 0, body);
|
|
right = formatBytes(size_);
|
|
keys = ok ? "Enter: install Tab: hex" : "Tab: hex";
|
|
if (confirm_) {
|
|
widgets::dialog(c, "Install update?", "The device restarts into " + scan_->version + " once it's written.", *confirm_);
|
|
return;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
c.setFont(&fonts::small);
|
|
c.setTextColor(theme::kMuted);
|
|
c.setTextDatum(top_left);
|
|
c.drawString(title().c_str(), 4, area.y + 1);
|
|
c.setTextDatum(top_right);
|
|
c.drawString(right.c_str(), area.w - 3, area.y + 1);
|
|
c.setTextDatum(top_left);
|
|
if (!message_.empty()) c.setTextColor(theme::kWarning);
|
|
c.drawString(message_.empty() ? keys.c_str() : message_.c_str(), 4, area.y + area.h - 9);
|
|
}
|
|
|
|
} // namespace roro
|