Files
roro9stack/src/apps/file_viewer.cpp
T
twislaandClaude Opus 5.5 e8a654a15f Notes: plain text notes on the SD card, with an editor that saves by itself (#19)
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
2026-10-06 10:08:10 +02:00

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